Skip to content

Commit c7020e9

Browse files
committed
refactor(css): enhance function call detection and add tests for relative color handling
Refactors the logic for detecting CSS function calls to improve accuracy, particularly for relative color functions. Introduces a new helper function to streamline the detection process and ensures that function names are not mistakenly matched as suffixes of longer identifiers. Additionally, adds a test case to verify that relative RGB values within strings are correctly normalized, maintaining proper spacing.
1 parent 2be0af4 commit c7020e9

2 files changed

Lines changed: 81 additions & 42 deletions

File tree

crates/stylex-css/src/css/common.rs

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -379,31 +379,81 @@ pub fn swc_parse_css(source: &str) -> (Result<Stylesheet, Error>, Vec<Error>) {
379379
(parse_string_input(input, None, config, &mut errors), errors)
380380
}
381381

382+
/// A byte that may appear in a CSS identifier (used to ensure a matched
383+
/// function name is not a suffix of a longer identifier).
384+
fn is_ident_byte(byte: u8) -> bool {
385+
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
386+
}
387+
382388
fn contains_css_function_call(value: &str, function_name: &str) -> bool {
389+
find_css_function_call(value, function_name, |_, _| true)
390+
}
391+
392+
fn find_css_function_call(
393+
value: &str,
394+
function_name: &str,
395+
mut matches_after_open_paren: impl FnMut(&[u8], usize) -> bool,
396+
) -> bool {
383397
let value_bytes = value.as_bytes();
384398
let function_bytes = function_name.as_bytes();
385399

386400
if function_bytes.is_empty() || value_bytes.len() <= function_bytes.len() {
387401
return false;
388402
}
389403

390-
for i in 0..=(value_bytes.len() - function_bytes.len() - 1) {
391-
if value_bytes[i..i + function_bytes.len()].eq_ignore_ascii_case(function_bytes)
392-
&& value_bytes[i + function_bytes.len()] == b'('
393-
{
394-
return true;
404+
let mut quote: Option<u8> = None;
405+
let mut is_comment = false;
406+
let mut index = 0;
407+
408+
while index < value_bytes.len() {
409+
let byte = value_bytes[index];
410+
411+
if quote.is_none() {
412+
if is_comment {
413+
if byte == b'*' && value_bytes.get(index + 1) == Some(&b'/') {
414+
is_comment = false;
415+
index += 2;
416+
continue;
417+
}
418+
419+
index += 1;
420+
continue;
421+
}
422+
423+
if byte == b'/' && value_bytes.get(index + 1) == Some(&b'*') {
424+
is_comment = true;
425+
index += 2;
426+
continue;
427+
}
395428
}
429+
430+
match quote {
431+
Some(current_quote) if byte == current_quote && !is_escaped(value_bytes, index) => {
432+
quote = None;
433+
},
434+
Some(_) => {},
435+
None if (byte == b'\'' || byte == b'"') && !is_escaped(value_bytes, index) => {
436+
quote = Some(byte);
437+
},
438+
None
439+
if index + function_bytes.len() < value_bytes.len()
440+
&& value_bytes[index..index + function_bytes.len()]
441+
.eq_ignore_ascii_case(function_bytes)
442+
&& value_bytes[index + function_bytes.len()] == b'('
443+
&& (index == 0 || !is_ident_byte(value_bytes[index - 1]))
444+
&& matches_after_open_paren(value_bytes, index + function_bytes.len() + 1) =>
445+
{
446+
return true;
447+
},
448+
_ => {},
449+
}
450+
451+
index += 1;
396452
}
397453

398454
false
399455
}
400456

401-
/// A byte that may appear in a CSS identifier (used to ensure a matched
402-
/// function name is not a suffix of a longer identifier).
403-
fn is_ident_byte(byte: u8) -> bool {
404-
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
405-
}
406-
407457
/// Detects CSS relative color syntax, e.g. `rgb(from red r g b)`.
408458
///
409459
/// A relative color function is any color function (see
@@ -420,46 +470,19 @@ fn contains_relative_color_function(value: &str) -> bool {
420470
/// `(` and, after any whitespace, the `from` keyword (the relative color
421471
/// marker).
422472
fn has_relative_color_call(value: &str, function_name: &str) -> bool {
423-
let value_bytes = value.as_bytes();
424-
let name_bytes = function_name.as_bytes();
425-
426-
if value_bytes.len() <= name_bytes.len() {
427-
return false;
428-
}
429-
430473
const FROM: &[u8] = b"from";
431474

432-
for i in 0..=(value_bytes.len() - name_bytes.len() - 1) {
433-
if !value_bytes[i..i + name_bytes.len()].eq_ignore_ascii_case(name_bytes) {
434-
continue;
435-
}
436-
437-
// The function name must be immediately followed by `(`.
438-
if value_bytes[i + name_bytes.len()] != b'(' {
439-
continue;
440-
}
441-
442-
// Avoid matching a suffix of a longer identifier (e.g. `srgb(` for `rgb`).
443-
if i > 0 && is_ident_byte(value_bytes[i - 1]) {
444-
continue;
445-
}
446-
475+
find_css_function_call(value, function_name, |value_bytes, mut cursor| {
447476
// Skip whitespace after `(`, then look for the `from` keyword followed by a
448477
// whitespace boundary.
449-
let mut cursor = i + name_bytes.len() + 1;
450478
while cursor < value_bytes.len() && value_bytes[cursor].is_ascii_whitespace() {
451479
cursor += 1;
452480
}
453481

454-
if cursor + FROM.len() < value_bytes.len()
482+
cursor + FROM.len() < value_bytes.len()
455483
&& value_bytes[cursor..cursor + FROM.len()].eq_ignore_ascii_case(FROM)
456484
&& value_bytes[cursor + FROM.len()].is_ascii_whitespace()
457-
{
458-
return true;
459-
}
460-
}
461-
462-
false
485+
})
463486
}
464487

465488
fn is_escaped(value: &[u8], index: usize) -> bool {
@@ -638,7 +661,9 @@ pub fn normalize_css_property_value(
638661
detect_unclosed_strings(css_property_value);
639662

640663
if should_normalize_spacing_only {
641-
return MANY_SPACES.replace_all(css_property_value, " ").to_string();
664+
return MANY_SPACES
665+
.replace_all(css_property_value, " ")
666+
.replace("( ", "(");
642667
}
643668

644669
let (parsed_css, errors) = swc_parse_css(css_rule.as_str());

crates/stylex-css/src/css/tests/common_test.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,13 @@ mod normalize_css_property_value_tests {
621621
assert_eq!(result, "rgb(from red r g b)");
622622
}
623623

624+
#[test]
625+
fn relative_rgb_color_collapses_whitespace_after_open_paren() {
626+
let opts = default_options();
627+
let result = normalize_css_property_value("color", "rgb( from red r g b)", &opts);
628+
assert_eq!(result, "rgb(from red r g b)");
629+
}
630+
624631
#[test]
625632
fn relative_color_function_returns_early() {
626633
let opts = default_options();
@@ -642,6 +649,13 @@ mod normalize_css_property_value_tests {
642649
);
643650
}
644651

652+
#[test]
653+
fn relative_rgb_inside_string_does_not_trigger_spacing_only_path() {
654+
let opts = default_options();
655+
let result = normalize_css_property_value("content", r#""rgb(from red r g b)""#, &opts);
656+
assert_eq!(result, r#""rgb(from red r g b)""#);
657+
}
658+
645659
#[test]
646660
fn non_relative_rgb_still_parsed_by_swc() {
647661
let opts = default_options();

0 commit comments

Comments
 (0)