Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sass-parser support for for the @supports rule #2378

Merged
merged 3 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
92 changes: 60 additions & 32 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.");
nex3 marked this conversation as resolved.
Show resolved Hide resolved
}

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.");
} 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