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("ed.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}