Repository: lambda-fairy/maud
Pull request #412 (2024-01-07 20:33:01)

feat: modernize parser
parser rewritten from the ground-up using `syn` idioms and more strongly-typed `syn` structures rather than `TokenStream`s. passes all test cases flawlessly, and makes the code much easier to understand.

also drops `proc-macro-error` for a more robust solution (`proc-macro2-diagnostics`) which doesn't need global state and macros.

all of the edited trybuild test-cases are either adding more information/cases or fixing some bugs.

vidhanio: > Thanks @vidhanio!
> 
> 
> 
> I remember playing with the `syn` parser and getting stuck on diagnostics. So I'm glad that you found a solution for that.
> 
> 
> 
> I'm really busy at the moment so apologies for the delay. I'll try to have a look some time this weekend.

no problem!

i've since changed a bit and repackaged it into my own library, [hypertext](vidhanio/hypertext). I could port the changes backwards to this if needed! 😁
yonas: Is this ready for merge?
lambda-fairy: There's some edge cases around integer literals that I haven't sorted out yet. Let me take a look.

Patch:
diff --git a/docs/Cargo.lock b/docs/Cargo.lock
index 1f7fce2..d341555 100644
--- a/docs/Cargo.lock
+++ b/docs/Cargo.lock
@@ -298,8 +298,8 @@ dependencies = [
 name = "maud_macros"
 version = "0.26.0"
 dependencies = [
- "proc-macro-error",
  "proc-macro2",
+ "proc-macro2-diagnostics",
  "quote",
  "syn",
 ]
@@ -379,37 +379,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
 
 [[package]]
-name = "proc-macro-error"
-version = "1.0.4"
+name = "proc-macro2"
+version = "1.0.86"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
 dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "version_check",
+ "unicode-ident",
 ]
 
 [[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
 dependencies = [
  "proc-macro2",
  "quote",
+ "syn",
  "version_check",
 ]
 
-[[package]]
-name = "proc-macro2"
-version = "1.0.86"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
-dependencies = [
- "unicode-ident",
-]
-
 [[package]]
 name = "quick-xml"
 version = "0.32.0"
diff --git a/maud_macros/Cargo.toml b/maud_macros/Cargo.toml
index 1a938be..a736d52 100644
--- a/maud_macros/Cargo.toml
+++ b/maud_macros/Cargo.toml
@@ -12,10 +12,10 @@ repository.workspace = true
 edition.workspace = true
 
 [dependencies]
-syn = "2"
+syn = { version = "2", features = ["extra-traits", "full"] }
 quote = "1.0.7"
 proc-macro2 = "1.0.23"
-proc-macro-error = { version = "1.0.0", default-features = false }
+proc-macro2-diagnostics = { version = "0.10", default-features = false }
 
 [lib]
 name = "maud_macros"
diff --git a/maud_macros/src/ast.rs b/maud_macros/src/ast.rs
index b95665e..1890e07 100644
--- a/maud_macros/src/ast.rs
+++ b/maud_macros/src/ast.rs
@@ -1,233 +1,1043 @@
-use proc_macro2::{TokenStream, TokenTree};
-use proc_macro_error::SpanRange;
-use syn::Lit;
+use std::fmt::{self, Display, Formatter};
 
-#[derive(Debug)]
+use proc_macro2::TokenStream;
+use proc_macro2_diagnostics::{Diagnostic, SpanDiagnosticExt};
+use quote::ToTokens;
+use syn::{
+    braced, bracketed,
+    ext::IdentExt,
+    parenthesized,
+    parse::{Parse, ParseStream},
+    punctuated::{Pair, Punctuated},
+    spanned::Spanned,
+    token::{Brace, Bracket, Paren},
+    Error, Expr, Ident, Lit, LitBool, LitInt, LitStr, Local, Pat, Stmt, Token,
+};
+
+#[derive(Debug, Clone)]
+pub struct Markups {
+    pub markups: Vec<Markup>,
+}
+
+impl DiagnosticParse for Markups {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let mut markups = Vec::new();
+        while !input.is_empty() {
+            markups.push(Markup::diagnostic_parse_in_block(input, diagnostics)?)
+        }
+        Ok(Self { markups })
+    }
+}
+
+impl ToTokens for Markups {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        for markup in &self.markups {
+            markup.to_tokens(tokens);
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
 pub enum Markup {
-    /// Used as a placeholder value on parse error.
-    ParseError {
-        span: SpanRange,
-    },
     Block(Block),
-    Literal {
-        content: String,
-        span: SpanRange,
-    },
-    Symbol {
-        symbol: TokenStream,
-    },
-    Splice {
-        expr: TokenStream,
-        outer_span: SpanRange,
-    },
-    Element {
-        name: TokenStream,
-        attrs: Vec<Attr>,
-        body: ElementBody,
-    },
-    Let {
-        at_span: SpanRange,
-        tokens: TokenStream,
-    },
-    Special {
-        segments: Vec<Special>,
-    },
-    Match {
-        at_span: SpanRange,
-        head: TokenStream,
-        arms: Vec<MatchArm>,
-        arms_span: SpanRange,
-    },
+    Lit(HtmlLit),
+    Splice { paren_token: Paren, expr: Expr },
+    Element(Element),
+    ControlFlow(ControlFlow),
+    Semi(Token![;]),
 }
 
 impl Markup {
-    pub fn span(&self) -> SpanRange {
-        match *self {
-            Markup::ParseError { span } => span,
-            Markup::Block(ref block) => block.span(),
-            Markup::Literal { span, .. } => span,
-            Markup::Symbol { ref symbol } => span_tokens(symbol.clone()),
-            Markup::Splice { outer_span, .. } => outer_span,
-            Markup::Element {
-                ref name, ref body, ..
-            } => {
-                let name_span = span_tokens(name.clone());
-                name_span.join_range(body.span())
+    pub fn diagnostic_parse_in_block(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        if input.peek(Token![let])
+            || input.peek(Token![if])
+            || input.peek(Token![for])
+            || input.peek(Token![while])
+            || input.peek(Token![match])
+        {
+            let kw = input.call(Ident::parse_any)?;
+            diagnostics.push(
+                kw.span()
+                    .error(format!("found keyword `{kw}`"))
+                    .help(format!("should this be a `@{kw}`?")),
+            );
+        }
+
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Brace) {
+            input.diagnostic_parse(diagnostics).map(Self::Block)
+        } else if lookahead.peek(Lit) {
+            input.diagnostic_parse(diagnostics).map(Self::Lit)
+        } else if lookahead.peek(Paren) {
+            let content;
+            Ok(Self::Splice {
+                paren_token: parenthesized!(content in input),
+                expr: content.parse()?,
+            })
+        } else if lookahead.peek(Ident::peek_any)
+            || lookahead.peek(Token![.])
+            || lookahead.peek(Token![#])
+        {
+            input.diagnostic_parse(diagnostics).map(Self::Element)
+        } else if lookahead.peek(Token![@]) {
+            input.diagnostic_parse(diagnostics).map(Self::ControlFlow)
+        } else if lookahead.peek(Token![;]) {
+            input.parse().map(Self::Semi)
+        } else {
+            Err(lookahead.error())
+        }
+    }
+}
+
+impl DiagnosticParse for Markup {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let markup = Self::diagnostic_parse_in_block(input, diagnostics)?;
+
+        if let Self::ControlFlow(ControlFlow {
+            kind: ControlFlowKind::Let(_),
+            ..
+        }) = &markup
+        {
+            diagnostics.push(
+                markup
+                    .span()
+                    .error("`@let` bindings are only allowed inside blocks"),
+            )
+        }
+
+        Ok(markup)
+    }
+}
+
+impl ToTokens for Markup {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Block(block) => block.to_tokens(tokens),
+            Self::Lit(lit) => lit.to_tokens(tokens),
+            Self::Splice { paren_token, expr } => {
+                paren_token.surround(tokens, |tokens| {
+                    expr.to_tokens(tokens);
+                });
             }
-            Markup::Let {
-                at_span,
-                ref tokens,
-            } => at_span.join_range(span_tokens(tokens.clone())),
-            Markup::Special { ref segments } => join_ranges(segments.iter().map(Special::span)),
-            Markup::Match {
-                at_span, arms_span, ..
-            } => at_span.join_range(arms_span),
+            Self::Element(element) => element.to_tokens(tokens),
+            Self::ControlFlow(control_flow) => control_flow.to_tokens(tokens),
+            Self::Semi(semi) => semi.to_tokens(tokens),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct Element {
+    pub name: Option<HtmlName>,
+    pub attrs: Vec<Attribute>,
+    pub body: ElementBody,
+}
+
+impl DiagnosticParse for Element {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            name: if input.peek(Ident::peek_any) {
+                Some(input.diagnostic_parse(diagnostics)?)
+            } else {
+                None
+            },
+            attrs: {
+                let mut id_pushed = false;
+                let mut attrs = Vec::new();
+
+                while input.peek(Ident::peek_any)
+                    || input.peek(Lit)
+                    || input.peek(Token![.])
+                    || input.peek(Token![#])
+                {
+                    let attr = input.diagnostic_parse(diagnostics)?;
+
+                    if let Attribute::Id { .. } = attr {
+                        if id_pushed {
+                            return Err(Error::new_spanned(
+                                attr,
+                                "duplicate id (`#`) attribute specified",
+                            ));
+                        }
+                        id_pushed = true;
+                    }
+
+                    attrs.push(attr);
+                }
+
+                if !(input.peek(Brace) || input.peek(Token![;]) || input.peek(Token![/])) {
+                    let lookahead = input.lookahead1();
+
+                    lookahead.peek(Ident::peek_any);
+                    lookahead.peek(Lit);
+                    lookahead.peek(Token![.]);
+                    lookahead.peek(Token![#]);
+
+                    lookahead.peek(Brace);
+                    lookahead.peek(Token![;]);
+
+                    return Err(lookahead.error());
+                }
+
+                attrs
+            },
+            body: input.diagnostic_parse(diagnostics)?,
+        })
+    }
+}
+
+impl ToTokens for Element {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        if let Some(name) = &self.name {
+            name.to_tokens(tokens);
+        }
+        for attr in &self.attrs {
+            attr.to_tokens(tokens);
+        }
+        self.body.to_tokens(tokens);
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum ElementBody {
+    Void(Token![;]),
+    Block(Block),
+}
+
+impl DiagnosticParse for ElementBody {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Token![;]) {
+            input.parse().map(Self::Void)
+        } else if lookahead.peek(Brace) {
+            input.diagnostic_parse(diagnostics).map(Self::Block)
+        } else if lookahead.peek(Token![/]) {
+            diagnostics.push(
+                input
+                    .parse::<Token![/]>()?
+                    .span()
+                    .error("void elements must use `;`, not `/`")
+                    .help("change this to `;`")
+                    .help("see https://github.com/lambda-fairy/maud/pull/315 for details"),
+            );
+
+            Ok(Self::Void(<Token![;]>::default()))
+        } else {
+            Err(lookahead.error())
+        }
+    }
+}
+
+impl ToTokens for ElementBody {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Void(semi) => semi.to_tokens(tokens),
+            Self::Block(block) => block.to_tokens(tokens),
         }
     }
 }
 
-#[derive(Debug)]
-pub enum Attr {
+#[derive(Debug, Clone)]
+pub struct Block {
+    pub brace_token: Brace,
+    pub markups: Markups,
+}
+
+impl DiagnosticParse for Block {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let content;
+        Ok(Self {
+            brace_token: braced!(content in input),
+            markups: content.diagnostic_parse(diagnostics)?,
+        })
+    }
+}
+
+impl ToTokens for Block {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.brace_token.surround(tokens, |tokens| {
+            self.markups.to_tokens(tokens);
+        });
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum Attribute {
     Class {
-        dot_span: SpanRange,
-        name: Markup,
+        dot_token: Token![.],
+        name: AttributeName,
         toggler: Option<Toggler>,
     },
     Id {
-        hash_span: SpanRange,
-        name: Markup,
+        pound_token: Token![#],
+        name: AttributeName,
     },
     Named {
-        named_attr: NamedAttr,
+        name: AttributeName,
+        attr_type: AttributeType,
     },
 }
 
-impl Attr {
-    pub fn span(&self) -> SpanRange {
-        match *self {
-            Attr::Class {
-                dot_span,
-                ref name,
-                ref toggler,
+impl DiagnosticParse for Attribute {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Token![.]) {
+            Ok(Self::Class {
+                dot_token: input.parse()?,
+                name: input.diagnostic_parse(diagnostics)?,
+                toggler: {
+                    let lookahead = input.lookahead1();
+
+                    if lookahead.peek(Bracket) {
+                        Some(input.diagnostic_parse(diagnostics)?)
+                    } else {
+                        None
+                    }
+                },
+            })
+        } else if lookahead.peek(Token![#]) {
+            Ok(Self::Id {
+                pound_token: input.parse()?,
+                name: input.diagnostic_parse(diagnostics)?,
+            })
+        } else {
+            let name = input.diagnostic_parse::<AttributeName>(diagnostics)?;
+            let name_display = name.to_string();
+            let fork = input.fork();
+
+            let attr = Self::Named {
+                name: name.clone(),
+                attr_type: input.diagnostic_parse(diagnostics)?,
+            };
+
+            if fork.peek(Token![=]) && fork.peek2(LitBool) {
+                diagnostics.push(
+                    attr.span()
+                        .error("attribute value must be a string")
+                        .help(format!("to declare an empty attribute, omit the equals sign: `{name_display}`"))
+                        .help(format!("to toggle the attribute, use square brackets: `{name_display}[some_boolean_flag]`"))
+                );
+            }
+
+            Ok(attr)
+        }
+    }
+}
+
+impl ToTokens for Attribute {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Class {
+                dot_token,
+                name,
+                toggler,
             } => {
-                let name_span = name.span();
-                let dot_name_span = dot_span.join_range(name_span);
+                dot_token.to_tokens(tokens);
+                name.to_tokens(tokens);
                 if let Some(toggler) = toggler {
-                    dot_name_span.join_range(toggler.cond_span)
-                } else {
-                    dot_name_span
+                    toggler.to_tokens(tokens);
                 }
             }
-            Attr::Id {
-                hash_span,
-                ref name,
-            } => {
-                let name_span = name.span();
-                hash_span.join_range(name_span)
+            Self::Id { pound_token, name } => {
+                pound_token.to_tokens(tokens);
+                name.to_tokens(tokens);
+            }
+            Self::Named { name, attr_type } => {
+                name.to_tokens(tokens);
+                attr_type.to_tokens(tokens);
             }
-            Attr::Named { ref named_attr } => named_attr.span(),
         }
     }
 }
 
-#[derive(Debug)]
-pub enum ElementBody {
-    Void { semi_span: SpanRange },
-    Block { block: Block },
+#[derive(Debug, Clone)]
+pub enum AttributeName {
+    Normal(HtmlName),
+    Markup(Markup),
 }
 
-impl ElementBody {
-    pub fn span(&self) -> SpanRange {
-        match *self {
-            ElementBody::Void { semi_span } => semi_span,
-            ElementBody::Block { ref block } => block.span(),
+impl DiagnosticParse for AttributeName {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let name = if input.peek(Ident::peek_any) || input.peek(Lit) {
+            input.diagnostic_parse(diagnostics).map(Self::Normal)
+        } else {
+            input.diagnostic_parse(diagnostics).map(Self::Markup)
+        };
+
+        if input.peek(Token![?]) {
+            input.parse::<Token![?]>()?;
         }
+
+        name
     }
 }
 
-#[derive(Debug)]
-pub struct Block {
-    pub markups: Vec<Markup>,
-    pub outer_span: SpanRange,
+impl Parse for AttributeName {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        Self::diagnostic_parse(input, &mut Vec::new())
+    }
 }
 
-impl Block {
-    pub fn span(&self) -> SpanRange {
-        self.outer_span
+impl ToTokens for AttributeName {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Normal(name) => name.to_tokens(tokens),
+            Self::Markup(markup) => markup.to_tokens(tokens),
+        }
     }
 }
 
-#[derive(Debug)]
-pub struct Special {
-    pub at_span: SpanRange,
-    pub head: TokenStream,
-    pub body: Block,
+impl Display for AttributeName {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Normal(name) => name.fmt(f),
+            Self::Markup(markup) => markup.to_token_stream().fmt(f),
+        }
+    }
 }
 
-impl Special {
-    pub fn span(&self) -> SpanRange {
-        let body_span = self.body.span();
-        self.at_span.join_range(body_span)
+#[derive(Debug, Clone)]
+pub enum AttributeType {
+    Normal {
+        eq_token: Token![=],
+        value: Markup,
+    },
+    Optional {
+        eq_token: Token![=],
+        toggler: Toggler,
+    },
+    Empty(Option<Toggler>),
+}
+
+impl DiagnosticParse for AttributeType {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Token![=]) {
+            let eq_token = input.parse()?;
+
+            if input.peek(Bracket) {
+                Ok(Self::Optional {
+                    eq_token,
+                    toggler: input.diagnostic_parse(diagnostics)?,
+                })
+            } else {
+                Ok(Self::Normal {
+                    eq_token,
+                    value: input.diagnostic_parse(diagnostics)?,
+                })
+            }
+        } else if lookahead.peek(Bracket) {
+            Ok(Self::Empty(Some(input.diagnostic_parse(diagnostics)?)))
+        } else {
+            Ok(Self::Empty(None))
+        }
+    }
+}
+
+impl ToTokens for AttributeType {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Normal { eq_token, value } => {
+                eq_token.to_tokens(tokens);
+                value.to_tokens(tokens);
+            }
+            Self::Optional { eq_token, toggler } => {
+                eq_token.to_tokens(tokens);
+                toggler.to_tokens(tokens);
+            }
+            Self::Empty(toggler) => {
+                if let Some(toggler) = toggler {
+                    toggler.to_tokens(tokens);
+                }
+            }
+        }
     }
 }
 
-#[derive(Debug)]
-pub struct NamedAttr {
-    pub name: TokenStream,
-    pub attr_type: AttrType,
+#[derive(Debug, Clone)]
+pub struct HtmlName {
+    pub name: Punctuated<HtmlNameFragment, HtmlNamePunct>,
 }
 
-impl NamedAttr {
-    fn span(&self) -> SpanRange {
-        let name_span = span_tokens(self.name.clone());
-        if let Some(attr_type_span) = self.attr_type.span() {
-            name_span.join_range(attr_type_span)
+impl DiagnosticParse for HtmlName {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            name: {
+                let mut punctuated = Punctuated::new();
+
+                loop {
+                    punctuated.push_value(input.diagnostic_parse(diagnostics)?);
+
+                    if !(input.peek(Token![-]) || input.peek(Token![:])) {
+                        break;
+                    }
+
+                    let punct = input.diagnostic_parse(diagnostics)?;
+                    punctuated.push_punct(punct);
+                }
+
+                punctuated
+            },
+        })
+    }
+}
+
+impl Parse for HtmlName {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        Self::diagnostic_parse(input, &mut Vec::new())
+    }
+}
+
+impl ToTokens for HtmlName {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.name.to_tokens(tokens);
+    }
+}
+
+impl Display for HtmlName {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        for pair in self.name.pairs() {
+            match pair {
+                Pair::Punctuated(fragment, punct) => {
+                    fragment.fmt(f)?;
+                    punct.fmt(f)?;
+                }
+                Pair::End(fragment) => {
+                    fragment.fmt(f)?;
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum HtmlNameFragment {
+    Ident(Ident),
+    Lit(HtmlLit),
+    Empty,
+}
+
+impl DiagnosticParse for HtmlNameFragment {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Ident::peek_any) {
+            input.call(Ident::parse_any).map(Self::Ident)
+        } else if lookahead.peek(Lit) {
+            input.diagnostic_parse(diagnostics).map(Self::Lit)
+        } else if lookahead.peek(Token![-]) || lookahead.peek(Token![:]) {
+            Ok(Self::Empty)
+        } else {
+            Err(lookahead.error())
+        }
+    }
+}
+
+impl ToTokens for HtmlNameFragment {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Ident(ident) => ident.to_tokens(tokens),
+            Self::Lit(lit) => lit.to_tokens(tokens),
+            Self::Empty => {}
+        }
+    }
+}
+
+impl Display for HtmlNameFragment {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Ident(ident) => ident.fmt(f),
+            Self::Lit(lit) => lit.fmt(f),
+            Self::Empty => Ok(()),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum HtmlLit {
+    Str(LitStr),
+    Int(LitInt),
+}
+
+impl DiagnosticParse for HtmlLit {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Lit) {
+            let lit = input.parse()?;
+            match lit {
+                Lit::Str(lit) => Ok(Self::Str(lit)),
+                Lit::Int(lit) => Ok(Self::Int(lit)),
+                Lit::Float(lit) => {
+                    diagnostics.push(
+                        lit.span()
+                            .error(format!(r#"literal must be double-quoted: `"{lit}"`"#)),
+                    );
+                    Ok(Self::Str(LitStr::new("", lit.span())))
+                }
+                Lit::Char(lit) => {
+                    diagnostics.push(lit.span().error(format!(
+                        r#"literal must be double-quoted: `"{}"`"#,
+                        lit.value()
+                    )));
+                    Ok(Self::Str(LitStr::new("", lit.span())))
+                }
+                Lit::Bool(_) => {
+                    // diagnostic handled earlier with more information
+                    Ok(Self::Str(LitStr::new("", lit.span())))
+                }
+                _ => {
+                    diagnostics.push(lit.span().error("expected string"));
+                    Ok(Self::Str(LitStr::new("", lit.span())))
+                }
+            }
         } else {
-            name_span
+            Err(lookahead.error())
         }
     }
 }
 
-#[derive(Debug)]
-pub enum AttrType {
-    Normal { value: Markup },
-    Optional { toggler: Toggler },
-    Empty { toggler: Option<Toggler> },
+impl ToTokens for HtmlLit {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Str(lit) => lit.to_tokens(tokens),
+            Self::Int(lit) => lit.to_tokens(tokens),
+        }
+    }
 }
 
-impl AttrType {
-    fn span(&self) -> Option<SpanRange> {
-        match *self {
-            AttrType::Normal { ref value } => Some(value.span()),
-            AttrType::Optional { ref toggler } => Some(toggler.span()),
-            AttrType::Empty { ref toggler } => toggler.as_ref().map(Toggler::span),
+impl Display for HtmlLit {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Str(lit) => lit.value().fmt(f),
+            Self::Int(lit) => lit.fmt(f),
         }
     }
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
+pub enum HtmlNamePunct {
+    Colon(Token![:]),
+    Hyphen(Token![-]),
+}
+
+impl DiagnosticParse for HtmlNamePunct {
+    fn diagnostic_parse(input: ParseStream, _: &mut Vec<Diagnostic>) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Token![:]) {
+            input.parse().map(Self::Colon)
+        } else if lookahead.peek(Token![-]) {
+            input.parse().map(Self::Hyphen)
+        } else {
+            Err(lookahead.error())
+        }
+    }
+}
+
+impl ToTokens for HtmlNamePunct {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::Colon(token) => token.to_tokens(tokens),
+            Self::Hyphen(token) => token.to_tokens(tokens),
+        }
+    }
+}
+
+impl Display for HtmlNamePunct {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        match self {
+            Self::Colon(_) => f.write_str(":"),
+            Self::Hyphen(_) => f.write_str("-"),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
 pub struct Toggler {
-    pub cond: TokenStream,
-    pub cond_span: SpanRange,
+    pub bracket_token: Bracket,
+    pub cond: Expr,
 }
 
-impl Toggler {
-    fn span(&self) -> SpanRange {
-        self.cond_span
+impl DiagnosticParse for Toggler {
+    fn diagnostic_parse(input: ParseStream, _: &mut Vec<Diagnostic>) -> syn::Result<Self> {
+        let content;
+        Ok(Self {
+            bracket_token: bracketed!(content in input),
+            cond: content.parse()?,
+        })
     }
 }
 
-#[derive(Debug)]
-pub struct MatchArm {
-    pub head: TokenStream,
+impl ToTokens for Toggler {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.bracket_token.surround(tokens, |tokens| {
+            self.cond.to_tokens(tokens);
+        });
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ControlFlow {
+    pub at_token: Token![@],
+    pub kind: ControlFlowKind,
+}
+
+impl DiagnosticParse for ControlFlow {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            at_token: input.parse()?,
+            kind: {
+                let lookahead = input.lookahead1();
+
+                if lookahead.peek(Token![if]) {
+                    ControlFlowKind::If(input.diagnostic_parse(diagnostics)?)
+                } else if lookahead.peek(Token![for]) {
+                    ControlFlowKind::For(input.diagnostic_parse(diagnostics)?)
+                } else if lookahead.peek(Token![while]) {
+                    ControlFlowKind::While(input.diagnostic_parse(diagnostics)?)
+                } else if lookahead.peek(Token![match]) {
+                    ControlFlowKind::Match(input.diagnostic_parse(diagnostics)?)
+                } else if lookahead.peek(Token![let]) {
+                    let Stmt::Local(local) = input.parse()? else {
+                        unreachable!()
+                    };
+
+                    ControlFlowKind::Let(local)
+                } else {
+                    return Err(lookahead.error());
+                }
+            },
+        })
+    }
+}
+
+impl ToTokens for ControlFlow {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.at_token.to_tokens(tokens);
+        match &self.kind {
+            ControlFlowKind::Let(local) => local.to_tokens(tokens),
+            ControlFlowKind::If(if_) => if_.to_tokens(tokens),
+            ControlFlowKind::For(for_) => for_.to_tokens(tokens),
+            ControlFlowKind::While(while_) => while_.to_tokens(tokens),
+            ControlFlowKind::Match(match_) => match_.to_tokens(tokens),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum ControlFlowKind {
+    Let(Local),
+    If(If),
+    For(For),
+    While(While),
+    Match(Match),
+}
+
+#[derive(Debug, Clone)]
+pub struct If {
+    pub if_token: Token![if],
+    pub cond: Expr,
+    pub then_branch: Block,
+    pub else_branch: Option<(Token![@], Token![else], Box<IfOrBlock>)>,
+}
+
+impl DiagnosticParse for If {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            if_token: input.parse()?,
+            cond: input.call(Expr::parse_without_eager_brace)?,
+            then_branch: input.diagnostic_parse(diagnostics)?,
+            else_branch: {
+                if input.peek(Token![@]) && input.peek2(Token![else]) {
+                    Some((
+                        input.parse()?,
+                        input.parse()?,
+                        input.diagnostic_parse(diagnostics)?,
+                    ))
+                } else {
+                    None
+                }
+            },
+        })
+    }
+}
+
+impl ToTokens for If {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.if_token.to_tokens(tokens);
+        self.cond.to_tokens(tokens);
+        self.then_branch.to_tokens(tokens);
+        if let Some((at_token, else_token, else_branch)) = &self.else_branch {
+            at_token.to_tokens(tokens);
+            else_token.to_tokens(tokens);
+            else_branch.to_tokens(tokens);
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub enum IfOrBlock {
+    If(If),
+    Block(Block),
+}
+
+impl DiagnosticParse for IfOrBlock {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let lookahead = input.lookahead1();
+
+        if lookahead.peek(Token![if]) {
+            input.diagnostic_parse(diagnostics).map(Self::If)
+        } else if lookahead.peek(Brace) {
+            input.diagnostic_parse(diagnostics).map(Self::Block)
+        } else {
+            Err(lookahead.error())
+        }
+    }
+}
+
+impl ToTokens for IfOrBlock {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        match self {
+            Self::If(if_) => if_.to_tokens(tokens),
+            Self::Block(block) => block.to_tokens(tokens),
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct For {
+    pub for_token: Token![for],
+    pub pat: Pat,
+    pub in_token: Token![in],
+    pub expr: Expr,
+    pub body: Block,
+}
+
+impl DiagnosticParse for For {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            for_token: input.parse()?,
+            pat: input.call(Pat::parse_multi_with_leading_vert)?,
+            in_token: input.parse()?,
+            expr: input.call(Expr::parse_without_eager_brace)?,
+            body: input.diagnostic_parse(diagnostics)?,
+        })
+    }
+}
+
+impl ToTokens for For {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.for_token.to_tokens(tokens);
+        self.pat.to_tokens(tokens);
+        self.in_token.to_tokens(tokens);
+        self.expr.to_tokens(tokens);
+        self.body.to_tokens(tokens);
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct While {
+    pub while_token: Token![while],
+    pub cond: Expr,
     pub body: Block,
 }
 
-pub fn span_tokens<I: IntoIterator<Item = TokenTree>>(tokens: I) -> SpanRange {
-    join_ranges(tokens.into_iter().map(|s| SpanRange::single_span(s.span())))
+impl DiagnosticParse for While {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            while_token: input.parse()?,
+            cond: input.call(Expr::parse_without_eager_brace)?,
+            body: input.diagnostic_parse(diagnostics)?,
+        })
+    }
 }
 
-pub fn join_ranges<I: IntoIterator<Item = SpanRange>>(ranges: I) -> SpanRange {
-    let mut iter = ranges.into_iter();
-    let first = match iter.next() {
-        Some(span) => span,
-        None => return SpanRange::call_site(),
-    };
-    let last = iter.last().unwrap_or(first);
-    first.join_range(last)
+impl ToTokens for While {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.while_token.to_tokens(tokens);
+        self.cond.to_tokens(tokens);
+        self.body.to_tokens(tokens);
+    }
 }
 
-pub fn name_to_string(name: TokenStream) -> String {
-    name.into_iter()
-        .map(|token| {
-            if let TokenTree::Literal(literal) = token {
-                match Lit::new(literal.clone()) {
-                    Lit::Str(str) => str.value(),
-                    _ => literal.to_string(),
+#[derive(Debug, Clone)]
+pub struct Match {
+    pub match_token: Token![match],
+    pub expr: Expr,
+    pub brace_token: Brace,
+    pub arms: Vec<MatchArm>,
+}
+
+impl DiagnosticParse for Match {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        let match_token = input.parse()?;
+        let expr = input.call(Expr::parse_without_eager_brace)?;
+
+        let content;
+        let brace_token = braced!(content in input);
+
+        let mut arms = Vec::new();
+        while !content.is_empty() {
+            arms.push(content.diagnostic_parse(diagnostics)?);
+        }
+
+        Ok(Self {
+            match_token,
+            expr,
+            brace_token,
+            arms,
+        })
+    }
+}
+
+impl ToTokens for Match {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.match_token.to_tokens(tokens);
+        self.expr.to_tokens(tokens);
+        self.brace_token.surround(tokens, |tokens| {
+            for arm in &self.arms {
+                arm.to_tokens(tokens);
+            }
+        });
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct MatchArm {
+    pub pat: Pat,
+    pub guard: Option<(Token![if], Expr)>,
+    pub fat_arrow_token: Token![=>],
+    pub body: Markup,
+    pub comma_token: Option<Token![,]>,
+}
+
+impl DiagnosticParse for MatchArm {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Self {
+            pat: Pat::parse_multi_with_leading_vert(input)?,
+            guard: {
+                if input.peek(Token![if]) {
+                    Some((input.parse()?, input.parse()?))
+                } else {
+                    None
                 }
+            },
+            fat_arrow_token: input.parse()?,
+            body: Markup::diagnostic_parse_in_block(input, diagnostics)?,
+            comma_token: if input.peek(Token![,]) {
+                Some(input.parse()?)
             } else {
-                token.to_string()
-            }
+                None
+            },
         })
-        .collect()
+    }
+}
+
+impl ToTokens for MatchArm {
+    fn to_tokens(&self, tokens: &mut TokenStream) {
+        self.pat.to_tokens(tokens);
+        if let Some((if_token, guard)) = &self.guard {
+            if_token.to_tokens(tokens);
+            guard.to_tokens(tokens);
+        }
+        self.fat_arrow_token.to_tokens(tokens);
+        self.body.to_tokens(tokens);
+        if let Some(comma_token) = &self.comma_token {
+            comma_token.to_tokens(tokens);
+        }
+    }
+}
+
+pub trait DiagnosticParse: Sized {
+    fn diagnostic_parse(input: ParseStream, diagnostics: &mut Vec<Diagnostic>)
+        -> syn::Result<Self>;
+}
+
+impl<T: DiagnosticParse> DiagnosticParse for Box<T> {
+    fn diagnostic_parse(
+        input: ParseStream,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<Self> {
+        Ok(Box::new(input.diagnostic_parse(diagnostics)?))
+    }
+}
+
+trait DiagonsticParseExt: Sized {
+    fn diagnostic_parse<T: DiagnosticParse>(
+        self,
+        diagnostics: &mut Vec<Diagnostic>,
+    ) -> syn::Result<T>;
+}
+
+impl DiagonsticParseExt for ParseStream<'_> {
+    fn diagnostic_parse<T>(self, diagnostics: &mut Vec<Diagnostic>) -> syn::Result<T>
+    where
+        T: DiagnosticParse,
+    {
+        T::diagnostic_parse(self, diagnostics)
+    }
 }
diff --git a/maud_macros/src/generate.rs b/maud_macros/src/generate.rs
index ee27a55..966c8cb 100644
--- a/maud_macros/src/generate.rs
+++ b/maud_macros/src/generate.rs
@@ -1,21 +1,21 @@
-use proc_macro2::{Delimiter, Group, Ident, Literal, Span, TokenStream, TokenTree};
-use proc_macro_error::SpanRange;
-use quote::quote;
+use proc_macro2::{Ident, Span, TokenStream};
+use quote::{quote, ToTokens};
+use syn::{parse_quote, token::Brace, Expr, Local};
 
 use crate::{ast::*, escape};
 
-pub fn generate(markups: Vec<Markup>, output_ident: TokenTree) -> TokenStream {
+pub fn generate(markups: Markups, output_ident: Ident) -> TokenStream {
     let mut build = Builder::new(output_ident.clone());
     Generator::new(output_ident).markups(markups, &mut build);
     build.finish()
 }
 
 struct Generator {
-    output_ident: TokenTree,
+    output_ident: Ident,
 }
 
 impl Generator {
-    fn new(output_ident: TokenTree) -> Generator {
+    fn new(output_ident: Ident) -> Generator {
         Generator { output_ident }
     }
 
@@ -23,248 +23,329 @@ impl Generator {
         Builder::new(self.output_ident.clone())
     }
 
-    fn markups(&self, markups: Vec<Markup>, build: &mut Builder) {
-        for markup in markups {
+    fn markups(&self, markups: Markups, build: &mut Builder) {
+        for markup in markups.markups {
             self.markup(markup, build);
         }
     }
 
     fn markup(&self, markup: Markup, build: &mut Builder) {
         match markup {
-            Markup::ParseError { .. } => {}
-            Markup::Block(Block {
-                markups,
-                outer_span,
-            }) => {
-                if markups
-                    .iter()
-                    .any(|markup| matches!(*markup, Markup::Let { .. }))
-                {
-                    self.block(
-                        Block {
-                            markups,
-                            outer_span,
-                        },
-                        build,
-                    );
+            Markup::Block(block) => {
+                if block.markups.markups.iter().any(|markup| {
+                    matches!(
+                        *markup,
+                        Markup::ControlFlow(ControlFlow {
+                            kind: ControlFlowKind::Let(_),
+                            ..
+                        })
+                    )
+                }) {
+                    self.block(block, build);
                 } else {
-                    self.markups(markups, build);
+                    self.markups(block.markups, build);
                 }
             }
-            Markup::Literal { content, .. } => build.push_escaped(&content),
-            Markup::Symbol { symbol } => self.name(symbol, build),
+            Markup::Lit(lit) => build.push_escaped(&lit.to_string()),
             Markup::Splice { expr, .. } => self.splice(expr, build),
-            Markup::Element { name, attrs, body } => self.element(name, attrs, body, build),
-            Markup::Let { tokens, .. } => build.push_tokens(tokens),
-            Markup::Special { segments } => {
-                for Special { head, body, .. } in segments {
-                    build.push_tokens(head);
-                    self.block(body, build);
-                }
-            }
-            Markup::Match {
-                head,
-                arms,
-                arms_span,
-                ..
-            } => {
-                let body = {
-                    let mut build = self.builder();
-                    for MatchArm { head, body } in arms {
-                        build.push_tokens(head);
-                        self.block(body, &mut build);
-                    }
-                    build.finish()
-                };
-                let mut body = TokenTree::Group(Group::new(Delimiter::Brace, body));
-                body.set_span(arms_span.collapse());
-                build.push_tokens(quote!(#head #body));
-            }
+            Markup::Element(element) => self.element(element, build),
+            Markup::ControlFlow(control_flow) => self.control_flow(control_flow, build),
+            Markup::Semi(_) => {}
         }
     }
 
-    fn block(
-        &self,
-        Block {
-            markups,
-            outer_span,
-        }: Block,
-        build: &mut Builder,
-    ) {
-        let block = {
+    fn block(&self, block: Block, build: &mut Builder) {
+        let markups = {
             let mut build = self.builder();
-            self.markups(markups, &mut build);
+            self.markups(block.markups, &mut build);
             build.finish()
         };
-        let mut block = TokenTree::Group(Group::new(Delimiter::Brace, block));
-        block.set_span(outer_span.collapse());
-        build.push_tokens(TokenStream::from(block));
+
+        build.push_tokens(quote!({ #markups }));
     }
 
-    fn splice(&self, expr: TokenStream, build: &mut Builder) {
-        let output_ident = self.output_ident.clone();
+    fn splice(&self, expr: Expr, build: &mut Builder) {
+        let output_ident = &self.output_ident;
         build.push_tokens(quote!(maud::macro_private::render_to!(&(#expr), &mut #output_ident);));
     }
 
-    fn element(&self, name: TokenStream, attrs: Vec<Attr>, body: ElementBody, build: &mut Builder) {
+    fn element(&self, element: Element, build: &mut Builder) {
+        let element_name = element.name.clone().unwrap_or_else(|| parse_quote!(div));
         build.push_str("<");
-        self.name(name.clone(), build);
-        self.attrs(attrs, build);
+        self.name(element_name.clone(), build);
+        self.attrs(element.attrs, build);
         build.push_str(">");
-        if let ElementBody::Block { block } = body {
+        if let ElementBody::Block(block) = element.body {
             self.markups(block.markups, build);
             build.push_str("</");
-            self.name(name, build);
+            self.name(element_name, build);
             build.push_str(">");
         }
     }
 
-    fn name(&self, name: TokenStream, build: &mut Builder) {
-        build.push_escaped(&name_to_string(name));
+    fn name(&self, name: HtmlName, build: &mut Builder) {
+        build.push_escaped(&name.to_string());
+    }
+
+    fn attr_name(&self, name: AttributeName, build: &mut Builder) {
+        match name {
+            AttributeName::Normal(name) => self.name(name, build),
+            AttributeName::Markup(markup) => self.markup(markup, build),
+        }
     }
 
-    fn attrs(&self, attrs: Vec<Attr>, build: &mut Builder) {
-        for NamedAttr { name, attr_type } in desugar_attrs(attrs) {
-            match attr_type {
-                AttrType::Normal { value } => {
+    fn attr(&self, name: AttributeName, value: AttributeType, build: &mut Builder) {
+        match value {
+            AttributeType::Normal { value, .. } => {
+                build.push_str(" ");
+                self.attr_name(name, build);
+                build.push_str("=\"");
+                self.markup(value, build);
+                build.push_str("\"");
+            }
+            AttributeType::Optional {
+                toggler: Toggler { cond, .. },
+                ..
+            } => {
+                let inner_value: Expr = parse_quote!(inner_value);
+
+                let body = {
+                    let mut build = self.builder();
                     build.push_str(" ");
-                    self.name(name, build);
+                    self.attr_name(name, &mut build);
                     build.push_str("=\"");
-                    self.markup(value, build);
+                    self.splice(inner_value.clone(), &mut build);
                     build.push_str("\"");
-                }
-                AttrType::Optional {
-                    toggler: Toggler { cond, .. },
-                } => {
-                    let inner_value = quote!(inner_value);
-                    let body = {
-                        let mut build = self.builder();
-                        build.push_str(" ");
-                        self.name(name, &mut build);
-                        build.push_str("=\"");
-                        self.splice(inner_value.clone(), &mut build);
-                        build.push_str("\"");
-                        build.finish()
-                    };
-                    build.push_tokens(quote!(if let Some(#inner_value) = (#cond) { #body }));
-                }
-                AttrType::Empty { toggler: None } => {
+                    build.finish()
+                };
+                build.push_tokens(quote!(if let Some(#inner_value) = (#cond) { #body }));
+            }
+            AttributeType::Empty(None) => {
+                build.push_str(" ");
+                self.attr_name(name, build);
+            }
+            AttributeType::Empty(Some(Toggler { cond, .. })) => {
+                let body = {
+                    let mut build = self.builder();
                     build.push_str(" ");
-                    self.name(name, build);
-                }
-                AttrType::Empty {
-                    toggler: Some(Toggler { cond, .. }),
-                } => {
-                    let body = {
-                        let mut build = self.builder();
+                    self.attr_name(name, &mut build);
+                    build.finish()
+                };
+                build.push_tokens(quote!(if (#cond) { #body }));
+            }
+        }
+    }
+
+    fn attrs(&self, attrs: Vec<Attribute>, build: &mut Builder) {
+        let (classes, id, named_attrs) = split_attrs(attrs);
+
+        if !classes.is_empty() {
+            let mut toggle_class_exprs = vec![];
+
+            build.push_str(" ");
+            self.attr_name(parse_quote!(class), build);
+            build.push_str("=\"");
+            for (i, (name, toggler)) in classes.into_iter().enumerate() {
+                if let Some(toggler) = toggler {
+                    toggle_class_exprs.push((i > 0, name, toggler));
+                } else {
+                    if i > 0 {
                         build.push_str(" ");
-                        self.name(name, &mut build);
-                        build.finish()
-                    };
-                    build.push_tokens(quote!(if (#cond) { #body }));
+                    }
+                    self.attr_name(name, build);
                 }
             }
+
+            for (not_first, name, toggler) in toggle_class_exprs {
+                let body = {
+                    let mut build = self.builder();
+                    if not_first {
+                        build.push_str(" ");
+                    }
+                    self.attr_name(name, &mut build);
+                    build.finish()
+                };
+                build.push_tokens(quote!(if (#toggler) { #body }));
+            }
+
+            build.push_str("\"");
+        }
+
+        if let Some(id) = id {
+            build.push_str(" ");
+            self.attr_name(parse_quote!(id), build);
+            build.push_str("=\"");
+            self.attr_name(id, build);
+            build.push_str("\"");
+        }
+
+        for (name, attr_type) in named_attrs {
+            self.attr(name, attr_type, build);
         }
     }
-}
 
-////////////////////////////////////////////////////////
+    fn control_flow(&self, control_flow: ControlFlow, build: &mut Builder) {
+        match control_flow.kind {
+            ControlFlowKind::If(if_) => self.control_flow_if(if_, build),
+            ControlFlowKind::Let(let_) => self.control_flow_let(let_, build),
+            ControlFlowKind::For(for_) => self.control_flow_for(for_, build),
+            ControlFlowKind::While(while_) => self.control_flow_while(while_, build),
+            ControlFlowKind::Match(match_) => self.control_flow_match(match_, build),
+        }
+    }
 
-fn desugar_attrs(attrs: Vec<Attr>) -> Vec<NamedAttr> {
-    let mut classes_static = vec![];
-    let mut classes_toggled = vec![];
-    let mut ids = vec![];
-    let mut named_attrs = vec![];
-    for attr in attrs {
-        match attr {
-            Attr::Class {
-                name,
-                toggler: Some(toggler),
-                ..
-            } => classes_toggled.push((name, toggler)),
-            Attr::Class {
-                name,
-                toggler: None,
-                ..
-            } => classes_static.push(name),
-            Attr::Id { name, .. } => ids.push(name),
-            Attr::Named { named_attr } => named_attrs.push(named_attr),
+    fn control_flow_if(
+        &self,
+        If {
+            if_token,
+            cond,
+            then_branch,
+            else_branch,
+        }: If,
+        build: &mut Builder,
+    ) {
+        build.push_tokens(quote!(#if_token #cond));
+        self.block(then_branch, build);
+
+        if let Some((_, else_token, if_or_block)) = else_branch {
+            build.push_tokens(quote!(#else_token));
+            self.control_flow_if_or_block(*if_or_block, build);
         }
     }
-    let classes = desugar_classes_or_ids("class", classes_static, classes_toggled);
-    let ids = desugar_classes_or_ids("id", ids, vec![]);
-    classes.into_iter().chain(ids).chain(named_attrs).collect()
-}
 
-fn desugar_classes_or_ids(
-    attr_name: &'static str,
-    values_static: Vec<Markup>,
-    values_toggled: Vec<(Markup, Toggler)>,
-) -> Option<NamedAttr> {
-    if values_static.is_empty() && values_toggled.is_empty() {
-        return None;
+    fn control_flow_if_or_block(&self, if_or_block: IfOrBlock, build: &mut Builder) {
+        match if_or_block {
+            IfOrBlock::If(if_) => self.control_flow_if(if_, build),
+            IfOrBlock::Block(block) => self.block(block, build),
+        }
     }
-    let mut markups = Vec::new();
-    let mut leading_space = false;
-    for name in values_static {
-        markups.extend(prepend_leading_space(name, &mut leading_space));
+
+    fn control_flow_let(&self, let_: Local, build: &mut Builder) {
+        build.push_tokens(let_.to_token_stream());
     }
-    for (name, Toggler { cond, cond_span }) in values_toggled {
-        let body = Block {
-            markups: prepend_leading_space(name, &mut leading_space),
-            // TODO: is this correct?
-            outer_span: cond_span,
-        };
-        markups.push(Markup::Special {
-            segments: vec![Special {
-                at_span: SpanRange::call_site(),
-                head: quote!(if (#cond)),
+
+    fn control_flow_for(
+        &self,
+        For {
+            for_token,
+            pat,
+            in_token,
+            expr,
+            body,
+        }: For,
+        build: &mut Builder,
+    ) {
+        build.push_tokens(quote!(#for_token #pat #in_token (#expr)));
+        self.block(body, build);
+    }
+
+    fn control_flow_while(
+        &self,
+        While {
+            while_token,
+            cond,
+            body,
+        }: While,
+        build: &mut Builder,
+    ) {
+        build.push_tokens(quote!(#while_token #cond));
+        self.block(body, build);
+    }
+
+    fn control_flow_match(
+        &self,
+        Match {
+            match_token,
+            expr,
+            brace_token,
+            arms,
+        }: Match,
+        build: &mut Builder,
+    ) {
+        let arms = {
+            let mut build = self.builder();
+            for MatchArm {
+                pat,
+                guard,
+                fat_arrow_token,
                 body,
-            }],
+                comma_token,
+            } in arms
+            {
+                build.push_tokens(quote!(#pat));
+                if let Some((if_token, cond)) = guard {
+                    build.push_tokens(quote!(#if_token #cond));
+                }
+                build.push_tokens(quote!(#fat_arrow_token));
+                self.block(
+                    Block {
+                        brace_token: Brace(Span::call_site()),
+                        markups: Markups {
+                            markups: vec![body],
+                        },
+                    },
+                    &mut build,
+                );
+                build.push_tokens(quote!(#comma_token));
+            }
+            build.finish()
+        };
+
+        let mut arm_block = TokenStream::new();
+
+        brace_token.surround(&mut arm_block, |tokens| {
+            arms.to_tokens(tokens);
         });
+
+        build.push_tokens(quote!(#match_token #expr #arm_block));
     }
-    Some(NamedAttr {
-        name: TokenStream::from(TokenTree::Ident(Ident::new(attr_name, Span::call_site()))),
-        attr_type: AttrType::Normal {
-            value: Markup::Block(Block {
-                markups,
-                outer_span: SpanRange::call_site(),
-            }),
-        },
-    })
 }
 
-fn prepend_leading_space(name: Markup, leading_space: &mut bool) -> Vec<Markup> {
-    let mut markups = Vec::new();
-    if *leading_space {
-        markups.push(Markup::Literal {
-            content: " ".to_owned(),
-            span: name.span(),
-        });
+////////////////////////////////////////////////////////
+
+#[allow(clippy::type_complexity)]
+fn split_attrs(
+    attrs: Vec<Attribute>,
+) -> (
+    Vec<(AttributeName, Option<Expr>)>,
+    Option<AttributeName>,
+    Vec<(AttributeName, AttributeType)>,
+) {
+    let mut classes = vec![];
+    let mut id = None;
+    let mut named_attrs = vec![];
+
+    for attr in attrs {
+        match attr {
+            Attribute::Class { name, toggler, .. } => {
+                classes.push((name, toggler.map(|toggler| toggler.cond)))
+            }
+            Attribute::Id { name, .. } => id = Some(name),
+            Attribute::Named { name, attr_type } => named_attrs.push((name, attr_type)),
+        }
     }
-    *leading_space = true;
-    markups.push(name);
-    markups
+
+    (classes, id, named_attrs)
 }
 
 ////////////////////////////////////////////////////////
 
 struct Builder {
-    output_ident: TokenTree,
-    tokens: Vec<TokenTree>,
+    output_ident: Ident,
+    tokens: TokenStream,
     tail: String,
 }
 
 impl Builder {
-    fn new(output_ident: TokenTree) -> Builder {
+    fn new(output_ident: Ident) -> Builder {
         Builder {
             output_ident,
-            tokens: Vec::new(),
+            tokens: TokenStream::new(),
             tail: String::new(),
         }
     }
 
-    fn push_str(&mut self, string: &str) {
+    fn push_str(&mut self, string: &'static str) {
         self.tail.push_str(string);
     }
 
@@ -283,8 +364,8 @@ impl Builder {
         }
         let push_str_expr = {
             let output_ident = self.output_ident.clone();
-            let string = TokenTree::Literal(Literal::string(&self.tail));
-            quote!(#output_ident.push_str(#string);)
+            let tail = &self.tail;
+            quote!(#output_ident.push_str(#tail);)
         };
         self.tail.clear();
         self.tokens.extend(push_str_expr);
@@ -292,6 +373,6 @@ impl Builder {
 
     fn finish(mut self) -> TokenStream {
         self.cut();
-        self.tokens.into_iter().collect()
+        self.tokens
     }
 }
diff --git a/maud_macros/src/lib.rs b/maud_macros/src/lib.rs
index f52e27e..cf4bbd0 100644
--- a/maud_macros/src/lib.rs
+++ b/maud_macros/src/lib.rs
@@ -8,30 +8,50 @@ extern crate proc_macro;
 mod ast;
 mod escape;
 mod generate;
-mod parse;
 
-use proc_macro2::{Ident, Span, TokenStream, TokenTree};
-use proc_macro_error::proc_macro_error;
+use ast::DiagnosticParse;
+use proc_macro2::{Ident, Span, TokenStream};
+use proc_macro2_diagnostics::Diagnostic;
 use quote::quote;
+use syn::parse::{ParseStream, Parser};
 
 #[proc_macro]
-#[proc_macro_error]
 pub fn html(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
     expand(input.into()).into()
 }
 
 fn expand(input: TokenStream) -> TokenStream {
-    let output_ident = TokenTree::Ident(Ident::new("__maud_output", Span::mixed_site()));
     // Heuristic: the size of the resulting markup tends to correlate with the
     // code size of the template itself
     let size_hint = input.to_string().len();
-    let markups = parse::parse(input);
+
+    let mut diagnostics = Vec::new();
+    let markups = match Parser::parse2(
+        |input: ParseStream| ast::Markups::diagnostic_parse(input, &mut diagnostics),
+        input,
+    ) {
+        Ok(data) => data,
+        Err(err) => {
+            let err = err.to_compile_error();
+            let diag_tokens = diagnostics.into_iter().map(Diagnostic::emit_as_expr_tokens);
+
+            return quote! {{
+                #err
+                #(#diag_tokens)*
+            }};
+        }
+    };
+
+    let diag_tokens = diagnostics.into_iter().map(Diagnostic::emit_as_expr_tokens);
+
+    let output_ident = Ident::new("__maud_output", Span::mixed_site());
     let stmts = generate::generate(markups, output_ident.clone());
-    quote!({
+    quote! {{
         extern crate alloc;
         extern crate maud;
         let mut #output_ident = alloc::string::String::with_capacity(#size_hint);
         #stmts
+        #(#diag_tokens)*
         maud::PreEscaped(#output_ident)
-    })
+    }}
 }


Test patch:
diff --git a/maud/tests/warnings/attribute-missing-value.stderr b/maud/tests/warnings/attribute-missing-value.stderr
index b2d11bd..61b1b3c 100644
--- a/maud/tests/warnings/attribute-missing-value.stderr
+++ b/maud/tests/warnings/attribute-missing-value.stderr
@@ -1,4 +1,4 @@
-error: unexpected end of input
+error: unexpected end of input, expected one of: curly braces, literal, parentheses, identifier, `.`, `#`, `@`, `;`
  --> $DIR/attribute-missing-value.rs:4:5
   |
 4 | /     html! {
diff --git a/maud/tests/warnings/class-shorthand-missing-value.stderr b/maud/tests/warnings/class-shorthand-missing-value.stderr
index 0e16827..2969bef 100644
--- a/maud/tests/warnings/class-shorthand-missing-value.stderr
+++ b/maud/tests/warnings/class-shorthand-missing-value.stderr
@@ -1,4 +1,4 @@
-error: unexpected end of input
+error: unexpected end of input, expected one of: curly braces, literal, parentheses, identifier, `.`, `#`, `@`, `;`
  --> $DIR/class-shorthand-missing-value.rs:4:5
   |
 4 | /     html! {
diff --git a/maud/tests/warnings/let-without-block.rs b/maud/tests/warnings/let-without-block.rs
new file mode 100644
index 0000000..0dcb1b8
--- /dev/null
+++ b/maud/tests/warnings/let-without-block.rs
@@ -0,0 +1,9 @@
+use maud::html;
+
+fn main() {
+    html! {
+        p.@let x = 1; {
+            (x)
+        }
+    };
+}
diff --git a/maud/tests/warnings/let-without-block.stderr b/maud/tests/warnings/let-without-block.stderr
new file mode 100644
index 0000000..11e9ef7
--- /dev/null
+++ b/maud/tests/warnings/let-without-block.stderr
@@ -0,0 +1,5 @@
+error: `@let` bindings are only allowed inside blocks
+ --> tests/warnings/let-without-block.rs:5:11
+  |
+5 |         p.@let x = 1; {
+  |           ^^^^^^^^^^^
diff --git a/maud/tests/warnings/non-closed-element.stderr b/maud/tests/warnings/non-closed-element.stderr
index 0a07197..df1fa88 100644
--- a/maud/tests/warnings/non-closed-element.stderr
+++ b/maud/tests/warnings/non-closed-element.stderr
@@ -1,4 +1,4 @@
-error: expected `;`, found end of macro
+error: unexpected end of input, expected one of: identifier, literal, `.`, `#`, curly braces, `;`
  --> $DIR/non-closed-element.rs:4:5
   |
 4 | /     html! {
diff --git a/maud/tests/warnings/non-string-literal.stderr b/maud/tests/warnings/non-string-literal.stderr
index d0a128b..f5ccdff 100644
--- a/maud/tests/warnings/non-string-literal.stderr
+++ b/maud/tests/warnings/non-string-literal.stderr
@@ -1,15 +1,3 @@
-error: literal must be double-quoted: `"42"`
- --> tests/warnings/non-string-literal.rs:5:9
-  |
-5 |         42
-  |         ^^
-
-error: literal must be double-quoted: `"42usize"`
- --> tests/warnings/non-string-literal.rs:6:9
-  |
-6 |         42usize
-  |         ^^^^^^^
-
 error: literal must be double-quoted: `"42.0"`
  --> tests/warnings/non-string-literal.rs:7:9
   |
@@ -35,19 +23,19 @@ error: expected string
    |         ^^^^
 
 error: attribute value must be a string
-  --> tests/warnings/non-string-literal.rs:13:24
+  --> tests/warnings/non-string-literal.rs:13:15
    |
 13 |         input disabled=true;
-   |                        ^^^^
+   |               ^^^^^^^^^^^^^
    |
    = help: to declare an empty attribute, omit the equals sign: `disabled`
    = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]`
 
 error: attribute value must be a string
-  --> tests/warnings/non-string-literal.rs:14:24
+  --> tests/warnings/non-string-literal.rs:14:15
    |
 14 |         input disabled=false;
-   |                        ^^^^^
+   |               ^^^^^^^^^^^^^^
    |
    = help: to declare an empty attribute, omit the equals sign: `disabled`
    = help: to toggle the attribute, use square brackets: `disabled[some_boolean_flag]`