extern crate proc_macro;
use proc_macro::{Delimiter, TokenStream, TokenTree};
const HTML_MACRO_EXAMPLE: &str = "html! { \"<p>Hello \" (name) \"</p>\" }";
#[proc_macro_attribute]
pub fn live_view(_attr: TokenStream, item: TokenStream) -> TokenStream {
item
}
#[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"));
}
}