From 939dca8e38fe97078fc882a0872d25d593e8d261 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 4 Nov 2020 16:10:37 -0800 Subject: [PATCH] Support generalized @supports conditions (#1134) Closes #894 See sass/sass#2780 --- CHANGELOG.md | 9 + lib/src/ast/sass.dart | 2 + .../ast/sass/supports_condition/anything.dart | 21 +++ .../ast/sass/supports_condition/function.dart | 23 +++ lib/src/parse/stylesheet.dart | 170 +++++++++++++----- lib/src/visitor/async_evaluate.dart | 5 + lib/src/visitor/evaluate.dart | 7 +- pubspec.yaml | 2 +- 8 files changed, 193 insertions(+), 46 deletions(-) create mode 100644 lib/src/ast/sass/supports_condition/anything.dart create mode 100644 lib/src/ast/sass/supports_condition/function.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfef18ca..10d6eb63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.29.0 + +* Support a broader syntax for `@supports` conditions, based on the latest + [Editor's Draft of CSS Conditional Rules 3]. Almost all syntax will be allowed + (with interpolation) in the conditions' parentheses, as well as function + syntax such as `@supports selector(...)`. + +[Editor's Draft of CSS Conditional Rules 3]: https://drafts.csswg.org/css-conditional-3/#at-supports + ## 1.28.0 * Add a [`color.hwb()`] function to `sass:color` that can express colors in [HWB] format. diff --git a/lib/src/ast/sass.dart b/lib/src/ast/sass.dart index 93f373b4e..e463a5da7 100644 --- a/lib/src/ast/sass.dart +++ b/lib/src/ast/sass.dart @@ -60,7 +60,9 @@ export 'sass/statement/variable_declaration.dart'; export 'sass/statement/warn_rule.dart'; export 'sass/statement/while_rule.dart'; export 'sass/supports_condition.dart'; +export 'sass/supports_condition/anything.dart'; export 'sass/supports_condition/declaration.dart'; +export 'sass/supports_condition/function.dart'; export 'sass/supports_condition/interpolation.dart'; export 'sass/supports_condition/negation.dart'; export 'sass/supports_condition/operation.dart'; diff --git a/lib/src/ast/sass/supports_condition/anything.dart b/lib/src/ast/sass/supports_condition/anything.dart new file mode 100644 index 000000000..11e7a4752 --- /dev/null +++ b/lib/src/ast/sass/supports_condition/anything.dart @@ -0,0 +1,21 @@ +// Copyright 2020 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:source_span/source_span.dart'; + +import '../interpolation.dart'; +import '../supports_condition.dart'; + +/// A supports condition that represents the forwards-compatible +/// `` production. +class SupportsAnything implements SupportsCondition { + /// The contents of the condition. + final Interpolation contents; + + final FileSpan span; + + SupportsAnything(this.contents, this.span); + + String toString() => "($contents)"; +} diff --git a/lib/src/ast/sass/supports_condition/function.dart b/lib/src/ast/sass/supports_condition/function.dart new file mode 100644 index 000000000..0ca118108 --- /dev/null +++ b/lib/src/ast/sass/supports_condition/function.dart @@ -0,0 +1,23 @@ +// Copyright 2020 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:source_span/source_span.dart'; + +import '../interpolation.dart'; +import '../supports_condition.dart'; + +/// A function-syntax condition. +class SupportsFunction implements SupportsCondition { + /// The name of the function. + final Interpolation name; + + /// The arguments to the function. + final Interpolation arguments; + + final FileSpan span; + + SupportsFunction(this.name, this.arguments, this.span); + + String toString() => "$name($arguments)"; +} diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 2d609626b..f1739c21f 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -372,7 +372,7 @@ abstract class StylesheetParser extends Parser { // Parse custom properties as declarations no matter what. var name = nameBuffer.interpolation(scanner.spanFrom(start, beforeColon)); if (name.initialPlain.startsWith('--')) { - var value = _interpolatedDeclarationValue(); + var value = StringExpression(_interpolatedDeclarationValue()); expectStatementSeparator("custom property"); return Declaration(name, scanner.spanFrom(start), value: value); } @@ -538,7 +538,7 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($colon); if (parseCustomProperties && name.initialPlain.startsWith('--')) { - var value = _interpolatedDeclarationValue(); + var value = StringExpression(_interpolatedDeclarationValue()); expectStatementSeparator("custom property"); return Declaration(name, scanner.spanFrom(start), value: value); } @@ -2681,8 +2681,7 @@ relase. For details, see http://bit.ly/moz-document. return null; } - buffer - .addInterpolation(_interpolatedDeclarationValue(allowEmpty: true).text); + buffer.addInterpolation(_interpolatedDeclarationValue(allowEmpty: true)); scanner.expectChar($rparen); buffer.writeCharCode($rparen); @@ -2808,8 +2807,7 @@ relase. For details, see http://bit.ly/moz-document. buffer ..write(name) ..writeCharCode($lparen) - ..addInterpolation( - _interpolatedDeclarationValue(allowEmpty: true).asInterpolation()) + ..addInterpolation(_interpolatedDeclarationValue(allowEmpty: true)) ..writeCharCode($rparen); if (!scanner.scanChar($rparen)) return false; return true; @@ -2984,10 +2982,18 @@ relase. For details, see http://bit.ly/moz-document. /// /// If [allowEmpty] is `false` (the default), this requires at least one token. /// + /// If [allowSemicolon] is `true`, this doesn't stop at semicolons and instead + /// includes them in the interpolated output. + /// + /// If [allowColon] is `false`, this stops at top-level colons. + /// /// Unlike [declarationValue], this allows interpolation. - StringExpression _interpolatedDeclarationValue({bool allowEmpty = false}) { - // NOTE: this logic is largely duplicated in Parser.declarationValue and - // isIdentifier in utils.dart. Most changes here should be mirrored there. + Interpolation _interpolatedDeclarationValue( + {bool allowEmpty = false, + bool allowSemicolon = false, + bool allowColon = true}) { + // NOTE: this logic is largely duplicated in Parser.declarationValue. Most + // changes here should be mirrored there. var start = scanner.state; var buffer = InterpolationBuffer(); @@ -3065,8 +3071,15 @@ relase. For details, see http://bit.ly/moz-document. break; case $semicolon: - if (brackets.isEmpty) break loop; + if (!allowSemicolon && brackets.isEmpty) break loop; + buffer.writeCharCode(scanner.readChar()); + wroteNewline = false; + break; + + case $colon: + if (!allowColon && brackets.isEmpty) break loop; buffer.writeCharCode(scanner.readChar()); + wroteNewline = false; break; case $u: @@ -3103,7 +3116,7 @@ relase. For details, see http://bit.ly/moz-document. if (brackets.isNotEmpty) scanner.expectChar(brackets.last); if (!allowEmpty && buffer.isEmpty) scanner.error("Expected token."); - return StringExpression(buffer.interpolation(scanner.spanFrom(start))); + return buffer.interpolation(scanner.spanFrom(start)); } /// Consumes an identifier that may contain interpolation. @@ -3301,10 +3314,7 @@ relase. For details, see http://bit.ly/moz-document. /// Consumes a `@supports` condition. SupportsCondition _supportsCondition() { var start = scanner.state; - var first = scanner.peekChar(); - if (first != $lparen && first != $hash) { - var start = scanner.state; - expectIdentifier("not"); + if (scanIdentifier("not")) { whitespace(); return SupportsNegation( _supportsConditionInParens(), scanner.spanFrom(start)); @@ -3312,9 +3322,11 @@ relase. For details, see http://bit.ly/moz-document. var condition = _supportsConditionInParens(); whitespace(); + String operator; while (lookingAtIdentifier()) { - String operator; - if (scanIdentifier("or")) { + if (operator != null) { + expectIdentifier(operator); + } else if (scanIdentifier("or")) { operator = "or"; } else { expectIdentifier("and"); @@ -3333,56 +3345,126 @@ relase. For details, see http://bit.ly/moz-document. /// Consumes a parenthesized supports condition, or an interpolation. SupportsCondition _supportsConditionInParens() { var start = scanner.state; - if (scanner.peekChar() == $hash) { - return SupportsInterpolation( - singleInterpolation(), scanner.spanFrom(start)); + + if (_lookingAtInterpolatedIdentifier()) { + var identifier = interpolatedIdentifier(); + if (identifier.asPlain?.toLowerCase() == "not") { + error('"not" is not a valid identifier here.', identifier.span); + } + + if (scanner.scanChar($lparen)) { + var arguments = _interpolatedDeclarationValue( + allowEmpty: true, allowSemicolon: true); + scanner.expectChar($rparen); + return SupportsFunction(identifier, arguments, scanner.spanFrom(start)); + } else if (identifier.contents.length != 1 || + identifier.contents.first is! Expression) { + error("Expected @supports condition.", identifier.span); + } else { + return SupportsInterpolation( + identifier.contents.first as Expression, scanner.spanFrom(start)); + } } scanner.expectChar($lparen); whitespace(); - var next = scanner.peekChar(); - if (next == $lparen || next == $hash) { - var condition = _supportsCondition(); + if (scanIdentifier("not")) { whitespace(); + var condition = _supportsConditionInParens(); + scanner.expectChar($rparen); + return SupportsNegation(condition, scanner.spanFrom(start)); + } else if (scanner.peekChar() == $lparen) { + var condition = _supportsCondition(); scanner.expectChar($rparen); return condition; } - if (next == $n || next == $N) { - var negation = _trySupportsNegation(); - if (negation != null) { + // Unfortunately, we may have to backtrack here. The grammar is: + // + // Expression ":" Expression + // | InterpolatedIdentifier InterpolatedAnyValue? + // + // These aren't ambiguous because this `InterpolatedAnyValue` is forbidden + // from containing a top-level colon, but we still have to parse the full + // expression to figure out if there's a colon after it. + // + // We could avoid the overhead of a full expression parse by looking ahead + // for a colon (outside of balanced brackets), but in practice we expect the + // vast majority of real uses to be `Expression ":" Expression`, so it makes + // sense to parse that case faster in exchange for less code complexity and + // a slower backtracking case. + Expression name; + var nameStart = scanner.state; + var wasInParentheses = _inParentheses; + try { + name = expression(); + scanner.expectChar($colon); + } on FormatException catch (_) { + scanner.state = nameStart; + _inParentheses = wasInParentheses; + + var identifier = interpolatedIdentifier(); + var operation = _trySupportsOperation(identifier, nameStart); + if (operation != null) { scanner.expectChar($rparen); - return negation; + return operation; } + + // If parsing an expression fails, try to parse an + // `InterpolatedAnyValue` instead. But if that value runs into a + // top-level colon, then this is probably intended to be a declaration + // after all, so we rethrow the declaration-parsing error. + var contents = (InterpolationBuffer() + ..addInterpolation(identifier) + ..addInterpolation(_interpolatedDeclarationValue( + allowEmpty: true, allowSemicolon: true, allowColon: false))) + .interpolation(scanner.spanFrom(nameStart)); + if (scanner.peekChar() == $colon) rethrow; + + scanner.expectChar($rparen); + return SupportsAnything(contents, scanner.spanFrom(start)); } - var name = expression(); - scanner.expectChar($colon); whitespace(); var value = expression(); scanner.expectChar($rparen); return SupportsDeclaration(name, value, scanner.spanFrom(start)); } - /// Tries to consume a negated supports condition. + /// If [interpolation] is followed by `"and"` or `"or"`, parse it as a supports operation. /// - /// Returns `null` if it fails. - SupportsNegation _trySupportsNegation() { - var start = scanner.state; - if (!scanIdentifier("not") || scanner.isDone) { - scanner.state = start; - return null; - } + /// Otherwise, return `null` without moving the scanner position. + SupportsOperation _trySupportsOperation(Interpolation interpolation, LineScannerState start) { + if (interpolation.contents.length != 1) return null; + var expression = interpolation.contents.first; + if (expression is! Expression) return null; - var next = scanner.peekChar(); - if (!isWhitespace(next) && next != $lparen) { - scanner.state = start; - return null; + var beforeWhitespace = scanner.state; + whitespace(); + + SupportsOperation operation; + String operator; + while (lookingAtIdentifier()) { + if (operator != null) { + expectIdentifier(operator); + } else if (scanIdentifier("and")) { + operator = "and"; + } else if (scanIdentifier("or")) { + operator = "or"; + } else { + scanner.state = beforeWhitespace; + return null; + } + + whitespace(); + var right = _supportsConditionInParens(); + operation = SupportsOperation( + operation ?? SupportsInterpolation( + expression as Expression, interpolation.span), right, operator, scanner.spanFrom(start)); + whitespace(); } - whitespace(); - return SupportsNegation( - _supportsConditionInParens(), scanner.spanFrom(start)); + return operation; } // ## Characters diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index b7bbf74b1..69594f024 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -1806,6 +1806,11 @@ class _EvaluateVisitor } else if (condition is SupportsDeclaration) { return "(${await _evaluateToCss(condition.name)}: " "${await _evaluateToCss(condition.value)})"; + } else if (condition is SupportsFunction) { + return "${await _performInterpolation(condition.name)}(" + "${await _performInterpolation(condition.arguments)})"; + } else if (condition is SupportsAnything) { + return "(${await _performInterpolation(condition.contents)})"; } else { return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 530301c11..4fcbb1de4 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 485fce53ba9f381973c25a69b193a681891be098 +// Checksum: ae80942bc5f7f9f4b92e7e4d46903578ea3b9a58 // // ignore_for_file: unused_import @@ -1797,6 +1797,11 @@ class _EvaluateVisitor } else if (condition is SupportsDeclaration) { return "(${_evaluateToCss(condition.name)}: " "${_evaluateToCss(condition.value)})"; + } else if (condition is SupportsFunction) { + return "${_performInterpolation(condition.name)}(" + "${_performInterpolation(condition.arguments)})"; + } else if (condition is SupportsAnything) { + return "(${_performInterpolation(condition.contents)})"; } else { return null; } diff --git a/pubspec.yaml b/pubspec.yaml index 120a1b2e1..cb1b1d1f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.28.0 +version: 1.29.0 description: A Sass implementation in Dart. author: Sass Team homepage: https://github.com/sass/dart-sass