Skip to main content

omp_tui_macros/
lib.rs

1//! Proc macros for `omp-tui`: the [`dom!`](macro@dom) markup macro.
2
3use 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/// Builds one component tree from markup with child-level `for`, `if`, and
9/// `match` control flow.
10#[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}