Skip to content

Commit e16ca1a

Browse files
committed
Add support for plain-CSS if()
See sass/sass#3886
1 parent 5050680 commit e16ca1a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+6186
-155
lines changed

CHANGELOG.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
1-
## 1.94.3-dev
1+
## 1.95.0
2+
3+
* Add support for the [CSS-style `if()` function]. In addition to supporting the
4+
plain CSS syntax, this also supports a `sass()` query that takes a Sass
5+
expression that evaluates to `true` or `false` at preprocessing time depending
6+
on whether the Sass value is truthy. If there are no plain-CSS queries, the
7+
function will return the first value whose query returns true during
8+
preprocessing. For example, `if(sass(false): 1; sass(true): 2; else: 3)`
9+
returns `2`.
10+
11+
[CSS-style `if()` function]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/if
12+
13+
* The old Sass `if()` syntax is now deprecated. Users are encouraged to migrate
14+
to the new CSS syntax. `if($condition, $if-true, $if-false)` can be changed to
15+
`if(sass($condition): $if-true; else: $if-false)`.
16+
17+
See [the Sass website](https://sass-lang.com/d/css-if) for details.
18+
19+
* Plain-CSS `if()` functions are now considered "special numbers", meaning that
20+
they can be used in place of arguments to CSS color functions.
21+
22+
* Plain-CSS `if()` functions and `attr()` functions are now considered "special
23+
variable strings" (like `var()`), meaning they can now be used in place of
24+
multiple arguments or syntax fragments in various CSS functions.
225

326
* Fix the span reported for standalone `%` expressions followed by whitespace.
427

lib/src/ast/sass.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
export 'sass/argument_list.dart';
66
export 'sass/at_root_query.dart';
7+
export 'sass/boolean_operator.dart';
78
export 'sass/callable_invocation.dart';
89
export 'sass/configured_variable.dart';
910
export 'sass/declaration.dart';
@@ -13,6 +14,7 @@ export 'sass/expression/binary_operation.dart';
1314
export 'sass/expression/boolean.dart';
1415
export 'sass/expression/color.dart';
1516
export 'sass/expression/function.dart';
17+
export 'sass/expression/if.dart';
1618
export 'sass/expression/interpolated_function.dart';
1719
export 'sass/expression/legacy_if.dart';
1820
export 'sass/expression/list.dart';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright 2025 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+
/// An enum for binary boolean operations.
6+
///
7+
/// Currently CSS only supports conjunctions (`and`) and disjunctions (`or`).
8+
enum BooleanOperator {
9+
and,
10+
or;
11+
12+
String toString() => name;
13+
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright 2025 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 'package:charcode/charcode.dart';
6+
import 'package:meta/meta.dart';
7+
import 'package:source_span/source_span.dart';
8+
9+
import '../../../ast/node.dart';
10+
import '../../../ast/sass.dart';
11+
import '../../../interpolation_buffer.dart';
12+
import '../../../util/lazy_file_span.dart';
13+
import '../../../visitor/interface/expression.dart';
14+
import '../../../visitor/interface/if_condition_expression.dart';
15+
16+
/// A CSS `if()` expression.
17+
///
18+
/// In addition to supporting the plain-CSS syntax, this supports a `sass()`
19+
/// condition that evaluates SassScript expressions.
20+
///
21+
/// {@category AST}
22+
final class IfExpression extends Expression {
23+
/// The conditional branches that make up the `if()`.
24+
///
25+
/// A `null` expression indicates an `else` branch that is always evaluated.
26+
final List<(IfConditionExpression?, Expression)> branches;
27+
28+
final FileSpan span;
29+
30+
IfExpression(
31+
Iterable<(IfConditionExpression?, Expression)> branches, this.span)
32+
: branches = List.unmodifiable(branches) {
33+
if (this.branches.isEmpty) {
34+
throw ArgumentError.value(this.branches, "branches", "may not be empty");
35+
}
36+
}
37+
38+
T accept<T>(ExpressionVisitor<T> visitor) => visitor.visitIfExpression(this);
39+
40+
String toString() {
41+
var buffer = StringBuffer("if(");
42+
var first = true;
43+
for (var (condition, expression) in branches) {
44+
if (first) {
45+
first = false;
46+
} else {
47+
buffer.write("; ");
48+
}
49+
50+
buffer.write(condition ?? "else");
51+
buffer.write(": ");
52+
buffer.write(expression);
53+
}
54+
buffer.writeCharCode($rparen);
55+
return buffer.toString();
56+
}
57+
}
58+
59+
/// The parent class of conditions in an [IfExpression].
60+
///
61+
/// {@category AST}
62+
sealed class IfConditionExpression implements SassNode {
63+
/// Returns whether this is an arbitrary substitution expression which may be
64+
/// replaced with multiple tokens at evaluation or render time.
65+
///
66+
/// @nodoc
67+
@internal
68+
bool get isArbitrarySubstitution => false;
69+
70+
/// Converts this expression into an interpolation that produces the same
71+
/// value.
72+
///
73+
/// Throws a [SourceSpanFormatException] if this contains an
74+
/// [IfConditionSass]. [arbitrarySubstitution]'s span is used for this error.
75+
///
76+
/// @nodoc
77+
@internal
78+
Interpolation toInterpolation(AstNode arbitrarySubstitution);
79+
80+
/// Calls the appropriate visit method on [visitor].
81+
T accept<T>(IfConditionExpressionVisitor<T> visitor);
82+
}
83+
84+
/// A parenthesized condition.
85+
///
86+
/// {@category AST}
87+
final class IfConditionParenthesized extends IfConditionExpression {
88+
/// The parenthesized expression.
89+
final IfConditionExpression expression;
90+
91+
final FileSpan span;
92+
93+
IfConditionParenthesized(this.expression, this.span);
94+
95+
/// @nodoc
96+
@internal
97+
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
98+
(InterpolationBuffer()
99+
..writeCharCode($lparen)
100+
..addInterpolation(
101+
expression.toInterpolation(arbitrarySubstitution))
102+
..writeCharCode($rparen))
103+
.interpolation(span);
104+
105+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
106+
visitor.visitIfConditionParenthesized(this);
107+
108+
String toString() => "($expression)";
109+
}
110+
111+
/// A negated condition.
112+
///
113+
/// {@category AST}
114+
final class IfConditionNegation extends IfConditionExpression {
115+
/// The expression negated by this.
116+
final IfConditionExpression expression;
117+
118+
final FileSpan span;
119+
120+
IfConditionNegation(this.expression, this.span);
121+
122+
/// @nodoc
123+
@internal
124+
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
125+
(InterpolationBuffer()
126+
..write('not ')
127+
..addInterpolation(
128+
expression.toInterpolation(arbitrarySubstitution)))
129+
.interpolation(span);
130+
131+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
132+
visitor.visitIfConditionNegation(this);
133+
134+
String toString() => "not $expression";
135+
}
136+
137+
/// A sequence of `and`s or `or`s.
138+
///
139+
/// {@category AST}
140+
final class IfConditionOperation extends IfConditionExpression {
141+
/// The expressions conjoined or disjoined by this operation.
142+
final List<IfConditionExpression> expressions;
143+
144+
final BooleanOperator op;
145+
146+
FileSpan get span => expressions.first.span.expand(expressions.last.span);
147+
148+
IfConditionOperation(Iterable<IfConditionExpression> expressions, this.op)
149+
: expressions = List.unmodifiable(expressions) {
150+
if (this.expressions.length < 2) {
151+
throw ArgumentError.value(
152+
this.expressions, "expressions", "must have length >= 2");
153+
}
154+
}
155+
156+
/// @nodoc
157+
@internal
158+
Interpolation toInterpolation(AstNode arbitrarySubstitution) {
159+
var buffer = InterpolationBuffer();
160+
var first = true;
161+
for (var expression in expressions) {
162+
if (first) {
163+
first = false;
164+
} else {
165+
buffer.write(' $op ');
166+
}
167+
buffer
168+
.addInterpolation(expression.toInterpolation(arbitrarySubstitution));
169+
}
170+
return buffer.interpolation(LazyFileSpan(() => span));
171+
}
172+
173+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
174+
visitor.visitIfConditionOperation(this);
175+
176+
String toString() => expressions.join(" $op ");
177+
}
178+
179+
/// A plain-CSS function-style condition.
180+
///
181+
/// {@category AST}
182+
final class IfConditionFunction extends IfConditionExpression {
183+
/// The name of the function being called.
184+
final Interpolation name;
185+
186+
/// The arguments passed to the function call.
187+
final Interpolation arguments;
188+
189+
final FileSpan span;
190+
191+
/// @nodoc
192+
@internal
193+
bool get isArbitrarySubstitution => switch (name.asPlain?.toLowerCase()) {
194+
"if" || "var" || "attr" => true,
195+
var str? when str.startsWith("--") => true,
196+
_ => false,
197+
};
198+
199+
IfConditionFunction(this.name, this.arguments, this.span);
200+
201+
/// @nodoc
202+
@internal
203+
Interpolation toInterpolation(AstNode _) => (InterpolationBuffer()
204+
..addInterpolation(name)
205+
..writeCharCode($lparen)
206+
..addInterpolation(arguments)
207+
..writeCharCode($rparen))
208+
.interpolation(span);
209+
210+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
211+
visitor.visitIfConditionFunction(this);
212+
213+
String toString() => "$name($arguments)";
214+
}
215+
216+
/// A Sass condition that will evaluate to true or false at compile time.
217+
///
218+
/// {@category AST}
219+
final class IfConditionSass extends IfConditionExpression {
220+
/// The expression that determines whether this condition matches.
221+
final Expression expression;
222+
223+
final FileSpan span;
224+
225+
IfConditionSass(this.expression, this.span);
226+
227+
/// @nodoc
228+
@internal
229+
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
230+
throw MultiSourceSpanFormatException(
231+
'if() conditions with arbitrary substitutions may not contain sass() '
232+
'expressions.',
233+
arbitrarySubstitution.span,
234+
"arbitrary substitution",
235+
{span: "sass() expression"});
236+
237+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
238+
visitor.visitIfConditionSass(this);
239+
240+
String toString() => "sass($expression)";
241+
}
242+
243+
/// A chunk of raw text, possibly with interpolations.
244+
///
245+
/// This is used to represent explicit interpolation, as well as whole
246+
/// expressions where arbitrary substitutions are used in place of operators.
247+
///
248+
/// {@category AST}
249+
final class IfConditionRaw extends IfConditionExpression {
250+
/// The text that encompasses this condition.
251+
final Interpolation text;
252+
253+
FileSpan get span => text.span;
254+
255+
/// @nodoc
256+
@internal
257+
bool get isArbitrarySubstitution => true;
258+
259+
IfConditionRaw(this.text);
260+
261+
/// @nodoc
262+
@internal
263+
Interpolation toInterpolation(AstNode _) => text;
264+
265+
T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
266+
visitor.visitIfConditionRaw(this);
267+
268+
String toString() => text.toString();
269+
}

lib/src/ast/sass/expression/legacy_if.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// MIT-style license that can be found in the LICENSE file or at
33
// https://opensource.org/licenses/MIT.
44

5+
import 'package:meta/meta.dart';
56
import 'package:source_span/source_span.dart';
67

78
import '../../../ast/sass.dart';
@@ -26,6 +27,24 @@ final class LegacyIfExpression extends Expression
2627

2728
final FileSpan span;
2829

30+
/// Returns a modern `if()` expression to use instead of this.
31+
///
32+
/// @nodoc
33+
@internal
34+
String? get modernSuggestion => switch (arguments) {
35+
ArgumentList(
36+
positional: [var condition, var ifTrue, var ifFalse],
37+
named: Map(isEmpty: true),
38+
rest: null,
39+
) =>
40+
ifFalse is NullExpression
41+
? "if(sass($condition): $ifTrue)"
42+
: ifTrue is NullExpression
43+
? "if(not sass($condition): $ifFalse)"
44+
: "if(sass($condition): $ifTrue; else: $ifFalse)",
45+
_ => null,
46+
};
47+
2948
LegacyIfExpression(this.arguments, this.span);
3049

3150
T accept<T>(ExpressionVisitor<T> visitor) =>

lib/src/ast/sass/supports_condition/operation.dart

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:source_span/source_span.dart';
77

88
import '../../../interpolation_buffer.dart';
99
import '../../../util/span.dart';
10+
import '../boolean_operator.dart';
1011
import '../interpolation.dart';
1112
import '../supports_condition.dart';
1213
import 'negation.dart';
@@ -22,22 +23,11 @@ final class SupportsOperation implements SupportsCondition {
2223
final SupportsCondition right;
2324

2425
/// The operator.
25-
///
26-
/// Currently, this can be only `"and"` or `"or"`.
27-
final String operator;
26+
final BooleanOperator operator;
2827

2928
final FileSpan span;
3029

31-
SupportsOperation(this.left, this.right, this.operator, this.span) {
32-
var lowerOperator = operator.toLowerCase();
33-
if (lowerOperator != "and" && lowerOperator != "or") {
34-
throw ArgumentError.value(
35-
operator,
36-
'operator',
37-
'may only be "and" or "or".',
38-
);
39-
}
40-
}
30+
SupportsOperation(this.left, this.right, this.operator, this.span);
4131

4232
/// @nodoc
4333
@internal

0 commit comments

Comments
 (0)