maud-extensions-macros 0.6.5

Proc-macro implementation for maud-extensions.
Documentation
// CSS helper DSL: recognizes and expands raw!/at-rule/unit helper forms.
use proc_macro2::{Delimiter, Group, TokenTree};
use syn::{
    LitStr, Result,
    parse::{Parse, ParseStream},
};

use crate::css::{diagnostics, source};

pub(crate) fn try_expand(tokens: &[TokenTree], index: &mut usize) -> Result<Option<String>> {
    if let Some(raw_css) = try_parse_raw_fragment(tokens, index)? {
        return Ok(Some(raw_css));
    }

    for (macro_name, at_rule) in [
        ("media", "media"),
        ("container", "container"),
        ("supports", "supports"),
        ("layer", "layer"),
        ("keyframes", "keyframes"),
    ] {
        let original_index = *index;
        if let Some(css) = try_parse_at_rule(tokens, index, macro_name, at_rule)? {
            return Ok(Some(css));
        }
        *index = original_index;
    }

    if let Some(unit_value) = try_parse_unit(tokens, index)? {
        return Ok(Some(unit_value));
    }

    Ok(None)
}

pub(crate) fn ends_with_word(css: &str) -> bool {
    css.chars()
        .next_back()
        .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '%' || ch == '-')
}

struct RawFragment {
    css: LitStr,
}

impl Parse for RawFragment {
    fn parse(input: ParseStream) -> Result<Self> {
        let css: LitStr = input.parse()?;
        if !input.is_empty() {
            return Err(input.error("raw! expects exactly one string literal argument"));
        }
        Ok(Self { css })
    }
}

fn parse_raw_fragment(group: &Group) -> Result<String> {
    let input = syn::parse2::<RawFragment>(group.stream())
        .map_err(|_| diagnostics::raw_expects_exactly_one_string_literal(group.span()))?;
    Ok(input.css.value())
}

fn parse_macro_group<'a>(
    tokens: &'a [TokenTree],
    index: &mut usize,
    macro_name: &str,
) -> Result<Option<&'a Group>> {
    let Some(TokenTree::Ident(ident)) = tokens.get(*index) else {
        return Ok(None);
    };
    if ident != macro_name {
        return Ok(None);
    }
    let Some(TokenTree::Punct(punct)) = tokens.get(*index + 1) else {
        return Ok(None);
    };
    if punct.as_char() != '!' {
        return Ok(None);
    }
    let Some(TokenTree::Group(group)) = tokens.get(*index + 2) else {
        return Err(diagnostics::macro_arguments_must_use_parentheses(
            punct.span(),
            macro_name,
        ));
    };
    if group.delimiter() != Delimiter::Parenthesis {
        return Err(diagnostics::macro_arguments_must_use_parentheses(
            group.span(),
            macro_name,
        ));
    }
    *index += 2;
    Ok(Some(group))
}

fn try_parse_raw_fragment(tokens: &[TokenTree], index: &mut usize) -> Result<Option<String>> {
    let Some(group) = parse_macro_group(tokens, index, "raw")? else {
        return Ok(None);
    };
    Ok(Some(parse_raw_fragment(group)?))
}

fn try_parse_at_rule(
    tokens: &[TokenTree],
    index: &mut usize,
    macro_name: &str,
    at_rule: &str,
) -> Result<Option<String>> {
    let Some(group) = parse_macro_group(tokens, index, macro_name)? else {
        return Ok(None);
    };
    Ok(Some(parse_at_rule(group, macro_name, at_rule)?))
}

fn parse_at_rule(group: &Group, macro_name: &str, at_rule: &str) -> Result<String> {
    let tokens: Vec<TokenTree> = group.stream().into_iter().collect();
    let Some(body_index) = tokens.iter().rposition(
        |token| matches!(token, TokenTree::Group(group) if group.delimiter() == Delimiter::Brace),
    ) else {
        return Err(diagnostics::at_rule_requires_body(group.span(), macro_name));
    };

    if body_index < 2
        || !matches!(tokens.get(body_index - 1), Some(TokenTree::Punct(punct)) if punct.as_char() == ',')
    {
        return Err(diagnostics::at_rule_requires_prelude_then_body(
            group.span(),
            macro_name,
        ));
    }

    if tokens
        .iter()
        .skip(body_index + 1)
        .any(|token| !matches!(token, TokenTree::Punct(punct) if punct.as_char() == ','))
    {
        return Err(diagnostics::at_rule_accepts_only_prelude_and_body(
            group.span(),
            macro_name,
        ));
    }

    let prelude_tokens = &tokens[..body_index - 1];
    let prelude = if let [TokenTree::Literal(literal)] = prelude_tokens {
        match syn::parse_str::<LitStr>(&literal.to_string()) {
            Ok(lit) => lit.value(),
            Err(_) => literal.to_string(),
        }
    } else {
        source::token_trees_to_source(prelude_tokens.to_vec())?
    };
    if prelude.trim().is_empty() {
        return Err(diagnostics::at_rule_requires_non_empty_prelude(
            group.span(),
            macro_name,
        ));
    }
    let TokenTree::Group(body_group) = &tokens[body_index] else {
        unreachable!();
    };
    let body = source::tokens_to_source(body_group.stream())?;
    Ok(format!("@{at_rule} {prelude} {{{body}}}"))
}

fn try_parse_unit(tokens: &[TokenTree], index: &mut usize) -> Result<Option<String>> {
    for (macro_name, suffix) in [
        ("rem", "rem"),
        ("em", "em"),
        ("px", "px"),
        ("pct", "%"),
        ("vw", "vw"),
        ("vh", "vh"),
        ("ms", "ms"),
        ("s", "s"),
    ] {
        let original_index = *index;
        if let Some(group) = parse_macro_group(tokens, index, macro_name)? {
            return Ok(Some(parse_unit(group, macro_name, suffix)?));
        }
        *index = original_index;
    }
    Ok(None)
}

fn parse_unit(group: &Group, macro_name: &str, suffix: &str) -> Result<String> {
    let tokens: Vec<TokenTree> = group.stream().into_iter().collect();
    if tokens.is_empty() {
        return Err(diagnostics::unit_requires_value(group.span(), macro_name));
    }
    if tokens
        .iter()
        .any(|token| matches!(token, TokenTree::Punct(punct) if punct.as_char() == ','))
    {
        return Err(diagnostics::unit_expects_exactly_one_value(
            group.span(),
            macro_name,
        ));
    }
    let value = source::token_trees_to_source(tokens)?;
    if value.trim().is_empty() {
        return Err(diagnostics::unit_requires_value(group.span(), macro_name));
    }
    Ok(format!("{value}{suffix}"))
}