Skip to content

Commit

Permalink
Add sass-parser support for for the @supports rule (#2378)
Browse files Browse the repository at this point in the history
Co-authored-by: Carlos (Goodwine) <2022649+Goodwine@users.noreply.github.com>
  • Loading branch information
nex3 and Goodwine authored Oct 10, 2024
1 parent 5535d1f commit d58e219
Show file tree
Hide file tree
Showing 30 changed files with 912 additions and 193 deletions.
110 changes: 23 additions & 87 deletions lib/src/ast/sass/expression.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@
// 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 '../../exception.dart';
import '../../logger.dart';
import '../../parse/scss.dart';
import '../../util/nullable.dart';
import '../../value.dart';
import '../../visitor/interface/expression.dart';
import '../../visitor/is_calculation_safe.dart';
import '../../visitor/source_interpolation.dart';
import '../sass.dart';

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

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

Expression();

/// Parses an expression from [contents].
///
/// If passed, [url] is the name of the file from which [contents] comes.
///
/// Throws a [SassFormatException] if parsing fails.
factory Expression.parse(String contents, {Object? url, Logger? logger}) =>
ScssParser(contents, url: url, logger: logger).parseExpression();
}

// Use an extension class rather than a method so we don't have to make
// [Expression] a concrete base class for something we'll get rid of anyway once
// we remove the global math functions that make this necessary.
extension ExpressionExtensions on Expression {
/// Whether this expression can be used in a calculation context.
///
/// @nodoc
@internal
bool get isCalculationSafe => accept(_IsCalculationSafeVisitor());
}

