const ENTITY_LOOKAHEAD_LIMIT: usize = 32;
pub(crate) fn escape_html_preserving_entities(s: &str, escape_quotes: bool) -> String {
let mut result = String::with_capacity(s.len());
for (i, c) in s.char_indices() {
match c {
'"' if escape_quotes => result.push_str("""),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'&' => {
if is_entity_start(&s[i..]) {
result.push('&');
} else {
result.push_str("&");
}
}
_ => result.push(c),
}
}
result
}
fn is_entity_start(s: &str) -> bool {
let rest = &s[1..];
let bounded_end = rest
.char_indices()
.nth(ENTITY_LOOKAHEAD_LIMIT)
.map(|(idx, _)| idx)
.unwrap_or(rest.len());
let window = &rest[..bounded_end];
let Some(semi_offset) = window.find(';') else {
return false;
};
let body = &window[..semi_offset];
if let Some(numeric) = body.strip_prefix('#') {
if let Some(hex) = numeric
.strip_prefix('x')
.or_else(|| numeric.strip_prefix('X'))
{
return !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit());
}
return !numeric.is_empty() && numeric.chars().all(|c| c.is_ascii_digit());
}
let mut chars = body.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => chars.all(|c| c.is_ascii_alphanumeric()),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_preserves_existing_entity() {
assert_eq!(
escape_html_preserving_entities("a?x=1&y=2", true),
"a?x=1&y=2"
);
}
#[test]
fn test_escapes_bare_ampersand() {
assert_eq!(
escape_html_preserving_entities("a?x=1&y=2", true),
"a?x=1&y=2"
);
}
#[test]
fn test_numeric_entities_and_bare_ampersand() {
assert_eq!(
escape_html_preserving_entities("& & ¬anentity", true),
"& & &notanentity"
);
}
#[test]
fn test_quotes_escaped_only_when_requested() {
assert_eq!(
escape_html_preserving_entities(r#"say "hi""#, true),
"say "hi""
);
assert_eq!(
escape_html_preserving_entities(r#"say "hi""#, false),
r#"say "hi""#
);
}
#[test]
fn test_multibyte_utf8_does_not_panic() {
let value = "caf\u{e9} \u{1F600} \u{4f60}\u{597d} & <tag> \"quoted\"";
assert_eq!(
escape_html_preserving_entities(value, true),
"caf\u{e9} \u{1F600} \u{4f60}\u{597d} & <tag> "quoted""
);
}
#[test]
fn test_unterminated_entity_beyond_lookahead_is_escaped() {
let long_run = "a".repeat(ENTITY_LOOKAHEAD_LIMIT + 5);
let input = format!("&{long_run};");
assert!(escape_html_preserving_entities(&input, true).starts_with("&"));
}
}