dioxus_rsx/
rsx_block.rs

1//! An arbitrary block parser.
2//!
3//! Is meant to parse the contents of a block that is either a component or an element.
4//! We put these together to cut down on code duplication and make the parsers a bit more resilient.
5//!
6//! This involves custom structs for name, attributes, and children, as well as a custom parser for the block itself.
7//! It also bubbles out diagnostics if it can to give better errors.
8
9use crate::innerlude::*;
10use proc_macro2::Span;
11use proc_macro2_diagnostics::SpanDiagnosticExt;
12use syn::{
13    ext::IdentExt,
14    parse::{Parse, ParseBuffer, ParseStream},
15    spanned::Spanned,
16    token::{self, Brace},
17    Expr, Ident, LitStr, Token,
18};
19
20/// An item in the form of
21///
22/// {
23///  attributes,
24///  ..spreads,
25///  children
26/// }
27///
28/// Does not make any guarantees about the contents of the block - this is meant to be verified by the
29/// element/component impls themselves.
30///
31/// The name of the block is expected to be parsed by the parent parser. It will accept items out of
32/// order if possible and then bubble up diagnostics to the parent. This lets us give better errors
33/// and autocomplete
34#[derive(PartialEq, Eq, Clone, Debug, Default)]
35pub struct RsxBlock {
36    pub brace: token::Brace,
37    pub attributes: Vec<Attribute>,
38    pub spreads: Vec<Spread>,
39    pub children: Vec<BodyNode>,
40    pub diagnostics: Diagnostics,
41}
42
43impl Parse for RsxBlock {
44    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
45        let content: ParseBuffer;
46        let brace = syn::braced!(content in input);
47        RsxBlock::parse_inner(&content, brace)
48    }
49}
50
51impl RsxBlock {
52    /// Only parse the children of the block - all others will be rejected
53    pub fn parse_children(content: &ParseBuffer) -> syn::Result<Self> {
54        let mut nodes = vec![];
55        let mut diagnostics = Diagnostics::new();
56        while !content.is_empty() {
57            nodes.push(Self::parse_body_node_with_comma_diagnostics(
58                content,
59                &mut diagnostics,
60            )?);
61        }
62        Ok(Self {
63            children: nodes,
64            diagnostics,
65            ..Default::default()
66        })
67    }
68
69    pub fn parse_inner(content: &ParseBuffer, brace: token::Brace) -> syn::Result<Self> {
70        let mut items = vec![];
71        let mut diagnostics = Diagnostics::new();
72
73        // If we are after attributes, we can try to provide better completions and diagnostics
74        // by parsing the following nodes as body nodes if they are ambiguous, we can parse them as body nodes
75        let mut after_attributes = false;
76
77        // Lots of manual parsing but it's important to do it all here to give the best diagnostics possible
78        // We can do things like lookaheads, peeking, etc. to give better errors and autocomplete
79        // We allow parsing in any order but complain if its done out of order.
80        // Autofmt will fortunately fix this for us in most cases
81        //
82        // We do this by parsing the unambiguous cases first and then do some clever lookahead to parse the rest
83        while !content.is_empty() {
84            // Parse spread attributes
85            if content.peek(Token![..]) {
86                let dots = content.parse::<Token![..]>()?;
87
88                // in case someone tries to do ...spread which is not valid
89                if let Ok(extra) = content.parse::<Token![.]>() {
90                    diagnostics.push(
91                        extra
92                            .span()
93                            .error("Spread expressions only take two dots - not 3! (..spread)"),
94                    );
95                }
96
97                let expr = content.parse::<Expr>()?;
98                let attr = Spread {
99                    expr,
100                    dots,
101                    dyn_idx: DynIdx::default(),
102                    comma: content.parse().ok(),
103                };
104
105                if !content.is_empty() && attr.comma.is_none() {
106                    diagnostics.push(
107                        attr.span()
108                            .error("Attributes must be separated by commas")
109                            .help("Did you forget a comma?"),
110                    );
111                }
112                items.push(RsxItem::Spread(attr));
113                after_attributes = true;
114
115                continue;
116            }
117
118            // Parse unambiguous attributes - these can't be confused with anything
119            if (content.peek(LitStr) || content.peek(Ident::peek_any))
120                && content.peek2(Token![:])
121                && !content.peek3(Token![:])
122            {
123                let attr = content.parse::<Attribute>()?;
124
125                if !content.is_empty() && attr.comma.is_none() {
126                    diagnostics.push(
127                        attr.span()
128                            .error("Attributes must be separated by commas")
129                            .help("Did you forget a comma?"),
130                    );
131                }
132
133                items.push(RsxItem::Attribute(attr));
134
135                continue;
136            }
137
138            // Eagerly match on completed children, generally
139            if content.peek(LitStr)
140                | content.peek(Token![for])
141                | content.peek(Token![if])
142                | content.peek(Token![match])
143                | content.peek(token::Brace)
144                // web components
145                | (content.peek(Ident::peek_any) && content.peek2(Token![-]))
146                // elements
147                | (content.peek(Ident::peek_any) && (after_attributes || content.peek2(token::Brace)))
148                // components
149                | (content.peek(Ident::peek_any) && (after_attributes || content.peek2(token::Brace) || content.peek2(Token![::])))
150            {
151                items.push(RsxItem::Child(
152                    Self::parse_body_node_with_comma_diagnostics(content, &mut diagnostics)?,
153                ));
154                if !content.is_empty() && content.peek(Token![,]) {
155                    let comma = content.parse::<Token![,]>()?;
156                    diagnostics.push(
157                        comma.span().warning(
158                            "Elements and text nodes do not need to be separated by commas.",
159                        ),
160                    );
161                }
162                after_attributes = true;
163                continue;
164            }
165
166            // Parse shorthand attributes
167            // todo: this might cause complications with partial expansion... think more about the cases
168            // where we can imagine expansion and what better diagnostics we can provide
169            if Self::peek_lowercase_ident(&content)
170                    && !content.peek2(Brace)
171                    && !content.peek2(Token![:]) // regular attributes / components with generics
172                    && !content.peek2(Token![-]) // web components
173                    && !content.peek2(Token![<]) // generics on components
174                    // generics on components
175                    && !content.peek2(Token![::])
176            {
177                let attribute = content.parse::<Attribute>()?;
178
179                if !content.is_empty() && attribute.comma.is_none() {
180                    diagnostics.push(
181                        attribute
182                            .span()
183                            .error("Attributes must be separated by commas")
184                            .help("Did you forget a comma?"),
185                    );
186                }
187
188                items.push(RsxItem::Attribute(attribute));
189
190                continue;
191            }
192
193            // Finally just attempt a bodynode parse
194            items.push(RsxItem::Child(
195                Self::parse_body_node_with_comma_diagnostics(content, &mut diagnostics)?,
196            ))
197        }
198
199        // Validate the order of the items
200        RsxBlock::validate(&items, &mut diagnostics);
201
202        // todo: maybe make this a method such that the rsxblock is lossless
203        // Decompose into attributes, spreads, and children
204        let mut attributes = vec![];
205        let mut spreads = vec![];
206        let mut children = vec![];
207        for item in items {
208            match item {
209                RsxItem::Attribute(attr) => attributes.push(attr),
210                RsxItem::Spread(spread) => spreads.push(spread),
211                RsxItem::Child(child) => children.push(child),
212            }
213        }
214
215        Ok(Self {
216            attributes,
217            children,
218            spreads,
219            brace,
220            diagnostics,
221        })
222    }
223
224    // Parse a body node with diagnostics for unnecessary trailing commas
225    fn parse_body_node_with_comma_diagnostics(
226        content: &ParseBuffer,
227        _diagnostics: &mut Diagnostics,
228    ) -> syn::Result<BodyNode> {
229        let body_node = content.parse::<BodyNode>()?;
230        if !content.is_empty() && content.peek(Token![,]) {
231            let _comma = content.parse::<Token![,]>()?;
232
233            // todo: we would've pushed a warning here but proc-macro-2 emits them as errors, which we
234            // dont' want. There's no built-in cfg way for checking if we're on nightly, and adding
235            // that would require a build script, so for the interest of compile times, we won't throw
236            // any warning at all.
237            //
238            // Whenever the user formats their code with `dx fmt`, the comma will be removed, so active
239            // projects will implicitly be fixed.
240            //
241            // Whenever the issue is resolved or diagnostics are added, we can re-add this warning.
242            //
243            // - https://github.com/SergioBenitez/proc-macro2-diagnostics/issues/9
244            // - https://github.com/DioxusLabs/dioxus/issues/2807
245            //
246            // diagnostics.push(
247            //     comma
248            //         .span()
249            //         .warning("Elements and text nodes do not need to be separated by commas."),
250            // );
251        }
252        Ok(body_node)
253    }
254
255    fn peek_lowercase_ident(stream: &ParseStream) -> bool {
256        let Ok(ident) = stream.fork().call(Ident::parse_any) else {
257            return false;
258        };
259
260        ident
261            .to_string()
262            .chars()
263            .next()
264            .unwrap()
265            .is_ascii_lowercase()
266    }
267
268    /// Ensure the ordering of the items is correct
269    /// - Attributes must come before children
270    /// - Spreads must come before children
271    /// - Spreads must come after attributes
272    ///
273    /// div {
274    ///     key: "value",
275    ///     ..props,
276    ///     "Hello, world!"
277    /// }
278    fn validate(items: &[RsxItem], diagnostics: &mut Diagnostics) {
279        #[derive(Debug, PartialEq, Eq)]
280        enum ValidationState {
281            Attributes,
282            Spreads,
283            Children,
284        }
285        use ValidationState::*;
286        let mut state = ValidationState::Attributes;
287
288        for item in items.iter() {
289            match item {
290                RsxItem::Attribute(_) => {
291                    if state == Children || state == Spreads {
292                        diagnostics.push(
293                            item.span()
294                                .error("Attributes must come before children in an element"),
295                        );
296                    }
297                    state = Attributes;
298                }
299                RsxItem::Spread(_) => {
300                    if state == Children {
301                        diagnostics.push(
302                            item.span()
303                                .error("Spreads must come before children in an element"),
304                        );
305                    }
306                    state = Spreads;
307                }
308                RsxItem::Child(_) => {
309                    state = Children;
310                }
311            }
312        }
313    }
314}
315
316pub enum RsxItem {
317    Attribute(Attribute),
318    Spread(Spread),
319    Child(BodyNode),
320}
321
322impl RsxItem {
323    pub fn span(&self) -> Span {
324        match self {
325            RsxItem::Attribute(attr) => attr.span(),
326            RsxItem::Spread(spread) => spread.dots.span(),
327            RsxItem::Child(child) => child.span(),
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use quote::quote;
336
337    #[test]
338    fn basic_cases() {
339        let input = quote! {
340            { "Hello, world!" }
341        };
342
343        let block: RsxBlock = syn::parse2(input).unwrap();
344        assert_eq!(block.attributes.len(), 0);
345        assert_eq!(block.children.len(), 1);
346
347        let input = quote! {
348            {
349                key: "value",
350                onclick: move |_| {
351                    "Hello, world!"
352                },
353                ..spread,
354                "Hello, world!"
355            }
356        };
357
358        let block: RsxBlock = syn::parse2(input).unwrap();
359        dbg!(block);
360
361        let complex_element = quote! {
362            {
363                key: "value",
364                onclick2: move |_| {
365                    "Hello, world!"
366                },
367                thing: if true { "value" },
368                otherthing: if true { "value" } else { "value" },
369                onclick: move |_| {
370                    "Hello, world!"
371                },
372                ..spread,
373                ..spread1
374                ..spread2,
375                "Hello, world!"
376            }
377        };
378
379        let _block: RsxBlock = syn::parse2(complex_element).unwrap();
380
381        let complex_component = quote! {
382            {
383                key: "value",
384                onclick2: move |_| {
385                    "Hello, world!"
386                },
387                ..spread,
388                "Hello, world!"
389            }
390        };
391
392        let _block: RsxBlock = syn::parse2(complex_component).unwrap();
393    }
394
395    /// Some tests of partial expansion to give better autocomplete
396    #[test]
397    fn partial_cases() {
398        let with_handler = quote! {
399            {
400                onclick: move |_| {
401                    some.
402                }
403            }
404        };
405
406        let _block: RsxBlock = syn::parse2(with_handler).unwrap();
407    }
408
409    /// Ensure the hotreload scoring algorithm works as expected
410    #[test]
411    fn hr_score() {
412        let _block = quote! {
413            {
414                a: "value {cool}",
415                b: "{cool} value",
416                b: "{cool} {thing} value",
417                b: "{thing} value",
418            }
419        };
420
421        // loop { accumulate perfect matches }
422        // stop when all matches are equally valid
423        //
424        // Remove new attr one by one as we find its perfect match. If it doesn't have a perfect match, we
425        // score it instead.
426
427        quote! {
428            // start with
429            div {
430                div { class: "other {abc} {def} {hij}" } // 1, 1, 1
431                div { class: "thing {abc} {def}" }       // 1, 1, 1
432                // div { class: "thing {abc}" }             // 1, 0, 1
433            }
434
435            // end with
436            div {
437                h1 {
438                    class: "thing {abc}" // 1, 1, MAX
439                }
440                h1 {
441                    class: "thing {hij}" // 1, 1, MAX
442                }
443                // h2 {
444                //     class: "thing {def}" // 1, 1, 0
445                // }
446                // h3 {
447                //     class: "thing {def}" // 1, 1, 0
448                // }
449            }
450
451            // how about shuffling components, for, if, etc
452            Component {
453                class: "thing {abc}",
454                other: "other {abc} {def}",
455            }
456            Component {
457                class: "thing",
458                other: "other",
459            }
460
461            Component {
462                class: "thing {abc}",
463                other: "other",
464            }
465            Component {
466                class: "thing {abc}",
467                other: "other {abc} {def}",
468            }
469        };
470    }
471
472    #[test]
473    fn kitchen_sink_parse() {
474        let input = quote! {
475            // Elements
476            {
477                class: "hello",
478                id: "node-{node_id}",
479                ..props,
480
481                // Text Nodes
482                "Hello, world!"
483
484                // Exprs
485                {rsx! { "hi again!" }}
486
487
488                for item in 0..10 {
489                    // "Second"
490                    div { "cool-{item}" }
491                }
492
493                Link {
494                    to: "/home",
495                    class: "link {is_ready}",
496                    "Home"
497                }
498
499                if false {
500                    div { "hi again!?" }
501                } else if true {
502                    div { "its cool?" }
503                } else {
504                    div { "not nice !" }
505                }
506            }
507        };
508
509        let _parsed: RsxBlock = syn::parse2(input).unwrap();
510    }
511
512    #[test]
513    fn simple_comp_syntax() {
514        let input = quote! {
515            { class: "inline-block mr-4", icons::icon_14 {} }
516        };
517
518        let _parsed: RsxBlock = syn::parse2(input).unwrap();
519    }
520
521    #[test]
522    fn with_sutter() {
523        let input = quote! {
524            {
525                div {}
526                d
527                div {}
528            }
529        };
530
531        let _parsed: RsxBlock = syn::parse2(input).unwrap();
532    }
533
534    #[test]
535    fn looks_like_prop_but_is_expr() {
536        let input = quote! {
537            {
538                a: "asd".to_string(),
539                // b can be omitted, and it will be filled with its default value
540                c: "asd".to_string(),
541                d: Some("asd".to_string()),
542                e: Some("asd".to_string()),
543            }
544        };
545
546        let _parsed: RsxBlock = syn::parse2(input).unwrap();
547    }
548
549    #[test]
550    fn no_comma_diagnostics() {
551        let input = quote! {
552            { a, ..ComponentProps { a: 1, b: 2, c: 3, children: VNode::empty(), onclick: Default::default() } }
553        };
554
555        let parsed: RsxBlock = syn::parse2(input).unwrap();
556        assert!(parsed.diagnostics.is_empty());
557    }
558    #[test]
559    fn proper_attributes() {
560        let input = quote! {
561            {
562                onclick: action,
563                href,
564                onmounted: onmounted,
565                prevent_default,
566                class,
567                rel,
568                target: tag_target,
569                aria_current,
570                ..attributes,
571                {children}
572            }
573        };
574
575        let parsed: RsxBlock = syn::parse2(input).unwrap();
576        dbg!(parsed.attributes);
577    }
578
579    #[test]
580    fn reserved_attributes() {
581        let input = quote! {
582            {
583                label {
584                    for: "blah",
585                }
586            }
587        };
588
589        let parsed: RsxBlock = syn::parse2(input).unwrap();
590        dbg!(parsed.attributes);
591    }
592
593    #[test]
594    fn diagnostics_check() {
595        let input = quote::quote! {
596            {
597                class: "foo bar"
598                "Hello world"
599            }
600        };
601
602        let _parsed: RsxBlock = syn::parse2(input).unwrap();
603    }
604
605    #[test]
606    fn incomplete_components() {
607        let input = quote::quote! {
608            {
609                some::cool::Component
610            }
611        };
612
613        let _parsed: RsxBlock = syn::parse2(input).unwrap();
614    }
615
616    #[test]
617    fn incomplete_root_elements() {
618        use syn::parse::Parser;
619
620        let input = quote::quote! {
621            di
622        };
623
624        let parsed = RsxBlock::parse_children.parse2(input).unwrap();
625        let children = parsed.children;
626
627        assert_eq!(children.len(), 1);
628        if let BodyNode::Element(parsed) = &children[0] {
629            assert_eq!(
630                parsed.name,
631                ElementName::Ident(Ident::new("di", Span::call_site()))
632            );
633        } else {
634            panic!("expected element, got {:?}", children);
635        }
636        assert!(parsed.diagnostics.is_empty());
637    }
638}