hyperide_macro/
lib.rs

1use std::fmt::Display;
2
3use proc_macro::TokenStream;
4use proc_macro2::{Ident, Span, TokenStream as TokenStream2};
5use proc_macro_crate::{crate_name, FoundCrate};
6use proc_macro_error::abort;
7use quote::{quote, quote_spanned, ToTokens};
8use rstml::{
9    atoms::{CloseTag, OpenTag},
10    node::{
11        KeyedAttribute, KeyedAttributeValue, Node, NodeAttribute, NodeComment, NodeElement,
12        NodeFragment, NodeName, NodeText,
13    },
14    Parser, ParserConfig,
15};
16use syn::{
17    punctuated::{Pair, Punctuated},
18    spanned::Spanned,
19    ExprPath, LitStr,
20};
21use uuid::Uuid;
22
23struct HyperideGenerator {
24    bindings: TokenStream2,
25    idents: Vec<Ident>,
26    hyperide: TokenStream2,
27    in_disabled_raw: bool,
28}
29impl HyperideGenerator {
30    fn new(hyperide: TokenStream2) -> HyperideGenerator {
31        HyperideGenerator {
32            bindings: quote_spanned! {Span::call_site()=>},
33            idents: Vec::new(),
34            hyperide,
35            in_disabled_raw: false,
36        }
37    }
38
39    fn push_raw_hypertext(&mut self, to: TokenStream2) -> Ident {
40        let bind = make_ident(Span::call_site());
41        let hyperide = &self.hyperide;
42        self.bindings.extend(quote_spanned! {Span::call_site()=>
43            #[allow(unused_braces)]
44            let #bind: #hyperide::HyperText<'_> = #to;
45        });
46        self.idents.push(bind.clone());
47        bind
48    }
49
50    fn push_as_hypertext(&mut self, to: TokenStream2) -> Ident {
51        let hyperide = &self.hyperide;
52        self.push_raw_hypertext(quote_spanned! {to.span()=>
53            #hyperide::IntoHyperText::into_hyper_text(#to)
54        })
55    }
56
57    fn push_lit(&mut self, lit: &LitStr) -> Ident {
58        self.push_as_hypertext(lit.to_token_stream())
59    }
60
61    fn push_str(&mut self, str: &str, span: Span) -> Ident {
62        self.push_lit(&LitStr::new(str, span))
63    }
64
65    fn push_ref(&mut self, ident: &Ident) -> Ident {
66        self.push_raw_hypertext(quote_spanned! {Span::call_site()=>
67            std::ops::Deref::deref(&#ident).into();
68        })
69    }
70
71    fn push_nodes(&mut self, nodes: &[Node]) {
72        for node in nodes {
73            match node {
74                Node::Comment(NodeComment { value, .. }) => {
75                    self.push_lit(value);
76                }
77                Node::Doctype(doctype) => {
78                    self.push_str("<!DOCTYPE html>", doctype.span());
79                }
80                Node::Fragment(NodeFragment { children, .. }) => {
81                    self.push_nodes(&children);
82                }
83                Node::Block(block) => {
84                    self.push_as_hypertext(block.to_token_stream());
85                }
86                Node::Text(NodeText { value }) => {
87                    self.push_lit(value);
88                }
89                Node::RawText(raw_text) => {
90                    if !self.in_disabled_raw {
91                        let best_string = raw_text.to_string_best();
92                        self.push_str(&best_string, raw_text.span());
93                    } else {
94                        self.push_as_hypertext(raw_text.to_token_stream());
95                    }
96                }
97                Node::Element(element) => self.push_element(element),
98            }
99        }
100    }
101
102    fn push_element(&mut self, element: &NodeElement) {
103        let NodeElement {
104            open_tag,
105            children,
106            close_tag,
107        } = element;
108
109        let open_ident = self.push_open_tag(open_tag);
110        self.push_nodes(&children);
111        self.in_disabled_raw = false;
112        self.push_close_tag(close_tag.as_ref(), &open_ident);
113    }
114
115    fn push_open_tag(&mut self, open_tag: &OpenTag) -> Ident {
116        let OpenTag {
117            token_lt,
118            name,
119            generics,
120            attributes,
121            end_tag,
122        } = open_tag;
123
124        if generics.lt_token.is_some() {
125            abort!(generics.lt_token.span(), "Tag must not have generics");
126        }
127
128        self.push_str("<", token_lt.span());
129
130        let open_value_ident = match name {
131            NodeName::Path(path) => {
132                let name = get_path_ident(path);
133                self.push_str(&name.to_string(), name.span())
134            }
135            NodeName::Punctuated(punct) => {
136                // custom-elements
137                let name = get_punct_hypertext(punct);
138                self.push_str(&name, punct.span())
139            }
140            NodeName::Block(block) => self.push_as_hypertext(block.to_token_stream()),
141        };
142
143        for attribute in attributes {
144            self.push_str(" ", attribute.span());
145            match attribute {
146                NodeAttribute::Block(block) => {
147                    self.push_as_hypertext(block.to_token_stream());
148                }
149                NodeAttribute::Attribute(keyed) => {
150                    let KeyedAttribute {
151                        key,
152                        possible_value,
153                    } = keyed;
154
155                    match key {
156                        NodeName::Path(path) => {
157                            let name = get_path_ident(path);
158                            if name == "_hr_no_raw" {
159                                self.in_disabled_raw = true;
160                            }
161                            self.push_str(&name.to_string(), key.span());
162                        }
163                        NodeName::Punctuated(punct) => {
164                            // data-attributes
165                            let name = get_punct_hypertext(punct);
166                            self.push_str(&name, punct.span());
167                        }
168                        NodeName::Block(block) => {
169                            self.push_as_hypertext(block.to_token_stream());
170                        }
171                    };
172                    // SAFETY always pushed to in previous match, pop is done for IntoAttrText
173                    let key_ident = unsafe { self.idents.pop().unwrap_unchecked() };
174
175                    match possible_value {
176                        KeyedAttributeValue::Binding(binding) => {
177                            abort!(
178                                binding.span(),
179                                "I have no idea what this is open an issue if you see it"
180                            )
181                        }
182                        KeyedAttributeValue::Value(expr) => {
183                            let hyperide = &self.hyperide;
184                            let value = &expr.value;
185                            self.push_as_hypertext(quote_spanned! {expr.span()=>
186                                #hyperide::IntoAttrText::into_attr_text(#value, #key_ident)
187                            });
188                        }
189                        KeyedAttributeValue::None => {
190                            self.push_ref(&key_ident);
191                        }
192                    }
193                }
194            }
195        }
196
197        self.push_str(">", end_tag.span());
198
199        open_value_ident
200    }
201
202    fn push_close_tag(&mut self, close_tag: Option<&CloseTag>, open_ident: &Ident) {
203        if let Some(close_tag) = close_tag {
204            let CloseTag {
205                start_tag,
206                name,
207                generics,
208                token_gt,
209            } = close_tag;
210
211            if generics.lt_token.is_some() {
212                abort!(generics.lt_token.span(), "Tag must not have generics");
213            }
214
215            self.push_str("</", start_tag.span());
216
217            if name.is_wildcard() {
218                self.push_ref(open_ident);
219            } else {
220                match name {
221                    NodeName::Path(path) => {
222                        let name = get_path_ident(path);
223                        self.push_str(&name.to_string(), name.span());
224                    }
225                    NodeName::Punctuated(punct) => {
226                        let name = get_punct_hypertext(punct);
227                        self.push_str(&name.to_string(), punct.span());
228                    }
229                    NodeName::Block(block) => {
230                        self.push_as_hypertext(block.to_token_stream());
231                    }
232                }
233            }
234
235            self.push_str(">", token_gt.span());
236        }
237    }
238}
239
240fn make_ident(span: Span) -> Ident {
241    Ident::new(
242        &format!("__hyperide_internal_{}", Uuid::new_v4().simple()),
243        span,
244    )
245}
246
247fn get_path_ident(path: &ExprPath) -> &Ident {
248    if !path.attrs.is_empty() {
249        abort!(path.span(), "Expected ident, found attribute");
250    }
251    match path.path.get_ident() {
252        Some(ident) => ident,
253        None => abort!(path.span(), "Expected ident, found path"),
254    }
255}
256
257fn get_punct_hypertext<T>(punct: &Punctuated<impl Display, T>) -> String {
258    let mut name = String::new();
259    for term in punct.pairs() {
260        match term {
261            Pair::Punctuated(term, _punct) => {
262                name.push_str(&format!("{term}"));
263                name.push('-'); // other puncts are invalid in tags and attrs
264            }
265            Pair::End(term) => {
266                name.push_str(&format!("{term}"));
267            }
268        }
269    }
270    name
271}
272
273/// Converts a HTML like syntax into a string.
274///
275/// ```rust
276/// use hyperide::hyperide;
277/// fn returns_tag() -> char {
278///     'p'
279/// }
280/// fn my_component(a: &str, b: &str) -> String {
281///     hyperide! {
282///         <p><strong>{a}{": "}</strong>{b}</p>
283///     }
284/// }
285/// let my_str = hyperide! {
286///     <!DOCTYPE html>
287///     <html lang="en">
288///     <head>
289///         <meta charset="utf-8" />
290///     </head>
291///     <body>
292///         <h1>{"Hello, world!"}</h1>
293///         <{returns_tag()}>This is in a closed paragraph.</_>
294///         <!-- "wildcard close tag ⬆️" -->
295///         {my_component("Foo", "bar")}
296///     </body>
297///     </html>
298/// };
299/// ```
300///
301/// Will generate:
302///
303/// ```html
304/// <!DOCTYPE html>
305/// <html lang="en">
306///   <head>
307///     <meta charset="utf-8" />
308///   </head>
309///   <body>
310///     <h1>Hello, world!</h1>
311///     <p>This is in a closed paragraph.</p>
312///     <!-- "wildcard close tag ⬆️" -->
313///     <p><strong>Foo: </strong>bar</p>
314///   </body>
315/// </html>
316/// ```
317#[proc_macro_error::proc_macro_error]
318#[proc_macro]
319pub fn hyperide(tokens: TokenStream) -> TokenStream {
320    let Ok(hyperide) = crate_name("hyperide") else {
321        abort!(proc_macro2::TokenStream::from(tokens), "hyperide crate must be available")
322    };
323    let hyperide = match hyperide {
324        FoundCrate::Itself => quote! { ::hyperide },
325        FoundCrate::Name(name) => {
326            let ident = Ident::new(&name, Span::call_site());
327            quote! { ::#ident }
328        }
329    };
330
331    let config = ParserConfig::new()
332        .recover_block(true)
333        // https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
334        .always_self_closed_elements(
335            [
336                "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
337                "param", "source", "track", "wbr",
338            ]
339            .into_iter()
340            .collect(),
341        )
342        .raw_text_elements(["script", "style"].into_iter().collect())
343        .element_close_wildcard(|_, close_tag| close_tag.name.is_wildcard());
344
345    let parser = Parser::new(config);
346    let (nodes, errors) = parser.parse_recoverable(tokens).split_vec();
347
348    let mut walker = HyperideGenerator::new(hyperide);
349    walker.push_nodes(&nodes);
350
351    let idents = walker.idents;
352    let bindings = walker.bindings;
353
354    let errors = errors.into_iter().map(|e| e.emit_as_expr_tokens());
355    let alloc_size = make_ident(Span::call_site());
356    let string_out = make_ident(Span::call_site());
357    let out = quote! {{
358        #(#errors;)*
359        #bindings
360        let #alloc_size = #(
361            std::ops::Deref::deref(&#idents).len()
362        )+*;
363        let mut #string_out = String::with_capacity(#alloc_size);
364        #(
365            #string_out.push_str(std::ops::Deref::deref(&#idents));
366        )*
367        #string_out
368    }};
369
370    out.into()
371}