use crate::whitespace::is_css_whitespace;
use bumpalo::Bump;
use bumpalo::collections::String as BumpString;
use std::borrow::Cow;
pub(crate) fn decode_escape_sequences(source: &str) -> Cow<'_, str> {
if !source.contains('\\') {
return Cow::Borrowed(source);
}
let mut result = String::with_capacity(source.len());
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(&next_ch) = chars.peek() {
if next_ch.is_ascii_hexdigit() {
let mut code_point: u32 = 0;
for _ in 0..6 {
match chars.peek().and_then(|d| d.to_digit(16)) {
Some(d) => {
code_point = code_point * 16 + d;
chars.next();
}
None => break,
}
}
if let Some(&ws) = chars.peek()
&& is_css_whitespace(ws)
{
chars.next();
}
if let Some(c) = char::from_u32(code_point) {
result.push(c);
}
} else {
result.push(next_ch);
chars.next();
}
} else {
result.push('\\');
}
} else {
result.push(ch);
}
}
Cow::Owned(result)
}
#[inline]
pub(crate) fn decode_escape_sequences_in<'arena>(
source: &str,
arena: &'arena Bump,
) -> Option<&'arena str> {
if !source.as_bytes().contains(&b'\\') {
return None;
}
Some(decode_escapes_into_arena(source, arena))
}
#[inline(never)]
fn decode_escapes_into_arena<'arena>(source: &str, arena: &'arena Bump) -> &'arena str {
let mut result = BumpString::with_capacity_in(source.len(), arena);
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
result.push(ch);
continue;
}
let Some(&next_ch) = chars.peek() else {
result.push('\\');
break;
};
if !next_ch.is_ascii_hexdigit() {
result.push(next_ch);
chars.next();
continue;
}
let mut code_point: u32 = 0;
for _ in 0..6 {
match chars.peek().and_then(|d| d.to_digit(16)) {
Some(d) => {
code_point = code_point * 16 + d;
chars.next();
}
None => break,
}
}
if let Some(&ws) = chars.peek()
&& is_css_whitespace(ws)
{
chars.next();
}
if let Some(c) = char::from_u32(code_point) {
result.push(c);
}
}
result.into_bump_str()
}
fn is_escapable_whitespace(c: char) -> bool {
matches!(c, ' ' | '\t')
}
pub(crate) fn escape_len(s: &str, i: usize) -> Option<usize> {
debug_assert_eq!(s.as_bytes().get(i), Some(&b'\\'));
let rest = s.get(i + 1..)?;
let first = rest.chars().next()?;
if matches!(first, '\n' | '\r' | '\x0C') {
return None;
}
if !first.is_ascii_hexdigit() {
return Some(1 + first.len_utf8());
}
let hex = rest
.bytes()
.take(6)
.take_while(u8::is_ascii_hexdigit)
.count();
let terminator = rest[hex..]
.chars()
.next()
.filter(|&c| is_css_whitespace(c))
.map_or(0, char::len_utf8);
Some(1 + hex + terminator)
}
pub(crate) fn escape_span_at(s: &str, i: usize) -> Option<usize> {
if s.as_bytes().get(i) != Some(&b'\\') {
return None;
}
Some(i + escape_len(s, i)?)
}
pub(crate) fn decodes_to_ascii_ignore_case(s: &str, kw: &[u8]) -> bool {
let verdict = decoded_bytes_eq_ignore_ascii_case(s, kw);
debug_assert_eq!(
verdict,
decode_escape_sequences(s)
.as_bytes()
.eq_ignore_ascii_case(kw)
);
verdict
}
fn decoded_bytes_eq_ignore_ascii_case(s: &str, kw: &[u8]) -> bool {
fn take(kw: &mut std::slice::Iter<'_, u8>, text: &[u8]) -> bool {
text.iter()
.all(|b| kw.next().is_some_and(|k| k.eq_ignore_ascii_case(b)))
}
let bytes = s.as_bytes();
let mut kw = kw.iter();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
if b != b'\\' {
if !take(&mut kw, &[b]) {
return false;
}
i += 1;
continue;
}
let Some(len) = escape_len(s, i) else {
let escaped = match bytes.get(i + 1) {
Some(next) => {
i += 2;
std::slice::from_ref(next)
}
None => {
i += 1;
b"\\"
}
};
if !take(&mut kw, escaped) {
return false;
}
continue;
};
let body = &bytes[i + 1..i + len];
i += len;
if body.first().is_some_and(u8::is_ascii_hexdigit) {
let code_point = body
.iter()
.filter_map(|&d| char::from(d).to_digit(16))
.fold(0, |cp, d| cp * 16 + d);
let mut utf8 = [0u8; 4];
if let Some(c) = char::from_u32(code_point)
&& !take(&mut kw, c.encode_utf8(&mut utf8).as_bytes())
{
return false;
}
} else if !take(&mut kw, body) {
return false;
}
}
kw.next().is_none()
}
#[inline]
pub(crate) fn trim_end_preserving_escape(s: &str) -> &str {
let trimmed = s.trim_ascii_end();
debug_assert_eq!(trimmed, s.trim_end_matches(is_css_whitespace));
if trimmed.len() == s.len() {
return trimmed;
}
restore_escape_payload(s, trimmed)
}
#[cold]
#[inline(never)]
fn restore_escape_payload<'a>(s: &'a str, trimmed: &'a str) -> &'a str {
if !tsv_lang::printing::ends_with_open_escape(trimmed.as_bytes()) {
return trimmed;
}
match s[trimmed.len()..].chars().next() {
Some(c) if is_escapable_whitespace(c) => &s[..trimmed.len() + c.len_utf8()],
_ => trimmed,
}
}
#[inline]
pub(crate) fn trim_start_css(s: &str) -> &str {
let trimmed = s.trim_ascii_start();
debug_assert_eq!(trimmed, s.trim_start_matches(is_css_whitespace));
trimmed
}
pub(crate) fn ends_with_live_hex_escape(name: &str) -> bool {
let bytes = name.as_bytes();
let mut i = 0;
let mut ends_with_escape = false;
while i < bytes.len() {
if let Some(end) = escape_span_at(name, i) {
ends_with_escape = bytes[i + 1].is_ascii_hexdigit()
&& end == bytes.len()
&& bytes[end - 1].is_ascii_hexdigit();
i = end;
continue;
}
ends_with_escape = false;
i += 1;
}
ends_with_escape
}
#[inline]
pub(crate) fn trim_css(s: &str) -> &str {
trim_end_preserving_escape(trim_start_css(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_escape_sequences_simple() {
assert_eq!(decode_escape_sequences(r"\\"), r"\");
assert_eq!(decode_escape_sequences(r#"\""#), r#"""#);
assert_eq!(decode_escape_sequences(r"\'"), r"'");
}
#[test]
fn a_live_hex_escape_is_one_still_owed_its_terminator() {
assert!(ends_with_live_hex_escape(r"c\41"));
assert!(ends_with_live_hex_escape(r"\41"));
assert!(ends_with_live_hex_escape(r"c\4")); assert!(ends_with_live_hex_escape(r"c\41abc")); assert!(!ends_with_live_hex_escape("c\\41 "));
assert!(!ends_with_live_hex_escape(r"c\41x"));
assert!(!ends_with_live_hex_escape(r"c\g"));
assert!(!ends_with_live_hex_escape(r"c\\41"));
assert!(!ends_with_live_hex_escape("c41"));
assert!(!ends_with_live_hex_escape(""));
}
#[test]
fn test_decode_escape_sequences_unicode() {
assert_eq!(decode_escape_sequences(r"\41"), "A");
assert_eq!(decode_escape_sequences(r"\1F4A9"), "💩");
assert_eq!(decode_escape_sequences(r"\01F4A9"), "💩");
}
#[test]
fn the_arena_decoder_matches_the_reference_decoder() {
let fragments = [
"a",
"é",
"0",
" ",
"\\",
"\\\\",
"\\4",
"\\41",
"\\041",
"\\0041",
"\\00041",
"\\000041",
"\\0000411",
"\\41 ",
"\\41\t",
"\\41\n",
"\\41\r\n",
"\\41\x0c",
"\\110000",
"\\D800",
"\\0",
"\\\n",
"\\\"",
"\\'",
"\\g",
"\\é",
];
let arena = Bump::new();
let mut decoded_cases = 0u32;
let mut check = |source: &str| {
let reference = decode_escape_sequences(source);
match (decode_escape_sequences_in(source, &arena), &reference) {
(None, Cow::Borrowed(_)) => {}
(Some(decoded), Cow::Owned(reference)) => {
assert_eq!(decoded, reference, "{source:?}");
decoded_cases += 1;
}
(ours, _) => panic!("{source:?}: arena {ours:?}, reference {reference:?}"),
}
};
check("");
for a in fragments {
check(a);
for b in fragments {
check(&format!("{a}{b}"));
for c in fragments {
check(&format!("{a}{b}{c}"));
}
}
}
assert!(
decoded_cases > 10_000,
"{decoded_cases} escaped sources decoded"
);
}
fn oracle(s: &str, kw: &[u8]) -> bool {
decode_escape_sequences(s)
.as_bytes()
.eq_ignore_ascii_case(kw)
}
#[test]
fn decodes_to_ascii_ignore_case_matches_the_decoder() {
let cases: &[(&str, &[u8])] = &[
(r"\75 rl", b"url"),
(r"\75rl", b"url"),
(r"\6c ayer", b"layer"),
(r"\73 upports", b"supports"),
(r"\55 RL", b"url"),
(r"\75 rl", b"URL"),
(r"u\rl", b"url"),
(r"\000075rl", b"url"),
(r"\000075 rl", b"url"),
(r"url\", b"url"),
(r"url\", b"url\\"),
(r"\76 rl", b"url"),
(r"\41", b"a"),
(r"\e9", b"\xc3\xa9"),
(r"\e9", b"e"),
(r"\d800url", b"url"),
(r"\110000url", b"url"),
(r"\75 r", b"url"),
(r"\75 rll", b"url"),
("", b""),
("", b"a"),
("u\\\nrl", b"u\nrl"),
];
for &(s, kw) in cases {
assert_eq!(
decodes_to_ascii_ignore_case(s, kw),
oracle(s, kw),
"{s:?} vs {kw:?}"
);
}
assert!(decodes_to_ascii_ignore_case(r"\75 rl", b"url"));
assert!(!decodes_to_ascii_ignore_case(r"\76 rl", b"url"));
}
}
#[cfg(test)]
mod escape_scan_tests {
use super::{escape_len, trim_end_preserving_escape, trim_start_css};
#[test]
fn trim_keeps_an_escaped_space() {
assert_eq!(trim_end_preserving_escape(r"50px\ "), r"50px\ ");
assert_eq!(trim_end_preserving_escape("50px\\ \t "), r"50px\ ");
assert_eq!(trim_end_preserving_escape("a\\\t"), "a\\\t");
}
#[test]
fn trim_drops_ordinary_whitespace() {
assert_eq!(trim_end_preserving_escape("50px "), "50px");
assert_eq!(trim_end_preserving_escape("50px"), "50px");
assert_eq!(trim_end_preserving_escape(r"\41 "), r"\41");
}
#[test]
fn trim_counts_the_backslash_run_parity() {
assert_eq!(trim_end_preserving_escape(r"a\\ "), r"a\\");
assert_eq!(trim_end_preserving_escape(r"a\\\ "), r"a\\\ ");
assert_eq!(trim_end_preserving_escape(r"a\\\\ "), r"a\\\\");
}
#[test]
fn trim_edges() {
assert_eq!(trim_end_preserving_escape(""), "");
assert_eq!(trim_end_preserving_escape(" "), "");
assert_eq!(trim_end_preserving_escape(r"\ "), r"\ ");
assert_eq!(trim_end_preserving_escape(r"\\ "), r"\\");
assert_eq!(trim_end_preserving_escape(r"a\"), r"a\");
}
#[test]
fn trim_never_keeps_a_newline() {
assert_eq!(trim_end_preserving_escape("a\\\n"), "a\\");
assert_eq!(trim_end_preserving_escape("a\\\r\n"), "a\\");
}
#[test]
fn trim_leaves_non_css_whitespace_alone() {
assert_eq!(trim_end_preserving_escape("a\u{a0}"), "a\u{a0}");
assert_eq!(trim_end_preserving_escape("a\u{3000}"), "a\u{3000}");
assert_eq!(trim_end_preserving_escape("a\\\u{a0}"), "a\\\u{a0}");
assert_eq!(trim_end_preserving_escape("a\u{a0} "), "a\u{a0}");
}
#[test]
fn trim_start_css_leaves_non_css_whitespace_alone() {
assert_eq!(trim_start_css(" a"), "a");
assert_eq!(trim_start_css("\u{a0}a"), "\u{a0}a");
assert_eq!(trim_start_css(" \u{a0}a"), "\u{a0}a");
}
#[test]
fn escape_len_identity_escapes() {
assert_eq!(escape_len(r"\ x", 0), Some(2)); assert_eq!(escape_len(r"\,y", 0), Some(2)); assert_eq!(escape_len(r"\)b", 0), Some(2)); assert_eq!(escape_len("\\\u{a0}b", 0), Some(3));
}
#[test]
fn escape_len_hex_escapes_swallow_one_terminator() {
assert_eq!(escape_len(r"\41 2px", 0), Some(4)); assert_eq!(escape_len(r"\41b", 0), Some(4)); assert_eq!(escape_len(r"\41z", 0), Some(3)); assert_eq!(escape_len(r"\0000411", 0), Some(7)); assert_eq!(escape_len(r"\41 x", 0), Some(4)); assert_eq!(escape_len("\\41\n", 0), Some(4));
}
#[test]
fn escape_len_rejects_a_non_escape() {
assert_eq!(escape_len("\\\n", 0), None); assert_eq!(escape_len("\\\r", 0), None);
assert_eq!(escape_len(r"\", 0), None); }
}