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