beck_macro/
ui.rs

1//! The `ui:` macro — a typed DOM tree from an indented block.
2//!
3//! [`docs/02-syntax.md`](../../../../../docs/02-syntax.md) §2.9 settles this: "**a `ui:` macro
4//! producing a typed DOM tree** vs. JSX-like literal syntax. The macro keeps the surface small and
5//! is implementable by users for other targets (terminal UI, native). Its output is the Hiccup
6//! lineage the original sketch used — `[:main [:h1 "todos"] ...]` maps 1:1 onto the `ui:` block's
7//! `Node` tree, so the sketch's pages *are* these pages."
8//!
9//! So this:
10//!
11//! ```text
12//! ui:
13//!     main:
14//!         h1: "todos"
15//!         ul:
16//!             for t in todos:
17//!                 li(key=t.id, class=done_class(t)):
18//!                     span(on_click=Toggle(id=t.id)): t.text
19//! ```
20//!
21//! is `(main (h1 "todos") (ul ...))` in the sketch's notation, and lowers to calls on five runtime
22//! primitives — `html_el`, `html_text`, `html_attr`, `html_on`, `html_key` — plus `map_list` and
23//! `concat_lists` for the loops. Nothing here is DOM mutation: §4.2 requires UI trees to stay
24//! symbolic so the same value can be server-side rendered, diffed, or (Phase 3) compiled for the
25//! client.
26//!
27//! Handlers become *declarative attributes*: `on_click=Toggle(id=t.id)` carries a serialised
28//! command constructor, so §5.1's "no user JavaScript runs in Mode A at all" holds by construction.
29
30use std::collections::BTreeSet;
31
32use beck_diag::{Diagnostic, Diagnostics, Span};
33use beck_syntax::{sym, Node};
34
35use crate::str_lit;
36use crate::vocabulary;
37
38/// Expand `(ui (kw do (quote (do …))))` into an `Html`-valued expression.
39pub fn expand_ui(call: &Node, diags: &mut Diagnostics) -> Node {
40    let span = call.span();
41    let Some(block) = block_of(call) else {
42        diags.push(
43            Diagnostic::error("B0210", "`ui` needs an indented block", span)
44                .with_primary_label("write `ui:` followed by an element"),
45        );
46        return unit(span);
47    };
48
49    let roots: Vec<&Node> = block.args.iter().collect();
50    match roots.len() {
51        1 => node_expr(roots[0], diags),
52        0 => {
53            diags.push(
54                Diagnostic::error("B0211", "`ui` block is empty", span)
55                    .with_primary_label("a view must produce exactly one root element"),
56            );
57            unit(span)
58        }
59        _ => {
60            diags.push(
61                Diagnostic::error(
62                    "B0212",
63                    "`ui` block has more than one root",
64                    roots[1].span(),
65                )
66                .with_primary_label("a second root element")
67                .with_note("an Html value is a single tree; wrap these in one element")
68                .with_fix("put them inside a `div:` or `main:` block"),
69            );
70            node_expr(roots[0], diags)
71        }
72    }
73}
74
75/// The `do=quote(...)` argument the block rule attached, if there is one.
76fn block_of(call: &Node) -> Option<&Node> {
77    let last = call.args.last()?;
78    if !last.is_form(sym::KW_ARG) || last.args.len() != 2 {
79        return None;
80    }
81    if last.args[0].as_var().map(|s| s.as_str()) != Some("do") {
82        return None;
83    }
84    let quoted = &last.args[1];
85    if !quoted.is_form(sym::QUOTE) || quoted.args.len() != 1 {
86        return None;
87    }
88    Some(&quoted.args[0])
89}
90
91fn unit(span: Span) -> Node {
92    Node::sym("unit", span)
93}
94
95/// The attribute that turns one of [`accessibility`]'s three checks off, with a reason.
96///
97/// [`docs/12`](../../../../../docs/12-standards-and-conformance.md) §12.4 asked for
98/// `@a11y(exempt, reason=…)`. It is an *attribute* instead because a `ui:` block's statements are
99/// element calls rather than declarations, so an annotation there would be new syntax in the
100/// parser for one escape hatch — and the tree already carries keyword arguments. It is stripped
101/// rather than emitted: the page must not carry it to a browser.
102const EXEMPT: &str = "a11y_exempt";
103
104/// One statement of a `ui` block as a single `Html` expression.
105fn node_expr(n: &Node, diags: &mut Diagnostics) -> Node {
106    let span = n.span();
107    if let Some(tag) = element_tag(n) {
108        let mut attrs = Vec::new();
109        // The names as HTML spells them, for the checks below. Collected here rather than derived
110        // from `attrs` afterwards, because by then each one is an `html_attr` form and the question
111        // "did somebody write `alt`" would be asked of generated code.
112        let mut written: BTreeSet<String> = BTreeSet::new();
113        let mut exempt = false;
114        for a in &n.args {
115            if a.is_form(sym::KW_ARG) && a.args.len() == 2 {
116                let name = a.args[0].as_var().map(|s| s.as_str().to_string());
117                match name.as_deref() {
118                    Some("do") => continue,
119                    Some(EXEMPT) => {
120                        exempt = true;
121                        continue;
122                    }
123                    Some(name) => {
124                        written.insert(name.replace('_', "-"));
125                    }
126                    None => {}
127                }
128                attrs.push(attr_expr(a, &tag, diags));
129            }
130        }
131        let children = match block_of(n) {
132            Some(block) => children_expr(&block.args, diags, span),
133            None => Node::form(sym::LIST, vec![], span),
134        };
135        if !exempt {
136            let has_children = block_of(n).is_some_and(|b| !b.args.is_empty());
137            accessibility(&tag, &written, has_children, span, diags);
138        }
139        return Node::form(
140            "html_el",
141            vec![
142                str_lit(&tag, span),
143                Node::form(sym::LIST, attrs, span),
144                children,
145            ],
146            span,
147        );
148    }
149    // Anything that is not an element is a text node.
150    Node::form("html_text", vec![n.clone()], span)
151}
152
153/// [`docs/12`](../../../../../docs/12-standards-and-conformance.md) §12.4's first three checks, over
154/// the tree `ui:` already builds.
155///
156/// The design claim §12.4 keeps is that a typed tree makes accessibility *checkable at compile
157/// time* in a way a template language cannot match — and it stayed a claim, because "checkable is
158/// not checked". These are the three it names: an `img` with no alt text, a `button` with no
159/// accessible name, and a form control with no label. Each is an error rather than a warning,
160/// because a warning on a page nobody can use is a page nobody can use.
161///
162/// Which element needs what is [`vocabulary::NAMING`] rather than three tag names written here,
163/// which is why the vocabulary was scheduled in front of these: a tree that accepted `on_keydown`
164/// and `cls=` in silence could not honestly carry an accessibility claim, and a check that matched
165/// a misspelled tag would never fire and no test over correct programs could notice.
166///
167/// # What each one can see, and the one it cannot
168///
169/// A compile-time check knows the *shape* of the tree and not the values in it, so each is written
170/// to fire on an absence rather than on a value: `alt=""` is HTML's own spelling for a decorative
171/// image and is accepted, and a `button` with any child at all is assumed to name itself, because
172/// whether an expression renders to empty text is not a question this stage can answer.
173///
174/// The label check has a real hole and it is stated rather than hidden. A control is named by
175/// `aria-label`, `aria-labelledby` or `title` — or by a `<label for=…>` elsewhere, which this
176/// cannot see, because a `ui:` block composes out of functions and the label may be in another one.
177/// So an `id` is accepted as evidence that such a label exists. What that leaves is the case worth
178/// having: a control whose only human-readable text is a **`placeholder`**, which is the commonest
179/// real failure of WCAG 3.3.2 and is exactly what four programs in this tree were doing.
180///
181/// What none of them can see is a *user's helper that shares an element's name*. Inside a `ui:`
182/// block a lowercase call with keyword arguments is indistinguishable from an element
183/// ([`crate::vocabulary`] says why), so `def input(…)` called by name is checked as an `input`.
184/// That is the same limit `B0218` already has, and it moves when `ui:` becomes a user-written typed
185/// macro rather than a compiler-provided one (D22).
186fn accessibility(
187    tag: &str,
188    written: &BTreeSet<String>,
189    has_children: bool,
190    span: Span,
191    diags: &mut Diagnostics,
192) {
193    let labelled = || vocabulary::LABELLING.iter().any(|n| written.contains(*n));
194    match vocabulary::naming(tag) {
195        None => {}
196        Some(vocabulary::Naming::Alt) if !written.contains("alt") => {
197            diags.push(
198                Diagnostic::error("B0219", "this image has no alt text", span)
199                    .with_primary_label("a screen reader announces the file name, or nothing")
200                    .with_note(
201                        "`alt=\"\"` is the right answer for an image that carries no meaning — it \
202                         is HTML's own way of saying so, and it is accepted here",
203                    )
204                    .with_fix(format!(
205                        "add `alt=\"…\"`, or `{EXEMPT}=\"…\"` with a reason"
206                    )),
207            );
208        }
209        Some(vocabulary::Naming::TextOrLabel) if !has_children && !labelled() => {
210            diags.push(
211                Diagnostic::error("B0220", "this button has no accessible name", span)
212                    .with_primary_label("nothing announces what it does")
213                    .with_note(
214                        "a button is named by its own text, or by `aria_label=` when it has none \
215                         — an icon button is the usual case",
216                    )
217                    .with_fix(format!(
218                        "give it text, `aria_label=\"…\"`, or `{EXEMPT}=\"…\"` with a reason"
219                    )),
220            );
221        }
222        Some(vocabulary::Naming::Label) if !labelled() && !written.contains("id") => {
223            diags.push(
224                Diagnostic::error("B0221", format!("this `{tag}` has no label"), span)
225                    .with_primary_label(if written.contains("placeholder") {
226                        "a placeholder is not a label — it disappears as soon as somebody types"
227                    } else {
228                        "nothing announces what this control is for"
229                    })
230                    .with_note(
231                        "`id=` is accepted as evidence of a `label(for=…)` elsewhere, because a \
232                         `ui:` block composes out of functions and this check sees one at a time",
233                    )
234                    .with_fix(format!(
235                        "add `aria_label=\"…\"`, an `id=` with a `label(for=…)`, or \
236                         `{EXEMPT}=\"…\"` with a reason"
237                    )),
238            );
239        }
240        Some(_) => {}
241    }
242}
243
244/// A statement is an element when its head is a symbol that is not one of the control forms.
245fn element_tag(n: &Node) -> Option<String> {
246    if !n.applied {
247        return None;
248    }
249    let head = n.head_name()?;
250    if matches!(
251        head,
252        sym::FOR
253            | sym::IF
254            | sym::LET
255            | sym::VAR
256            | sym::DO
257            | sym::MATCH
258            | sym::RETURN
259            | sym::LIST
260            | sym::RECORD
261            | sym::MAP
262            | sym::CALL
263            | sym::DOT
264            | sym::QUOTE
265    ) {
266        return None;
267    }
268    // An element's arguments are all keyword arguments — `li(key=…)`. A call with positional
269    // arguments is an ordinary function call producing text or Html, e.g. `row(t)`.
270    if !n.args.iter().all(|a| a.is_form(sym::KW_ARG)) {
271        return None;
272    }
273    if !head
274        .chars()
275        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
276    {
277        return None;
278    }
279    Some(head.to_string())
280}
281
282/// One `name=value` on an element, and the two places a name is held to a vocabulary.
283///
284/// The check is here rather than beside the table because what it needs is a *span*: a name that
285/// does not exist is a diagnostic pointing at where somebody wrote it, and everything the reader
286/// needs — what it might have meant, and what the alternative is — is in the message rather than
287/// in a rule they have to go and read. [`crate::vocabulary`] is where the names live and why.
288fn attr_expr(kw: &Node, tag: &str, diags: &mut Diagnostics) -> Node {
289    let span = kw.span();
290    let name = kw.args[0]
291        .as_var()
292        .map(|s| s.as_str().to_string())
293        .unwrap_or_default();
294    let value = kw.args[1].clone();
295
296    if name == "key" {
297        return Node::form("html_key", vec![value], span);
298    }
299    // `on_click=Toggle(id=…)` — a handler is a declarative attribute carrying a command.
300    if let Some(event) = name.strip_prefix("on_") {
301        if !vocabulary::is_event(event) {
302            let known = vocabulary::EVENTS
303                .iter()
304                .map(|(e, what)| format!("`on_{e}` ({what})"))
305                .collect::<Vec<_>>()
306                .join(", ");
307            let mut d = Diagnostic::error(
308                "B0217",
309                format!("`{name}` is not an event the client listens for"),
310                span,
311            )
312            .with_primary_label("this would be an attribute wired to nothing")
313            .with_note(format!("the client interprets {known}"));
314            if let Some(near) = vocabulary::event_suggestion(event) {
315                d = d.with_fix(format!("did you mean `on_{near}`?"));
316            }
317            diags.push(d);
318            return Node::form("html_on", vec![str_lit(event, span), value], span);
319        }
320        return Node::form("html_on", vec![str_lit(event, span), value], span);
321    }
322    // `data_b_k` reads better in Python than `data-b-k`, and hyphens are what HTML wants.
323    let attr_name = name.replace('_', "-");
324    if !vocabulary::is_attribute(&attr_name) {
325        let mut d = Diagnostic::error(
326            "B0218",
327            format!("`{attr_name}` is not an HTML attribute"),
328            span,
329        )
330        .with_primary_label(format!(
331            "`{tag}` would carry this to the browser, which ignores it"
332        ))
333        .with_note(
334            "an attribute of your own is spelled `data_…`, which is HTML's own extension point \
335             and reaches the page as `data-…`",
336        );
337        if let Some(near) = vocabulary::suggestion(&attr_name) {
338            d = d.with_fix(format!("did you mean `{}`?", near.replace('-', "_")));
339        }
340        diags.push(d);
341    }
342    // A list where HTML wants a space-separated value, joined here rather than at the seam.
343    //
344    // `class=["btn", "primary" if hot else "plain"]` is the shape [`docs/104`] §104.4 asks programs
345    // to write, and the reason is not taste: the alternative is `"btn " + …`, and a concatenation
346    // is as invisible to a compiler that wants to enumerate the classes a page can carry as it is
347    // to Tailwind's scanner. A list of alternatives can be enumerated; a string built at run time
348    // cannot, and [`beck_core::style`] is what says which of the two a program wrote.
349    //
350    // It is done in the **lowering** rather than in `html_attr`, so every backend agrees by
351    // construction: what reaches the checker is one `str_join` and there is nothing for an emitter
352    // to know about ([`docs/19`] §19.9's seam, honoured by not touching it).
353    //
354    // A list whose every element is a **literal** is joined here and now: a string decided at
355    // compile time should not be assembled at run time, on every element of every page.
356    //
357    // It used to be more than that, and the reason it is not is worth keeping. A list with a
358    // *name* in it stays a `str_join`, and [`beck_core::incremental`] used to block on that name
359    // and report the whole view as a recompute — so this fold was the difference between a page
360    // the report called maintained and one it did not. It was never the difference between two
361    // plans: the join sits inside the per-element function of a maintained `map_list`, applied to
362    // what moved and nothing else. That analysis now asks what the join is applied *to*, which is
363    // the question, and the shape §104.4's item 4 recommends costs nothing either way.
364    let value = match value.head_name() {
365        Some(head) if head == sym::LIST && SPACE_SEPARATED.contains(&attr_name.as_str()) => {
366            match value
367                .args
368                .iter()
369                .map(Node::as_str_lit)
370                .collect::<Option<Vec<&str>>>()
371            {
372                Some(tokens) => str_lit(tokens.join(" "), span),
373                None => Node::form("str_join", vec![value, str_lit(" ", span)], span),
374            }
375        }
376        _ => value,
377    };
378    Node::form("html_attr", vec![str_lit(attr_name, span), value], span)
379}
380
381/// The attributes whose value HTML defines as a space-separated list of tokens.
382///
383/// Not "every attribute", because most take one value and joining a list into one of those would
384/// turn a mistake into a plausible string. These are the ones where a list is what the attribute
385/// *means*: `class` and `rel` by the HTML specification, and the two ARIA relationships whose value
386/// is a list of ids.
387const SPACE_SEPARATED: &[&str] = &["class", "rel", "aria-labelledby", "aria-describedby"];
388
389/// The children of an element: a `list[Html]` built from the block's statements.
390///
391/// Each statement contributes a *list* so that `for` loops splice rather than nest, and the parts
392/// are concatenated. A block with one plain child is emitted as a literal list, with no
393/// concatenation call at all.
394fn children_expr(stmts: &[Node], diags: &mut Diagnostics, span: Span) -> Node {
395    let mut parts: Vec<Node> = Vec::new();
396    let mut literal: Vec<Node> = Vec::new();
397
398    for s in stmts {
399        if s.is_form(sym::FOR) && s.args.len() == 3 {
400            if !literal.is_empty() {
401                parts.push(Node::form(sym::LIST, std::mem::take(&mut literal), span));
402            }
403            let var = s.args[0].clone();
404            let seq = s.args[1].clone();
405            let body = children_expr(&s.args[2].args, diags, s.span());
406            let lambda = Node::form(
407                sym::FN,
408                vec![
409                    Node::form(sym::PARAMS, vec![var], s.span()),
410                    Node::form(sym::DO, vec![body], s.span()),
411                ],
412                s.span(),
413            );
414            parts.push(Node::form(
415                "concat_lists",
416                vec![Node::form("map_list", vec![seq, lambda], s.span())],
417                s.span(),
418            ));
419            continue;
420        }
421
422        if s.is_form(sym::IF) && s.args.len() >= 2 && s.args[1].is_form(sym::DO) {
423            if !literal.is_empty() {
424                parts.push(Node::form(sym::LIST, std::mem::take(&mut literal), span));
425            }
426            let cond = s.args[0].clone();
427            let then = children_expr(&s.args[1].args, diags, s.span());
428            let alt = match s.args.get(2) {
429                Some(a) => children_expr(&a.args, diags, s.span()),
430                None => Node::form(sym::LIST, vec![], s.span()),
431            };
432            parts.push(Node::form(sym::IF, vec![cond, then, alt], s.span()));
433            continue;
434        }
435
436        literal.push(node_expr(s, diags));
437    }
438
439    if !literal.is_empty() {
440        parts.push(Node::form(sym::LIST, literal, span));
441    }
442    match parts.len() {
443        0 => Node::form(sym::LIST, vec![], span),
444        1 => parts.pop().expect("checked non-empty"),
445        _ => Node::form(
446            "concat_lists",
447            vec![Node::form(sym::LIST, parts, span)],
448            span,
449        ),
450    }
451}
452
453/// Scope-annotation stripper, shared by this crate's tests.
454#[cfg(test)]
455pub(crate) fn tests_strip(s: &str) -> String {
456    tests::strip_scopes(s)
457}
458
459#[cfg(test)]
460mod tests {
461    use beck_diag::{Diagnostics, SourceMap};
462    use beck_syntax::{parser, print};
463
464    /// Print without hygiene annotations.
465    ///
466    /// The names `ui` introduces (`html_el`, `list`, `fn`, …) legitimately carry a scope — that is
467    /// what stops a user function called `html_el` from capturing them — but the scope number is
468    /// an expansion-order detail, so these tests assert on structure instead. Hygiene itself is
469    /// asserted directly in the parent module.
470    pub(super) fn strip_scopes(s: &str) -> String {
471        let mut out = String::with_capacity(s.len());
472        let mut chars = s.chars().peekable();
473        while let Some(c) = chars.next() {
474            if c == '{' {
475                let mut buf = String::new();
476                while let Some(&n) = chars.peek() {
477                    if n == '}' {
478                        chars.next();
479                        break;
480                    }
481                    buf.push(n);
482                    chars.next();
483                }
484                if !buf.chars().all(|c| c.is_ascii_digit() || c == ',') {
485                    out.push('{');
486                    out.push_str(&buf);
487                    out.push('}');
488                }
489                continue;
490            }
491            out.push(c);
492        }
493        out
494    }
495
496    fn ui(src: &str) -> (String, Diagnostics, SourceMap) {
497        let mut map = SourceMap::new();
498        let f = map.add("t.beck", src);
499        let mut d = Diagnostics::new();
500        let module = parser::parse_module(f, "t", src, &mut d);
501        assert!(!d.has_errors(), "parse: {}", d.render(&map));
502        let out = crate::expand_module(&module, &mut d);
503        (strip_scopes(&print::to_sexpr(&out)), d, map)
504    }
505
506    #[test]
507    fn an_element_with_text_becomes_html_primitives() {
508        let (out, d, map) = ui("def v() -> Html:\n    return ui:\n        h1: \"todos\"\n");
509        assert!(!d.has_errors(), "{}", d.render(&map));
510        assert!(
511            out.contains(r#"(html_el "h1" (list) (list (html_text "todos")))"#),
512            "{out}"
513        );
514    }
515
516    #[test]
517    fn attributes_keys_and_handlers_are_distinguished() {
518        let (out, d, map) = ui("def v() -> Html:\n\
519             \x20   return ui:\n\
520             \x20       li(key=k, class=c, on_click=Toggle(id=i)):\n\
521             \x20           \"x\"\n");
522        assert!(!d.has_errors(), "{}", d.render(&map));
523        assert!(out.contains("(html_key k)"), "{out}");
524        assert!(out.contains(r#"(html_attr "class" c)"#), "{out}");
525        assert!(
526            out.contains(r#"(html_on "click" (Toggle (kw id i)))"#),
527            "{out}"
528        );
529    }
530
531    #[test]
532    fn a_for_loop_splices_children_rather_than_nesting_them() {
533        let (out, d, map) = ui("def v() -> Html:\n\
534             \x20   return ui:\n\
535             \x20       ul:\n\
536             \x20           for t in todos:\n\
537             \x20               li: t.text\n");
538        assert!(!d.has_errors(), "{}", d.render(&map));
539        assert!(
540            out.contains("(concat_lists (map_list todos (fn (params t)"),
541            "{out}"
542        );
543    }
544
545    #[test]
546    fn literal_children_and_loops_concatenate_in_order() {
547        let (out, d, map) = ui("def v() -> Html:\n\
548             \x20   return ui:\n\
549             \x20       main:\n\
550             \x20           h1: \"todos\"\n\
551             \x20           for t in todos:\n\
552             \x20               li: t.text\n\
553             \x20           footer: \"end\"\n");
554        assert!(!d.has_errors(), "{}", d.render(&map));
555        let body = out.split(r#"(html_el "main""#).nth(1).unwrap();
556        let h1 = body.find("\"h1\"").unwrap();
557        let loop_at = body.find("map_list").unwrap();
558        let footer = body.find("\"footer\"").unwrap();
559        assert!(
560            h1 < loop_at && loop_at < footer,
561            "order not preserved: {out}"
562        );
563    }
564
565    /// The checks accept what is correct, which is the half a suite of refusals never states.
566    ///
567    /// `docs/12` §12.4's three checks are errors, so every way of satisfying one has to be listed
568    /// somewhere that fails when it stops working — otherwise the first program to name a control
569    /// properly and be refused anyway is a user's.
570    #[test]
571    fn every_way_of_naming_a_control_is_accepted() {
572        for element in [
573            // An image that carries no meaning: HTML's own spelling, and the reason the check is
574            // for the attribute rather than for a value.
575            "img(src=\"/x.png\", alt=\"\")",
576            "img(src=\"/x.png\", alt=\"a chart\")",
577            "input(aria_label=\"name\")",
578            "input(aria_labelledby=\"h\")",
579            "input(title=\"name\")",
580            // An `id` a `label(for=…)` somewhere else may point at — the check's stated hole.
581            "input(id=\"name\")",
582            "select(aria_label=\"tier\")",
583            "textarea(aria_label=\"notes\")",
584            // Exempted by hand, with a reason, which is the escape hatch every one of them has.
585            "img(src=\"/x.png\", a11y_exempt=\"decorative, and behind aria-hidden\")",
586            "input(placeholder=\"search\", a11y_exempt=\"labelled by the heading above it\")",
587        ] {
588            let (out, d, map) = ui(&format!(
589                "def v() -> Html:\n    return ui:\n        {element}\n"
590            ));
591            assert!(!d.has_errors(), "`{element}`: {}", d.render(&map));
592            assert!(
593                !out.contains("a11y-exempt") && !out.contains("a11y_exempt"),
594                "the exemption reached the page: {out}"
595            );
596        }
597    }
598
599    /// A button names itself with its own text, which is the ordinary case and must not be refused.
600    #[test]
601    fn a_button_with_text_names_itself() {
602        let (out, d, map) = ui("def v() -> Html:\n\
603             \x20   return ui:\n\
604             \x20       button(on_click=Drop):\n\
605             \x20           \"x\"\n");
606        assert!(!d.has_errors(), "{}", d.render(&map));
607        assert!(out.contains(r#"(html_el "button""#), "{out}");
608    }
609
610    #[test]
611    fn two_roots_are_an_error_with_a_suggestion() {
612        let (_, d, _) = ui("def v() -> Html:\n\
613             \x20   return ui:\n\
614             \x20       h1: \"a\"\n\
615             \x20       h2: \"b\"\n");
616        assert!(d.iter().any(|x| x.code == "B0212" && x.fix.is_some()));
617    }
618
619    #[test]
620    fn an_ordinary_call_inside_a_block_stays_a_call() {
621        // `row(t)` has a positional argument, so it is a function producing Html, not a tag.
622        let (out, d, map) = ui("def v() -> Html:\n\
623             \x20   return ui:\n\
624             \x20       ul:\n\
625             \x20           row(t)\n");
626        assert!(!d.has_errors(), "{}", d.render(&map));
627        assert!(out.contains("(html_text (row t))"), "{out}");
628    }
629}