From 68a1b0a7f081d287cdcbbedb02aaed659567baeb Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 2 Sep 2026 14:51:43 +0800 Subject: [PATCH] fix(cssom): preserve invalid forgiving selectors --- moli-selector/src/cssom_selector.rs | 226 ++++++++++++++++++++++ moli-selector/src/lib.rs | 6 +- moli-selector/src/stylo.rs | 2 +- moli-selector/src/stylo/selector_parse.rs | 10 +- 4 files changed, 241 insertions(+), 3 deletions(-) diff --git a/moli-selector/src/cssom_selector.rs b/moli-selector/src/cssom_selector.rs index 907b9c9ef3..0bb7327ded 100644 --- a/moli-selector/src/cssom_selector.rs +++ b/moli-selector/src/cssom_selector.rs @@ -2,6 +2,7 @@ use cssparser::{ BasicParseErrorKind, ParseError, Parser, ParserInput, Token, parse_nth, serialize_identifier, serialize_string, }; +use std::ops::Range; use moli_css_parse::serialize_component_values_single_line; @@ -147,6 +148,191 @@ pub(crate) fn serialize_cssom_selector_text( )) } +pub(crate) fn serialize_cssom_selector_text_preserving_invalid_forgiving_items( + selector_text: &str, + has_default_namespace: bool, + prefixes_matching_default_namespace: &[String], + is_strictly_valid: impl Fn(&str) -> bool, +) -> Option { + let (protected_selector, preserved_items) = + protect_invalid_forgiving_selector_items(selector_text, is_strictly_valid)?; + let mut serialized = serialize_cssom_selector_text( + &protected_selector, + has_default_namespace, + prefixes_matching_default_namespace, + )?; + for (marker, item) in preserved_items { + if serialized.matches(&marker).count() != 1 { + return None; + } + serialized = serialized.replacen(&marker, &item, 1); + } + Some(serialized) +} + +fn protect_invalid_forgiving_selector_items( + selector_text: &str, + is_strictly_valid: impl Fn(&str) -> bool, +) -> Option<(String, Vec<(String, String)>)> { + let argument_ranges = outermost_forgiving_selector_argument_ranges(selector_text)?; + if argument_ranges.is_empty() || is_strictly_valid(selector_text) { + return Some((selector_text.to_owned(), Vec::new())); + } + + let baseline = + selector_with_forgiving_argument_replacements(selector_text, &argument_ranges, None); + let can_classify_items = is_strictly_valid(&baseline); + let mut invalid_item_ranges = Vec::new(); + for (argument_index, argument_range) in argument_ranges.iter().enumerate() { + let argument = selector_text.get(argument_range.clone())?; + for item_range in top_level_comma_separated_selector_item_ranges(argument)? { + let item = argument.get(item_range.clone())?; + let item_is_valid = can_classify_items + && is_strictly_valid(&selector_with_forgiving_argument_replacements( + selector_text, + &argument_ranges, + Some((argument_index, item)), + )); + if !item_is_valid { + invalid_item_ranges.push( + argument_range.start + item_range.start..argument_range.start + item_range.end, + ); + } + } + } + + let collision_text = serialize_component_values_single_line(selector_text)?; + let mut protected_selector = selector_text.to_owned(); + let mut preserved_items = Vec::with_capacity(invalid_item_ranges.len()); + for (index, range) in invalid_item_ranges.into_iter().enumerate().rev() { + let item = selector_text.get(range.clone())?; + let marker = unused_forgiving_selector_marker(selector_text, &collision_text, index); + protected_selector.replace_range(range, &marker); + preserved_items.push((marker, trim_css_whitespace(item).to_owned())); + } + Some((protected_selector, preserved_items)) +} + +fn selector_with_forgiving_argument_replacements( + selector_text: &str, + argument_ranges: &[Range], + selected_item: Option<(usize, &str)>, +) -> String { + let mut selector = selector_text.to_owned(); + for (index, range) in argument_ranges.iter().enumerate().rev() { + let replacement = selected_item + .filter(|(selected_index, _)| *selected_index == index) + .map_or(":hover", |(_, item)| trim_css_whitespace(item)); + selector.replace_range(range.clone(), replacement); + } + selector +} + +fn unused_forgiving_selector_marker( + selector_text: &str, + collision_text: &str, + index: usize, +) -> String { + let mut attempt = 0_u32; + loop { + let marker = format!(".__moli_cssom_invalid_forgiving_{index}_{attempt}"); + if !selector_text.contains(&marker) && !collision_text.contains(&marker) { + return marker; + } + attempt = attempt + .checked_add(1) + .expect("forgiving selector marker search exhausted"); + } +} + +fn outermost_forgiving_selector_argument_ranges(selector_text: &str) -> Option>> { + let mut input = ParserInput::new(selector_text); + let mut parser = Parser::new(&mut input); + let mut ranges = Vec::new(); + collect_outermost_forgiving_selector_argument_ranges(&mut parser, &mut ranges)?; + Some(ranges) +} + +fn collect_outermost_forgiving_selector_argument_ranges( + input: &mut Parser<'_, '_>, + ranges: &mut Vec>, +) -> Option<()> { + let mut previous_was_colon = false; + while let Ok(token) = input.next_including_whitespace_and_comments().cloned() { + if token.is_parse_error() { + return None; + } + match token { + Token::Colon => previous_was_colon = true, + Token::Comment(_) if previous_was_colon => {} + Token::Function(name) => { + let is_forgiving_selector = previous_was_colon + && (name.eq_ignore_ascii_case("is") || name.eq_ignore_ascii_case("where")); + let argument_start = input.position().byte_index(); + let mut argument_end = None; + let nested: Result<(), ParseError<'_, ()>> = input.parse_nested_block(|input| { + if is_forgiving_selector { + consume_component_values(input)?; + argument_end = Some(input.position().byte_index()); + Ok(()) + } else { + collect_outermost_forgiving_selector_argument_ranges(input, ranges) + .ok_or_else(|| input.new_custom_error::<(), ()>(())) + } + }); + nested.ok()?; + if let Some(argument_end) = argument_end { + ranges.push(argument_start..argument_end); + } + previous_was_colon = false; + } + Token::ParenthesisBlock | Token::CurlyBracketBlock => { + let nested: Result<(), ParseError<'_, ()>> = input.parse_nested_block(|input| { + collect_outermost_forgiving_selector_argument_ranges(input, ranges) + .ok_or_else(|| input.new_custom_error::<(), ()>(())) + }); + nested.ok()?; + previous_was_colon = false; + } + Token::SquareBracketBlock => { + consume_nested_component_value(input, &token).ok()?; + previous_was_colon = false; + } + _ => previous_was_colon = false, + } + } + Some(()) +} + +fn top_level_comma_separated_selector_item_ranges(argument: &str) -> Option>> { + let mut input = ParserInput::new(argument); + let mut parser = Parser::new(&mut input); + let mut ranges = Vec::new(); + let mut item_start = 0; + while !parser.is_exhausted() { + let token_start = parser.position().byte_index(); + let token = parser + .next_including_whitespace_and_comments() + .ok()? + .clone(); + if token.is_parse_error() { + return None; + } + if matches!(token, Token::Comma) { + ranges.push(item_start..token_start); + item_start = parser.position().byte_index(); + } else { + consume_nested_component_value(&mut parser, &token).ok()?; + } + } + ranges.push(item_start..argument.len()); + Some(ranges) +} + +fn trim_css_whitespace(value: &str) -> &str { + value.trim_matches(['\t', '\n', '\x0c', '\r', ' ']) +} + fn serialize_cssom_selector_text_without_default_namespace(selector_text: &str) -> Option { serialize_cssom_selector_text(selector_text, false, &[]) } @@ -1073,6 +1259,46 @@ mod tests { ); } + #[test] + fn preserves_only_invalid_forgiving_selector_items() { + let serialize = |selector| { + serialize_cssom_selector_text_preserving_invalid_forgiving_items( + selector, + false, + &[], + |candidate| crate::stylo::validate_supports_selector_list(candidate).is_ok(), + ) + .unwrap() + }; + + assert_eq!( + serialize("::part(foo):is([attr='value'])"), + "::part(foo):is([attr='value'])" + ); + assert_eq!( + serialize("::part(foo):where([attr='value'])"), + "::part(foo):where([attr='value'])" + ); + assert_eq!( + serialize(":is([att=val], ::before:HOVER )"), + r#":is([att="val"], ::before:HOVER)"# + ); + assert_eq!(serialize(":is(,,, )"), ":is(, , , )"); + assert_eq!(serialize(":host(:is(,,,))"), ":host(:is(, , , ))"); + assert_eq!( + serialize(":host(:is(.a, .b+.c, .d))"), + ":host(:is(.a, .b+.c, .d))" + ); + assert_eq!( + serialize(":has(:is(:has(.a+.b)))"), + ":has(:is(:has(.a+.b)))" + ); + assert_eq!( + serialize(":is([att='valid'], .other)"), + r#":is([att="valid"], .other)"# + ); + } + #[test] fn parses_get_computed_style_pseudo_element_argument() { assert_eq!( diff --git a/moli-selector/src/lib.rs b/moli-selector/src/lib.rs index c560b7d458..326eb3d4c6 100644 --- a/moli-selector/src/lib.rs +++ b/moli-selector/src/lib.rs @@ -432,10 +432,14 @@ fn serialize_cssom_style_rule_selector_text( ) -> String { let prefixes_matching_default_namespace = namespace_context.prefixes_matching_default_namespace(); - cssom_selector::serialize_cssom_selector_text( + cssom_selector::serialize_cssom_selector_text_preserving_invalid_forgiving_items( selector, namespace_context.has_default_namespace(), &prefixes_matching_default_namespace, + |candidate| { + stylo::validate_supports_selector_list_with_namespaces(candidate, namespace_context) + .is_ok() + }, ) .unwrap_or_else(|| selector.to_owned()) } diff --git a/moli-selector/src/stylo.rs b/moli-selector/src/stylo.rs index f41a1d172f..0d0b2f62c5 100644 --- a/moli-selector/src/stylo.rs +++ b/moli-selector/src/stylo.rs @@ -143,7 +143,7 @@ pub(crate) use selector_parse::{ normalize_scope_style_rule_selector_list_with_namespaces, parse_dom_api_selector_list, parse_dom_api_selector_list_for_url, validate_style_rule_selector_list, validate_style_rule_selector_list_with_namespaces, - validate_supports_selector_condition_argument, + validate_supports_selector_condition_argument, validate_supports_selector_list_with_namespaces, }; pub use style_traversal::{ StyloDocument, StyloDomHostBinding, StyloDomStyleAdapter, StyloElement, StyloElementDataStore, diff --git a/moli-selector/src/stylo/selector_parse.rs b/moli-selector/src/stylo/selector_parse.rs index 8ff20a9a0d..8fab411790 100644 --- a/moli-selector/src/stylo/selector_parse.rs +++ b/moli-selector/src/stylo/selector_parse.rs @@ -187,9 +187,17 @@ pub(crate) fn validate_style_rule_selector_list(selector: &str) -> Result<(), Se } pub(crate) fn validate_supports_selector_list(selector: &str) -> Result<(), SelectorError> { - parse_style_rule_selector_list_for_url( + validate_supports_selector_list_with_namespaces(selector, &StyleRuleNamespaceContext::default()) +} + +pub(crate) fn validate_supports_selector_list_with_namespaces( + selector: &str, + namespace_context: &StyleRuleNamespaceContext, +) -> Result<(), SelectorError> { + parse_style_rule_selector_list_for_url_and_namespaces( selector, Url::parse("about:blank").expect("about:blank is valid"), + namespace_context, true, ) .map(|_| ())