diff --git a/CHANGELOG.md b/CHANGELOG.md index 46559a243..b29326068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## 1.24.0 +* Add an optional `with` clause to the `@forward` rule. This works like the + `@use` rule's `with` clause, except that `@forward ... with` can declare + variables as `!default` to allow downstream modules to reconfigure their + values. + * Support configuring modules through `@import` rules. ## 1.23.8 diff --git a/lib/src/ast/sass.dart b/lib/src/ast/sass.dart index a8940af85..93f373b4e 100644 --- a/lib/src/ast/sass.dart +++ b/lib/src/ast/sass.dart @@ -7,6 +7,7 @@ export 'sass/argument_declaration.dart'; export 'sass/argument_invocation.dart'; export 'sass/at_root_query.dart'; export 'sass/callable_invocation.dart'; +export 'sass/configured_variable.dart'; export 'sass/expression.dart'; export 'sass/expression/binary_operation.dart'; export 'sass/expression/boolean.dart'; diff --git a/lib/src/ast/sass/configured_variable.dart b/lib/src/ast/sass/configured_variable.dart new file mode 100644 index 000000000..302c4dde2 --- /dev/null +++ b/lib/src/ast/sass/configured_variable.dart @@ -0,0 +1,30 @@ +// Copyright 2019 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:source_span/source_span.dart'; + +import 'expression.dart'; +import 'node.dart'; + +/// A variable configured by a `with` clause in a `@use` or `@forward` rule. +class ConfiguredVariable implements SassNode { + /// The name of the variable being configured. + final String name; + + /// The variable's value. + final Expression expression; + + /// Whether the variable can be further configured by outer modules. + /// + /// This is always `false` for `@use` rules. + final bool isGuarded; + + final FileSpan span; + + ConfiguredVariable(this.name, this.expression, this.span, + {bool guarded = false}) + : isGuarded = guarded; + + String toString() => "\$$name: $expression${isGuarded ? ' !default' : ''}"; +} diff --git a/lib/src/ast/sass/statement/forward_rule.dart b/lib/src/ast/sass/statement/forward_rule.dart index 3ca15bc2b..35c3bf181 100644 --- a/lib/src/ast/sass/statement/forward_rule.dart +++ b/lib/src/ast/sass/statement/forward_rule.dart @@ -6,6 +6,7 @@ import 'package:collection/collection.dart'; import 'package:source_span/source_span.dart'; import '../../../visitor/interface/statement.dart'; +import '../configured_variable.dart'; import '../expression/string.dart'; import '../statement.dart'; @@ -64,36 +65,46 @@ class ForwardRule implements Statement { /// module, or `null` if member names are used as-is. final String prefix; + /// A list of variable assignments used to configure the loaded modules. + final List configuration; + final FileSpan span; /// Creates a `@forward` rule that allows all members to be accessed. - ForwardRule(this.url, this.span, {this.prefix}) + ForwardRule(this.url, this.span, + {this.prefix, Iterable configuration}) : shownMixinsAndFunctions = null, shownVariables = null, hiddenMixinsAndFunctions = null, - hiddenVariables = null; + hiddenVariables = null, + configuration = + configuration == null ? const [] : List.unmodifiable(configuration); /// Creates a `@forward` rule that allows only members included in /// [shownMixinsAndFunctions] and [shownVariables] to be accessed. ForwardRule.show(this.url, Iterable shownMixinsAndFunctions, Iterable shownVariables, this.span, - {this.prefix}) + {this.prefix, Iterable configuration}) : shownMixinsAndFunctions = UnmodifiableSetView(Set.of(shownMixinsAndFunctions)), shownVariables = UnmodifiableSetView(Set.of(shownVariables)), hiddenMixinsAndFunctions = null, - hiddenVariables = null; + hiddenVariables = null, + configuration = + configuration == null ? const [] : List.unmodifiable(configuration); /// Creates a `@forward` rule that allows only members not included in /// [hiddenMixinsAndFunctions] and [hiddenVariables] to be accessed. ForwardRule.hide(this.url, Iterable hiddenMixinsAndFunctions, Iterable hiddenVariables, this.span, - {this.prefix}) + {this.prefix, Iterable configuration}) : shownMixinsAndFunctions = null, shownVariables = null, hiddenMixinsAndFunctions = UnmodifiableSetView(Set.of(hiddenMixinsAndFunctions)), - hiddenVariables = UnmodifiableSetView(Set.of(hiddenVariables)); + hiddenVariables = UnmodifiableSetView(Set.of(hiddenVariables)), + configuration = + configuration == null ? const [] : List.unmodifiable(configuration); T accept(StatementVisitor visitor) => visitor.visitForwardRule(this); @@ -112,6 +123,11 @@ class ForwardRule implements Statement { } if (prefix != null) buffer.write(" as $prefix*"); + + if (configuration.isNotEmpty) { + buffer.write(" with (${configuration.join(", ")})"); + } + buffer.write(";"); return buffer.toString(); } diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart index 6f77178dd..3a582c089 100644 --- a/lib/src/ast/sass/statement/use_rule.dart +++ b/lib/src/ast/sass/statement/use_rule.dart @@ -3,12 +3,11 @@ // https://opensource.org/licenses/MIT. import 'package:source_span/source_span.dart'; -import 'package:tuple/tuple.dart'; import '../../../logger.dart'; import '../../../parse/scss.dart'; import '../../../visitor/interface/statement.dart'; -import '../expression.dart'; +import '../configured_variable.dart'; import '../expression/string.dart'; import '../statement.dart'; @@ -23,15 +22,23 @@ class UseRule implements Statement { /// can be accessed without a namespace. final String namespace; - /// A map from variable names to their values and the spans for those - /// variables, used to configure the loaded modules. - final Map> configuration; + /// A list of variable assignments used to configure the loaded modules. + final List configuration; final FileSpan span; UseRule(this.url, this.namespace, this.span, - {Map> configuration}) - : configuration = Map.unmodifiable(configuration ?? const {}); + {Iterable configuration}) + : configuration = configuration == null + ? const [] + : List.unmodifiable(configuration) { + for (var variable in this.configuration) { + if (variable.isGuarded) { + throw ArgumentError.value(variable, "configured variable", + "can't be guarded in a @use rule."); + } + } + } /// Parses a `@use` rule from [contents]. /// @@ -54,11 +61,7 @@ class UseRule implements Statement { } if (configuration.isNotEmpty) { - buffer.write(" with ("); - buffer.write(configuration.entries - .map((entry) => "\$${entry.key}: ${entry.value.item1}") - .join(", ")); - buffer.write(")"); + buffer.write(" with (${configuration.join(", ")})"); } buffer.write(";"); diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 843194b05..242cb7d1d 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -962,6 +962,8 @@ abstract class StylesheetParser extends Parser { hiddenVariables = members.item2; } + var configuration = _configuration(allowGuarded: true); + expectStatementSeparator("@forward rule"); var span = scanner.spanFrom(start); if (!_isUseAllowed) { @@ -971,13 +973,14 @@ abstract class StylesheetParser extends Parser { if (shownMixinsAndFunctions != null) { return ForwardRule.show( url, shownMixinsAndFunctions, shownVariables, span, - prefix: prefix); + prefix: prefix, configuration: configuration); } else if (hiddenMixinsAndFunctions != null) { return ForwardRule.hide( url, hiddenMixinsAndFunctions, hiddenVariables, span, - prefix: prefix); + prefix: prefix, configuration: configuration); } else { - return ForwardRule(url, span, prefix: prefix); + return ForwardRule(url, span, + prefix: prefix, configuration: configuration); } } @@ -1350,7 +1353,7 @@ relase. For details, see http://bit.ly/moz-document. var namespace = _useNamespace(url, start); whitespace(); - var configuration = _useConfiguration(); + var configuration = _configuration(); expectStatementSeparator("@use rule"); @@ -1381,14 +1384,18 @@ relase. For details, see http://bit.ly/moz-document. } } - /// Returns the map from variable names to expressions from a `@use` rule's - /// `with` clause. + /// Returns the map from variable names to expressions from a `@use` or + /// `@forward` rule's `with` clause. + /// + /// If `allowGuarded` is `true`, this will allow configured variable with the + /// `!default` flag. /// /// Returns `null` if there is no `with` clause. - Map> _useConfiguration() { + List _configuration({bool allowGuarded = false}) { if (!scanIdentifier("with")) return null; - var configuration = >{}; + var variableNames = {}; + var configuration = []; whitespace(); scanner.expectChar($lparen); @@ -1401,12 +1408,25 @@ relase. For details, see http://bit.ly/moz-document. scanner.expectChar($colon); whitespace(); var expression = _expressionUntilComma(); - var span = scanner.spanFrom(variableStart); - if (configuration.containsKey(name)) { + var guarded = false; + var flagStart = scanner.state; + if (allowGuarded && scanner.scanChar($exclamation)) { + var flag = identifier(); + if (flag == 'default') { + guarded = true; + } else { + error("Invalid flag name.", scanner.spanFrom(flagStart)); + } + } + + var span = scanner.spanFrom(variableStart); + if (variableNames.contains(name)) { error("The same variable may only be configured once.", span); } - configuration[name] = Tuple2(expression, span); + variableNames.add(name); + configuration + .add(ConfiguredVariable(name, expression, span, guarded: guarded)); if (!scanner.scanChar($comma)) break; whitespace(); diff --git a/lib/src/util/merged_map_view.dart b/lib/src/util/merged_map_view.dart index 51c60c8f7..9dbb2d262 100644 --- a/lib/src/util/merged_map_view.dart +++ b/lib/src/util/merged_map_view.dart @@ -14,8 +14,9 @@ import '../utils.dart'; /// key. /// /// Unlike `CombinedMapView` from the `collection` package, this provides `O(1)` -/// index and `length` operations. It does so by imposing the additional -/// constraint that the underlying maps' sets of keys remain unchanged. +/// index and `length` operations and provides some degree of mutability. It +/// does so by imposing the additional constraint that the underlying maps' sets +/// of keys remain unchanged. class MergedMapView extends MapBase { // A map from keys to the maps in which those keys first appear. final _mapsByKey = >{}; diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index acac14cd8..de618423e 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -431,6 +431,8 @@ class _EvaluateVisitor baseUrl: _callableNode.span?.sourceUrl, configuration: configuration, namesInErrors: true); + _assertConfigurationIsEmpty(configuration, nameInError: true); + return null; }) ]; @@ -575,12 +577,11 @@ class _EvaluateVisitor /// Executes [stylesheet], loaded by [importer], to produce a module. /// /// If [configuration] is not passed, the current configuration is used - /// instead. Throws a [SassRuntimeException] if a configured variable is not - /// declared with `!default`. + /// instead. /// - /// If [namesInErrors] is `true`, this includes the names of modules or - /// configured variables in errors relating to them. This should only be - /// `true` if the names won't be obvious from the source span. + /// If [namesInErrors] is `true`, this includes the names of modules in errors + /// relating to them. This should only be `true` if the names won't be obvious + /// from the source span. Future _execute(AsyncImporter importer, Stylesheet stylesheet, {Configuration configuration, bool namesInErrors = false}) async { var url = stylesheet.span.sourceUrl; @@ -629,7 +630,6 @@ class _EvaluateVisitor _inUnknownAtRule = false; _atRootExcludingStyleRule = false; _inKeyframes = false; - if (configuration != null) _configuration = configuration; await visitStylesheet(stylesheet); @@ -650,18 +650,6 @@ class _EvaluateVisitor _inUnknownAtRule = oldInUnknownAtRule; _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; _inKeyframes = oldInKeyframes; - - if (configuration != null && - !_configuration.isEmpty && - !_configuration.isImplicit) { - throw _exception( - namesInErrors - ? "\$${_configuration.values.keys.first} was not declared with " - "!default in the @used module." - : "This variable was not declared with !default in the @used " - "module.", - _configuration.values.values.first.configurationSpan); - } _configuration = oldConfiguration; }); @@ -1199,16 +1187,93 @@ class _EvaluateVisitor Future visitForwardRule(ForwardRule node) async { var oldConfiguration = _configuration; - _configuration = _configuration.throughForward(node); - - await _loadModule(node.url, "@forward", node, (module) { - _environment.forwardModule(module, node); - }); + var adjustedConfiguration = oldConfiguration.throughForward(node); + + if (node.configuration.isNotEmpty) { + var newConfiguration = + await _addForwardConfiguration(adjustedConfiguration, node); + + await _loadModule(node.url, "@forward", node, (module) { + _environment.forwardModule(module, node); + }, configuration: newConfiguration); + + _removeUsedConfiguration(adjustedConfiguration, newConfiguration, + except: node.configuration.isEmpty + ? const {} + : { + for (var variable in node.configuration) + if (!variable.isGuarded) variable.name + }); + + _assertConfigurationIsEmpty(newConfiguration, + only: {for (var variable in node.configuration) variable.name}); + } else { + _configuration = adjustedConfiguration; + await _loadModule(node.url, "@forward", node, (module) { + _environment.forwardModule(module, node); + }); + _configuration = oldConfiguration; + } - _configuration = oldConfiguration; return null; } + /// Updates [configuration] to include [node]'s configuration and return the + /// result. + Future _addForwardConfiguration( + Configuration configuration, ForwardRule node) async { + var newValues = Map.of(configuration.values); + for (var variable in node.configuration) { + if (variable.isGuarded) { + var oldValue = configuration.remove(variable.name); + if (oldValue != null && oldValue.value != sassNull) { + newValues[variable.name] = oldValue; + continue; + } + } + + newValues[variable.name] = ConfiguredValue( + (await variable.expression.accept(this)).withoutSlash(), + variable.span, + _expressionNode(variable.expression)); + } + + return Configuration(newValues); + } + + /// Remove configured values from [upstream] that have been removed from + /// [downstream], unless they match a name in [except]. + void _removeUsedConfiguration( + Configuration upstream, Configuration downstream, + {@required Set except}) { + for (var name in upstream.values.keys.toList()) { + if (except.contains(name)) continue; + if (!downstream.values.containsKey(name)) upstream.remove(name); + } + } + + /// Throws an error if [configuration] contains any values. + /// + /// If [only] is passed, this will only throw an error for configured values + /// with the given names. + /// + /// If [nameInError] is `true`, this includes the name of the configured + /// variable in the error message. This should only be `true` if the name + /// won't be obvious from the source span. + void _assertConfigurationIsEmpty(Configuration configuration, + {Set only, bool nameInError = false}) { + configuration.values.forEach((name, value) { + if (only != null && !only.contains(name)) return; + + throw _exception( + nameInError + ? "\$$name was not declared with !default in the @used module." + : "This variable was not declared with !default in the @used " + "module.", + value.configurationSpan); + }); + } + Future visitFunctionRule(FunctionRule node) async { _environment.setFunction(UserDefinedCallable(node, _environment.closure())); return null; @@ -1754,18 +1819,20 @@ class _EvaluateVisitor } Future visitUseRule(UseRule node) async { + var configuration = node.configuration.isEmpty + ? const Configuration.empty() + : Configuration({ + for (var variable in node.configuration) + variable.name: ConfiguredValue( + (await variable.expression.accept(this)).withoutSlash(), + variable.span, + _expressionNode(variable.expression)) + }); + await _loadModule(node.url, "@use", node, (module) { _environment.addModule(module, namespace: node.namespace); - }, - configuration: node.configuration.isEmpty - ? const Configuration.empty() - : Configuration({ - for (var entry in node.configuration.entries) - entry.key: ConfiguredValue( - (await entry.value.item1.accept(this)).withoutSlash(), - entry.value.item2, - _expressionNode(entry.value.item1)) - })); + }, configuration: configuration); + _assertConfigurationIsEmpty(configuration); return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 7d8ca0df9..e605e997d 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: de58af7bb88d7a688632ff096930a5d2be263a6d +// Checksum: a1d9ccb1f21e0210717c3ecd86b6176c0543f44c // // ignore_for_file: unused_import @@ -437,6 +437,8 @@ class _EvaluateVisitor baseUrl: _callableNode.span?.sourceUrl, configuration: configuration, namesInErrors: true); + _assertConfigurationIsEmpty(configuration, nameInError: true); + return null; }) ]; @@ -579,12 +581,11 @@ class _EvaluateVisitor /// Executes [stylesheet], loaded by [importer], to produce a module. /// /// If [configuration] is not passed, the current configuration is used - /// instead. Throws a [SassRuntimeException] if a configured variable is not - /// declared with `!default`. + /// instead. /// - /// If [namesInErrors] is `true`, this includes the names of modules or - /// configured variables in errors relating to them. This should only be - /// `true` if the names won't be obvious from the source span. + /// If [namesInErrors] is `true`, this includes the names of modules in errors + /// relating to them. This should only be `true` if the names won't be obvious + /// from the source span. Module _execute(Importer importer, Stylesheet stylesheet, {Configuration configuration, bool namesInErrors = false}) { var url = stylesheet.span.sourceUrl; @@ -633,7 +634,6 @@ class _EvaluateVisitor _inUnknownAtRule = false; _atRootExcludingStyleRule = false; _inKeyframes = false; - if (configuration != null) _configuration = configuration; visitStylesheet(stylesheet); @@ -654,18 +654,6 @@ class _EvaluateVisitor _inUnknownAtRule = oldInUnknownAtRule; _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; _inKeyframes = oldInKeyframes; - - if (configuration != null && - !_configuration.isEmpty && - !_configuration.isImplicit) { - throw _exception( - namesInErrors - ? "\$${_configuration.values.keys.first} was not declared with " - "!default in the @used module." - : "This variable was not declared with !default in the @used " - "module.", - _configuration.values.values.first.configurationSpan); - } _configuration = oldConfiguration; }); @@ -1198,16 +1186,93 @@ class _EvaluateVisitor Value visitForwardRule(ForwardRule node) { var oldConfiguration = _configuration; - _configuration = _configuration.throughForward(node); - - _loadModule(node.url, "@forward", node, (module) { - _environment.forwardModule(module, node); - }); + var adjustedConfiguration = oldConfiguration.throughForward(node); + + if (node.configuration.isNotEmpty) { + var newConfiguration = + _addForwardConfiguration(adjustedConfiguration, node); + + _loadModule(node.url, "@forward", node, (module) { + _environment.forwardModule(module, node); + }, configuration: newConfiguration); + + _removeUsedConfiguration(adjustedConfiguration, newConfiguration, + except: node.configuration.isEmpty + ? const {} + : { + for (var variable in node.configuration) + if (!variable.isGuarded) variable.name + }); + + _assertConfigurationIsEmpty(newConfiguration, + only: {for (var variable in node.configuration) variable.name}); + } else { + _configuration = adjustedConfiguration; + _loadModule(node.url, "@forward", node, (module) { + _environment.forwardModule(module, node); + }); + _configuration = oldConfiguration; + } - _configuration = oldConfiguration; return null; } + /// Updates [configuration] to include [node]'s configuration and return the + /// result. + Configuration _addForwardConfiguration( + Configuration configuration, ForwardRule node) { + var newValues = Map.of(configuration.values); + for (var variable in node.configuration) { + if (variable.isGuarded) { + var oldValue = configuration.remove(variable.name); + if (oldValue != null && oldValue.value != sassNull) { + newValues[variable.name] = oldValue; + continue; + } + } + + newValues[variable.name] = ConfiguredValue( + variable.expression.accept(this).withoutSlash(), + variable.span, + _expressionNode(variable.expression)); + } + + return Configuration(newValues); + } + + /// Remove configured values from [upstream] that have been removed from + /// [downstream], unless they match a name in [except]. + void _removeUsedConfiguration( + Configuration upstream, Configuration downstream, + {@required Set except}) { + for (var name in upstream.values.keys.toList()) { + if (except.contains(name)) continue; + if (!downstream.values.containsKey(name)) upstream.remove(name); + } + } + + /// Throws an error if [configuration] contains any values. + /// + /// If [only] is passed, this will only throw an error for configured values + /// with the given names. + /// + /// If [nameInError] is `true`, this includes the name of the configured + /// variable in the error message. This should only be `true` if the name + /// won't be obvious from the source span. + void _assertConfigurationIsEmpty(Configuration configuration, + {Set only, bool nameInError = false}) { + configuration.values.forEach((name, value) { + if (only != null && !only.contains(name)) return; + + throw _exception( + nameInError + ? "\$$name was not declared with !default in the @used module." + : "This variable was not declared with !default in the @used " + "module.", + value.configurationSpan); + }); + } + Value visitFunctionRule(FunctionRule node) { _environment.setFunction(UserDefinedCallable(node, _environment.closure())); return null; @@ -1746,18 +1811,20 @@ class _EvaluateVisitor } Value visitUseRule(UseRule node) { + var configuration = node.configuration.isEmpty + ? const Configuration.empty() + : Configuration({ + for (var variable in node.configuration) + variable.name: ConfiguredValue( + variable.expression.accept(this).withoutSlash(), + variable.span, + _expressionNode(variable.expression)) + }); + _loadModule(node.url, "@use", node, (module) { _environment.addModule(module, namespace: node.namespace); - }, - configuration: node.configuration.isEmpty - ? const Configuration.empty() - : Configuration({ - for (var entry in node.configuration.entries) - entry.key: ConfiguredValue( - entry.value.item1.accept(this).withoutSlash(), - entry.value.item2, - _expressionNode(entry.value.item1)) - })); + }, configuration: configuration); + _assertConfigurationIsEmpty(configuration); return null; } diff --git a/lib/src/visitor/recursive_ast.dart b/lib/src/visitor/recursive_ast.dart index 5c4f11be3..3900253ad 100644 --- a/lib/src/visitor/recursive_ast.dart +++ b/lib/src/visitor/recursive_ast.dart @@ -29,6 +29,13 @@ abstract class RecursiveAstVisitor extends RecursiveStatementVisitor T visitColorExpression(ColorExpression node) => null; + T visitForwardRule(ForwardRule node) { + for (var variable in node.configuration) { + variable.expression.accept(this); + } + return null; + } + T visitFunctionExpression(FunctionExpression node) { visitInterpolation(node.name); visitArgumentInvocation(node.arguments); @@ -72,7 +79,12 @@ abstract class RecursiveAstVisitor extends RecursiveStatementVisitor T visitUnaryOperationExpression(UnaryOperationExpression node) => node.operand.accept(this); - T visitUseRule(UseRule node) => null; + T visitUseRule(UseRule node) { + for (var variable in node.configuration) { + variable.expression.accept(this); + } + return null; + } T visitValueExpression(ValueExpression node) => null;