Skip to content

Commit 13cc7d2

Browse files
authored
Improve the suggested replacements for unary minus in /-as-division (#1888)
Closes #1887
1 parent c8b4cd0 commit 13cc7d2

13 files changed

+280
-21
lines changed

Diff for: CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 1.58.2
2+
3+
* Print better `calc()`-based suggestions for `/`-as-division expression that
4+
contain calculation-incompatible constructs like unary minus.
5+
16
## 1.58.1
27

38
* Emit a unitless hue when serializing `hsl()` colors. The `deg` unit is

Diff for: lib/src/ast/sass/argument_invocation.dart

+18-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import 'package:meta/meta.dart';
66
import 'package:source_span/source_span.dart';
77

8+
import '../../value/list.dart';
89
import 'expression.dart';
10+
import 'expression/list.dart';
911
import 'node.dart';
1012

1113
/// A set of arguments passed in to a function or mixin.
@@ -46,12 +48,24 @@ class ArgumentInvocation implements SassNode {
4648
keywordRest = null;
4749

4850
String toString() {
51+
var rest = this.rest;
52+
var keywordRest = this.keywordRest;
4953
var components = [
50-
...positional,
51-
for (var name in named.keys) "\$$name: ${named[name]}",
52-
if (rest != null) "$rest...",
53-
if (keywordRest != null) "$keywordRest..."
54+
for (var argument in positional) _parenthesizeArgument(argument),
55+
for (var entry in named.entries)
56+
"\$${entry.key}: ${_parenthesizeArgument(entry.value)}",
57+
if (rest != null) "${_parenthesizeArgument(rest)}...",
58+
if (keywordRest != null) "${_parenthesizeArgument(keywordRest)}..."
5459
];
5560
return "(${components.join(', ')})";
5661
}
62+
63+
/// Wraps [argument] in parentheses if necessary.
64+
String _parenthesizeArgument(Expression argument) =>
65+
argument is ListExpression &&
66+
argument.separator == ListSeparator.comma &&
67+
!argument.hasBrackets &&
68+
argument.contents.length > 1
69+
? "($argument)"
70+
: argument.toString();
5771
}

Diff for: lib/src/ast/sass/expression/binary_operation.dart

+24-9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:source_span/source_span.dart';
88

99
import '../../../visitor/interface/expression.dart';
1010
import '../expression.dart';
11+
import 'list.dart';
1112

1213
/// A binary operator, as in `1 + 2` or `$this and $other`.
1314
///
@@ -64,8 +65,11 @@ class BinaryOperationExpression implements Expression {
6465
var buffer = StringBuffer();
6566

6667
var left = this.left; // Hack to make analysis work.
67-
var leftNeedsParens = left is BinaryOperationExpression &&
68-
left.operator.precedence < operator.precedence;
68+
var leftNeedsParens = (left is BinaryOperationExpression &&
69+
left.operator.precedence < operator.precedence) ||
70+
(left is ListExpression &&
71+
!left.hasBrackets &&
72+
left.contents.length > 1);
6973
if (leftNeedsParens) buffer.writeCharCode($lparen);
7074
buffer.write(left);
7175
if (leftNeedsParens) buffer.writeCharCode($rparen);
@@ -75,8 +79,12 @@ class BinaryOperationExpression implements Expression {
7579
buffer.writeCharCode($space);
7680

7781
var right = this.right; // Hack to make analysis work.
78-
var rightNeedsParens = right is BinaryOperationExpression &&
79-
right.operator.precedence <= operator.precedence;
82+
var rightNeedsParens = (right is BinaryOperationExpression &&
83+
right.operator.precedence <= operator.precedence &&
84+
!(right.operator == operator && operator.isAssociative)) ||
85+
(right is ListExpression &&
86+
!right.hasBrackets &&
87+
right.contents.length > 1);
8088
if (rightNeedsParens) buffer.writeCharCode($lparen);
8189
buffer.write(right);
8290
if (rightNeedsParens) buffer.writeCharCode($rparen);
@@ -93,10 +101,10 @@ enum BinaryOperator {
93101
singleEquals('single equals', '=', 0),
94102

95103
/// The disjunction operator, `or`.
96-
or('or', 'or', 1),
104+
or('or', 'or', 1, associative: true),
97105

98106
/// The conjunction operator, `and`.
99-
and('and', 'and', 2),
107+
and('and', 'and', 2, associative: true),
100108

101109
/// The equality operator, `==`.
102110
equals('equals', '==', 3),
@@ -117,13 +125,13 @@ enum BinaryOperator {
117125
lessThanOrEquals('less than or equals', '<=', 4),
118126

119127
/// The addition operator, `+`.
120-
plus('plus', '+', 5),
128+
plus('plus', '+', 5, associative: true),
121129

122130
/// The subtraction operator, `-`.
123131
minus('minus', '-', 5),
124132

125133
/// The multiplication operator, `*`.
126-
times('times', '*', 6),
134+
times('times', '*', 6, associative: true),
127135

128136
/// The division operator, `/`.
129137
dividedBy('divided by', '/', 6),
@@ -142,7 +150,14 @@ enum BinaryOperator {
142150
/// An operator with higher precedence binds tighter.
143151
final int precedence;
144152

145-
const BinaryOperator(this.name, this.operator, this.precedence);
153+
/// Whether this operation has the [associative property].
154+
///
155+
/// [associative property]: https://en.wikipedia.org/wiki/Associative_property
156+
final bool isAssociative;
157+
158+
const BinaryOperator(this.name, this.operator, this.precedence,
159+
{bool associative = false})
160+
: isAssociative = associative;
146161

147162
String toString() => name;
148163
}

Diff for: lib/src/ast/sass/expression/list.dart

+16-2
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,26 @@ class ListExpression implements Expression {
3737

3838
String toString() {
3939
var buffer = StringBuffer();
40-
if (hasBrackets) buffer.writeCharCode($lbracket);
40+
if (hasBrackets) {
41+
buffer.writeCharCode($lbracket);
42+
} else if (contents.isEmpty ||
43+
(contents.length == 1 && separator == ListSeparator.comma)) {
44+
buffer.writeCharCode($lparen);
45+
}
46+
4147
buffer.write(contents
4248
.map((element) =>
4349
_elementNeedsParens(element) ? "($element)" : element.toString())
4450
.join(separator == ListSeparator.comma ? ", " : " "));
45-
if (hasBrackets) buffer.writeCharCode($rbracket);
51+
52+
if (hasBrackets) {
53+
buffer.writeCharCode($rbracket);
54+
} else if (contents.isEmpty) {
55+
buffer.writeCharCode($rparen);
56+
} else if (contents.length == 1 && separator == ListSeparator.comma) {
57+
buffer.write(",)");
58+
}
59+
4660
return buffer.toString();
4761
}
4862

Diff for: lib/src/ast/sass/expression/unary_operation.dart

+10
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import 'package:source_span/source_span.dart';
88

99
import '../../../visitor/interface/expression.dart';
1010
import '../expression.dart';
11+
import 'binary_operation.dart';
12+
import 'list.dart';
1113

1214
/// A unary operator, as in `+$var` or `not fn()`.
1315
///
@@ -30,7 +32,15 @@ class UnaryOperationExpression implements Expression {
3032
String toString() {
3133
var buffer = StringBuffer(operator.operator);
3234
if (operator == UnaryOperator.not) buffer.writeCharCode($space);
35+
var operand = this.operand;
36+
var needsParens = operand is BinaryOperationExpression ||
37+
operand is UnaryOperationExpression ||
38+
(operand is ListExpression &&
39+
!operand.hasBrackets &&
40+
operand.contents.length > 1);
41+
if (needsParens) buffer.write($lparen);
3342
buffer.write(operand);
43+
if (needsParens) buffer.write($rparen);
3444
return buffer.toString();
3545
}
3646
}

Diff for: lib/src/visitor/async_evaluate.dart

+3-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import '../utils.dart';
4141
import '../util/multi_span.dart';
4242
import '../util/nullable.dart';
4343
import '../value.dart';
44+
import 'expression_to_calc.dart';
4445
import 'interface/css.dart';
4546
import 'interface/expression.dart';
4647
import 'interface/modifiable_css.dart';
@@ -2229,7 +2230,8 @@ class _EvaluateVisitor
22292230
"Using / for division outside of calc() is deprecated "
22302231
"and will be removed in Dart Sass 2.0.0.\n"
22312232
"\n"
2232-
"Recommendation: ${recommendation(node)} or calc($node)\n"
2233+
"Recommendation: ${recommendation(node)} or "
2234+
"${expressionToCalc(node)}\n"
22332235
"\n"
22342236
"More info and automated migrator: "
22352237
"https://sass-lang.com/d/slash-div",

Diff for: lib/src/visitor/evaluate.dart

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// DO NOT EDIT. This file was generated from async_evaluate.dart.
66
// See tool/grind/synchronize.dart for details.
77
//
8-
// Checksum: d5cb0fe933051782cbfb79ee3d65bc4353471f11
8+
// Checksum: d84fe267879d0fb034853a0a8a5105b2919916ec
99
//
1010
// ignore_for_file: unused_import
1111

@@ -50,6 +50,7 @@ import '../utils.dart';
5050
import '../util/multi_span.dart';
5151
import '../util/nullable.dart';
5252
import '../value.dart';
53+
import 'expression_to_calc.dart';
5354
import 'interface/css.dart';
5455
import 'interface/expression.dart';
5556
import 'interface/modifiable_css.dart';
@@ -2219,7 +2220,8 @@ class _EvaluateVisitor
22192220
"Using / for division outside of calc() is deprecated "
22202221
"and will be removed in Dart Sass 2.0.0.\n"
22212222
"\n"
2222-
"Recommendation: ${recommendation(node)} or calc($node)\n"
2223+
"Recommendation: ${recommendation(node)} or "
2224+
"${expressionToCalc(node)}\n"
22232225
"\n"
22242226
"More info and automated migrator: "
22252227
"https://sass-lang.com/d/slash-div",

Diff for: lib/src/visitor/expression_to_calc.dart

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2023 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import '../ast/sass.dart';
6+
import 'replace_expression.dart';
7+
8+
/// Converts [expression] to an equivalent `calc()`.
9+
///
10+
/// This assumes that [expression] already returns a number. It's intended for
11+
/// use in end-user messaging, and may not produce directly evaluable
12+
/// expressions.
13+
CalculationExpression expressionToCalc(Expression expression) =>
14+
CalculationExpression.calc(
15+
expression.accept(const _MakeExpressionCalculationSafe()),
16+
expression.span);
17+
18+
/// A visitor that replaces constructs that can't be used in a calculation with
19+
/// those that can.
20+
class _MakeExpressionCalculationSafe with ReplaceExpressionVisitor {
21+
const _MakeExpressionCalculationSafe();
22+
23+
Expression visitCalculationExpression(CalculationExpression node) => node;
24+
25+
Expression visitBinaryOperationExpression(BinaryOperationExpression node) => node
26+
.operator ==
27+
BinaryOperator.modulo
28+
// `calc()` doesn't support `%` for modulo but Sass doesn't yet support the
29+
// `mod()` calculation function because there's no browser support, so we have
30+
// to work around it by wrapping the call in a Sass function.
31+
? FunctionExpression(
32+
'max', ArgumentInvocation([node], const {}, node.span), node.span,
33+
namespace: 'math')
34+
: super.visitBinaryOperationExpression(node);
35+
36+
Expression visitInterpolatedFunctionExpression(
37+
InterpolatedFunctionExpression node) =>
38+
node;
39+
40+
Expression visitUnaryOperationExpression(UnaryOperationExpression node) {
41+
// `calc()` doesn't support unary operations.
42+
if (node.operator == UnaryOperator.plus) {
43+
return node.operand;
44+
} else if (node.operator == UnaryOperator.minus) {
45+
return BinaryOperationExpression(
46+
BinaryOperator.times, NumberExpression(-1, node.span), node.operand);
47+
} else {
48+
// Other unary operations don't produce numbers, so keep them as-is to
49+
// give the user a more useful syntax error after serialization.
50+
return super.visitUnaryOperationExpression(node);
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)