1use core::fmt;
2
3use memchr::{memchr2, memchr3};
4
5use crate::Formatter;
6
7#[non_exhaustive]
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum HtmlContext {
32 Unescaped,
34 Text,
37 AttributeValue,
40 Comment,
47 AttributeKey,
49 ElementName,
51}
52
53impl HtmlContext {
54 #[inline]
57 pub fn writer<'a, 'b>(self, f: &'a mut Formatter<'b>) -> HtmlWriter<'a, 'b> {
58 HtmlWriter { context: self, f }
59 }
60
61 #[inline]
69 const fn forbidden_in_ident(b: u8) -> bool {
70 b <= b' ' || b == 0x7F || matches!(b, b'"' | b'\'' | b'/' | b'<' | b'=' | b'>')
71 }
72
73 fn description(self) -> &'static str {
75 match self {
76 Self::Unescaped => "unescaped content",
77 Self::Text => "text",
78 Self::AttributeValue => "attribute value",
79 Self::Comment => "comment payload",
80 Self::AttributeKey => "attribute key",
81 Self::ElementName => "element name",
82 }
83 }
84}
85
86type EscapeTable = [Option<&'static str>; 256];
90
91const fn escape_table<const N: usize>(escapes: [(u8, &'static str); N]) -> EscapeTable {
92 let mut table: EscapeTable = [None; 256];
93 let mut i = 0;
94 while i < N {
95 table[escapes[i].0 as usize] = Some(escapes[i].1);
96 i += 1;
97 }
98 table
99}
100
101const TEXT_ESCAPES: EscapeTable = escape_table([(b'&', "&"), (b'<', "<"), (b'>', ">")]);
102
103const ATTRIBUTE_VALUE_ESCAPES: EscapeTable = escape_table([(b'&', "&"), (b'"', """)]);
104
105const COMMENT_ESCAPES: EscapeTable =
106 escape_table([(b'&', "&"), (b'>', ">"), (b'"', """)]);
107
108macro_rules! impl_write_escaped {
112 ($(#[$doc:meta])* $method:ident, $memchr:ident($($needle:literal),+), $table:ident) => {
113 $(#[$doc])*
114 #[inline]
115 fn $method(&mut self, s: &str) {
116 let bytes = s.as_bytes();
117 let mut start = 0;
118
119 while let Some(offset) = $memchr($($needle,)+ &bytes[start..]) {
122 let special = start + offset;
124 self.f.write_str(&s[start..special]);
125
126 let mut end = special;
130 while let Some(escape) = bytes.get(end).and_then(|&b| $table[b as usize]) {
131 self.f.write_str(escape);
132 end += 1;
133 }
134 start = end;
135 }
136
137 self.f.write_str(&s[start..]);
138 }
139 };
140}
141
142pub struct HtmlWriter<'a, 'b> {
149 context: HtmlContext,
150 f: &'a mut Formatter<'b>,
151}
152
153impl HtmlWriter<'_, '_> {
154 pub fn write_str(&mut self, s: &str) {
163 match self.context {
164 HtmlContext::Unescaped => self.f.write_str(s),
165 HtmlContext::Text => self.write_text_escaped(s),
166 HtmlContext::AttributeValue => self.write_attribute_value_escaped(s),
167 HtmlContext::Comment => self.write_comment_escaped(s),
168 HtmlContext::AttributeKey | HtmlContext::ElementName => self.write_ident(s),
169 }
170 }
171
172 pub fn write_char(&mut self, c: char) {
180 let table = match self.context {
181 HtmlContext::Unescaped => return self.f.write_char(c),
182 HtmlContext::Text => &TEXT_ESCAPES,
183 HtmlContext::AttributeValue => &ATTRIBUTE_VALUE_ESCAPES,
184 HtmlContext::Comment => &COMMENT_ESCAPES,
185 HtmlContext::AttributeKey | HtmlContext::ElementName => {
186 assert!(
187 !u8::try_from(c).is_ok_and(HtmlContext::forbidden_in_ident),
188 "invalid {}: forbidden character {c:?}",
189 self.context.description(),
190 );
191 return self.f.write_char(c);
192 }
193 };
194 match table.get(c as usize).copied().flatten() {
197 Some(escape) => self.f.write_str(escape),
198 None => self.f.write_char(c),
199 }
200 }
201
202 impl_write_escaped!(
203 write_text_escaped,
205 memchr3(b'&', b'<', b'>'),
206 TEXT_ESCAPES
207 );
208
209 impl_write_escaped!(
210 write_attribute_value_escaped,
212 memchr2(b'&', b'"'),
213 ATTRIBUTE_VALUE_ESCAPES
214 );
215
216 impl_write_escaped!(
217 write_comment_escaped,
219 memchr3(b'&', b'>', b'"'),
220 COMMENT_ESCAPES
221 );
222
223 fn write_ident(&mut self, s: &str) {
226 let invalid = s.bytes().fold(false, |invalid, b| {
230 invalid | HtmlContext::forbidden_in_ident(b)
231 });
232 if invalid {
233 self.panic_invalid_ident(s);
234 }
235 self.f.write_str(s);
236 }
237
238 #[cold]
240 fn panic_invalid_ident(&self, s: &str) -> ! {
241 let position = s
242 .bytes()
243 .position(HtmlContext::forbidden_in_ident)
244 .expect("an invalid ident contains a forbidden byte");
245 let c = s.as_bytes()[position] as char;
247 panic!(
248 "invalid {} {s:?}: forbidden character {c:?}",
249 self.context.description(),
250 );
251 }
252}
253
254impl fmt::Write for HtmlWriter<'_, '_> {
255 fn write_str(&mut self, s: &str) -> fmt::Result {
256 HtmlWriter::write_str(self, s);
257 Ok(())
258 }
259
260 fn write_char(&mut self, c: char) -> fmt::Result {
261 HtmlWriter::write_char(self, c);
262 Ok(())
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn write(context: HtmlContext, s: &str) -> String {
271 let mut buf = String::new();
272 let mut f = Formatter::new(&mut buf);
273 context.writer(&mut f).write_str(s);
274 buf
275 }
276
277 fn write_char(context: HtmlContext, c: char) -> String {
278 let mut buf = String::new();
279 let mut f = Formatter::new(&mut buf);
280 context.writer(&mut f).write_char(c);
281 buf
282 }
283
284 #[test]
285 fn unescaped_passthrough() {
286 assert_eq!(write(HtmlContext::Unescaped, "<b>&\"'</b>"), "<b>&\"'</b>");
287 assert_eq!(write_char(HtmlContext::Unescaped, '<'), "<");
288 }
289
290 #[test]
291 fn text_escapes_amp_lt_gt() {
292 assert_eq!(
293 write(HtmlContext::Text, "a < b && c > d"),
294 "a < b && c > d"
295 );
296 }
297
298 #[test]
299 fn text_leaves_quotes() {
300 assert_eq!(
301 write(HtmlContext::Text, "she said \"hi\" and 'bye'"),
302 "she said \"hi\" and 'bye'"
303 );
304 }
305
306 #[test]
307 fn text_empty_string() {
308 assert_eq!(write(HtmlContext::Text, ""), "");
309 }
310
311 #[test]
312 fn text_run_of_consecutive_specials() {
313 assert_eq!(write(HtmlContext::Text, "abc<>&def"), "abc<>&def");
314 }
315
316 #[test]
317 fn text_quote_inside_special_run_ends_it() {
318 assert_eq!(write(HtmlContext::Text, "<\">"), "<\">");
321 }
322
323 #[test]
324 fn text_specials_at_boundaries() {
325 assert_eq!(write(HtmlContext::Text, "<middle>"), "<middle>");
326 assert_eq!(write(HtmlContext::Text, "&start"), "&start");
327 assert_eq!(write(HtmlContext::Text, "end&"), "end&");
328 }
329
330 #[test]
331 fn text_long_plain_run() {
332 let s = "abcdefghij".repeat(50);
334 assert_eq!(write(HtmlContext::Text, &s), s);
335 }
336
337 #[test]
338 fn text_special_inside_long_run() {
339 let s = format!("{}<{}", "a".repeat(100), "b".repeat(100));
340 let expected = format!("{}<{}", "a".repeat(100), "b".repeat(100));
341 assert_eq!(write(HtmlContext::Text, &s), expected);
342 }
343
344 #[test]
345 fn text_multibyte_utf8() {
346 assert_eq!(
347 write(HtmlContext::Text, "café < résumé"),
348 "café < résumé"
349 );
350 }
351
352 #[test]
353 fn text_multibyte_hugging_specials() {
354 assert_eq!(write(HtmlContext::Text, "é<é>é&é"), "é<é>é&é");
357 }
358
359 #[test]
360 fn text_multibyte_codepoint_embeds_special_byte() {
361 assert_eq!(
366 write(HtmlContext::Text, "\u{263C}<\u{2026}&\u{203E}>"),
367 "\u{263C}<\u{2026}&\u{203E}>"
368 );
369 }
370
371 #[test]
372 fn text_write_char() {
373 assert_eq!(write_char(HtmlContext::Text, '<'), "<");
374 assert_eq!(write_char(HtmlContext::Text, '"'), "\"");
375 assert_eq!(write_char(HtmlContext::Text, 'é'), "é");
376 }
377
378 #[test]
379 fn attribute_value_escapes_amp_and_quote() {
380 assert_eq!(
381 write(HtmlContext::AttributeValue, "a=\"b\" & c"),
382 "a="b" & c"
383 );
384 }
385
386 #[test]
387 fn attribute_value_leaves_lt_gt_apostrophe() {
388 assert_eq!(
389 write(HtmlContext::AttributeValue, "<a href='x'>"),
390 "<a href='x'>"
391 );
392 }
393
394 #[test]
395 fn attribute_value_run_of_consecutive_specials() {
396 assert_eq!(write(HtmlContext::AttributeValue, "x&\"y"), "x&"y");
397 }
398
399 #[test]
400 fn attribute_value_write_char() {
401 assert_eq!(write_char(HtmlContext::AttributeValue, '"'), """);
402 assert_eq!(write_char(HtmlContext::AttributeValue, '<'), "<");
403 }
404
405 #[test]
406 fn comment_escapes_amp_gt_quote() {
407 assert_eq!(
408 write(HtmlContext::Comment, "x --> y && \"z\""),
409 "x --> y && "z""
410 );
411 }
412
413 #[test]
414 fn comment_leaves_lt_and_apostrophe() {
415 assert_eq!(write(HtmlContext::Comment, "<!-- 'a'"), "<!-- 'a'");
416 }
417
418 #[test]
419 fn comment_cannot_terminate_comment() {
420 assert!(!write(HtmlContext::Comment, "--> \"js\" -->").contains("-->"));
421 }
422
423 #[test]
424 fn attribute_key_accepts_common_names() {
425 for key in ["class", "data-x", "aria-label", "@click.prevent", ":href"] {
426 assert_eq!(write(HtmlContext::AttributeKey, key), key);
427 }
428 }
429
430 #[test]
431 fn element_name_accepts_custom_elements() {
432 assert_eq!(write(HtmlContext::ElementName, "my-element"), "my-element");
433 }
434
435 #[test]
436 fn ident_contexts_accept_multibyte() {
437 assert_eq!(write(HtmlContext::ElementName, "emotion-😍"), "emotion-😍");
439 }
440
441 #[test]
442 fn ident_contexts_only_reject_ascii() {
443 assert_eq!(
446 write(HtmlContext::AttributeKey, "x\u{A0}\u{85}y"),
447 "x\u{A0}\u{85}y"
448 );
449 }
450
451 #[test]
452 #[should_panic(expected = "invalid attribute key")]
453 fn attribute_key_rejects_space() {
454 write(HtmlContext::AttributeKey, "on click");
455 }
456
457 #[test]
458 #[should_panic(expected = "invalid attribute key")]
459 fn attribute_key_rejects_equals() {
460 write(HtmlContext::AttributeKey, "a=b");
461 }
462
463 #[test]
464 #[should_panic(expected = "invalid attribute key")]
465 fn attribute_key_rejects_quote() {
466 write(HtmlContext::AttributeKey, "a\"b");
467 }
468
469 #[test]
470 #[should_panic(expected = "invalid element name")]
471 fn element_name_rejects_slash() {
472 write(HtmlContext::ElementName, "div/onmouseover");
473 }
474
475 #[test]
476 #[should_panic(expected = "invalid element name")]
477 fn element_name_rejects_gt() {
478 write(HtmlContext::ElementName, "div><script");
479 }
480
481 #[test]
482 #[should_panic(expected = "invalid element name")]
483 fn element_name_rejects_control() {
484 write(HtmlContext::ElementName, "di\nv");
485 }
486
487 #[test]
488 #[should_panic(expected = "invalid attribute key")]
489 fn ident_write_char_rejects_forbidden() {
490 write_char(HtmlContext::AttributeKey, '=');
491 }
492
493 #[test]
494 fn fmt_write_goes_through_context() {
495 use core::fmt::Write;
496
497 let tag = "<tag>";
498 let mut buf = String::new();
499 let mut f = Formatter::new(&mut buf);
500 write!(HtmlContext::Text.writer(&mut f), "a {tag} b").unwrap();
501 assert_eq!(buf, "a <tag> b");
502 }
503}