use core::fmt;
use memchr::{memchr2, memchr3};
use crate::Formatter;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HtmlContext {
Unescaped,
Text,
AttributeValue,
Comment,
AttributeKey,
ElementName,
}
impl HtmlContext {
#[inline]
pub fn writer<'a, 'b>(self, f: &'a mut Formatter<'b>) -> HtmlWriter<'a, 'b> {
HtmlWriter { context: self, f }
}
#[inline]
const fn forbidden_in_ident(b: u8) -> bool {
b <= b' ' || b == 0x7F || matches!(b, b'"' | b'\'' | b'/' | b'<' | b'=' | b'>')
}
fn description(self) -> &'static str {
match self {
Self::Unescaped => "unescaped content",
Self::Text => "text",
Self::AttributeValue => "attribute value",
Self::Comment => "comment payload",
Self::AttributeKey => "attribute key",
Self::ElementName => "element name",
}
}
}
type EscapeTable = [Option<&'static str>; 256];
const fn escape_table<const N: usize>(escapes: [(u8, &'static str); N]) -> EscapeTable {
let mut table: EscapeTable = [None; 256];
let mut i = 0;
while i < N {
table[escapes[i].0 as usize] = Some(escapes[i].1);
i += 1;
}
table
}
const TEXT_ESCAPES: EscapeTable = escape_table([(b'&', "&"), (b'<', "<"), (b'>', ">")]);
const ATTRIBUTE_VALUE_ESCAPES: EscapeTable = escape_table([(b'&', "&"), (b'"', """)]);
const COMMENT_ESCAPES: EscapeTable =
escape_table([(b'&', "&"), (b'>', ">"), (b'"', """)]);
macro_rules! impl_write_escaped {
($(#[$doc:meta])* $method:ident, $memchr:ident($($needle:literal),+), $table:ident) => {
$(#[$doc])*
#[inline]
fn $method(&mut self, s: &str) {
let bytes = s.as_bytes();
let mut start = 0;
while let Some(offset) = $memchr($($needle,)+ &bytes[start..]) {
let special = start + offset;
self.f.write_str(&s[start..special]);
let mut end = special;
while let Some(escape) = bytes.get(end).and_then(|&b| $table[b as usize]) {
self.f.write_str(escape);
end += 1;
}
start = end;
}
self.f.write_str(&s[start..]);
}
};
}
pub struct HtmlWriter<'a, 'b> {
context: HtmlContext,
f: &'a mut Formatter<'b>,
}
impl HtmlWriter<'_, '_> {
pub fn write_str(&mut self, s: &str) {
match self.context {
HtmlContext::Unescaped => self.f.write_str(s),
HtmlContext::Text => self.write_text_escaped(s),
HtmlContext::AttributeValue => self.write_attribute_value_escaped(s),
HtmlContext::Comment => self.write_comment_escaped(s),
HtmlContext::AttributeKey | HtmlContext::ElementName => self.write_ident(s),
}
}
pub fn write_char(&mut self, c: char) {
let table = match self.context {
HtmlContext::Unescaped => return self.f.write_char(c),
HtmlContext::Text => &TEXT_ESCAPES,
HtmlContext::AttributeValue => &ATTRIBUTE_VALUE_ESCAPES,
HtmlContext::Comment => &COMMENT_ESCAPES,
HtmlContext::AttributeKey | HtmlContext::ElementName => {
assert!(
!u8::try_from(c).is_ok_and(HtmlContext::forbidden_in_ident),
"invalid {}: forbidden character {c:?}",
self.context.description(),
);
return self.f.write_char(c);
}
};
match table.get(c as usize).copied().flatten() {
Some(escape) => self.f.write_str(escape),
None => self.f.write_char(c),
}
}
impl_write_escaped!(
write_text_escaped,
memchr3(b'&', b'<', b'>'),
TEXT_ESCAPES
);
impl_write_escaped!(
write_attribute_value_escaped,
memchr2(b'&', b'"'),
ATTRIBUTE_VALUE_ESCAPES
);
impl_write_escaped!(
write_comment_escaped,
memchr3(b'&', b'>', b'"'),
COMMENT_ESCAPES
);
fn write_ident(&mut self, s: &str) {
let invalid = s.bytes().fold(false, |invalid, b| {
invalid | HtmlContext::forbidden_in_ident(b)
});
if invalid {
self.panic_invalid_ident(s);
}
self.f.write_str(s);
}
#[cold]
fn panic_invalid_ident(&self, s: &str) -> ! {
let position = s
.bytes()
.position(HtmlContext::forbidden_in_ident)
.expect("an invalid ident contains a forbidden byte");
let c = s.as_bytes()[position] as char;
panic!(
"invalid {} {s:?}: forbidden character {c:?}",
self.context.description(),
);
}
}
impl fmt::Write for HtmlWriter<'_, '_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
HtmlWriter::write_str(self, s);
Ok(())
}
fn write_char(&mut self, c: char) -> fmt::Result {
HtmlWriter::write_char(self, c);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(context: HtmlContext, s: &str) -> String {
let mut buf = String::new();
let mut f = Formatter::new(&mut buf);
context.writer(&mut f).write_str(s);
buf
}
fn write_char(context: HtmlContext, c: char) -> String {
let mut buf = String::new();
let mut f = Formatter::new(&mut buf);
context.writer(&mut f).write_char(c);
buf
}
#[test]
fn unescaped_passthrough() {
assert_eq!(write(HtmlContext::Unescaped, "<b>&\"'</b>"), "<b>&\"'</b>");
assert_eq!(write_char(HtmlContext::Unescaped, '<'), "<");
}
#[test]
fn text_escapes_amp_lt_gt() {
assert_eq!(
write(HtmlContext::Text, "a < b && c > d"),
"a < b && c > d"
);
}
#[test]
fn text_leaves_quotes() {
assert_eq!(
write(HtmlContext::Text, "she said \"hi\" and 'bye'"),
"she said \"hi\" and 'bye'"
);
}
#[test]
fn text_empty_string() {
assert_eq!(write(HtmlContext::Text, ""), "");
}
#[test]
fn text_run_of_consecutive_specials() {
assert_eq!(write(HtmlContext::Text, "abc<>&def"), "abc<>&def");
}
#[test]
fn text_quote_inside_special_run_ends_it() {
assert_eq!(write(HtmlContext::Text, "<\">"), "<\">");
}
#[test]
fn text_specials_at_boundaries() {
assert_eq!(write(HtmlContext::Text, "<middle>"), "<middle>");
assert_eq!(write(HtmlContext::Text, "&start"), "&start");
assert_eq!(write(HtmlContext::Text, "end&"), "end&");
}
#[test]
fn text_long_plain_run() {
let s = "abcdefghij".repeat(50);
assert_eq!(write(HtmlContext::Text, &s), s);
}
#[test]
fn text_special_inside_long_run() {
let s = format!("{}<{}", "a".repeat(100), "b".repeat(100));
let expected = format!("{}<{}", "a".repeat(100), "b".repeat(100));
assert_eq!(write(HtmlContext::Text, &s), expected);
}
#[test]
fn text_multibyte_utf8() {
assert_eq!(
write(HtmlContext::Text, "café < résumé"),
"café < résumé"
);
}
#[test]
fn text_multibyte_hugging_specials() {
assert_eq!(write(HtmlContext::Text, "é<é>é&é"), "é<é>é&é");
}
#[test]
fn text_multibyte_codepoint_embeds_special_byte() {
assert_eq!(
write(HtmlContext::Text, "\u{263C}<\u{2026}&\u{203E}>"),
"\u{263C}<\u{2026}&\u{203E}>"
);
}
#[test]
fn text_write_char() {
assert_eq!(write_char(HtmlContext::Text, '<'), "<");
assert_eq!(write_char(HtmlContext::Text, '"'), "\"");
assert_eq!(write_char(HtmlContext::Text, 'é'), "é");
}
#[test]
fn attribute_value_escapes_amp_and_quote() {
assert_eq!(
write(HtmlContext::AttributeValue, "a=\"b\" & c"),
"a="b" & c"
);
}
#[test]
fn attribute_value_leaves_lt_gt_apostrophe() {
assert_eq!(
write(HtmlContext::AttributeValue, "<a href='x'>"),
"<a href='x'>"
);
}
#[test]
fn attribute_value_run_of_consecutive_specials() {
assert_eq!(write(HtmlContext::AttributeValue, "x&\"y"), "x&"y");
}
#[test]
fn attribute_value_write_char() {
assert_eq!(write_char(HtmlContext::AttributeValue, '"'), """);
assert_eq!(write_char(HtmlContext::AttributeValue, '<'), "<");
}
#[test]
fn comment_escapes_amp_gt_quote() {
assert_eq!(
write(HtmlContext::Comment, "x --> y && \"z\""),
"x --> y && "z""
);
}
#[test]
fn comment_leaves_lt_and_apostrophe() {
assert_eq!(write(HtmlContext::Comment, "<!-- 'a'"), "<!-- 'a'");
}
#[test]
fn comment_cannot_terminate_comment() {
assert!(!write(HtmlContext::Comment, "--> \"js\" -->").contains("-->"));
}
#[test]
fn attribute_key_accepts_common_names() {
for key in ["class", "data-x", "aria-label", "@click.prevent", ":href"] {
assert_eq!(write(HtmlContext::AttributeKey, key), key);
}
}
#[test]
fn element_name_accepts_custom_elements() {
assert_eq!(write(HtmlContext::ElementName, "my-element"), "my-element");
}
#[test]
fn ident_contexts_accept_multibyte() {
assert_eq!(write(HtmlContext::ElementName, "emotion-😍"), "emotion-😍");
}
#[test]
fn ident_contexts_only_reject_ascii() {
assert_eq!(
write(HtmlContext::AttributeKey, "x\u{A0}\u{85}y"),
"x\u{A0}\u{85}y"
);
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn attribute_key_rejects_space() {
write(HtmlContext::AttributeKey, "on click");
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn attribute_key_rejects_equals() {
write(HtmlContext::AttributeKey, "a=b");
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn attribute_key_rejects_quote() {
write(HtmlContext::AttributeKey, "a\"b");
}
#[test]
#[should_panic(expected = "invalid element name")]
fn element_name_rejects_slash() {
write(HtmlContext::ElementName, "div/onmouseover");
}
#[test]
#[should_panic(expected = "invalid element name")]
fn element_name_rejects_gt() {
write(HtmlContext::ElementName, "div><script");
}
#[test]
#[should_panic(expected = "invalid element name")]
fn element_name_rejects_control() {
write(HtmlContext::ElementName, "di\nv");
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn ident_write_char_rejects_forbidden() {
write_char(HtmlContext::AttributeKey, '=');
}
#[test]
fn fmt_write_goes_through_context() {
use core::fmt::Write;
let tag = "<tag>";
let mut buf = String::new();
let mut f = Formatter::new(&mut buf);
write!(HtmlContext::Text.writer(&mut f), "a {tag} b").unwrap();
assert_eq!(buf, "a <tag> b");
}
}