From 6f71ef5fdfb1b9f9041259eda1004744d8807fbf Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 17 Sep 2023 14:35:32 -0700 Subject: [PATCH] Support @scope rule (#586) --- node/src/transformer.rs | 1 + selectors/parser.rs | 14 +++-- src/lib.rs | 116 ++++++++++++++++++++++++++++++++++++++++ src/parser.rs | 60 +++++++++++++++++++-- src/printer.rs | 10 ++++ src/rules/mod.rs | 10 ++++ src/rules/scope.rs | 82 ++++++++++++++++++++++++++++ src/selector.rs | 1 + 8 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/rules/scope.rs diff --git a/node/src/transformer.rs b/node/src/transformer.rs index ea0a2be2..3480d2dd 100644 --- a/node/src/transformer.rs +++ b/node/src/transformer.rs @@ -281,6 +281,7 @@ impl<'i> Visitor<'i, AtRule<'i>> for JsVisitor { CssRule::LayerStatement(..) => "layer-statement", CssRule::Property(..) => "property", CssRule::Container(..) => "container", + CssRule::Scope(..) => "scope", CssRule::MozDocument(..) => "moz-document", CssRule::Nesting(..) => "nesting", CssRule::Viewport(..) => "viewport", diff --git a/selectors/parser.rs b/selectors/parser.rs index b5aba4a0..502491db 100644 --- a/selectors/parser.rs +++ b/selectors/parser.rs @@ -405,6 +405,7 @@ impl<'i, Impl: SelectorImpl<'i>> SelectorList<'i, Impl> { pub fn parse<'t, P>( parser: &P, input: &mut CssParser<'i, 't>, + error_recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> Result> where @@ -414,7 +415,7 @@ impl<'i, Impl: SelectorImpl<'i>> SelectorList<'i, Impl> { parser, input, &mut SelectorParsingState::empty(), - ParseErrorRecovery::DiscardList, + error_recovery, nesting_requirement, ) } @@ -466,6 +467,7 @@ impl<'i, Impl: SelectorImpl<'i>> SelectorList<'i, Impl> { pub fn parse_relative<'t, P>( parser: &P, input: &mut CssParser<'i, 't>, + error_recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> Result> where @@ -475,7 +477,7 @@ impl<'i, Impl: SelectorImpl<'i>> SelectorList<'i, Impl> { parser, input, &mut SelectorParsingState::empty(), - ParseErrorRecovery::DiscardList, + error_recovery, nesting_requirement, ) } @@ -3265,7 +3267,12 @@ pub mod tests { expected: Option<&'a str>, ) -> Result, SelectorParseError<'i>> { let mut parser_input = ParserInput::new(input); - let result = SelectorList::parse(parser, &mut CssParser::new(&mut parser_input), NestingRequirement::None); + let result = SelectorList::parse( + parser, + &mut CssParser::new(&mut parser_input), + ParseErrorRecovery::DiscardList, + NestingRequirement::None, + ); if let Ok(ref selectors) = result { assert_eq!(selectors.0.len(), 1); // We can't assume that the serialized parsed selector will equal @@ -3292,6 +3299,7 @@ pub mod tests { let list = SelectorList::parse( &DummyParser::default(), &mut CssParser::new(&mut input), + ParseErrorRecovery::DiscardList, NestingRequirement::None, ); assert!(list.is_ok()); diff --git a/src/lib.rs b/src/lib.rs index c51a1c5e..9d0338e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24485,6 +24485,122 @@ mod tests { ); } + #[test] + fn test_at_scope() { + minify_test( + r#" + @scope { + .foo { + display: flex; + } + } + "#, + "@scope{.foo{display:flex}}", + ); + minify_test( + r#" + @scope { + :scope { + display: flex; + color: lightblue; + } + }"#, + "@scope{:scope{color:#add8e6;display:flex}}", + ); + minify_test( + r#" + @scope (.light-scheme) { + a { color: yellow; } + } + "#, + "@scope(.light-scheme){a{color:#ff0}}", + ); + minify_test( + r#" + @scope (.media-object) to (.content > *) { + a { color: yellow; } + } + "#, + "@scope(.media-object) to (.content>*){a{color:#ff0}}", + ); + minify_test( + r#" + @scope to (.content > *) { + a { color: yellow; } + } + "#, + "@scope to (.content>*){a{color:#ff0}}", + ); + minify_test( + r#" + @scope (#my-component) { + & { color: yellow; } + } + "#, + "@scope(#my-component){&{color:#ff0}}", + ); + minify_test( + r#" + @scope (.parent-scope) { + @scope (:scope > .child-scope) to (:scope .limit) { + .content { color: yellow; } + } + } + "#, + "@scope(.parent-scope){@scope(:scope>.child-scope) to (:scope .limit){.content{color:#ff0}}}", + ); + minify_test( + r#" + .foo { + @scope (.bar) { + color: yellow; + } + } + "#, + ".foo{@scope(.bar){&{color:#ff0}}}", + ); + nesting_test( + r#" + .foo { + @scope (.bar) { + color: yellow; + } + } + "#, + indoc! {r#" + @scope (.bar) { + :scope { + color: #ff0; + } + } + "#}, + ); + nesting_test( + r#" + .parent { + color: blue; + + @scope (& > .scope) to (& .limit) { + & .content { + color: yellow; + } + } + } + "#, + indoc! {r#" + .parent { + color: #00f; + } + + @scope (.parent > .scope) to (.parent > .scope .limit) { + :scope .content { + color: #ff0; + } + } + "#}, + ); + } + #[test] fn test_custom_media() { custom_media_test( diff --git a/src/parser.rs b/src/parser.rs index 61da11d3..a80e2120 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -7,8 +7,10 @@ use crate::rules::container::{ContainerCondition, ContainerName, ContainerRule}; use crate::rules::font_palette_values::FontPaletteValuesRule; use crate::rules::layer::{LayerBlockRule, LayerStatementRule}; use crate::rules::property::PropertyRule; +use crate::rules::scope::ScopeRule; use crate::rules::starting_style::StartingStyleRule; use crate::rules::viewport::ViewportRule; + use crate::rules::{ counter_style::CounterStyleRule, custom_media::CustomMediaRule, @@ -35,7 +37,7 @@ use crate::vendor_prefix::VendorPrefix; use crate::visitor::{Visit, VisitTypes, Visitor}; use bitflags::bitflags; use cssparser::*; -use parcel_selectors::parser::NestingRequirement; +use parcel_selectors::parser::{NestingRequirement, ParseErrorRecovery}; use std::sync::{Arc, RwLock}; bitflags! { @@ -202,6 +204,8 @@ pub enum AtRulePrelude<'i, T> { Container(Option>, ContainerCondition<'i>), /// A @starting-style prelude. StartingStyle, + /// A @scope rule prelude. + Scope(Option>, Option>), /// An unknown prelude. Unknown(CowArcStr<'i>, TokenList<'i>), /// A custom prelude. @@ -221,6 +225,7 @@ impl<'i, T> AtRulePrelude<'i, T> { | Self::MozDocument | Self::Layer(..) | Self::StartingStyle + | Self::Scope(..) | Self::Nest(..) | Self::Unknown(..) | Self::Custom(..) => true, @@ -629,13 +634,40 @@ impl<'a, 'o, 'b, 'i, T: crate::traits::AtRuleParser<'i>> AtRuleParser<'i> for Ne "starting-style" => { AtRulePrelude::StartingStyle }, + "scope" => { + let selector_parser = SelectorParser { + is_nesting_allowed: true, + options: &self.options, + }; + + let scope_start = if input.try_parse(|input| input.expect_parenthesis_block()).is_ok() { + Some(input.parse_nested_block(|input| { + // https://drafts.csswg.org/css-cascade-6/#scoped-rules + // TODO: disallow pseudo elements? + SelectorList::parse_relative(&selector_parser, input, ParseErrorRecovery::IgnoreInvalidSelector, NestingRequirement::None) + })?) + } else { + None + }; + + let scope_end = if input.try_parse(|input| input.expect_ident_matching("to")).is_ok() { + input.expect_parenthesis_block()?; + Some(input.parse_nested_block(|input| { + SelectorList::parse_relative(&selector_parser, input, ParseErrorRecovery::IgnoreInvalidSelector, NestingRequirement::None) + })?) + } else { + None + }; + + AtRulePrelude::Scope(scope_start, scope_end) + }, "nest" if self.is_in_style_rule => { self.options.warn(input.new_custom_error(ParserError::DeprecatedNestRule)); let selector_parser = SelectorParser { is_nesting_allowed: true, options: &self.options, }; - let selectors = SelectorList::parse(&selector_parser, input, NestingRequirement::Contained)?; + let selectors = SelectorList::parse(&selector_parser, input, ParseErrorRecovery::DiscardList, NestingRequirement::Contained)?; AtRulePrelude::Nest(selectors) }, _ => parse_custom_at_rule_prelude(&name, input, self.options, self.at_rule_parser)? @@ -717,6 +749,16 @@ impl<'a, 'o, 'b, 'i, T: crate::traits::AtRuleParser<'i>> AtRuleParser<'i> for Ne })); Ok(()) } + AtRulePrelude::Scope(scope_start, scope_end) => { + let rules = self.parse_style_block(input)?; + self.rules.0.push(CssRule::Scope(ScopeRule { + scope_start, + scope_end, + rules, + loc, + })); + Ok(()) + } AtRulePrelude::Viewport(vendor_prefix) => { self.rules.0.push(CssRule::Viewport(ViewportRule { vendor_prefix, @@ -871,9 +913,19 @@ impl<'a, 'o, 'b, 'i, T: crate::traits::AtRuleParser<'i>> QualifiedRuleParser<'i> options: &self.options, }; if self.is_in_style_rule { - SelectorList::parse_relative(&selector_parser, input, NestingRequirement::Implicit) + SelectorList::parse_relative( + &selector_parser, + input, + ParseErrorRecovery::DiscardList, + NestingRequirement::Implicit, + ) } else { - SelectorList::parse(&selector_parser, input, NestingRequirement::None) + SelectorList::parse( + &selector_parser, + input, + ParseErrorRecovery::DiscardList, + NestingRequirement::None, + ) } } diff --git a/src/printer.rs b/src/printer.rs index 18d43783..12fe09e3 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -353,6 +353,16 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { res } + pub(crate) fn with_cleared_context) -> Result>( + &mut self, + f: F, + ) -> Result { + let parent = std::mem::take(&mut self.context); + let res = f(self); + self.context = parent; + res + } + pub(crate) fn context(&self) -> Option<&'a StyleContext<'a, 'b>> { self.context.clone() } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 906cf028..f273699f 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -50,6 +50,7 @@ pub mod namespace; pub mod nesting; pub mod page; pub mod property; +pub mod scope; pub mod starting_style; pub mod style; pub mod supports; @@ -88,6 +89,7 @@ use media::MediaRule; use namespace::NamespaceRule; use nesting::NestingRule; use page::PageRule; +use scope::ScopeRule; use smallvec::{smallvec, SmallVec}; use starting_style::StartingStyleRule; use std::collections::{HashMap, HashSet}; @@ -166,6 +168,8 @@ pub enum CssRule<'i, R = DefaultAtRule> { Property(PropertyRule<'i>), /// A `@container` rule. Container(ContainerRule<'i, R>), + /// A `@scope` rule. + Scope(ScopeRule<'i, R>), /// A `@starting-style` rule. StartingStyle(StartingStyleRule<'i, R>), /// A placeholder for a rule that was removed. @@ -304,6 +308,10 @@ impl<'i, 'de: 'i, R: serde::Deserialize<'de>> serde::Deserialize<'de> for CssRul let rule = ContainerRule::deserialize(deserializer)?; Ok(CssRule::Container(rule)) } + "scope" => { + let rule = ScopeRule::deserialize(deserializer)?; + Ok(CssRule::Scope(rule)) + } "starting-style" => { let rule = StartingStyleRule::deserialize(deserializer)?; Ok(CssRule::StartingStyle(rule)) @@ -347,6 +355,7 @@ impl<'a, 'i, T: ToCss> ToCss for CssRule<'i, T> { CssRule::Property(property) => property.to_css(dest), CssRule::StartingStyle(rule) => rule.to_css(dest), CssRule::Container(container) => container.to_css(dest), + CssRule::Scope(scope) => scope.to_css(dest), CssRule::Unknown(unknown) => unknown.to_css(dest), CssRule::Custom(rule) => rule.to_css(dest).map_err(|_| PrinterError { kind: PrinterErrorKind::FmtError, @@ -758,6 +767,7 @@ impl<'i, T: Clone> CssRuleList<'i, T> { continue; } } + CssRule::Scope(scope) => scope.minify(context)?, CssRule::Nesting(nesting) => { if nesting.minify(context, parent_is_unused)? { continue; diff --git a/src/rules/scope.rs b/src/rules/scope.rs new file mode 100644 index 00000000..ff730dd6 --- /dev/null +++ b/src/rules/scope.rs @@ -0,0 +1,82 @@ +//! The `@scope` rule. + +use super::Location; +use super::{CssRuleList, MinifyContext}; +use crate::error::{MinifyError, PrinterError}; +use crate::parser::DefaultAtRule; +use crate::printer::Printer; +use crate::selector::SelectorList; +use crate::traits::ToCss; +#[cfg(feature = "visitor")] +use crate::visitor::Visit; + +/// A [@scope](https://drafts.csswg.org/css-cascade-6/#scope-atrule) rule. +/// +/// @scope () [to ()]? { +/// +/// } +#[derive(Debug, PartialEq, Clone)] +#[cfg_attr(feature = "visitor", derive(Visit))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct ScopeRule<'i, R = DefaultAtRule> { + /// A selector list used to identify the scoping root(s). + pub scope_start: Option>, + /// A selector list used to identify any scoping limits. + pub scope_end: Option>, + /// Nested rules within the `@scope` rule. + #[cfg_attr(feature = "serde", serde(borrow))] + pub rules: CssRuleList<'i, R>, + /// The location of the rule in the source file. + #[cfg_attr(feature = "visitor", skip_visit)] + pub loc: Location, +} + +impl<'i, T: Clone> ScopeRule<'i, T> { + pub(crate) fn minify(&mut self, context: &mut MinifyContext<'_, 'i>) -> Result<(), MinifyError> { + self.rules.minify(context, false) + } +} + +impl<'i, T: ToCss> ToCss for ScopeRule<'i, T> { + fn to_css(&self, dest: &mut Printer) -> Result<(), PrinterError> + where + W: std::fmt::Write, + { + #[cfg(feature = "sourcemap")] + dest.add_mapping(self.loc); + dest.write_str("@scope")?; + dest.whitespace()?; + if let Some(scope_start) = &self.scope_start { + dest.write_char('(')?; + scope_start.to_css(dest)?; + dest.write_char(')')?; + dest.whitespace()?; + } + if let Some(scope_end) = &self.scope_end { + if dest.minify { + dest.write_char(' ')?; + } + dest.write_str("to (")?; + // is treated as an ancestor of scope end. + // https://drafts.csswg.org/css-nesting/#nesting-at-scope + if let Some(scope_start) = &self.scope_start { + dest.with_context(scope_start, |dest| scope_end.to_css(dest))?; + } else { + scope_end.to_css(dest)?; + } + dest.write_char(')')?; + dest.whitespace()?; + } + dest.write_char('{')?; + dest.indent(); + dest.newline()?; + // Nested style rules within @scope are implicitly relative to the + // so clear our style context while printing them to avoid replacing & ourselves. + // https://drafts.csswg.org/css-cascade-6/#scoped-rules + dest.with_cleared_context(|dest| self.rules.to_css(dest))?; + dest.dedent(); + dest.newline()?; + dest.write_char('}') + } +} diff --git a/src/selector.rs b/src/selector.rs index 21a23904..1165d602 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -2118,6 +2118,7 @@ impl<'i> ParseWithOptions<'i> for SelectorList<'i> { options: &options, }, input, + parcel_selectors::parser::ParseErrorRecovery::DiscardList, parcel_selectors::parser::NestingRequirement::None, ) }