Skip to content

Commit a604acd

Browse files
authored
[Function Name] Implement changes (#2731)
See sass/sass#4048
1 parent 5a81ae3 commit a604acd

File tree

11 files changed

+285
-24
lines changed

11 files changed

+285
-24
lines changed

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
## 1.99.0
2+
3+
* User-defined functions named `calc` or `clamp` are no longer forbidden. If
4+
such a function exists without a namespace in the current module, it will be
5+
used instead of the built-in `calc()` or `clamp()` function.
6+
7+
* User-defined functions whose names begin with `-` and end with `-expression`,
8+
`-url`, `-and`, `-or`, or `-not` are no longer forbidden. These were
9+
originally intended to match vendor prefixes, but in practice no vendor
10+
prefixes for these functions ever existed in real browsers.
11+
12+
* User-defined functions named `EXPRESSION`, `URL`, and `ELEMENT`, those that
13+
begin with `-` and end with `-ELEMENT`, as well as the same names with some
14+
lowercase letters are now deprecated, These are names conflict with plain CSS
15+
functions that have special syntax.
16+
17+
See [the Sass website](https://sass-lang.com/d/function-name) for details.
18+
19+
* In a future release, calls to functions whose names begin with `-` and end
20+
with `-expression` and `-url` will no longer have special parsing. For now,
21+
these calls are deprecated if their behavior will change in the future.
22+
23+
See [the Sass website](https://sass-lang.com/d/function-name) for details.
24+
25+
* Calls to functions whose names begin with `-` and end with `-progid:...` are
26+
deprecated.
27+
28+
See [the Sass website](https://sass-lang.com/d/function-name) for details.
29+
130
## 1.98.0
231

332
### Command-Line Interface

lib/src/ast/sass/expression.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../../exception.dart';
88
import '../../parse/scss.dart';
99
import '../../visitor/interface/expression.dart';
1010
import '../../visitor/is_calculation_safe.dart';
11+
import '../../visitor/is_plain_css.dart';
1112
import '../../visitor/source_interpolation.dart';
1213
import '../sass.dart';
1314

@@ -48,4 +49,12 @@ abstract class Expression implements SassNode {
4849
/// Throws a [SassFormatException] if parsing fails.
4950
factory Expression.parse(String contents, {Object? url}) =>
5051
ScssParser(contents, url: url).parseExpression().$1;
52+
53+
/// Whether this expression is valid plain CSS that will produce the same
54+
/// result as it would in Sass
55+
///
56+
/// If [allowInterpolation] is true, interpolated expressions are allowed as
57+
/// an exception, even if they contain SassScript.
58+
bool isPlainCss({bool allowInterpolation = false}) =>
59+
accept(IsPlainCssVisitor(allowInterpolation: allowInterpolation));
5160
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ final class StringExpression extends Expression {
5252

5353
/// Interpolation that, when evaluated, produces the syntax of this string.
5454
///
55-
/// Unlike [text], his doesn't resolve escapes and does include quotes for
55+
/// Unlike [text], this doesn't resolve escapes and does include quotes for
5656
/// quoted strings.
5757
///
5858
/// If [static] is true, this escapes any `#{` sequences in the string. If

lib/src/deprecation.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ enum Deprecation {
1515
// DO NOT EDIT. This section was generated from the language repo.
1616
// See tool/grind/generate_deprecations.dart for details.
1717
//
18-
// Checksum: 6fc524360d067b73c243c666e27a9a9ea7e08841
18+
// Checksum: 916d5fa5139e08988de6e29f7d8f7fab5e973b31
1919

2020
/// Deprecation for passing a string directly to meta.call().
2121
callString('call-string',
@@ -151,6 +151,11 @@ enum Deprecation {
151151
deprecatedIn: '1.95.0',
152152
description: 'The Sass if(\$condition, \$if-true, \$if-false) function.'),
153153

154+
/// Deprecation for uppercase reserved function names.
155+
functionName('function-name',
156+
deprecatedIn: '1.98.0',
157+
description: 'Uppercase reserved function names.'),
158+
154159
// END AUTOGENERATED CODE
155160

156161
/// Used for deprecations coming from user-authored code.

lib/src/parse/stylesheet.dart

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,25 @@ abstract class StylesheetParser extends Parser {
962962
spanFrom(beforeName));
963963
}
964964

965+
if (switch (name) {
966+
"expression" || "url" || "and" || "or" || "not" => true,
967+
_ => unvendor(name) == "element"
968+
}) {
969+
error("Invalid function name.", spanFrom(beforeName));
970+
} else if (switch (name.toLowerCase()) {
971+
"expression" || "url" => true,
972+
var name => unvendor(name) == "element"
973+
}) {
974+
warnings.add((
975+
deprecation: Deprecation.functionName,
976+
message: "Custom functions with this name are deprecated and will be "
977+
"removed in a future\n"
978+
"release. Please choose a different name.\n"
979+
"More info: https://sass-lang.com/d/function-name",
980+
span: spanFrom(beforeName)
981+
));
982+
}
983+
965984
whitespace(consumeNewlines: true);
966985
var parameters = _parameterList();
967986

@@ -977,18 +996,6 @@ abstract class StylesheetParser extends Parser {
977996
);
978997
}
979998

980-
if (unvendor(name)
981-
case "calc" ||
982-
"element" ||
983-
"expression" ||
984-
"url" ||
985-
"and" ||
986-
"or" ||
987-
"not" ||
988-
"clamp") {
989-
error("Invalid function name.", spanFrom(start));
990-
}
991-
992999
whitespace(consumeNewlines: false);
9931000
return _withChildren(
9941001
_functionChild,
@@ -3324,9 +3331,54 @@ abstract class StylesheetParser extends Parser {
33243331
..writeCharCode($lparen);
33253332
} else {
33263333
var normalized = unvendor(name);
3334+
var vendored = normalized != name;
33273335
switch (normalized) {
3328-
case "calc" when normalized != name && scanner.scanChar($lparen):
3329-
case "element" || "expression" when scanner.scanChar($lparen):
3336+
case "expression" when vendored && scanner.scanChar($lparen):
3337+
buffer = InterpolationBuffer()
3338+
..write(name)
3339+
..writeCharCode($lparen);
3340+
3341+
var beforeArg = scanner.state;
3342+
var invalidSassScript = false;
3343+
var nonCssSassScript = false;
3344+
try {
3345+
var argument = _expression();
3346+
nonCssSassScript = !argument.isPlainCss(allowInterpolation: true);
3347+
} on StringScannerException {
3348+
invalidSassScript = true;
3349+
}
3350+
scanner.state = beforeArg;
3351+
3352+
var value = _interpolatedDeclarationValue(allowEmpty: true);
3353+
buffer.addInterpolation(value);
3354+
scanner.expectChar($rparen);
3355+
buffer.writeCharCode($rparen);
3356+
3357+
if (invalidSassScript || nonCssSassScript) {
3358+
var suggestion =
3359+
StringExpression(value, quotes: true).asInterpolation();
3360+
warnings.add((
3361+
deprecation: Deprecation.functionName,
3362+
message: "Vendor-prefixed $normalized() functions will no longer "
3363+
"have special parsing in a future release of Dart Sass. "
3364+
"Once that happens, this argument will " +
3365+
(invalidSassScript
3366+
? "be parsed as SassScript. "
3367+
: "no longer be valid syntax. ") +
3368+
"To preserve current behavior:\n"
3369+
"\n"
3370+
"$name(#{$suggestion})\n"
3371+
"\n"
3372+
"More info: https://sass-lang.com/d/function-name",
3373+
span: spanFrom(start)
3374+
));
3375+
}
3376+
3377+
return StringExpression(buffer.interpolation(spanFrom(start)));
3378+
3379+
case "calc" when vendored && scanner.scanChar($lparen):
3380+
case "expression" when !vendored && scanner.scanChar($lparen):
3381+
case "element" when scanner.scanChar($lparen):
33303382
buffer = InterpolationBuffer()
33313383
..write(name)
33323384
..writeCharCode($lparen);
@@ -3343,9 +3395,36 @@ abstract class StylesheetParser extends Parser {
33433395
scanner.expectChar($lparen);
33443396
buffer.writeCharCode($lparen);
33453397

3398+
buffer.addInterpolation(
3399+
_interpolatedDeclarationValue(allowEmpty: true));
3400+
scanner.expectChar($rparen);
3401+
buffer.writeCharCode($rparen);
3402+
3403+
if (vendored) {
3404+
var suggestion = StringExpression(
3405+
buffer.interpolation(spanFrom(start)),
3406+
quotes: true)
3407+
.asInterpolation();
3408+
warnings.add((
3409+
deprecation: Deprecation.functionName,
3410+
message:
3411+
"Vendor-prefixed progid:...() functions will no longer be "
3412+
"supported in a future release of Dart Sass. To preserve "
3413+
"current behavior:\n"
3414+
"\n"
3415+
"#{$suggestion}\n"
3416+
"\n"
3417+
"More info: https://sass-lang.com/d/function-name",
3418+
span: spanFrom(start)
3419+
));
3420+
}
3421+
3422+
return StringExpression(buffer.interpolation(spanFrom(start)));
3423+
33463424
case "url":
33473425
return _tryUrlContents(
33483426
start,
3427+
vendored: vendored,
33493428
).andThen((contents) => StringExpression(contents));
33503429

33513430
case _:
@@ -3363,13 +3442,28 @@ abstract class StylesheetParser extends Parser {
33633442
/// Like [_urlContents], but returns `null` if the URL fails to parse.
33643443
///
33653444
/// [start] is the position before the beginning of the name. [name] is the
3366-
/// function's name; it defaults to `"url"`.
3367-
Interpolation? _tryUrlContents(LineScannerState start, {String? name}) {
3445+
/// function's name; it defaults to `"url"`. [vendored] is true if this is
3446+
/// being parsed in an expression context as a deprecated vendor-prefixed
3447+
/// `url()` expression.
3448+
Interpolation? _tryUrlContents(LineScannerState start,
3449+
{String? name, bool vendored = false}) {
33683450
// NOTE: this logic is largely duplicated in Parser.tryUrl. Most changes
33693451
// here should be mirrored there.
33703452

33713453
var beginningOfContents = scanner.state;
33723454
if (!scanner.scanChar($lparen)) return null;
3455+
3456+
var invalidSassScript = false;
3457+
if (vendored) {
3458+
var beforeArg = scanner.state;
3459+
try {
3460+
_expression();
3461+
} on StringScannerException {
3462+
invalidSassScript = true;
3463+
}
3464+
scanner.state = beforeArg;
3465+
}
3466+
33733467
whitespaceWithoutComments(consumeNewlines: true);
33743468

33753469
// Match Ruby Sass's behavior: parse a raw URL() if possible, and if not
@@ -3391,8 +3485,6 @@ abstract class StylesheetParser extends Parser {
33913485
$percent ||
33923486
$ampersand ||
33933487
$hash ||
3394-
// dart-lang/sdk#52740
3395-
// ignore: non_constant_relational_pattern_expression
33963488
(>= $asterisk && <= $tilde) ||
33973489
>= 0x80:
33983490
buffer.writeCharCode(scanner.readChar());
@@ -3401,6 +3493,26 @@ abstract class StylesheetParser extends Parser {
34013493
if (scanner.peekChar() != $rparen) break loop;
34023494
case $rparen:
34033495
buffer.writeCharCode(scanner.readChar());
3496+
3497+
if (vendored && invalidSassScript) {
3498+
var suggestion = StringExpression(
3499+
buffer.interpolation(spanFrom(start)),
3500+
quotes: true)
3501+
.asInterpolation();
3502+
warnings.add((
3503+
deprecation: Deprecation.functionName,
3504+
message: "Vendor-prefixed url() functions will no longer have "
3505+
"special parsing in a future release of Dart Sass. Once "
3506+
"that happens, this argument will be parsed as SassScript. "
3507+
"To preserve current behavior:\n"
3508+
"\n"
3509+
"$name(#{$suggestion})\n"
3510+
"\n"
3511+
"More info: https://sass-lang.com/d/function-name",
3512+
span: spanFrom(start)
3513+
));
3514+
}
3515+
34043516
return buffer.interpolation(spanFrom(start));
34053517
case _:
34063518
break loop;

lib/src/visitor/is_plain_css.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright 2026 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 'interface/expression.dart';
7+
import 'interface/if_condition_expression.dart';
8+
9+
// We could use [AstSearchVisitor] to implement this more tersely, but that
10+
// would default to returning `true` if we added a new expression type and
11+
// forgot to update this class.
12+
13+
/// A visitor that determines whether an expression is valid plain CSS that will
14+
/// produce the same result as it would in Sass.
15+
///
16+
/// This should be used through [Expression.isPlainCss].
17+
class IsPlainCssVisitor
18+
implements ExpressionVisitor<bool>, IfConditionExpressionVisitor<bool> {
19+
/// Whether to allow interpolation to as an exception to allowing plain CSS.
20+
final bool _allowInterpolation;
21+
22+
/// If [allowInterpolation] is true, interpolated expressions are allowed as
23+
/// an exception, even if they contain SassScript.
24+
const IsPlainCssVisitor({bool allowInterpolation = false})
25+
: _allowInterpolation = allowInterpolation;
26+
27+
bool visitBinaryOperationExpression(BinaryOperationExpression node) => false;
28+
29+
bool visitBooleanExpression(BooleanExpression node) => false;
30+
31+
bool visitColorExpression(ColorExpression node) => true;
32+
33+
bool visitFunctionExpression(FunctionExpression node) =>
34+
node.namespace == null && _visitArgumentList(node.arguments);
35+
36+
bool visitIfExpression(IfExpression node) =>
37+
node.branches.every((pair) => switch (pair) {
38+
(var condition?, var branch) =>
39+
condition.accept(this) && branch.accept(this),
40+
(_, var branch) => branch.accept(this),
41+
});
42+
43+
bool visitInterpolatedFunctionExpression(
44+
InterpolatedFunctionExpression node,
45+
) =>
46+
_allowInterpolation && _visitArgumentList(node.arguments);
47+
48+
bool visitLegacyIfExpression(LegacyIfExpression node) => false;
49+
50+
bool visitListExpression(ListExpression node) =>
51+
(node.contents.isNotEmpty || node.hasBrackets) &&
52+
node.contents.every((element) => element.accept(this));
53+
54+
bool visitMapExpression(MapExpression node) => false;
55+
56+
bool visitNullExpression(NullExpression node) => false;
57+
58+
bool visitNumberExpression(NumberExpression node) => true;
59+
60+
bool visitParenthesizedExpression(ParenthesizedExpression node) =>
61+
node.expression.accept(this);
62+
63+
bool visitSelectorExpression(SelectorExpression node) => false;
64+
65+
bool visitStringExpression(StringExpression node) =>
66+
_allowInterpolation || node.text.isPlain;
67+
68+
bool visitSupportsExpression(SupportsExpression node) => false;
69+
70+
bool visitUnaryOperationExpression(UnaryOperationExpression node) => false;
71+
72+
bool visitValueExpression(ValueExpression node) => false;
73+
74+
bool visitVariableExpression(VariableExpression node) => false;
75+
76+
bool visitIfConditionParenthesized(IfConditionParenthesized node) =>
77+
node.expression.accept(this);
78+
79+
bool visitIfConditionNegation(IfConditionNegation node) =>
80+
node.expression.accept(this);
81+
82+
bool visitIfConditionOperation(IfConditionOperation node) =>
83+
node.expressions.every((expression) => expression.accept(this));
84+
85+
bool visitIfConditionFunction(IfConditionFunction node) =>
86+
_allowInterpolation || (node.name.isPlain && node.arguments.isPlain);
87+
88+
bool visitIfConditionSass(IfConditionSass node) => false;
89+
90+
bool visitIfConditionRaw(IfConditionRaw node) =>
91+
_allowInterpolation || node.text.isPlain;
92+
93+
/// Returns whether [arguments] contains only plain CSS.
94+
bool _visitArgumentList(ArgumentList node) =>
95+
node.named.isEmpty &&
96+
node.rest == null &&
97+
node.positional.every((argument) => argument.accept(this));
98+
}

pkg/sass-parser/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.4.44
2+
3+
* No user-visible changes.
4+
15
## 0.4.43
26

37
* No user-visible changes.

0 commit comments

Comments
 (0)