1use proc_macro::TokenStream;
4use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream as TokenStream2, TokenTree};
5use quote::{format_ident, quote};
6use syn::{Arm, Expr, ExprForLoop, ExprIf, ExprMatch, LitInt, LitStr, parse2};
7
8#[proc_macro]
11pub fn dom(input: TokenStream) -> TokenStream {
12 match expand(input.into()) {
13 Ok(tokens) => tokens.into(),
14 Err(error) => error.into_compile_error().into(),
15 }
16}
17
18fn expand(input: TokenStream2) -> syn::Result<TokenStream2> {
19 let mut parser = Parser::new(input);
20 let root = parser.element()?;
21 if let Some(token) = parser.peek() {
22 return Err(syn::Error::new(token.span(), "expected a single root element"));
23 }
24 lower_element(&root)
25}
26
27struct Element {
28 name: Name,
29 attrs: Vec<Attr>,
30 children: Vec<Child>,
31}
32
33struct Name {
34 text: String,
35 span: Span,
36 icon: Option<String>,
37}
38
39struct Attr {
40 name: String,
41 span: Span,
42 value: AttrValue,
43}
44
45enum AttrValue {
46 Flag,
47 String(LitStr),
48 Expr(TokenStream2),
49 Bare(LitStr),
50}
51
52enum Child {
53 Element(Element),
54 Expr(TokenStream2),
55 String(LitStr),
56 Control(Control),
57}
58
59enum Control {
60 For(ForControl),
61 If(IfControl),
62 Match(MatchControl),
63}
64
65struct ForControl {
66 head: TokenStream2,
67 body: Vec<Child>,
68}
69
70struct IfControl {
71 branches: Vec<IfBranch>,
72 else_body: Option<Vec<Child>>,
73}
74
75struct IfBranch {
76 head: TokenStream2,
77 body: Vec<Child>,
78}
79
80struct MatchControl {
81 head: TokenStream2,
82 arms: Vec<MatchArm>,
83}
84
85struct MatchArm {
86 prefix: TokenStream2,
87 body: Vec<Child>,
88}
89
90struct Parser {
91 tokens: Vec<TokenTree>,
92 at: usize,
93}
94
95impl Parser {
96 fn new(input: TokenStream2) -> Self {
97 Self { tokens: input.into_iter().collect(), at: 0 }
98 }
99
100 fn peek(&self) -> Option<&TokenTree> {
101 self.tokens.get(self.at)
102 }
103
104 fn next(&mut self) -> Option<TokenTree> {
105 let token = self.tokens.get(self.at).cloned()?;
106 self.at += 1;
107 Some(token)
108 }
109
110 fn punct(&self, ch: char) -> bool {
111 matches!(self.peek(), Some(TokenTree::Punct(punct)) if punct.as_char() == ch)
112 }
113
114 fn keyword(&self, keyword: &str) -> bool {
115 matches!(self.peek(), Some(TokenTree::Ident(ident)) if ident == keyword)
116 }
117
118 fn take_punct(&mut self, ch: char) -> Option<Span> {
119 if !self.punct(ch) {
120 return None;
121 }
122 let span = self.peek().expect("punctuation was just checked").span();
123 self.at += 1;
124 Some(span)
125 }
126
127 fn expect_punct(&mut self, ch: char, message: &str) -> syn::Result<Span> {
128 self.take_punct(ch).ok_or_else(|| {
129 let span = self.peek().map_or_else(Span::call_site, TokenTree::span);
130 syn::Error::new(span, message)
131 })
132 }
133
134 fn word(&mut self, message: &str) -> syn::Result<(String, Span)> {
135 match self.next() {
136 Some(TokenTree::Ident(ident)) => Ok((ident.to_string(), ident.span())),
137 Some(token) => Err(syn::Error::new(token.span(), message)),
138 None => Err(syn::Error::new(Span::call_site(), message)),
139 }
140 }
141
142 fn finish_dashed(&mut self, mut value: String, message: &str) -> syn::Result<String> {
143 while self.take_punct('-').is_some() {
144 let (part, _) = self.word(message)?;
145 value.push('-');
146 value.push_str(&part);
147 }
148 Ok(value)
149 }
150
151 fn dashed_name(&mut self, message: &str) -> syn::Result<(String, Span)> {
152 let (name, span) = self.word(message)?;
153 let name = self.finish_dashed(name, "expected a word after `-`")?;
154 Ok((name, span))
155 }
156
157 fn tag_name(&mut self) -> syn::Result<Name> {
158 let (text, span) = self.dashed_name("expected a tag name")?;
159 if self.take_punct(':').is_some() {
160 if text != "i" {
161 return Err(syn::Error::new(span, "only `i:name` icon shorthand may contain `:`"));
162 }
163 let (icon, _) = self.dashed_name("expected an icon name after `i:`")?;
164 return Ok(Name { text: "i".into(), span, icon: Some(icon) });
165 }
166 Ok(Name { text, span, icon: None })
167 }
168
169 fn element(&mut self) -> syn::Result<Element> {
170 self.expect_punct('<', "expected `<` to start an element")?;
171 if self.punct('/') {
172 let span = self.peek().expect("slash was just checked").span();
173 return Err(syn::Error::new(span, "unexpected closing tag"));
174 }
175
176 let name = self.tag_name()?;
177 let mut attrs = Vec::new();
178 let self_closing = loop {
179 if self.take_punct('>').is_some() {
180 break false;
181 }
182 if self.take_punct('/').is_some() {
183 self.expect_punct('>', "expected `>` after `/`")?;
184 break true;
185 }
186 if self.peek().is_none() {
187 return Err(syn::Error::new(name.span, "unterminated opening tag"));
188 }
189 attrs.push(self.attr()?);
190 };
191
192 if self_closing {
193 return Ok(Element { name, attrs, children: Vec::new() });
194 }
195
196 let mut children = Vec::new();
197 loop {
198 let Some(_) = self.peek() else {
199 return Err(syn::Error::new(name.span, format!("unclosed tag <{}>", name.text)));
200 };
201 if self.punct('<')
202 && matches!(
203 self.tokens.get(self.at + 1),
204 Some(TokenTree::Punct(punct)) if punct.as_char() == '/'
205 ) {
206 self.at += 2;
207 let close = self.tag_name()?;
208 self.expect_punct('>', "expected `>` after closing tag")?;
209 if close.text != name.text || close.icon.as_deref() != name.icon.as_deref() {
210 let expected = name
211 .icon
212 .as_ref()
213 .map_or_else(|| name.text.clone(), |icon| format!("i:{icon}"));
214 let found = close
215 .icon
216 .as_ref()
217 .map_or_else(|| close.text.clone(), |icon| format!("i:{icon}"));
218 return Err(syn::Error::new(
219 close.span,
220 format!("mismatched closing tag: expected </{expected}>, found </{found}>"),
221 ));
222 }
223 break;
224 }
225 children.push(self.child()?);
226 }
227
228 Ok(Element { name, attrs, children })
229 }
230
231 fn fragment(mut self) -> syn::Result<Vec<Child>> {
232 let mut children = Vec::new();
233 while self.peek().is_some() {
234 children.push(self.child()?);
235 }
236 Ok(children)
237 }
238
239 fn child(&mut self) -> syn::Result<Child> {
240 let Some(token) = self.peek() else {
241 return Err(syn::Error::new(Span::call_site(), "expected a child"));
242 };
243 if self.punct('<') {
244 return self.element().map(Child::Element);
245 }
246 if self.keyword("for") {
247 return self.for_control().map(Child::Control);
248 }
249 if self.keyword("if") {
250 return self.if_control().map(Child::Control);
251 }
252 if self.keyword("match") {
253 return self.match_control().map(Child::Control);
254 }
255
256 match token {
257 TokenTree::Group(group) if group.delimiter() == Delimiter::Brace => {
258 let Some(TokenTree::Group(group)) = self.next() else {
259 unreachable!("peeked group changed");
260 };
261 Ok(Child::Expr(parse_expr_group(group)?))
262 },
263 TokenTree::Literal(_) => {
264 let token = self.next().expect("peeked literal changed");
265 Ok(Child::String(string_literal(
266 token,
267 "text content must be a string literal or {expr}",
268 )?))
269 },
270 _ => Err(syn::Error::new(
271 token.span(),
272 "text content must be a string literal or {expr}, or control flow",
273 )),
274 }
275 }
276
277 fn control_head(
278 &mut self,
279 start: usize,
280 valid: impl Fn(TokenStream2) -> bool,
281 message: &str,
282 ) -> syn::Result<(TokenStream2, Group)> {
283 for end in start + 1..self.tokens.len() {
284 let TokenTree::Group(group) = &self.tokens[end] else {
285 continue;
286 };
287 if group.delimiter() != Delimiter::Brace {
288 continue;
289 }
290 let head = self.tokens[start..end]
291 .iter()
292 .cloned()
293 .collect::<TokenStream2>();
294 if !valid(quote!(#head {})) {
295 continue;
296 }
297 self.at = end + 1;
298 return Ok((head, group.clone()));
299 }
300 Err(syn::Error::new(self.tokens[start].span(), message))
301 }
302
303 fn for_control(&mut self) -> syn::Result<Control> {
304 let start = self.at;
305 let (head, body) = self.control_head(
306 start,
307 |tokens| parse2::<ExprForLoop>(tokens).is_ok(),
308 "expected `for pattern in expression { children }`",
309 )?;
310 Ok(Control::For(ForControl { head, body: parse_child_group(body)? }))
311 }
312
313 fn if_control(&mut self) -> syn::Result<Control> {
314 let mut branches = Vec::new();
315 let mut else_body = None;
316 loop {
317 let start = self.at;
318 let (head, body) = self.control_head(
319 start,
320 |tokens| parse2::<ExprIf>(tokens).is_ok(),
321 "expected `if condition { children }`",
322 )?;
323 branches.push(IfBranch { head, body: parse_child_group(body)? });
324 if !self.keyword("else") {
325 break;
326 }
327 let else_span = self.next().expect("peeked else changed").span();
328 if self.keyword("if") {
329 continue;
330 }
331 let Some(TokenTree::Group(body)) = self.next() else {
332 return Err(syn::Error::new(else_span, "expected `if` or `{ children }` after `else`"));
333 };
334 if body.delimiter() != Delimiter::Brace {
335 return Err(syn::Error::new(body.span(), "expected `{ children }` after `else`"));
336 }
337 else_body = Some(parse_child_group(body)?);
338 break;
339 }
340 Ok(Control::If(IfControl { branches, else_body }))
341 }
342
343 fn match_control(&mut self) -> syn::Result<Control> {
344 let start = self.at;
345 let (head, body) = self.control_head(
346 start,
347 |tokens| parse2::<ExprMatch>(tokens).is_ok(),
348 "expected `match expression { pattern => children }`",
349 )?;
350 let arms = Self::new(body.stream()).match_arms()?;
351 Ok(Control::Match(MatchControl { head, arms }))
352 }
353
354 fn match_arms(mut self) -> syn::Result<Vec<MatchArm>> {
355 let mut arms = Vec::new();
356 while self.peek().is_some() {
357 if self.take_punct(',').is_some() {
358 if self.peek().is_none() {
359 break;
360 }
361 return Err(syn::Error::new(
362 self.peek().expect("checked next match arm").span(),
363 "expected a match pattern after `,`",
364 ));
365 }
366 let start = self.at;
367 let arrow = (start..self.tokens.len().saturating_sub(1))
368 .find(|&at| {
369 matches!(&self.tokens[at], TokenTree::Punct(punct) if punct.as_char() == '=')
370 && matches!(&self.tokens[at + 1], TokenTree::Punct(punct) if punct.as_char() == '>')
371 })
372 .ok_or_else(|| {
373 syn::Error::new(self.tokens[start].span(), "expected `=>` after match pattern")
374 })?;
375 let prefix = self.tokens[start..arrow]
376 .iter()
377 .cloned()
378 .collect::<TokenStream2>();
379 parse2::<Arm>(quote!(#prefix => (),)).map_err(|error| {
380 syn::Error::new(self.tokens[start].span(), format!("invalid match arm: {error}"))
381 })?;
382 self.at = arrow + 2;
383 let body = self.match_arm_body()?;
384 arms.push(MatchArm { prefix, body });
385 self.take_punct(',');
386 }
387 Ok(arms)
388 }
389
390 fn match_arm_body(&mut self) -> syn::Result<Vec<Child>> {
391 let Some(token) = self.peek() else {
392 return Err(syn::Error::new(Span::call_site(), "expected children after `=>`"));
393 };
394 if let TokenTree::Group(group) = token
395 && group.delimiter() == Delimiter::Brace
396 {
397 let Some(TokenTree::Group(group)) = self.next() else {
398 unreachable!("peeked group changed");
399 };
400 return parse_child_group(group);
401 }
402 Ok(vec![self.child()?])
403 }
404
405 fn attr(&mut self) -> syn::Result<Attr> {
406 let (name, span) = self.dashed_name("expected an attribute name")?;
407 let value = if self.take_punct('=').is_none() {
408 AttrValue::Flag
409 } else {
410 self.attr_value()?
411 };
412 Ok(Attr { name, span, value })
413 }
414
415 fn attr_value(&mut self) -> syn::Result<AttrValue> {
416 let Some(token) = self.next() else {
417 return Err(syn::Error::new(Span::call_site(), "expected an attribute value"));
418 };
419 match token {
420 TokenTree::Group(group) if group.delimiter() == Delimiter::Brace => {
421 Ok(AttrValue::Expr(parse_expr_group(group)?))
422 },
423 TokenTree::Group(group) => {
424 Err(syn::Error::new(group.span(), "quote this value or use `{expr}`"))
425 },
426 TokenTree::Ident(ident) => {
427 let span = ident.span();
428 let value = self
429 .finish_dashed(ident.to_string(), "expected a word after `-` in attribute value")?;
430 Ok(AttrValue::Bare(LitStr::new(&value, span)))
431 },
432 TokenTree::Literal(literal) => {
433 let literal_token = TokenTree::Literal(literal.clone());
434 if let Ok(value) = parse2::<LitStr>(literal_token.clone().into()) {
435 return Ok(AttrValue::String(value));
436 }
437 let integer = parse2::<LitInt>(literal_token.into())
438 .map_err(|_| syn::Error::new(literal.span(), "quote this value"))?;
439 if !integer.suffix().is_empty() {
440 return Err(syn::Error::new(literal.span(), "quote this value"));
441 }
442 let mut value = literal.to_string();
443 if self.take_punct('%').is_some() {
444 value.push('%');
445 }
446 Ok(AttrValue::Bare(LitStr::new(&value, literal.span())))
447 },
448 other => Err(syn::Error::new(other.span(), "quote this value")),
449 }
450 }
451}
452
453fn parse_expr_group(group: Group) -> syn::Result<TokenStream2> {
454 let tokens = group.stream();
455 if tokens.is_empty() {
456 return Err(syn::Error::new(group.span(), "expected an expression inside braces"));
457 }
458 parse2::<Expr>(tokens.clone())?;
459 Ok(tokens)
460}
461
462fn parse_child_group(group: Group) -> syn::Result<Vec<Child>> {
463 let tokens = group.stream();
464 match Parser::new(tokens).fragment() {
465 Ok(children) => Ok(children),
466 Err(markup_error) => {
467 let expression = TokenStream2::from(TokenTree::Group(group));
468 if parse2::<Expr>(expression.clone()).is_ok() {
469 Ok(vec![Child::Expr(expression)])
470 } else {
471 Err(markup_error)
472 }
473 },
474 }
475}
476
477fn string_literal(token: TokenTree, message: &str) -> syn::Result<LitStr> {
478 let span = token.span();
479 parse2::<LitStr>(token.into()).map_err(|_| syn::Error::new(span, message))
480}
481
482#[derive(Clone, Copy)]
483struct EditorPaths(u8);
484
485impl EditorPaths {
486 const EMPTY: Self = Self(1);
487 const NONE: Self = Self(0);
488
489 fn add(self, element: &Element) -> syn::Result<Self> {
490 let kind = if element.name.text == "status" { 2 } else { 1 };
491 let mut next = 0;
492 for state in 0_u8..4 {
493 let path = 1_u8 << state;
494 if self.0 & path == 0 {
495 continue;
496 }
497 if state & kind != 0 {
498 return Err(syn::Error::new(
499 element.name.span,
500 "editor takes at most one input child and one <status>",
501 ));
502 }
503 next |= 1_u8 << (state | kind);
504 }
505 Ok(Self(next))
506 }
507
508 const fn union(self, other: Self) -> Self {
509 Self(self.0 | other.0)
510 }
511}
512
513fn validate_editor_children(children: &[Child]) -> syn::Result<()> {
514 validate_editor_sequence(EditorPaths::EMPTY, children).map(|_| ())
515}
516
517fn validate_editor_sequence(
518 mut paths: EditorPaths,
519 children: &[Child],
520) -> syn::Result<EditorPaths> {
521 for child in children {
522 paths = match child {
523 Child::Element(element) => paths.add(element)?,
524 Child::Control(control) => validate_editor_control(paths, control)?,
525 Child::Expr(_) | Child::String(_) => paths,
526 };
527 }
528 Ok(paths)
529}
530
531fn validate_editor_control(paths: EditorPaths, control: &Control) -> syn::Result<EditorPaths> {
532 match control {
533 Control::For(control) => {
534 if let Some(element) = first_editor_element(&control.body) {
535 return Err(syn::Error::new(
536 element.name.span,
537 "editor cannot produce input or <status> children from a for loop",
538 ));
539 }
540 Ok(paths)
541 },
542 Control::If(control) => {
543 let mut next = if control.else_body.is_some() {
544 EditorPaths::NONE
545 } else {
546 paths
547 };
548 for branch in &control.branches {
549 next = next.union(validate_editor_sequence(paths, &branch.body)?);
550 }
551 if let Some(children) = &control.else_body {
552 next = next.union(validate_editor_sequence(paths, children)?);
553 }
554 Ok(next)
555 },
556 Control::Match(control) => {
557 if control.arms.is_empty() {
558 return Ok(paths);
559 }
560 let mut next = EditorPaths::NONE;
561 for arm in &control.arms {
562 next = next.union(validate_editor_sequence(paths, &arm.body)?);
563 }
564 Ok(next)
565 },
566 }
567}
568
569fn first_editor_element(children: &[Child]) -> Option<&Element> {
570 children.iter().find_map(|child| match child {
571 Child::Element(element) => Some(element),
572 Child::Expr(_) | Child::String(_) => None,
573 Child::Control(Control::For(control)) => first_editor_element(&control.body),
574 Child::Control(Control::If(control)) => control
575 .branches
576 .iter()
577 .find_map(|branch| first_editor_element(&branch.body))
578 .or_else(|| control.else_body.as_deref().and_then(first_editor_element)),
579 Child::Control(Control::Match(control)) => control
580 .arms
581 .iter()
582 .find_map(|arm| first_editor_element(&arm.body)),
583 })
584}
585
586fn lower_element(element: &Element) -> syn::Result<TokenStream2> {
587 if is_data_tag(&element.name.text) {
588 return Err(syn::Error::new(
589 element.name.span,
590 format!("<{}> is only valid inside its owning component", element.name.text),
591 ));
592 }
593
594 let mut output = lower_constructor(element);
595 for attr in &element.attrs {
596 if element.name.text != "icon" || attr.name != "name" {
597 output = lower_attr(output, attr)?;
598 }
599 }
600
601 if is_text_tag(&element.name.text) {
602 for child in &element.children {
603 output = lower_child(output, ChildTarget::Text(&element.name.text), child)?;
604 }
605 return Ok(output);
606 }
607 if element.name.text == "editor" {
608 validate_editor_children(&element.children)?;
609 for child in &element.children {
610 output = lower_child(output, ChildTarget::Editor, child)?;
611 }
612 return Ok(output);
613 }
614
615 for child in &element.children {
616 output = lower_child(output, ChildTarget::Owner(&element.name.text), child)?;
617 }
618 Ok(output)
619}
620
621fn lower_constructor(element: &Element) -> TokenStream2 {
622 if let Some(icon) = &element.name.icon {
623 let icon = LitStr::new(icon, element.name.span);
624 return quote!(::omp_tui::components::Icon::named(#icon));
625 }
626
627 let component = match element.name.text.as_str() {
628 "box" => Some("Boxed"),
629 "text" => Some("TextLeaf"),
630 "pre" => Some("Pre"),
631 "md" => Some("Markdown"),
632 "latex" => Some("Latex"),
633 "callout" => Some("Callout"),
634 "col" => Some("Col"),
635 "row" => Some("Row"),
636 "hr" => Some("Hr"),
637 "spacer" => Some("Spacer"),
638 "select" => Some("Select"),
639 "table" => Some("Table"),
640 "radio" => Some("Radio"),
641 "status" => Some("Status"),
642 "input" => Some("Input"),
643 "button" => Some("Button"),
644 "scroll" => Some("Scroll"),
645 "tabs" => Some("Tabs"),
646 "tree" => Some("Tree"),
647 "todo" => Some("Todo"),
648 "form" => Some("Form"),
649 "progress" => Some("Progress"),
650 "img" => Some("Img"),
651 "editor" => Some("EditorPane"),
652 "wizard" => Some("Wizard"),
653 "icon" => {
654 let name = attr_named(&element.attrs, "name").map_or_else(|| quote!(""), attr_tokens);
655 return quote!(::omp_tui::components::Icon::named(#name));
656 },
657 _ => None,
658 };
659 if let Some(component) = component {
660 let component = format_ident!("{component}", span = element.name.span);
661 quote!(::omp_tui::components::#component::new())
662 } else {
663 let name = LitStr::new(&element.name.text, element.name.span);
664 quote!(::omp_tui::components::CustomElement::new(#name))
665 }
666}
667
668fn lower_attrs(mut output: TokenStream2, attrs: &[Attr]) -> syn::Result<TokenStream2> {
669 for attr in attrs {
670 output = lower_attr(output, attr)?;
671 }
672 Ok(output)
673}
674
675fn lower_attr(output: TokenStream2, attr: &Attr) -> syn::Result<TokenStream2> {
676 if matches!(attr.name.as_str(), "gradient" | "dir") {
677 return Err(syn::Error::new(
678 attr.span,
679 "gradient and dir were replaced by fg=/bg= and angle=",
680 ));
681 }
682 let name = LitStr::new(&attr.name, attr.span);
683 let value = attr_tokens(attr);
684 if let Some(prop) = prop_variant(&attr.name) {
685 let prop = format_ident!("{prop}", span = attr.span);
686 Ok(quote!(#output.with(::omp_tui::Prop::#prop, #value)))
687 } else {
688 Ok(quote!(#output.with_custom(#name, #value)))
689 }
690}
691
692fn attr_tokens(attr: &Attr) -> TokenStream2 {
693 match &attr.value {
694 AttrValue::Flag => quote!(true),
695 AttrValue::String(value) | AttrValue::Bare(value) => quote!(#value),
696 AttrValue::Expr(value) => quote!(#value),
697 }
698}
699
700#[derive(Clone, Copy)]
701enum ChildTarget<'a> {
702 Owner(&'a str),
703 Text(&'a str),
704 Editor,
705 DataRecord,
706 StatusSegment,
707 TreeNode,
708 TodoTask,
709 Pane,
710 TableRow,
711}
712
713fn lower_child(
714 output: TokenStream2,
715 target: ChildTarget<'_>,
716 child: &Child,
717) -> syn::Result<TokenStream2> {
718 match child {
719 Child::Control(control) => lower_control(output, target, control),
720 Child::Expr(expr) => match target {
721 ChildTarget::Owner(_) | ChildTarget::Pane => Ok(quote!(#output.child(#expr))),
722 ChildTarget::Text(_) => Ok(quote!(#output.text(#expr))),
723 ChildTarget::Editor => {
724 let span = expr
725 .clone()
726 .into_iter()
727 .next()
728 .map_or_else(Span::call_site, |token| token.span());
729 Err(syn::Error::new(span, "editor takes element children only"))
730 },
731 ChildTarget::DataRecord
732 | ChildTarget::StatusSegment
733 | ChildTarget::TreeNode
734 | ChildTarget::TodoTask => Ok(quote!(#output.label(#expr))),
735 ChildTarget::TableRow => {
736 let span = expr
737 .clone()
738 .into_iter()
739 .next()
740 .map_or_else(Span::call_site, |token| token.span());
741 Err(syn::Error::new(span, "<tr> takes <td> children only"))
742 },
743 },
744 Child::String(text) => match target {
745 ChildTarget::Owner(_) | ChildTarget::Pane => Ok(quote!(#output.child(#text))),
746 ChildTarget::Text(_) => Ok(quote!(#output.text(#text))),
747 ChildTarget::Editor => {
748 Err(syn::Error::new(text.span(), "editor takes element children only"))
749 },
750 ChildTarget::DataRecord
751 | ChildTarget::StatusSegment
752 | ChildTarget::TreeNode
753 | ChildTarget::TodoTask => Ok(quote!(#output.label(#text))),
754 ChildTarget::TableRow => {
755 Err(syn::Error::new(text.span(), "<tr> takes <td> children only"))
756 },
757 },
758 Child::Element(element) => match target {
759 ChildTarget::Owner(owner) if is_data_tag(&element.name.text) => {
760 lower_data_child(output, owner, element)
761 },
762 ChildTarget::DataRecord if element.name.text == "td" => {
763 let cell = lower_table_cell(element)?;
764 Ok(quote!(#output.cell(#cell)))
765 },
766 ChildTarget::TableRow if element.name.text == "td" => {
767 let cell = lower_table_cell(element)?;
768 Ok(quote!(#output.cell(#cell)))
769 },
770 ChildTarget::TableRow => {
771 Err(syn::Error::new(element.name.span, "<tr> takes <td> children only"))
772 },
773 ChildTarget::Owner(_) | ChildTarget::DataRecord | ChildTarget::Pane => {
774 let element = lower_element(element)?;
775 Ok(quote!(#output.child(#element)))
776 },
777 ChildTarget::Text(owner) => Err(syn::Error::new(
778 element.name.span,
779 format!("elements are not allowed inside <{owner}>; use a string literal or {{expr}}"),
780 )),
781 ChildTarget::Editor if element.name.text == "status" => {
782 let element = lower_element(element)?;
783 Ok(quote!(#output.status(#element)))
784 },
785 ChildTarget::Editor => {
786 let element = lower_element(element)?;
787 Ok(quote!(#output.input(#element)))
788 },
789 ChildTarget::StatusSegment => Err(syn::Error::new(
790 element.name.span,
791 "elements are not allowed inside <segment>; use a string literal or braced expression",
792 )),
793 ChildTarget::TreeNode if element.name.text == "node" => {
794 let nested = lower_tree_node(element)?;
795 Ok(quote!(#output.node(#nested)))
796 },
797 ChildTarget::TodoTask if element.name.text == "task" => {
798 let nested = lower_todo_task(element)?;
799 Ok(quote!(#output.task(#nested)))
800 },
801 ChildTarget::TreeNode | ChildTarget::TodoTask => {
802 let element = lower_element(element)?;
803 Ok(quote!(#output.child(#element)))
804 },
805 },
806 }
807}
808
809fn lower_control(
810 output: TokenStream2,
811 target: ChildTarget<'_>,
812 control: &Control,
813) -> syn::Result<TokenStream2> {
814 let builder = format_ident!("__omp_tui_layout", span = Span::mixed_site());
815 let statements = lower_control_statements(&builder, target, control)?;
816 if control_adds_children(control) {
817 Ok(quote!({
818 let mut #builder = #output;
819 #statements
820 #builder
821 }))
822 } else {
823 Ok(quote!({
824 let #builder = #output;
825 #statements
826 #builder
827 }))
828 }
829}
830
831fn lower_control_statements(
832 builder: &Ident,
833 target: ChildTarget<'_>,
834 control: &Control,
835) -> syn::Result<TokenStream2> {
836 match control {
837 Control::For(control) => {
838 let head = &control.head;
839 let body = lower_child_statements(builder, target, &control.body)?;
840 Ok(quote!(#head { #body }))
841 },
842 Control::If(control) => {
843 let mut output = TokenStream2::new();
844 for (index, branch) in control.branches.iter().enumerate() {
845 let head = &branch.head;
846 let body = lower_child_statements(builder, target, &branch.body)?;
847 if index == 0 {
848 output.extend(quote!(#head { #body }));
849 } else {
850 output.extend(quote!(else #head { #body }));
851 }
852 }
853 if let Some(children) = &control.else_body {
854 let body = lower_child_statements(builder, target, children)?;
855 output.extend(quote!(else { #body }));
856 }
857 Ok(output)
858 },
859 Control::Match(control) => {
860 let head = &control.head;
861 let mut arms = TokenStream2::new();
862 for arm in &control.arms {
863 let prefix = &arm.prefix;
864 let body = lower_child_statements(builder, target, &arm.body)?;
865 arms.extend(quote!(#prefix => { #body },));
866 }
867 Ok(quote!(#head { #arms }))
868 },
869 }
870}
871
872fn lower_child_statements(
873 builder: &Ident,
874 target: ChildTarget<'_>,
875 children: &[Child],
876) -> syn::Result<TokenStream2> {
877 let mut statements = TokenStream2::new();
878 for child in children {
879 let statement = match child {
880 Child::Control(control) => lower_control_statements(builder, target, control)?,
881 Child::Element(_) | Child::Expr(_) | Child::String(_) => {
882 let next = lower_child(quote!(#builder), target, child)?;
883 quote!(#builder = #next;)
884 },
885 };
886 statements.extend(statement);
887 }
888 Ok(statements)
889}
890
891fn control_adds_children(control: &Control) -> bool {
892 match control {
893 Control::For(control) => children_add(&control.body),
894 Control::If(control) => {
895 control
896 .branches
897 .iter()
898 .any(|branch| children_add(&branch.body))
899 || control.else_body.as_deref().is_some_and(children_add)
900 },
901 Control::Match(control) => control.arms.iter().any(|arm| children_add(&arm.body)),
902 }
903}
904
905fn children_add(children: &[Child]) -> bool {
906 children.iter().any(|child| match child {
907 Child::Control(control) => control_adds_children(control),
908 Child::Element(_) | Child::Expr(_) | Child::String(_) => true,
909 })
910}
911
912fn lower_data_child(
913 output: TokenStream2,
914 owner: &str,
915 data: &Element,
916) -> syn::Result<TokenStream2> {
917 let valid_owner = matches!(
918 (owner, data.name.text.as_str()),
919 ("select", "option")
920 | ("status", "segment")
921 | ("tabs", "tab")
922 | ("tree", "node")
923 | ("todo", "task")
924 | ("form", "field")
925 | ("wizard", "step")
926 | ("table", "tr")
927 );
928 if !valid_owner {
929 return Err(syn::Error::new(
930 data.name.span,
931 format!("<{}> is not valid inside <{owner}>", data.name.text),
932 ));
933 }
934
935 match data.name.text.as_str() {
936 "option" => {
937 let item = lower_data_record("SelectOption", data)?;
938 Ok(quote!(#output.option(#item)))
939 },
940 "segment" => {
941 let item = lower_status_segment(data)?;
942 Ok(quote!(#output.segment(#item)))
943 },
944 "field" => {
945 let item = lower_data_record("Field", data)?;
946 Ok(quote!(#output.field(#item)))
947 },
948 "node" => {
949 let item = lower_tree_node(data)?;
950 Ok(quote!(#output.node(#item)))
951 },
952 "task" => {
953 let item = lower_todo_task(data)?;
954 Ok(quote!(#output.task(#item)))
955 },
956 "tab" => lower_named_pane(output, "pane", data),
957 "step" => lower_named_pane(output, "step", data),
958 "tr" => {
959 let item = lower_table_row(data)?;
960 Ok(quote!(#output.row(#item)))
961 },
962 _ => unreachable!("all data-only tags were matched"),
963 }
964}
965
966fn lower_data_record(kind: &str, data: &Element) -> syn::Result<TokenStream2> {
967 let kind = format_ident!("{kind}", span = data.name.span);
968 let mut output = quote!(::omp_tui::components::#kind::new());
969 output = lower_attrs(output, &data.attrs)?;
970 for child in &data.children {
971 output = lower_child(output, ChildTarget::DataRecord, child)?;
972 }
973 Ok(output)
974}
975fn lower_status_segment(data: &Element) -> syn::Result<TokenStream2> {
976 let mut output = quote!(::omp_tui::components::Segment::new());
977 output = lower_attrs(output, &data.attrs)?;
978 for child in &data.children {
979 output = lower_child(output, ChildTarget::StatusSegment, child)?;
980 }
981 Ok(output)
982}
983
984fn lower_tree_node(data: &Element) -> syn::Result<TokenStream2> {
985 let mut output = quote!(::omp_tui::components::TreeNode::new());
986 output = lower_attrs(output, &data.attrs)?;
987 for child in &data.children {
988 output = lower_child(output, ChildTarget::TreeNode, child)?;
989 }
990 Ok(output)
991}
992
993fn lower_todo_task(data: &Element) -> syn::Result<TokenStream2> {
994 let mut output = quote!(::omp_tui::components::TodoTask::new());
995 output = lower_attrs(output, &data.attrs)?;
996 for child in &data.children {
997 output = lower_child(output, ChildTarget::TodoTask, child)?;
998 }
999 Ok(output)
1000}
1001
1002fn lower_table_row(data: &Element) -> syn::Result<TokenStream2> {
1003 let mut output = quote!(::omp_tui::components::TableRow::new());
1004 output = lower_attrs(output, &data.attrs)?;
1005 for child in &data.children {
1006 output = lower_child(output, ChildTarget::TableRow, child)?;
1007 }
1008 Ok(output)
1009}
1010
1011fn lower_table_cell(data: &Element) -> syn::Result<TokenStream2> {
1012 let mut output = quote!(::omp_tui::components::TableCell::new());
1013 output = lower_attrs(output, &data.attrs)?;
1014 for child in &data.children {
1015 output = lower_child(output, ChildTarget::Pane, child)?;
1016 }
1017 Ok(output)
1018}
1019
1020fn lower_named_pane(
1021 output: TokenStream2,
1022 method: &str,
1023 data: &Element,
1024) -> syn::Result<TokenStream2> {
1025 let method = format_ident!("{method}", span = data.name.span);
1026 let title = attr_named(&data.attrs, "title")
1027 .or_else(|| attr_named(&data.attrs, "label"))
1028 .map_or_else(|| quote!(""), attr_tokens);
1029 let mut body = quote!(::omp_tui::components::Col::new());
1030 for attr in &data.attrs {
1031 if attr.name != "title" && attr.name != "label" {
1032 body = lower_attr(body, attr)?;
1033 }
1034 }
1035 for child in &data.children {
1036 body = lower_child(body, ChildTarget::Pane, child)?;
1037 }
1038 Ok(quote!(#output.#method(#title, #body)))
1039}
1040
1041fn attr_named<'a>(attrs: &'a [Attr], name: &str) -> Option<&'a Attr> {
1042 attrs.iter().find(|attr| attr.name == name)
1043}
1044
1045fn is_text_tag(name: &str) -> bool {
1046 matches!(name, "text" | "pre" | "md" | "latex" | "callout")
1047}
1048
1049fn is_data_tag(name: &str) -> bool {
1050 matches!(name, "option" | "segment" | "tab" | "node" | "task" | "field" | "step" | "tr" | "td")
1051}
1052
1053fn prop_variant(name: &str) -> Option<&'static str> {
1054 Some(match name {
1055 "gap" => "Gap",
1056 "pad" => "Pad",
1057 "pad-x" => "PadX",
1058 "pad-y" => "PadY",
1059 "grow" => "Grow",
1060 "w" => "W",
1061 "min" => "Min",
1062 "max" => "Max",
1063 "h" => "H",
1064 "border" => "Border",
1065 "bc" => "Bc",
1066 "edge" => "Edge",
1067 "bleed" => "Bleed",
1068 "title" => "Title",
1069 "title-align" => "TitleAlign",
1070 "footer" => "Footer",
1071 "footer-align" => "FooterAlign",
1072 "align" => "Align",
1073 "valign" => "VAlign",
1074 "justify" => "Justify",
1075 "fg" => "Fg",
1076 "bg" => "Bg",
1077 "on" => "On",
1078 "bold" => "Bold",
1079 "dim" => "Dim",
1080 "italic" => "Italic",
1081 "underline" => "Underline",
1082 "reverse" => "Reverse",
1083 "strike" => "Strike",
1084 "wrap" => "Wrap",
1085 "truncate" => "Truncate",
1086 "trim" => "Trim",
1087 "id" => "Id",
1088 "when" => "When",
1089 "value" => "Value",
1090 "options" => "Options",
1091 "label" => "Label",
1092 "desc" => "Desc",
1093 "kind" => "Kind",
1094 "step" => "Step",
1095 "multi" => "Multi",
1096 "filter" => "Filter",
1097 "custom" => "Custom",
1098 "mask" => "Mask",
1099 "recommended" => "Recommended",
1100 "open" => "Open",
1101 "required" => "Required",
1102 "match" => "Match",
1103 "src" => "Src",
1104 "icon" => "Icon",
1105 "badge" => "Badge",
1106 "submit" => "Submit",
1107 "cancel" => "Cancel",
1108 "confirm" => "Confirm",
1109 "placeholder" => "Placeholder",
1110 "angle" => "Angle",
1111 "accent" => "Accent",
1112 "vertical" => "Vertical",
1113 "anim" => "Anim",
1114 "ease" => "Ease",
1115 "spin" => "Spin",
1116 "hover" => "Hover",
1117 "lift" => "Lift",
1118 "focus" => "Focus",
1119 "guides" => "Guides",
1120 "status" => "Status",
1121 "shimmer" => "Shimmer",
1122 "reveal" => "Reveal",
1123 _ => return None,
1124 })
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130 const ATTR_FIXTURE: &[&str] = &[
1131 "gap",
1132 "pad",
1133 "pad-x",
1134 "pad-y",
1135 "grow",
1136 "w",
1137 "min",
1138 "max",
1139 "h",
1140 "border",
1141 "bc",
1142 "edge",
1143 "bleed",
1144 "title",
1145 "title-align",
1146 "footer",
1147 "footer-align",
1148 "align",
1149 "valign",
1150 "justify",
1151 "fg",
1152 "bg",
1153 "on",
1154 "bold",
1155 "dim",
1156 "italic",
1157 "underline",
1158 "reverse",
1159 "strike",
1160 "wrap",
1161 "truncate",
1162 "trim",
1163 "id",
1164 "when",
1165 "value",
1166 "options",
1167 "label",
1168 "desc",
1169 "kind",
1170 "step",
1171 "multi",
1172 "filter",
1173 "custom",
1174 "mask",
1175 "recommended",
1176 "open",
1177 "required",
1178 "match",
1179 "src",
1180 "icon",
1181 "badge",
1182 "submit",
1183 "cancel",
1184 "confirm",
1185 "placeholder",
1186 "angle",
1187 "accent",
1188 "vertical",
1189 "anim",
1190 "ease",
1191 "spin",
1192 "hover",
1193 "lift",
1194 "shimmer",
1195 "reveal",
1196 ];
1197
1198 #[test]
1199 fn known_attributes_match_mirrored_fixture() {
1200 assert_eq!(ATTR_FIXTURE.len(), 65);
1201 for &name in ATTR_FIXTURE {
1202 assert!(prop_variant(name).is_some(), "missing macro entry for {name:?}");
1203 }
1204 }
1205
1206 #[test]
1207 fn lowers_plan_example() {
1208 let actual = expand(quote! {
1209 <box bg=yellow><row><col fg=blue><i:new/><text italic>{x}</text></col></row></box>
1210 })
1211 .expect("example should expand");
1212 let expected = quote! {
1213 ::omp_tui::components::Boxed::new()
1214 .with(::omp_tui::Prop::Bg, "yellow")
1215 .child(::omp_tui::components::Row::new()
1216 .child(::omp_tui::components::Col::new()
1217 .with(::omp_tui::Prop::Fg, "blue")
1218 .child(::omp_tui::components::Icon::named("new"))
1219 .child(::omp_tui::components::TextLeaf::new()
1220 .with(::omp_tui::Prop::Italic, true)
1221 .text(x))))
1222 };
1223 assert_eq!(actual.to_string(), expected.to_string());
1224 }
1225
1226 #[test]
1227 fn lowers_gradient_values_through_fg_bg_and_angle() {
1228 let actual = expand(quote! {
1229 <box bg="magenta..cyan" angle=45><text fg="yellow..red">"hi"</text></box>
1230 })
1231 .expect("gradient attributes should expand");
1232 let expected = quote! {
1233 ::omp_tui::components::Boxed::new()
1234 .with(::omp_tui::Prop::Bg, "magenta..cyan")
1235 .with(::omp_tui::Prop::Angle, "45")
1236 .child(::omp_tui::components::TextLeaf::new()
1237 .with(::omp_tui::Prop::Fg, "yellow..red")
1238 .text("hi"))
1239 };
1240 assert_eq!(actual.to_string(), expected.to_string());
1241 }
1242
1243 #[test]
1244 fn rejects_legacy_gradient_attributes() {
1245 for input in [quote!(<pre gradient="accent..info">"x"</pre>), quote!(<pre dir=h>"x"</pre>)] {
1246 let error = expand(input).expect_err("legacy gradient syntax must fail");
1247 assert!(error.to_string().contains("replaced by fg=/bg= and angle="));
1248 }
1249 }
1250
1251 #[test]
1252 fn accepts_dash_names_percent_and_expr_values() {
1253 let actual = expand(quote!(<user-card pad-x=2 w=50% data-id={id}/>)).expect("valid layout");
1254 let expected = quote! {
1255 ::omp_tui::components::CustomElement::new("user-card")
1256 .with(::omp_tui::Prop::PadX, "2")
1257 .with(::omp_tui::Prop::W, "50%")
1258 .with_custom("data-id", id)
1259 };
1260 assert_eq!(actual.to_string(), expected.to_string());
1261 }
1262
1263 #[test]
1264 fn accepts_dashed_bare_values() {
1265 let actual = expand(quote!(<box ease=in-out lift=2/>)).expect("dashed values should expand");
1266 let expected = quote! {
1267 ::omp_tui::components::Boxed::new()
1268 .with(::omp_tui::Prop::Ease, "in-out")
1269 .with(::omp_tui::Prop::Lift, "2")
1270 };
1271 assert_eq!(actual.to_string(), expected.to_string());
1272 }
1273
1274 #[test]
1275 fn accepts_dashed_icon_shorthand() {
1276 for input in [quote!(<i:log-in/>), quote!(<i:log-in></i:log-in>)] {
1277 let actual = expand(input).expect("dashed icon shorthand should expand");
1278 let expected = quote!(::omp_tui::components::Icon::named("log-in"));
1279 assert_eq!(actual.to_string(), expected.to_string());
1280 }
1281 }
1282
1283 #[test]
1284 fn lowers_typed_data_children() {
1285 let actual = expand(quote! {
1286 <select><option value=a>"Alpha"<md>"preview"</md></option></select>
1287 })
1288 .expect("data child should expand");
1289 let expected = quote! {
1290 ::omp_tui::components::Select::new()
1291 .option(::omp_tui::components::SelectOption::new()
1292 .with(::omp_tui::Prop::Value, "a")
1293 .label("Alpha")
1294 .child(::omp_tui::components::Markdown::new().text("preview")))
1295 };
1296 assert_eq!(actual.to_string(), expected.to_string());
1297 }
1298
1299 #[test]
1300 fn editor_children_lower_to_status_and_input_builders() {
1301 let actual = expand(quote! {
1302 <editor value="hi"><status><segment>{"S1"}</segment></status><input id=body/></editor>
1303 })
1304 .expect("editor element children should expand");
1305 let expected = quote! {
1306 ::omp_tui::components::EditorPane::new()
1307 .with(::omp_tui::Prop::Value, "hi")
1308 .status(::omp_tui::components::Status::new()
1309 .segment(::omp_tui::components::Segment::new().label("S1")))
1310 .input(::omp_tui::components::Input::new()
1311 .with(::omp_tui::Prop::Id, "body"))
1312 };
1313 assert_eq!(actual.to_string(), expected.to_string());
1314 }
1315
1316 #[test]
1317 fn editor_rejects_non_elements_and_extra_input_children() {
1318 for input in [quote!(<editor>{"text"}</editor>), quote!(<editor>"text"</editor>)] {
1319 let error = expand(input).expect_err("editor text children must fail");
1320 assert!(
1321 error
1322 .to_string()
1323 .contains("editor takes element children only")
1324 );
1325 }
1326 let error = expand(quote!(<editor><input/><button/></editor>))
1327 .expect_err("a second input child must fail");
1328 assert!(
1329 error
1330 .to_string()
1331 .contains("editor takes at most one input child and one <status>")
1332 );
1333 }
1334
1335 #[test]
1336 fn editor_accepts_mutually_exclusive_control_flow_children() {
1337 expand(quote! {
1338 <editor>
1339 <status/>
1340 if custom {
1341 <input/>
1342 } else if alternate {
1343 <button/>
1344 } else {
1345 <row/>
1346 }
1347 </editor>
1348 })
1349 .expect("exclusive branches should contribute at most one editor input");
1350 }
1351
1352 #[test]
1353 fn editor_rejects_duplicates_across_control_flow_paths() {
1354 for input in [
1355 quote!(<editor>if custom { <input/><button/> }</editor>),
1356 quote!(<editor><input/> if custom { <button/> }</editor>),
1357 quote!(<editor>if custom { <input/> } <button/></editor>),
1358 quote!(<editor>match mode {
1359 Mode::A => { <status/><status/> },
1360 _ => {},
1361 }</editor>),
1362 ] {
1363 let error = expand(input).expect_err("one reachable path contains duplicate editor slots");
1364 assert!(
1365 error
1366 .to_string()
1367 .contains("editor takes at most one input child and one <status>")
1368 );
1369 }
1370 }
1371
1372 #[test]
1373 fn editor_rejects_children_from_for_loops() {
1374 let error = expand(quote!(<editor>for item in items { <input value={item}/> }</editor>))
1375 .expect_err("an editor loop could produce the same slot more than once");
1376 assert!(
1377 error
1378 .to_string()
1379 .contains("editor cannot produce input or <status> children from a for loop")
1380 );
1381 }
1382 #[test]
1383 fn status_macro_lowers_segments() {
1384 let actual = expand(quote! {
1385 <status><segment fg=green data-kind={kind}>{"alpha"}</segment></status>
1386 })
1387 .expect("status segment should expand");
1388 let expected = quote! {
1389 ::omp_tui::components::Status::new()
1390 .segment(::omp_tui::components::Segment::new()
1391 .with(::omp_tui::Prop::Fg, "green")
1392 .with_custom("data-kind", kind)
1393 .label("alpha"))
1394 };
1395 assert_eq!(actual.to_string(), expected.to_string());
1396 }
1397
1398 #[test]
1399 fn rejects_segment_outside_status() {
1400 let error =
1401 expand(quote!(<segment>{"alpha"}</segment>)).expect_err("orphan segment must fail");
1402 assert!(
1403 error
1404 .to_string()
1405 .contains("only valid inside its owning component")
1406 );
1407 }
1408
1409 #[test]
1410 fn lowers_for_if_else_and_match_children() {
1411 let expanded = expand(quote! {
1412 <col>
1413 for item in items {
1414 <text>{item}</text>
1415 }
1416 if ready {
1417 <text>"ready"</text>
1418 } else if waiting {
1419 <text>"waiting"</text>
1420 } else {
1421 <text>"idle"</text>
1422 }
1423 match state {
1424 State::One => <row/>,
1425 State::Many(value) if value > 1 => {
1426 <text>{value}</text>
1427 <spacer/>
1428 },
1429 _ => {},
1430 }
1431 </col>
1432 })
1433 .expect("control flow should expand");
1434 parse2::<Expr>(expanded).expect("expanded control flow should be a Rust expression");
1435 }
1436
1437 #[test]
1438 fn points_out_mismatched_closer() {
1439 let error = expand(quote!(<row></col>)).expect_err("closer should not match");
1440 assert!(error.to_string().contains("mismatched closing tag"));
1441 }
1442
1443 #[test]
1444 fn rejects_bare_text() {
1445 let error = expand(quote!(<text>hello</text>)).expect_err("bare text loses whitespace");
1446 assert!(
1447 error
1448 .to_string()
1449 .contains("text content must be a string literal or {expr}")
1450 );
1451 }
1452}