1use proc_macro2::{Delimiter, Group, Span, TokenStream};
2use proc_macro_error::emit_warning;
3use quote::{quote, quote_spanned, ToTokens};
4use syn::buffer::Cursor;
5use syn::parse::{Parse, ParseStream};
6use syn::spanned::Spanned;
7use syn::{Expr, Ident, Lit, LitStr, Token};
8
9use super::{HtmlChildrenTree, HtmlDashedName, TagTokens};
10use crate::props::{ElementProps, Prop, PropDirective};
11use crate::stringify::{Stringify, Value};
12use crate::{is_ide_completion, non_capitalized_ascii, Peek, PeekValue};
13
14fn is_normalised_element_name(name: &str) -> bool {
15 match name {
16 "animateMotion"
17 | "animateTransform"
18 | "clipPath"
19 | "feBlend"
20 | "feColorMatrix"
21 | "feComponentTransfer"
22 | "feComposite"
23 | "feConvolveMatrix"
24 | "feDiffuseLighting"
25 | "feDisplacementMap"
26 | "feDistantLight"
27 | "feDropShadow"
28 | "feFlood"
29 | "feFuncA"
30 | "feFuncB"
31 | "feFuncG"
32 | "feFuncR"
33 | "feGaussianBlur"
34 | "feImage"
35 | "feMerge"
36 | "feMergeNode"
37 | "feMorphology"
38 | "feOffset"
39 | "fePointLight"
40 | "feSpecularLighting"
41 | "feSpotLight"
42 | "feTile"
43 | "feTurbulence"
44 | "foreignObject"
45 | "glyphRef"
46 | "linearGradient"
47 | "radialGradient"
48 | "textPath" => true,
49 _ => !name.chars().any(|c| c.is_ascii_uppercase()),
50 }
51}
52
53pub struct HtmlElement {
54 pub name: TagName,
55 pub props: ElementProps,
56 pub children: HtmlChildrenTree,
57}
58
59impl PeekValue<()> for HtmlElement {
60 fn peek(cursor: Cursor) -> Option<()> {
61 HtmlElementOpen::peek(cursor)
62 .or_else(|| HtmlElementClose::peek(cursor))
63 .map(|_| ())
64 }
65}
66
67impl Parse for HtmlElement {
68 fn parse(input: ParseStream) -> syn::Result<Self> {
69 if HtmlElementClose::peek(input.cursor()).is_some() {
70 return match input.parse::<HtmlElementClose>() {
71 Ok(close) => Err(syn::Error::new_spanned(
72 close.to_spanned(),
73 "this closing tag has no corresponding opening tag",
74 )),
75 Err(err) => Err(err),
76 };
77 }
78
79 let open = input.parse::<HtmlElementOpen>()?;
80 if open.is_self_closing() {
82 return Ok(HtmlElement {
83 name: open.name,
84 props: open.props,
85 children: HtmlChildrenTree::new(),
86 });
87 }
88
89 if let TagName::Lit(name) = &open.name {
90 match name.to_ascii_lowercase_string().as_str() {
95 "textarea" => {
96 return Err(syn::Error::new_spanned(
97 open.to_spanned(),
98 "the tag `<textarea>` is a void element and cannot have children (hint: \
99 to provide value to it, rewrite it as `<textarea value={x} />`. If you \
100 wish to set the default value, rewrite it as `<textarea defaultvalue={x} \
101 />`)",
102 ))
103 }
104
105 "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link"
106 | "meta" | "param" | "source" | "track" | "wbr" => {
107 return Err(syn::Error::new_spanned(
108 open.to_spanned(),
109 format!(
110 "the tag `<{name}>` is a void element and cannot have children (hint: \
111 rewrite this as `<{name} />`)",
112 ),
113 ))
114 }
115
116 _ => {}
117 }
118 }
119
120 let open_key = open.name.get_key();
121 let mut children = HtmlChildrenTree::new();
122 loop {
123 if input.is_empty() {
124 if is_ide_completion() {
125 break;
126 }
127 return Err(syn::Error::new_spanned(
128 open.to_spanned(),
129 "this opening tag has no corresponding closing tag",
130 ));
131 }
132 if let Some(close_key) = HtmlElementClose::peek(input.cursor()) {
133 if open_key == close_key {
134 break;
135 }
136 }
137
138 children.parse_child(input)?;
139 }
140
141 if !input.is_empty() || !is_ide_completion() {
142 input.parse::<HtmlElementClose>()?;
143 }
144
145 Ok(Self {
146 name: open.name,
147 props: open.props,
148 children,
149 })
150 }
151}
152
153impl ToTokens for HtmlElement {
154 #[allow(clippy::cognitive_complexity)]
155 fn to_tokens(&self, tokens: &mut TokenStream) {
156 let Self {
157 name,
158 props,
159 children,
160 } = self;
161
162 let ElementProps {
163 classes,
164 attributes,
165 booleans,
166 value,
167 checked,
168 listeners,
169 special,
170 defaultvalue,
171 } = &props;
172
173 let node_ref = special.wrap_node_ref_attr();
176 let key = special.wrap_key_attr();
177 let value = || {
178 value
179 .as_ref()
180 .map(|prop| wrap_attr_value(prop.value.optimize_literals()))
181 .unwrap_or(quote! { ::std::option::Option::None })
182 };
183 let checked = || {
184 checked
185 .as_ref()
186 .map(|attr| {
187 let value = &attr.value;
188 quote! { ::std::option::Option::Some( #value ) }
189 })
190 .unwrap_or(quote! { ::std::option::Option::None })
191 };
192 let defaultvalue = || {
193 defaultvalue
194 .as_ref()
195 .map(|prop| wrap_attr_value(prop.value.optimize_literals()))
196 .unwrap_or(quote! { ::std::option::Option::None })
197 };
198
199 let attributes = {
202 let normal_attrs = attributes.iter().map(
203 |Prop {
204 label,
205 value,
206 directive,
207 ..
208 }| {
209 (
210 label.to_lit_str(),
211 value.optimize_literals_tagged(),
212 *directive,
213 )
214 },
215 );
216 let boolean_attrs = booleans.iter().filter_map(
217 |Prop {
218 label,
219 value,
220 directive,
221 ..
222 }| {
223 let key = label.to_lit_str();
224 Some((
225 key.clone(),
226 match value {
227 Expr::Lit(e) => match &e.lit {
228 Lit::Bool(b) => Value::Static(if b.value {
229 quote! { #key }
230 } else {
231 return None;
232 }),
233 _ => Value::Dynamic(quote_spanned! {value.span()=> {
234 ::yew::utils::__ensure_type::<::std::primitive::bool>(#value);
235 #key
236 }}),
237 },
238 expr => Value::Dynamic(
239 quote_spanned! {expr.span().resolved_at(Span::call_site())=>
240 if #expr {
241 ::std::option::Option::Some(
242 ::yew::virtual_dom::AttrValue::Static(#key)
243 )
244 } else {
245 ::std::option::Option::None
246 }
247 },
248 ),
249 },
250 *directive,
251 ))
252 },
253 );
254
255 let class_attr =
256 classes
257 .as_ref()
258 .and_then(|classes| match classes.value.try_into_lit() {
259 Some(lit) => {
260 if lit.value().is_empty() {
261 None
262 } else {
263 Some((
264 LitStr::new("class", lit.span()),
265 Value::Static(quote! { #lit }),
266 None,
267 ))
268 }
269 }
270 None => {
271 let expr = &classes.value;
272 Some((
273 LitStr::new("class", classes.label.span()),
274 Value::Dynamic(quote! {
275 ::std::convert::Into::<::yew::html::Classes>::into(#expr)
276 }),
277 None,
278 ))
279 }
280 });
281
282 fn try_into_static(
284 src: &[(LitStr, Value, Option<PropDirective>)],
285 ) -> Option<TokenStream> {
286 if src
287 .iter()
288 .any(|(_, _, d)| matches!(d, Some(PropDirective::ApplyAsProperty(_))))
289 {
290 return None;
293 }
294 let mut kv = Vec::with_capacity(src.len());
295 for (k, v, directive) in src.iter() {
296 let v = match v {
297 Value::Static(v) => quote! { #v },
298 Value::Dynamic(_) => return None,
299 };
300 let v = match directive {
301 Some(PropDirective::ApplyAsProperty(token)) => {
302 quote_spanned!(token.span()=> ::yew::virtual_dom::AttributeOrProperty::Property(
303 ::std::convert::Into::into(#v)
304 ))
305 }
306 None => quote!(::yew::virtual_dom::AttributeOrProperty::Static(
307 #v
308 )),
309 };
310 kv.push(quote! { ( #k, #v) });
311 }
312
313 Some(quote! { ::yew::virtual_dom::Attributes::Static(&[#(#kv),*]) })
314 }
315
316 let attrs = normal_attrs
317 .chain(boolean_attrs)
318 .chain(class_attr)
319 .collect::<Vec<(LitStr, Value, Option<PropDirective>)>>();
320 try_into_static(&attrs).unwrap_or_else(|| {
321 let keys = attrs.iter().map(|(k, ..)| quote! { #k });
322 let values = attrs.iter().map(|(_, v, directive)| {
323 let value = match directive {
324 Some(PropDirective::ApplyAsProperty(token)) => {
325 quote_spanned!(token.span()=> ::std::option::Option::Some(
326 ::yew::virtual_dom::AttributeOrProperty::Property(
327 ::std::convert::Into::into(#v)
328 ))
329 )
330 }
331 None => {
332 let value = wrap_attr_value(v);
333 quote! {
334 ::std::option::Option::map(#value, ::yew::virtual_dom::AttributeOrProperty::Attribute)
335 }
336 },
337 };
338 quote! { #value }
339 });
340 quote! {
341 ::yew::virtual_dom::Attributes::Dynamic{
342 keys: &[#(#keys),*],
343 values: ::std::boxed::Box::new([#(#values),*]),
344 }
345 }
346 })
347 };
348
349 let listeners = if listeners.is_empty() {
350 quote! { ::yew::virtual_dom::listeners::Listeners::None }
351 } else {
352 let listeners_it = listeners.iter().map(|Prop { label, value, .. }| {
353 let name = &label.name;
354 quote! {
355 ::yew::html::#name::Wrapper::__macro_new(#value)
356 }
357 });
358
359 quote! {
360 ::yew::virtual_dom::listeners::Listeners::Pending(
361 ::std::boxed::Box::new([#(#listeners_it),*])
362 )
363 }
364 };
365
366 let children = children.to_vnode_tokens();
369
370 tokens.extend(match &name {
371 TagName::Lit(dashedname) => {
372 let name_span = dashedname.span();
373 let name = dashedname.to_ascii_lowercase_string();
374 if !is_normalised_element_name(&dashedname.to_string()) {
375 emit_warning!(
376 name_span.clone(),
377 format!(
378 "The tag '{dashedname}' is not matching its normalized form '{name}'. If you want \
379 to keep this form, change this to a dynamic tag `@{{\"{dashedname}\"}}`."
380 )
381 )
382 }
383 let node = match &*name {
384 "input" => {
385 let value = value();
386 let checked = checked();
387 quote! {
388 ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
389 ::yew::virtual_dom::VTag::__new_input(
390 #value,
391 #checked,
392 #node_ref,
393 #key,
394 #attributes,
395 #listeners,
396 ),
397 )
398 }
399 }
400 "textarea" => {
401 let value = value();
402 let defaultvalue = defaultvalue();
403 quote! {
404 ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
405 ::yew::virtual_dom::VTag::__new_textarea(
406 #value,
407 #defaultvalue,
408 #node_ref,
409 #key,
410 #attributes,
411 #listeners,
412 ),
413 )
414 }
415 }
416 _ => {
417 quote! {
418 ::std::convert::Into::<::yew::virtual_dom::VNode>::into(
419 ::yew::virtual_dom::VTag::__new_other(
420 ::yew::virtual_dom::AttrValue::Static(#name),
421 #node_ref,
422 #key,
423 #attributes,
424 #listeners,
425 #children,
426 ),
427 )
428 }
429 }
430 };
431 quote_spanned!{
434 name_span =>
435 {
436 #[allow(clippy::redundant_clone, unused_braces)]
437 let node = #node;
438 node
439 }
440 }
441 }
442 TagName::Expr(name) => {
443 let vtag = Ident::new("__yew_vtag", name.span());
444 let expr = name.expr.as_ref().map(Group::stream);
445 let vtag_name = Ident::new("__yew_vtag_name", expr.span());
446
447 let void_children = Ident::new("__yew_void_children", Span::mixed_site());
448
449 let handle_value_attr = props.value.as_ref().map(|prop| {
451 let v = prop.value.optimize_literals();
452 quote_spanned! {v.span()=> {
453 __yew_vtag.__macro_push_attr("value", #v);
454 }}
455 });
456
457 #[rustversion::since(1.89)]
458 fn derive_debug_tag(vtag: &Ident) -> String {
459 let span = vtag.span().unwrap();
460 format!("[{}:{}:{}] ", span.file(), span.line(), span.column())
461 }
462 #[rustversion::before(1.89)]
463 fn derive_debug_tag(_: &Ident) -> &'static str {
464 ""
465 }
466 let invalid_void_tag_msg_start = derive_debug_tag(&vtag);
467
468 let value = value();
469 let checked = checked();
470 let defaultvalue = defaultvalue();
471 quote_spanned! {expr.span()=> {
474 let mut #vtag_name = ::std::convert::Into::<
475 ::yew::virtual_dom::AttrValue
476 >::into(#expr);
477 ::std::debug_assert!(
478 #vtag_name.is_ascii(),
479 "a dynamic tag returned a tag name containing non ASCII characters: `{}`",
480 #vtag_name,
481 );
482
483 #[allow(clippy::redundant_clone, unused_braces, clippy::let_and_return)]
484 let mut #vtag = match () {
485 _ if "input".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
486 ::yew::virtual_dom::VTag::__new_input(
487 #value,
488 #checked,
489 #node_ref,
490 #key,
491 #attributes,
492 #listeners,
493 )
494 }
495 _ if "textarea".eq_ignore_ascii_case(::std::convert::AsRef::<::std::primitive::str>::as_ref(&#vtag_name)) => {
496 ::yew::virtual_dom::VTag::__new_textarea(
497 #value,
498 #defaultvalue,
499 #node_ref,
500 #key,
501 #attributes,
502 #listeners,
503 )
504 }
505 _ => {
506 let mut __yew_vtag = ::yew::virtual_dom::VTag::__new_other(
507 #vtag_name,
508 #node_ref,
509 #key,
510 #attributes,
511 #listeners,
512 #children,
513 );
514
515 #handle_value_attr
516
517 __yew_vtag
518 }
519 };
520
521 if ::yew::virtual_dom::VTag::children(&#vtag).is_some() &&
526 !::std::matches!(
527 ::yew::virtual_dom::VTag::children(&#vtag),
528 ::std::option::Option::Some(::yew::virtual_dom::VNode::VList(ref #void_children)) if ::std::vec::Vec::is_empty(#void_children)
529 ) {
530 ::std::debug_assert!(
531 !::std::matches!(#vtag.tag().to_ascii_lowercase().as_str(),
532 "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
533 | "link" | "meta" | "param" | "source" | "track" | "wbr" | "textarea"
534 ),
535 concat!(#invalid_void_tag_msg_start, "a dynamic tag tried to create a `<{0}>` tag with children. `<{0}>` is a void element which can't have any children."),
536 #vtag.tag(),
537 );
538 }
539
540 ::std::convert::Into::<::yew::virtual_dom::VNode>::into(#vtag)
541 }}
542 }
543 });
544 }
545}
546
547fn wrap_attr_value<T: ToTokens>(value: T) -> TokenStream {
548 quote_spanned! {value.span()=>
549 ::yew::html::IntoPropValue::<
550 ::std::option::Option<
551 ::yew::virtual_dom::AttrValue
552 >
553 >
554 ::into_prop_value(#value)
555 }
556}
557
558pub struct DynamicName {
559 at: Token![@],
560 expr: Option<Group>,
561}
562
563impl Peek<'_, ()> for DynamicName {
564 fn peek(cursor: Cursor) -> Option<((), Cursor)> {
565 let (punct, cursor) = cursor.punct()?;
566 if punct.as_char() != '@' {
567 return None;
568 }
569
570 let cursor = cursor
572 .group(Delimiter::Brace)
573 .map(|(_, _, cursor)| cursor)
574 .unwrap_or(cursor);
575
576 Some(((), cursor))
577 }
578}
579
580impl Parse for DynamicName {
581 fn parse(input: ParseStream) -> syn::Result<Self> {
582 let at = input.parse()?;
583 let expr = input.parse().ok();
585 Ok(Self { at, expr })
586 }
587}
588
589impl ToTokens for DynamicName {
590 fn to_tokens(&self, tokens: &mut TokenStream) {
591 let Self { at, expr } = self;
592 tokens.extend(quote! {#at #expr});
593 }
594}
595
596#[derive(PartialEq)]
597enum TagKey {
598 Lit(HtmlDashedName),
599 Expr,
600}
601
602pub enum TagName {
603 Lit(HtmlDashedName),
604 Expr(DynamicName),
605}
606
607impl TagName {
608 fn get_key(&self) -> TagKey {
609 match self {
610 TagName::Lit(name) => TagKey::Lit(name.clone()),
611 TagName::Expr(_) => TagKey::Expr,
612 }
613 }
614}
615
616impl Peek<'_, TagKey> for TagName {
617 fn peek(cursor: Cursor) -> Option<(TagKey, Cursor)> {
618 if let Some((_, cursor)) = DynamicName::peek(cursor) {
619 Some((TagKey::Expr, cursor))
620 } else {
621 HtmlDashedName::peek(cursor).map(|(name, cursor)| (TagKey::Lit(name), cursor))
622 }
623 }
624}
625
626impl Parse for TagName {
627 fn parse(input: ParseStream) -> syn::Result<Self> {
628 if DynamicName::peek(input.cursor()).is_some() {
629 DynamicName::parse(input).map(Self::Expr)
630 } else {
631 HtmlDashedName::parse(input).map(Self::Lit)
632 }
633 }
634}
635
636impl ToTokens for TagName {
637 fn to_tokens(&self, tokens: &mut TokenStream) {
638 match self {
639 TagName::Lit(name) => name.to_tokens(tokens),
640 TagName::Expr(name) => name.to_tokens(tokens),
641 }
642 }
643}
644
645struct HtmlElementOpen {
646 tag: TagTokens,
647 name: TagName,
648 props: ElementProps,
649}
650impl HtmlElementOpen {
651 fn is_self_closing(&self) -> bool {
652 self.tag.div.is_some()
653 }
654
655 fn to_spanned(&self) -> impl ToTokens {
656 self.tag.to_spanned()
657 }
658}
659
660impl PeekValue<TagKey> for HtmlElementOpen {
661 fn peek(cursor: Cursor) -> Option<TagKey> {
662 let (punct, cursor) = cursor.punct()?;
663 if punct.as_char() != '<' {
664 return None;
665 }
666
667 let (tag_key, cursor) = TagName::peek(cursor)?;
668 if let TagKey::Lit(name) = &tag_key {
669 if name.to_string() == "key" {
671 let (punct, _) = cursor.punct()?;
672 if punct.as_char() == '=' {
674 return None;
675 }
676 } else if !non_capitalized_ascii(&name.to_string()) {
677 return None;
678 }
679 }
680
681 Some(tag_key)
682 }
683}
684
685impl Parse for HtmlElementOpen {
686 fn parse(input: ParseStream) -> syn::Result<Self> {
687 TagTokens::parse_start_content(input, |input, tag| {
688 let name = input.parse::<TagName>()?;
689 let mut props = input.parse::<ElementProps>()?;
690
691 match &name {
692 TagName::Lit(name) => {
693 match name.to_ascii_lowercase_string().as_str() {
696 "input" | "textarea" => {}
697 _ => {
698 if let Some(attr) = props.value.take() {
699 props.attributes.push(attr);
700 }
701 if let Some(attr) = props.checked.take() {
702 props.attributes.push(attr);
703 }
704 }
705 }
706 }
707 TagName::Expr(name) => {
708 if name.expr.is_none() {
709 return Err(syn::Error::new_spanned(
710 name,
711 "this dynamic tag is missing an expression block defining its value",
712 ));
713 }
714 }
715 }
716
717 Ok(Self { tag, name, props })
718 })
719 }
720}
721
722struct HtmlElementClose {
723 tag: TagTokens,
724 _name: TagName,
725}
726impl HtmlElementClose {
727 fn to_spanned(&self) -> impl ToTokens {
728 self.tag.to_spanned()
729 }
730}
731
732impl PeekValue<TagKey> for HtmlElementClose {
733 fn peek(cursor: Cursor) -> Option<TagKey> {
734 let (punct, cursor) = cursor.punct()?;
735 if punct.as_char() != '<' {
736 return None;
737 }
738
739 let (punct, cursor) = cursor.punct()?;
740 if punct.as_char() != '/' {
741 return None;
742 }
743
744 let (tag_key, cursor) = TagName::peek(cursor)?;
745 if matches!(&tag_key, TagKey::Lit(name) if !non_capitalized_ascii(&name.to_string())) {
746 return None;
747 }
748
749 let (punct, _) = cursor.punct()?;
750 (punct.as_char() == '>').then_some(tag_key)
751 }
752}
753
754impl Parse for HtmlElementClose {
755 fn parse(input: ParseStream) -> syn::Result<Self> {
756 TagTokens::parse_end_content(input, |input, tag| {
757 let name = input.parse()?;
758
759 if let TagName::Expr(name) = &name {
760 if let Some(expr) = &name.expr {
761 return Err(syn::Error::new_spanned(
762 expr,
763 "dynamic closing tags must not have a body (hint: replace it with just \
764 `</@>`)",
765 ));
766 }
767 }
768
769 Ok(Self { tag, _name: name })
770 })
771 }
772}