From 5c31d1f245c274ff90eded7d7ae4437c664798b9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 13 Sep 2023 17:23:05 -0700 Subject: [PATCH] Re-enable new calculation functions (#2080) --- CHANGELOG.md | 26 + lib/src/ast/sass.dart | 1 - lib/src/ast/sass/expression.dart | 87 ++- .../ast/sass/expression/binary_operation.dart | 12 + lib/src/ast/sass/expression/calculation.dart | 108 ---- lib/src/ast/sass/interpolation.dart | 3 + lib/src/deprecation.dart | 5 + lib/src/embedded/protofier.dart | 4 +- lib/src/functions/math.dart | 88 +-- lib/src/js/value/calculation.dart | 4 +- lib/src/parse/css.dart | 25 +- lib/src/parse/stylesheet.dart | 253 +------- lib/src/util/nullable.dart | 7 + lib/src/util/number.dart | 78 +++ lib/src/value/calculation.dart | 596 +++++++++++++++++- lib/src/value/number.dart | 2 +- lib/src/value/number/unitless.dart | 2 +- lib/src/visitor/ast_search.dart | 185 ++++++ lib/src/visitor/async_evaluate.dart | 407 +++++++++--- lib/src/visitor/evaluate.dart | 404 +++++++++--- lib/src/visitor/expression_to_calc.dart | 12 +- lib/src/visitor/interface/expression.dart | 1 - lib/src/visitor/recursive_ast.dart | 12 +- lib/src/visitor/replace_expression.dart | 4 - lib/src/visitor/serialize.dart | 11 +- pkg/sass_api/CHANGELOG.md | 8 + pkg/sass_api/lib/sass_api.dart | 1 + pkg/sass_api/pubspec.yaml | 4 +- pubspec.yaml | 2 +- test/dart_api/value/calculation_test.dart | 7 + test/embedded/function_test.dart | 4 +- 31 files changed, 1677 insertions(+), 686 deletions(-) delete mode 100644 lib/src/ast/sass/expression/calculation.dart create mode 100644 lib/src/visitor/ast_search.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2894b314a..e828616a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ ## 1.67.0 +* All functions defined in CSS Values and Units 4 are now once again parsed as + calculation objects: `round()`, `mod()`, `rem()`, `sin()`, `cos()`, `tan()`, + `asin()`, `acos()`, `atan()`, `atan2()`, `pow()`, `sqrt()`, `hypot()`, + `log()`, `exp()`, `abs()`, and `sign()`. + + Unlike in 1.65.0, function calls are _not_ locked into being parsed as + calculations or plain Sass functions at parse-time. This means that + user-defined functions will take precedence over CSS calculations of the same + name. Although the function names `calc()` and `clamp()` are still forbidden, + users may continue to freely define functions whose names overlap with other + CSS calculations (including `abs()`, `min()`, `max()`, and `round()` whose + names overlap with global Sass functions). + +* As a consequence of the change in calculation parsing described above, + calculation functions containing interpolation are now parsed more strictly + than before. However, all interpolations that would have produced valid CSS + will continue to work, so this is not considered a breaking change. + +* Interpolations in calculation functions that aren't used in a position that + could also have a normal calculation value are now deprecated. For example, + `calc(1px #{"+ 2px"})` is deprecated, but `calc(1px + #{"2px"})` is still + allowed. This deprecation is named `calc-interp`. See [the Sass website] for + more information. + + [the Sass website]: https://sass-lang.com/d/calc-interp + * **Potentially breaking bug fix**: The importer used to load a given file is no longer used to load absolute URLs that appear in that file. This was unintented behavior that contradicted the Sass specification. Absolute URLs diff --git a/lib/src/ast/sass.dart b/lib/src/ast/sass.dart index aa5ebbb79..149641670 100644 --- a/lib/src/ast/sass.dart +++ b/lib/src/ast/sass.dart @@ -13,7 +13,6 @@ export 'sass/dependency.dart'; export 'sass/expression.dart'; export 'sass/expression/binary_operation.dart'; export 'sass/expression/boolean.dart'; -export 'sass/expression/calculation.dart'; export 'sass/expression/color.dart'; export 'sass/expression/function.dart'; export 'sass/expression/if.dart'; diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart index a5682411e..051e1c269 100644 --- a/lib/src/ast/sass/expression.dart +++ b/lib/src/ast/sass/expression.dart @@ -2,13 +2,16 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; import '../../exception.dart'; import '../../logger.dart'; import '../../parse/scss.dart'; +import '../../util/nullable.dart'; +import '../../value.dart'; import '../../visitor/interface/expression.dart'; -import 'node.dart'; +import '../sass.dart'; /// A SassScript expression in a Sass syntax tree. /// @@ -27,3 +30,85 @@ abstract interface class Expression implements SassNode { factory Expression.parse(String contents, {Object? url, Logger? logger}) => ScssParser(contents, url: url, logger: logger).parseExpression(); } + +// Use an extension class rather than a method so we don't have to make +// [Expression] a concrete base class for something we'll get rid of anyway once +// we remove the global math functions that make this necessary. +extension ExpressionExtensions on Expression { + /// Whether this expression can be used in a calculation context. + /// + /// @nodoc + @internal + bool get isCalculationSafe => accept(_IsCalculationSafeVisitor()); +} + +// We could use [AstSearchVisitor] to implement this more tersely, but that +// would default to returning `true` if we added a new expression type and +// forgot to update this class. +class _IsCalculationSafeVisitor implements ExpressionVisitor { + const _IsCalculationSafeVisitor(); + + bool visitBinaryOperationExpression(BinaryOperationExpression node) => + (const { + BinaryOperator.times, + BinaryOperator.dividedBy, + BinaryOperator.plus, + BinaryOperator.minus + }).contains(node.operator) && + (node.left.accept(this) || node.right.accept(this)); + + bool visitBooleanExpression(BooleanExpression node) => false; + + bool visitColorExpression(ColorExpression node) => false; + + bool visitFunctionExpression(FunctionExpression node) => true; + + bool visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + true; + + bool visitIfExpression(IfExpression node) => true; + + bool visitListExpression(ListExpression node) => + node.separator == ListSeparator.space && + !node.hasBrackets && + node.contents.length > 1 && + node.contents.every((expression) => expression.accept(this)); + + bool visitMapExpression(MapExpression node) => false; + + bool visitNullExpression(NullExpression node) => false; + + bool visitNumberExpression(NumberExpression node) => true; + + bool visitParenthesizedExpression(ParenthesizedExpression node) => + node.expression.accept(this); + + bool visitSelectorExpression(SelectorExpression node) => false; + + bool visitStringExpression(StringExpression node) { + if (node.hasQuotes) return false; + + // Exclude non-identifier constructs that are parsed as [StringExpression]s. + // We could just check if they parse as valid identifiers, but this is + // cheaper. + var text = node.text.initialPlain; + return + // !important + !text.startsWith("!") && + // ID-style identifiers + !text.startsWith("#") && + // Unicode ranges + text.codeUnitAtOrNull(1) != $plus && + // url() + text.codeUnitAtOrNull(3) != $lparen; + } + + bool visitSupportsExpression(SupportsExpression node) => false; + + bool visitUnaryOperationExpression(UnaryOperationExpression node) => false; + + bool visitValueExpression(ValueExpression node) => false; + + bool visitVariableExpression(VariableExpression node) => true; +} diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart index 4e87fe8de..dc750900a 100644 --- a/lib/src/ast/sass/expression/binary_operation.dart +++ b/lib/src/ast/sass/expression/binary_operation.dart @@ -6,6 +6,7 @@ import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../util/span.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; import 'list.dart'; @@ -45,6 +46,17 @@ final class BinaryOperationExpression implements Expression { return left.span.expand(right.span); } + /// Returns the span that covers only [operator]. + /// + /// @nodoc + @internal + FileSpan get operatorSpan => left.span.file == right.span.file && + left.span.end.offset < right.span.start.offset + ? left.span.file + .span(left.span.end.offset, right.span.start.offset) + .trim() + : span; + BinaryOperationExpression(this.operator, this.left, this.right) : allowsSlash = false; diff --git a/lib/src/ast/sass/expression/calculation.dart b/lib/src/ast/sass/expression/calculation.dart deleted file mode 100644 index 38c25ed14..000000000 --- a/lib/src/ast/sass/expression/calculation.dart +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2021 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:meta/meta.dart'; -import 'package:source_span/source_span.dart'; - -import '../../../visitor/interface/expression.dart'; -import '../expression.dart'; -import 'binary_operation.dart'; -import 'function.dart'; -import 'if.dart'; -import 'number.dart'; -import 'parenthesized.dart'; -import 'string.dart'; -import 'variable.dart'; - -/// A calculation literal. -/// -/// {@category AST} -final class CalculationExpression implements Expression { - /// This calculation's name. - final String name; - - /// The arguments for the calculation. - final List arguments; - - final FileSpan span; - - /// Returns a `calc()` calculation expression. - CalculationExpression.calc(Expression argument, FileSpan span) - : this("calc", [argument], span); - - /// Returns a `min()` calculation expression. - CalculationExpression.min(Iterable arguments, this.span) - : name = "min", - arguments = _verifyArguments(arguments) { - if (this.arguments.isEmpty) { - throw ArgumentError("min() requires at least one argument."); - } - } - - /// Returns a `max()` calculation expression. - CalculationExpression.max(Iterable arguments, this.span) - : name = "max", - arguments = _verifyArguments(arguments) { - if (this.arguments.isEmpty) { - throw ArgumentError("max() requires at least one argument."); - } - } - - /// Returns a `clamp()` calculation expression. - CalculationExpression.clamp( - Expression min, Expression value, Expression max, FileSpan span) - : this("clamp", [min, max, value], span); - - /// Returns a calculation expression with the given name and arguments. - /// - /// Unlike the other constructors, this doesn't verify that the arguments are - /// valid for the name. - @internal - CalculationExpression(this.name, Iterable arguments, this.span) - : arguments = _verifyArguments(arguments); - - /// Throws an [ArgumentError] if [arguments] aren't valid calculation - /// arguments, and returns them as an unmodifiable list if they are. - static List _verifyArguments(Iterable arguments) => - List.unmodifiable(arguments.map((arg) { - _verify(arg); - return arg; - })); - - /// Throws an [ArgumentError] if [expression] isn't a valid calculation - /// argument. - static void _verify(Expression expression) { - switch (expression) { - case NumberExpression() || - CalculationExpression() || - VariableExpression() || - FunctionExpression() || - IfExpression() || - StringExpression(hasQuotes: false): - break; - - case ParenthesizedExpression(:var expression): - _verify(expression); - - case BinaryOperationExpression( - :var left, - :var right, - operator: BinaryOperator.plus || - BinaryOperator.minus || - BinaryOperator.times || - BinaryOperator.dividedBy - ): - _verify(left); - _verify(right); - - case _: - throw ArgumentError("Invalid calculation argument $expression."); - } - } - - T accept(ExpressionVisitor visitor) => - visitor.visitCalculationExpression(this); - - String toString() => "$name(${arguments.join(', ')})"; -} diff --git a/lib/src/ast/sass/interpolation.dart b/lib/src/ast/sass/interpolation.dart index 578394e83..075b3344f 100644 --- a/lib/src/ast/sass/interpolation.dart +++ b/lib/src/ast/sass/interpolation.dart @@ -21,6 +21,9 @@ final class Interpolation implements SassNode { final FileSpan span; + /// Returns whether this contains no interpolated expressions. + bool get isPlain => asPlain != null; + /// If this contains no interpolated expressions, returns its text contents. /// /// Otherwise, returns `null`. diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index 5ea363008..007fbb152 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -69,6 +69,11 @@ enum Deprecation { deprecatedIn: '1.62.3', description: 'Passing null as alpha in the ${isJS ? 'JS' : 'Dart'} API.'), + calcInterp('calc-interp', + deprecatedIn: '1.67.0', + description: 'Using interpolation in a calculation outside a value ' + 'position.'), + /// Deprecation for `@import` rules. import.future('import', description: '@import rules.'), diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart index 3a1a792b0..6ad083ca4 100644 --- a/lib/src/embedded/protofier.dart +++ b/lib/src/embedded/protofier.dart @@ -134,8 +134,6 @@ final class Protofier { ..operator = _protofyCalculationOperator(value.operator) ..left = _protofyCalculationValue(value.left) ..right = _protofyCalculationValue(value.right); - case CalculationInterpolation(): - result.interpolation = value.value; case _: throw "Unknown calculation value $value"; } @@ -352,7 +350,7 @@ final class Protofier { _deprotofyCalculationValue(value.operation.left), _deprotofyCalculationValue(value.operation.right)), Value_Calculation_CalculationValue_Value.interpolation => - CalculationInterpolation(value.interpolation), + SassString('(${value.interpolation})', quotes: false), Value_Calculation_CalculationValue_Value.notSet => throw mandatoryError("Value.Calculation.value") }; diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart index a85e5b1a4..bca609d0d 100644 --- a/lib/src/functions/math.dart +++ b/lib/src/functions/math.dart @@ -12,6 +12,7 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../util/number.dart'; import '../value.dart'; /// The global definitions of Sass math functions. @@ -149,87 +150,32 @@ final _log = _function("log", r"$number, $base: null", (arguments) { final _pow = _function("pow", r"$base, $exponent", (arguments) { var base = arguments[0].assertNumber("base"); var exponent = arguments[1].assertNumber("exponent"); - if (base.hasUnits) { - throw SassScriptException("\$base: Expected $base to have no units."); - } else if (exponent.hasUnits) { - throw SassScriptException( - "\$exponent: Expected $exponent to have no units."); - } else { - return SassNumber(math.pow(base.value, exponent.value)); - } + return pow(base, exponent); }); -final _sqrt = _function("sqrt", r"$number", (arguments) { - var number = arguments[0].assertNumber("number"); - if (number.hasUnits) { - throw SassScriptException("\$number: Expected $number to have no units."); - } else { - return SassNumber(math.sqrt(number.value)); - } -}); +final _sqrt = _singleArgumentMathFunc("sqrt", sqrt); /// /// Trigonometric functions /// -final _acos = _function("acos", r"$number", (arguments) { - var number = arguments[0].assertNumber("number"); - if (number.hasUnits) { - throw SassScriptException("\$number: Expected $number to have no units."); - } else { - return SassNumber.withUnits(math.acos(number.value) * 180 / math.pi, - numeratorUnits: ['deg']); - } -}); +final _acos = _singleArgumentMathFunc("acos", acos); -final _asin = _function("asin", r"$number", (arguments) { - var number = arguments[0].assertNumber("number"); - if (number.hasUnits) { - throw SassScriptException("\$number: Expected $number to have no units."); - } else { - return SassNumber.withUnits(math.asin(number.value) * 180 / math.pi, - numeratorUnits: ['deg']); - } -}); +final _asin = _singleArgumentMathFunc("asin", asin); -final _atan = _function("atan", r"$number", (arguments) { - var number = arguments[0].assertNumber("number"); - if (number.hasUnits) { - throw SassScriptException("\$number: Expected $number to have no units."); - } else { - return SassNumber.withUnits(math.atan(number.value) * 180 / math.pi, - numeratorUnits: ['deg']); - } -}); +final _atan = _singleArgumentMathFunc("atan", atan); final _atan2 = _function("atan2", r"$y, $x", (arguments) { var y = arguments[0].assertNumber("y"); var x = arguments[1].assertNumber("x"); - return SassNumber.withUnits( - math.atan2(y.value, x.convertValueToMatch(y, 'x', 'y')) * 180 / math.pi, - numeratorUnits: ['deg']); + return atan2(y, x); }); -final _cos = _function( - "cos", - r"$number", - (arguments) => SassNumber(math.cos(arguments[0] - .assertNumber("number") - .coerceValueToUnit("rad", "number")))); - -final _sin = _function( - "sin", - r"$number", - (arguments) => SassNumber(math.sin(arguments[0] - .assertNumber("number") - .coerceValueToUnit("rad", "number")))); - -final _tan = _function( - "tan", - r"$number", - (arguments) => SassNumber(math.tan(arguments[0] - .assertNumber("number") - .coerceValueToUnit("rad", "number")))); +final _cos = _singleArgumentMathFunc("cos", cos); + +final _sin = _singleArgumentMathFunc("sin", sin); + +final _tan = _singleArgumentMathFunc("tan", tan); /// /// Unit functions @@ -305,6 +251,16 @@ final _div = _function("div", r"$number1, $number2", (arguments) { /// Helpers /// +/// Returns a [Callable] named [name] that calls a single argument +/// math function. +BuiltInCallable _singleArgumentMathFunc( + String name, SassNumber mathFunc(SassNumber value)) { + return _function(name, r"$number", (arguments) { + var number = arguments[0].assertNumber("number"); + return mathFunc(number); + }); +} + /// Returns a [Callable] named [name] that transforms a number's value /// using [transform] and preserves its units. BuiltInCallable _numberFunction(String name, double transform(double value)) { diff --git a/lib/src/js/value/calculation.dart b/lib/src/js/value/calculation.dart index 6154de77b..51dfadae8 100644 --- a/lib/src/js/value/calculation.dart +++ b/lib/src/js/value/calculation.dart @@ -93,7 +93,7 @@ final JSClass calculationOperationClass = () { _assertCalculationValue(left); _assertCalculationValue(right); return SassCalculation.operateInternal(operator, left, right, - inMinMax: false, simplify: false); + inLegacySassFunction: false, simplify: false); }); jsClass.defineMethods({ @@ -109,7 +109,7 @@ final JSClass calculationOperationClass = () { getJSClass(SassCalculation.operateInternal( CalculationOperator.plus, SassNumber(1), SassNumber(1), - inMinMax: false, simplify: false)) + inLegacySassFunction: false, simplify: false)) .injectSuperclass(jsClass); return jsClass; }(); diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index acbb51fe7..6ed9123b7 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -22,7 +22,11 @@ final _disallowedFunctionNames = ..remove("invert") ..remove("alpha") ..remove("opacity") - ..remove("saturate"); + ..remove("saturate") + ..remove("min") + ..remove("max") + ..remove("round") + ..remove("abs"); class CssParser extends ScssParser { bool get plainCss => true; @@ -96,6 +100,17 @@ class CssParser extends ScssParser { ], scanner.spanFrom(start)); } + ParenthesizedExpression parentheses() { + // Expressions are only allowed within calculations, but we verify this at + // evaluation time. + var start = scanner.state; + scanner.expectChar($lparen); + whitespace(); + var expression = expressionUntilComma(); + scanner.expectChar($rparen); + return ParenthesizedExpression(expression, scanner.spanFrom(start)); + } + Expression identifierLike() { var start = scanner.state; var identifier = interpolatedIdentifier(); @@ -107,6 +122,8 @@ class CssParser extends ScssParser { } var beforeArguments = scanner.state; + // `namespacedExpression()` is just here to throw a clearer error. + if (scanner.scanChar($dot)) return namespacedExpression(plain, start); if (!scanner.scanChar($lparen)) return StringExpression(identifier); var allowEmptySecondArg = lower == 'var'; @@ -132,10 +149,8 @@ class CssParser extends ScssParser { "This function isn't allowed in plain CSS.", scanner.spanFrom(start)); } - return InterpolatedFunctionExpression( - // Create a fake interpolation to force the function to be interpreted - // as plain CSS, rather than calling a user-defined function. - Interpolation([StringExpression(identifier)], identifier.span), + return FunctionExpression( + plain, ArgumentInvocation( arguments, const {}, scanner.spanFrom(beforeArguments)), scanner.spanFrom(start)); diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 9e9f7b2ed..23c53952e 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -1821,8 +1821,13 @@ abstract class StylesheetParser extends Parser { void addOperator(BinaryOperator operator) { if (plainCss && - operator != BinaryOperator.dividedBy && - operator != BinaryOperator.singleEquals) { + operator != BinaryOperator.singleEquals && + // These are allowed in calculations, so we have to check them at + // evaluation time. + operator != BinaryOperator.plus && + operator != BinaryOperator.minus && + operator != BinaryOperator.times && + operator != BinaryOperator.dividedBy) { scanner.error("Operators aren't allowed in plain CSS.", position: scanner.position - operator.operator.length, length: operator.operator.length); @@ -1876,7 +1881,7 @@ abstract class StylesheetParser extends Parser { case $lparen: // Parenthesized numbers can't be slash-separated. - addSingleExpression(_parentheses()); + addSingleExpression(parentheses()); case $lbracket: addSingleExpression(_expression(bracketList: true)); @@ -2065,7 +2070,7 @@ abstract class StylesheetParser extends Parser { /// produces a potentially slash-separated number. bool _isSlashOperand(Expression expression) => expression is NumberExpression || - expression is CalculationExpression || + expression is FunctionExpression || (expression is BinaryOperationExpression && expression.allowsSlash); /// Consumes an expression that doesn't contain any top-level whitespace. @@ -2073,7 +2078,7 @@ abstract class StylesheetParser extends Parser { // Note: when adding a new case, make sure it's reflected in // [_lookingAtExpression] and [_expression]. null => scanner.error("Expected expression."), - $lparen => _parentheses(), + $lparen => parentheses(), $slash => _unaryOperation(), $dot => _number(), $lbracket => _expression(bracketList: true), @@ -2101,11 +2106,8 @@ abstract class StylesheetParser extends Parser { }; /// Consumes a parenthesized expression. - Expression _parentheses() { - if (plainCss) { - scanner.error("Parentheses aren't allowed in plain CSS.", length: 1); - } - + @protected + Expression parentheses() { var wasInParentheses = _inParentheses; _inParentheses = true; try { @@ -2601,17 +2603,12 @@ abstract class StylesheetParser extends Parser { /// [name]. @protected Expression? trySpecialFunction(String name, LineScannerState start) { - if (scanner.peekChar() == $lparen) { - if (_tryCalculation(name, start) case var calculation?) { - return calculation; - } - } - var normalized = unvendor(name); InterpolationBuffer buffer; switch (normalized) { - case "calc" || "element" || "expression" when scanner.scanChar($lparen): + case "calc" when normalized != name && scanner.scanChar($lparen): + case "element" || "expression" when scanner.scanChar($lparen): buffer = InterpolationBuffer() ..write(name) ..writeCharCode($lparen); @@ -2643,228 +2640,6 @@ abstract class StylesheetParser extends Parser { return StringExpression(buffer.interpolation(scanner.spanFrom(start))); } - /// If [name] is the name of a calculation expression, parses the - /// corresponding calculation and returns it. - /// - /// Assumes the scanner is positioned immediately before the opening - /// parenthesis of the argument list. - CalculationExpression? _tryCalculation(String name, LineScannerState start) { - assert(scanner.peekChar() == $lparen); - switch (name) { - case "calc": - var arguments = _calculationArguments(1); - return CalculationExpression(name, arguments, scanner.spanFrom(start)); - - case "min" || "max": - // min() and max() are parsed as calculations if possible, and otherwise - // are parsed as normal Sass functions. - var beforeArguments = scanner.state; - List arguments; - try { - arguments = _calculationArguments(); - } on FormatException catch (_) { - scanner.state = beforeArguments; - return null; - } - - return CalculationExpression(name, arguments, scanner.spanFrom(start)); - - case "clamp": - var arguments = _calculationArguments(3); - return CalculationExpression(name, arguments, scanner.spanFrom(start)); - - case _: - return null; - } - } - - /// Consumes and returns arguments for a calculation expression, including the - /// opening and closing parentheses. - /// - /// If [maxArgs] is passed, at most that many arguments are consumed. - /// Otherwise, any number greater than zero are consumed. - List _calculationArguments([int? maxArgs]) { - scanner.expectChar($lparen); - if (_tryCalculationInterpolation() case var interpolation?) { - scanner.expectChar($rparen); - return [interpolation]; - } - - whitespace(); - var arguments = [_calculationSum()]; - while ((maxArgs == null || arguments.length < maxArgs) && - scanner.scanChar($comma)) { - whitespace(); - arguments.add(_calculationSum()); - } - - scanner.expectChar($rparen, - name: arguments.length == maxArgs - ? '"+", "-", "*", "/", or ")"' - : '"+", "-", "*", "/", ",", or ")"'); - - return arguments; - } - - /// Parses a calculation operation or value expression. - Expression _calculationSum() { - var sum = _calculationProduct(); - - while (true) { - var next = scanner.peekChar(); - if (next != $plus && next != $minus) return sum; - - if (!scanner.peekChar(-1).isWhitespace || - !scanner.peekChar(1).isWhitespace) { - scanner.error( - '"+" and "-" must be surrounded by whitespace in calculations.'); - } - - scanner.readChar(); - whitespace(); - sum = BinaryOperationExpression( - next == $plus ? BinaryOperator.plus : BinaryOperator.minus, - sum, - _calculationProduct()); - } - } - - /// Parses a calculation product or value expression. - Expression _calculationProduct() { - var product = _calculationValue(); - - while (true) { - whitespace(); - var next = scanner.peekChar(); - if (next != $asterisk && next != $slash) return product; - - scanner.readChar(); - whitespace(); - product = BinaryOperationExpression( - next == $asterisk ? BinaryOperator.times : BinaryOperator.dividedBy, - product, - _calculationValue()); - } - } - - /// Parses a single calculation value. - Expression _calculationValue() { - switch (scanner.peekChar()) { - case $plus || $dot || int(isDigit: true): - return _number(); - case $dollar: - return _variable(); - case $lparen: - var start = scanner.state; - scanner.readChar(); - - Expression? value = _tryCalculationInterpolation(); - if (value == null) { - whitespace(); - value = _calculationSum(); - } - - whitespace(); - scanner.expectChar($rparen); - return ParenthesizedExpression(value, scanner.spanFrom(start)); - case _ when lookingAtIdentifier(): - var start = scanner.state; - var ident = identifier(); - if (scanner.scanChar($dot)) return namespacedExpression(ident, start); - if (scanner.peekChar() != $lparen) { - return StringExpression( - Interpolation([ident], scanner.spanFrom(start)), - quotes: false); - } - - var lowerCase = ident.toLowerCase(); - if (_tryCalculation(lowerCase, start) case var calculation?) { - return calculation; - } else if (lowerCase == "if") { - return IfExpression(_argumentInvocation(), scanner.spanFrom(start)); - } else { - return FunctionExpression( - ident, _argumentInvocation(), scanner.spanFrom(start)); - } - - // This has to go after [lookingAtIdentifier] because a hyphen can start - // an identifier as well as a number. - case $minus: - return _number(); - - case _: - scanner.error("Expected number, variable, function, or calculation."); - } - } - - /// If the following text up to the next unbalanced `")"`, `"]"`, or `"}"` - /// contains interpolation, parses that interpolation as an unquoted - /// [StringExpression] and returns it. - StringExpression? _tryCalculationInterpolation() => - _containsCalculationInterpolation() - ? StringExpression(_interpolatedDeclarationValue()) - : null; - - /// Returns whether the following text up to the next unbalanced `")"`, `"]"`, - /// or `"}"` contains interpolation. - bool _containsCalculationInterpolation() { - var parens = 0; - var brackets = []; - - var start = scanner.state; - while (!scanner.isDone) { - var next = scanner.peekChar(); - switch (next) { - case $backslash: - scanner.readChar(); - scanner.readChar(); - - case $slash: - if (!scanComment()) scanner.readChar(); - - case $single_quote || $double_quote: - interpolatedString(); - - case $hash: - if (parens == 0 && scanner.peekChar(1) == $lbrace) { - scanner.state = start; - return true; - } - scanner.readChar(); - - case $lparen: - parens++; - continue left; - - left: - case $lbrace: - case $lbracket: - // dart-lang/sdk#45357 - brackets.add(opposite(next!)); - scanner.readChar(); - - case $rparen: - parens--; - continue right; - - right: - case $rbrace: - case $rbracket: - if (brackets.isEmpty || brackets.removeLast() != next) { - scanner.state = start; - return false; - } - scanner.readChar(); - - case _: - scanner.readChar(); - } - } - - scanner.state = start; - return false; - } - /// Like [_urlContents], but returns `null` if the URL fails to parse. /// /// [start] is the position before the beginning of the name. [name] is the diff --git a/lib/src/util/nullable.dart b/lib/src/util/nullable.dart index 125f58d46..ad4a8ba2f 100644 --- a/lib/src/util/nullable.dart +++ b/lib/src/util/nullable.dart @@ -21,3 +21,10 @@ extension SetExtension on Set { return cast(); } } + +extension StringExtension on String { + /// Like [String.codeUnitAt], but returns `null` instead of throwing an error + /// if [index] is past the end of the string. + int? codeUnitAtOrNull(int index) => + index >= length ? null : codeUnitAt(index); +} diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 80fd3aaa2..8df7beb1a 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -110,6 +110,11 @@ double fuzzyAssertRange(double number, int min, int max, [String? name]) { /// /// [floored division]: https://en.wikipedia.org/wiki/Modulo_operation#Variants_of_the_definition double moduloLikeSass(double num1, double num2) { + if (num1.isInfinite) return double.nan; + if (num2.isInfinite) { + return num1.signIncludingZero == num2.sign ? num1 : double.nan; + } + if (num2 > 0) return num1 % num2; if (num2 == 0) return double.nan; @@ -118,3 +123,76 @@ double moduloLikeSass(double num1, double num2) { var result = num1 % num2; return result == 0 ? 0 : result + num2; } + +/// Returns the square root of [number]. +SassNumber sqrt(SassNumber number) { + number.assertNoUnits("number"); + return SassNumber(math.sqrt(number.value)); +} + +/// Returns the sine of [number]. +SassNumber sin(SassNumber number) => + SassNumber(math.sin(number.coerceValueToUnit("rad", "number"))); + +/// Returns the cosine of [number]. +SassNumber cos(SassNumber number) => + SassNumber(math.cos(number.coerceValueToUnit("rad", "number"))); + +/// Returns the tangent of [number]. +SassNumber tan(SassNumber number) => + SassNumber(math.tan(number.coerceValueToUnit("rad", "number"))); + +/// Returns the arctangent of [number]. +SassNumber atan(SassNumber number) { + number.assertNoUnits("number"); + return _radiansToDegrees(math.atan(number.value)); +} + +/// Returns the arcsine of [number]. +SassNumber asin(SassNumber number) { + number.assertNoUnits("number"); + return _radiansToDegrees(math.asin(number.value)); +} + +/// Returns the arccosine of [number] +SassNumber acos(SassNumber number) { + number.assertNoUnits("number"); + return _radiansToDegrees(math.acos(number.value)); +} + +/// Returns the absolute value of [number]. +SassNumber abs(SassNumber number) => + SassNumber(number.value.abs()).coerceToMatch(number); + +/// Returns the logarithm of [number] with respect to [base]. +SassNumber log(SassNumber number, SassNumber? base) { + if (base != null) { + return SassNumber(math.log(number.value) / math.log(base.value)); + } + return SassNumber(math.log(number.value)); +} + +/// Returns the value of [base] raised to the power of [exponent]. +SassNumber pow(SassNumber base, SassNumber exponent) { + base.assertNoUnits("base"); + exponent.assertNoUnits("exponent"); + return SassNumber(math.pow(base.value, exponent.value)); +} + +/// Returns the arctangent for [y] and [x]. +SassNumber atan2(SassNumber y, SassNumber x) => + _radiansToDegrees(math.atan2(y.value, x.convertValueToMatch(y, 'x', 'y'))); + +/// Returns [radians] as a [SassNumber] with unit `deg`. +SassNumber _radiansToDegrees(double radians) => + SassNumber.withUnits(radians * (180 / math.pi), numeratorUnits: ['deg']); + +/// Extension methods to get the sign of the double's numerical value, +/// including positive and negative zero. +extension DoubleWithSignedZero on double { + double get signIncludingZero { + if (identical(this, -0.0)) return -1.0; + if (this == 0) return 1.0; + return sign; + } +} diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index b39011bf0..cbb8b92e6 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -2,11 +2,18 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:math' as math; + +import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../exception.dart'; +import '../callable.dart'; +import '../util/character.dart'; import '../util/nullable.dart'; -import '../util/number.dart'; +import '../util/number.dart' as number_lib; import '../utils.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; @@ -27,7 +34,7 @@ final class SassCalculation extends Value { /// The calculation's arguments. /// /// Each argument is either a [SassNumber], a [SassCalculation], an unquoted - /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. + /// [SassString], or a [CalculationOperation]. final List arguments; /// @nodoc @@ -44,8 +51,7 @@ final class SassCalculation extends Value { /// Creates a `calc()` calculation with the given [argument]. /// /// The [argument] must be either a [SassNumber], a [SassCalculation], an - /// unquoted [SassString], a [CalculationOperation], or a - /// [CalculationInterpolation]. + /// unquoted [SassString], or a [CalculationOperation]. /// /// This automatically simplifies the calculation, so it may return a /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it @@ -60,8 +66,8 @@ final class SassCalculation extends Value { /// Creates a `min()` calculation with the given [arguments]. /// /// Each argument must be either a [SassNumber], a [SassCalculation], an - /// unquoted [SassString], a [CalculationOperation], or a - /// [CalculationInterpolation]. It must be passed at least one argument. + /// unquoted [SassString], or a [CalculationOperation]. It must be passed at + /// least one argument. /// /// This automatically simplifies the calculation, so it may return a /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it @@ -91,8 +97,8 @@ final class SassCalculation extends Value { /// Creates a `max()` calculation with the given [arguments]. /// /// Each argument must be either a [SassNumber], a [SassCalculation], an - /// unquoted [SassString], a [CalculationOperation], or a - /// [CalculationInterpolation]. It must be passed at least one argument. + /// unquoted [SassString], or a [CalculationOperation]. It must be passed at + /// least one argument. /// /// This automatically simplifies the calculation, so it may return a /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it @@ -119,11 +125,181 @@ final class SassCalculation extends Value { return SassCalculation._("max", args); } + /// Creates a `hypot()` calculation with the given [arguments]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. It must be passed at + /// least one argument. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value hypot(Iterable arguments) { + var args = _simplifyArguments(arguments); + if (args.isEmpty) { + throw ArgumentError("hypot() must have at least one argument."); + } + _verifyCompatibleNumbers(args); + + var subtotal = 0.0; + var first = args.first; + if (first is! SassNumber || first.hasUnit('%')) { + return SassCalculation._("hypot", args); + } + for (var i = 0; i < args.length; i++) { + var number = args.elementAt(i); + if (number is! SassNumber || !number.hasCompatibleUnits(first)) { + return SassCalculation._("hypot", args); + } + var value = + number.convertValueToMatch(first, "numbers[${i + 1}]", "numbers[1]"); + subtotal += value * value; + } + return SassNumber.withUnits(math.sqrt(subtotal), + numeratorUnits: first.numeratorUnits, + denominatorUnits: first.denominatorUnits); + } + + /// Creates a `sqrt()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value sqrt(Object argument) => + _singleArgument("sqrt", argument, number_lib.sqrt, forbidUnits: true); + + /// Creates a `sin()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value sin(Object argument) => + _singleArgument("sin", argument, number_lib.sin); + + /// Creates a `cos()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value cos(Object argument) => + _singleArgument("cos", argument, number_lib.cos); + + /// Creates a `tan()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value tan(Object argument) => + _singleArgument("tan", argument, number_lib.tan); + + /// Creates an `atan()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value atan(Object argument) => + _singleArgument("atan", argument, number_lib.atan, forbidUnits: true); + + /// Creates an `asin()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value asin(Object argument) => + _singleArgument("asin", argument, number_lib.asin, forbidUnits: true); + + /// Creates an `acos()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value acos(Object argument) => + _singleArgument("acos", argument, number_lib.acos, forbidUnits: true); + + /// Creates an `abs()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value abs(Object argument) { + argument = _simplify(argument); + if (argument is! SassNumber) return SassCalculation._("abs", [argument]); + if (argument.hasUnit("%")) { + warnForDeprecation( + "Passing percentage units to the global abs() function is deprecated.\n" + "In the future, this will emit a CSS abs() function to be resolved by the browser.\n" + "To preserve current behavior: math.abs($argument)" + "\n" + "To emit a CSS abs() now: abs(#{$argument})\n" + "More info: https://sass-lang.com/d/abs-percent", + Deprecation.absPercent); + } + return number_lib.abs(argument); + } + + /// Creates an `exp()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value exp(Object argument) { + argument = _simplify(argument); + if (argument is! SassNumber) { + return SassCalculation._("exp", [argument]); + } + argument.assertNoUnits(); + return number_lib.pow(SassNumber(math.e), argument); + } + + /// Creates a `sign()` calculation with the given [argument]. + /// + /// The [argument] must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + static Value sign(Object argument) { + argument = _simplify(argument); + return switch (argument) { + SassNumber(value: double(isNaN: true) || 0) => argument, + SassNumber arg when !arg.hasUnit('%') => + SassNumber(arg.value.sign).coerceToMatch(argument), + _ => SassCalculation._("sign", [argument]), + }; + } + /// Creates a `clamp()` calculation with the given [min], [value], and [max]. /// /// Each argument must be either a [SassNumber], a [SassCalculation], an - /// unquoted [SassString], a [CalculationOperation], or a - /// [CalculationInterpolation]. + /// unquoted [SassString], or a [CalculationOperation]. /// /// This automatically simplifies the calculation, so it may return a /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it @@ -157,6 +333,246 @@ final class SassCalculation extends Value { return SassCalculation._("clamp", args); } + /// Creates a `pow()` calculation with the given [base] and [exponent]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// This may be passed fewer than two arguments, but only if one of the + /// arguments is an unquoted `var()` string. + static Value pow(Object base, Object? exponent) { + var args = [base, if (exponent != null) exponent]; + _verifyLength(args, 2); + base = _simplify(base); + exponent = exponent.andThen(_simplify); + if (base is! SassNumber || exponent is! SassNumber) { + return SassCalculation._("pow", args); + } + base.assertNoUnits(); + exponent.assertNoUnits(); + return number_lib.pow(base, exponent); + } + + /// Creates a `log()` calculation with the given [number] and [base]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// If arguments contains exactly a single argument, the base is set to + /// `math.e` by default. + static Value log(Object number, Object? base) { + number = _simplify(number); + base = base.andThen(_simplify); + var args = [number, if (base != null) base]; + if (number is! SassNumber || (base != null && base is! SassNumber)) { + return SassCalculation._("log", args); + } + number.assertNoUnits(); + if (base is SassNumber) { + base.assertNoUnits(); + return number_lib.log(number, base); + } + return number_lib.log(number, null); + } + + /// Creates a `atan2()` calculation for [y] and [x]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// This may be passed fewer than two arguments, but only if one of the + /// arguments is an unquoted `var()` string. + static Value atan2(Object y, Object? x) { + y = _simplify(y); + x = x.andThen(_simplify); + var args = [y, if (x != null) x]; + _verifyLength(args, 2); + _verifyCompatibleNumbers(args); + if (y is! SassNumber || + x is! SassNumber || + y.hasUnit('%') || + x.hasUnit('%') || + !y.hasCompatibleUnits(x)) { + return SassCalculation._("atan2", args); + } + return number_lib.atan2(y, x); + } + + /// Creates a `rem()` calculation with the given [dividend] and [modulus]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// This may be passed fewer than two arguments, but only if one of the + /// arguments is an unquoted `var()` string. + static Value rem(Object dividend, Object? modulus) { + dividend = _simplify(dividend); + modulus = modulus.andThen(_simplify); + var args = [dividend, if (modulus != null) modulus]; + _verifyLength(args, 2); + _verifyCompatibleNumbers(args); + if (dividend is! SassNumber || + modulus is! SassNumber || + !dividend.hasCompatibleUnits(modulus)) { + return SassCalculation._("rem", args); + } + var result = dividend.modulo(modulus); + if (modulus.value.signIncludingZero != dividend.value.signIncludingZero) { + if (modulus.value.isInfinite) return dividend; + if (result.value == 0) { + return result.unaryMinus(); + } + return result.minus(modulus); + } + return result; + } + + /// Creates a `mod()` calculation with the given [dividend] and [modulus]. + /// + /// Each argument must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// This may be passed fewer than two arguments, but only if one of the + /// arguments is an unquoted `var()` string. + static Value mod(Object dividend, Object? modulus) { + dividend = _simplify(dividend); + modulus = modulus.andThen(_simplify); + var args = [dividend, if (modulus != null) modulus]; + _verifyLength(args, 2); + _verifyCompatibleNumbers(args); + if (dividend is! SassNumber || + modulus is! SassNumber || + !dividend.hasCompatibleUnits(modulus)) { + return SassCalculation._("mod", args); + } + return dividend.modulo(modulus); + } + + /// Creates a `round()` calculation with the given [strategyOrNumber], + /// [numberOrStep], and [step]. Strategy must be either nearest, up, down or + /// to-zero. + /// + /// Number and step must be either a [SassNumber], a [SassCalculation], an + /// unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation, so it may return a + /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it + /// can determine that the calculation will definitely produce invalid CSS. + /// + /// This may be passed fewer than two arguments, but only if one of the + /// arguments is an unquoted `var()` string. + static Value round(Object strategyOrNumber, + [Object? numberOrStep, Object? step]) { + switch (( + _simplify(strategyOrNumber), + numberOrStep.andThen(_simplify), + step.andThen(_simplify) + )) { + case (SassNumber number, null, null): + return _matchUnits(number.value.round().toDouble(), number); + + case (SassNumber number, SassNumber step, null) + when !number.hasCompatibleUnits(step): + _verifyCompatibleNumbers([number, step]); + return SassCalculation._("round", [number, step]); + + case (SassNumber number, SassNumber step, null): + _verifyCompatibleNumbers([number, step]); + return _roundWithStep('nearest', number, step); + + case ( + SassString(text: 'nearest' || 'up' || 'down' || 'to-zero') && + var strategy, + SassNumber number, + SassNumber step + ) + when !number.hasCompatibleUnits(step): + _verifyCompatibleNumbers([number, step]); + return SassCalculation._("round", [strategy, number, step]); + + case ( + SassString(text: 'nearest' || 'up' || 'down' || 'to-zero') && + var strategy, + SassNumber number, + SassNumber step + ): + _verifyCompatibleNumbers([number, step]); + return _roundWithStep(strategy.text, number, step); + + case ( + SassString(text: 'nearest' || 'up' || 'down' || 'to-zero') && + var strategy, + SassString rest, + null + ): + return SassCalculation._("round", [strategy, rest]); + + case ( + SassString(text: 'nearest' || 'up' || 'down' || 'to-zero'), + _?, + null + ): + throw SassScriptException("If strategy is not null, step is required."); + + case ( + SassString(text: 'nearest' || 'up' || 'down' || 'to-zero'), + null, + null + ): + throw SassScriptException( + "Number to round and step arguments are required."); + + case (SassString rest, null, null): + return SassCalculation._("round", [rest]); + + case (var number, null, null): + throw SassScriptException( + "Single argument $number expected to be simplifiable."); + + case (var number, var step?, null): + return SassCalculation._("round", [number, step]); + + case ( + (SassString(text: 'nearest' || 'up' || 'down' || 'to-zero') || + SassString(isVar: true)) && + var strategy, + var number?, + var step? + ): + return SassCalculation._("round", [strategy, number, step]); + + case (_, _?, _?): + throw SassScriptException( + "$strategyOrNumber must be either nearest, up, down or to-zero."); + + case (_, null, _?): + // TODO(pamelalozano): Get rid of this case once dart-lang/sdk#52908 is solved. + // ignore: unreachable_switch_case + case (_, _, _): + throw SassScriptException("Invalid parameters."); + } + } + /// Creates and simplifies a [CalculationOperation] with the given [operator], /// [left], and [right]. /// @@ -164,15 +580,15 @@ final class SassCalculation extends Value { /// [SassNumber] rather than a [CalculationOperation]. /// /// Each of [left] and [right] must be either a [SassNumber], a - /// [SassCalculation], an unquoted [SassString], a [CalculationOperation], or - /// a [CalculationInterpolation]. + /// [SassCalculation], an unquoted [SassString], or a [CalculationOperation]. static Object operate( CalculationOperator operator, Object left, Object right) => - operateInternal(operator, left, right, inMinMax: false, simplify: true); + operateInternal(operator, left, right, + inLegacySassFunction: false, simplify: true); - /// Like [operate], but with the internal-only [inMinMax] parameter. + /// Like [operate], but with the internal-only [inLegacySassFunction] parameter. /// - /// If [inMinMax] is `true`, this allows unitless numbers to be added and + /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and /// subtracted with numbers with units, for backwards-compatibility with the /// old global `min()` and `max()` functions. /// @@ -180,7 +596,7 @@ final class SassCalculation extends Value { @internal static Object operateInternal( CalculationOperator operator, Object left, Object right, - {required bool inMinMax, required bool simplify}) { + {required bool inLegacySassFunction, required bool simplify}) { if (!simplify) return CalculationOperation._(operator, left, right); left = _simplify(left); right = _simplify(right); @@ -188,7 +604,7 @@ final class SassCalculation extends Value { if (operator case CalculationOperator.plus || CalculationOperator.minus) { if (left is SassNumber && right is SassNumber && - (inMinMax + (inLegacySassFunction ? left.isComparableTo(right) : left.hasCompatibleUnits(right))) { return operator == CalculationOperator.plus @@ -198,7 +614,7 @@ final class SassCalculation extends Value { _verifyCompatibleNumbers([left, right]); - if (right is SassNumber && fuzzyLessThan(right.value, 0)) { + if (right is SassNumber && number_lib.fuzzyLessThan(right.value, 0)) { right = right.times(SassNumber(-1)); operator = operator == CalculationOperator.plus ? CalculationOperator.minus @@ -219,19 +635,88 @@ final class SassCalculation extends Value { /// simplification. SassCalculation._(this.name, this.arguments); + // Returns [value] coerced to [number]'s units. + static SassNumber _matchUnits(double value, SassNumber number) => + SassNumber.withUnits(value, + numeratorUnits: number.numeratorUnits, + denominatorUnits: number.denominatorUnits); + + /// Returns a rounded [number] based on a selected rounding [strategy], + /// to the nearest integer multiple of [step]. + static SassNumber _roundWithStep( + String strategy, SassNumber number, SassNumber step) { + if (!{'nearest', 'up', 'down', 'to-zero'}.contains(strategy)) { + throw ArgumentError( + "$strategy must be either nearest, up, down or to-zero."); + } + + if (number.value.isInfinite && step.value.isInfinite || + step.value == 0 || + number.value.isNaN || + step.value.isNaN) { + return _matchUnits(double.nan, number); + } + if (number.value.isInfinite) return number; + + if (step.value.isInfinite) { + return switch ((strategy, number.value)) { + (_, 0) => number, + ('nearest' || 'to-zero', > 0) => _matchUnits(0.0, number), + ('nearest' || 'to-zero', _) => _matchUnits(-0.0, number), + ('up', > 0) => _matchUnits(double.infinity, number), + ('up', _) => _matchUnits(-0.0, number), + ('down', < 0) => _matchUnits(-double.infinity, number), + ('down', _) => _matchUnits(0, number), + (_, _) => throw UnsupportedError("Invalid argument: $strategy.") + }; + } + + var stepWithNumberUnit = step.convertValueToMatch(number); + return switch (strategy) { + 'nearest' => _matchUnits( + (number.value / stepWithNumberUnit).round() * stepWithNumberUnit, + number), + 'up' => _matchUnits( + (step.value < 0 + ? (number.value / stepWithNumberUnit).floor() + : (number.value / stepWithNumberUnit).ceil()) * + stepWithNumberUnit, + number), + 'down' => _matchUnits( + (step.value < 0 + ? (number.value / stepWithNumberUnit).ceil() + : (number.value / stepWithNumberUnit).floor()) * + stepWithNumberUnit, + number), + 'to-zero' => number.value < 0 + ? _matchUnits( + (number.value / stepWithNumberUnit).ceil() * stepWithNumberUnit, + number) + : _matchUnits( + (number.value / stepWithNumberUnit).floor() * stepWithNumberUnit, + number), + _ => _matchUnits(double.nan, number) + }; + } + /// Returns an unmodifiable list of [args], with each argument simplified. static List _simplifyArguments(Iterable args) => List.unmodifiable(args.map(_simplify)); /// Simplifies a calculation argument. static Object _simplify(Object arg) => switch (arg) { - SassNumber() || - CalculationInterpolation() || - CalculationOperation() => - arg, + SassNumber() || CalculationOperation() => arg, + CalculationInterpolation() => + SassString('(${arg.value})', quotes: false), SassString(hasQuotes: false) => arg, SassString() => throw SassScriptException( "Quoted string $arg can't be used in a calculation."), + SassCalculation( + name: 'calc', + arguments: [SassString(hasQuotes: false, :var text)] + ) + when _needsParentheses(text) => + SassString('($text)', quotes: false), SassCalculation(name: 'calc', arguments: [var value]) => value, SassCalculation() => arg, Value() => throw SassScriptException( @@ -239,6 +724,40 @@ final class SassCalculation extends Value { _ => throw ArgumentError("Unexpected calculation argument $arg.") }; + /// Returns whether [text] needs parentheses if it's the contents of a + /// `calc()` being embedded in another calculation. + static bool _needsParentheses(String text) { + var first = text.codeUnitAt(0); + if (_charNeedsParentheses(first)) return true; + var couldBeVar = text.length >= 4 && characterEqualsIgnoreCase(first, $v); + + if (text.length < 2) return false; + var second = text.codeUnitAt(1); + if (_charNeedsParentheses(second)) return true; + couldBeVar = couldBeVar && characterEqualsIgnoreCase(second, $a); + + if (text.length < 3) return false; + var third = text.codeUnitAt(2); + if (_charNeedsParentheses(third)) return true; + couldBeVar = couldBeVar && characterEqualsIgnoreCase(third, $r); + + if (text.length < 4) return false; + var fourth = text.codeUnitAt(3); + if (couldBeVar && fourth == $lparen) return true; + if (_charNeedsParentheses(fourth)) return true; + + for (var i = 4; i < text.length; i++) { + if (_charNeedsParentheses(text.codeUnitAt(i))) return true; + } + return false; + } + + /// Returns whether [character] intrinsically needs parentheses if it appears + /// in the unquoted string argument of a `calc()` being embedded in another + /// calculation. + static bool _charNeedsParentheses(int character) => + character.isWhitespace || character == $slash || character == $asterisk; + /// Verifies that all the numbers in [args] aren't known to be incompatible /// with one another, and that they don't have units that are too complex for /// calculations. @@ -267,11 +786,10 @@ final class SassCalculation extends Value { } /// Throws a [SassScriptException] if [args] isn't [expectedLength] *and* - /// doesn't contain either a [SassString] or a [CalculationInterpolation]. + /// doesn't contain a [SassString]. static void _verifyLength(List args, int expectedLength) { if (args.length == expectedLength) return; - if (args - .any((arg) => arg is SassString || arg is CalculationInterpolation)) { + if (args.any((arg) => arg is SassString)) { return; } throw SassScriptException( @@ -279,6 +797,21 @@ final class SassCalculation extends Value { "${pluralize('was', args.length, plural: 'were')} passed."); } + /// Returns a [Callable] named [name] that calls a single argument + /// math function. + /// + /// If [forbidUnits] is `true` it will throw an error if [argument] has units. + static Value _singleArgument( + String name, Object argument, SassNumber mathFunc(SassNumber value), + {bool forbidUnits = false}) { + argument = _simplify(argument); + if (argument is! SassNumber) { + return SassCalculation._(name, [argument]); + } + if (forbidUnits) argument.assertNoUnits(); + return mathFunc(argument); + } + /// @nodoc @internal T accept(ValueVisitor visitor) => visitor.visitCalculation(this); @@ -329,14 +862,14 @@ final class CalculationOperation { /// The left-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted - /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. + /// [SassString], or a [CalculationOperation]. Object get left => _left; final Object _left; /// The right-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted - /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. + /// [SassString], or a [CalculationOperation]. Object get right => _right; final Object _right; @@ -392,12 +925,15 @@ enum CalculationOperator { String toString() => name; } -/// A string injected into a [SassCalculation] using interpolation. +/// A deprecated representation of a string injected into a [SassCalculation] +/// using interpolation. /// -/// This is tracked separately from string arguments because it requires -/// additional parentheses when used as an operand of a [CalculationOperation]. +/// This only exists for backwards-compatibility with an older version of Dart +/// Sass. It's now equivalent to creating a `SassString` whose value is wrapped +/// in parentheses. /// /// {@category Value} +@Deprecated("Use SassString instead.") @sealed class CalculationInterpolation { /// We use a getters to allow overriding the logic in the JS API diff --git a/lib/src/value/number.dart b/lib/src/value/number.dart index 410bc8465..a5c90a501 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -710,7 +710,7 @@ abstract class SassNumber extends Value { /// @nodoc @internal - Value modulo(Value other) { + SassNumber modulo(Value other) { if (other is SassNumber) { return withValue(_coerceUnits(other, moduloLikeSass)); } diff --git a/lib/src/value/number/unitless.dart b/lib/src/value/number/unitless.dart index 06b54d39b..7272b7c59 100644 --- a/lib/src/value/number/unitless.dart +++ b/lib/src/value/number/unitless.dart @@ -98,7 +98,7 @@ class UnitlessSassNumber extends SassNumber { return super.lessThanOrEquals(other); } - Value modulo(Value other) { + SassNumber modulo(Value other) { if (other is SassNumber) { return other.withValue(moduloLikeSass(value, other.value)); } diff --git a/lib/src/visitor/ast_search.dart b/lib/src/visitor/ast_search.dart new file mode 100644 index 000000000..d971afd23 --- /dev/null +++ b/lib/src/visitor/ast_search.dart @@ -0,0 +1,185 @@ +// Copyright 2023 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:meta/meta.dart'; + +import '../ast/sass.dart'; +import '../util/iterable.dart'; +import '../util/nullable.dart'; +import 'interface/expression.dart'; +import 'recursive_statement.dart'; +import 'statement_search.dart'; + +/// A visitor that recursively traverses each statement and expression in a Sass +/// AST whose `visit*` methods default to returning `null`, but which returns +/// the first non-`null` value returned by any method. +/// +/// This extends [RecursiveStatementVisitor] to traverse each expression in +/// addition to each statement. It supports the same additional methods as +/// [RecursiveAstVisitor]. +/// +/// {@category Visitor} +mixin AstSearchVisitor on StatementSearchVisitor + implements ExpressionVisitor { + T? visitAtRootRule(AtRootRule node) => + node.query.andThen(visitInterpolation) ?? super.visitAtRootRule(node); + + T? visitAtRule(AtRule node) => + visitInterpolation(node.name) ?? + node.value.andThen(visitInterpolation) ?? + super.visitAtRule(node); + + T? visitContentRule(ContentRule node) => + visitArgumentInvocation(node.arguments); + + T? visitDebugRule(DebugRule node) => visitExpression(node.expression); + + T? visitDeclaration(Declaration node) => + visitInterpolation(node.name) ?? + node.value.andThen(visitExpression) ?? + super.visitDeclaration(node); + + T? visitEachRule(EachRule node) => + visitExpression(node.list) ?? super.visitEachRule(node); + + T? visitErrorRule(ErrorRule node) => visitExpression(node.expression); + + T? visitExtendRule(ExtendRule node) => visitInterpolation(node.selector); + + T? visitForRule(ForRule node) => + visitExpression(node.from) ?? + visitExpression(node.to) ?? + super.visitForRule(node); + + T? visitForwardRule(ForwardRule node) => node.configuration + .search((variable) => visitExpression(variable.expression)); + + T? visitIfRule(IfRule node) => + node.clauses.search((clause) => + visitExpression(clause.expression) ?? + clause.children.search((child) => child.accept(this))) ?? + node.lastClause.andThen((lastClause) => + lastClause.children.search((child) => child.accept(this))); + + T? visitImportRule(ImportRule node) => + node.imports.search((import) => import is StaticImport + ? visitInterpolation(import.url) ?? + import.modifiers.andThen(visitInterpolation) + : null); + + T? visitIncludeRule(IncludeRule node) => + visitArgumentInvocation(node.arguments) ?? super.visitIncludeRule(node); + + T? visitLoudComment(LoudComment node) => visitInterpolation(node.text); + + T? visitMediaRule(MediaRule node) => + visitInterpolation(node.query) ?? super.visitMediaRule(node); + + T? visitReturnRule(ReturnRule node) => visitExpression(node.expression); + + T? visitStyleRule(StyleRule node) => + visitInterpolation(node.selector) ?? super.visitStyleRule(node); + + T? visitSupportsRule(SupportsRule node) => + visitSupportsCondition(node.condition) ?? super.visitSupportsRule(node); + + T? visitUseRule(UseRule node) => node.configuration + .search((variable) => visitExpression(variable.expression)); + + T? visitVariableDeclaration(VariableDeclaration node) => + visitExpression(node.expression); + + T? visitWarnRule(WarnRule node) => visitExpression(node.expression); + + T? visitWhileRule(WhileRule node) => + visitExpression(node.condition) ?? super.visitWhileRule(node); + + T? visitExpression(Expression expression) => expression.accept(this); + + T? visitBinaryOperationExpression(BinaryOperationExpression node) => + node.left.accept(this) ?? node.right.accept(this); + + T? visitBooleanExpression(BooleanExpression node) => null; + + T? visitColorExpression(ColorExpression node) => null; + + T? visitFunctionExpression(FunctionExpression node) => + visitArgumentInvocation(node.arguments); + + T? visitInterpolatedFunctionExpression(InterpolatedFunctionExpression node) => + visitInterpolation(node.name) ?? visitArgumentInvocation(node.arguments); + + T? visitIfExpression(IfExpression node) => + visitArgumentInvocation(node.arguments); + + T? visitListExpression(ListExpression node) => + node.contents.search((item) => item.accept(this)); + + T? visitMapExpression(MapExpression node) => + node.pairs.search((pair) => pair.$1.accept(this) ?? pair.$2.accept(this)); + + T? visitNullExpression(NullExpression node) => null; + + T? visitNumberExpression(NumberExpression node) => null; + + T? visitParenthesizedExpression(ParenthesizedExpression node) => + node.expression.accept(this); + + T? visitSelectorExpression(SelectorExpression node) => null; + + T? visitStringExpression(StringExpression node) => + visitInterpolation(node.text); + + T? visitSupportsExpression(SupportsExpression node) => + visitSupportsCondition(node.condition); + + T? visitUnaryOperationExpression(UnaryOperationExpression node) => + node.operand.accept(this); + + T? visitValueExpression(ValueExpression node) => null; + + T? visitVariableExpression(VariableExpression node) => null; + + @protected + T? visitCallableDeclaration(CallableDeclaration node) => + node.arguments.arguments.search( + (argument) => argument.defaultValue.andThen(visitExpression)) ?? + super.visitCallableDeclaration(node); + + /// Visits each expression in an [invocation]. + /// + /// The default implementation of the visit methods calls this to visit any + /// argument invocation in a statement. + @protected + T? visitArgumentInvocation(ArgumentInvocation invocation) => + invocation.positional + .search((expression) => visitExpression(expression)) ?? + invocation.named.values + .search((expression) => visitExpression(expression)) ?? + invocation.rest.andThen(visitExpression) ?? + invocation.keywordRest.andThen(visitExpression); + + /// Visits each expression in [condition]. + /// + /// The default implementation of the visit methods call this to visit any + /// [SupportsCondition] they encounter. + @protected + T? visitSupportsCondition(SupportsCondition condition) => switch (condition) { + SupportsOperation() => visitSupportsCondition(condition.left) ?? + visitSupportsCondition(condition.right), + SupportsNegation() => visitSupportsCondition(condition.condition), + SupportsInterpolation() => visitExpression(condition.expression), + SupportsDeclaration() => + visitExpression(condition.name) ?? visitExpression(condition.value), + _ => null + }; + + /// Visits each expression in an [interpolation]. + /// + /// The default implementation of the visit methods call this to visit any + /// interpolation in a statement. + @protected + T? visitInterpolation(Interpolation interpolation) => interpolation.contents + .search((node) => node is Expression ? visitExpression(node) : null); +} diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 3e8dabcd3..b8917ba42 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -40,6 +40,7 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/character.dart'; import '../util/map.dart'; import '../util/multi_span.dart'; import '../util/nullable.dart'; @@ -430,10 +431,14 @@ final class _EvaluateVisitor return SassFunction(PlainCssCallable(name.text)); } - var callable = _addExceptionSpan( - _callableNode!, - () => _getFunction(name.text.replaceAll("_", "-"), - namespace: module?.text)); + var callable = _addExceptionSpan(_callableNode!, () { + var normalizedName = name.text.replaceAll("_", "-"); + var namespace = module?.text; + var local = + _environment.getFunction(normalizedName, namespace: namespace); + if (local != null || namespace != null) return local; + return _builtInFunctions[normalizedName]; + }); if (callable == null) throw "Function not found: $name"; return SassFunction(callable); @@ -2182,6 +2187,13 @@ final class _EvaluateVisitor // ## Expressions Future visitBinaryOperationExpression(BinaryOperationExpression node) { + if (_stylesheet.plainCss && + node.operator != BinaryOperator.singleEquals && + node.operator != BinaryOperator.dividedBy) { + throw _exception( + "Operators aren't allowed in plain CSS.", node.operatorSpan); + } + return _addExceptionSpanAsync(node, () async { var left = await node.left.accept(this); return switch (node.operator) { @@ -2217,7 +2229,10 @@ final class _EvaluateVisitor Value _slash(Value left, Value right, BinaryOperationExpression node) { var result = left.dividedBy(right); switch ((left, right)) { - case (SassNumber left, SassNumber right) when node.allowsSlash: + case (SassNumber left, SassNumber right) + when node.allowsSlash && + _operandAllowsSlash(node.left) && + _operandAllowsSlash(node.right): return (result as SassNumber).withSlash(left, right); case (SassNumber(), SassNumber()): @@ -2250,6 +2265,20 @@ final class _EvaluateVisitor } } + /// Returns whether [node] can be used as a component of a slash-separated + /// number. + /// + /// Although this logic is mostly resolved at parse-time, we can't tell + /// whether operands will be evaluated as calculations until evaluation-time. + bool _operandAllowsSlash(Expression node) => + node is! FunctionExpression || + (node.namespace == null && + const { + "calc", "clamp", "hypot", "sin", "cos", "tan", "asin", "acos", // + "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", "log" + }.contains(node.name.toLowerCase()) && + _environment.getFunction(node.name) == null); + Future visitValueExpression(ValueExpression node) async => node.value; Future visitVariableExpression(VariableExpression node) async { @@ -2294,23 +2323,142 @@ final class _EvaluateVisitor SassNumber(node.value, node.unit); Future visitParenthesizedExpression(ParenthesizedExpression node) => - node.expression.accept(this); + _stylesheet.plainCss + ? throw _exception( + "Parentheses aren't allowed in plain CSS.", node.span) + : node.expression.accept(this); + + Future visitColorExpression(ColorExpression node) async => + node.value; + + Future visitListExpression(ListExpression node) async => SassList( + await mapAsync( + node.contents, (Expression expression) => expression.accept(this)), + node.separator, + brackets: node.hasBrackets); + + Future visitMapExpression(MapExpression node) async { + var map = {}; + var keyNodes = {}; + for (var (key, value) in node.pairs) { + var keyValue = await key.accept(this); + var valueValue = await value.accept(this); - Future visitCalculationExpression(CalculationExpression node) async { + if (map.containsKey(keyValue)) { + var oldValueSpan = keyNodes[keyValue]?.span; + throw MultiSpanSassRuntimeException( + 'Duplicate key.', + key.span, + 'second key', + {if (oldValueSpan != null) oldValueSpan: 'first key'}, + _stackTrace(key.span)); + } + map[keyValue] = valueValue; + keyNodes[keyValue] = key; + } + return SassMap(map); + } + + Future visitFunctionExpression(FunctionExpression node) async { + var function = _stylesheet.plainCss + ? null + : _addExceptionSpan( + node, + () => + _environment.getFunction(node.name, namespace: node.namespace)); + if (function == null) { + if (node.namespace != null) { + throw _exception("Undefined function.", node.span); + } + + switch (node.name.toLowerCase()) { + case "min" || "max" || "round" || "abs" + when node.arguments.named.isEmpty && + node.arguments.rest == null && + node.arguments.positional + .every((argument) => argument.isCalculationSafe): + return await _visitCalculation(node, inLegacySassFunction: true); + + case "calc" || + "clamp" || + "hypot" || + "sin" || + "cos" || + "tan" || + "asin" || + "acos" || + "atan" || + "sqrt" || + "exp" || + "sign" || + "mod" || + "rem" || + "atan2" || + "pow" || + "log": + return await _visitCalculation(node); + } + + function = (_stylesheet.plainCss ? null : _builtInFunctions[node.name]) ?? + PlainCssCallable(node.originalName); + } + + var oldInFunction = _inFunction; + _inFunction = true; + var result = await _addErrorSpan( + node, () => _runFunctionCallable(node.arguments, function, node)); + _inFunction = oldInFunction; + return result; + } + + Future _visitCalculation(FunctionExpression node, + {bool inLegacySassFunction = false}) async { + if (node.arguments.named.isNotEmpty) { + throw _exception( + "Keyword arguments can't be used with calculations.", node.span); + } else if (node.arguments.rest != null) { + throw _exception( + "Rest arguments can't be used with calculations.", node.span); + } + + _checkCalculationArguments(node); var arguments = [ - for (var argument in node.arguments) - await _visitCalculationValue(argument, - inMinMax: node.name == 'min' || node.name == 'max') + for (var argument in node.arguments.positional) + await _visitCalculationExpression(argument, + inLegacySassFunction: inLegacySassFunction) ]; if (_inSupportsDeclaration) { return SassCalculation.unsimplified(node.name, arguments); } try { - return switch (node.name) { + return switch (node.name.toLowerCase()) { "calc" => SassCalculation.calc(arguments[0]), + "sqrt" => SassCalculation.sqrt(arguments[0]), + "sin" => SassCalculation.sin(arguments[0]), + "cos" => SassCalculation.cos(arguments[0]), + "tan" => SassCalculation.tan(arguments[0]), + "asin" => SassCalculation.asin(arguments[0]), + "acos" => SassCalculation.acos(arguments[0]), + "atan" => SassCalculation.atan(arguments[0]), + "abs" => SassCalculation.abs(arguments[0]), + "exp" => SassCalculation.exp(arguments[0]), + "sign" => SassCalculation.sign(arguments[0]), "min" => SassCalculation.min(arguments), "max" => SassCalculation.max(arguments), + "hypot" => SassCalculation.hypot(arguments), + "pow" => + SassCalculation.pow(arguments[0], arguments.elementAtOrNull(1)), + "atan2" => + SassCalculation.atan2(arguments[0], arguments.elementAtOrNull(1)), + "log" => + SassCalculation.log(arguments[0], arguments.elementAtOrNull(1)), + "mod" => + SassCalculation.mod(arguments[0], arguments.elementAtOrNull(1)), + "rem" => + SassCalculation.rem(arguments[0], arguments.elementAtOrNull(1)), + "round" => SassCalculation.round(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') @@ -2319,11 +2467,54 @@ final class _EvaluateVisitor // The simplification logic in the [SassCalculation] static methods will // throw an error if the arguments aren't compatible, but we have access // to the original spans so we can throw a more informative error. - _verifyCompatibleNumbers(arguments, node.arguments); + if (error.message.contains("compatible")) { + _verifyCompatibleNumbers(arguments, node.arguments.positional); + } throwWithTrace(_exception(error.message, node.span), error, stackTrace); } } + /// Verifies that the calculation [node] has the correct number of arguments. + void _checkCalculationArguments(FunctionExpression node) { + void check([int? maxArgs]) { + if (node.arguments.positional.isEmpty) { + throw _exception("Missing argument.", node.span); + } else if (maxArgs != null && + node.arguments.positional.length > maxArgs) { + throw _exception( + "Only $maxArgs ${pluralize('argument', maxArgs)} allowed, but " + "${node.arguments.positional.length} " + + pluralize('was', node.arguments.positional.length, + plural: 'were') + + " passed.", + node.span); + } + } + + switch (node.name.toLowerCase()) { + case "calc" || + "sqrt" || + "sin" || + "cos" || + "tan" || + "asin" || + "acos" || + "atan" || + "abs" || + "exp" || + "sign": + check(1); + case "min" || "max" || "hypot": + check(); + case "pow" || "atan2" || "log" || "mod" || "rem": + check(2); + case "round" || "clamp": + check(3); + case _: + throw UnsupportedError('Unknown calculation name "${node.name}".'); + } + } + /// Verifies that [args] all have compatible units that can be used for CSS /// calculations, and throws a [SassException] if not. /// @@ -2361,55 +2552,47 @@ final class _EvaluateVisitor /// Evaluates [node] as a component of a calculation. /// - /// If [inMinMax] is `true`, this allows unitless numbers to be added and + /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and /// subtracted with numbers with units, for backwards-compatibility with the - /// old global `min()` and `max()` functions. - Future _visitCalculationValue(Expression node, - {required bool inMinMax}) async { + /// old global `min()`, `max()`, `round()`, and `abs()` functions. + Future _visitCalculationExpression(Expression node, + {required bool inLegacySassFunction}) async { switch (node) { case ParenthesizedExpression(expression: var inner): - var result = await _visitCalculationValue(inner, inMinMax: inMinMax); - return inner is FunctionExpression && - inner.name.toLowerCase() == 'var' && - result is SassString && - !result.hasQuotes + var result = await _visitCalculationExpression(inner, + inLegacySassFunction: inLegacySassFunction); + return result is SassString ? SassString('(${result.text})', quotes: false) : result; - case StringExpression(text: Interpolation(asPlain: var text?)): + case StringExpression() when node.isCalculationSafe: assert(!node.hasQuotes); - return switch (text.toLowerCase()) { + return switch (node.text.asPlain?.toLowerCase()) { 'pi' => SassNumber(math.pi), 'e' => SassNumber(math.e), 'infinity' => SassNumber(double.infinity), '-infinity' => SassNumber(double.negativeInfinity), 'nan' => SassNumber(double.nan), - _ => SassString(text, quotes: false) + _ => SassString(await _performInterpolation(node.text), quotes: false) }; - // If there's actual interpolation, create a CalculationInterpolation. - // Otherwise, create an UnquotedString. The main difference is that - // UnquotedStrings don't get extra defensive parentheses. - case StringExpression(): - assert(!node.hasQuotes); - return CalculationInterpolation(await _performInterpolation(node.text)); - case BinaryOperationExpression(:var operator, :var left, :var right): + _checkWhitespaceAroundCalculationOperator(node); return await _addExceptionSpanAsync( node, () async => SassCalculation.operateInternal( - _binaryOperatorToCalculationOperator(operator), - await _visitCalculationValue(left, inMinMax: inMinMax), - await _visitCalculationValue(right, inMinMax: inMinMax), - inMinMax: inMinMax, + _binaryOperatorToCalculationOperator(operator, node), + await _visitCalculationExpression(left, + inLegacySassFunction: inLegacySassFunction), + await _visitCalculationExpression(right, + inLegacySassFunction: inLegacySassFunction), + inLegacySassFunction: inLegacySassFunction, simplify: !_inSupportsDeclaration)); - case _: - assert(node is NumberExpression || - node is CalculationExpression || - node is VariableExpression || - node is FunctionExpression || - node is IfExpression); + case NumberExpression() || + VariableExpression() || + FunctionExpression() || + IfExpression(): return switch (await node.accept(this)) { SassNumber result => result, SassCalculation result => result, @@ -2417,70 +2600,104 @@ final class _EvaluateVisitor var result => throw _exception( "Value $result can't be used in a calculation.", node.span) }; + + case ListExpression( + hasBrackets: false, + separator: ListSeparator.space, + contents: [_, _, ...] + ): + var elements = [ + for (var element in node.contents) + await _visitCalculationExpression(element, + inLegacySassFunction: inLegacySassFunction) + ]; + + _checkAdjacentCalculationValues(elements, node); + + for (var i = 0; i < elements.length; i++) { + if (elements[i] is CalculationOperation && + node.contents[i] is ParenthesizedExpression) { + elements[i] = SassString("(${elements[i]})", quotes: false); + } + } + + return SassString(elements.join(' '), quotes: false); + + case _: + assert(!node.isCalculationSafe); + throw _exception( + "This expression can't be used in a calculation.", node.span); + } + } + + /// Throws an error if [node] requires whitespace around its operator in a + /// calculation but doesn't have it. + void _checkWhitespaceAroundCalculationOperator( + BinaryOperationExpression node) { + if (node.operator != BinaryOperator.plus && + node.operator != BinaryOperator.minus) { + return; + } + + // We _should_ never be able to violate these conditions since we always + // parse binary operations from a single file, but it's better to be safe + // than have this crash bizarrely. + if (node.left.span.file != node.right.span.file) return; + if (node.left.span.end.offset >= node.right.span.start.offset) return; + + var textBetweenOperands = node.left.span.file + .getText(node.left.span.end.offset, node.right.span.start.offset); + var first = textBetweenOperands.codeUnitAt(0); + var last = textBetweenOperands.codeUnitAt(textBetweenOperands.length - 1); + if (!(first.isWhitespace || first == $slash) || + !(last.isWhitespace || last == $slash)) { + throw _exception( + '"+" and "-" must be surrounded by whitespace in calculations.', + node.operatorSpan); } } /// Returns the [CalculationOperator] that corresponds to [operator]. CalculationOperator _binaryOperatorToCalculationOperator( - BinaryOperator operator) => + BinaryOperator operator, BinaryOperationExpression node) => switch (operator) { BinaryOperator.plus => CalculationOperator.plus, BinaryOperator.minus => CalculationOperator.minus, BinaryOperator.times => CalculationOperator.times, BinaryOperator.dividedBy => CalculationOperator.dividedBy, - _ => throw UnsupportedError("Invalid calculation operator $operator.") + _ => throw _exception( + "This operation can't be used in a calculation.", node.operatorSpan) }; - Future visitColorExpression(ColorExpression node) async => - node.value; - - Future visitListExpression(ListExpression node) async => SassList( - await mapAsync( - node.contents, (Expression expression) => expression.accept(this)), - node.separator, - brackets: node.hasBrackets); - - Future visitMapExpression(MapExpression node) async { - var map = {}; - var keyNodes = {}; - for (var (key, value) in node.pairs) { - var keyValue = await key.accept(this); - var valueValue = await value.accept(this); - - var oldValue = map[keyValue]; - if (oldValue != null) { - var oldValueSpan = keyNodes[keyValue]?.span; - throw MultiSpanSassRuntimeException( - 'Duplicate key.', - key.span, - 'second key', - {if (oldValueSpan != null) oldValueSpan: 'first key'}, - _stackTrace(key.span)); - } - map[keyValue] = valueValue; - keyNodes[keyValue] = key; - } - return SassMap(map); - } - - Future visitFunctionExpression(FunctionExpression node) async { - var function = _addExceptionSpan( - node, () => _getFunction(node.name, namespace: node.namespace)); - - if (function == null) { - if (node.namespace != null) { - throw _exception("Undefined function.", node.span); + /// Throws an error if [elements] contains two adjacent non-string values. + void _checkAdjacentCalculationValues( + List elements, ListExpression node) { + assert(elements.length > 1); + + for (var i = 1; i < elements.length; i++) { + var previous = elements[i - 1]; + var current = elements[i]; + if (previous is SassString || current is SassString) continue; + + var previousNode = node.contents[i - 1]; + var currentNode = node.contents[i]; + if (currentNode + case UnaryOperationExpression( + operator: UnaryOperator.minus || UnaryOperator.plus + ) || + NumberExpression(value: < 0)) { + // `calc(1 -2)` parses as a space-separated list whose second value is a + // unary operator or a negative number, but just saying it's an invalid + // expression doesn't help the user understand what's going wrong. We + // add special case error handling to help clarify the issue. + throw _exception( + '"+" and "-" must be surrounded by whitespace in calculations.', + currentNode.span.subspan(0, 1)); + } else { + throw _exception('Missing math operator.', + previousNode.span.expand(currentNode.span)); } - - function = PlainCssCallable(node.originalName); } - - var oldInFunction = _inFunction; - _inFunction = true; - var result = await _addErrorSpan( - node, () => _runFunctionCallable(node.arguments, function, node)); - _inFunction = oldInFunction; - return result; } Future visitInterpolatedFunctionExpression( @@ -2494,14 +2711,6 @@ final class _EvaluateVisitor return result; } - /// Like `_environment.getFunction`, but also returns built-in - /// globally-available functions. - AsyncCallable? _getFunction(String name, {String? namespace}) { - var local = _environment.getFunction(name, namespace: namespace); - if (local != null || namespace != null) return local; - return _builtInFunctions[name]; - } - /// Evaluates the arguments in [arguments] as applied to [callable], and /// invokes [run] in a scope with those arguments defined. Future _runUserDefinedCallable( diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index a8639f4e6..521e6cd02 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: 6eb7f76735562eba91e9460af796b269b3b0aaf7 +// Checksum: ccd4ec1a65cfc2487fccd30481d427086f5c76cc // // ignore_for_file: unused_import @@ -49,6 +49,7 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/character.dart'; import '../util/map.dart'; import '../util/multi_span.dart'; import '../util/nullable.dart'; @@ -438,10 +439,14 @@ final class _EvaluateVisitor return SassFunction(PlainCssCallable(name.text)); } - var callable = _addExceptionSpan( - _callableNode!, - () => _getFunction(name.text.replaceAll("_", "-"), - namespace: module?.text)); + var callable = _addExceptionSpan(_callableNode!, () { + var normalizedName = name.text.replaceAll("_", "-"); + var namespace = module?.text; + var local = + _environment.getFunction(normalizedName, namespace: namespace); + if (local != null || namespace != null) return local; + return _builtInFunctions[normalizedName]; + }); if (callable == null) throw "Function not found: $name"; return SassFunction(callable); @@ -2170,6 +2175,13 @@ final class _EvaluateVisitor // ## Expressions Value visitBinaryOperationExpression(BinaryOperationExpression node) { + if (_stylesheet.plainCss && + node.operator != BinaryOperator.singleEquals && + node.operator != BinaryOperator.dividedBy) { + throw _exception( + "Operators aren't allowed in plain CSS.", node.operatorSpan); + } + return _addExceptionSpan(node, () { var left = node.left.accept(this); return switch (node.operator) { @@ -2200,7 +2212,10 @@ final class _EvaluateVisitor Value _slash(Value left, Value right, BinaryOperationExpression node) { var result = left.dividedBy(right); switch ((left, right)) { - case (SassNumber left, SassNumber right) when node.allowsSlash: + case (SassNumber left, SassNumber right) + when node.allowsSlash && + _operandAllowsSlash(node.left) && + _operandAllowsSlash(node.right): return (result as SassNumber).withSlash(left, right); case (SassNumber(), SassNumber()): @@ -2233,6 +2248,20 @@ final class _EvaluateVisitor } } + /// Returns whether [node] can be used as a component of a slash-separated + /// number. + /// + /// Although this logic is mostly resolved at parse-time, we can't tell + /// whether operands will be evaluated as calculations until evaluation-time. + bool _operandAllowsSlash(Expression node) => + node is! FunctionExpression || + (node.namespace == null && + const { + "calc", "clamp", "hypot", "sin", "cos", "tan", "asin", "acos", // + "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", "log" + }.contains(node.name.toLowerCase()) && + _environment.getFunction(node.name) == null); + Value visitValueExpression(ValueExpression node) => node.value; Value visitVariableExpression(VariableExpression node) { @@ -2276,23 +2305,140 @@ final class _EvaluateVisitor SassNumber(node.value, node.unit); Value visitParenthesizedExpression(ParenthesizedExpression node) => - node.expression.accept(this); + _stylesheet.plainCss + ? throw _exception( + "Parentheses aren't allowed in plain CSS.", node.span) + : node.expression.accept(this); + + SassColor visitColorExpression(ColorExpression node) => node.value; - Value visitCalculationExpression(CalculationExpression node) { + SassList visitListExpression(ListExpression node) => SassList( + node.contents.map((Expression expression) => expression.accept(this)), + node.separator, + brackets: node.hasBrackets); + + SassMap visitMapExpression(MapExpression node) { + var map = {}; + var keyNodes = {}; + for (var (key, value) in node.pairs) { + var keyValue = key.accept(this); + var valueValue = value.accept(this); + + if (map.containsKey(keyValue)) { + var oldValueSpan = keyNodes[keyValue]?.span; + throw MultiSpanSassRuntimeException( + 'Duplicate key.', + key.span, + 'second key', + {if (oldValueSpan != null) oldValueSpan: 'first key'}, + _stackTrace(key.span)); + } + map[keyValue] = valueValue; + keyNodes[keyValue] = key; + } + return SassMap(map); + } + + Value visitFunctionExpression(FunctionExpression node) { + var function = _stylesheet.plainCss + ? null + : _addExceptionSpan( + node, + () => + _environment.getFunction(node.name, namespace: node.namespace)); + if (function == null) { + if (node.namespace != null) { + throw _exception("Undefined function.", node.span); + } + + switch (node.name.toLowerCase()) { + case "min" || "max" || "round" || "abs" + when node.arguments.named.isEmpty && + node.arguments.rest == null && + node.arguments.positional + .every((argument) => argument.isCalculationSafe): + return _visitCalculation(node, inLegacySassFunction: true); + + case "calc" || + "clamp" || + "hypot" || + "sin" || + "cos" || + "tan" || + "asin" || + "acos" || + "atan" || + "sqrt" || + "exp" || + "sign" || + "mod" || + "rem" || + "atan2" || + "pow" || + "log": + return _visitCalculation(node); + } + + function = (_stylesheet.plainCss ? null : _builtInFunctions[node.name]) ?? + PlainCssCallable(node.originalName); + } + + var oldInFunction = _inFunction; + _inFunction = true; + var result = _addErrorSpan( + node, () => _runFunctionCallable(node.arguments, function, node)); + _inFunction = oldInFunction; + return result; + } + + Value _visitCalculation(FunctionExpression node, + {bool inLegacySassFunction = false}) { + if (node.arguments.named.isNotEmpty) { + throw _exception( + "Keyword arguments can't be used with calculations.", node.span); + } else if (node.arguments.rest != null) { + throw _exception( + "Rest arguments can't be used with calculations.", node.span); + } + + _checkCalculationArguments(node); var arguments = [ - for (var argument in node.arguments) - _visitCalculationValue(argument, - inMinMax: node.name == 'min' || node.name == 'max') + for (var argument in node.arguments.positional) + _visitCalculationExpression(argument, + inLegacySassFunction: inLegacySassFunction) ]; if (_inSupportsDeclaration) { return SassCalculation.unsimplified(node.name, arguments); } try { - return switch (node.name) { + return switch (node.name.toLowerCase()) { "calc" => SassCalculation.calc(arguments[0]), + "sqrt" => SassCalculation.sqrt(arguments[0]), + "sin" => SassCalculation.sin(arguments[0]), + "cos" => SassCalculation.cos(arguments[0]), + "tan" => SassCalculation.tan(arguments[0]), + "asin" => SassCalculation.asin(arguments[0]), + "acos" => SassCalculation.acos(arguments[0]), + "atan" => SassCalculation.atan(arguments[0]), + "abs" => SassCalculation.abs(arguments[0]), + "exp" => SassCalculation.exp(arguments[0]), + "sign" => SassCalculation.sign(arguments[0]), "min" => SassCalculation.min(arguments), "max" => SassCalculation.max(arguments), + "hypot" => SassCalculation.hypot(arguments), + "pow" => + SassCalculation.pow(arguments[0], arguments.elementAtOrNull(1)), + "atan2" => + SassCalculation.atan2(arguments[0], arguments.elementAtOrNull(1)), + "log" => + SassCalculation.log(arguments[0], arguments.elementAtOrNull(1)), + "mod" => + SassCalculation.mod(arguments[0], arguments.elementAtOrNull(1)), + "rem" => + SassCalculation.rem(arguments[0], arguments.elementAtOrNull(1)), + "round" => SassCalculation.round(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') @@ -2301,11 +2447,54 @@ final class _EvaluateVisitor // The simplification logic in the [SassCalculation] static methods will // throw an error if the arguments aren't compatible, but we have access // to the original spans so we can throw a more informative error. - _verifyCompatibleNumbers(arguments, node.arguments); + if (error.message.contains("compatible")) { + _verifyCompatibleNumbers(arguments, node.arguments.positional); + } throwWithTrace(_exception(error.message, node.span), error, stackTrace); } } + /// Verifies that the calculation [node] has the correct number of arguments. + void _checkCalculationArguments(FunctionExpression node) { + void check([int? maxArgs]) { + if (node.arguments.positional.isEmpty) { + throw _exception("Missing argument.", node.span); + } else if (maxArgs != null && + node.arguments.positional.length > maxArgs) { + throw _exception( + "Only $maxArgs ${pluralize('argument', maxArgs)} allowed, but " + "${node.arguments.positional.length} " + + pluralize('was', node.arguments.positional.length, + plural: 'were') + + " passed.", + node.span); + } + } + + switch (node.name.toLowerCase()) { + case "calc" || + "sqrt" || + "sin" || + "cos" || + "tan" || + "asin" || + "acos" || + "atan" || + "abs" || + "exp" || + "sign": + check(1); + case "min" || "max" || "hypot": + check(); + case "pow" || "atan2" || "log" || "mod" || "rem": + check(2); + case "round" || "clamp": + check(3); + case _: + throw UnsupportedError('Unknown calculation name "${node.name}".'); + } + } + /// Verifies that [args] all have compatible units that can be used for CSS /// calculations, and throws a [SassException] if not. /// @@ -2343,54 +2532,47 @@ final class _EvaluateVisitor /// Evaluates [node] as a component of a calculation. /// - /// If [inMinMax] is `true`, this allows unitless numbers to be added and + /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and /// subtracted with numbers with units, for backwards-compatibility with the - /// old global `min()` and `max()` functions. - Object _visitCalculationValue(Expression node, {required bool inMinMax}) { + /// old global `min()`, `max()`, `round()`, and `abs()` functions. + Object _visitCalculationExpression(Expression node, + {required bool inLegacySassFunction}) { switch (node) { case ParenthesizedExpression(expression: var inner): - var result = _visitCalculationValue(inner, inMinMax: inMinMax); - return inner is FunctionExpression && - inner.name.toLowerCase() == 'var' && - result is SassString && - !result.hasQuotes + var result = _visitCalculationExpression(inner, + inLegacySassFunction: inLegacySassFunction); + return result is SassString ? SassString('(${result.text})', quotes: false) : result; - case StringExpression(text: Interpolation(asPlain: var text?)): + case StringExpression() when node.isCalculationSafe: assert(!node.hasQuotes); - return switch (text.toLowerCase()) { + return switch (node.text.asPlain?.toLowerCase()) { 'pi' => SassNumber(math.pi), 'e' => SassNumber(math.e), 'infinity' => SassNumber(double.infinity), '-infinity' => SassNumber(double.negativeInfinity), 'nan' => SassNumber(double.nan), - _ => SassString(text, quotes: false) + _ => SassString(_performInterpolation(node.text), quotes: false) }; - // If there's actual interpolation, create a CalculationInterpolation. - // Otherwise, create an UnquotedString. The main difference is that - // UnquotedStrings don't get extra defensive parentheses. - case StringExpression(): - assert(!node.hasQuotes); - return CalculationInterpolation(_performInterpolation(node.text)); - case BinaryOperationExpression(:var operator, :var left, :var right): + _checkWhitespaceAroundCalculationOperator(node); return _addExceptionSpan( node, () => SassCalculation.operateInternal( - _binaryOperatorToCalculationOperator(operator), - _visitCalculationValue(left, inMinMax: inMinMax), - _visitCalculationValue(right, inMinMax: inMinMax), - inMinMax: inMinMax, + _binaryOperatorToCalculationOperator(operator, node), + _visitCalculationExpression(left, + inLegacySassFunction: inLegacySassFunction), + _visitCalculationExpression(right, + inLegacySassFunction: inLegacySassFunction), + inLegacySassFunction: inLegacySassFunction, simplify: !_inSupportsDeclaration)); - case _: - assert(node is NumberExpression || - node is CalculationExpression || - node is VariableExpression || - node is FunctionExpression || - node is IfExpression); + case NumberExpression() || + VariableExpression() || + FunctionExpression() || + IfExpression(): return switch (node.accept(this)) { SassNumber result => result, SassCalculation result => result, @@ -2398,68 +2580,104 @@ final class _EvaluateVisitor var result => throw _exception( "Value $result can't be used in a calculation.", node.span) }; + + case ListExpression( + hasBrackets: false, + separator: ListSeparator.space, + contents: [_, _, ...] + ): + var elements = [ + for (var element in node.contents) + _visitCalculationExpression(element, + inLegacySassFunction: inLegacySassFunction) + ]; + + _checkAdjacentCalculationValues(elements, node); + + for (var i = 0; i < elements.length; i++) { + if (elements[i] is CalculationOperation && + node.contents[i] is ParenthesizedExpression) { + elements[i] = SassString("(${elements[i]})", quotes: false); + } + } + + return SassString(elements.join(' '), quotes: false); + + case _: + assert(!node.isCalculationSafe); + throw _exception( + "This expression can't be used in a calculation.", node.span); + } + } + + /// Throws an error if [node] requires whitespace around its operator in a + /// calculation but doesn't have it. + void _checkWhitespaceAroundCalculationOperator( + BinaryOperationExpression node) { + if (node.operator != BinaryOperator.plus && + node.operator != BinaryOperator.minus) { + return; + } + + // We _should_ never be able to violate these conditions since we always + // parse binary operations from a single file, but it's better to be safe + // than have this crash bizarrely. + if (node.left.span.file != node.right.span.file) return; + if (node.left.span.end.offset >= node.right.span.start.offset) return; + + var textBetweenOperands = node.left.span.file + .getText(node.left.span.end.offset, node.right.span.start.offset); + var first = textBetweenOperands.codeUnitAt(0); + var last = textBetweenOperands.codeUnitAt(textBetweenOperands.length - 1); + if (!(first.isWhitespace || first == $slash) || + !(last.isWhitespace || last == $slash)) { + throw _exception( + '"+" and "-" must be surrounded by whitespace in calculations.', + node.operatorSpan); } } /// Returns the [CalculationOperator] that corresponds to [operator]. CalculationOperator _binaryOperatorToCalculationOperator( - BinaryOperator operator) => + BinaryOperator operator, BinaryOperationExpression node) => switch (operator) { BinaryOperator.plus => CalculationOperator.plus, BinaryOperator.minus => CalculationOperator.minus, BinaryOperator.times => CalculationOperator.times, BinaryOperator.dividedBy => CalculationOperator.dividedBy, - _ => throw UnsupportedError("Invalid calculation operator $operator.") + _ => throw _exception( + "This operation can't be used in a calculation.", node.operatorSpan) }; - SassColor visitColorExpression(ColorExpression node) => node.value; - - SassList visitListExpression(ListExpression node) => SassList( - node.contents.map((Expression expression) => expression.accept(this)), - node.separator, - brackets: node.hasBrackets); - - SassMap visitMapExpression(MapExpression node) { - var map = {}; - var keyNodes = {}; - for (var (key, value) in node.pairs) { - var keyValue = key.accept(this); - var valueValue = value.accept(this); - - var oldValue = map[keyValue]; - if (oldValue != null) { - var oldValueSpan = keyNodes[keyValue]?.span; - throw MultiSpanSassRuntimeException( - 'Duplicate key.', - key.span, - 'second key', - {if (oldValueSpan != null) oldValueSpan: 'first key'}, - _stackTrace(key.span)); - } - map[keyValue] = valueValue; - keyNodes[keyValue] = key; - } - return SassMap(map); - } - - Value visitFunctionExpression(FunctionExpression node) { - var function = _addExceptionSpan( - node, () => _getFunction(node.name, namespace: node.namespace)); - - if (function == null) { - if (node.namespace != null) { - throw _exception("Undefined function.", node.span); + /// Throws an error if [elements] contains two adjacent non-string values. + void _checkAdjacentCalculationValues( + List elements, ListExpression node) { + assert(elements.length > 1); + + for (var i = 1; i < elements.length; i++) { + var previous = elements[i - 1]; + var current = elements[i]; + if (previous is SassString || current is SassString) continue; + + var previousNode = node.contents[i - 1]; + var currentNode = node.contents[i]; + if (currentNode + case UnaryOperationExpression( + operator: UnaryOperator.minus || UnaryOperator.plus + ) || + NumberExpression(value: < 0)) { + // `calc(1 -2)` parses as a space-separated list whose second value is a + // unary operator or a negative number, but just saying it's an invalid + // expression doesn't help the user understand what's going wrong. We + // add special case error handling to help clarify the issue. + throw _exception( + '"+" and "-" must be surrounded by whitespace in calculations.', + currentNode.span.subspan(0, 1)); + } else { + throw _exception('Missing math operator.', + previousNode.span.expand(currentNode.span)); } - - function = PlainCssCallable(node.originalName); } - - var oldInFunction = _inFunction; - _inFunction = true; - var result = _addErrorSpan( - node, () => _runFunctionCallable(node.arguments, function, node)); - _inFunction = oldInFunction; - return result; } Value visitInterpolatedFunctionExpression( @@ -2473,14 +2691,6 @@ final class _EvaluateVisitor return result; } - /// Like `_environment.getFunction`, but also returns built-in - /// globally-available functions. - Callable? _getFunction(String name, {String? namespace}) { - var local = _environment.getFunction(name, namespace: namespace); - if (local != null || namespace != null) return local; - return _builtInFunctions[name]; - } - /// Evaluates the arguments in [arguments] as applied to [callable], and /// invokes [run] in a scope with those arguments defined. V _runUserDefinedCallable( diff --git a/lib/src/visitor/expression_to_calc.dart b/lib/src/visitor/expression_to_calc.dart index 961735655..aca554355 100644 --- a/lib/src/visitor/expression_to_calc.dart +++ b/lib/src/visitor/expression_to_calc.dart @@ -10,9 +10,13 @@ import 'replace_expression.dart'; /// This assumes that [expression] already returns a number. It's intended for /// use in end-user messaging, and may not produce directly evaluable /// expressions. -CalculationExpression expressionToCalc(Expression expression) => - CalculationExpression.calc( - expression.accept(const _MakeExpressionCalculationSafe()), +FunctionExpression expressionToCalc(Expression expression) => + FunctionExpression( + "calc", + ArgumentInvocation( + [expression.accept(const _MakeExpressionCalculationSafe())], + const {}, + expression.span), expression.span); /// A visitor that replaces constructs that can't be used in a calculation with @@ -20,8 +24,6 @@ CalculationExpression expressionToCalc(Expression expression) => class _MakeExpressionCalculationSafe with ReplaceExpressionVisitor { const _MakeExpressionCalculationSafe(); - Expression visitCalculationExpression(CalculationExpression node) => node; - Expression visitBinaryOperationExpression(BinaryOperationExpression node) => node .operator == BinaryOperator.modulo diff --git a/lib/src/visitor/interface/expression.dart b/lib/src/visitor/interface/expression.dart index db5f70f32..7d40c87a2 100644 --- a/lib/src/visitor/interface/expression.dart +++ b/lib/src/visitor/interface/expression.dart @@ -12,7 +12,6 @@ import '../../ast/sass.dart'; abstract interface class ExpressionVisitor { T visitBinaryOperationExpression(BinaryOperationExpression node); T visitBooleanExpression(BooleanExpression node); - T visitCalculationExpression(CalculationExpression node); T visitColorExpression(ColorExpression node); T visitInterpolatedFunctionExpression(InterpolatedFunctionExpression node); T visitFunctionExpression(FunctionExpression node); diff --git a/lib/src/visitor/recursive_ast.dart b/lib/src/visitor/recursive_ast.dart index 0b31aafe2..290572697 100644 --- a/lib/src/visitor/recursive_ast.dart +++ b/lib/src/visitor/recursive_ast.dart @@ -33,12 +33,6 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor super.visitAtRule(node); } - void visitCalculationExpression(CalculationExpression node) { - for (var argument in node.arguments) { - argument.accept(this); - } - } - void visitContentRule(ContentRule node) { visitArgumentInvocation(node.arguments); } @@ -110,8 +104,6 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor super.visitMediaRule(node); } - void visitMixinRule(MixinRule node) => visitCallableDeclaration(node); - void visitReturnRule(ReturnRule node) { visitExpression(node.expression); } @@ -128,7 +120,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor void visitUseRule(UseRule node) { for (var variable in node.configuration) { - variable.expression.accept(this); + visitExpression(variable.expression); } } @@ -160,7 +152,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor void visitForwardRule(ForwardRule node) { for (var variable in node.configuration) { - variable.expression.accept(this); + visitExpression(variable.expression); } } diff --git a/lib/src/visitor/replace_expression.dart b/lib/src/visitor/replace_expression.dart index b330cfbbf..43d93eebc 100644 --- a/lib/src/visitor/replace_expression.dart +++ b/lib/src/visitor/replace_expression.dart @@ -22,10 +22,6 @@ import 'interface/expression.dart'; /// /// {@category Visitor} mixin ReplaceExpressionVisitor implements ExpressionVisitor { - Expression visitCalculationExpression(CalculationExpression node) => - CalculationExpression(node.name, - node.arguments.map((argument) => argument.accept(this)), node.span); - Expression visitBinaryOperationExpression(BinaryOperationExpression node) => BinaryOperationExpression( node.operator, node.left.accept(this), node.right.accept(this)); diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index c0c071155..f86d6c7fb 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -518,13 +518,9 @@ final class _SerializeVisitor case Value(): value.accept(this); - case CalculationInterpolation(): - _buffer.write(value.value); - case CalculationOperation(:var operator, :var left, :var right): - var parenthesizeLeft = left is CalculationInterpolation || - (left is CalculationOperation && - left.operator.precedence < operator.precedence); + var parenthesizeLeft = left is CalculationOperation && + left.operator.precedence < operator.precedence; if (parenthesizeLeft) _buffer.writeCharCode($lparen); _writeCalculationValue(left); if (parenthesizeLeft) _buffer.writeCharCode($rparen); @@ -534,8 +530,7 @@ final class _SerializeVisitor _buffer.write(operator.operator); if (operatorWhitespace) _buffer.writeCharCode($space); - var parenthesizeRight = right is CalculationInterpolation || - (right is CalculationOperation && + var parenthesizeRight = (right is CalculationOperation && _parenthesizeCalculationRhs(operator, right.operator)) || (operator == CalculationOperator.dividedBy && right is SassNumber && diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 1ba7b431c..d42f28444 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,11 @@ +## 9.0.0 + +* Remove the `CalculationExpression` class and the associated visitor methods. + +* Add an `AstSearchVisitor` helper class. + +* Add an `Interpolation.isPlain` getter. + ## 8.2.1 * No user-visible changes. diff --git a/pkg/sass_api/lib/sass_api.dart b/pkg/sass_api/lib/sass_api.dart index 1f4b076e3..b0369f908 100644 --- a/pkg/sass_api/lib/sass_api.dart +++ b/pkg/sass_api/lib/sass_api.dart @@ -23,6 +23,7 @@ export 'package:sass/src/visitor/find_dependencies.dart'; export 'package:sass/src/visitor/interface/expression.dart'; export 'package:sass/src/visitor/interface/selector.dart'; export 'package:sass/src/visitor/interface/statement.dart'; +export 'package:sass/src/visitor/ast_search.dart'; export 'package:sass/src/visitor/recursive_ast.dart'; export 'package:sass/src/visitor/recursive_selector.dart'; export 'package:sass/src/visitor/recursive_statement.dart'; diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 21f333de9..0528454f0 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 8.2.1 +version: 9.0.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: - sass: 1.66.1 + sass: 1.67.0 dev_dependencies: dartdoc: ^6.0.0 diff --git a/pubspec.yaml b/pubspec.yaml index 5c2a1698b..e9cceabb1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.67.0-dev +version: 1.67.0 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass diff --git a/test/dart_api/value/calculation_test.dart b/test/dart_api/value/calculation_test.dart index 594842cee..ed284db04 100644 --- a/test/dart_api/value/calculation_test.dart +++ b/test/dart_api/value/calculation_test.dart @@ -69,5 +69,12 @@ void main() { .assertNumber(), equals(SassNumber(8.5))); }); + + test('interpolation', () { + var result = SassCalculation.calc(CalculationInterpolation('1 + 2')) + .assertCalculation(); + expect(result.name, equals('calc')); + expect(result.arguments[0], equals(SassString('(1 + 2)'))); + }); }); } diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index af4bd62db..d927a780f 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -761,7 +761,7 @@ void main() { equals(Value_Calculation() ..name = "calc" ..arguments.add(Value_Calculation_CalculationValue() - ..interpolation = "var(--foo)"))); + ..string = "var(--foo)"))); }); test("with number arguments", () async { @@ -1429,7 +1429,7 @@ void main() { ..name = "calc" ..arguments.add(Value_Calculation_CalculationValue() ..interpolation = "var(--foo)"))), - "calc(var(--foo))"); + "calc((var(--foo)))"); }); test("with number arguments", () async {