shelly-macros 0.6.0

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

extern crate proc_macro;

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

const HTML_MACRO_EXAMPLE: &str = "html! { \"<p>Hello \" (name) \"</p>\" }";

/// 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(with_hint(
                        "html! dynamic expression cannot be empty",
                        "place a Rust expression inside `(...)`, for example `(user_name)`",
                    ));
                }
                dynamic_segments.push(expr);
                static_segments.push(String::new());
            }
            other => {
                return Err(with_hint(
                    &format!(
                        "html! expected string literal or parenthesized expression, found `{other}`"
                    ),
                    &format!(
                        "write static HTML in quoted string literals and dynamic values in `(...)`, for example: {HTML_MACRO_EXAMPLE}"
                    ),
                ));
            }
        }
    }

    if static_segments.len() != dynamic_segments.len() + 1 {
        return Err(with_hint(
            "html! produced invalid static/dynamic segment counts",
            "this usually means malformed input; ensure literals and `(...)` alternate in order",
        ));
    }

    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(with_hint(
            &format!("html! expected a string literal, found `{source}`"),
            "wrap static HTML in quotes; use raw strings for quote-heavy HTML like r#\"<button class=\\\"x\\\">\"#",
        ));
    };

    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(with_hint(
                "html! string literal ends with an incomplete escape",
                "end escapes with a valid sequence (for example `\\n`) or use a raw string literal",
            ));
        };

        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(with_hint(
                    &format!("html! string literal escape `\\{other}` is not supported yet"),
                    "supported escapes are \\\\, \\\" , \\n, \\r, \\t, and \\0. For complex content, use a raw string literal",
                ));
            }
        }
    }

    Ok(out)
}

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

fn with_hint(message: &str, hint: &str) -> String {
    format!("{message}\nHint: {hint}")
}

#[cfg(test)]
mod tests {
    use super::{parse_string_literal, unescape_string_literal};

    #[test]
    fn parse_string_literal_reports_actionable_hint_for_non_string_input() {
        let err = parse_string_literal("name").expect_err("should reject non-string token");
        assert!(err.contains("Hint:"));
        assert!(err.contains("wrap static HTML in quotes"));
    }

    #[test]
    fn unescape_reports_actionable_hint_for_unknown_escape() {
        let err =
            unescape_string_literal("foo\\xbar").expect_err("unknown escape should be rejected");
        assert!(err.contains("Hint:"));
        assert!(err.contains("supported escapes"));
    }

    #[test]
    fn unescape_reports_actionable_hint_for_incomplete_escape() {
        let err = unescape_string_literal("foo\\").expect_err("incomplete escape should fail");
        assert!(err.contains("Hint:"));
        assert!(err.contains("raw string literal"));
    }
}