diff --git a/.changepacks/changepack_log_quoted_css_boundaries.json b/.changepacks/changepack_log_quoted_css_boundaries.json new file mode 100644 index 000000000..0727a0dc4 --- /dev/null +++ b/.changepacks/changepack_log_quoted_css_boundaries.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Preserve quoted CSS text during selector substitution, media-query folding and template interpolation placement, and resume optimization after bad-string newlines without changing EOF string contents. No new styling runtime or build-error kind is added.", + "date": "2026-10-20T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_quoted_css_strings.json b/.changepacks/changepack_log_quoted_css_strings.json new file mode 100644 index 000000000..6a67ea5fc --- /dev/null +++ b/.changepacks/changepack_log_quoted_css_strings.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep quoted strings in CSS values, template blocks and selectors byte for byte: a number, color, space, comma, semicolon, brace, parenthesis or comment marker inside quotes is data, so content: '0.5' no longer becomes '.5'. The text around a string is still optimized, with no styling runtime and no new build errors.", + "date": "2026-10-20T00:00:00.000Z" +} diff --git a/libs/css/src/at_rule.rs b/libs/css/src/at_rule.rs index 55b0c8e41..2f255cb25 100644 --- a/libs/css/src/at_rule.rs +++ b/libs/css/src/at_rule.rs @@ -1,6 +1,7 @@ //! At-rule preludes: media shorthands, query normalization, media query //! combination, and the deterministic order at-rule blocks are emitted in. +use crate::quoted::{string_end, trim_css, unquoted_bytes}; use crate::style_selector::AtRuleKind; use crate::utils::to_kebab_case; @@ -33,7 +34,7 @@ pub fn media_shorthand_query(name: &str) -> Option<&'static str> { /// Split an at-rule key such as `@media print` into its kind and prelude. #[must_use] pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> { - let rest = key.trim().strip_prefix('@')?; + let rest = trim_css(key).strip_prefix('@')?; [ ("media", AtRuleKind::Media), ("supports", AtRuleKind::Supports), @@ -42,7 +43,8 @@ pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> { .into_iter() .find_map(|(name, kind)| { let query = rest.strip_prefix(name)?; - (query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim())) + (query.starts_with([' ', '(']) && !trim_css(query).is_empty()) + .then_some((kind, trim_css(query))) }) } @@ -109,8 +111,8 @@ pub fn normalize_query(query: &str) -> String { // arguments can contain significant whitespace. let mut parens: Vec = Vec::new(); let mut pending_space = false; - let mut chars = query.chars(); - while let Some(c) = chars.next() { + let mut chars = query.char_indices().peekable(); + while let Some((index, c)) = chars.next() { if c.is_whitespace() { pending_space = true; continue; @@ -129,21 +131,20 @@ pub fn normalize_query(query: &str) -> String { pending_space = false; out.push(c); match c { + '\\' => { + if let Some((_, escaped)) = chars.next() { + out.push(escaped); + } + } '(' => parens.push(function_paren), ')' => { parens.pop(); } '"' | '\'' => { - let mut escaped = false; - for inner in chars.by_ref() { - out.push(inner); - if escaped { - escaped = false; - } else if inner == '\\' { - escaped = true; - } else if inner == c { - break; - } + let end = string_end(query.as_bytes(), index, query.as_bytes()[index]); + out.push_str(&query[index + 1..end]); + while chars.peek().is_some_and(|&(index, _)| index < end) { + chars.next(); } } _ => {} @@ -174,18 +175,18 @@ fn split_list(query: &str) -> Vec<&str> { let mut parts = Vec::new(); let mut depth = 0usize; let mut start = 0; - for (index, c) in query.char_indices() { + for (index, c) in unquoted_bytes(query) { match c { - '(' => depth += 1, - ')' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(query[start..index].trim()); + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + parts.push(trim_css(&query[start..index])); start = index + 1; } _ => {} } } - parts.push(query[start..].trim()); + parts.push(trim_css(&query[start..])); parts } @@ -261,11 +262,13 @@ const fn resolve_negation(query: MediaQuery<'_>) -> Result, Media fn has_top_level_or(condition: &str) -> bool { let mut depth = 0usize; let mut previous = ' '; - for (index, c) in condition.char_indices() { + for (index, byte) in unquoted_bytes(condition) { + let c = char::from(byte); match c { '(' => depth += 1, ')' => depth = depth.saturating_sub(1), _ if depth == 0 + && matches!(byte, b'o' | b'O') && !is_word_char(previous) && leading_word(&condition[index..]).eq_ignore_ascii_case("or") => { diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 88f5a8bf5..ccb989a0c 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -12,6 +12,7 @@ pub mod is_special_property; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; +pub mod quoted; pub mod rm_css_comment; mod selector_separator; pub mod slot_reservation; @@ -83,13 +84,9 @@ mod prefix_state { use prefix_state::with_prefix; pub use prefix_state::{get_prefix, set_prefix}; -/// Write `value` into `out`, replacing every `&` with `"."` in place. -/// -/// Equivalent to `out.push_str(&value.replace('&', &format!(".{class_name}")))` -/// but avoids both the throwaway `"."` String and the intermediate -/// `str::replace` result String. +/// Expand nesting tokens without copying or modifying quoted/escaped text. fn write_ampersand_expansion(out: &mut String, value: &str, class_name: &str) { - for (i, seg) in value.split('&').enumerate() { + for (i, seg) in quoted::split_unquoted(value, b'&').enumerate() { if i > 0 { out.push('.'); out.push_str(class_name); @@ -314,7 +311,12 @@ fn params_position(selector: &str) -> Option { let mut depth = 0usize; let mut found = None; let mut index = 0; + let mut quotes = quoted::QuoteState::default(); while let Some(&byte) = bytes.get(index) { + if !quotes.is_syntax(byte) { + index += 1; + continue; + } match byte { b'(' | b'[' => depth += 1, b')' | b']' => depth = depth.saturating_sub(1), @@ -610,7 +612,7 @@ pub fn sheet_to_classname( ); } if is_debug() { - let selector = selector.unwrap_or_default().trim(); + let selector = quoted::trim_css(selector.unwrap_or_default()); let encoded = if selector.is_empty() { String::new() } else { @@ -643,7 +645,7 @@ pub fn sheet_to_classname( result }) } else { - let trimmed_selector = selector.unwrap_or_default().trim(); + let trimmed_selector = quoted::trim_css(selector.unwrap_or_default()); let order = style_order.unwrap_or(255); let file_num_str = filename.map(|f| num_to_nm_base(get_file_num_by_filename(f))); let trimmed_prop = property.trim(); @@ -710,7 +712,7 @@ pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) return format!("--{}", atom_name::counter_name(&identity, None)); } if is_debug() { - let selector = selector.unwrap_or_default().trim(); + let selector = quoted::trim_css(selector.unwrap_or_default()); let encoded = if selector.is_empty() { String::new() } else { @@ -729,7 +731,7 @@ pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>) result }) } else { - let trimmed_selector = selector.unwrap_or_default().trim(); + let trimmed_selector = quoted::trim_css(selector.unwrap_or_default()); let base_name = class_num_for_key("", |key| { key.reserve(property.len() + 4 + trimmed_selector.len()); key.push_str(property); @@ -868,6 +870,22 @@ mod tests { ); } + #[test] + #[serial] + fn quoted_strings_keep_their_own_class() { + let _state = test_state::TestStateGuard::new(); + set_debug(false); + reset_class_map(); + let class = |value: &str| sheet_to_classname("content", 0, Some(value), None, None, None); + let exact = class("'0.5'"); + assert_ne!(exact, class("'.5'")); + assert_ne!(class("'#ff0000'"), class("'#F00'")); + assert_ne!(class("'a, b'"), class("'a,b'")); + assert_ne!(class("'a;'"), class("'a'")); + assert_eq!(exact, class(" '0.5' ")); + assert_eq!(class("0.5"), class(".5")); + } + #[test] #[serial] fn test_component_marker() { diff --git a/libs/css/src/optimize_multi_css_value.rs b/libs/css/src/optimize_multi_css_value.rs index 14081e2d7..9fff8ed7d 100644 --- a/libs/css/src/optimize_multi_css_value.rs +++ b/libs/css/src/optimize_multi_css_value.rs @@ -1,111 +1,147 @@ use std::borrow::Cow; use crate::constant::{CSS_FUNCTION_RE, OPTIMIZE_MULTI_CSS_VALUE_PROPERTY}; +use crate::quoted::{self, PROTECTED}; + +/// Whether a byte makes a bare value need quotes: a parenthesis or white space +/// (`\s` in `regex_lite` is `[ \t\n\r\x0b\x0c]`, so vertical tab and form feed +/// MUST be included) +const fn needs_quote_byte(b: u8) -> bool { + matches!(b, b'(' | b')' | b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c) +} #[must_use] pub fn optimize_multi_css_value(value: &str) -> Cow<'_, str> { + // One scan for both bytes that steer the work below. + let (has_comma, has_quote) = value.bytes().fold((false, false), |(comma, quote), b| { + (comma || b == b',', quote || matches!(b, b'\'' | b'"')) + }); + if has_quote { + return Cow::Owned(optimize_quoted_list(value)); + } // Borrow fast path: a single bare segment (no comma) whose trimmed form is // already the exact output. This is the common `Roboto` / `sans-serif` case. // It qualifies only when trimming does not slice (the trimmed slice IS a - // suffix-and-prefix of `value` — we return that borrowed slice), the segment - // is NOT surrounded by strippable quotes, and it needs no re-quoting. Every - // other shape (comma list, quoted, requote-needed, <=1 byte) falls through to - // the owning builder below and stays byte-identical. - let has_comma = value.as_bytes().contains(&b','); + // suffix-and-prefix of `value` — we return that borrowed slice) and it needs no + // re-quoting. Every other shape (comma list, requote-needed, <=1 byte) falls + // through to the owning builder below and stays byte-identical. if !has_comma { let s = value.trim(); - let quote_byte = - |b: u8| matches!(b, b'(' | b')' | b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c); if s.len() >= 2 { - let quoted = (s.starts_with('\'') && s.ends_with('\'')) - || (s.starts_with('"') && s.ends_with('"')); - if !quoted { - let needs_quotes = s.bytes().any(quote_byte); - if !needs_quotes || CSS_FUNCTION_RE.is_match(s) { - // Output would be exactly `s` pushed bare -> borrow it. - return Cow::Borrowed(s); - } + let needs_quotes = s.bytes().any(needs_quote_byte); + if !needs_quotes || CSS_FUNCTION_RE.is_match(s) { + // Output would be exactly `s` pushed bare -> borrow it. + return Cow::Borrowed(s); } - } else if s.len() == 1 && !quote_byte(s.as_bytes()[0]) { - // A single-byte bare segment cannot be a quote *pair* (a lone `'`/`"` - // is length 1, not two), and `CSS_FUNCTION_RE = ^[a-zA-Z-]+(\(.*\))` - // can never match `<2` bytes, so it is never re-quoted. When the byte - // is not quote-worthy the owned builder pushes it bare too — the output - // is exactly `s`, so borrow it and skip the `String` allocation. - // (A lone `'`/`"` is not a `quote_byte`, so it also borrows bare, - // matching the owned `unquoted.len()==1` non-quote-byte arm.) + } else if s.len() == 1 && !needs_quote_byte(s.as_bytes()[0]) { + // A single-byte bare segment can never be a quote *pair*, and + // `CSS_FUNCTION_RE = ^[a-zA-Z-]+(\(.*\))` can never match `<2` bytes, so it + // is never re-quoted. When the byte is not quote-worthy the owned + // builder pushes it bare too — the output is exactly `s`, so borrow it and + // skip the `String` allocation. return Cow::Borrowed(s); } } Cow::Owned(optimize_multi_css_value_owned(value, has_comma)) } +/// Push one comma-separated segment that holds no quoted string: wrapped in +/// quotes when it holds a parenthesis or white space and is not a function +fn push_segment(result: &mut String, s: &str) { + // Fast path for empty/single-char segments: `CSS_FUNCTION_RE` can NEVER match a + // string shorter than 2 bytes (it needs a name byte plus a `(`), so a single + // quote-worthy byte is always wrapped and everything else is pushed bare. + // Deciding this from the single byte skips both the `.any()` scan and the + // regex-engine setup while staying byte-identical. + if s.len() <= 1 { + if s.len() == 1 && needs_quote_byte(s.as_bytes()[0]) { + result.push('"'); + result.push_str(s); + result.push('"'); + } else { + result.push_str(s); + } + return; + } + + let needs_quotes = s.bytes().any(needs_quote_byte); + if needs_quotes && !CSS_FUNCTION_RE.is_match(s) { + result.push('"'); + result.push_str(s); + result.push('"'); + } else { + result.push_str(s); + } +} + fn optimize_multi_css_value_owned(value: &str, has_comma: bool) -> String { // Headroom for re-wrapped segments: EACH comma-separated segment may gain a - // `"…"` quote pair (+2 bytes), so a multi-segment list like `'A B', 'C D', E` + // `"…"` quote pair (+2 bytes), so a multi-segment list like `A B, C D, E` // → `"A B","C D",E` can add up to one pair per segment. Budget `2` per comma // plus a trailing `2` so even the worst case (every segment re-wrapped) never - // triggers a grow-realloc. Stripping source quotes only ever shrinks, so this - // is a safe upper bound; output stays byte-identical. + // triggers a grow-realloc. Output stays byte-identical. // Headroom is `2` per comma, `0` when none. The caller already probed comma - // presence (`value.as_bytes().contains(&b',')`) to pick its borrow fast path - // and hands the result in via `has_comma`. On the no-comma owned path (a - // quote-pair strip like `'Roboto'` or a single-segment re-quote) the count is - // provably `0`, so we skip a second full O(n) `value.bytes()` scan entirely. - // Only the comma-present path pays the count. Capacity stays byte-for-byte - // identical to `value.len() + comma_count * 2 + 2` (a `0` headroom on the - // no-comma path is exactly what a full scan would have yielded). + // presence to pick its borrow fast path and hands the result in via + // `has_comma`. On the no-comma owned path the count is provably `0`, so we skip + // a second full O(n) `value.bytes()` scan entirely. Only the comma-present path + // pays the count. let comma_headroom = if has_comma { value.bytes().filter(|&b| b == b',').count() * 2 } else { 0 }; let mut result = String::with_capacity(value.len() + comma_headroom + 2); - // Loop-invariant predicate: captures nothing, so construct it once per call - // instead of once per comma-separated segment. Byte-scan equivalent of the - // `[()\s]` regex — `\s` in `regex_lite` matches `[ \t\n\r\x0b\x0c]`, so the - // vertical-tab (0x0b) and form-feed (0x0c) bytes MUST be included to stay - // byte-identical. - let quote_byte = |b: u8| matches!(b, b'(' | b')' | b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c); for (idx, s) in value.split(',').enumerate() { if idx > 0 { result.push(','); } + push_segment(&mut result, s.trim()); + } + result +} + +/// The content of a quoted string that can stand bare or in double quotes without +/// changing what it says: no escape, and no `,` or `;` to end the item. Another +/// kind of quote is only fine inside a function such as `url('a.woff2')`. +fn plain_content(string: &str) -> Option<&str> { + let quote = string.chars().next()?; + let content = string.strip_prefix(quote)?.strip_suffix(quote)?; + if content.is_empty() || content.contains(['\\', ',', ';']) { + return None; + } + (CSS_FUNCTION_RE.is_match(content) || !content.contains(['\'', '"'])).then_some(content) +} - let s = s.trim(); - let unquoted = if s.len() >= 2 - && ((s.starts_with('\'') && s.ends_with('\'')) - || (s.starts_with('"') && s.ends_with('"'))) +/// `optimize_multi_css_value` for a value with quoted strings. A comma inside a +/// string does not separate items. An item that is one string loses its quotes, or +/// gets double ones, only when its content says the same either way; any other +/// item holding a string stays as written. Items without strings are optimized as +/// they are in a value without any. +fn optimize_quoted_list(value: &str) -> String { + let (masked, spans) = quoted::mask(value); + let mut spans = spans.into_iter(); + let mut result = String::with_capacity(value.len() + 2); + for (idx, segment) in masked.split(',').enumerate() { + if idx > 0 { + result.push(','); + } + let segment = segment.trim(); + if !segment.contains(PROTECTED) { + push_segment(&mut result, segment); + } else if segment.len() == PROTECTED.len_utf8() + && let Some(string) = spans.next() { - &s[1..s.len() - 1] - } else { - s - }; - - // Fast path for empty/single-char segments (`''`/`""` strip to empty, the - // lone `'`/`"` cases stay one char): `CSS_FUNCTION_RE = ^[a-zA-Z-]+(\(.*\))` - // can NEVER match a string shorter than 2 bytes (it needs a name byte plus - // a `(`), so a single quote-worthy byte is always wrapped and everything - // else is pushed bare. Deciding this from the single byte skips both the - // `.any()` scan and the regex-engine setup while staying byte-identical. - if unquoted.len() <= 1 { - if unquoted.len() == 1 && quote_byte(unquoted.as_bytes()[0]) { - result.push('"'); - result.push_str(unquoted); - result.push('"'); - } else { - result.push_str(unquoted); + match plain_content(string) { + Some(content) => push_segment(&mut result, content), + None => result.push_str(string), } - continue; - } - - let needs_quotes = unquoted.bytes().any(quote_byte); - if needs_quotes && !CSS_FUNCTION_RE.is_match(unquoted) { - result.push('"'); - result.push_str(unquoted); - result.push('"'); } else { - result.push_str(unquoted); + for (idx, part) in segment.split(PROTECTED).enumerate() { + if idx > 0 { + result.push_str(spans.next().unwrap_or_default()); + } + result.push_str(part); + } } } result @@ -170,6 +206,38 @@ mod tests { assert_eq!(optimize_multi_css_value(input), expected); } + #[rstest] + #[case(r"'A, B', sans-serif", r"'A, B',sans-serif")] + #[case(r#""A,B", C"#, r#""A,B",C"#)] + #[case(r"'A,B'", r"'A,B'")] + #[case(r"',', a", r"',',a")] + #[case(r"'a', 'b,c', d", r"a,'b,c',d")] + #[case(r"'A B' , C", r#""A B",C"#)] + #[case( + r#"url("a,b.woff2") format("woff2"), local(x)"#, + r#"url("a,b.woff2") format("woff2"),local(x)"# + )] + #[case(r#"'say "hi"'"#, r#"'say "hi"'"#)] + #[case(r#""url('/fonts/a.ttf')""#, "url('/fonts/a.ttf')")] + #[case(r#"'local("Open Sans")'"#, r#"local("Open Sans")"#)] + #[case(r#""url(a), url(b)""#, r#""url(a), url(b)""#)] + #[case(r#"'url(\"a\")'"#, r#"'url(\"a\")'"#)] + #[case(r#""it's""#, r#""it's""#)] + #[case(r"'It\'s two', b", r"'It\'s two',b")] + #[case(r"'\\' , a", r"'\\',a")] + #[case(r#"Open "X" Sans, b"#, r#"Open "X" Sans,b"#)] + #[case("''", "''")] + #[case(r"'', a", r"'',a")] + #[case(r#""", a"#, r#""",a"#)] + #[case(r"'a, b", r"'a, b")] + fn test_optimize_multi_css_value_keeps_quoted_strings( + #[case] input: &str, + #[case] expected: &str, + ) { + assert_eq!(optimize_multi_css_value(input), expected); + assert_eq!(optimize_multi_css_value(expected), expected); + } + #[rstest] #[case("font-family", true)] #[case("src", true)] diff --git a/libs/css/src/optimize_value.rs b/libs/css/src/optimize_value.rs index 1a1175b51..197777451 100644 --- a/libs/css/src/optimize_value.rs +++ b/libs/css/src/optimize_value.rs @@ -4,84 +4,123 @@ use crate::constant::{ COLOR_HASH, DOT_ZERO_RE, F_DOT_RE, F_RGB_RE, F_RGBA_RE, F_SPACE_RE, INNER_TRIM_RE, NUM_TRIM_RE, RM_MINUS_ZERO_RE, ZERO_PERCENT_FUNCTION, ZERO_RE, }; +use crate::quoted; /// (symbol, ";{symbol}", ";{symbol})") — compile-time constants, zero probe allocation -const SEMI_SUFFIXES: [(&str, &str, &str); 4] = [ - ("", ";", ";)"), - ("`", ";`", ";`)"), - ("\"", ";\"", ";\")"), - ("'", ";'", ";')"), -]; +const SEMI_SUFFIXES: [(&str, &str, &str); 2] = [("", ";", ";)"), ("`", ";`", ";`)")]; -pub fn optimize_value(value: &str) -> Cow<'_, str> { - let trimmed = value.trim(); +// What one scan of a value finds. Every mutating pass of `rewrite` syntactically +// REQUIRES at least one of these bytes to fire: +// - var() wrap: leading `--` (⇒ `DOUBLE_DASH`) +// - INNER_TRIM_RE `\(\s*([^)]*?)\s*\)`: a `(` +// - RM_MINUS_ZERO_RE `-0(px|…|\)|,)`: a `0` +// - NUM_TRIM_RE `(\d(unit)?)\s+(\d)`: ASCII whitespace +// - F_SPACE_RE `\s*,\s*` (gated on `contains(',')`): a `,` +// - F_RGBA_RE / F_RGB_RE (gated on `contains("rgba(")`/`contains("rgb(")`): a `(` +// - COLOR_HASH `#([0-9a-zA-Z]+)`: a `#` +// - DOT_ZERO_RE / F_DOT_RE / ZERO_RE / ZERO_PERCENT (all inside `has_zero`): a `0` +// - SEMI_SUFFIXES strip: a `;` +// - unbalanced-paren fixup: a `(` or `)` +// so a value with NONE of them set is provably returned byte-for-byte unchanged +// and can be borrowed instead of copied into a fresh `String`. +// `SPACE` (a literal `' '`) is tracked separately from `WS` (any ASCII whitespace) +// because the var()-wrap condition only rejects `' '`/`,`. `QUOTE` (a `'` or `"`) +// routes the value through `optimize_quoted`. +#[derive(Clone, Copy)] +struct Flags(u16); - // ONE pre-scan of `trimmed` for every byte flag the passes below key off. - // This subsumes the two former folds (the `--`-wrap space/comma probe and the - // post-wrap `(has_open_paren, saw_close_paren, has_double_dash, _, saw_semi)` - // fold) AND feeds a borrow fast path: every mutating pass in this function - // syntactically REQUIRES at least one of these bytes to fire — - // - var() wrap: leading `--` (⇒ `has_double_dash`) - // - INNER_TRIM_RE `\(\s*([^)]*?)\s*\)`: a `(` - // - RM_MINUS_ZERO_RE `-0(px|…|\)|,)`: a `0` - // - NUM_TRIM_RE `(\d(unit)?)\s+(\d)`: ASCII whitespace - // - F_SPACE_RE `\s*,\s*` (gated on `contains(',')`): a `,` - // - F_RGBA_RE / F_RGB_RE (gated on `contains("rgba(")`/`contains("rgb(")`): a `(` - // - COLOR_HASH `#([0-9a-zA-Z]+)`: a `#` - // - DOT_ZERO_RE / F_DOT_RE / ZERO_RE / ZERO_PERCENT (all inside `has_zero`): a `0` - // - SEMI_SUFFIXES strip: a `;` - // - unbalanced-paren fixup: a `(` or `)` - // so a value with NONE of them set is provably returned byte-for-byte - // unchanged and can be borrowed instead of copied into a fresh `String`. - // `has_space` (a literal `' '`) is tracked separately from `has_ws` (any - // ASCII whitespace) because the var()-wrap condition only rejects `' '`/`,`, - // exactly like the fold it replaces. - let mut has_space = false; - let mut has_ws = false; - let mut has_comma = false; - let mut pre_open_paren = false; - let mut pre_close_paren = false; - let mut has_double_dash = false; - let mut saw_semi = false; - let mut has_hash = false; - let mut has_zero = false; - let mut prev_dash = false; - for b in trimmed.bytes() { - match b { - b' ' => { - has_space = true; - has_ws = true; - } - b'\t' | b'\n' | b'\x0C' | b'\r' => has_ws = true, - b',' => has_comma = true, - b'(' => pre_open_paren = true, - b')' => pre_close_paren = true, - b';' => saw_semi = true, - b'#' => has_hash = true, - b'0' => has_zero = true, - b'-' if prev_dash => has_double_dash = true, - _ => {} +impl Flags { + const SPACE: u16 = 1; + const WS: u16 = 1 << 1; + const COMMA: u16 = 1 << 2; + const OPEN_PAREN: u16 = 1 << 3; + const CLOSE_PAREN: u16 = 1 << 4; + const DOUBLE_DASH: u16 = 1 << 5; + const SEMI: u16 = 1 << 6; + const HASH: u16 = 1 << 7; + const ZERO: u16 = 1 << 8; + const QUOTE: u16 = 1 << 9; + /// Every flag a pass keys off; the others only route (`SPACE`, `QUOTE`) + const MUTATING: u16 = Self::WS + | Self::COMMA + | Self::OPEN_PAREN + | Self::CLOSE_PAREN + | Self::DOUBLE_DASH + | Self::SEMI + | Self::HASH + | Self::ZERO; + + /// ONE pre-scan of `trimmed` for every byte flag the passes key off + fn scan(trimmed: &str) -> Self { + let mut flags = 0; + let mut prev_dash = false; + for b in trimmed.bytes() { + flags |= match b { + b' ' => Self::SPACE | Self::WS, + b'\t' | b'\n' | b'\x0C' | b'\r' => Self::WS, + b',' => Self::COMMA, + b'(' => Self::OPEN_PAREN, + b')' => Self::CLOSE_PAREN, + b';' => Self::SEMI, + b'#' => Self::HASH, + b'0' => Self::ZERO, + b'\'' | b'"' => Self::QUOTE, + b'-' if prev_dash => Self::DOUBLE_DASH, + _ => 0, + }; + prev_dash = b == b'-'; } - prev_dash = b == b'-'; + Self(flags) + } + + const fn has(self, flag: u16) -> bool { + self.0 & flag != 0 + } + + /// No pass can modify the value, so the caller may borrow it. A leading `--` + /// raises `DOUBLE_DASH` (two consecutive dashes), so the var()-wrap path can + /// never be skipped by this early return. + const fn is_inert(self) -> bool { + !self.has(Self::MUTATING) + } +} + +pub fn optimize_value(value: &str) -> Cow<'_, str> { + let trimmed = quoted::trim_css(value); + let flags = Flags::scan(trimmed); + + // FAST PATH: no flag set ⇒ no pass can modify `trimmed`, so hand the caller a + // borrow of the trimmed input. This covers the dominant plain values (`red`, + // `14px`, `$primary`, `1.3s`) with zero allocation. + if flags.is_inert() { + return Cow::Borrowed(trimmed); } + if flags.has(Flags::QUOTE) { + return optimize_quoted(trimmed); + } + Cow::Owned(rewrite(trimmed, flags)) +} - // FAST PATH: no flag set ⇒ no pass can modify `trimmed` (see table above), - // so hand the caller a borrow of the trimmed input. This covers the dominant - // plain values (`red`, `14px`, `$primary`, `1.3s`) with zero allocation. - // A leading `--` raises `has_double_dash` (two consecutive dashes), so the - // var()-wrap path can never be skipped by this early return. - if !has_ws - && !has_comma - && !pre_open_paren - && !pre_close_paren - && !has_double_dash - && !saw_semi - && !has_hash - && !has_zero - { +/// Optimize the text around the quoted strings of `trimmed`, which are data and +/// stay byte-for-byte as written: each stands for one inert character while the +/// passes run, so the text beside it is optimized as if the string were one word +fn optimize_quoted(trimmed: &str) -> Cow<'_, str> { + let (masked, spans) = quoted::mask(trimmed); + let flags = Flags::scan(&masked); + if flags.is_inert() { return Cow::Borrowed(trimmed); } + Cow::Owned(quoted::unmask(&rewrite(&masked, flags), &spans)) +} +/// Run every optimization pass over `trimmed`, which holds no quoted string +fn rewrite(trimmed: &str, flags: Flags) -> String { + let has_space = flags.has(Flags::SPACE); + let has_comma = flags.has(Flags::COMMA); + let pre_open_paren = flags.has(Flags::OPEN_PAREN); + let pre_close_paren = flags.has(Flags::CLOSE_PAREN); + let has_double_dash = flags.has(Flags::DOUBLE_DASH); + let saw_semi = flags.has(Flags::SEMI); let mut ret = String::with_capacity(trimmed.len() + 8); // Wrap CSS custom property names in var() when used as values @@ -301,16 +340,17 @@ pub fn optimize_value(value: &str) -> Cow<'_, str> { // `saw_semi` was folded from the SAME post-wrap byte scan above (no later pass can // add a `;`), so this reuses that result instead of a fresh `contains(';')` scan. // - // Do NOT `break` after a match. `expression_to_code` serializes the value inside - // an `ExpressionStatement`, so a quoted dynamic value can arrive carrying BOTH an - // inner `;` before its closing symbol AND the appended statement terminator — e.g. - // ``expression_to_code(`${color};`)`` yields ``` `${color};`; ```. Entry 0 (`;`) - // strips the bare terminator, leaving `` `${color};` `` whose inner `;` only the - // backtick entry can strip. The suffixes are therefore NOT mutually exclusive at - // the end of such a value, so every entry must get a turn (this mirrors the - // original pre-`SEMI_SUFFIXES` loop; a premature `break` regressed the + // Do NOT `break` after a match. A template value can arrive carrying BOTH an + // inner `;` before its closing backtick AND the appended statement terminator — + // e.g. ``` `${color};`; ```. Entry 0 (`;`) strips the bare terminator, leaving + // `` `${color};` `` whose inner `;` only the backtick entry can strip. The + // suffixes are therefore NOT mutually exclusive at the end of such a value, so + // every entry must get a turn (a premature `break` regressed the // `remove_semicolon` case). At most one strip fires per entry, and any strip - // leaves `ret` ending in `str_symbol`/`)`, so the pass still terminates in ≤4 steps. + // leaves `ret` ending in `str_symbol`/`)`, so the pass still terminates. + // + // A `'` or `"` string never reaches this pass: it is masked, so a `;` inside one + // is data and only the `;` after it is a terminator. if saw_semi { for (str_symbol, suffix_without_paren, suffix_with_paren) in SEMI_SUFFIXES { if let Some(stripped) = ret.strip_suffix(suffix_without_paren) { @@ -364,7 +404,7 @@ pub fn optimize_value(value: &str) -> Cow<'_, str> { } } } - Cow::Owned(ret) + ret } fn optimize_color(value: &str) -> String { @@ -507,22 +547,21 @@ mod tests { #[case("\"red\"", "\"red\"")] #[case("'red'", "'red'")] #[case("`red`", "`red`")] - #[case("\"red;\"", "\"red\"")] - #[case("'red;'", "'red'")] + #[case("\"red;\"", "\"red;\"")] + #[case("'red;'", "'red;'")] #[case("`red;`", "`red`")] - // `expression_to_code` serializes the value inside an `ExpressionStatement`, - // appending a statement `;`. A quoted value that already ends in an inner `;` - // therefore arrives with TWO trailing semicolons; BOTH must be stripped. - // Regression guard for `remove_semicolon` (a premature `break` used to keep the - // inner one, e.g. `` `${color};` ``). + // A template value that already ends in an inner `;` arrives with TWO trailing + // semicolons; BOTH must be stripped. Regression guard for `remove_semicolon` (a + // premature `break` used to keep the inner one, e.g. `` `${color};` ``). The `;` + // inside a `'` or `"` string is data and stays. #[case("`red;`;", "`red`")] - #[case("\"red;\";", "\"red\"")] - #[case("'red;';", "'red'")] + #[case("\"red;\";", "\"red;\"")] + #[case("'red;';", "'red;'")] #[case("`${color};`;", "`${color}`")] - #[case("(\"red;\")", "(\"red\")")] + #[case("(\"red;\")", "(\"red;\")")] #[case("(`red;`)", "(`red`)")] - #[case("('red;')", "('red')")] - #[case("('red') + 'blue;'", "('red') + 'blue'")] + #[case("('red;')", "('red;')")] + #[case("('red') + 'blue;'", "('red') + 'blue;'")] #[case("translateX(0px) translateY(0px)", "translateX(0) translateY(0)")] // recovery case #[case("max(10px, 0", "max(10px,0%)")] @@ -576,4 +615,81 @@ mod tests { fn test_css_custom_property_wrapping(#[case] input: &str, #[case] expected: &str) { assert_eq!(optimize_value(input), expected); } + + #[rstest] + #[case(r"'0.5'")] + #[case(r#""0.5""#)] + #[case(r"'-0.12px'")] + #[case(r"'0.0px'")] + #[case(r"'0px'")] + #[case(r"'-0px'")] + #[case(r"'#ff0000'")] + #[case(r##""#ff0000ff""##)] + #[case(r"'rgba(255,0,0,0.5)'")] + #[case(r#""rgb(255, 0, 0)""#)] + #[case(r"'a, b'")] + #[case(r#""a ,b""#)] + #[case(r"'1 2'")] + #[case(r#""1px 2px""#)] + #[case(r"'( x )'")] + #[case(r#""(""#)] + #[case(r"')'")] + #[case(r"'(('")] + #[case(r"'red;'")] + #[case(r#""a;b""#)] + #[case(r"'translate(0px, 0px)'")] + #[case(r"'clamp(0, 10px)'")] + #[case(r#""max(0, 1px)""#)] + #[case(r"'--x'")] + #[case(r"'it\'s 0.5'")] + #[case(r#""say \"0.5\" , x""#)] + #[case(r"'\\'")] + #[case(r#""\\\"0.5""#)] + #[case(r#""it's 0.5""#)] + #[case(r#"'say "0.5"'"#)] + #[case("'한글 0.5, #ff0000'")] + #[case(r"'0.5''0.5'")] + #[case(r#""a""b""#)] + #[case(r#"("(")"#)] + #[case(r#"(")")"#)] + // a string the input ends inside runs to the end + #[case(r"'0.5")] + #[case(r#""a, b"#)] + #[case(r"'\")] + fn test_quoted_strings_are_kept(#[case] input: &str) { + assert_eq!(optimize_value(input), input); + let expected = match input { + r"'0.5" | r#""a, b"# | r"'\" => format!("{input}\t"), + _ => input.to_string(), + }; + assert_eq!(optimize_value(&format!(" {input}\t")), expected); + } + + #[rstest] + #[case(r#""0.5" 0.5px"#, r#""0.5" .5px"#)] + #[case(r"0.5px '0.5' 0.5px", r".5px '0.5' .5px")] + #[case(r"url('a-0.5.png') 0.5", r"url('a-0.5.png') .5")] + #[case(r"'a' , 'b'", r"'a','b'")] + #[case(r"fn( 'x' )", r"fn('x')")] + #[case(r#"url( "a b" )"#, r#"url("a b")"#)] + #[case(r#"0px "0px" 0px"#, r#"0 "0px" 0"#)] + #[case(r"-0px '-0px'", r"0 '-0px'")] + #[case(r"#ff0000 '#ff0000'", r"#F00 '#ff0000'")] + #[case( + r"rgba(255,0,0,0.5) 'rgba(255,0,0,0.5)'", + r"#FF000080 'rgba(255,0,0,0.5)'" + )] + #[case(r#"fn("(", ")")"#, r#"fn("(",")")"#)] + #[case(r"'x' 1px 2px", r"'x' 1px 2px")] + #[case(r"'a b' , 'c d' , 0.5", r"'a b','c d',.5")] + #[case("'x';", "'x'")] + #[case(r#""red;";"#, r#""red;""#)] + #[case(r"'red;';", "'red;'")] + #[case("'한글' 0.5", "'한글' .5")] + #[case(r"'a\'b' 0.50", r"'a\'b' .50")] + #[case(r#"("(" 0px"#, r#"("(" 0)"#)] + fn test_text_around_quoted_strings_is_optimized(#[case] input: &str, #[case] expected: &str) { + assert_eq!(optimize_value(input), expected); + assert_eq!(optimize_value(expected), expected); + } } diff --git a/libs/css/src/quoted.rs b/libs/css/src/quoted.rs new file mode 100644 index 000000000..569dcf0de --- /dev/null +++ b/libs/css/src/quoted.rs @@ -0,0 +1,296 @@ +/// Stands for one quoted string in masked text. No optimization pass reads it: +/// they look for ASCII digits, white space, parentheses, `,`, `#`, `;` and `.` +pub(crate) const PROTECTED: char = '\u{E000}'; + +/// End of a string span, including its closing quote or raw bad-string newline. +/// Escaped newlines stay inside the span; EOF string payload is never discarded. +#[must_use] +pub fn string_end(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut state = QuoteState { + quote: Some(quote), + ..QuoteState::default() + }; + let mut index = start + 1; + while let Some(&byte) = bytes.get(index) { + state.is_syntax(byte); + index += 1; + if state.quote.is_none() { + if byte == b'\r' && bytes.get(index) == Some(&b'\n') { + index += 1; + } + return index; + } + } + bytes.len() +} + +/// Quote and escape state retained across adjacent CSS text fragments. +#[derive(Default)] +pub struct QuoteState { + quote: Option, + escaped: bool, + skip_lf: bool, + hex_digits: u8, +} + +impl QuoteState { + /// Consume a byte, returning whether it is unquoted, unescaped syntax. + #[inline] + pub fn is_syntax(&mut self, byte: u8) -> bool { + if self.skip_lf { + self.skip_lf = false; + if byte == b'\n' { + return false; + } + } + if self.hex_digits > 0 { + if byte.is_ascii_hexdigit() && self.hex_digits < 6 { + self.hex_digits += 1; + return false; + } + self.hex_digits = 0; + if matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') { + self.skip_lf = byte == b'\r'; + return false; + } + } + if self.escaped { + self.escaped = false; + self.skip_lf = byte == b'\r' && self.quote.is_some(); + self.hex_digits = u8::from(byte.is_ascii_hexdigit()); + return false; + } + if byte == b'\\' { + self.escaped = true; + return false; + } + match self.quote { + Some(quote) => { + if byte == quote || matches!(byte, b'\n' | b'\r' | b'\x0c') { + self.quote = None; + } + self.skip_lf = byte == b'\r'; + false + } + None => { + if matches!(byte, b'\'' | b'"') { + self.quote = Some(byte); + false + } else { + true + } + } + } + } +} + +/// Trim outside whitespace without discarding EOF payload or bad-string newlines. +#[must_use] +pub fn trim_css(value: &str) -> &str { + let value = value.trim_start(); + let trimmed = value.trim_end(); + if value.len() == trimmed.len() + || !value + .bytes() + .any(|byte| matches!(byte, b'\'' | b'"' | b'\\')) + { + return trimmed; + } + let mut state = QuoteState::default(); + let mut end = trimmed.len(); + for (index, c) in value.char_indices() { + if !state.is_syntax(value.as_bytes()[index]) { + end = end.max(index + c.len_utf8()); + } + } + &value[..end] +} + +/// The bytes outside quoted strings and escapes, with their positions. +struct Unquoted<'a> { + bytes: &'a [u8], + index: usize, + state: QuoteState, +} + +impl Iterator for Unquoted<'_> { + type Item = (usize, u8); + + fn next(&mut self) -> Option { + loop { + let &byte = self.bytes.get(self.index)?; + self.index += 1; + if self.state.is_syntax(byte) { + return Some((self.index - 1, byte)); + } + } + } +} + +/// The bytes of `text` outside quoted strings and escapes, with their positions. +pub fn unquoted_bytes(text: &str) -> impl Iterator + '_ { + Unquoted { + bytes: text.as_bytes(), + index: 0, + state: QuoteState::default(), + } +} + +/// Replace an ASCII delimiter outside strings and escapes, borrowing if absent. +#[must_use] +pub fn replace_unquoted<'a>( + text: &'a str, + delimiter: u8, + replacement: &str, +) -> std::borrow::Cow<'a, str> { + let mut positions = unquoted_bytes(text).filter(|&(_, byte)| byte == delimiter); + let Some((first, _)) = positions.next() else { + return std::borrow::Cow::Borrowed(text); + }; + let mut out = String::with_capacity(text.len() + replacement.len()); + out.push_str(&text[..first]); + out.push_str(replacement); + let mut start = first + 1; + for (index, _) in positions { + out.push_str(&text[start..index]); + out.push_str(replacement); + start = index + 1; + } + out.push_str(&text[start..]); + std::borrow::Cow::Owned(out) +} + +/// Where `needle` first stands in `text` outside quoted strings +#[must_use] +pub fn find_unquoted(text: &str, needle: u8) -> Option { + unquoted_bytes(text) + .find(|&(_, byte)| byte == needle) + .map(|(index, _)| index) +} + +/// Where `needle` last stands in `text` outside quoted strings +#[must_use] +pub fn rfind_unquoted(text: &str, needle: u8) -> Option { + unquoted_bytes(text) + .filter(|&(_, byte)| byte == needle) + .last() + .map(|(index, _)| index) +} + +/// The parts of `text` between the `delimiter`s outside quoted strings +pub fn split_unquoted(text: &str, delimiter: u8) -> impl Iterator { + let mut positions = unquoted_bytes(text) + .filter(move |&(_, byte)| byte == delimiter) + .map(|(index, _)| index); + let mut start = Some(0); + std::iter::from_fn(move || { + let from = start?; + if let Some(index) = positions.next() { + start = Some(index + 1); + Some(&text[from..index]) + } else { + start = None; + Some(&text[from..]) + } + }) +} + +/// Mask strings, ASCII escapes and literal markers, retaining their original spans. +pub(crate) fn mask(value: &str) -> (String, Vec<&str>) { + let bytes = value.as_bytes(); + let mut masked = String::with_capacity(value.len()); + let mut spans = Vec::new(); + let mut copied = 0; + let mut index = 0; + while index < bytes.len() { + let end = match bytes[index] { + b'\\' if bytes.get(index + 1).is_some_and(u8::is_ascii) => index + 2, + quote @ (b'\'' | b'"') => string_end(bytes, index, quote), + 0xEE if value[index..].starts_with(PROTECTED) => index + PROTECTED.len_utf8(), + _ => { + index += 1; + continue; + } + }; + masked.push_str(&value[copied..index]); + masked.push(PROTECTED); + spans.push(&value[index..end]); + copied = end; + index = end; + } + masked.push_str(&value[copied..]); + (masked, spans) +} + +/// Put `spans` back in place of the [`PROTECTED`] characters of `masked` +pub(crate) fn unmask(masked: &str, spans: &[&str]) -> String { + let mut parts = masked.split(PROTECTED); + let mut value = + String::with_capacity(masked.len() + spans.iter().map(|span| span.len()).sum::()); + value.push_str(parts.next().unwrap_or_default()); + for (part, span) in parts.zip(spans) { + value.push_str(span); + value.push_str(part); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case("red", "red", &[])] + #[case("'a'", "\u{E000}", &["'a'"])] + #[case("1 'a' 2", "1 \u{E000} 2", &["'a'"])] + #[case(r#""a" 'b'"#, "\u{E000} \u{E000}", &[r#""a""#, "'b'"])] + #[case(r#"'a"b' "c'd""#, "\u{E000} \u{E000}", &[r#"'a"b'"#, r#""c'd""#])] + #[case(r"'a\'b' c", "\u{E000} c", &[r"'a\'b'"])] + #[case(r"'a\\' b", "\u{E000} b", &[r"'a\\'"])] + #[case("'a\\한' b", "\u{E000} b", &["'a\\한'"])] + #[case("x 'abc", "x \u{E000}", &["'abc"])] + #[case("x '", "x \u{E000}", &["'"])] + #[case(r"x 'a\", "x \u{E000}", &[r"'a\"])] + #[case("a\u{E000}b 'c'", "a\u{E000}b \u{E000}", &["\u{E000}", "'c'"])] + #[case("한글 '한 글' 0.5", "한글 \u{E000} 0.5", &["'한 글'"])] + fn masks_quoted_strings_and_restores_them( + #[case] value: &str, + #[case] masked: &str, + #[case] spans: &[&str], + ) { + let (actual, actual_spans) = mask(value); + assert_eq!(actual, masked); + assert_eq!(actual_spans, spans); + assert_eq!(unmask(&actual, &actual_spans), value); + } + + #[rstest] + #[case("a;b", b';', Some(1), Some(1))] + #[case("a'x;y';b", b';', Some(6), Some(6))] + #[case("'x;y'", b';', None, None)] + #[case("a;b;c", b';', Some(1), Some(3))] + #[case("\"{\" {", b'{', Some(4), Some(4))] + #[case("'open ; string", b';', None, None)] + #[case("한;'글;'", b';', Some(3), Some(3))] + fn finds_bytes_outside_quoted_strings( + #[case] text: &str, + #[case] needle: u8, + #[case] first: Option, + #[case] last: Option, + ) { + assert_eq!(find_unquoted(text, needle), first); + assert_eq!(rfind_unquoted(text, needle), last); + } + + #[rstest] + #[case("a;b", vec!["a", "b"])] + #[case("a;'b;c';d", vec!["a", "'b;c'", "d"])] + #[case("", vec![""])] + #[case(";", vec!["", ""])] + #[case("'x;", vec!["'x;"])] + #[case("한;'글;'", vec!["한", "'글;'"])] + fn splits_outside_quoted_strings(#[case] text: &str, #[case] parts: Vec<&str>) { + assert_eq!(split_unquoted(text, b';').collect::>(), parts); + } +} diff --git a/libs/css/src/rm_css_comment.rs b/libs/css/src/rm_css_comment.rs index 99c7e113f..36be74c1a 100644 --- a/libs/css/src/rm_css_comment.rs +++ b/libs/css/src/rm_css_comment.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use crate::{constant::CSS_COMMENT_RE, utils::collapse_whitespace}; +use crate::{constant::CSS_COMMENT_RE, quoted::string_end, utils::collapse_whitespace}; #[must_use] pub fn rm_css_comment(value: &str) -> Cow<'_, str> { @@ -14,6 +14,9 @@ pub fn rm_css_comment(value: &str) -> Cow<'_, str> { if !value.contains("/*") { return collapse_whitespace(value); } + if value.bytes().any(|b| matches!(b, b'\'' | b'"')) { + return rm_comments_outside_strings(value); + } // On the no-comment path `replace_all` returns `Cow::Borrowed` (an alias of // `value`), so collapse whitespace on the original slice and skip // materializing the borrowed-equal temporary. @@ -31,6 +34,35 @@ pub fn rm_css_comment(value: &str) -> Cow<'_, str> { } } +/// `rm_css_comment` for a value with quoted strings: a `/*` inside one is text, and a +/// quote inside a comment does not open a string +fn rm_comments_outside_strings(value: &str) -> Cow<'_, str> { + let bytes = value.as_bytes(); + let mut stripped = String::new(); + let mut copied = 0; + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'\\' => index = (index + 2).min(bytes.len()), + quote @ (b'\'' | b'"') => index = string_end(bytes, index, quote), + b'/' if bytes.get(index + 1) == Some(&b'*') => { + let Some(length) = value[index + 2..].find("*/") else { + break; + }; + stripped.push_str(&value[copied..index]); + index += length + 4; + copied = index; + } + _ => index += 1, + } + } + if copied == 0 { + return collapse_whitespace(value); + } + stripped.push_str(&value[copied..]); + Cow::Owned(collapse_whitespace(&stripped).into_owned()) +} + #[cfg(test)] mod tests { use super::*; @@ -57,4 +89,18 @@ mod tests { fn test_rm_css_comment(#[case] input: &str, #[case] expected: &str) { assert_eq!(rm_css_comment(input), expected); } + + #[rstest] + #[case("content: '/* x */'", "content: '/* x */'")] + #[case( + "content: '/* x */' /* y */ color: red", + "content: '/* x */' color: red" + )] + #[case("/* it's */ content: 'a b'", "content: 'a b'")] + #[case("a /* b */ \"c /* d */ e\" /* f */", "a \"c /* d */ e\"")] + #[case("content: '/* x", "content: '/* x")] + #[case("'a' /* open", "'a' /* open")] + fn test_rm_css_comment_keeps_quoted_strings(#[case] input: &str, #[case] expected: &str) { + assert_eq!(rm_css_comment(input), expected); + } } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index 93fa5577d..95625c001 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -110,36 +110,19 @@ pub fn split_selector_list(selector: &str) -> Vec<&str> { let mut parts = vec![]; let mut depth = 0usize; let mut start = 0; - let mut quote = None; - let mut escaped = false; - for (index, c) in selector.char_indices() { - if escaped { - escaped = false; - continue; - } - if c == '\\' { - escaped = true; - continue; - } - if let Some(delimiter) = quote { - if c == delimiter { - quote = None; - } - continue; - } + for (index, c) in crate::quoted::unquoted_bytes(selector) { match c { - '\'' | '"' => quote = Some(c), - '(' | '[' => depth += 1, - ')' | ']' => depth = depth.saturating_sub(1), - ',' if depth == 0 => { - parts.push(selector[start..index].trim()); + b'(' | b'[' => depth += 1, + b')' | b']' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + parts.push(crate::quoted::trim_css(&selector[start..index])); start = index + 1; } _ => {} } } if !selector.is_empty() { - parts.push(selector[start..].trim()); + parts.push(crate::quoted::trim_css(&selector[start..])); } parts } @@ -151,11 +134,11 @@ fn substitute(template: &str, parent: &str) -> String { if parents.len() > 1 { parents .iter() - .map(|parent| template.replace('&', parent)) + .map(|parent| crate::quoted::replace_unquoted(template, b'&', parent)) .collect::>() .join(",") } else { - template.replace('&', parent) + crate::quoted::replace_unquoted(template, b'&', parent).into_owned() } } diff --git a/libs/css/src/utils.rs b/libs/css/src/utils.rs index d5e4a225b..f9e7f3580 100644 --- a/libs/css/src/utils.rs +++ b/libs/css/src/utils.rs @@ -2,6 +2,8 @@ use std::borrow::Cow; use regex_lite::Regex; +use crate::quoted; + /// Compile a built-in regex pattern, panicking with a descriptive message. /// /// The pattern strings are compile-time constants, so a failure here is a @@ -89,6 +91,9 @@ pub(crate) fn collapse_whitespace(value: &str) -> Cow<'_, str> { let mut prev_ws = true; // treat start-of-string as preceding whitespace so a leading run is caught let mut prev_comma = false; for &b in bytes { + if matches!(b, b'\'' | b'"') { + return collapse_around_strings(value); + } let is_ws = b.is_ascii_whitespace(); if is_ws && (prev_ws || prev_comma) { // leading run, doubled whitespace, or a space right after a comma @@ -105,6 +110,9 @@ pub(crate) fn collapse_whitespace(value: &str) -> Cow<'_, str> { if !needs_collapse { return Cow::Borrowed(value); } + if bytes.iter().any(|&b| matches!(b, b'\'' | b'"')) { + return collapse_around_strings(value); + } let mut result = String::with_capacity(value.len()); for part in value.split_whitespace() { @@ -119,6 +127,15 @@ pub(crate) fn collapse_whitespace(value: &str) -> Cow<'_, str> { Cow::Owned(result) } +/// `collapse_whitespace` for a value with quoted strings, which keep every byte +fn collapse_around_strings(value: &str) -> Cow<'_, str> { + let (masked, spans) = quoted::mask(value); + match collapse_whitespace(&masked) { + Cow::Borrowed(_) => Cow::Borrowed(value), + Cow::Owned(collapsed) => Cow::Owned(quoted::unmask(&collapsed, &spans)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -152,4 +169,16 @@ mod tests { fn test_collapse_whitespace(#[case] input: &str, #[case] expected: &str) { assert_eq!(collapse_whitespace(input), expected); } + + #[rstest] + #[case(r"'a, b'", r"'a, b'")] + #[case(r#"[title="a, b"]"#, r#"[title="a, b"]"#)] + #[case(r"content: 'a b' , 'c'", r"content: 'a b' ,'c'")] + #[case(r" 'x' ", "'x'")] + #[case(r#"a "x y" b"#, r#"a "x y" b"#)] + #[case(r"'it\'s x' y", r"'it\'s x' y")] + #[case("'open string", "'open string")] + fn test_collapse_whitespace_keeps_quoted_strings(#[case] input: &str, #[case] expected: &str) { + assert_eq!(collapse_whitespace(input), expected); + } } diff --git a/libs/css/tests/quoted_boundaries.rs b/libs/css/tests/quoted_boundaries.rs new file mode 100644 index 000000000..a24031fa1 --- /dev/null +++ b/libs/css/tests/quoted_boundaries.rs @@ -0,0 +1,135 @@ +use css::{ + add_selector_params, + at_rule::{MediaCombination, combine_media_queries, normalize_query}, + merge_selector, + optimize_value::optimize_value, + style_selector::{AtRuleKind, StyleSelector}, +}; +use rstest::rstest; + +#[rstest] +#[case(r#"&[title="&"]"#, r#"&:hover[title="&"]"#)] +#[case(r#"[title="&"] > &"#, r#"[title="&"] > &:hover"#)] +#[case(r"&[title='a\'&']", r"&:hover[title='a\'&']")] +fn nested_selector_keeps_quoted_ampersands(#[case] template: &str, #[case] expected: &str) { + let parent = StyleSelector::Selector("&:hover".to_string()); + let actual = StyleSelector::nest_selector(Some(&parent), template); + assert_eq!(actual.to_string(), expected); +} + +#[test] +fn nested_media_keeps_quoted_parentheses_and_commas() { + let actual = combine_media_queries("print", r#"(name:"a),b")"#); + assert_eq!( + actual, + MediaCombination::Merged(r#"print and (name:"a),b")"#.to_string()) + ); +} + +#[rstest] +#[case("'bad\n0.5px", "'bad\n.5px")] +#[case("'bad\r0.5px", "'bad\r.5px")] +#[case("'bad\x0c0.5px", "'bad\x0c.5px")] +#[case("'0.5' 0.5px", "'0.5' .5px")] +fn bad_string_ends_at_an_unescaped_css_newline(#[case] input: &str, #[case] expected: &str) { + assert_eq!(optimize_value(input), expected); +} + +#[rstest] +#[case(r#"&[title="a&b"]"#, r#".cls[title="a&b"]"#)] +#[case(r"&.a\&b", r".cls.a\&b")] +#[case(r#"&.a\" > &"#, r#".cls.a\" > .cls"#)] +#[case(r#"&[title="a\"&b"] > &"#, r#".cls[title="a\"&b"] > .cls"#)] +fn merge_preserves_literal_ampersands_when_emitting_a_selector( + #[case] template: &str, + #[case] expected: &str, + #[values(false, true)] in_media: bool, +) { + // Given a selector emitted directly or within an at-rule. + let selector = if in_media { + StyleSelector::At { + kind: AtRuleKind::Media, + query: "print".to_string(), + selector: Some(template.to_string()), + outer: vec![], + file: None, + } + } else { + StyleSelector::Selector(template.to_string()) + }; + // When the generated class replaces nesting tokens. + let actual = merge_selector("cls", Some(&selector)); + // Then quoted and escaped ampersands remain data. + assert_eq!(actual, expected); +} + +#[test] +fn nesting_preserves_strings_when_the_parent_is_a_selector_list() { + // Given a list parent and a template with real and literal ampersands. + let parent = StyleSelector::Selector("&:hover,&:focus".to_string()); + // When nesting distributes over the parent list. + let actual = StyleSelector::nest_selector(Some(&parent), r#"&[title="&"] .a\&b"#); + // Then only the nesting token is substituted in each branch. + assert_eq!( + actual.to_string(), + r#"&:hover[title="&"] .a\&b,&:focus[title="&"] .a\&b"# + ); +} + +#[test] +fn params_skip_punctuation_when_it_is_quoted() { + // Given a real pseudo before an attribute containing fake closing syntax. + let selector = StyleSelector::Selector(r#"&:nth-child[title="]:not"]"#.to_string()); + // When parameters are attached to the unparameterized pseudo. + let actual = add_selector_params(selector, "2n"); + // Then the quoted pseudo is untouched. + assert_eq!(actual.to_string(), r#"&:nth-child(2n)[title="]:not"]"#); +} + +#[rstest] +#[case(r#"(name:"a) or b")"#, r#"print and (name:"a) or b")"#)] +#[case(r#"(name:"a),b")"#, r#"print and (name:"a),b")"#)] +#[case( + r#"(name:"a),b"),(color)"#, + r#"print and (name:"a),b"),print and (color)"# +)] +fn media_combination_ignores_structure_when_it_is_quoted( + #[case] inner: &str, + #[case] expected: &str, +) { + // Given a condition/list containing quoted structure. + // When a media type is folded into the condition. + let actual = combine_media_queries("print", inner); + // Then quoted punctuation never splits or wraps the condition. + assert_eq!(actual, MediaCombination::Merged(expected.to_string())); +} + +#[test] +fn prelude_normalizes_structure_when_a_quote_is_externally_escaped() { + // Given a quote that is an escaped token, not a string opener. + let query = r#"selector(.a\") and ( color : red )"#; + // When whitespace outside strings is normalized. + let actual = normalize_query(query); + // Then the remaining prelude is not hidden behind a false quote. + assert_eq!(actual, r#"selector(.a\")and (color:red)"#); +} + +#[test] +fn escaped_quotes_keep_following_comments_visible() { + // Given an escaped quote outside a string before an actual comment. + let source = r#"a\" b /* remove */ c"#; + // When comments and outside whitespace are normalized. + let actual = css::rm_css_comment::rm_css_comment(source); + // Then the escaped quote does not hide the comment or whitespace. + assert_eq!(actual, r#"a\" b c"#); +} + +#[test] +fn escaped_quotes_keep_following_numbers_optimizable() { + // Given an escaped quote outside a string before a number. + let source = r#"\" 0.5"#; + // When numeric CSS optimization runs. + let actual = optimize_value(source); + // Then the number is not falsely protected as string data. + assert_eq!(actual, r#"\" .5"#); +} diff --git a/libs/css/tests/quoted_escape_boundaries.rs b/libs/css/tests/quoted_escape_boundaries.rs new file mode 100644 index 000000000..8caf9429f --- /dev/null +++ b/libs/css/tests/quoted_escape_boundaries.rs @@ -0,0 +1,33 @@ +use css::{optimize_value::optimize_value, quoted::replace_unquoted}; +use rstest::rstest; + +#[rstest] +#[case("61")] +#[case("000061")] +fn hex_escape_whitespace_keeps_the_string_open(#[case] digits: &str) { + let source = format!("'a\\{digits}\n0.5' 0.5px"); + assert_eq!(optimize_value(&source), format!("'a\\{digits}\n0.5' .5px")); +} + +#[test] +fn a_seventh_hex_digit_leaves_the_following_newline_unescaped() { + let source = "'a\\000061f\n0.5' 0.5px"; + assert_eq!(optimize_value(source), "'a\\000061f\n.5' 0.5px"); +} + +#[rstest] +#[case(r"&.\000061&", r".cls.\000061.cls")] +#[case(r"&.\26 &", r".cls.\26 .cls")] +fn a_nesting_token_after_a_complete_hex_escape_is_syntax( + #[case] source: &str, + #[case] expected: &str, +) { + assert_eq!(replace_unquoted(source, b'&', ".cls"), expected); +} + +#[test] +fn replacement_borrows_when_every_ampersand_is_data() { + let source = r"'&' .a\&b"; + let actual = replace_unquoted(source, b'&', ".cls"); + assert!(matches!(actual, std::borrow::Cow::Borrowed(value) if value == source)); +} diff --git a/libs/css/tests/quoted_recovery.rs b/libs/css/tests/quoted_recovery.rs new file mode 100644 index 000000000..33bbc5c44 --- /dev/null +++ b/libs/css/tests/quoted_recovery.rs @@ -0,0 +1,199 @@ +use css::{ + at_rule::{normalize_query, split_at_rule_key}, + merge_selector, + optimize_value::optimize_value, + quoted::{QuoteState, split_unquoted, string_end}, + rm_css_comment::rm_css_comment, + style_selector::split_selector_list, +}; +use rstest::rstest; + +#[rstest] +fn optimization_resumes_when_a_string_hits_a_newline( + #[values("\n", "\r", "\x0c", "\r\n")] newline: &str, +) { + // Given CSS Syntax's bad-string boundary: https://drafts.csswg.org/css-syntax-3/#consume-string-token + let source = format!("'bad{newline}0.5px"); + // When the value is optimized after the unescaped newline. + let actual = optimize_value(&source); + // Then optimization resumes without deleting the raw token boundary. + assert_eq!(actual, format!("'bad{newline}.5px")); +} + +#[rstest] +fn string_span_keeps_the_boundary_when_a_newline_ends_it( + #[values("\n", "\r", "\x0c", "\r\n")] newline: &str, +) { + // Given a bad-string followed by a real declaration separator. + let source = format!("'bad{newline};color:red"); + // When its protected span is located. + let end = string_end(source.as_bytes(), 0, b'\''); + // Then the raw newline is protected but following syntax is not. + assert_eq!(&source[..end], format!("'bad{newline}")); +} + +#[rstest] +fn continuation_bytes_stay_quoted_when_the_newline_is_escaped( + #[values("\n", "\r", "\r\n", "\x0c")] newline: &str, +) { + // Given an escaped newline inside a closed string. + let source = format!("'a\\{newline}0.5' 0.5px"); + // When the value is optimized. + let actual = optimize_value(&source); + // Then the complete raw continuation stays inside the protected string. + assert_eq!(actual, format!("'a\\{newline}0.5' .5px")); +} + +#[rstest] +fn escape_terminator_stays_quoted_when_a_hex_escape_consumes_a_newline( + #[values("\n", "\r", "\r\n", "\x0c")] newline: &str, +) { + // Given the optional whitespace consumed by a CSS hexadecimal escape. + let source = format!("'a\\a{newline}0.5' 0.5px"); + // When the following value is optimized. + let actual = optimize_value(&source); + // Then escape whitespace is not misclassified as a bad-string boundary. + assert_eq!(actual, format!("'a\\a{newline}0.5' .5px")); +} + +#[rstest] +#[case("'0.5 ")] +#[case("\"한글 0.5 \t")] +#[case("'0.5\\\n ")] +#[case("'0.5\\\r ")] +#[case("'0.5\\\r\n ")] +#[case("'0.5\\\x0c ")] +#[case("'bad\n")] +#[case("'bad\r\n")] +fn trailing_payload_survives_when_a_value_ends_inside_a_string(#[case] source: &str) { + // Given an EOF string or a raw bad-string boundary at the end of the value. + let input = format!(" {source}"); + // When surrounding CSS whitespace is trimmed for optimization. + let actual = optimize_value(&input); + // Then payload and boundary bytes are not mistaken for outside whitespace. + assert_eq!(actual, source); +} + +#[rstest] +fn comments_are_visible_when_a_bad_string_has_ended( + #[values("\n", "\r", "\x0c", "\r\n")] newline: &str, +) { + // Given a real comment after a bad-string boundary. + let input = format!("content:'bad{newline}/* gone */opacity:0.5"); + // When comments are removed. + let actual = rm_css_comment(&input); + // Then the comment is removed without folding away the terminating newline. + assert_eq!(actual, format!("content:'bad{newline}opacity:0.5")); +} + +#[rstest] +fn separators_are_visible_when_a_bad_string_has_ended(#[values("\n", "\r", "\x0c")] newline: &str) { + // Given a semicolon after an unescaped newline. + let input = format!("'bad{newline};opacity:0.5"); + // When declaration separators are scanned. + let actual: Vec<_> = split_unquoted(&input, b';').collect(); + // Then the recovered separator divides the original byte slices. + assert_eq!( + actual, + vec![format!("'bad{newline}"), "opacity:0.5".to_string()] + ); +} + +#[rstest] +fn selector_lists_recover_when_a_quoted_part_hits_a_newline( + #[values("\n", "\r", "\x0c")] newline: &str, +) { + // Given a comma after a malformed quoted selector part. + let source = format!("&'bad{newline}, &:hover"); + // When the selector list is split. + let actual = split_selector_list(&source); + // Then the list recovers and the first part keeps its raw boundary. + assert_eq!( + actual, + vec![format!("&'bad{newline}"), "&:hover".to_string()] + ); +} + +#[rstest] +fn preludes_recover_when_a_string_hits_a_newline( + #[values("\n", "\r", "\x0c", "\r\n")] newline: &str, +) { + // Given normalizable prelude syntax after a malformed string. + let source = format!("'bad{newline}( color : red )"); + // When the prelude is normalized. + let actual = normalize_query(&source); + // Then only the outside syntax changes and the raw newline survives. + assert_eq!(actual, format!("'bad{newline}(color:red)")); +} + +#[test] +fn prelude_keys_keep_payload_when_the_string_reaches_eof() { + // Given an at-rule key ending inside a string with trailing spaces. + let key = "@supports 'open "; + // When its query is separated from the at-rule name. + let actual = split_at_rule_key(key).map(|(_, query)| query); + // Then key trimming does not discard string content. + assert_eq!(actual, Some("'open ")); +} + +#[test] +fn prelude_normalization_keeps_payload_when_the_string_reaches_eof() { + // Given a prelude ending inside a string. + let source = "'open "; + // When its outside syntax is normalized. + let actual = normalize_query(source); + // Then the EOF string remains byte for byte. + assert_eq!(actual, source); +} + +#[test] +fn escaped_crlf_stays_quoted_when_fragments_split_between_cr_and_lf() { + // Given an escaped CRLF continuation split across adjacent input fragments. + let mut state = QuoteState::default(); + // When the fragments are consumed with the same quote state. + let actual: Vec<_> = ["'a\\\r", "\n&'", ";"] + .into_iter() + .flat_map(str::bytes) + .filter(|&byte| state.is_syntax(byte)) + .collect(); + // Then neither LF nor the ampersand becomes outside syntax. + assert_eq!(actual, b";"); +} + +#[test] +fn class_expansion_resumes_when_a_bad_string_has_ended() { + // Given a nesting token after an unescaped newline. + let selector = css::style_selector::StyleSelector::Selector("&'bad\n &".to_string()); + // When the generated class is written into the selector. + let actual = merge_selector("cls", Some(&selector)); + // Then the recovered nesting token expands while the newline stays intact. + assert_eq!(actual, ".cls'bad\n .cls"); +} + +#[test] +fn selector_lists_keep_payload_when_a_string_reaches_eof() { + // Given a selector list ending inside a quoted string. + let source = "&'open "; + // When list parts are trimmed. + let actual = split_selector_list(source); + // Then the EOF string's trailing spaces remain in its original slice. + assert_eq!(actual, vec![source]); +} + +#[test] +#[serial_test::serial] +fn eof_selector_payload_keeps_distinct_counter_keys() { + // Given two selectors differing only in trailing EOF-string payload. + let _state = css::test_state::TestStateGuard::new(); + css::debug::set_debug(false); + css::class_map::reset_class_map(); + // When class names are assigned to their distinct style identities. + let actual: Vec<_> = ["&'open ", "&'open "] + .map(|selector| { + css::sheet_to_classname("color", 0, Some("red"), Some(selector), None, None) + }) + .into_iter() + .collect(); + // Then generic key trimming cannot alias the two raw string payloads. + assert_ne!(actual[0], actual[1]); +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index be1213ee2..460a151a4 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -670,11 +670,18 @@ pub(crate) fn template_parts<'a>( let mut parts = Vec::new(); let mut text = String::new(); let mut depth = 0usize; + let mut quotes = css::quoted::QuoteState::default(); let mut from = 0; for (index, expression) in template.expressions.iter().enumerate() { let quasi = template.quasis[index].value.raw.as_str(); text.push_str(quasi); - depth = (depth + quasi.matches('{').count()).saturating_sub(quasi.matches('}').count()); + for byte in quasi.bytes().filter(|&byte| quotes.is_syntax(byte)) { + match byte { + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } let place = interpolation_place(&text, &template.quasis[index + 1..]); if matches!(place, Place::Value) || get_string_by_literal_expression(expression).is_some() { continue; @@ -971,4 +978,25 @@ mod tests { assert_eq!(requirement, UNPLACED); assert_eq!(expression_to_code(&code), "selector;"); } + + #[rstest::rstest] + #[case("`content: '{'; ${base}; color:red;`")] + #[case("`content: '${label}{'; ${base}; color:red;`")] + #[case("`content: '${first}${last}{'; ${base}; color:red;`")] + #[case("`&:hover {content:'{';} ${base}; color:red;`")] + fn quoted_boundaries_keep_mixins_top_level(#[case] code: &str) { + // Given quoted braces, including a string split across quasis. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let Expression::TemplateLiteral(template) = parsed(&allocator, code) else { + panic!("expected template literal"); + }; + // When the css prop is split around its top-level mixin. + let parts = template_parts(&ast, &template, true).unwrap(); + // Then the mixin remains its own expression between two text segments. + assert_eq!( + parts.get(1).map(expression_to_code), + Some("base;".to_string()) + ); + } } diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 15b1ebafc..24d43997f 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -9,6 +9,10 @@ use crate::utils::{ use css::{ at_rule::split_at_rule_key, optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value}, + quoted::{ + QuoteState, find_unquoted, rfind_unquoted, split_unquoted, string_end, trim_css, + unquoted_bytes, + }, rm_css_comment::rm_css_comment, style_selector::{StyleSelector, split_selector_list}, }; @@ -235,18 +239,35 @@ pub(crate) enum Place { /// Where an interpolation stands, from the CSS written before and after it pub(crate) fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { - let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; - let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); - let end = rest.find([';', '{', '}']); - if end.is_some_and(|end| rest.as_bytes()[end] == b'{') { + let mut quotes = QuoteState::default(); + let mut head_start = 0; + let mut has_colon = false; + for (index, byte) in before.bytes().enumerate() { + if !quotes.is_syntax(byte) { + continue; + } + match byte { + b';' | b'{' | b'}' => { + head_start = index + 1; + has_colon = false; + } + b':' => has_colon = true, + _ => {} + } + } + let end = after + .iter() + .flat_map(|quasi| quasi.value.raw.bytes()) + .find(|&byte| quotes.is_syntax(byte) && matches!(byte, b';' | b'{' | b'}')); + if end == Some(b'{') { return Place::Other; } - if head.contains(':') { + if has_colon { Place::Value - } else if head.trim().is_empty() - && rest - .chars() - .next() + } else if before[head_start..].trim().is_empty() + && after + .iter() + .find_map(|quasi| quasi.value.raw.chars().next()) .is_none_or(|c| c.is_whitespace() || c == ';') { Place::Statement @@ -629,26 +650,26 @@ fn collect_css_block( styles: &mut Vec, ) { let mut rest = css; - while let Some(open) = rest.find('{') { + while let Some(open) = find_unquoted(rest, b'{') { let head = &rest[..open]; - let (declarations, prelude) = head.rsplit_once(';').unwrap_or(("", head)); + let (declarations, prelude) = rfind_unquoted(head, b';') + .map_or(("", head), |index| (&head[..index], &head[index + 1..])); styles.extend(css_to_style_block(declarations, level, selector)); let body_start = open + 1; let mut depth = 1usize; - let body_end = rest[body_start..] - .char_indices() - .find_map(|(index, c)| { - match c { - '{' => depth += 1, - '}' => depth -= 1, + let body_end = unquoted_bytes(&rest[body_start..]) + .find_map(|(index, byte)| { + match byte { + b'{' => depth += 1, + b'}' => depth -= 1, _ => {} } (depth == 0).then_some(body_start + index) }) .unwrap_or(rest.len()); let body = &rest[body_start..body_end]; - let prelude = prelude.trim(); + let prelude = trim_css(prelude); if prelude.is_empty() || prelude == "&" { collect_css_block(body, level, selector, styles); } else if let Some(nested) = nest_prelude(selector.as_ref(), prelude) { @@ -686,7 +707,7 @@ pub(crate) fn descendants(selectors: &str) -> String { /// goes, a pseudo-class or pseudo-element applies to that rule's element, and /// any other selector to its descendants (`> p` to its children) fn relative_selector(part: &str) -> Cow<'_, str> { - if part.contains('&') { + if find_unquoted(part, b'&').is_some() { Cow::Borrowed(part) } else if part.starts_with(':') { Cow::Owned(format!("&{part}")) @@ -721,16 +742,17 @@ fn css_to_style_block( // present. let cap = cleaned.bytes().filter(|&b| b == b';').count() + 1; let mut styles = Vec::with_capacity(cap); - for s in cleaned.split(';') { - let s = s.trim(); + for s in split_unquoted(&cleaned, b';') { + let s = trim_css(s); if s.is_empty() { continue; } - let Some((property, value)) = s.split_once(':') else { + let Some(colon) = find_unquoted(s, b':') else { continue; }; + let (property, value) = (&s[..colon], &s[colon + 1..]); let property = property.trim(); - let value = value.trim(); + let value = trim_css(value); let value: Cow = optimize_decl_value(property, value); styles.push(ExtractStaticStyle::new( property, @@ -746,10 +768,10 @@ pub fn keyframes_to_keyframes_style(keyframes: &str) -> BTreeMap String { // brace-boundary rewrite would produce a string byte-identical to `cleaned`. In // that case borrow `cleaned` directly (`Cow::Borrowed`) instead of allocating and // filling a second whole-string buffer. - let bytes = cleaned.as_bytes(); - let trimmed: Cow = if bytes.iter().any(|&b| b == b'{' || b == b'}') { + let trimmed: Cow = if unquoted_bytes(cleaned).any(|(_, b)| b == b'{' || b == b'}') { let mut buf = String::with_capacity(cleaned.len()); let mut segment_start = 0; - for (idx, &b) in bytes.iter().enumerate() { + for (idx, b) in unquoted_bytes(cleaned) { if b == b'{' || b == b'}' { - buf.push_str(cleaned[segment_start..idx].trim()); + buf.push_str(trim_css(&cleaned[segment_start..idx])); buf.push(b as char); segment_start = idx + 1; } } - buf.push_str(cleaned[segment_start..].trim()); + buf.push_str(trim_css(&cleaned[segment_start..])); Cow::Owned(buf) } else { Cow::Borrowed(cleaned) }; let mut first_segment = true; - for s in trimmed.split(';') { + for s in split_unquoted(&trimmed, b';') { if !first_segment { result.push(';'); } first_segment = false; - let last_part = if let Some((prefix, last_part)) = s.rsplit_once('{') { - append_brace_prefix(&mut result, prefix); - last_part.trim() + let last_part = if let Some(brace) = rfind_unquoted(s, b'{') { + append_brace_prefix(&mut result, &s[..brace]); + trim_css(&s[brace + 1..]) } else { - s.trim() + trim_css(s) }; - if let Some((property, value)) = last_part.split_once(':') { + if let Some(colon) = find_unquoted(last_part, b':') { + let (property, value) = (&last_part[..colon], &last_part[colon + 1..]); let property = property.trim(); - let value = value.trim(); + let value = trim_css(value); let property_name = property .rsplit_once('{') @@ -837,22 +859,22 @@ pub fn optimize_css_block(css: &str) -> String { } fn append_brace_prefix(result: &mut String, prefix: &str) { - for (idx, part) in prefix.split('{').enumerate() { + for (idx, part) in split_unquoted(prefix, b'{').enumerate() { if idx > 0 { result.push('{'); } - result.push_str(part.trim()); + result.push_str(trim_css(part)); } result.push('{'); } fn trim_string_in_place(value: &mut String) { + let trimmed_len = trim_css(value).len(); let trimmed_start = value.len() - value.trim_start().len(); if trimmed_start > 0 { value.drain(..trimmed_start); } - let trimmed_len = value.trim_end().len(); value.truncate(trimmed_len); } @@ -875,6 +897,14 @@ fn remove_semicolon_before_closing_brace(value: &mut String) { let mut read = 0usize; let len = bytes.len(); while read < len { + if matches!(bytes[read], b'\'' | b'"') { + // A quoted string is data: carry it over whole. + let end = string_end(bytes, read, bytes[read]); + bytes.copy_within(read..end, write); + write += end - read; + read = end; + continue; + } if bytes[read] == b';' { // Peek past the run of ';' to see what follows it. let mut peek = read; diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 94c47d6e2..9d38fd88e 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -308,9 +308,12 @@ fn collect_global_styles<'a>( )); continue; } - StyleSelector::from(pseudo.as_ref()) - .to_string() - .replace('&', "*") + css::quoted::replace_unquoted( + &StyleSelector::from(pseudo.as_ref()).to_string(), + b'&', + "*", + ) + .into_owned() } else { name }, diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 01e3a0f14..17398d3d8 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1344,7 +1344,8 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested { /// (`'&:hover'`, `':hover'`, `'.parent &'`, `'.child'`, `'> li'`) rather /// than CSS properties. pub(crate) fn is_nested_selector_key(key: &str) -> bool { - key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) + || css::quoted::find_unquoted(key, b'&').is_some() } /// A key without `&` is relative to the element: a pseudo-class, a @@ -1356,7 +1357,7 @@ fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Nested { StyleSelector::nest_at_rule(parent, kind, query).into() } else if let Some(name) = key.strip_prefix('_') { nest_underscore_name(parent, name) - } else if key.contains('&') { + } else if css::quoted::find_unquoted(key, b'&').is_some() { Nested::Under(StyleSelector::nest_selector(parent, key)) } else if key.starts_with([':', '[']) { Nested::Under(StyleSelector::nest_selector(parent, &format!("&{key}"))) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 10f315d47..f43e6201d 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -34,6 +34,10 @@ mod pragma_path_tests; mod pragma_tests; mod prop_modify_utils; mod prop_valid; +#[cfg(test)] +mod quoted_recovery_tests; +#[cfg(test)] +mod quoted_strings_tests; pub mod reservation_state; #[cfg(test)] mod responsive_selector_tests; diff --git a/libs/extractor/src/quoted_recovery_tests.rs b/libs/extractor/src/quoted_recovery_tests.rs new file mode 100644 index 000000000..a40f4cdb6 --- /dev/null +++ b/libs/extractor/src/quoted_recovery_tests.rs @@ -0,0 +1,165 @@ +use crate::{ + css_prop::template_parts, + css_utils::{ + CssToStyleResult, Place, css_to_style, css_to_style_literal, interpolation_place, + optimize_css_block, + }, + extract_style::extract_static_style::ExtractStaticStyle, + utils::expression_to_code, +}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, Statement}; +use oxc_ast::builder::AstBuilder; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case("'open ")] +#[case("\"한글 0.5 \t")] +#[case("'a\\\r\n ")] +fn quoted_recovery_keeps_eof_payload_when_parsing_declarations(#[case] value: &str) { + // Given CSS Syntax EOF string content: https://drafts.csswg.org/css-syntax-3/#consume-string-token + let source = format!("content: {value}"); + // When a declaration is parsed into static styles. + let styles = css_to_style(&source, 0, &None); + // Then trimming at every parser boundary preserves the trailing payload. + assert_eq!( + styles + .iter() + .map(ExtractStaticStyle::value) + .collect::>(), + vec![value] + ); +} + +#[test] +fn quoted_recovery_keeps_eof_payload_when_optimizing_blocks() { + // Given an EOF-open string at the end of a raw CSS block. + let source = "content: 'open "; + // When declaration formatting is optimized. + let actual = optimize_css_block(source); + // Then the final whole-block trim preserves the string's spaces. + assert_eq!(actual, "content:'open "); +} + +#[test] +fn quoted_recovery_keeps_quoted_colons_when_an_optimized_block_ends_at_eof() { + // Given an incomplete selector with a colon inside its EOF string. + let source = "&[title='a:b "; + // When raw block optimization sees no actual declaration or rule body. + let actual = optimize_css_block(source); + // Then the quoted colon cannot split off payload for generic value trimming. + assert_eq!(actual, source); +} + +#[rstest] +fn quoted_recovery_parses_following_syntax_when_a_string_hits_a_newline( + #[values("\n", "\r", "\x0c", "\r\n")] newline: &str, +) { + // Given a bad string before a real separator, comment and declaration. + let source = format!("content:'bad{newline};/* gone */opacity:0.5"); + // When the CSS block is parsed. + let styles = css_to_style(&source, 0, &None); + // Then following syntax is recovered without deleting the raw newline. + let actual: Vec<_> = styles + .iter() + .map(|style| (style.property().to_string(), style.value().to_string())) + .collect(); + assert_eq!( + actual, + vec![ + ("content".to_string(), format!("'bad{newline}")), + ("opacity".to_string(), ".5".to_string()) + ] + ); +} + +#[rstest] +fn quoted_recovery_places_mixins_when_a_newline_crosses_quasis( + #[values("\n", "\r", "\x0c")] newline: &str, +) { + // Given a string opened in one quasi and terminated in the following quasi. + let source = format!("`content:'${{label}}{newline}; ${{base}};`"); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &source, SourceType::ts()).parse(); + let Statement::ExpressionStatement(expression) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + let Expression::TemplateLiteral(template) = &expression.expression else { + panic!("expected template"); + }; + let before = format!( + "{}{}", + template.quasis[0].value.raw, template.quasis[1].value.raw + ); + // When placement scans the boundary across those quasis. + let actual = interpolation_place(&before, &template.quasis[2..]); + // Then the following interpolation is a statement, not string value data. + assert!(matches!(actual, Place::Statement)); +} + +#[rstest] +fn quoted_recovery_splits_mixins_when_a_bad_string_ends_in_a_later_quasi( + #[values("\n", "\r", "\x0c")] newline: &str, +) { + // Given the same malformed string in a css-prop template. + let source = format!("`content:'${{label}}{newline}; ${{base}};`"); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &source, SourceType::ts()).parse(); + let Statement::ExpressionStatement(expression) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + let Expression::TemplateLiteral(template) = &expression.expression else { + panic!("expected template"); + }; + // When the css prop is decomposed around top-level mixins. + let Ok(actual) = template_parts(&AstBuilder::new(&allocator), template, true) else { + panic!("expected top-level mixin parts"); + }; + // Then the recovered mixin is represented as its own expression. + assert_eq!( + actual.get(1).map(expression_to_code), + Some("base;".to_string()) + ); +} + +#[test] +fn quoted_recovery_selects_dependencies_when_a_bad_string_ended_in_an_earlier_quasi() { + // Given a selector interpolation after a string's terminating newline. + let source = concat!( + "css`content:'${", + "value}\n; ${", + "selector}{", + "color:red}`" + ); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::ts()).parse(); + let mut selected = Vec::new(); + // When dependencies are classified before extraction. + crate::style_values::selectors(&parsed.program, &mut |expression| { + selected.push(crate::utils::readable_code(expression)); + }); + // Then only the recovered selector dependency is selected. + assert_eq!(selected, vec!["selector"]); +} + +#[test] +fn quoted_recovery_keeps_eof_payload_when_extracting_a_template() { + // Given an EOF-open CSS string ending at the JavaScript template boundary. + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, "`content:'open `", SourceType::ts()).parse(); + let Statement::ExpressionStatement(expression) = &parsed.program.body[0] else { + panic!("expected expression"); + }; + let Expression::TemplateLiteral(template) = &expression.expression else { + panic!("expected template"); + }; + // When the template is extracted into static declarations. + let styles = css_to_style_literal(template, 0, &None); + // Then the original trailing CSS string bytes reach the extracted value. + let [CssToStyleResult::Static(style)] = styles.as_slice() else { + panic!("expected one static style"); + }; + assert_eq!(style.value(), "'open "); +} diff --git a/libs/extractor/src/quoted_strings_tests.rs b/libs/extractor/src/quoted_strings_tests.rs new file mode 100644 index 000000000..4acb6e6bb --- /dev/null +++ b/libs/extractor/src/quoted_strings_tests.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeSet; + +use crate::{ + ExtractOption, ExtractStyleValue, + css_utils::{css_to_style, keyframes_to_keyframes_style, optimize_css_block}, + extract, +}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[test] +fn quoted_boundaries_select_only_selector_interpolations() { + // Given quoted value punctuation and selector strings spanning quasis. + let allocator = oxc_allocator::Allocator::default(); + let source = concat!( + "css`content:'a;${", + "value}${", + "other}{'; &[title='${", + "selector}'] {", + "color:red}`" + ); + let parsed = oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::tsx()).parse(); + let mut selected = Vec::new(); + // When selector dependencies are visited before extraction. + crate::style_values::selectors(&parsed.program, &mut |expression| { + selected.push(crate::utils::readable_code(expression)); + }); + // Then value expressions are not mistaken for selector dependencies. + assert_eq!(selected, vec!["selector"]); +} + +fn static_values(source: &str) -> Result, Box> { + reset_class_map(); + reset_file_map(); + let output = extract("quoted.tsx", source, ExtractOption::default())?; + Ok(output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property().to_string(), style.value().to_string())) + } + _ => None, + }) + .collect()) +} + +fn boxed(props: &str) -> String { + format!("import {{ Box }} from '@devup-ui/react';\nexport const View = () => ;") +} + +#[rstest] +#[case::number(r#"content='"value:0.5"'"#, "content", r#""value:0.5""#)] +#[case::single_quoted(r#"content="'0.5'""#, "content", "'0.5'")] +#[case::signed_unit(r#"content='"-0.12px"'"#, "content", r#""-0.12px""#)] +#[case::color(r#"content="'#ff0000'""#, "content", "'#ff0000'")] +#[case::color_function( + r#"content='"rgba(255,0,0,0.5)"'"#, + "content", + r#""rgba(255,0,0,0.5)""# +)] +#[case::list(r#"content='"a, b"'"#, "content", r#""a, b""#)] +#[case::semicolon(r#"content='"a;"'"#, "content", r#""a;""#)] +#[case::open_paren(r#"content='"("'"#, "content", r#""(""#)] +#[case::close_paren(r#"content="')'""#, "content", "')'")] +#[case::spaces(r#"content='"white space"'"#, "content", r#""white space""#)] +#[case::mixed_css( + r#"content='"color: #ff0000; gap: 0.50px"'"#, + "content", + r#""color: #ff0000; gap: 0.50px""# +)] +#[case::escaped_quote(r#"content={"'it\\'s 0.5'"}"#, "content", r"'it\'s 0.5'")] +#[case::around_string(r#"content="'a' 0.50 'b'""#, "content", "'a' .50 'b'")] +#[case::unquoted_number("opacity={0.5}", "opacity", ".5")] +#[case::unquoted_color(r##"color="#ff0000""##, "color", "#F00")] +#[serial] +fn box_props_keep_quoted_strings( + #[case] props: &str, + #[case] property: &str, + #[case] expected: &str, +) -> Result<(), Box> { + let values = static_values(&boxed(props))?; + assert_eq!( + values, + BTreeSet::from([(property.to_string(), expected.to_string())]) + ); + Ok(()) +} + +#[rstest] +#[case::number("content: '0.5'", "content", "'0.5'")] +#[case::color("content: \"#ff0000\"", "content", "\"#ff0000\"")] +#[case::list("content: 'a, b'", "content", "'a, b'")] +#[case::paren("content: '('", "content", "'('")] +#[case::family_list("font-family: 'A, B', sans-serif", "font-family", "'A, B',sans-serif")] +#[case::family_quote("font-family: 'say \"hi\"', b", "font-family", "'say \"hi\"',b")] +#[case::family_escape(r"font-family: 'It\'s two', b", "font-family", r"'It\'s two',b")] +#[case::around_string("margin: 0px 'x' 0px", "margin", "0 'x' 0")] +fn templates_keep_quoted_strings( + #[case] css: &str, + #[case] property: &str, + #[case] expected: &str, +) { + let styles = css_to_style(css, 0, &None); + assert_eq!( + styles + .iter() + .map(|style| (style.property(), style.value())) + .collect::>(), + vec![(property, expected)] + ); +} + +#[rstest] +#[serial] +fn stylex_keeps_quoted_strings() -> Result<(), Box> { + let values = static_values( + "import stylex from '@stylexjs/stylex';\nconst styles = stylex.create({ a: { content: \"'0.5'\", opacity: '0.5' } });", + )?; + assert_eq!( + values, + BTreeSet::from([ + ("content".to_string(), "'0.5'".to_string()), + ("opacity".to_string(), ".5".to_string()), + ]) + ); + Ok(()) +} + +#[rstest] +#[serial] +fn font_face_names_keep_quoted_strings() -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let output = extract( + "quoted.tsx", + "import { globalCss } from '@devup-ui/react';\nglobalCss({ fontFaces: [{ fontFamily: \"'A, B'\", src: 'a.woff2' }] });", + ExtractOption::default(), + )?; + let faces: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::FontFace(face) => { + Some(face.properties["font-family"].as_str().to_string()) + } + _ => None, + }) + .collect(); + assert_eq!(faces, vec!["'A, B'".to_string()]); + Ok(()) +} + +#[rstest] +#[case::semicolon("content: 'a;b'; color: red", &[("color", "red"), ("content", "'a;b'")])] +#[case::semicolon_space("content: 'a; b'", &[("content", "'a; b'")])] +#[case::open_brace("content: '{'; color: red", &[("color", "red"), ("content", "'{'")])] +#[case::close_brace("content: '}'; color: red", &[("color", "red"), ("content", "'}'")])] +#[case::semicolon_brace("content: ';}'", &[("content", "';}'")])] +#[case::colon("content: \"a:b\"; color: red", &[("color", "red"), ("content", "\"a:b\"")])] +#[case::nested( + "&:hover { content: 'a;b'; color: red } content: '}'", + &[("color", "red"), ("content", "'a;b'"), ("content", "'}'")] +)] +#[case::comment("content: '/* a */'; /* b */ color: red", &[("color", "red"), ("content", "'/* a */'")])] +#[case::spaces("content: 'a b'; color: red", &[("color", "red"), ("content", "'a b'")])] +fn template_blocks_keep_quoted_strings(#[case] css: &str, #[case] expected: &[(&str, &str)]) { + let mut styles: Vec<_> = css_to_style(css, 0, &None) + .iter() + .map(|style| (style.property().to_string(), style.value().to_string())) + .collect(); + styles.sort(); + let expected: Vec<_> = expected + .iter() + .map(|(property, value)| (property.to_string(), value.to_string())) + .collect(); + assert_eq!(styles, expected); +} + +#[rstest] +#[case::semicolon("content: 'a; b'", "content:'a; b'")] +#[case::both("content: 'a;b'; color: red", "content:'a;b';color:red")] +#[case::open_brace("content: '{'; color: red", "content:'{';color:red")] +#[case::semicolon_brace(concat!("a{", "content:';}';color:red;}"), concat!("a{", "content:';}';color:red}"))] +#[case::nested( + concat!("a { content: '}' ; b ", "{ color: red ; } }"), + concat!("a{content:'}';b", "{color:red}}") +)] +#[case::family( + "a{font-family: 'A, B', 'Open Sans'; }", + "a{font-family:'A, B',\"Open Sans\"}" +)] +fn optimized_blocks_keep_quoted_strings(#[case] css: &str, #[case] expected: &str) { + assert_eq!(optimize_css_block(css), expected); +} + +#[test] +fn keyframes_keep_quoted_strings() { + let frames = keyframes_to_keyframes_style( + "from { content: '}'; color: red } to { content: 'a;b'; opacity: 0.5 }", + ); + let frames: Vec<_> = frames + .iter() + .map(|(key, styles)| { + ( + key.as_str(), + styles + .iter() + .map(|style| (style.property(), style.value())) + .collect::>(), + ) + }) + .collect(); + assert_eq!( + frames, + vec![ + ("from", vec![("color", "red"), ("content", "'}'")]), + ("to", vec![("content", "'a;b'"), ("opacity", ".5")]), + ] + ); +} diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 2a6722fd7..27bc8652b 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -870,10 +870,9 @@ pub fn parse_class(class: &str) -> Option { selectors = selectors .iter() .flat_map(|outer| { - variant - .selectors - .iter() - .map(move |selector| selector.replace('&', outer)) + variant.selectors.iter().map(move |selector| { + css::quoted::replace_unquoted(selector, b'&', outer).into_owned() + }) }) .collect(); } @@ -883,7 +882,9 @@ pub fn parse_class(class: &str) -> Option { if utility_selector != "&" { selectors = selectors .iter() - .map(|selector| utility_selector.replace('&', selector)) + .map(|selector| { + css::quoted::replace_unquoted(utility_selector, b'&', selector).into_owned() + }) .collect(); } if content diff --git a/libs/extractor/tests/quoted_boundaries.rs b/libs/extractor/tests/quoted_boundaries.rs new file mode 100644 index 000000000..bed15a900 --- /dev/null +++ b/libs/extractor/tests/quoted_boundaries.rs @@ -0,0 +1,114 @@ +use std::{collections::HashMap, error::Error}; + +use extractor::{ExtractOption, extract, extract_style::extract_style_value::ExtractStyleValue}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(r"content: 'a;${p => p.label}';")] +#[case(r"content: '${p => p.label}{';")] +#[case(r"content: 'a}${p => p.label}';")] +#[case(r"content: 'a:${p => p.label}';")] +#[case(r"content: 'a;${p => p.first}${p => p.last}{';")] +#[case(r"content: 'a\'${p => p.label}{';")] +#[serial] +fn interpolation_in_a_quoted_value_stays_a_value(#[case] css: &str) -> Result<(), Box> { + let _state = extractor::test_state::TestStateGuard::new(); + let source = + format!("import {{styled}} from '@devup-ui/react';export const C=styled('div')`{css}`;"); + let output = extract("quoted-boundaries.tsx", &source, options())?; + assert!(output.styles.iter().any( + |style| matches!(style, ExtractStyleValue::Dynamic(style) if style.property() == "content") + )); + Ok(()) +} + +#[rstest] +#[case(r#"&[title="a, > b"]"#)] +#[case(r#"&[title="a;{b}"]"#)] +#[case(r#"&[title="/* keep */"]"#)] +#[serial] +fn template_selector_preserves_quoted_bytes(#[case] selector: &str) -> Result<(), Box> { + let _state = extractor::test_state::TestStateGuard::new(); + let source = format!( + "import {{css}} from '@devup-ui/react';export const cls=css`{selector}{{color:red}}`;" + ); + let output = extract("quoted-boundaries.tsx", &source, options())?; + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|actual| actual.to_string() == selector))), "{:?}", output.styles); + Ok(()) +} + +fn options() -> ExtractOption { + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + } +} + +#[rstest] +#[case(concat!(r#"css`[title="&"]"#, "{", "color:red", "}`"), r#"& [title="&"]"#)] +#[case(r#"css({selectors:{'[title="&"]':{color:'red'}}})"#, r#"&[title="&"]"#)] +#[serial] +fn bare_selectors_keep_their_anchor_when_ampersands_are_quoted( + #[case] expression: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given template descendant or object self-selector syntax. + let _state = extractor::test_state::TestStateGuard::new(); + let source = format!("import {{css}} from '@devup-ui/react';export const cls={expression};"); + // When the style is extracted. + let output = extract("quoted-anchors.tsx", &source, options())?; + // Then the literal ampersand cannot stand in for the element's anchor. + let selectors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => style.selector.as_ref().map(ToString::to_string), + _ => None, + }) + .collect(); + assert_eq!(selectors, vec![expected]); + Ok(()) +} + +#[rstest] +#[case(r"focus:[&[title='a&b']]:p-4", r"&:focus[title='a&b']")] +#[case(r"[&[title='a&b']]:focus:p-4", r"&[title='a&b']:focus")] +#[serial] +fn tailwind_composition_keeps_ampersands_when_they_are_quoted( + #[case] class: &str, + #[case] expected: &str, +) -> Result<(), Box> { + // Given a stacked arbitrary selector variant. + let _state = extractor::test_state::TestStateGuard::new(); + let source = format!( + "import {{Box}} from '@devup-ui/react';export const C=()=> ;" + ); + // When the utility is extracted. + let output = extract("quoted-tailwind.tsx", &source, options())?; + // Then substitution preserves the attribute's literal payload. + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string() == expected) + )), "{:?}", output.styles); + Ok(()) +} + +#[test] +#[serial] +fn global_pseudo_nesting_preserves_quoted_ampersands() -> Result<(), Box> { + let _state = extractor::test_state::TestStateGuard::new(); + let source = concat!( + "import{globalCss}from'@devup-ui/react';globalCss(", + "{_hover:{selectors:{", + r#""&[title='&']": {color:'red'}"#, + "}}});" + ); + let output = extract("quoted-global.tsx", source, options())?; + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string() == "*:hover[title='&']") + )), "{:?}", output.styles); + Ok(()) +}