Skip to content

Commit 2abc89e

Browse files
authored
Merge pull request #2693 from sass/css-if
Add support for plain-CSS if()
2 parents 2f7a16c + 38d4ac8 commit 2abc89e

Some content is hidden

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

68 files changed

+6264
-188
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
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.
25+
126
## 1.94.3
227

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

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';
@@ -15,6 +16,7 @@ export 'sass/expression/color.dart';
1516
export 'sass/expression/function.dart';
1617
export 'sass/expression/if.dart';
1718
export 'sass/expression/interpolated_function.dart';
19+
export 'sass/expression/legacy_if.dart';
1820
export 'sass/expression/list.dart';
1921
export 'sass/expression/map.dart';
2022
export 'sass/expression/null.dart';

lib/src/ast/sass/argument_list.dart

Lines changed: 11 additions & 1 deletion
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:sass/src/utils.dart';
56
import 'package:source_span/source_span.dart';
67

78
import '../../value/list.dart';
@@ -20,6 +21,11 @@ final class ArgumentList implements SassNode {
2021
/// The arguments passed by name.
2122
final Map<String, Expression> named;
2223

24+
/// The spans for the arguments passed by name, including their argument names.
25+
///
26+
/// This always has the same keys as [named] in the same order.
27+
final Map<String, FileSpan> namedSpans;
28+
2329
/// The first rest argument (as in `$args...`).
2430
final Expression? rest;
2531

@@ -34,18 +40,22 @@ final class ArgumentList implements SassNode {
3440
ArgumentList(
3541
Iterable<Expression> positional,
3642
Map<String, Expression> named,
43+
Map<String, FileSpan> namedSpans,
3744
this.span, {
3845
this.rest,
3946
this.keywordRest,
4047
}) : positional = List.unmodifiable(positional),
41-
named = Map.unmodifiable(named) {
48+
named = Map.unmodifiable(named),
49+
namedSpans = Map.unmodifiable(namedSpans) {
4250
assert(rest != null || keywordRest == null);
51+
assert(iterableEquals(named.keys, namedSpans.keys));
4352
}
4453

4554
/// Creates an invocation that passes no arguments.
4655
ArgumentList.empty(this.span)
4756
: positional = const [],
4857
named = const {},
58+
namedSpans = const {},
4959
rest = null,
5060
keywordRest = null;
5161

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: 251 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,269 @@
1-
// Copyright 2016 Google Inc. Use of this source code is governed by an
1+
// Copyright 2025 Google Inc. Use of this source code is governed by an
22
// MIT-style license that can be found in the LICENSE file or at
33
// https://opensource.org/licenses/MIT.
44

5+
import 'package:charcode/charcode.dart';
6+
import 'package:meta/meta.dart';
57
import 'package:source_span/source_span.dart';
68

9+
import '../../../ast/node.dart';
710
import '../../../ast/sass.dart';
11+
import '../../../interpolation_buffer.dart';
12+
import '../../../util/lazy_file_span.dart';
813
import '../../../visitor/interface/expression.dart';
14+
import '../../../visitor/interface/if_condition_expression.dart';
915

10-
/// A ternary expression.
16+
/// A CSS `if()` expression.
1117
///
12-
/// This is defined as a separate syntactic construct rather than a normal
13-
/// function because only one of the `$if-true` and `$if-false` arguments are
14-
/// evaluated.
18+
/// In addition to supporting the plain-CSS syntax, this supports a `sass()`
19+
/// condition that evaluates SassScript expressions.
1520
///
1621
/// {@category AST}
17-
final class IfExpression extends Expression implements CallableInvocation {
18-
/// The declaration of `if()`, as though it were a normal function.
19-
static final declaration = ParameterList.parse(
20-
r"@function if($condition, $if-true, $if-false) {",
21-
);
22-
23-
/// The arguments passed to `if()`.
24-
final ArgumentList arguments;
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;
2527

2628
final FileSpan span;
2729

28-
IfExpression(this.arguments, this.span);
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+
}
2937

3038
T accept<T>(ExpressionVisitor<T> visitor) => visitor.visitIfExpression(this);
3139

32-
String toString() => "if$arguments";
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();
33269
}

0 commit comments

Comments
 (0)