shelly-macros 0.2.0

Placeholder macros for future Shelly typed templates.
Documentation
//! Template macros for Shelly.

extern crate proc_macro;

use proc_macro::{Delimiter, TokenStream, TokenTree};

/// No-op attribute reserved for future LiveView metadata.
#[proc_macro_attribute]
pub fn live_view(_attr: TokenStream, item: TokenStream) -> TokenStream {
    item
}

/// Build a Shelly template from alternating static strings and dynamic Rust expressions.
///
/// Static HTML is written as string literals. Dynamic values are written inside
/// parentheses and are HTML-escaped by default.
///
/// ```ignore
/// shelly_macros::html! {
///     "<p>Hello " (name) "</p>"
/// }
/// ```
#[proc_macro]
pub fn html(input: TokenStream) -> TokenStream {
    match parse_template(input) {
        Ok(template) => template.into_token_stream(),
        Err(message) => compile_error(&message),
    }
}

#[derive(Debug)]
struct ParsedTemplate {
    static_segments: Vec<String>,
    dynamic_segments: Vec<String>,
}

impl ParsedTemplate {
    fn into_token_stream(self) -> TokenStream {
        let static_segments = self
            .static_segments
            .iter()
            .map(|segment| format!("{segment:?}"))
            .collect::<Vec<_>>()
            .join(", ");
        let dynamic_segments = self
            .dynamic_segments
            .iter()
            .map(|expr| format!("::shelly::escape_html(&::std::format!(\"{{}}\", ({expr})))"))
            .collect::<Vec<_>>()
            .join(", ");

        format!(
            "::shelly::Template::new(::std::vec![{static_segments}], ::std::vec![{dynamic_segments}])"
        )
        .parse()
        .expect("generated html! template should parse")
    }
}

fn parse_template(input: TokenStream) -> Result<ParsedTemplate, String> {
    let mut static_segments = vec![String::new()];
    let mut dynamic_segments = Vec::new();

    for token in input {
        match token {
            TokenTree::Literal(literal) => {
                let value = parse_string_literal(&literal.to_string())?;
                static_segments
                    .last_mut()
                    .expect("template should always have a current static segment")
                    .push_str(&value);
            }
            TokenTree::Group(group) if group.delimiter() == Delimiter::Parenthesis => {
                let expr = group.stream().to_string();
                if expr.trim().is_empty() {
                    return Err("html! dynamic expression cannot be empty".to_string());
                }
                dynamic_segments.push(expr);
                static_segments.push(String::new());
            }
            other => {
                return Err(format!(
                    "html! expected string literal or parenthesized expression, found `{other}`"
                ));
            }
        }
    }

    if static_segments.len() != dynamic_segments.len() + 1 {
        return Err("html! produced invalid static/dynamic segment counts".to_string());
    }

    Ok(ParsedTemplate {
        static_segments,
        dynamic_segments,
    })
}

fn parse_string_literal(source: &str) -> Result<String, String> {
    if let Some(value) = parse_raw_string_literal(source) {
        return Ok(value);
    }

    let Some(inner) = source
        .strip_prefix('"')
        .and_then(|value| value.strip_suffix('"'))
    else {
        return Err(format!("html! expected a string literal, found `{source}`"));
    };

    unescape_string_literal(inner)
}

fn parse_raw_string_literal(source: &str) -> Option<String> {
    let rest = source.strip_prefix('r')?;
    let hashes = rest.chars().take_while(|ch| *ch == '#').count();
    let rest = &rest[hashes..];
    let inner = rest.strip_prefix('"')?;
    let suffix = format!("\"{}", "#".repeat(hashes));
    Some(inner.strip_suffix(&suffix)?.to_string())
}

fn unescape_string_literal(inner: &str) -> Result<String, String> {
    let mut out = String::new();
    let mut chars = inner.chars();

    while let Some(ch) = chars.next() {
        if ch != '\\' {
            out.push(ch);
            continue;
        }

        let Some(escaped) = chars.next() else {
            return Err("html! string literal ends with an incomplete escape".to_string());
        };

        match escaped {
            '\\' => out.push('\\'),
            '"' => out.push('"'),
            'n' => out.push('\n'),
            'r' => out.push('\r'),
            't' => out.push('\t'),
            '0' => out.push('\0'),
            other => {
                return Err(format!(
                    "html! string literal escape `\\{other}` is not supported yet"
                ));
            }
        }
    }

    Ok(out)
}

fn compile_error(message: &str) -> TokenStream {
    format!("::std::compile_error!({message:?});")
        .parse()
        .expect("compile_error! should parse")
}