// We could use [AstSearchVisitor] to implement this more tersely, but that
// would default to returning `true` if we added a new expression type and
// forgot to update this class.
class _IsCalculationSafeVisitor implements ExpressionVisitor<bool> {
const _IsCalculationSafeVisitor();

bool visitBinaryOperationExpression(BinaryOperationExpression node) =>
(const {
BinaryOperator.times,
BinaryOperator.dividedBy,
BinaryOperator.plus,
BinaryOperator.minus
}).contains(node.operator) &&
(node.left.accept(this) || node.right.accept(this));

bool visitBooleanExpression(BooleanExpression node) => false;

bool visitColorExpression(ColorExpression node) => false;

bool visitFunctionExpression(FunctionExpression node) => true;

bool visitInterpolatedFunctionExpression(
InterpolatedFunctionExpression node) =>
true;

bool visitIfExpression(IfExpression node) => true;

bool visitListExpression(ListExpression node) =>
node.separator == ListSeparator.space &&
!node.hasBrackets &&
node.contents.length > 1 &&
node.contents.every((expression) => expression.accept(this));

bool visitMapExpression(MapExpression node) => false;
bool get isCalculationSafe => accept(const IsCalculationSafeVisitor());

bool visitNullExpression(NullExpression node) => false;

bool visitNumberExpression(NumberExpression node) => true;

bool visitParenthesizedExpression(ParenthesizedExpression node) =>
node.expression.accept(this);

bool visitSelectorExpression(SelectorExpression node) => false;

bool visitStringExpression(StringExpression node) {
if (node.hasQuotes) return false;

// Exclude non-identifier constructs that are parsed as [StringExpression]s.
// We could just check if they parse as valid identifiers, but this is
// cheaper.
var text = node.text.initialPlain;
return
// !important
!text.startsWith("!") &&
// ID-style identifiers
!text.startsWith("#") &&
// Unicode ranges
text.codeUnitAtOrNull(1) != $plus &&
// url()
text.codeUnitAtOrNull(3) != $lparen;
/// If this expression is valid interpolated plain CSS, returns the equivalent
/// of parsing its source as an interpolated unknown value.
///
/// Otherwise, returns null.
///
/// @nodoc
@internal
Interpolation? get sourceInterpolation {
var visitor = SourceInterpolationVisitor();
accept(visitor);
return visitor.buffer?.interpolation(span);
}

bool visitSupportsExpression(SupportsExpression node) => false;

bool visitUnaryOperationExpression(UnaryOperationExpression node) => false;

bool visitValueExpression(ValueExpression node) => false;

bool visitVariableExpression(VariableExpression node) => true;
/// Parses an expression from [contents].
///
/// If passed, [url] is the name of the file from which [contents] comes.
///
/// Throws a [SassFormatException] if parsing fails.
factory Expression.parse(String contents, {Object? url, Logger? logger}) =>
ScssParser(contents, url: url, logger: logger).parseExpression();
}
7 changes: 4 additions & 3 deletions lib/src/ast/sass/expression/string.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class StringExpression extends Expression {

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

T accept<T>(ExpressionVisitor<T> visitor) =>
Expand All @@ -64,11 +64,12 @@ final class StringExpression extends Expression {
quote ??= _bestQuote(text.contents.whereType<String>());
var buffer = InterpolationBuffer();
buffer.writeCharCode(quote);
for (var value in text.contents) {
for (var i = 0; i < text.contents.length; i++) {
var value = text.contents[i];
assert(value is Expression || value is String);
switch (value) {
case Expression():
buffer.add(value);
buffer.add(value, text.spanForElement(i));
case String():
_quoteInnerText(value, quote, buffer, static: static);
}
Expand Down
94 changes: 61 additions & 33 deletions lib/src/ast/sass/interpolation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import 'package:meta/meta.dart';
import 'package:source_span/source_span.dart';

import '../../interpolation_buffer.dart';
import 'expression.dart';
import 'node.dart';

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

/// The source spans for each [Expression] in [contents].
///
/// Unlike [Expression.span], which just covers the expresssion itself, this
/// should go from `#{` through `}`.
///
/// @nodoc
@internal
final List<FileSpan?> spans;

final FileSpan span;

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

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

return buffer.interpolation(span);
}
Interpolation.plain(String text, this.span)
: contents = List.unmodifiable([text]),
spans = const [null];

/// Creates a new [Interpolation] with the given [contents].
///
/// The [spans] must include a [FileSpan] for each [Expression] in [contents].
/// These spans should generally cover the entire `#{}` surrounding the
/// expression.
///
/// The single [span] must cover the entire interpolation.
Interpolation(Iterable<Object /* String | Expression */ > contents,
Iterable<FileSpan?> spans, this.span)
: contents = List.unmodifiable(contents),
spans = List.unmodifiable(spans) {
if (spans.length != contents.length) {
throw ArgumentError.value(
this.spans, "spans", "Must be the same length as contents.");
}

Interpolation(Iterable<Object /* String | Expression */ > contents, this.span)
: contents = List.unmodifiable(contents) {
for (var i = 0; i < this.contents.length; i++) {
if (this.contents[i] is! String && this.contents[i] is! Expression) {
var isString = this.contents[i] is String;
if (!isString && this.contents[i] is! Expression) {
throw ArgumentError.value(this.contents, "contents",
"May only contains Strings or Expressions.");
}

if (i != 0 &&
this.contents[i - 1] is String &&
this.contents[i] is String) {
throw ArgumentError.value(
this.contents, "contents", "May not contain adjacent Strings.");
"May only contain Strings or Expressions.");
} else if (isString) {
if (i != 0 && this.contents[i - 1] is String) {
throw ArgumentError.value(
this.contents, "contents", "May not contain adjacent Strings.");
} else if (i < spans.length && this.spans[i] != null) {
throw ArgumentError.value(this.spans, "spans",
"May not have a value for string elements (at index $i).");
}
} else if (i >= spans.length || this.spans[i] == null) {
throw ArgumentError.value(this.spans, "spans",
"Must not have a value for expression elements (at index $i).");
}
}
}
Expand Down
19 changes: 18 additions & 1 deletion lib/src/ast/sass/supports_condition.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,26 @@
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'package:meta/meta.dart';
import 'package:source_span/source_span.dart';

import 'interpolation.dart';
import 'node.dart';

/// An abstract class for defining the condition a `@supports` rule selects.
///
/// {@category AST}
abstract interface class SupportsCondition implements SassNode {}
abstract interface class SupportsCondition implements SassNode {
/// Converts this condition into an interpolation that produces the same
/// value.
///
/// @nodoc
@internal
Interpolation toInterpolation();

/// Returns a copy of this condition with [span] as its span.
///
/// @nodoc
@internal
SupportsCondition withSpan(FileSpan span);
}
15 changes: 15 additions & 0 deletions lib/src/ast/sass/supports_condition/anything.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
// https://opensource.org/licenses/MIT.

import 'package:source_span/source_span.dart';
import 'package:meta/meta.dart';

import '../../../interpolation_buffer.dart';
import '../../../util/span.dart';
import '../interpolation.dart';
import '../supports_condition.dart';

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

SupportsAnything(this.contents, this.span);

/// @nodoc
@internal
Interpolation toInterpolation() => (InterpolationBuffer()
..write(span.before(contents.span).text)
..addInterpolation(contents)
..write(span.after(contents.span).text))
.interpolation(span);

/// @nodoc
@internal
SupportsAnything withSpan(FileSpan span) => SupportsAnything(contents, span);

String toString() => "($contents)";
}
30 changes: 30 additions & 0 deletions lib/src/ast/sass/supports_condition/declaration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
import 'package:meta/meta.dart';
import 'package:source_span/source_span.dart';

import '../../../interpolation_buffer.dart';
import '../../../util/span.dart';
import '../expression.dart';
import '../expression/string.dart';
import '../interpolation.dart';
import '../supports_condition.dart';

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

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

/// @nodoc
@internal
Interpolation toInterpolation() {
var buffer = InterpolationBuffer();
buffer.write(span.before(name.span).text);
if (name case StringExpression(hasQuotes: false, :var text)) {
buffer.addInterpolation(text);
} else {
buffer.add(name, name.span);
}

buffer.write(name.span.between(value.span).text);
if (value.sourceInterpolation case var interpolation?) {
buffer.addInterpolation(interpolation);
} else {
buffer.add(value, value.span);
}

buffer.write(span.after(value.span).text);
return buffer.interpolation(span);
}

/// @nodoc
@internal
SupportsDeclaration withSpan(FileSpan span) =>
SupportsDeclaration(name, value, span);

String toString() => "($name: $value)";
}
Loading

0 comments on commit d58e219

Please sign in to comment.