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