Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
## 1.94.3-dev
## 1.95.0

* Add support for the [CSS-style `if()` function]. In addition to supporting the
plain CSS syntax, this also supports a `sass()` query that takes a Sass
expression that evaluates to `true` or `false` at preprocessing time depending
on whether the Sass value is truthy. If there are no plain-CSS queries, the
function will return the first value whose query returns true during
preprocessing. For example, `if(sass(false): 1; sass(true): 2; else: 3)`
returns `2`.

[CSS-style `if()` function]: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/if

* The old Sass `if()` syntax is now deprecated. Users are encouraged to migrate
to the new CSS syntax. `if($condition, $if-true, $if-false)` can be changed to
`if(sass($condition): $if-true; else: $if-false)`.

See [the Sass website](https://sass-lang.com/d/css-if) for details.

* Plain-CSS `if()` functions are now considered "special numbers", meaning that
they can be used in place of arguments to CSS color functions.

* Plain-CSS `if()` functions and `attr()` functions are now considered "special
variable strings" (like `var()`), meaning they can now be used in place of
multiple arguments or syntax fragments in various CSS functions.

## 1.94.3

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

Expand Down
2 changes: 2 additions & 0 deletions lib/src/ast/sass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export 'sass/argument_list.dart';
export 'sass/at_root_query.dart';
export 'sass/boolean_operator.dart';
export 'sass/callable_invocation.dart';
export 'sass/configured_variable.dart';
export 'sass/declaration.dart';
Expand All @@ -15,6 +16,7 @@ export 'sass/expression/color.dart';
export 'sass/expression/function.dart';
export 'sass/expression/if.dart';
export 'sass/expression/interpolated_function.dart';
export 'sass/expression/legacy_if.dart';
export 'sass/expression/list.dart';
export 'sass/expression/map.dart';
export 'sass/expression/null.dart';
Expand Down
12 changes: 11 additions & 1 deletion lib/src/ast/sass/argument_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'package:sass/src/utils.dart';
import 'package:source_span/source_span.dart';

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

/// The spans for the arguments passed by name, including their argument names.
///
/// This always has the same keys as [named] in the same order.
final Map<String, FileSpan> namedSpans;

/// The first rest argument (as in `$args...`).
final Expression? rest;

Expand All @@ -34,18 +40,22 @@ final class ArgumentList implements SassNode {
ArgumentList(
Iterable<Expression> positional,
Map<String, Expression> named,
Map<String, FileSpan> namedSpans,
this.span, {
this.rest,
this.keywordRest,
}) : positional = List.unmodifiable(positional),
named = Map.unmodifiable(named) {
named = Map.unmodifiable(named),
namedSpans = Map.unmodifiable(namedSpans) {
assert(rest != null || keywordRest == null);
assert(iterableEquals(named.keys, namedSpans.keys));
}

/// Creates an invocation that passes no arguments.
ArgumentList.empty(this.span)
: positional = const [],
named = const {},
namedSpans = const {},
rest = null,
keywordRest = null;

Expand Down
13 changes: 13 additions & 0 deletions lib/src/ast/sass/boolean_operator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright 2025 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.

/// An enum for binary boolean operations.
///
/// Currently CSS only supports conjunctions (`and`) and disjunctions (`or`).
enum BooleanOperator {
and,
or;

String toString() => name;
}
266 changes: 251 additions & 15 deletions lib/src/ast/sass/expression/if.dart
Original file line number Diff line number Diff line change
@@ -1,33 +1,269 @@
// Copyright 2016 Google Inc. Use of this source code is governed by an
// Copyright 2025 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:charcode/charcode.dart';
import 'package:meta/meta.dart';
import 'package:source_span/source_span.dart';

import '../../../ast/node.dart';
import '../../../ast/sass.dart';
import '../../../interpolation_buffer.dart';
import '../../../util/lazy_file_span.dart';
import '../../../visitor/interface/expression.dart';
import '../../../visitor/interface/if_condition_expression.dart';

/// A ternary expression.
/// A CSS `if()` expression.
///
/// This is defined as a separate syntactic construct rather than a normal
/// function because only one of the `$if-true` and `$if-false` arguments are
/// evaluated.
/// In addition to supporting the plain-CSS syntax, this supports a `sass()`
/// condition that evaluates SassScript expressions.
///
/// {@category AST}
final class IfExpression extends Expression implements CallableInvocation {
/// The declaration of `if()`, as though it were a normal function.
static final declaration = ParameterList.parse(
r"@function if($condition, $if-true, $if-false) {",
);

/// The arguments passed to `if()`.
final ArgumentList arguments;
final class IfExpression extends Expression {
/// The conditional branches that make up the `if()`.
///
/// A `null` expression indicates an `else` branch that is always evaluated.
final List<(IfConditionExpression?, Expression)> branches;

final FileSpan span;

IfExpression(this.arguments, this.span);
IfExpression(
Iterable<(IfConditionExpression?, Expression)> branches, this.span)
: branches = List.unmodifiable(branches) {
if (this.branches.isEmpty) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if() with not branches is considered a valid (if not useful) plain CSS expression, at least according to MDN. Is the decision to require at least one branch in Sass intentional here?

throw ArgumentError.value(this.branches, "branches", "may not be empty");
}
}

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

String toString() => "if$arguments";
String toString() {
var buffer = StringBuffer("if(");
var first = true;
for (var (condition, expression) in branches) {
if (first) {
first = false;
} else {
buffer.write("; ");
}

buffer.write(condition ?? "else");
buffer.write(": ");
buffer.write(expression);
}
buffer.writeCharCode($rparen);
return buffer.toString();
}
}

/// The parent class of conditions in an [IfExpression].
///
/// {@category AST}
sealed class IfConditionExpression implements SassNode {
/// Returns whether this is an arbitrary substitution expression which may be
/// replaced with multiple tokens at evaluation or render time.
///
/// @nodoc
@internal
bool get isArbitrarySubstitution => false;

/// Converts this expression into an interpolation that produces the same
/// value.
///
/// Throws a [SourceSpanFormatException] if this contains an
/// [IfConditionSass]. [arbitrarySubstitution]'s span is used for this error.
///
/// @nodoc
@internal
Interpolation toInterpolation(AstNode arbitrarySubstitution);

/// Calls the appropriate visit method on [visitor].
T accept<T>(IfConditionExpressionVisitor<T> visitor);
}

/// A parenthesized condition.
///
/// {@category AST}
final class IfConditionParenthesized extends IfConditionExpression {
/// The parenthesized expression.
final IfConditionExpression expression;

final FileSpan span;

IfConditionParenthesized(this.expression, this.span);

/// @nodoc
@internal
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
(InterpolationBuffer()
..writeCharCode($lparen)
..addInterpolation(
expression.toInterpolation(arbitrarySubstitution))
..writeCharCode($rparen))
.interpolation(span);

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionParenthesized(this);

String toString() => "($expression)";
}

/// A negated condition.
///
/// {@category AST}
final class IfConditionNegation extends IfConditionExpression {
/// The expression negated by this.
final IfConditionExpression expression;

final FileSpan span;

IfConditionNegation(this.expression, this.span);

/// @nodoc
@internal
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
(InterpolationBuffer()
..write('not ')
..addInterpolation(
expression.toInterpolation(arbitrarySubstitution)))
.interpolation(span);

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionNegation(this);

String toString() => "not $expression";
}

/// A sequence of `and`s or `or`s.
///
/// {@category AST}
final class IfConditionOperation extends IfConditionExpression {
/// The expressions conjoined or disjoined by this operation.
final List<IfConditionExpression> expressions;

final BooleanOperator op;

FileSpan get span => expressions.first.span.expand(expressions.last.span);

IfConditionOperation(Iterable<IfConditionExpression> expressions, this.op)
: expressions = List.unmodifiable(expressions) {
if (this.expressions.length < 2) {
throw ArgumentError.value(
this.expressions, "expressions", "must have length >= 2");
}
}

/// @nodoc
@internal
Interpolation toInterpolation(AstNode arbitrarySubstitution) {
var buffer = InterpolationBuffer();
var first = true;
for (var expression in expressions) {
if (first) {
first = false;
} else {
buffer.write(' $op ');
}
buffer
.addInterpolation(expression.toInterpolation(arbitrarySubstitution));
}
return buffer.interpolation(LazyFileSpan(() => span));
}

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionOperation(this);

String toString() => expressions.join(" $op ");
}

/// A plain-CSS function-style condition.
///
/// {@category AST}
final class IfConditionFunction extends IfConditionExpression {
/// The name of the function being called.
final Interpolation name;

/// The arguments passed to the function call.
final Interpolation arguments;

final FileSpan span;

/// @nodoc
@internal
bool get isArbitrarySubstitution => switch (name.asPlain?.toLowerCase()) {
"if" || "var" || "attr" => true,
var str? when str.startsWith("--") => true,
_ => false,
};

IfConditionFunction(this.name, this.arguments, this.span);

/// @nodoc
@internal
Interpolation toInterpolation(AstNode _) => (InterpolationBuffer()
..addInterpolation(name)
..writeCharCode($lparen)
..addInterpolation(arguments)
..writeCharCode($rparen))
.interpolation(span);

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionFunction(this);

String toString() => "$name($arguments)";
}

/// A Sass condition that will evaluate to true or false at compile time.
///
/// {@category AST}
final class IfConditionSass extends IfConditionExpression {
/// The expression that determines whether this condition matches.
final Expression expression;

final FileSpan span;

IfConditionSass(this.expression, this.span);

/// @nodoc
@internal
Interpolation toInterpolation(AstNode arbitrarySubstitution) =>
throw MultiSourceSpanFormatException(
'if() conditions with arbitrary substitutions may not contain sass() '
'expressions.',
arbitrarySubstitution.span,
"arbitrary substitution",
{span: "sass() expression"});

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionSass(this);

String toString() => "sass($expression)";
}

/// A chunk of raw text, possibly with interpolations.
///
/// This is used to represent explicit interpolation, as well as whole
/// expressions where arbitrary substitutions are used in place of operators.
///
/// {@category AST}
final class IfConditionRaw extends IfConditionExpression {
/// The text that encompasses this condition.
final Interpolation text;

FileSpan get span => text.span;

/// @nodoc
@internal
bool get isArbitrarySubstitution => true;

IfConditionRaw(this.text);

/// @nodoc
@internal
Interpolation toInterpolation(AstNode _) => text;

T accept<T>(IfConditionExpressionVisitor<T> visitor) =>
visitor.visitIfConditionRaw(this);

String toString() => text.toString();
}
Loading