Skip to main content

shelly_macros/
lib.rs

1//! Template macros for Shelly.
2
3extern crate proc_macro;
4
5use proc_macro::{Delimiter, TokenStream, TokenTree};
6
7const HTML_MACRO_EXAMPLE: &str = "html! { \"<p>Hello \" (name) \"</p>\" }";
8
9/// No-op attribute reserved for future LiveView metadata.
10#[proc_macro_attribute]
11pub fn live_view(_attr: TokenStream, item: TokenStream) -> TokenStream {
12    item
13}
14
15/// Build a Shelly template from alternating static strings and dynamic Rust expressions.
16///
17/// Static HTML is written as string literals. Dynamic values are written inside
18/// parentheses and are HTML-escaped by default.
19///
20/// ```ignore
21/// shelly_macros::html! {
22///     "<p>Hello " (name) "</p>"
23/// }
24/// ```
25#[proc_macro]
26pub fn html(input: TokenStream) -> TokenStream {
27    match parse_template(input) {
28        Ok(template) => template.into_token_stream(),
29        Err(message) => compile_error(&message),
30    }
31}
32
33#[derive(Debug)]
34struct ParsedTemplate {
35    static_segments: Vec<String>,
36    dynamic_segments: Vec<String>,
37}
38
39impl ParsedTemplate {
40    fn into_token_stream(self) -> TokenStream {
41        let static_segments = self
42            .static_segments
43            .iter()
44            .map(|segment| format!("{segment:?}"))
45            .collect::<Vec<_>>()
46            .join(", ");
47        let dynamic_segments = self
48            .dynamic_segments
49            .iter()
50            .map(|expr| format!("::shelly::escape_html(&::std::format!(\"{{}}\", ({expr})))"))
51            .collect::<Vec<_>>()
52            .join(", ");
53
54        format!(
55            "::shelly::Template::new(::std::vec![{static_segments}], ::std::vec![{dynamic_segments}])"
56        )
57        .parse()
58        .expect("generated html! template should parse")
59    }
60}
61
62fn parse_template(input: TokenStream) -> Result<ParsedTemplate, String> {
63    let mut static_segments = vec![String::new()];
64    let mut dynamic_segments = Vec::new();
65
66    for token in input {
67        match token {
68            TokenTree::Literal(literal) => {
69                let value = parse_string_literal(&literal.to_string())?;
70                static_segments
71                    .last_mut()
72                    .expect("template should always have a current static segment")
73                    .push_str(&value);
74            }
75            TokenTree::Group(group) if group.delimiter() == Delimiter::Parenthesis => {
76                let expr = group.stream().to_string();
77                if expr.trim().is_empty() {
78                    return Err(with_hint(
79                        "html! dynamic expression cannot be empty",
80                        "place a Rust expression inside `(...)`, for example `(user_name)`",
81                    ));
82                }
83                dynamic_segments.push(expr);
84                static_segments.push(String::new());
85            }
86            other => {
87                return Err(with_hint(
88                    &format!(
89                        "html! expected string literal or parenthesized expression, found `{other}`"
90                    ),
91                    &format!(
92                        "write static HTML in quoted string literals and dynamic values in `(...)`, for example: {HTML_MACRO_EXAMPLE}"
93                    ),
94                ));
95            }
96        }
97    }
98
99    if static_segments.len() != dynamic_segments.len() + 1 {
100        return Err(with_hint(
101            "html! produced invalid static/dynamic segment counts",
102            "this usually means malformed input; ensure literals and `(...)` alternate in order",
103        ));
104    }
105
106    Ok(ParsedTemplate {
107        static_segments,
108        dynamic_segments,
109    })
110}
111
112fn parse_string_literal(source: &str) -> Result<String, String> {
113    if let Some(value) = parse_raw_string_literal(source) {
114        return Ok(value);
115    }
116
117    let Some(inner) = source
118        .strip_prefix('"')
119        .and_then(|value| value.strip_suffix('"'))
120    else {
121        return Err(with_hint(
122            &format!("html! expected a string literal, found `{source}`"),
123            "wrap static HTML in quotes; use raw strings for quote-heavy HTML like r#\"<button class=\\\"x\\\">\"#",
124        ));
125    };
126
127    unescape_string_literal(inner)
128}
129
130fn parse_raw_string_literal(source: &str) -> Option<String> {
131    let rest = source.strip_prefix('r')?;
132    let hashes = rest.chars().take_while(|ch| *ch == '#').count();
133    let rest = &rest[hashes..];
134    let inner = rest.strip_prefix('"')?;
135    let suffix = format!("\"{}", "#".repeat(hashes));
136    Some(inner.strip_suffix(&suffix)?.to_string())
137}
138
139fn unescape_string_literal(inner: &str) -> Result<String, String> {
140    let mut out = String::new();
141    let mut chars = inner.chars();
142
143    while let Some(ch) = chars.next() {
144        if ch != '\\' {
145            out.push(ch);
146            continue;
147        }
148
149        let Some(escaped) = chars.next() else {
150            return Err(with_hint(
151                "html! string literal ends with an incomplete escape",
152                "end escapes with a valid sequence (for example `\\n`) or use a raw string literal",
153            ));
154        };
155
156        match escaped {
157            '\\' => out.push('\\'),
158            '"' => out.push('"'),
159            'n' => out.push('\n'),
160            'r' => out.push('\r'),
161            't' => out.push('\t'),
162            '0' => out.push('\0'),
163            other => {
164                return Err(with_hint(
165                    &format!("html! string literal escape `\\{other}` is not supported yet"),
166                    "supported escapes are \\\\, \\\" , \\n, \\r, \\t, and \\0. For complex content, use a raw string literal",
167                ));
168            }
169        }
170    }
171
172    Ok(out)
173}
174
175fn compile_error(message: &str) -> TokenStream {
176    format!("::std::compile_error!({message:?});")
177        .parse()
178        .expect("compile_error! should parse")
179}
180
181fn with_hint(message: &str, hint: &str) -> String {
182    format!("{message}\nHint: {hint}")
183}
184
185#[cfg(test)]
186mod tests {
187    use super::{parse_string_literal, unescape_string_literal};
188
189    #[test]
190    fn parse_string_literal_reports_actionable_hint_for_non_string_input() {
191        let err = parse_string_literal("name").expect_err("should reject non-string token");
192        assert!(err.contains("Hint:"));
193        assert!(err.contains("wrap static HTML in quotes"));
194    }
195
196    #[test]
197    fn unescape_reports_actionable_hint_for_unknown_escape() {
198        let err =
199            unescape_string_literal("foo\\xbar").expect_err("unknown escape should be rejected");
200        assert!(err.contains("Hint:"));
201        assert!(err.contains("supported escapes"));
202    }
203
204    #[test]
205    fn unescape_reports_actionable_hint_for_incomplete_escape() {
206        let err = unescape_string_literal("foo\\").expect_err("incomplete escape should fail");
207        assert!(err.contains("Hint:"));
208        assert!(err.contains("raw string literal"));
209    }
210}