From 0598f1cd0c16bd2cfc12e5f1de860b6e98e2ecbd Mon Sep 17 00:00:00 2001 From: Rasmus Kaj Date: Sun, 23 Oct 2022 00:53:58 +0200 Subject: [PATCH] Add proper handling of `@keyframes`. --- CHANGELOG.md | 2 + rsass/src/css/atrule.rs | 12 ++++- rsass/src/css/item.rs | 10 +++- rsass/src/css/keyframes.rs | 58 +++++++++++++++++++++++ rsass/src/css/mod.rs | 2 + rsass/src/output/cssdest.rs | 4 +- rsass/src/output/transform.rs | 46 +++++++++++++++++-- rsass/src/parser/keyframes.rs | 76 +++++++++++++++++++++++++++++++ rsass/src/parser/mod.rs | 6 ++- rsass/src/parser/strings.rs | 18 ++++++++ rsass/src/sass/functions/math.rs | 2 +- rsass/src/sass/item.rs | 14 ++++++ rsass/src/sass/mod.rs | 2 +- rsass/tests/spec/css/keyframes.rs | 2 - 14 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 rsass/src/css/keyframes.rs create mode 100644 rsass/src/parser/keyframes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 31dec061..46fc9efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ project adheres to (they are still parsed of the internal data representation, so they can be used when implementing `@extend`) (PR #180). * Filter out some other illegal / never-matching selectors (PR #181). +* Added proper handling of `@keyframes`. This is a breaking change for + adding new variants to public enums (PR #178). * Handle trailing comma in function arguments in plain css correctly. * Refactored function name/plain string handling in scss values to not parse the same unquoted string twice. diff --git a/rsass/src/css/atrule.rs b/rsass/src/css/atrule.rs index 640e9685..00e5a51c 100644 --- a/rsass/src/css/atrule.rs +++ b/rsass/src/css/atrule.rs @@ -1,6 +1,6 @@ use super::{ - BodyItem, Comment, CustomProperty, Import, MediaRule, Property, Rule, - Value, + BodyItem, Comment, CustomProperty, Import, Keyframes, MediaRule, + Property, Rule, Value, }; use crate::output::CssBuf; use std::io::{self, Write}; @@ -71,6 +71,8 @@ pub enum AtRuleBodyItem { MediaRule(MediaRule), /// An `@` rule. AtRule(AtRule), + /// Keyframes + Keyframes(Keyframes), } impl AtRuleBodyItem { @@ -83,6 +85,7 @@ impl AtRuleBodyItem { AtRuleBodyItem::CustomProperty(cp) => cp.write(buf), AtRuleBodyItem::MediaRule(rule) => rule.write(buf)?, AtRuleBodyItem::AtRule(rule) => rule.write(buf)?, + AtRuleBodyItem::Keyframes(rule) => rule.write(buf)?, } Ok(()) } @@ -128,3 +131,8 @@ impl From for AtRuleBodyItem { } } } +impl From for AtRuleBodyItem { + fn from(value: Keyframes) -> Self { + AtRuleBodyItem::Keyframes(value) + } +} diff --git a/rsass/src/css/item.rs b/rsass/src/css/item.rs index f79ac18e..9a72baac 100644 --- a/rsass/src/css/item.rs +++ b/rsass/src/css/item.rs @@ -1,4 +1,4 @@ -use super::{AtRule, Comment, CssString, MediaRule, Rule, Value}; +use super::{AtRule, Comment, CssString, Keyframes, MediaRule, Rule, Value}; use crate::output::CssBuf; use std::io::{self, Write}; @@ -15,6 +15,8 @@ pub enum Item { MediaRule(MediaRule), /// An (unknown) `@` rule. AtRule(AtRule), + /// An `@keyframes` rule. + Keyframes(Keyframes), /// An extra newline for grouping (unless compressed format). Separator, } @@ -27,6 +29,7 @@ impl Item { Item::Rule(rule) => rule.write(buf)?, Item::MediaRule(rule) => rule.write(buf)?, Item::AtRule(atrule) => atrule.write(buf)?, + Item::Keyframes(rule) => rule.write(buf)?, Item::Separator => buf.opt_nl(), } Ok(()) @@ -53,6 +56,11 @@ impl From for Item { Item::AtRule(value) } } +impl From for Item { + fn from(value: Keyframes) -> Self { + Item::Keyframes(value) + } +} impl From for Item { fn from(value: MediaRule) -> Self { Item::MediaRule(value) diff --git a/rsass/src/css/keyframes.rs b/rsass/src/css/keyframes.rs new file mode 100644 index 00000000..cf860bdc --- /dev/null +++ b/rsass/src/css/keyframes.rs @@ -0,0 +1,58 @@ +use super::{Comment, Property}; +use crate::output::CssBuf; +use std::io::{self, Write}; + +/// An `@keyframes` rule in css. +#[derive(Clone, Debug)] +pub struct Keyframes { + name: String, + items: Vec, +} + +impl Keyframes { + pub(crate) fn new(name: String, items: Vec) -> Self { + Keyframes { name, items } + } + pub(crate) fn write(&self, buf: &mut CssBuf) -> io::Result<()> { + write!(buf, "@keyframes {}", self.name)?; + if let &[KfItem::Comment(ref single)] = &self.items[..] { + buf.add_one("{ ", "{"); + single.write(buf); + buf.pop_nl(); + buf.add_one(" }", "}"); + } else { + buf.start_block(); + for item in &self.items { + match item { + KfItem::Stop(name, rules) => { + buf.do_indent_no_nl(); + if let Some((first, rest)) = name.split_first() { + buf.add_str(first); + for name in rest { + buf.add_one(", ", ","); + buf.add_str(name); + } + } + buf.start_block(); + for rule in rules { + rule.write(buf); + } + buf.end_block(); + } + KfItem::Comment(comment) => comment.write(buf), + } + } + buf.end_block(); + } + Ok(()) + } +} + +/// An item in keyframes, either a stop or a comment. +#[derive(Clone, Debug)] +pub enum KfItem { + /// A stop + Stop(Vec, Vec), + /// A comment + Comment(Comment), +} diff --git a/rsass/src/css/mod.rs b/rsass/src/css/mod.rs index 1d7da0f1..b472e695 100644 --- a/rsass/src/css/mod.rs +++ b/rsass/src/css/mod.rs @@ -4,6 +4,7 @@ mod binop; mod call_args; mod comment; mod item; +mod keyframes; mod mediarule; mod rule; mod selectors; @@ -17,6 +18,7 @@ pub use self::binop::BinOp; pub use self::call_args::CallArgs; pub use self::comment::Comment; pub use self::item::{Import, Item}; +pub use self::keyframes::{Keyframes, KfItem}; pub use self::mediarule::{MediaArgs, MediaRule}; pub use self::rule::{BodyItem, CustomProperty, Property, Rule}; pub use self::selectors::{BadSelector, Selector, SelectorPart, Selectors}; diff --git a/rsass/src/output/cssdest.rs b/rsass/src/output/cssdest.rs index da3dbd22..8141c91d 100644 --- a/rsass/src/output/cssdest.rs +++ b/rsass/src/output/cssdest.rs @@ -274,6 +274,7 @@ impl<'a> CssDestination for AtRuleDest<'a> { // FIXME: This should bubble or something? Item::MediaRule(r) => r.into(), Item::AtRule(r) => r.into(), + Item::Keyframes(k) => k.into(), Item::Separator => return Ok(()), // Not pushed? }); Ok(()) @@ -391,10 +392,11 @@ impl<'a> CssDestination for AtMediaDest<'a> { Item::Comment(c) => c.into(), Item::Import(i) => i.into(), Item::Rule(r) => r.into(), + Item::AtRule(r) => r.into(), + Item::Keyframes(k) => k.into(), // FIXME: Check if the args can be merged! // Or is that a separate pass after building a first css tree? Item::MediaRule(r) => r.into(), - Item::AtRule(r) => r.into(), Item::Separator => return Ok(()), // Not pushed? }); Ok(()) diff --git a/rsass/src/output/transform.rs b/rsass/src/output/transform.rs index 3ed747d8..7f920a22 100644 --- a/rsass/src/output/transform.rs +++ b/rsass/src/output/transform.rs @@ -4,9 +4,10 @@ use super::cssdest::CssDestination; use super::CssData; use crate::css::{self, AtRule, Import, SelectorCtx}; +use crate::css::{Comment, Property}; use crate::error::ResultPos; use crate::input::{Context, Loader, Parsed, SourceKind}; -use crate::sass::{get_global_module, Expose, Item, UseAs}; +use crate::sass::{get_global_module, Expose, Item, KfItem, UseAs}; use crate::value::ValueRange; use crate::{Error, Invalid, ScopeRef}; @@ -201,6 +202,42 @@ fn handle_item( dest.push_import(Import::new(name, args)); } } + Item::KeyFrames(name, stops) => { + let name = name.evaluate(scope.clone())?.unquote(); + let rules = stops + .iter() + .map(|item| match item { + KfItem::Stop(name, rules) => Ok(Some(css::KfItem::Stop( + name.iter() + .map(|name| { + Ok(name.evaluate(scope.clone())?.take_value()) + }) + .collect::>()?, + rules + .iter() + .map(|(name, val)| { + let name = name.evaluate(scope.clone())?; + let val = val.evaluate(scope.clone())?; + Ok(Property::new(name.take_value(), val)) + }) + .collect::>()?, + ))), + KfItem::VariableDeclaration(var) => { + var.evaluate(&scope)?; + Ok(None) + } + KfItem::Comment(comment) => { + let comment = Comment::from( + comment.evaluate(scope.clone())?.value(), + ); + Ok(Some(css::KfItem::Comment(comment))) + } + }) + .filter_map(|r| r.transpose()) + .collect::>()?; + dest.push_item(css::Keyframes::new(name, rules).into()) + .no_pos()?; + } Item::AtRoot(ref selectors, ref body) => { let selectors = selectors .eval(scope.clone())? @@ -231,7 +268,9 @@ fn handle_item( let args = args.evaluate(scope.clone())?; if let Some(ref body) = *body { let mut atrule = dest.start_atrule(name.clone(), args); - let local = if name == "keyframes" { + let local = if name == "keyframes" + || (name.starts_with("-") && name.ends_with("-keyframes")) + { ScopeRef::sub_selectors(scope, SelectorCtx::root()) } else { ScopeRef::sub(scope) @@ -470,7 +509,7 @@ fn check_body(body: &[Item], context: BodyContext) -> Result<(), Error> { Ok(()) } -const CSS_AT_RULES: [&str; 16] = [ +const CSS_AT_RULES: [&str; 15] = [ "charset", "color-profile", "counter-style", @@ -478,7 +517,6 @@ const CSS_AT_RULES: [&str; 16] = [ "font-face", "font-feature-values", "import", - "keyframes", "layer", "media", "namespace", diff --git a/rsass/src/parser/keyframes.rs b/rsass/src/parser/keyframes.rs new file mode 100644 index 00000000..bf344d27 --- /dev/null +++ b/rsass/src/parser/keyframes.rs @@ -0,0 +1,76 @@ +use super::strings::{sass_string, sass_string_ext2}; +use super::util::{comment, ignore_comments, opt_spacelike}; +use super::value::{number, value_expression}; +use super::PResult; +use super::{variable_declaration, Span}; +use crate::sass::{Item, KfItem, SassString, Value}; +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::combinator::{map, map_res, opt, recognize}; +use nom::multi::{many_till, separated_list1}; +use nom::sequence::{delimited, pair, preceded, terminated}; +use std::str::from_utf8; + +pub fn keyframes2(input: Span) -> PResult { + let (input, setname) = + dbg!(terminated(sass_string_ext2, opt_spacelike)(input))?; + let (input, (body, _end)) = preceded( + terminated(tag("{"), opt_spacelike), + many_till( + alt(( + map( + pair( + separated_list1( + delimited(opt_spacelike, tag(","), opt_spacelike), + stop_name, + ), + preceded( + delimited(opt_spacelike, tag("{"), opt_spacelike), + map( + many_till( + body_rule, + terminated( + terminated(tag("}"), opt_spacelike), + opt(tag(";")), + ), + ), + |(a, _)| a, + ), + ), + ), + |(names, body)| KfItem::Stop(names, body), + ), + map( + terminated(variable_declaration, opt_spacelike), + KfItem::VariableDeclaration, + ), + map(comment, KfItem::Comment), + )), + terminated(terminated(tag("}"), opt_spacelike), opt(tag(";"))), + ), + )(input)?; + Ok((input, Item::KeyFrames(setname, body))) +} + +fn body_rule(input: Span) -> PResult<(SassString, Value)> { + pair( + terminated( + sass_string, + delimited(ignore_comments, tag(":"), ignore_comments), + ), + terminated( + value_expression, + delimited(opt_spacelike, opt(tag(";")), opt_spacelike), + ), + )(input) +} + +fn stop_name(input: Span) -> PResult { + alt(( + map_res(recognize(terminated(number, tag("%"))), |s| { + from_utf8(s.fragment()) + .map(|s| SassString::from(s.to_lowercase())) + }), + sass_string, + ))(input) +} diff --git a/rsass/src/parser/mod.rs b/rsass/src/parser/mod.rs index e5e6fd0d..9d3d7f96 100644 --- a/rsass/src/parser/mod.rs +++ b/rsass/src/parser/mod.rs @@ -3,6 +3,7 @@ mod css_function; mod error; pub mod formalargs; mod imports; +mod keyframes; mod media; pub mod selectors; mod span; @@ -29,7 +30,9 @@ use self::value::{ dictionary, function_call_or_string, single_value, value_expression, }; use crate::input::{SourceFile, SourceName, SourcePos}; -use crate::sass::parser::{variable_declaration2, variable_declaration_mod}; +use crate::sass::parser::{ + variable_declaration, variable_declaration2, variable_declaration_mod, +}; use crate::sass::{Callable, FormalArgs, Item, Name, Selectors, Value}; use crate::value::ListSeparator; #[cfg(test)] @@ -226,6 +229,7 @@ fn at_rule2(input0: Span) -> PResult { "if" => if_statement2(input), "import" => import2(input), "include" => mixin_call(input0, input), + "keyframes" => keyframes::keyframes2(input), "media" => media::rule(input0, input), "mixin" => mixin_declaration2(input), "return" => return_stmt2(input0, input), diff --git a/rsass/src/parser/strings.rs b/rsass/src/parser/strings.rs index 96a3ed92..6c0f22cd 100644 --- a/rsass/src/parser/strings.rs +++ b/rsass/src/parser/strings.rs @@ -125,6 +125,13 @@ pub fn sass_string_ext(input: Span) -> PResult { Ok((input, SassString::new(parts, Quotes::None))) } +/// An unquoted string that may contain the `$` sign. +pub fn sass_string_ext2(input: Span) -> PResult { + let (input, parts) = + many0(alt((string_part_interpolation, extended_part2)))(input)?; + Ok((input, SassString::new(parts, Quotes::None))) +} + fn unquoted_first_part(input: Span) -> PResult { let (input, first) = alt(( map(str_plain_part, String::from), @@ -421,6 +428,17 @@ pub fn extended_part(input: Span) -> PResult { Ok((input, StringPart::Raw(part))) } +fn extended_part2(input: Span) -> PResult { + let (input, part) = map_res( + recognize(pair( + verify(take_char, |c| is_ext_str_start_char(c) || *c == '$'), + many0(verify(take_char, is_ext_str_char)), + )), + input_to_string, + )(input)?; + Ok((input, StringPart::Raw(part))) +} + fn is_ext_str_start_char(c: &char) -> bool { is_name_char(c) || *c == '*' diff --git a/rsass/src/sass/functions/math.rs b/rsass/src/sass/functions/math.rs index d0406fdf..de136a44 100644 --- a/rsass/src/sass/functions/math.rs +++ b/rsass/src/sass/functions/math.rs @@ -472,7 +472,7 @@ fn find_extreme(v: &[Value], pref: Ordering) -> Result { Err(ExtremeError::NonNumeric(v)) => { if let Value::Literal(s) = &v { if s.quotes().is_none() - && crate::parser::value::number( + && crate::parser::value::numeric( input_span(s.value()).borrow(), ) .is_ok() diff --git a/rsass/src/sass/item.rs b/rsass/src/sass/item.rs index 89bff6ef..e862991f 100644 --- a/rsass/src/sass/item.rs +++ b/rsass/src/sass/item.rs @@ -14,6 +14,9 @@ pub enum Item { /// A variable declaration. VariableDeclaration(VariableDeclaration), + /// An `@keyframes` directive with a name. + KeyFrames(SassString, Vec), + /// An `@at-root` directive. AtRoot(Selectors, Vec), /// An `@media` directive. @@ -112,6 +115,17 @@ impl From for Item { } } +/// An item of a keyframes declaration. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd)] +pub enum KfItem { + /// An `@keyframes` directive with a name. + Stop(Vec, Vec<(SassString, Value)>), + /// A comment (that might be preserved for the output). + Comment(SassString), + /// A local variable in the keyframes. + VariableDeclaration(VariableDeclaration), +} + /// How an `@forward`-ed module should be exposed. /// /// As directed by the `show` or `hide` keywords or their absense. diff --git a/rsass/src/sass/mod.rs b/rsass/src/sass/mod.rs index add7edbe..af532cd9 100644 --- a/rsass/src/sass/mod.rs +++ b/rsass/src/sass/mod.rs @@ -28,7 +28,7 @@ pub use self::formal_args::{ArgsError, FormalArgs}; pub use self::functions::{ get_global_module, CallError, Function, ResolvedArgs, }; -pub use self::item::{Expose, Item, UseAs}; +pub use self::item::{Expose, Item, KfItem, UseAs}; pub use self::mixin::{Mixin, MixinDecl}; pub use self::name::Name; pub use self::selectors::{Selector, SelectorPart, Selectors}; diff --git a/rsass/tests/spec/css/keyframes.rs b/rsass/tests/spec/css/keyframes.rs index 3d1dcbb3..e7bb8656 100644 --- a/rsass/tests/spec/css/keyframes.rs +++ b/rsass/tests/spec/css/keyframes.rs @@ -220,7 +220,6 @@ mod selector { ); } #[test] - #[ignore] // wrong result fn negative_exponent() { assert_eq!( runner().ok("@keyframes a {\ @@ -236,7 +235,6 @@ mod selector { ); } #[test] - #[ignore] // wrong result fn positive_exponent() { assert_eq!( runner().ok("@keyframes a {\