Skip to content

Commit d58e219

Browse files
nex3Goodwine
andauthored
Add sass-parser support for for the @supports rule (#2378)
Co-authored-by: Carlos (Goodwine) <[email protected]>
1 parent 5535d1f commit d58e219

30 files changed

+912
-193
lines changed

lib/src/ast/sass/expression.dart

Lines changed: 23 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,18 @@
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';
65
import 'package:meta/meta.dart';
76

87
import '../../exception.dart';
98
import '../../logger.dart';
109
import '../../parse/scss.dart';
11-
import '../../util/nullable.dart';
12-
import '../../value.dart';
1310
import '../../visitor/interface/expression.dart';
11+
import '../../visitor/is_calculation_safe.dart';
12+
import '../../visitor/source_interpolation.dart';
1413
import '../sass.dart';
1514

16-
// Note: despite not defining any methods here, this has to be a concrete class
17-
// so we can expose its accept() function to the JS parser.
15+
// Note: this has to be a concrete class so we can expose its accept() function
16+
// to the JS parser.
1817

1918
/// A SassScript expression in a Sass syntax tree.
2019
///
@@ -27,93 +26,30 @@ abstract class Expression implements SassNode {
2726

2827
Expression();
2928

30-
/// Parses an expression from [contents].
31-
///
32-
/// If passed, [url] is the name of the file from which [contents] comes.
33-
///
34-
/// Throws a [SassFormatException] if parsing fails.
35-
factory Expression.parse(String contents, {Object? url, Logger? logger}) =>
36-
ScssParser(contents, url: url, logger: logger).parseExpression();
37-
}
38-
39-
// Use an extension class rather than a method so we don't have to make
40-
// [Expression] a concrete base class for something we'll get rid of anyway once
41-
// we remove the global math functions that make this necessary.
42-
extension ExpressionExtensions on Expression {
4329
/// Whether this expression can be used in a calculation context.
4430
///
4531
/// @nodoc
4632
@internal
47-
bool get isCalculationSafe => accept(_IsCalculationSafeVisitor());
48-
}
49-
50-
// We could use [AstSearchVisitor] to implement this more tersely, but that
51-
// would default to returning `true` if we added a new expression type and
52-
// forgot to update this class.
53-
class _IsCalculationSafeVisitor implements ExpressionVisitor<bool> {
54-
const _IsCalculationSafeVisitor();
55-
56-
bool visitBinaryOperationExpression(BinaryOperationExpression node) =>
57-
(const {
58-
BinaryOperator.times,
59-
BinaryOperator.dividedBy,
60-
BinaryOperator.plus,
61-
BinaryOperator.minus
62-
}).contains(node.operator) &&
63-
(node.left.accept(this) || node.right.accept(this));
64-
65-
bool visitBooleanExpression(BooleanExpression node) => false;
66-
67-
bool visitColorExpression(ColorExpression node) => false;
68-
69-
bool visitFunctionExpression(FunctionExpression node) => true;
70-
71-
bool visitInterpolatedFunctionExpression(
72-
InterpolatedFunctionExpression node) =>
73-
true;
74-
75-
bool visitIfExpression(IfExpression node) => true;
76-
77-
bool visitListExpression(ListExpression node) =>
78-
node.separator == ListSeparator.space &&
79-
!node.hasBrackets &&
80-
node.contents.length > 1 &&
81-
node.contents.every((expression) => expression.accept(this));
82-
83-
bool visitMapExpression(MapExpression node) => false;
33+
bool get isCalculationSafe => accept(const IsCalculationSafeVisitor());
8434

85-
bool visitNullExpression(NullExpression node) => false;
86-
87-
bool visitNumberExpression(NumberExpression node) => true;
88-
89-
bool visitParenthesizedExpression(ParenthesizedExpression node) =>
90-
node.expression.accept(this);
91-
92-
bool visitSelectorExpression(SelectorExpression node) => false;
93-
94-
bool visitStringExpression(StringExpression node) {
95-
if (node.hasQuotes) return false;
96-
97-
// Exclude non-identifier constructs that are parsed as [StringExpression]s.
98-
// We could just check if they parse as valid identifiers, but this is
99-
// cheaper.
100-
var text = node.text.initialPlain;
101-
return
102-
// !important
103-
!text.startsWith("!") &&
104-
// ID-style identifiers
105-
!text.startsWith("#") &&
106-
// Unicode ranges
107-
text.codeUnitAtOrNull(1) != $plus &&
108-
// url()
109-
text.codeUnitAtOrNull(3) != $lparen;
35+
/// If this expression is valid interpolated plain CSS, returns the equivalent
36+
/// of parsing its source as an interpolated unknown value.
37+
///
38+
/// Otherwise, returns null.
39+
///
40+
/// @nodoc
41+
@internal
42+
Interpolation? get sourceInterpolation {
43+
var visitor = SourceInterpolationVisitor();
44+
accept(visitor);
45+
return visitor.buffer?.interpolation(span);
11046
}
11147

112-
bool visitSupportsExpression(SupportsExpression node) => false;
113-
114-
bool visitUnaryOperationExpression(UnaryOperationExpression node) => false;
115-
116-
bool visitValueExpression(ValueExpression node) => false;
117-
118-
bool visitVariableExpression(VariableExpression node) => true;
48+
/// Parses an expression from [contents].
49+
///
50+
/// If passed, [url] is the name of the file from which [contents] comes.
51+
///
52+
/// Throws a [SassFormatException] if parsing fails.
53+
factory Expression.parse(String contents, {Object? url, Logger? logger}) =>
54+
ScssParser(contents, url: url, logger: logger).parseExpression();
11955
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ final class StringExpression extends Expression {
4444

4545
/// Returns a string expression with no interpolation.
4646
StringExpression.plain(String text, FileSpan span, {bool quotes = false})
47-
: text = Interpolation([text], span),
47+
: text = Interpolation.plain(text, span),
4848
hasQuotes = quotes;
4949

5050
T accept<T>(ExpressionVisitor<T> visitor) =>
@@ -64,11 +64,12 @@ final class StringExpression extends Expression {
6464
quote ??= _bestQuote(text.contents.whereType<String>());
6565
var buffer = InterpolationBuffer();
6666
buffer.writeCharCode(quote);
67-
for (var value in text.contents) {
67+
for (var i = 0; i < text.contents.length; i++) {
68+
var value = text.contents[i];
6869
assert(value is Expression || value is String);
6970
switch (value) {
7071
case Expression():
71-
buffer.add(value);
72+
buffer.add(value, text.spanForElement(i));
7273
case String():
7374
_quoteInnerText(value, quote, buffer, static: static);
7475
}

lib/src/ast/sass/interpolation.dart

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import 'package:meta/meta.dart';
66
import 'package:source_span/source_span.dart';
77

8-
import '../../interpolation_buffer.dart';
98
import 'expression.dart';
109
import 'node.dart';
1110

@@ -19,6 +18,15 @@ final class Interpolation implements SassNode {
1918
/// [String]s.
2019
final List<Object /* String | Expression */ > contents;
2120

21+
/// The source spans for each [Expression] in [contents].
22+
///
23+
/// Unlike [Expression.span], which just covers the expresssion itself, this
24+
/// should go from `#{` through `}`.
25+
///
26+
/// @nodoc
27+
@internal
28+
final List<FileSpan?> spans;
29+
2230
final FileSpan span;
2331

2432
/// Returns whether this contains no interpolated expressions.
@@ -37,42 +45,62 @@ final class Interpolation implements SassNode {
3745
String get initialPlain =>
3846
switch (contents) { [String first, ...] => first, _ => '' };
3947

40-
/// Creates a new [Interpolation] by concatenating a sequence of [String]s,
41-
/// [Expression]s, or nested [Interpolation]s.
42-
static Interpolation concat(
43-
Iterable<Object /* String | Expression | Interpolation */ > contents,
44-
FileSpan span) {
45-
var buffer = InterpolationBuffer();
46-
for (var element in contents) {
47-
switch (element) {
48-
case String():
49-
buffer.write(element);
50-
case Expression():
51-
buffer.add(element);
52-
case Interpolation():
53-
buffer.addInterpolation(element);
54-
case _:
55-
throw ArgumentError.value(contents, "contents",
56-
"May only contains Strings, Expressions, or Interpolations.");
57-
}
58-
}
48+
/// Returns the [FileSpan] covering the element of the interpolation at
49+
/// [index].
50+
///
51+
/// Unlike `contents[index].span`, which only covers the text of the
52+
/// expression itself, this typically covers the entire `#{}` that surrounds
53+
/// the expression. However, this is not a strong guarantee—there are cases
54+
/// where interpolations are constructed when the source uses Sass expressions
55+
/// directly where this may return the same value as `contents[index].span`.
56+
///
57+
/// For string elements, this is the span that covers the entire text of the
58+
/// string, including the quote for text at the beginning or end of quoted
59+
/// strings. Note that the quote is *never* included for expressions.
60+
FileSpan spanForElement(int index) => switch (contents[index]) {
61+
String() => span.file.span(
62+
(index == 0 ? span.start : spans[index - 1]!.end).offset,
63+
(index == spans.length ? span.end : spans[index + 1]!.start)
64+
.offset),
65+
_ => spans[index]!
66+
};
5967

60-
return buffer.interpolation(span);
61-
}
68+
Interpolation.plain(String text, this.span)
69+
: contents = List.unmodifiable([text]),
70+
spans = const [null];
71+
72+
/// Creates a new [Interpolation] with the given [contents].
73+
///
74+
/// The [spans] must include a [FileSpan] for each [Expression] in [contents].
75+
/// These spans should generally cover the entire `#{}` surrounding the
76+
/// expression.
77+
///
78+
/// The single [span] must cover the entire interpolation.
79+
Interpolation(Iterable<Object /* String | Expression */ > contents,
80+
Iterable<FileSpan?> spans, this.span)
81+
: contents = List.unmodifiable(contents),
82+
spans = List.unmodifiable(spans) {
83+
if (spans.length != contents.length) {
84+
throw ArgumentError.value(
85+
this.spans, "spans", "Must be the same length as contents.");
86+
}
6287

63-
Interpolation(Iterable<Object /* String | Expression */ > contents, this.span)
64-
: contents = List.unmodifiable(contents) {
6588
for (var i = 0; i < this.contents.length; i++) {
66-
if (this.contents[i] is! String && this.contents[i] is! Expression) {
89+
var isString = this.contents[i] is String;
90+
if (!isString && this.contents[i] is! Expression) {
6791
throw ArgumentError.value(this.contents, "contents",
68-
"May only contains Strings or Expressions.");
69-
}
70-
71-
if (i != 0 &&
72-
this.contents[i - 1] is String &&
73-
this.contents[i] is String) {
74-
throw ArgumentError.value(
75-
this.contents, "contents", "May not contain adjacent Strings.");
92+
"May only contain Strings or Expressions.");
93+
} else if (isString) {
94+
if (i != 0 && this.contents[i - 1] is String) {
95+
throw ArgumentError.value(
96+
this.contents, "contents", "May not contain adjacent Strings.");
97+
} else if (i < spans.length && this.spans[i] != null) {
98+
throw ArgumentError.value(this.spans, "spans",
99+
"May not have a value for string elements (at index $i).");
100+
}
101+
} else if (i >= spans.length || this.spans[i] == null) {
102+
throw ArgumentError.value(this.spans, "spans",
103+
"Must not have a value for expression elements (at index $i).");
76104
}
77105
}
78106
}

lib/src/ast/sass/supports_condition.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@
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';
6+
import 'package:source_span/source_span.dart';
7+
8+
import 'interpolation.dart';
59
import 'node.dart';
610

711
/// An abstract class for defining the condition a `@supports` rule selects.
812
///
913
/// {@category AST}
10-
abstract interface class SupportsCondition implements SassNode {}
14+
abstract interface class SupportsCondition implements SassNode {
15+
/// Converts this condition into an interpolation that produces the same
16+
/// value.
17+
///
18+
/// @nodoc
19+
@internal
20+
Interpolation toInterpolation();
21+
22+
/// Returns a copy of this condition with [span] as its span.
23+
///
24+
/// @nodoc
25+
@internal
26+
SupportsCondition withSpan(FileSpan span);
27+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
// https://opensource.org/licenses/MIT.
44

55
import 'package:source_span/source_span.dart';
6+
import 'package:meta/meta.dart';
67

8+
import '../../../interpolation_buffer.dart';
9+
import '../../../util/span.dart';
710
import '../interpolation.dart';
811
import '../supports_condition.dart';
912

@@ -19,5 +22,17 @@ final class SupportsAnything implements SupportsCondition {
1922

2023
SupportsAnything(this.contents, this.span);
2124

25+
/// @nodoc
26+
@internal
27+
Interpolation toInterpolation() => (InterpolationBuffer()
28+
..write(span.before(contents.span).text)
29+
..addInterpolation(contents)
30+
..write(span.after(contents.span).text))
31+
.interpolation(span);
32+
33+
/// @nodoc
34+
@internal
35+
SupportsAnything withSpan(FileSpan span) => SupportsAnything(contents, span);
36+
2237
String toString() => "($contents)";
2338
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import 'package:meta/meta.dart';
66
import 'package:source_span/source_span.dart';
77

8+
import '../../../interpolation_buffer.dart';
9+
import '../../../util/span.dart';
810
import '../expression.dart';
911
import '../expression/string.dart';
12+
import '../interpolation.dart';
1013
import '../supports_condition.dart';
1114

1215
/// A condition that selects for browsers where a given declaration is
@@ -40,5 +43,32 @@ final class SupportsDeclaration implements SupportsCondition {
4043

4144
SupportsDeclaration(this.name, this.value, this.span);
4245

46+
/// @nodoc
47+
@internal
48+
Interpolation toInterpolation() {
49+
var buffer = InterpolationBuffer();
50+
buffer.write(span.before(name.span).text);
51+
if (name case StringExpression(hasQuotes: false, :var text)) {
52+
buffer.addInterpolation(text);
53+
} else {
54+
buffer.add(name, name.span);
55+
}
56+
57+
buffer.write(name.span.between(value.span).text);
58+
if (value.sourceInterpolation case var interpolation?) {
59+
buffer.addInterpolation(interpolation);
60+
} else {
61+
buffer.add(value, value.span);
62+
}
63+
64+
buffer.write(span.after(value.span).text);
65+
return buffer.interpolation(span);
66+
}
67+
68+
/// @nodoc
69+
@internal
70+
SupportsDeclaration withSpan(FileSpan span) =>
71+
SupportsDeclaration(name, value, span);
72+
4373
String toString() => "($name: $value)";
4474
}

0 commit comments

Comments
 (0)