use std::borrow::Cow;
use tsv_lang::Span;
use crate::whitespace::is_css_whitespace;
fn emit_opaque(
result: &mut String,
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
byte_pos: &mut usize,
consumed: &str,
) {
result.push_str(consumed);
step_past(chars, byte_pos, consumed);
}
fn step_past(
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
byte_pos: &mut usize,
consumed: &str,
) {
for _ in 0..consumed.chars().count() - 1 {
chars.next();
}
*byte_pos += consumed.len() - consumed.chars().next().map_or(0, char::len_utf8);
}
fn unquoted_url_token_at(s: &str, start: usize) -> Option<&str> {
let bytes = s.as_bytes();
let open = start + 3;
if bytes.get(open) != Some(&b'(') || !s[start..open].eq_ignore_ascii_case("url") {
return None;
}
if s[..start]
.chars()
.next_back()
.is_some_and(super::is_css_ident_code_point)
{
return None;
}
if super::url_arg_is_quoted(s, open) {
return None;
}
let mut end = open;
super::consume_paren_group(s, &mut end);
Some(&s[start..end])
}
pub(crate) fn normalize_css_whitespace(s: &str) -> Cow<'_, str> {
if s.bytes().all(is_normalize_noop_byte) {
return Cow::Borrowed(s);
}
let mut result = String::with_capacity(s.len());
let mut chars = s.char_indices().map(|(_, c)| c).peekable();
let mut byte_pos = 0usize;
let mut in_string = false;
let mut string_delim = '\0';
let mut pending_space = false;
let mut paren_depth = 0usize;
let mut escape_payload_end = 0usize;
while let Some(ch) = chars.next() {
let ch_start = byte_pos;
byte_pos += ch.len_utf8();
if !in_string && crate::comments::is_comment_start(s.as_bytes(), ch_start) {
if !result.is_empty() && !result.ends_with(' ') && !result.ends_with('(') {
result.push(' ');
}
let end = crate::comments::comment_end(s.as_bytes(), ch_start);
emit_opaque(&mut result, &mut chars, &mut byte_pos, &s[ch_start..end]);
pending_space = true;
continue;
}
if !in_string && (ch == '\'' || ch == '"') {
if pending_space && !result.is_empty() && !result.ends_with('(') {
result.push(' ');
}
pending_space = false;
in_string = true;
string_delim = ch;
result.push(ch);
continue;
}
if in_string && ch == '\\' {
result.push(ch);
if let Some(next) = chars.next() {
result.push(next);
byte_pos += next.len_utf8();
}
continue;
}
if in_string && ch == string_delim {
in_string = false;
result.push(ch);
pending_space = false;
continue;
}
if in_string {
result.push(ch);
continue;
}
if ch == '\\'
&& let Some(len) = crate::escapes::escape_len(s, ch_start)
{
if pending_space && !result.is_empty() {
result.push(' ');
}
pending_space = false;
emit_opaque(
&mut result,
&mut chars,
&mut byte_pos,
&s[ch_start..ch_start + len],
);
if len == 2 && is_css_whitespace(s.as_bytes()[ch_start + 1] as char) {
escape_payload_end = result.len();
}
continue;
}
if let Some(raw) = unquoted_url_token_at(s, ch_start) {
if pending_space && !result.is_empty() {
result.push(' ');
}
pending_space = false;
let trimmed = crate::url::trim_url_raw(raw).unwrap_or(Cow::Borrowed(raw));
result.push_str(&trimmed);
step_past(&mut chars, &mut byte_pos, raw);
continue;
}
if ch == '(' {
if pending_space && !result.is_empty() {
result.push(' ');
}
pending_space = false;
paren_depth += 1;
result.push(ch);
while chars.peek().is_some_and(|&c| is_css_whitespace(c)) {
chars.next();
byte_pos += 1;
}
continue;
}
if ch == ')' {
if paren_depth > 0 {
paren_depth -= 1;
while result.len() > escape_payload_end && result.ends_with(' ') {
result.pop();
}
} else if pending_space && !result.is_empty() {
result.push(' ');
}
result.push(ch);
pending_space = false;
continue;
}
if ch == ',' {
while result.len() > escape_payload_end && result.ends_with(' ') {
result.pop();
}
result.push(ch);
pending_space = true;
continue;
}
if is_css_whitespace(ch) {
if !result.is_empty() && !result.ends_with('(') {
pending_space = true;
}
continue;
}
if pending_space && !result.is_empty() {
result.push(' ');
pending_space = false;
}
result.push(ch);
}
let trimmed = crate::escapes::trim_css(&result);
if trimmed.len() == result.len() {
Cow::Owned(result)
} else {
Cow::Owned(trimmed.to_string())
}
}
pub(crate) fn collapse_whitespace_runs(s: &str) -> Cow<'_, str> {
if !needs_whitespace_collapse(s) {
return Cow::Borrowed(s);
}
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
let mut byte_pos = 0usize;
let mut in_string = false;
let mut string_delim = '\0';
while let Some(ch) = chars.next() {
let ch_start = byte_pos;
byte_pos += ch.len_utf8();
if in_string {
result.push(ch);
if ch == '\\' {
if let Some(next) = chars.next() {
result.push(next);
byte_pos += next.len_utf8();
}
} else if ch == string_delim {
in_string = false;
}
continue;
}
if crate::comments::is_comment_start(s.as_bytes(), ch_start) {
let end = crate::comments::comment_end(s.as_bytes(), ch_start);
emit_opaque(&mut result, &mut chars, &mut byte_pos, &s[ch_start..end]);
continue;
}
if ch == '\'' || ch == '"' {
in_string = true;
string_delim = ch;
result.push(ch);
continue;
}
if ch == '\\'
&& let Some(len) = crate::escapes::escape_len(s, ch_start)
{
emit_opaque(
&mut result,
&mut chars,
&mut byte_pos,
&s[ch_start..ch_start + len],
);
continue;
}
if is_css_whitespace(ch) {
while chars.peek().is_some_and(|&next| is_css_whitespace(next)) {
let next = chars.next().unwrap_or_default();
byte_pos += next.len_utf8();
}
result.push(' ');
continue;
}
result.push(ch);
}
Cow::Owned(result)
}
fn needs_whitespace_collapse(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
if b == b' ' {
bytes.get(i + 1) == Some(&b' ')
} else {
b.is_ascii_whitespace()
}
})
}
const NORMALIZE_STRUCTURAL: [u8; 6] = [b'(', b')', b',', b'/', b'\'', b'"'];
pub(crate) fn normalize_is_noop_in(source: &str, span: Span) -> bool {
let bytes = source.as_bytes();
let (from, end) = (span.start as usize, span.end as usize);
let noop =
tsv_lang::swar::next_byte_below_or_of(bytes, from, end, 0x21, NORMALIZE_STRUCTURAL) == end;
debug_assert!(!noop || bytes[from..end].iter().all(|&b| is_normalize_noop_byte(b)));
noop
}
#[inline]
fn is_normalize_noop_byte(b: u8) -> bool {
b.is_ascii()
&& !matches!(
b,
b'\t' | b'\n' | 0x0C | b'\r' | b' ' | b'(' | b')' | b',' | b'/' | b'\'' | b'"'
)
}
pub(crate) fn split_by_space_preserving_parens(content: &str) -> Vec<&str> {
split_top_level(content, |b| b == b' ' || b == b'\t', true)
}
pub(crate) fn split_args_by_comma(content: &str) -> Vec<&str> {
split_top_level(content, |b| b == b',', false)
}
pub(crate) fn has_closing_comma(content: &str, parts: &[&str]) -> bool {
let consumed: usize = parts.iter().map(|part| part.len()).sum();
!parts.is_empty() && content.len() == consumed + parts.len()
}
fn split_top_level(content: &str, is_sep: impl Fn(u8) -> bool, trim: bool) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth: u32 = 0;
let mut start = 0;
let mut in_quote = false;
let mut quote_char = b'\0';
let bytes = content.as_bytes();
let mut i = 0;
while i < content.len() {
if !in_quote && crate::comments::is_comment_start(bytes, i) {
i = crate::comments::comment_end(bytes, i);
continue;
}
if bytes[i] == b'\\'
&& let Some(len) = crate::escapes::escape_len(content, i)
{
i += len;
continue;
}
if !in_quote && (bytes[i] == b'\'' || bytes[i] == b'"') {
in_quote = true;
quote_char = bytes[i];
i += 1;
continue;
}
if in_quote && bytes[i] == quote_char {
in_quote = false;
i += 1;
continue;
}
if in_quote {
i += 1;
continue;
}
match bytes[i] {
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b if depth == 0 && is_sep(b) => {
push_segment(&mut parts, &content[start..i], trim);
start = i + 1;
}
_ => {}
}
i += 1;
}
if start < content.len() {
push_segment(&mut parts, &content[start..], trim);
}
parts
}
fn push_segment<'a>(parts: &mut Vec<&'a str>, segment: &'a str, trim: bool) {
if trim {
let trimmed = crate::escapes::trim_css(segment);
if !trimmed.is_empty() {
parts.push(trimmed);
}
} else {
parts.push(segment);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_css_whitespace_fast_path_equivalence() {
for s in [
"red", "1px", "#fff", "flex", "inherit", "-0.5em", "bold!", "a-b_c",
] {
assert_eq!(
normalize_css_whitespace(s),
s,
"bare token {s:?} must pass through"
);
}
assert_eq!(normalize_css_whitespace(""), "");
assert_eq!(normalize_css_whitespace("a b"), "a b"); assert_eq!(normalize_css_whitespace("( a )"), "(a)"); assert_eq!(normalize_css_whitespace("a , b"), "a, b"); assert_eq!(normalize_css_whitespace("a,b"), "a, b");
assert_eq!(normalize_css_whitespace(" red "), "red"); assert_eq!(normalize_css_whitespace("a\tb"), "a b");
assert_eq!(normalize_css_whitespace("u\u{000B}v"), "u\u{000B}v"); assert_eq!(
normalize_css_whitespace("u\u{000B}\u{000B}v"),
"u\u{000B}\u{000B}v"
);
assert_eq!(normalize_css_whitespace("a\u{00A0}b"), "a\u{00A0}b"); assert_eq!(
normalize_css_whitespace("a\u{00A0}\u{00A0}b"),
"a\u{00A0}\u{00A0}b"
); assert_eq!(normalize_css_whitespace("émotion"), "émotion"); }
#[test]
fn a_whitespace_escapes_payload_survives_the_delimiter_strips() {
assert_eq!(normalize_css_whitespace("(1px\\ )"), "(1px\\ )");
assert_eq!(normalize_css_whitespace("(a\\ ,b)"), "(a\\ , b)");
assert_eq!(normalize_css_whitespace("(a\\\t)"), "(a\\\t)");
assert_eq!(normalize_css_whitespace("( 1px\\ )"), "(1px\\ )");
assert_eq!(normalize_css_whitespace("(a\\41 )"), "(a\\41)");
assert_eq!(normalize_css_whitespace("(a\\41 ,b)"), "(a\\41, b)");
}
#[test]
fn an_unquoted_url_token_is_opaque() {
assert_eq!(normalize_css_whitespace("url(x,y)"), "url(x,y)");
assert_eq!(normalize_css_whitespace("url(x ,y)"), "url(x ,y)");
assert_eq!(normalize_css_whitespace("URL( x ,y )"), "URL(x ,y)");
assert_eq!(normalize_css_whitespace("a url( x,y ) b"), "a url(x,y) b");
assert_eq!(normalize_css_whitespace("url(a(b,c))"), "url(a(b,c))");
assert_eq!(normalize_css_whitespace("url(a\\),b)"), "url(a\\),b)");
assert_eq!(normalize_css_whitespace("url( 'x' ,y )"), "url('x', y)");
assert_eq!(normalize_css_whitespace("fooURL(x,y)"), "fooURL(x, y)");
}
#[test]
fn a_stray_close_paren_keeps_its_separator() {
assert_eq!(
normalize_css_whitespace("screen ) print"),
"screen ) print"
);
assert_eq!(normalize_css_whitespace("screen )"), "screen )");
assert_eq!(normalize_css_whitespace("( a ) ) ( b )"), "(a) ) (b)");
assert_eq!(normalize_css_whitespace(")a"), ")a");
}
#[test]
fn test_collapse_whitespace_runs() {
assert_eq!(collapse_whitespace_runs(".a > . > .b"), ".a > . > .b");
assert_eq!(collapse_whitespace_runs(".a\t>\t.b"), ".a > .b");
assert_eq!(collapse_whitespace_runs(".a >\n> .b"), ".a > > .b");
assert_eq!(collapse_whitespace_runs(".a \t\n .b"), ".a .b");
assert_eq!(collapse_whitespace_runs(".a\t.b"), ".a .b");
let out = collapse_whitespace_runs(".a > .b");
assert_eq!(out, ".a > .b");
assert!(matches!(out, Cow::Borrowed(_)));
assert!(matches!(collapse_whitespace_runs(".a.b"), Cow::Borrowed(_)));
assert_eq!(
collapse_whitespace_runs("[data-x='a b'] .c"),
"[data-x='a b'] .c"
);
assert_eq!(
collapse_whitespace_runs("[data-x=\"a\tb\"]"),
"[data-x=\"a\tb\"]"
);
assert_eq!(
collapse_whitespace_runs(".a /* c d */ .b"),
".a /* c d */ .b"
);
assert_eq!(
collapse_whitespace_runs(r"[x='a\' b'] .c"),
r"[x='a\' b'] .c"
);
let once = collapse_whitespace_runs(".a >\n> .b").into_owned();
assert_eq!(collapse_whitespace_runs(&once), once);
}
#[test]
fn test_split_args_by_comma() {
assert_eq!(split_args_by_comma("a, b, c"), vec!["a", " b", " c"]);
assert_eq!(split_args_by_comma("--a, red"), vec!["--a", " red"]);
assert_eq!(
split_args_by_comma("--a, calc(1, 2)"),
vec!["--a", " calc(1, 2)"]
);
assert_eq!(split_args_by_comma("--a"), vec!["--a"]);
assert_eq!(split_args_by_comma(""), Vec::<&str>::new());
assert_eq!(split_args_by_comma("a,,b"), vec!["a", "", "b"]);
assert_eq!(split_args_by_comma(",a"), vec!["", "a"]);
assert_eq!(split_args_by_comma(","), vec![""]);
assert_eq!(split_args_by_comma("a,b,"), vec!["a", "b"]);
assert_eq!(split_args_by_comma(",,"), vec!["", ""]);
assert_eq!(
split_args_by_comma("--a, /* with, comma */ red"),
vec!["--a", " /* with, comma */ red"]
);
assert_eq!(
split_args_by_comma("/* a, b */ value"),
vec!["/* a, b */ value"]
);
assert_eq!(
split_args_by_comma(r#"--font, "Font, Name""#),
vec!["--font", r#" "Font, Name""#]
);
assert_eq!(
split_args_by_comma(r"'a, b', 'c, d'"),
vec!["'a, b'", " 'c, d'"]
);
}
#[test]
fn test_split_by_space_preserving_parens() {
assert_eq!(
split_by_space_preserving_parens("var(--b) color-mix(in srgb, red, blue)"),
vec!["var(--b)", "color-mix(in srgb, red, blue)"]
);
assert_eq!(split_by_space_preserving_parens("a b"), vec!["a", "b"]);
assert_eq!(split_by_space_preserving_parens(" a "), vec!["a"]);
assert_eq!(split_by_space_preserving_parens("a\tb"), vec!["a", "b"]);
assert_eq!(split_by_space_preserving_parens("solid"), vec!["solid"]);
assert_eq!(split_by_space_preserving_parens(" "), Vec::<&str>::new());
assert_eq!(
split_by_space_preserving_parens("calc(1px + 2px) red"),
vec!["calc(1px + 2px)", "red"]
);
assert_eq!(
split_by_space_preserving_parens("a /* x y */ b"),
vec!["a", "/* x y */", "b"]
);
assert_eq!(
split_by_space_preserving_parens(r"'Font Name' serif"),
vec!["'Font Name'", "serif"]
);
}
#[test]
fn host_word_noop_scan_agrees_with_the_byte_predicate() {
for b in 0..=255u8 {
let by_predicate = is_normalize_noop_byte(b);
let by_word = (0x21..0x80).contains(&b) && !NORMALIZE_STRUCTURAL.contains(&b);
if b < 0x21 && by_predicate {
assert!(!by_word, "byte {b:#x}");
} else {
assert_eq!(by_predicate, by_word, "byte {b:#x}");
}
}
let doc = "red (1px - 2px) a,b /*c*/ 'q' \u{a0}x\u{b}y\tz\u{feff}";
let bytes = doc.as_bytes();
for from in 0..=bytes.len() {
for end in from..=bytes.len() {
if !doc.is_char_boundary(from) || !doc.is_char_boundary(end) {
continue;
}
let noop = normalize_is_noop_in(doc, Span::new(from as u32, end as u32));
let all = bytes[from..end].iter().all(|&b| is_normalize_noop_byte(b));
assert!(!noop || all, "a true must be exact: [{from}, {end})");
if noop != all {
assert!(
bytes[from..end]
.iter()
.any(|&b| b < 0x21 && is_normalize_noop_byte(b)),
"only a kept control byte may be refused: [{from}, {end})"
);
}
}
}
}
}