diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe47caa4..dd635060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +## 1.23.0 + +* **Launch the new Sass module system!** This adds: + + * The [`@use` rule][], which loads Sass files as *modules* and makes their + members available only in the current file, with automatic namespacing. + + [`@use` rule]: https://sass-lang.com/documentation/at-rules/use + + * The [`@forward` rule][], which makes members of another Sass file available + to stylesheets that `@use` the current file. + + [`@forward` rule]: https://sass-lang.com/documentation/at-rules/forward + + * Built-in modules named `sass:color`, `sass:list`, `sass:map`, `sass:math`, + `sass:meta`, `sass:selector`, and `sass:string` that provide access to all + the built-in Sass functions you know and love, with automatic module + namespaces. + + * The [`meta.load-css()` mixin][], which includes the CSS contents of a module + loaded from a (potentially dynamic) URL. + + [`meta.load-css()` function]: https://sass-lang.com/documentation/modules/meta#load-css + + * The [`meta.module-variables()` function][], which provides access to the + variables defined in a given module. + + [`meta.module-variables()` function]: https://sass-lang.com/documentation/modules/meta#module-variables + + * The [`meta.module-functions()` function][], which provides access to the + functions defined in a given module. + + [`meta.module-functions()` function]: https://sass-lang.com/documentation/modules/meta#module-functions + + Check out [the Sass blog][migrator blog] for more information on the new + module system. You can also use the new [Sass migrator][] to automatically + migrate your stylesheets to the new module system! + + [migrator blog]: https://sass-lang.com/blog/7858341-the-module-system-is-launched + [Sass migrator]: https://sass-lang.com/documentation/cli/migrator + ## 1.22.12 * **Potentially breaking bug fix:** character sequences consisting of two or diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart index 97532283b..4041b3306 100644 --- a/lib/src/ast/sass/statement/use_rule.dart +++ b/lib/src/ast/sass/statement/use_rule.dart @@ -3,8 +3,12 @@ // 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 '../expression/string.dart'; import '../statement.dart'; @@ -19,12 +23,45 @@ 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; + final FileSpan span; - UseRule(this.url, this.namespace, this.span); + UseRule(this.url, this.namespace, this.span, + {Map> configuration}) + : configuration = Map.unmodifiable(configuration ?? const {}); + + /// Parses a `@use` rule from [contents]. + /// + /// If passed, [url] is the name of the file from which [contents] comes. + /// + /// Throws a [SassFormatException] if parsing fails. + factory UseRule.parse(String contents, {url, Logger logger}) => + ScssParser(contents, url: url, logger: logger).parseUseRule(); T accept(StatementVisitor visitor) => visitor.visitUseRule(this); - String toString() => "@use ${StringExpression.quoteText(url.toString())} as " - "${namespace ?? "*"};"; + String toString() { + var buffer = + StringBuffer("@use ${StringExpression.quoteText(url.toString())}"); + + var basename = url.pathSegments.isEmpty ? "" : url.pathSegments.last; + var dot = basename.indexOf("."); + if (namespace != basename.substring(0, dot == -1 ? basename.length : dot)) { + buffer.write(" as ${namespace ?? "*"}"); + } + + if (configuration.isNotEmpty) { + buffer.write(" with ("); + buffer.write(configuration.entries + .map((entry) => "\$${entry.key}: ${entry.value.item1}") + .join(", ")); + buffer.write(")"); + } + + buffer.write(";"); + return buffer.toString(); + } } diff --git a/lib/src/ast/selector/placeholder.dart b/lib/src/ast/selector/placeholder.dart index b8e53b4b6..8c7a43f83 100644 --- a/lib/src/ast/selector/placeholder.dart +++ b/lib/src/ast/selector/placeholder.dart @@ -2,8 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:charcode/charcode.dart'; - +import '../../util/character.dart' as character; import '../../visitor/interface/selector.dart'; import '../selector.dart'; @@ -20,10 +19,7 @@ class PlaceholderSelector extends SimpleSelector { /// Returns whether this is a private selector (that is, whether it begins /// with `-` or `_`). - bool get isPrivate { - var start = name.codeUnitAt(0); - return start == $dash || start == $underscore; - } + bool get isPrivate => character.isPrivate(name); PlaceholderSelector(this.name); diff --git a/lib/src/async_environment.dart b/lib/src/async_environment.dart index f5a0fc7a7..e470b8c8e 100644 --- a/lib/src/async_environment.dart +++ b/lib/src/async_environment.dart @@ -48,9 +48,7 @@ class AsyncEnvironment { /// A list of variables defined at each lexical scope level. /// - /// Each scope maps the names of declared variables to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared variables to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -67,17 +65,12 @@ class AsyncEnvironment { /// A map of variable names to their indices in [_variables]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _variableIndices; /// A list of functions defined at each lexical scope level. /// - /// Each scope maps the names of declared functions to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared functions to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -85,17 +78,12 @@ class AsyncEnvironment { /// A map of function names to their indices in [_functions]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _functionIndices; /// A list of mixins defined at each lexical scope level. /// - /// Each scope maps the names of declared mixins to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared mixins to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -103,9 +91,6 @@ class AsyncEnvironment { /// A map of mixin names to their indices in [_mixins]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _mixinIndices; @@ -211,7 +196,7 @@ class AsyncEnvironment { /// with the same name as a variable defined in this environment. void addModule(Module module, {String namespace}) { if (namespace == null) { - _globalModules ??= Set(); + _globalModules ??= {}; _globalModules.add(module); _allModules.add(module); @@ -715,10 +700,14 @@ class AsyncEnvironment { T value; for (var module in _globalModules) { var valueInModule = callback(module); - if (valueInModule != null && value != null) { - // TODO(nweiz): List the module URLs. + if (valueInModule == null) continue; + + if (value != null) { throw SassScriptException( - 'This $type is available from multiple global modules.'); + 'This $type is available from multiple global modules:\n' + + bulletedList(_globalModules + .where((module) => callback(module) != null) + .map((module) => p.prettyUri(module.url)))); } value = valueInModule; diff --git a/lib/src/environment.dart b/lib/src/environment.dart index caa752a82..19db8373b 100644 --- a/lib/src/environment.dart +++ b/lib/src/environment.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_environment.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 0e6357eae4a02ebe64d8cdbf48a14797f98a418c +// Checksum: 2522d5fbfb9d301b361a9bacac97228de2c6fd68 // // ignore_for_file: unused_import @@ -54,9 +54,7 @@ class Environment { /// A list of variables defined at each lexical scope level. /// - /// Each scope maps the names of declared variables to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared variables to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -73,17 +71,12 @@ class Environment { /// A map of variable names to their indices in [_variables]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _variableIndices; /// A list of functions defined at each lexical scope level. /// - /// Each scope maps the names of declared functions to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared functions to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -91,17 +84,12 @@ class Environment { /// A map of function names to their indices in [_functions]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _functionIndices; /// A list of mixins defined at each lexical scope level. /// - /// Each scope maps the names of declared mixins to their values. These - /// maps are *normalized*, meaning that they treat hyphens and underscores in - /// its keys interchangeably. + /// Each scope maps the names of declared mixins to their values. /// /// The first element is the global scope, and each successive element is /// deeper in the tree. @@ -109,9 +97,6 @@ class Environment { /// A map of mixin names to their indices in [_mixins]. /// - /// This map is *normalized*, meaning that it treats hyphens and underscores - /// in its keys interchangeably. - /// /// This map is filled in as-needed, and may not be complete. final Map _mixinIndices; @@ -217,7 +202,7 @@ class Environment { /// with the same name as a variable defined in this environment. void addModule(Module module, {String namespace}) { if (namespace == null) { - _globalModules ??= Set(); + _globalModules ??= {}; _globalModules.add(module); _allModules.add(module); @@ -719,10 +704,14 @@ class Environment { T value; for (var module in _globalModules) { var valueInModule = callback(module); - if (valueInModule != null && value != null) { - // TODO(nweiz): List the module URLs. + if (valueInModule == null) continue; + + if (value != null) { throw SassScriptException( - 'This $type is available from multiple global modules.'); + 'This $type is available from multiple global modules:\n' + + bulletedList(_globalModules + .where((module) => callback(module) != null) + .map((module) => p.prettyUri(module.url)))); } value = valueInModule; diff --git a/lib/src/executable/options.dart b/lib/src/executable/options.dart index bb4cd6368..9f76ee761 100644 --- a/lib/src/executable/options.dart +++ b/lib/src/executable/options.dart @@ -138,8 +138,8 @@ class ExecutableOptions { if (!_interactive) return false; var invalidOptions = [ - 'stdin', 'indented', 'load-path', 'style', 'source-map', // - 'source-map-urls', 'embed-sources', 'embed-source-map', 'update', 'watch' + 'stdin', 'indented', 'style', 'source-map', 'source-map-urls', // + 'embed-sources', 'embed-source-map', 'update', 'watch' ]; for (var option in invalidOptions) { if (_options.wasParsed(option)) { diff --git a/lib/src/executable/repl.dart b/lib/src/executable/repl.dart index d69910163..642d5984e 100644 --- a/lib/src/executable/repl.dart +++ b/lib/src/executable/repl.dart @@ -11,32 +11,38 @@ import 'package:stack_trace/stack_trace.dart'; import '../ast/sass.dart'; import '../exception.dart'; import '../executable/options.dart'; +import '../import_cache.dart'; +import '../importer/filesystem.dart'; import '../logger/tracking.dart'; import '../parse/parser.dart'; -import '../value.dart' as internal; import '../visitor/evaluate.dart'; /// Runs an interactive SassScript shell according to [options]. Future repl(ExecutableOptions options) async { var repl = Repl(prompt: '>> '); - var variables = {}; + var logger = TrackingLogger(options.logger); + var evaluator = Evaluator( + importer: FilesystemImporter('.'), + importCache: + ImportCache(const [], loadPaths: options.loadPaths, logger: logger), + logger: logger); await for (String line in repl.runAsync()) { if (line.trim().isEmpty) continue; - var logger = TrackingLogger(options.logger); try { - VariableDeclaration declaration; - Expression expression; + if (line.startsWith("@")) { + evaluator.use(UseRule.parse(line, logger: logger)); + continue; + } + if (Parser.isVariableDeclarationLike(line)) { - declaration = VariableDeclaration.parse(line, logger: logger); - expression = declaration.expression; + var declaration = VariableDeclaration.parse(line, logger: logger); + evaluator.setVariable(declaration); + print(evaluator.evaluate(VariableExpression( + declaration.name, declaration.span, + namespace: declaration.namespace))); } else { - expression = Expression.parse(line, logger: logger); + print(evaluator.evaluate(Expression.parse(line, logger: logger))); } - - var result = - evaluateExpression(expression, variables: variables, logger: logger); - if (declaration != null) variables[declaration.name] = result; - print(result); } on SassException catch (error, stackTrace) { _logError(error, stackTrace, line, repl, options, logger); } @@ -46,10 +52,11 @@ Future repl(ExecutableOptions options) async { /// Logs an error from the interactive shell. void _logError(SassException error, StackTrace stackTrace, String line, Repl repl, ExecutableOptions options, TrackingLogger logger) { - // If something was logged after the input, just print the error. - if (!options.quiet && (logger.emittedDebug || logger.emittedWarning)) { - print("Error: ${error.message}"); - print(error.span.highlight(color: options.color)); + // If the error doesn't come from the repl line, or if something was logged + // after the user's input, just print the error normally. + if (error.span.sourceUrl != null || + (!options.quiet && (logger.emittedDebug || logger.emittedWarning))) { + print(error.toString(color: options.color)); return; } diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 2928d7f74..fa43d6f58 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -66,7 +66,7 @@ final global = UnmodifiableListView([ }), // ### HSL - _hue, _saturation, _lightness, _adjustHue, _complement, + _hue, _saturation, _lightness, _complement, BuiltInCallable.overloaded("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -117,6 +117,12 @@ final global = UnmodifiableListView([ return color.changeHsl(saturation: 0); }), + BuiltInCallable("adjust-hue", r"$color, $degrees", (arguments) { + var color = arguments[0].assertColor("color"); + var degrees = arguments[1].assertNumber("degrees"); + return color.changeHsl(hue: color.hue + degrees.value); + }), + BuiltInCallable("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); @@ -212,7 +218,7 @@ final global = UnmodifiableListView([ ]); /// The Sass color module. -final module = BuiltInModule("color", [ +final module = BuiltInModule("color", functions: [ // ### RGB _red, _green, _blue, _mix, @@ -239,7 +245,8 @@ final module = BuiltInModule("color", [ }), // ### HSL - _hue, _saturation, _lightness, _adjustHue, _complement, + _hue, _saturation, _lightness, _complement, + _removedColorFunction("adjust-hue", "hue"), _removedColorFunction("lighten", "lightness"), _removedColorFunction("darken", "lightness", negative: true), _removedColorFunction("saturate", "saturation"), @@ -355,13 +362,6 @@ final _lightness = BuiltInCallable( (arguments) => SassNumber(arguments.first.assertColor("color").lightness, "%")); -final _adjustHue = - BuiltInCallable("adjust-hue", r"$color, $degrees", (arguments) { - var color = arguments[0].assertColor("color"); - var degrees = arguments[1].assertNumber("degrees"); - return color.changeHsl(hue: color.hue + degrees.value); -}); - final _complement = BuiltInCallable("complement", r"$color", (arguments) { var color = arguments[0].assertColor("color"); return color.changeHsl(hue: color.hue + 180); diff --git a/lib/src/functions/list.dart b/lib/src/functions/list.dart index f8db21381..7f460b62a 100644 --- a/lib/src/functions/list.dart +++ b/lib/src/functions/list.dart @@ -18,7 +18,7 @@ final global = UnmodifiableListView([ ]); /// The Sass list module. -final module = BuiltInModule("list", [ +final module = BuiltInModule("list", functions: [ _length, _nth, _setNth, _join, _append, _zip, _index, _isBracketed, // _separator ]); diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart index c71f3726a..9ca6a3025 100644 --- a/lib/src/functions/map.dart +++ b/lib/src/functions/map.dart @@ -21,8 +21,8 @@ final global = UnmodifiableListView([ ]); /// The Sass map module. -final module = - BuiltInModule("map", [_get, _merge, _remove, _keys, _values, _hasKey]); +final module = BuiltInModule("map", + functions: [_get, _merge, _remove, _keys, _values, _hasKey]); final _get = BuiltInCallable("get", r"$map, $key", (arguments) { var map = arguments[0].assertMap("map"); diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart index b78706c0d..b0b52ec7a 100644 --- a/lib/src/functions/math.dart +++ b/lib/src/functions/math.dart @@ -25,7 +25,7 @@ final global = UnmodifiableListView([ ]); /// The Sass math module. -final module = BuiltInModule("math", [ +final module = BuiltInModule("math", functions: [ _round, _ceil, _floor, _abs, _max, _min, _randomFunction, _unit, _isUnitless, // _percentage, _compatible diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart index 2883565bb..0af5cfccf 100644 --- a/lib/src/functions/selector.dart +++ b/lib/src/functions/selector.dart @@ -26,7 +26,7 @@ final global = UnmodifiableListView([ ]); /// The Sass selector module. -final module = BuiltInModule("selector", [ +final module = BuiltInModule("selector", functions: [ _isSuperselector, _simpleSelectors, _parse, diff --git a/lib/src/functions/string.dart b/lib/src/functions/string.dart index 4eb663a76..03b7728d4 100644 --- a/lib/src/functions/string.dart +++ b/lib/src/functions/string.dart @@ -29,7 +29,7 @@ final global = UnmodifiableListView([ ]); /// The Sass string module. -final module = BuiltInModule("string", [ +final module = BuiltInModule("string", functions: [ _unquote, _quote, _toUpperCase, _toLowerCase, _length, _insert, _index, // _slice, _uniqueId, ]); diff --git a/lib/src/module/built_in.dart b/lib/src/module/built_in.dart index 9a2a15f5f..63d098687 100644 --- a/lib/src/module/built_in.dart +++ b/lib/src/module/built_in.dart @@ -16,20 +16,28 @@ import '../value.dart'; class BuiltInModule implements Module { final Uri url; final Map functions; + final Map mixins; List> get upstream => const []; Map get variables => const {}; Map get variableNodes => const {}; - Map get mixins => const {}; Extender get extender => Extender.empty; CssStylesheet get css => CssStylesheet.empty(url: url); bool get transitivelyContainsCss => false; bool get transitivelyContainsExtensions => false; - BuiltInModule(String name, Iterable functions) + BuiltInModule(String name, {Iterable functions, Iterable mixins}) : url = Uri(scheme: "sass", path: name), - functions = UnmodifiableMapView( - {for (var function in functions) function.name: function}); + functions = _callableMap(functions), + mixins = _callableMap(mixins); + + /// Returns a map from [callables]' names to their values. + static Map _callableMap( + Iterable callables) => + UnmodifiableMapView(callables == null + ? {} + : UnmodifiableMapView( + {for (var callable in callables) callable.name: callable})); void setVariable(String name, Value value, AstNode nodeWithSpan) { throw SassScriptException("Undefined variable."); diff --git a/lib/src/module/forwarded_view.dart b/lib/src/module/forwarded_view.dart index ca38098ef..441133b75 100644 --- a/lib/src/module/forwarded_view.dart +++ b/lib/src/module/forwarded_view.dart @@ -11,7 +11,6 @@ import '../extend/extender.dart'; import '../module.dart'; import '../util/limited_map_view.dart'; import '../util/prefixed_map_view.dart'; -import '../utils.dart'; import '../value.dart'; /// A [Module] that exposes members according to a [ForwardRule]. @@ -82,7 +81,7 @@ class ForwardedModuleView implements Module { } if (_rule.prefix != null) { - if (!startsWithIgnoreSeparator(name, _rule.prefix)) { + if (!name.startsWith(_rule.prefix)) { throw SassScriptException("Undefined variable."); } diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index eb39aa269..bd2b89f4d 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -172,7 +172,7 @@ class SassParser extends StylesheetParser { return null; case $dollar: - return variableDeclaration(); + return variableDeclarationWithoutNamespace(); break; case $slash: diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index 439033283..aec633d57 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -66,7 +66,7 @@ class ScssParser extends StylesheetParser { while (true) { switch (scanner.peekChar()) { case $dollar: - children.add(variableDeclaration()); + children.add(variableDeclarationWithoutNamespace()); break; case $slash: @@ -107,7 +107,7 @@ class ScssParser extends StylesheetParser { while (!scanner.isDone) { switch (scanner.peekChar()) { case $dollar: - statements.add(variableDeclaration()); + statements.add(variableDeclarationWithoutNamespace()); break; case $slash: diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 1eed929eb..0a36b7bd7 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -22,14 +22,6 @@ import '../value.dart'; import '../value/color.dart'; import 'parser.dart'; -/// Whether to parse `@use` rules. -/// -/// This is set to `false` on Dart Sass's master branch and `true` on the -/// `feature.use` branch. It allows us to avoid having separate development -/// tracks as much as possible without shipping `@use` support until we're -/// ready. -const _parseUse = false; - /// The base class for both the SCSS and indented syntax parsers. /// /// Having a base class that's separate from both parsers allows us to make @@ -109,27 +101,30 @@ abstract class StylesheetParser extends Parser { }); } - ArgumentDeclaration parseArgumentDeclaration() { - return wrapSpanFormatException(() { - var declaration = _argumentDeclaration(); - scanner.expectDone(); - return declaration; - }); - } + ArgumentDeclaration parseArgumentDeclaration() => + _parseSingleProduction(_argumentDeclaration); - Expression parseExpression() { - return wrapSpanFormatException(() { - var result = expression(); - scanner.expectDone(); - return result; - }); - } + Expression parseExpression() => _parseSingleProduction(expression); - VariableDeclaration parseVariableDeclaration() { + VariableDeclaration parseVariableDeclaration() => + _parseSingleProduction(() => lookingAtIdentifier() + ? _variableDeclarationWithNamespace() + : variableDeclarationWithoutNamespace()); + + UseRule parseUseRule() => _parseSingleProduction(() { + var start = scanner.state; + scanner.expectChar($at, name: "@-rule"); + expectIdentifier("use"); + whitespace(); + return _useRule(start); + }); + + /// Parses and returns [production] as the entire contents of [scanner]. + T _parseSingleProduction(T production()) { return wrapSpanFormatException(() { - var declaration = variableDeclaration(); + var result = production(); scanner.expectDone(); - return declaration; + return result; }); } @@ -174,27 +169,38 @@ abstract class StylesheetParser extends Parser { whitespace(); return _mixinRule(start); + case $rbrace: + scanner.error('unmatched "}".', length: 1); + return null; + default: - _isUseAllowed = false; return _inStyleRule || _inUnknownAtRule || _inMixin || _inContentBlock ? _declarationOrStyleRule() - : _styleRule(); + : _variableDeclarationOrStyleRule(); } } + /// Consumes a namespaced variable declaration. + VariableDeclaration _variableDeclarationWithNamespace() { + var start = scanner.state; + var namespace = identifier(); + scanner.expectChar($dot); + return variableDeclarationWithoutNamespace(namespace, start); + } + /// Consumes a variable declaration. + /// + /// This never *consumes* a namespace, but if [namespace] is passed it will be + /// used for the declaration. @protected - VariableDeclaration variableDeclaration() { + VariableDeclaration variableDeclarationWithoutNamespace( + [String namespace, LineScannerState start]) { var precedingComment = lastSilentComment; lastSilentComment = null; - var start = scanner.state; + start ??= scanner.state; - String namespace; var name = variableName(); - if (scanner.scanChar($dot)) { - namespace = name; - name = _publicIdentifier(); - } + if (namespace != null) _assertPublic(name, () => scanner.spanFrom(start)); if (plainCss) { error("Sass variables aren't allowed in plain CSS.", @@ -239,31 +245,39 @@ abstract class StylesheetParser extends Parser { return declaration; } - /// Consumes a style rule. - StyleRule _styleRule() { - var wasInStyleRule = _inStyleRule; - _inStyleRule = true; + /// Consumes a namespaced [VariableDeclaration] or a [StyleRule]. + Statement _variableDeclarationOrStyleRule() { + if (plainCss) return _styleRule(); // The indented syntax allows a single backslash to distinguish a style rule // from old-style property syntax. We don't support old property syntax, but // we do support the backslash because it's easy to do. - if (indented) scanner.scanChar($backslash); + if (indented && scanner.scanChar($backslash)) return _styleRule(); + + if (!lookingAtIdentifier()) return _styleRule(); var start = scanner.state; - var selector = styleRuleSelector(); - var rule = _withChildren(_statement, start, - (children, span) => StyleRule(selector, children, span)); - _inStyleRule = wasInStyleRule; - return rule; + var variableOrInterpolation = _variableDeclarationOrInterpolation(); + if (variableOrInterpolation is VariableDeclaration) { + return variableOrInterpolation; + } else { + return _styleRule( + InterpolationBuffer() + ..addInterpolation(variableOrInterpolation as Interpolation), + start); + } } - /// Consumes a [Declaration] or a [StyleRule]. + /// Consumes a [VariableDeclaration], a [Declaration], or a [StyleRule]. + /// + /// When parsing the children of a style rule, property declarations, + /// namespaced variable declarations, and nested style rules can all begin + /// with bare identifiers. In order to know which statement type to produce, + /// we need to disambiguate them. We use the following criteria: /// - /// When parsing the contents of a style rule, it can be difficult to tell - /// declarations apart from nested style rules. Since we don't thoroughly - /// parse selectors until after resolving interpolation, we can share a bunch - /// of the parsing of the two, but we need to disambiguate them first. We use - /// the following criteria: + /// * If the entity starts with an identifier followed by a period and a + /// dollar sign, it's a variable declaration. This is the simplest case, + /// because `.$` is used in and only in variable declarations. /// /// * If the entity doesn't start with an identifier followed by a colon, /// it's a selector. There are some additional mostly-unimportant cases @@ -284,7 +298,9 @@ abstract class StylesheetParser extends Parser { /// parsed as a selector and never as a property with nested properties /// beneath it. Statement _declarationOrStyleRule() { - if (plainCss && _inStyleRule && !_inUnknownAtRule) return _declaration(); + if (plainCss && _inStyleRule && !_inUnknownAtRule) { + return _propertyOrVariableDeclaration(); + } // The indented syntax allows a single backslash to distinguish a style rule // from old-style property syntax. We don't support old property syntax, but @@ -293,38 +309,18 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; var declarationOrBuffer = _declarationOrBuffer(); - - if (declarationOrBuffer is Declaration) return declarationOrBuffer; - - var buffer = declarationOrBuffer as InterpolationBuffer; - buffer.addInterpolation(styleRuleSelector()); - var selectorSpan = scanner.spanFrom(start); - - var wasInStyleRule = _inStyleRule; - _inStyleRule = true; - - if (buffer.isEmpty) scanner.error('expected "}".'); - - return _withChildren(_statement, start, (children, span) { - if (indented && children.isEmpty) { - warn("This selector doesn't have any properties and won't be rendered.", - selectorSpan); - } - - _inStyleRule = wasInStyleRule; - - return StyleRule(buffer.interpolation(selectorSpan), children, - scanner.spanFrom(start)); - }); + return declarationOrBuffer is Statement + ? declarationOrBuffer + : _styleRule(declarationOrBuffer as InterpolationBuffer, start); } - /// Tries to parse a declaration, and returns the value parsed so far if it - /// fails. + /// Tries to parse a variable or property declaration, and returns the value + /// parsed so far if it fails. /// /// This can return either an [InterpolationBuffer], indicating that it /// couldn't consume a declaration and that selector parsing should be - /// attempted; or it can return a [Declaration], indicating that it - /// successfully consumed a declaration. + /// attempted; or it can return a [Declaration] or a [VariableDeclaration], + /// indicating that it successfully consumed a declaration. dynamic _declarationOrBuffer() { var start = scanner.state; var nameBuffer = InterpolationBuffer(); @@ -332,16 +328,28 @@ abstract class StylesheetParser extends Parser { // Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val" // hacks. var first = scanner.peekChar(); + var startsWithPunctuation = false; if (first == $colon || first == $asterisk || first == $dot || (first == $hash && scanner.peekChar(1) != $lbrace)) { + startsWithPunctuation = true; nameBuffer.writeCharCode(scanner.readChar()); nameBuffer.write(rawText(whitespace)); } if (!_lookingAtInterpolatedIdentifier()) return nameBuffer; - nameBuffer.addInterpolation(interpolatedIdentifier()); + + var variableOrInterpolation = startsWithPunctuation + ? interpolatedIdentifier() + : _variableDeclarationOrInterpolation(); + if (variableOrInterpolation is VariableDeclaration) { + return variableOrInterpolation; + } else { + nameBuffer.addInterpolation(variableOrInterpolation as Interpolation); + } + + _isUseAllowed = false; if (scanner.matches("/*")) nameBuffer.write(rawText(loudComment)); var midBuffer = StringBuffer(); @@ -425,13 +433,70 @@ abstract class StylesheetParser extends Parser { } } - /// Consumes a property declaration. + /// Tries to parse a namespaced [VariableDeclaration], and returns the value + /// parsed so far if it fails. + /// + /// This can return either an [Interpolation], indicating that it couldn't + /// consume a variable declaration and that property declaration or selector + /// parsing should be attempted; or it can return a [VariableDeclaration], + /// indicating that it successfully consumed a variable declaration. + dynamic _variableDeclarationOrInterpolation() { + if (!lookingAtIdentifier()) return interpolatedIdentifier(); + + var start = scanner.state; + var identifier = this.identifier(); + if (scanner.matches(".\$")) { + scanner.readChar(); + return variableDeclarationWithoutNamespace(identifier, start); + } else { + var buffer = InterpolationBuffer()..write(identifier); + + // Parse the rest of an interpolated identifier if one exists, so callers + // don't have to. + if (_lookingAtInterpolatedIdentifierBody()) { + buffer.addInterpolation(interpolatedIdentifier()); + } + + return buffer.interpolation(scanner.spanFrom(start)); + } + } + + /// Consumes a [StyleRule], optionally with a [buffer] that may contain some + /// text that has already been parsed. + StyleRule _styleRule([InterpolationBuffer buffer, LineScannerState start]) { + _isUseAllowed = false; + start ??= scanner.state; + + var interpolation = styleRuleSelector(); + if (buffer != null) { + buffer.addInterpolation(interpolation); + interpolation = buffer.interpolation(scanner.spanFrom(start)); + } + if (interpolation.contents.isEmpty) scanner.error('expected "}".'); + + var wasInStyleRule = _inStyleRule; + _inStyleRule = true; + + return _withChildren(_statement, start, (children, span) { + if (indented && children.isEmpty) { + warn("This selector doesn't have any properties and won't be rendered.", + interpolation.span); + } + + _inStyleRule = wasInStyleRule; + + return StyleRule(interpolation, children, scanner.spanFrom(start)); + }); + } + + /// Consumes either a property declaration or a namespaced variable + /// declaration. /// /// This is only used in contexts where declarations are allowed but style /// rules are not, such as nested declarations. Otherwise, /// [_declarationOrStyleRule] is used instead. @protected - Declaration _declaration() { + Statement _propertyOrVariableDeclaration() { var start = scanner.state; Interpolation name; @@ -447,6 +512,13 @@ abstract class StylesheetParser extends Parser { nameBuffer.write(rawText(whitespace)); nameBuffer.addInterpolation(interpolatedIdentifier()); name = nameBuffer.interpolation(scanner.spanFrom(start)); + } else if (!plainCss) { + var variableOrInterpolation = _variableDeclarationOrInterpolation(); + if (variableOrInterpolation is VariableDeclaration) { + return variableOrInterpolation; + } else { + name = variableOrInterpolation as Interpolation; + } } else { name = interpolatedIdentifier(); } @@ -482,7 +554,7 @@ abstract class StylesheetParser extends Parser { /// Consumes a statement that's allowed within a declaration. Statement _declarationChild() { if (scanner.peekChar() == $at) return _declarationAtRule(); - return _declaration(); + return _propertyOrVariableDeclaration(); } // ## At Rules @@ -600,23 +672,30 @@ abstract class StylesheetParser extends Parser { } } - /// Consumes an at-rule allowed within a function. - Statement _functionAtRule() { + /// Consumes a statement allowed within a function. + Statement _functionChild() { if (scanner.peekChar() != $at) { - var position = scanner.position; - Statement statement; + var state = scanner.state; try { - statement = _declarationOrStyleRule(); - } on SourceSpanFormatException catch (_) { - // If we can't parse a valid declaration or style rule, throw a more - // generic error message. - scanner.error("expected @-rule", position: position); - } + return _variableDeclarationWithNamespace(); + } on SourceSpanFormatException catch (variableDeclarationError) { + scanner.state = state; + + // If a variable declaration failed to parse, it's possible the user + // thought they could write a style rule or property declaration in a + // function. If so, throw a more helpful error message. + Statement statement; + try { + statement = _declarationOrStyleRule(); + } on SourceSpanFormatException catch (_) { + throw variableDeclarationError; + } - error( - "@function rules may not contain " - "${statement is StyleRule ? "style rules" : "declarations"}.", - statement.span); + error( + "@function rules may not contain " + "${statement is StyleRule ? "style rules" : "declarations"}.", + statement.span); + } } var start = scanner.state; @@ -624,21 +703,21 @@ abstract class StylesheetParser extends Parser { case "debug": return _debugRule(start); case "each": - return _eachRule(start, _functionAtRule); + return _eachRule(start, _functionChild); case "else": return _disallowedAtRule(start); case "error": return _errorRule(start); case "for": - return _forRule(start, _functionAtRule); + return _forRule(start, _functionChild); case "if": - return _ifRule(start, _functionAtRule); + return _ifRule(start, _functionChild); case "return": return _returnRule(start); case "warn": return _warnRule(start); case "while": - return _whileRule(start, _functionAtRule); + return _whileRule(start, _functionChild); default: return _disallowedAtRule(start); } @@ -810,7 +889,7 @@ abstract class StylesheetParser extends Parser { whitespace(); return _withChildren( - _functionAtRule, + _functionChild, start, (children, span) => FunctionRule(name, arguments, children, span, comment: precedingComment)); @@ -864,7 +943,7 @@ abstract class StylesheetParser extends Parser { String prefix; if (scanIdentifier("as")) { whitespace(); - prefix = identifier(); + prefix = identifier(normalize: true); scanner.expectChar($asterisk); whitespace(); } @@ -885,12 +964,7 @@ abstract class StylesheetParser extends Parser { expectStatementSeparator("@forward rule"); var span = scanner.spanFrom(start); - if (!_parseUse) { - error( - "@forward is coming soon, but it's not supported in this version of " - "Dart Sass.", - span); - } else if (!_isUseAllowed) { + if (!_isUseAllowed) { error("@forward rules must be written before any other rules.", span); } @@ -921,7 +995,7 @@ abstract class StylesheetParser extends Parser { if (scanner.peekChar() == $dollar) { variables.add(variableName()); } else { - identifiers.add(identifier()); + identifiers.add(identifier(normalize: true)); } }); whitespace(); @@ -1274,35 +1348,73 @@ relase. For details, see http://bit.ly/moz-document. var url = _urlString(); whitespace(); - String namespace; - if (scanIdentifier("as")) { - whitespace(); - namespace = scanner.scanChar($asterisk) ? null : identifier(); - } else { - var basename = url.pathSegments.isEmpty ? "" : url.pathSegments.last; - var dot = basename.indexOf("."); - namespace = basename.substring(0, dot == -1 ? basename.length : dot); + var namespace = _useNamespace(url, start); + whitespace(); + var configuration = _useConfiguration(); - try { - namespace = Parser.parseIdentifier(namespace, logger: logger); - } on SassFormatException { - error('Invalid Sass identifier "$namespace"', scanner.spanFrom(start)); - } - } expectStatementSeparator("@use rule"); var span = scanner.spanFrom(start); - if (!_parseUse) { - error( - "@use is coming soon, but it's not supported in this version of " - "Dart Sass.", - span); - } else if (!_isUseAllowed) { + if (!_isUseAllowed) { error("@use rules must be written before any other rules.", span); } expectStatementSeparator("@use rule"); - return UseRule(url, namespace, span); + return UseRule(url, namespace, span, configuration: configuration); + } + + /// Parses the namespace of a `@use` rule from an `as` clause, or returns the + /// default namespace from its URL. + String _useNamespace(Uri url, LineScannerState start) { + if (scanIdentifier("as")) { + whitespace(); + return scanner.scanChar($asterisk) ? null : identifier(); + } + + var basename = url.pathSegments.isEmpty ? "" : url.pathSegments.last; + var dot = basename.indexOf("."); + var namespace = basename.substring(0, dot == -1 ? basename.length : dot); + try { + return Parser.parseIdentifier(namespace, logger: logger); + } on SassFormatException { + error('Invalid Sass identifier "$namespace"', scanner.spanFrom(start)); + } + } + + /// Returns the map from variable names to expressions from a `@use` rule's + /// `with` clause. + /// + /// Returns `null` if there is no `with` clause. + Map> _useConfiguration() { + if (!scanIdentifier("with")) return null; + + var configuration = >{}; + whitespace(); + scanner.expectChar($lparen); + + while (true) { + whitespace(); + + var variableStart = scanner.state; + var name = variableName(); + whitespace(); + scanner.expectChar($colon); + whitespace(); + var expression = _expressionUntilComma(); + var span = scanner.spanFrom(variableStart); + + if (configuration.containsKey(name)) { + error("The same variable may only be configured once.", span); + } + configuration[name] = Tuple2(expression, span); + + if (!scanner.scanChar($comma)) break; + whitespace(); + if (!_lookingAtExpression()) break; + } + + scanner.expectChar($rparen); + return configuration; } /// Consumes a `@warn` rule. @@ -2335,29 +2447,14 @@ relase. For details, see http://bit.ly/moz-document. /// Consumes a variable expression. VariableExpression _variable() { var start = scanner.state; - - // We can't use [variableName] here because it always normalizes the - // identifier, but we don't want the variable namespace to end up - // normalized. - scanner.expectChar($dollar); - - String namespace; - var name = identifier(); - if (scanner.peekChar() == $dot && scanner.peekChar(1) != $dot) { - scanner.readChar(); - namespace = name; - name = _publicIdentifier(); - } else { - name = name.replaceAll("_", "-"); - } + var name = variableName(); if (plainCss) { error("Sass variables aren't allowed in plain CSS.", scanner.spanFrom(start)); } - return VariableExpression(name, scanner.spanFrom(start), - namespace: namespace); + return VariableExpression(name, scanner.spanFrom(start)); } /// Consumes a selector expression. @@ -2467,20 +2564,26 @@ relase. For details, see http://bit.ly/moz-document. switch (scanner.peekChar()) { case $dot: if (scanner.peekChar(1) == $dot) return StringExpression(identifier); - - var namespace = identifier.asPlain; scanner.readChar(); - var beforeName = scanner.state; - var name = - Interpolation([_publicIdentifier()], scanner.spanFrom(beforeName)); - if (namespace == null) { + if (plain == null) { error("Interpolation isn't allowed in namespaces.", identifier.span); } + if (scanner.peekChar() == $dollar) { + var name = variableName(); + _assertPublic(name, () => scanner.spanFrom(start)); + return VariableExpression(name, scanner.spanFrom(start), + namespace: plain); + } + + var beforeName = scanner.state; + var name = + Interpolation([_publicIdentifier()], scanner.spanFrom(beforeName)); + return FunctionExpression( name, _argumentInvocation(), scanner.spanFrom(start), - namespace: namespace); + namespace: plain); case $lparen: return FunctionExpression( @@ -3340,16 +3443,19 @@ relase. For details, see http://bit.ly/moz-document. String _publicIdentifier() { var start = scanner.state; var result = identifier(normalize: true); - - var first = result.codeUnitAt(0); - if (first == $dash || first == $underscore) { - error("Private members can't be accessed from outside their modules.", - scanner.spanFrom(start)); - } - + _assertPublic(result, () => scanner.spanFrom(start)); return result; } + /// Throws an error if [identifier] isn't public. + /// + /// Calls [span] to provide the span for an error if one occurs. + void _assertPublic(String identifier, FileSpan span()) { + if (!isPrivate(identifier)) return; + error("Private members can't be accessed from outside their modules.", + span()); + } + // ## Abstract Methods /// Whether this is parsing the indented syntax. diff --git a/lib/src/util/character.dart b/lib/src/util/character.dart index cdc1c57e0..13d2e937d 100644 --- a/lib/src/util/character.dart +++ b/lib/src/util/character.dart @@ -65,6 +65,14 @@ bool isSimpleSelectorStart(int character) => character == $percent || character == $colon; +/// Returns whether [identifier] is module-private. +/// +/// Assumes [identifier] is a valid Sass identifier. +bool isPrivate(String identifier) { + var first = identifier.codeUnitAt(0); + return first == $dash || first == $underscore; +} + /// Returns the value of [character] as a hex digit. /// /// Assumes that [character] is a hex digit. diff --git a/lib/src/util/limited_map_view.dart b/lib/src/util/limited_map_view.dart index 1253f77f4..f7c370f35 100644 --- a/lib/src/util/limited_map_view.dart +++ b/lib/src/util/limited_map_view.dart @@ -6,13 +6,17 @@ import 'dart:collection'; import 'package:collection/collection.dart'; -/// An unmodifiable view of a map that only allows certain keys to be accessed. +/// A mostly-unmodifiable view of a map that only allows certain keys to be +/// accessed. /// /// Whether or not the underlying map contains keys that aren't allowed, this /// view will behave as though it doesn't contain them. /// /// The underlying map's values may change independently of this view, but its /// set of keys may not. +/// +/// This is unmodifiable *except for the [remove] method*, which is used for +/// `@used with` to mark configured variables as used. class LimitedMapView extends UnmodifiableMapBase { /// The wrapped map. final Map _map; @@ -39,4 +43,6 @@ class LimitedMapView extends UnmodifiableMapBase { V operator [](Object key) => _keys.contains(key) ? _map[key] : null; bool containsKey(Object key) => _keys.contains(key); + + V remove(Object key) => _keys.contains(key) ? _map.remove(key) : null; } diff --git a/lib/src/util/prefixed_map_view.dart b/lib/src/util/prefixed_map_view.dart index a50b600a7..29c0b3565 100644 --- a/lib/src/util/prefixed_map_view.dart +++ b/lib/src/util/prefixed_map_view.dart @@ -21,17 +21,13 @@ class PrefixedMapView extends UnmodifiableMapBase { /// Creates a new prefixed map view. PrefixedMapView(this._map, this._prefix); - V operator [](Object key) => key is String && _startsWith(key, _prefix) + V operator [](Object key) => key is String && key.startsWith(_prefix) ? _map[key.substring(_prefix.length)] : null; - bool containsKey(Object key) => key is String && _startsWith(key, _prefix) + bool containsKey(Object key) => key is String && key.startsWith(_prefix) ? _map.containsKey(key.substring(_prefix.length)) : false; - - /// Returns whether [string] begins with [prefix]. - bool _startsWith(String string, String prefix) => - string.length >= prefix.length && string.startsWith(prefix); } /// The implementation of [PrefixedMapViews.keys]. diff --git a/lib/src/util/unprefixed_map_view.dart b/lib/src/util/unprefixed_map_view.dart new file mode 100644 index 000000000..e11de4532 --- /dev/null +++ b/lib/src/util/unprefixed_map_view.dart @@ -0,0 +1,49 @@ +// 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 'dart:collection'; + +/// A mostly-unmodifiable view of a map with string keys that only allows keys +/// with a given prefix to be accessed, and presents them as though they didn't +/// have that prefix. +/// +/// Whether or not the underlying map contains keys without the given prefix, +/// this view will behave as though it doesn't contain them. +/// +/// This is unmodifiable *except for the [remove] method*, which is used for +/// `@used with` to mark configured variables as used. +class UnprefixedMapView extends UnmodifiableMapBase { + /// The wrapped map. + final Map _map; + + /// The prefix to remove from the map keys. + final String _prefix; + + Iterable get keys => _UnprefixedKeys(this); + + /// Creates a new unprefixed map view. + UnprefixedMapView(this._map, this._prefix); + + V operator [](Object key) => key is String ? _map[_prefix + key] : null; + + bool containsKey(Object key) => + key is String ? _map.containsKey(_prefix + key) : false; + + V remove(Object key) => key is String ? _map.remove(_prefix + key) : null; +} + +/// The implementation of [UnprefixedMapViews.keys]. +class _UnprefixedKeys extends IterableBase { + /// The view whose keys are being iterated over. + final UnprefixedMapView _view; + + Iterator get iterator => _view._map.keys + .where((key) => key.startsWith(_view._prefix)) + .map((key) => key.substring(_view._prefix.length)) + .iterator; + + _UnprefixedKeys(this._view); + + bool contains(Object key) => _view.containsKey(key); +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 8cd061491..dbcf12d94 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -9,6 +9,7 @@ import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; +import 'package:term_glyph/term_glyph.dart' as glyph; import 'ast/node.dart'; import 'util/character.dart'; @@ -37,6 +38,15 @@ String pluralize(String name, int number, {String plural}) { return '${name}s'; } +/// Returns a bulleted list of items in [bullets]. +String bulletedList(Iterable bullets) { + return bullets.map((element) { + var lines = element.split("\n"); + return "${glyph.bullet} ${lines.first}" + + (lines.length > 1 ? "\n" + indent(lines.skip(1).join("\n"), 2) : ""); + }).join("\n"); +} + /// Returns the number of times [codeUnit] appears in [string]. int countOccurrences(String string, int codeUnit) { var count = 0; @@ -258,25 +268,6 @@ bool startsWithIgnoreCase(String string, String prefix) { return true; } -/// Returns whether [string] begins with [prefix] if `-` and `_` are -/// considered equivalent. -bool startsWithIgnoreSeparator(String string, String prefix) { - if (string.length < prefix.length) return false; - for (var i = 0; i < prefix.length; i++) { - var stringCodeUnit = string.codeUnitAt(i); - var prefixCodeUnit = prefix.codeUnitAt(i); - if (stringCodeUnit == prefixCodeUnit) continue; - if (stringCodeUnit == $dash) { - if (prefixCodeUnit != $underscore) return false; - } else if (stringCodeUnit == $underscore) { - if (prefixCodeUnit != $dash) return false; - } else { - return false; - } - } - return true; -} - /// Destructively updates every element of [list] with the result of [function]. void mapInPlace(List list, T function(T element)) { for (var i = 0; i < list.length; i++) { diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 2f8ba210e..79ca86a07 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -37,6 +37,8 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../util/fixed_length_list_builder.dart'; +import '../util/limited_map_view.dart'; +import '../util/unprefixed_map_view.dart'; import '../utils.dart'; import '../value.dart'; import '../warn.dart'; @@ -85,24 +87,35 @@ Future evaluateAsync(Stylesheet stylesheet, sourceMap: sourceMap) .run(importer, stylesheet); -/// Evaluates a single [expression] -/// -/// The [functions] are available as global functions when evaluating -/// [expression]. -/// -/// The [variables] are available as global variables when evaluating -/// [expression]. -/// -/// Warnings are emitted using [logger], or printed to standard error by -/// default. -/// -/// Throws a [SassRuntimeException] if evaluation fails. -Future evaluateExpressionAsync(Expression expression, - {Iterable functions, - Map variables, - Logger logger}) => - _EvaluateVisitor(functions: functions, logger: logger, sourceMap: false) - .runExpression(expression, variables: variables); +/// A class that can evaluate multiple independent statements and expressions +/// in the context of a single module. +class AsyncEvaluator { + /// The visitor that evaluates each expression and statement. + final _EvaluateVisitor _visitor; + + /// The importer to use to resolve `@use` rules in [_importer]. + final AsyncImporter _importer; + + /// Creates an evaluator. + /// + /// Arguments are the same as for [evaluateAsync]. + AsyncEvaluator( + {AsyncImportCache importCache, + AsyncImporter importer, + Iterable functions, + Logger logger}) + : _visitor = _EvaluateVisitor( + importCache: importCache, functions: functions, logger: logger), + _importer = importer; + + Future use(UseRule use) => _visitor.runStatement(_importer, use); + + Future evaluate(Expression expression) => + _visitor.runExpression(_importer, expression); + + Future setVariable(VariableDeclaration declaration) => + _visitor.runStatement(_importer, declaration); +} /// A visitor that executes Sass code to produce a CSS tree. class _EvaluateVisitor @@ -238,18 +251,32 @@ class _EvaluateVisitor /// module. Extender _extender; + /// A map from variable names to the values that override their `!default` + /// definitions in this module. + /// + /// If this is empty, that indicates that the current module is not confiured. + /// Note that it may be unmodifiable when empty, in which case [Map.remove] + /// must not be called. + var _configuration = const {}; + + /// Creates a new visitor. + /// + /// Most arguments are the same as those to [evaluateAsync]. _EvaluateVisitor( {AsyncImportCache importCache, NodeImporter nodeImporter, Iterable functions, Logger logger, - bool sourceMap}) + bool sourceMap = false}) : _importCache = nodeImporter == null ? importCache ?? AsyncImportCache.none(logger: logger) : null, _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), - _sourceMap = sourceMap { + _sourceMap = sourceMap, + // The default environment is overridden in [_execute] for full + // stylesheets, but for [AsyncEvaluator] this environment is used. + _environment = AsyncEnvironment(sourceMap: sourceMap) { var metaFunctions = [ // These functions are defined in the context of the evaluator because // they need access to the [_environment] or other local state. @@ -380,7 +407,39 @@ class _EvaluateVisitor }) ]; - var metaModule = BuiltInModule("meta", [...meta.global, ...metaFunctions]); + var metaMixins = [ + AsyncBuiltInCallable("load-css", r"$module, $with: null", + (arguments) async { + var url = Uri.parse(arguments[0].assertString("module").text); + var withMap = arguments[1].realNull?.assertMap("with")?.contents; + + Map configuration; + if (withMap != null) { + configuration = {}; + var span = _callableNode.span; + withMap.forEach((variable, value) { + var name = + variable.assertString("with key").text.replaceAll("_", "-"); + if (configuration.containsKey(name)) { + throw "The variable \$$name was configured twice."; + } + + configuration[name] = _ConfiguredValue(value, span); + }); + } + + await _loadModule(url, "load-css()", _callableNode, + (module) => _combineCss(module, clone: true).accept(this), + baseUrl: _callableNode.span?.sourceUrl, + configuration: configuration, + namesInErrors: true); + return null; + }) + ]; + + var metaModule = BuiltInModule("meta", + functions: [...meta.global, ...metaFunctions], mixins: metaMixins); + for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } @@ -411,18 +470,13 @@ class _EvaluateVisitor }); } - Future runExpression(Expression expression, - {Map variables}) { - return _withWarnCallback(() async { - _environment = AsyncEnvironment(sourceMap: _sourceMap); - - for (var name in variables?.keys ?? const []) { - _environment.setVariable(name, variables[name], null, global: true); - } + Future runExpression(AsyncImporter importer, Expression expression) => + _withWarnCallback(() => _withFakeStylesheet( + importer, expression, () => expression.accept(this))); - return expression.accept(this); - }); - } + Future runStatement(AsyncImporter importer, Statement statement) => + _withWarnCallback(() => _withFakeStylesheet( + importer, statement, () => statement.accept(this))); /// Runs [callback] with a definition for the top-level `warn` function. T _withWarnCallback(T callback()) { @@ -433,39 +487,89 @@ class _EvaluateVisitor callback); } + /// Runs [callback] with [importer] as [_importer] and a fake [_stylesheet] + /// with [nodeForSpan]'s source span. + Future _withFakeStylesheet(AsyncImporter importer, AstNode nodeForSpan, + FutureOr callback()) async { + var oldImporter = _importer; + _importer = importer; + var oldStylesheet = _stylesheet; + _stylesheet = Stylesheet(const [], nodeForSpan.span); + + try { + return await callback(); + } finally { + _importer = oldImporter; + _stylesheet = oldStylesheet; + } + } + /// Loads the module at [url] and passes it to [callback]. /// + /// This first tries loading [url] relative to [baseUrl], which defaults to + /// `_stylesheet.span.sourceUrl`. + /// + /// The [configuration] overrides values for `!default` variables defined in + /// the module or modules it forwards and/or imports. If it's not passed, the + /// current configuration is used instead. Throws a [SassRuntimeException] if + /// a configured variable is not declared with `!default`. + /// + /// 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. + /// /// The [stackFrame] and [nodeForSpan] are used for the name and location of /// the stack frame for the duration of the [callback]. Future _loadModule(Uri url, String stackFrame, AstNode nodeForSpan, - void callback(Module module)) async { + void callback(Module module), + {Uri baseUrl, + Map configuration, + bool namesInErrors = false}) async { + configuration ??= const {}; + var builtInModule = _builtInModules[url]; if (builtInModule != null) { + if (configuration.isNotEmpty || _configuration.isNotEmpty) { + throw _exception( + namesInErrors + ? "Built-in module $url can't be configured." + : "Built-in modules can't be configured.", + nodeForSpan.span); + } + callback(builtInModule); return; } await _withStackFrame(stackFrame, nodeForSpan, () async { - var result = await inUseRuleAsync( - () => _loadStylesheet(url.toString(), nodeForSpan.span)); + var result = await inUseRuleAsync(() => + _loadStylesheet(url.toString(), nodeForSpan.span, baseUrl: baseUrl)); var importer = result.item1; var stylesheet = result.item2; var canonicalUrl = stylesheet.span.sourceUrl; if (_activeModules.contains(canonicalUrl)) { - throw _exception("Module loop: this module is already being loaded."); + throw _exception(namesInErrors + ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " + "loaded." + : "Module loop: this module is already being loaded."); } _activeModules.add(canonicalUrl); Module module; try { - module = await _execute(importer, stylesheet); + module = await _execute(importer, stylesheet, + configuration: configuration, namesInErrors: namesInErrors); } finally { _activeModules.remove(canonicalUrl); } try { await callback(module); + } on SassRuntimeException { + rethrow; + } on SassException catch (error) { + throw _exception(error.message, error.span); } on SassScriptException catch (error) { throw _exception(error.message); } @@ -473,62 +577,102 @@ class _EvaluateVisitor } /// Executes [stylesheet], loaded by [importer], to produce a module. - Future _execute(AsyncImporter importer, Stylesheet stylesheet) { + /// + /// The [configuration] overrides values for `!default` variables defined in + /// the module or modules it forwards and/or imports. If it's not passed, the + /// current configuration is used instead. Throws a [SassRuntimeException] if + /// a configured variable is not declared with `!default`. + /// + /// 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. + Future _execute(AsyncImporter importer, Stylesheet stylesheet, + {Map configuration, + bool namesInErrors = false}) async { + configuration ??= const {}; var url = stylesheet.span.sourceUrl; - return putIfAbsentAsync(_modules, url, () async { - var environment = AsyncEnvironment(sourceMap: _sourceMap); - CssStylesheet css; - var extender = Extender(); - await _withEnvironment(environment, () async { - var oldImporter = _importer; - var oldStylesheet = _stylesheet; - var oldRoot = _root; - var oldParent = _parent; - var oldEndOfImports = _endOfImports; - var oldOutOfOrderImports = _outOfOrderImports; - var oldExtender = _extender; - var oldStyleRule = _styleRule; - var oldMediaQueries = _mediaQueries; - var oldDeclarationName = _declarationName; - var oldInUnknownAtRule = _inUnknownAtRule; - var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; - var oldInKeyframes = _inKeyframes; - _importer = importer; - _stylesheet = stylesheet; - _root = ModifiableCssStylesheet(stylesheet.span); - _parent = _root; - _endOfImports = 0; - _outOfOrderImports = null; - _extender = extender; - _styleRule = null; - _mediaQueries = null; - _declarationName = null; - _inUnknownAtRule = false; - _atRootExcludingStyleRule = false; - _inKeyframes = false; - await visitStylesheet(stylesheet); - css = _outOfOrderImports == null - ? _root - : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + var alreadyLoaded = _modules[url]; + if (alreadyLoaded != null) { + if (configuration.isNotEmpty || _configuration.isNotEmpty) { + throw _exception(namesInErrors + ? "${p.prettyUri(url)} was already loaded, so it can't be " + "configured using \"with\"." + : "This module was already loaded, so it can't be configured using " + "\"with\"."); + } - _importer = oldImporter; - _stylesheet = oldStylesheet; - _root = oldRoot; - _parent = oldParent; - _endOfImports = oldEndOfImports; - _outOfOrderImports = oldOutOfOrderImports; - _extender = oldExtender; - _styleRule = oldStyleRule; - _mediaQueries = oldMediaQueries; - _declarationName = oldDeclarationName; - _inUnknownAtRule = oldInUnknownAtRule; - _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; - _inKeyframes = oldInKeyframes; - }); + return alreadyLoaded; + } + + var environment = AsyncEnvironment(sourceMap: _sourceMap); + CssStylesheet css; + var extender = Extender(); + await _withEnvironment(environment, () async { + var oldImporter = _importer; + var oldStylesheet = _stylesheet; + var oldRoot = _root; + var oldParent = _parent; + var oldEndOfImports = _endOfImports; + var oldOutOfOrderImports = _outOfOrderImports; + var oldExtender = _extender; + var oldStyleRule = _styleRule; + var oldMediaQueries = _mediaQueries; + var oldDeclarationName = _declarationName; + var oldInUnknownAtRule = _inUnknownAtRule; + var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; + var oldInKeyframes = _inKeyframes; + var oldConfiguration = _configuration; + _importer = importer; + _stylesheet = stylesheet; + _root = ModifiableCssStylesheet(stylesheet.span); + _parent = _root; + _endOfImports = 0; + _outOfOrderImports = null; + _extender = extender; + _styleRule = null; + _mediaQueries = null; + _declarationName = null; + _inUnknownAtRule = false; + _atRootExcludingStyleRule = false; + _inKeyframes = false; + + if (configuration.isNotEmpty) _configuration = Map.of(configuration); + + await visitStylesheet(stylesheet); + css = _outOfOrderImports == null + ? _root + : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + + _importer = oldImporter; + _stylesheet = oldStylesheet; + _root = oldRoot; + _parent = oldParent; + _endOfImports = oldEndOfImports; + _outOfOrderImports = oldOutOfOrderImports; + _extender = oldExtender; + _styleRule = oldStyleRule; + _mediaQueries = oldMediaQueries; + _declarationName = oldDeclarationName; + _inUnknownAtRule = oldInUnknownAtRule; + _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + _inKeyframes = oldInKeyframes; - return environment.toModule(css, extender); + if (configuration.isNotEmpty && _configuration.isNotEmpty) { + throw _exception( + namesInErrors + ? "\$${_configuration.keys.first} was not declared with " + "!default in the @used module." + : "This variable was not declared with !default in the @used " + "module.", + _configuration.values.first.configurationSpan); + } + _configuration = oldConfiguration; }); + + var module = environment.toModule(css, extender); + _modules[url] = module; + return module; } /// Returns a copy of [_root.children] with [_outOfOrderImports] inserted @@ -1059,10 +1203,31 @@ class _EvaluateVisitor } Future visitForwardRule(ForwardRule node) async { + // Only allow variables that are visible through the `@forward` to be + // configured. These views support [Map.remove] so we can mark when a + // configuration variable is used by removing it even when the underlying + // map is wrapped. + var oldConfiguration = _configuration; + if (_configuration.isNotEmpty) { + if (node.prefix != null) { + _configuration = UnprefixedMapView(_configuration, node.prefix); + } + + if (node.shownVariables != null) { + _configuration = + LimitedMapView.whitelist(_configuration, node.shownVariables); + } else if (node.hiddenVariables != null && + node.hiddenVariables.isNotEmpty) { + _configuration = + LimitedMapView.blacklist(_configuration, node.hiddenVariables); + } + } + await _loadModule(node.url, "@forward", node, (module) { _environment.forwardModule(module, node); }); + _configuration = oldConfiguration; return null; } @@ -1186,8 +1351,12 @@ class _EvaluateVisitor /// Loads the [Stylesheet] identified by [url], or throws a /// [SassRuntimeException] if loading fails. + /// + /// This first tries loading [url] relative to [baseUrl], which defaults to + /// `_stylesheet.span.sourceUrl`. Future> _loadStylesheet( - String url, FileSpan span) async { + String url, FileSpan span, + {Uri baseUrl}) async { try { assert(_importSpan == null); _importSpan = span; @@ -1197,7 +1366,7 @@ class _EvaluateVisitor if (stylesheet != null) return Tuple2(null, stylesheet); } else { var tuple = await _importCache.import( - Uri.parse(url), _importer, _stylesheet.span?.sourceUrl); + Uri.parse(url), _importer, baseUrl ?? _stylesheet?.span?.sourceUrl); if (tuple != null) return tuple; } @@ -1275,30 +1444,41 @@ class _EvaluateVisitor Future visitIncludeRule(IncludeRule node) async { var mixin = _addExceptionSpan(node, - () => _environment.getMixin(node.name, namespace: node.namespace)) - as UserDefinedCallable; + () => _environment.getMixin(node.name, namespace: node.namespace)); if (mixin == null) { throw _exception("Undefined mixin.", node.span); } - if (node.content != null && !(mixin.declaration as MixinRule).hasContent) { - throw _exception("Mixin doesn't accept a content block.", node.span); - } + if (mixin is AsyncBuiltInCallable) { + if (node.content != null) { + throw _exception("Mixin doesn't accept a content block.", node.span); + } - var contentCallable = node.content == null - ? null - : UserDefinedCallable(node.content, _environment.closure()); - await _runUserDefinedCallable(node.arguments, mixin, node, () async { - await _environment.withContent(contentCallable, () async { - await _environment.asMixin(() async { - for (var statement in mixin.declaration.children) { - await statement.accept(this); - } + await _runBuiltInCallable(node.arguments, mixin, node); + } else if (mixin is UserDefinedCallable) { + if (node.content != null && + !(mixin.declaration as MixinRule).hasContent) { + throw _exception("Mixin doesn't accept a content block.", node.span); + } + + var contentCallable = node.content == null + ? null + : UserDefinedCallable(node.content, _environment.closure()); + + await _runUserDefinedCallable(node.arguments, mixin, node, () async { + await _environment.withContent(contentCallable, () async { + await _environment.asMixin(() async { + for (var statement in mixin.declaration.children) { + await statement.accept(this); + } + }); + return null; }); return null; }); - return null; - }); + } else { + throw UnsupportedError("Unknown callable type $mixin."); + } return null; } @@ -1546,6 +1726,23 @@ class _EvaluateVisitor Future visitVariableDeclaration(VariableDeclaration node) async { if (node.isGuarded) { + if (node.namespace == null && _environment.atRoot) { + // Explicitly check whether [_configuration] is empty because if it is, + // it may be a constant map which doesn't support `remove()`. + // + // See also dart-lang/sdk#38540. + var override = + _configuration.isEmpty ? null : _configuration.remove(node.name); + if (override != null) { + _addExceptionSpan(node, () { + _environment.setVariable( + node.name, override.value, override.assignmentNode, + global: true); + }); + return null; + } + } + var value = _addExceptionSpan(node, () => _environment.getVariable(node.name, namespace: node.namespace)); if (value != null && value != sassNull) return null; @@ -1579,7 +1776,16 @@ class _EvaluateVisitor Future visitUseRule(UseRule node) async { await _loadModule(node.url, "@use", node, (module) { _environment.addModule(module, namespace: node.namespace); - }); + }, + configuration: node.configuration.isEmpty + ? null + : { + for (var entry in node.configuration.entries) + entry.key: _ConfiguredValue( + (await entry.value.item1.accept(this)).withoutSlash(), + entry.value.item2, + _expressionNode(entry.value.item1)) + }); return null; } @@ -1868,8 +2074,13 @@ class _EvaluateVisitor Future _runFunctionCallable(ArgumentInvocation arguments, AsyncCallable callable, AstNode nodeWithSpan) async { if (callable is AsyncBuiltInCallable) { - return (await _runBuiltInCallable(arguments, callable, nodeWithSpan)) - .withoutSlash(); + var result = await _runBuiltInCallable(arguments, callable, nodeWithSpan); + if (result == null) { + throw _exception( + "Custom functions may not return Dart's null.", nodeWithSpan.span); + } + + return result.withoutSlash(); } else if (callable is UserDefinedCallable) { return (await _runUserDefinedCallable(arguments, callable, nodeWithSpan, () async { @@ -1959,7 +2170,8 @@ class _EvaluateVisitor Value result; try { result = await callback(evaluated.positional); - if (result == null) throw "Custom functions may not return Dart's null."; + } on SassRuntimeException { + rethrow; } catch (error) { String message; try { @@ -2754,3 +2966,19 @@ class _ArgumentResults { _ArgumentResults(this.positional, this.named, this.separator, {this.positionalNodes, this.namedNodes}); } + +/// A variable value that's been configured using `@use ... with`. +class _ConfiguredValue { + /// The value of the variable. + final Value value; + + /// The span where the variable's configuration was written. + final FileSpan configurationSpan; + + /// The [AstNode] where the variable's value originated. + /// + /// This is used to generate source maps. + final AstNode assignmentNode; + + _ConfiguredValue(this.value, this.configurationSpan, [this.assignmentNode]); +} diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 5896b779a..ebd2d7dc6 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: d79b881dbbe875fafde129b908aab5e7ff532a87 +// Checksum: d520e2c69342b6e9c9ce0ddf6e75ac74bdf80574 // // ignore_for_file: unused_import @@ -46,6 +46,8 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../util/fixed_length_list_builder.dart'; +import '../util/limited_map_view.dart'; +import '../util/unprefixed_map_view.dart'; import '../utils.dart'; import '../value.dart'; import '../warn.dart'; @@ -93,24 +95,35 @@ EvaluateResult evaluate(Stylesheet stylesheet, sourceMap: sourceMap) .run(importer, stylesheet); -/// Evaluates a single [expression] -/// -/// The [functions] are available as global functions when evaluating -/// [expression]. -/// -/// The [variables] are available as global variables when evaluating -/// [expression]. -/// -/// Warnings are emitted using [logger], or printed to standard error by -/// default. -/// -/// Throws a [SassRuntimeException] if evaluation fails. -Value evaluateExpression(Expression expression, - {Iterable functions, - Map variables, - Logger logger}) => - _EvaluateVisitor(functions: functions, logger: logger, sourceMap: false) - .runExpression(expression, variables: variables); +/// A class that can evaluate multiple independent statements and expressions +/// in the context of a single module. +class Evaluator { + /// The visitor that evaluates each expression and statement. + final _EvaluateVisitor _visitor; + + /// The importer to use to resolve `@use` rules in [_importer]. + final Importer _importer; + + /// Creates an evaluator. + /// + /// Arguments are the same as for [evaluate]. + Evaluator( + {ImportCache importCache, + Importer importer, + Iterable functions, + Logger logger}) + : _visitor = _EvaluateVisitor( + importCache: importCache, functions: functions, logger: logger), + _importer = importer; + + void use(UseRule use) => _visitor.runStatement(_importer, use); + + Value evaluate(Expression expression) => + _visitor.runExpression(_importer, expression); + + void setVariable(VariableDeclaration declaration) => + _visitor.runStatement(_importer, declaration); +} /// A visitor that executes Sass code to produce a CSS tree. class _EvaluateVisitor @@ -246,18 +259,32 @@ class _EvaluateVisitor /// module. Extender _extender; + /// A map from variable names to the values that override their `!default` + /// definitions in this module. + /// + /// If this is empty, that indicates that the current module is not confiured. + /// Note that it may be unmodifiable when empty, in which case [Map.remove] + /// must not be called. + var _configuration = const {}; + + /// Creates a new visitor. + /// + /// Most arguments are the same as those to [evaluate]. _EvaluateVisitor( {ImportCache importCache, NodeImporter nodeImporter, Iterable functions, Logger logger, - bool sourceMap}) + bool sourceMap = false}) : _importCache = nodeImporter == null ? importCache ?? ImportCache.none(logger: logger) : null, _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), - _sourceMap = sourceMap { + _sourceMap = sourceMap, + // The default environment is overridden in [_execute] for full + // stylesheets, but for [AsyncEvaluator] this environment is used. + _environment = Environment(sourceMap: sourceMap) { var metaFunctions = [ // These functions are defined in the context of the evaluator because // they need access to the [_environment] or other local state. @@ -387,7 +414,38 @@ class _EvaluateVisitor }) ]; - var metaModule = BuiltInModule("meta", [...meta.global, ...metaFunctions]); + var metaMixins = [ + BuiltInCallable("load-css", r"$module, $with: null", (arguments) { + var url = Uri.parse(arguments[0].assertString("module").text); + var withMap = arguments[1].realNull?.assertMap("with")?.contents; + + Map configuration; + if (withMap != null) { + configuration = {}; + var span = _callableNode.span; + withMap.forEach((variable, value) { + var name = + variable.assertString("with key").text.replaceAll("_", "-"); + if (configuration.containsKey(name)) { + throw "The variable \$$name was configured twice."; + } + + configuration[name] = _ConfiguredValue(value, span); + }); + } + + _loadModule(url, "load-css()", _callableNode, + (module) => _combineCss(module, clone: true).accept(this), + baseUrl: _callableNode.span?.sourceUrl, + configuration: configuration, + namesInErrors: true); + return null; + }) + ]; + + var metaModule = BuiltInModule("meta", + functions: [...meta.global, ...metaFunctions], mixins: metaMixins); + for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } @@ -418,17 +476,13 @@ class _EvaluateVisitor }); } - Value runExpression(Expression expression, {Map variables}) { - return _withWarnCallback(() { - _environment = Environment(sourceMap: _sourceMap); - - for (var name in variables?.keys ?? const []) { - _environment.setVariable(name, variables[name], null, global: true); - } + Value runExpression(Importer importer, Expression expression) => + _withWarnCallback(() => _withFakeStylesheet( + importer, expression, () => expression.accept(this))); - return expression.accept(this); - }); - } + void runStatement(Importer importer, Statement statement) => + _withWarnCallback(() => _withFakeStylesheet( + importer, statement, () => statement.accept(this))); /// Runs [callback] with a definition for the top-level `warn` function. T _withWarnCallback(T callback()) { @@ -439,39 +493,89 @@ class _EvaluateVisitor callback); } + /// Runs [callback] with [importer] as [_importer] and a fake [_stylesheet] + /// with [nodeForSpan]'s source span. + T _withFakeStylesheet( + Importer importer, AstNode nodeForSpan, T callback()) { + var oldImporter = _importer; + _importer = importer; + var oldStylesheet = _stylesheet; + _stylesheet = Stylesheet(const [], nodeForSpan.span); + + try { + return callback(); + } finally { + _importer = oldImporter; + _stylesheet = oldStylesheet; + } + } + /// Loads the module at [url] and passes it to [callback]. /// + /// This first tries loading [url] relative to [baseUrl], which defaults to + /// `_stylesheet.span.sourceUrl`. + /// + /// The [configuration] overrides values for `!default` variables defined in + /// the module or modules it forwards and/or imports. If it's not passed, the + /// current configuration is used instead. Throws a [SassRuntimeException] if + /// a configured variable is not declared with `!default`. + /// + /// 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. + /// /// The [stackFrame] and [nodeForSpan] are used for the name and location of /// the stack frame for the duration of the [callback]. void _loadModule(Uri url, String stackFrame, AstNode nodeForSpan, - void callback(Module module)) { + void callback(Module module), + {Uri baseUrl, + Map configuration, + bool namesInErrors = false}) { + configuration ??= const {}; + var builtInModule = _builtInModules[url]; if (builtInModule != null) { + if (configuration.isNotEmpty || _configuration.isNotEmpty) { + throw _exception( + namesInErrors + ? "Built-in module $url can't be configured." + : "Built-in modules can't be configured.", + nodeForSpan.span); + } + callback(builtInModule); return; } _withStackFrame(stackFrame, nodeForSpan, () { - var result = - inUseRule(() => _loadStylesheet(url.toString(), nodeForSpan.span)); + var result = inUseRule(() => + _loadStylesheet(url.toString(), nodeForSpan.span, baseUrl: baseUrl)); var importer = result.item1; var stylesheet = result.item2; var canonicalUrl = stylesheet.span.sourceUrl; if (_activeModules.contains(canonicalUrl)) { - throw _exception("Module loop: this module is already being loaded."); + throw _exception(namesInErrors + ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " + "loaded." + : "Module loop: this module is already being loaded."); } _activeModules.add(canonicalUrl); Module module; try { - module = _execute(importer, stylesheet); + module = _execute(importer, stylesheet, + configuration: configuration, namesInErrors: namesInErrors); } finally { _activeModules.remove(canonicalUrl); } try { callback(module); + } on SassRuntimeException { + rethrow; + } on SassException catch (error) { + throw _exception(error.message, error.span); } on SassScriptException catch (error) { throw _exception(error.message); } @@ -479,62 +583,102 @@ class _EvaluateVisitor } /// Executes [stylesheet], loaded by [importer], to produce a module. - Module _execute(Importer importer, Stylesheet stylesheet) { + /// + /// The [configuration] overrides values for `!default` variables defined in + /// the module or modules it forwards and/or imports. If it's not passed, the + /// current configuration is used instead. Throws a [SassRuntimeException] if + /// a configured variable is not declared with `!default`. + /// + /// 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. + Module _execute(Importer importer, Stylesheet stylesheet, + {Map configuration, + bool namesInErrors = false}) { + configuration ??= const {}; var url = stylesheet.span.sourceUrl; - return _modules.putIfAbsent(url, () { - var environment = Environment(sourceMap: _sourceMap); - CssStylesheet css; - var extender = Extender(); - _withEnvironment(environment, () { - var oldImporter = _importer; - var oldStylesheet = _stylesheet; - var oldRoot = _root; - var oldParent = _parent; - var oldEndOfImports = _endOfImports; - var oldOutOfOrderImports = _outOfOrderImports; - var oldExtender = _extender; - var oldStyleRule = _styleRule; - var oldMediaQueries = _mediaQueries; - var oldDeclarationName = _declarationName; - var oldInUnknownAtRule = _inUnknownAtRule; - var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; - var oldInKeyframes = _inKeyframes; - _importer = importer; - _stylesheet = stylesheet; - _root = ModifiableCssStylesheet(stylesheet.span); - _parent = _root; - _endOfImports = 0; - _outOfOrderImports = null; - _extender = extender; - _styleRule = null; - _mediaQueries = null; - _declarationName = null; - _inUnknownAtRule = false; - _atRootExcludingStyleRule = false; - _inKeyframes = false; - visitStylesheet(stylesheet); - css = _outOfOrderImports == null - ? _root - : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + var alreadyLoaded = _modules[url]; + if (alreadyLoaded != null) { + if (configuration.isNotEmpty || _configuration.isNotEmpty) { + throw _exception(namesInErrors + ? "${p.prettyUri(url)} was already loaded, so it can't be " + "configured using \"with\"." + : "This module was already loaded, so it can't be configured using " + "\"with\"."); + } - _importer = oldImporter; - _stylesheet = oldStylesheet; - _root = oldRoot; - _parent = oldParent; - _endOfImports = oldEndOfImports; - _outOfOrderImports = oldOutOfOrderImports; - _extender = oldExtender; - _styleRule = oldStyleRule; - _mediaQueries = oldMediaQueries; - _declarationName = oldDeclarationName; - _inUnknownAtRule = oldInUnknownAtRule; - _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; - _inKeyframes = oldInKeyframes; - }); + return alreadyLoaded; + } + + var environment = Environment(sourceMap: _sourceMap); + CssStylesheet css; + var extender = Extender(); + _withEnvironment(environment, () { + var oldImporter = _importer; + var oldStylesheet = _stylesheet; + var oldRoot = _root; + var oldParent = _parent; + var oldEndOfImports = _endOfImports; + var oldOutOfOrderImports = _outOfOrderImports; + var oldExtender = _extender; + var oldStyleRule = _styleRule; + var oldMediaQueries = _mediaQueries; + var oldDeclarationName = _declarationName; + var oldInUnknownAtRule = _inUnknownAtRule; + var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; + var oldInKeyframes = _inKeyframes; + var oldConfiguration = _configuration; + _importer = importer; + _stylesheet = stylesheet; + _root = ModifiableCssStylesheet(stylesheet.span); + _parent = _root; + _endOfImports = 0; + _outOfOrderImports = null; + _extender = extender; + _styleRule = null; + _mediaQueries = null; + _declarationName = null; + _inUnknownAtRule = false; + _atRootExcludingStyleRule = false; + _inKeyframes = false; + + if (configuration.isNotEmpty) _configuration = Map.of(configuration); + + visitStylesheet(stylesheet); + css = _outOfOrderImports == null + ? _root + : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + + _importer = oldImporter; + _stylesheet = oldStylesheet; + _root = oldRoot; + _parent = oldParent; + _endOfImports = oldEndOfImports; + _outOfOrderImports = oldOutOfOrderImports; + _extender = oldExtender; + _styleRule = oldStyleRule; + _mediaQueries = oldMediaQueries; + _declarationName = oldDeclarationName; + _inUnknownAtRule = oldInUnknownAtRule; + _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + _inKeyframes = oldInKeyframes; - return environment.toModule(css, extender); + if (configuration.isNotEmpty && _configuration.isNotEmpty) { + throw _exception( + namesInErrors + ? "\$${_configuration.keys.first} was not declared with " + "!default in the @used module." + : "This variable was not declared with !default in the @used " + "module.", + _configuration.values.first.configurationSpan); + } + _configuration = oldConfiguration; }); + + var module = environment.toModule(css, extender); + _modules[url] = module; + return module; } /// Returns a copy of [_root.children] with [_outOfOrderImports] inserted @@ -1060,10 +1204,31 @@ class _EvaluateVisitor } Value visitForwardRule(ForwardRule node) { + // Only allow variables that are visible through the `@forward` to be + // configured. These views support [Map.remove] so we can mark when a + // configuration variable is used by removing it even when the underlying + // map is wrapped. + var oldConfiguration = _configuration; + if (_configuration.isNotEmpty) { + if (node.prefix != null) { + _configuration = UnprefixedMapView(_configuration, node.prefix); + } + + if (node.shownVariables != null) { + _configuration = + LimitedMapView.whitelist(_configuration, node.shownVariables); + } else if (node.hiddenVariables != null && + node.hiddenVariables.isNotEmpty) { + _configuration = + LimitedMapView.blacklist(_configuration, node.hiddenVariables); + } + } + _loadModule(node.url, "@forward", node, (module) { _environment.forwardModule(module, node); }); + _configuration = oldConfiguration; return null; } @@ -1187,7 +1352,11 @@ class _EvaluateVisitor /// Loads the [Stylesheet] identified by [url], or throws a /// [SassRuntimeException] if loading fails. - Tuple2 _loadStylesheet(String url, FileSpan span) { + /// + /// This first tries loading [url] relative to [baseUrl], which defaults to + /// `_stylesheet.span.sourceUrl`. + Tuple2 _loadStylesheet(String url, FileSpan span, + {Uri baseUrl}) { try { assert(_importSpan == null); _importSpan = span; @@ -1197,7 +1366,7 @@ class _EvaluateVisitor if (stylesheet != null) return Tuple2(null, stylesheet); } else { var tuple = _importCache.import( - Uri.parse(url), _importer, _stylesheet.span?.sourceUrl); + Uri.parse(url), _importer, baseUrl ?? _stylesheet?.span?.sourceUrl); if (tuple != null) return tuple; } @@ -1274,30 +1443,41 @@ class _EvaluateVisitor Value visitIncludeRule(IncludeRule node) { var mixin = _addExceptionSpan(node, - () => _environment.getMixin(node.name, namespace: node.namespace)) - as UserDefinedCallable; + () => _environment.getMixin(node.name, namespace: node.namespace)); if (mixin == null) { throw _exception("Undefined mixin.", node.span); } - if (node.content != null && !(mixin.declaration as MixinRule).hasContent) { - throw _exception("Mixin doesn't accept a content block.", node.span); - } + if (mixin is BuiltInCallable) { + if (node.content != null) { + throw _exception("Mixin doesn't accept a content block.", node.span); + } - var contentCallable = node.content == null - ? null - : UserDefinedCallable(node.content, _environment.closure()); - _runUserDefinedCallable(node.arguments, mixin, node, () { - _environment.withContent(contentCallable, () { - _environment.asMixin(() { - for (var statement in mixin.declaration.children) { - statement.accept(this); - } + _runBuiltInCallable(node.arguments, mixin, node); + } else if (mixin is UserDefinedCallable) { + if (node.content != null && + !(mixin.declaration as MixinRule).hasContent) { + throw _exception("Mixin doesn't accept a content block.", node.span); + } + + var contentCallable = node.content == null + ? null + : UserDefinedCallable(node.content, _environment.closure()); + + _runUserDefinedCallable(node.arguments, mixin, node, () { + _environment.withContent(contentCallable, () { + _environment.asMixin(() { + for (var statement in mixin.declaration.children) { + statement.accept(this); + } + }); + return null; }); return null; }); - return null; - }); + } else { + throw UnsupportedError("Unknown callable type $mixin."); + } return null; } @@ -1540,6 +1720,23 @@ class _EvaluateVisitor Value visitVariableDeclaration(VariableDeclaration node) { if (node.isGuarded) { + if (node.namespace == null && _environment.atRoot) { + // Explicitly check whether [_configuration] is empty because if it is, + // it may be a constant map which doesn't support `remove()`. + // + // See also dart-lang/sdk#38540. + var override = + _configuration.isEmpty ? null : _configuration.remove(node.name); + if (override != null) { + _addExceptionSpan(node, () { + _environment.setVariable( + node.name, override.value, override.assignmentNode, + global: true); + }); + return null; + } + } + var value = _addExceptionSpan(node, () => _environment.getVariable(node.name, namespace: node.namespace)); if (value != null && value != sassNull) return null; @@ -1573,7 +1770,16 @@ class _EvaluateVisitor Value visitUseRule(UseRule node) { _loadModule(node.url, "@use", node, (module) { _environment.addModule(module, namespace: node.namespace); - }); + }, + configuration: node.configuration.isEmpty + ? null + : { + for (var entry in node.configuration.entries) + entry.key: _ConfiguredValue( + entry.value.item1.accept(this).withoutSlash(), + entry.value.item2, + _expressionNode(entry.value.item1)) + }); return null; } @@ -1857,8 +2063,13 @@ class _EvaluateVisitor Value _runFunctionCallable( ArgumentInvocation arguments, Callable callable, AstNode nodeWithSpan) { if (callable is BuiltInCallable) { - return _runBuiltInCallable(arguments, callable, nodeWithSpan) - .withoutSlash(); + var result = _runBuiltInCallable(arguments, callable, nodeWithSpan); + if (result == null) { + throw _exception( + "Custom functions may not return Dart's null.", nodeWithSpan.span); + } + + return result.withoutSlash(); } else if (callable is UserDefinedCallable) { return _runUserDefinedCallable(arguments, callable, nodeWithSpan, () { for (var statement in callable.declaration.children) { @@ -1946,7 +2157,8 @@ class _EvaluateVisitor Value result; try { result = callback(evaluated.positional); - if (result == null) throw "Custom functions may not return Dart's null."; + } on SassRuntimeException { + rethrow; } catch (error) { String message; try { @@ -2703,3 +2915,19 @@ class _ArgumentResults { _ArgumentResults(this.positional, this.named, this.separator, {this.positionalNodes, this.namedNodes}); } + +/// A variable value that's been configured using `@use ... with`. +class _ConfiguredValue { + /// The value of the variable. + final Value value; + + /// The span where the variable's configuration was written. + final FileSpan configurationSpan; + + /// The [AstNode] where the variable's value originated. + /// + /// This is used to generate source maps. + final AstNode assignmentNode; + + _ConfiguredValue(this.value, this.configurationSpan, [this.assignmentNode]); +} diff --git a/pubspec.yaml b/pubspec.yaml index e0eb14acf..7047712d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.22.12 +version: 1.23.0 description: A Sass implementation in Dart. author: Sass Team homepage: https://github.com/sass/dart-sass diff --git a/test/cli/shared/repl.dart b/test/cli/shared/repl.dart index a609e5869..2705d3610 100644 --- a/test/cli/shared/repl.dart +++ b/test/cli/shared/repl.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; import 'package:test_process/test_process.dart'; /// Defines test that are shared between the Dart and Node.js CLI test suites. @@ -13,7 +14,6 @@ void sharedTests(Future runSass(Iterable arguments)) { var invalidArgs = [ '--stdin', '--indented', - '--load-path=x', '--style=compressed', '--source-map', '--source-map-urls=absolute', @@ -78,6 +78,91 @@ void sharedTests(Future runSass(Iterable arguments)) { await sass.kill(); }); + group("with @use", () { + test("uses variables from the @used module", () async { + await d.file("other.scss", r"$var: 12;").create(); + + var sass = await runSass(["--interactive"]); + sass.stdin.writeln("@use 'other'"); + await expectLater(sass.stdout, emits(">> @use 'other'")); + + sass.stdin.writeln(r"other.$var"); + await expectLater(sass.stdout, emitsInOrder([r">> other.$var", "12"])); + + await sass.kill(); + }); + + test("uses functions from the @used module", () async { + await d.file("other.scss", r"@function foo() {@return 12}").create(); + + var sass = await runSass(["--interactive"]); + sass.stdin.writeln("@use 'other'"); + await expectLater(sass.stdout, emits(">> @use 'other'")); + + sass.stdin.writeln(r"other.foo()"); + await expectLater(sass.stdout, emitsInOrder([">> other.foo()", "12"])); + + await sass.kill(); + }); + + test("uses a built-in module", () async { + var sass = await runSass(["--interactive"]); + sass.stdin.writeln("@use 'sass:math'"); + await expectLater(sass.stdout, emits(">> @use 'sass:math'")); + + sass.stdin.writeln(r"math.abs(-1)"); + await expectLater(sass.stdout, emitsInOrder([">> math.abs(-1)", "1"])); + + await sass.kill(); + }); + + test("loads a module from the load path", () async { + await d.dir("dir", [d.file("other.scss", r"$var: 12;")]).create(); + + var sass = await runSass(["--load-path=dir", "--interactive"]); + sass.stdin.writeln("@use 'other'"); + await expectLater(sass.stdout, emits(">> @use 'other'")); + + sass.stdin.writeln(r"other.$var"); + await expectLater(sass.stdout, emitsInOrder([r">> other.$var", "12"])); + + await sass.kill(); + }); + + test("loads a module in the global scope", () async { + await d.file("other.scss", r"$var: 12;").create(); + + var sass = await runSass(["--interactive"]); + sass.stdin.writeln("@use 'other' as *"); + await expectLater(sass.stdout, emits(">> @use 'other' as *")); + + sass.stdin.writeln(r"$var"); + await expectLater(sass.stdout, emitsInOrder([r">> $var", "12"])); + + await sass.kill(); + }); + + test("loads a module with configuration", () async { + await d.file("other.scss", r""" + $var: 12 !default; + $derived: $var + 13; + """).create(); + + var sass = await runSass(["--interactive"]); + sass.stdin.writeln(r"@use 'other' with ($var: 1)"); + await expectLater(sass.stdout, emits(r">> @use 'other' with ($var: 1)")); + + sass.stdin.writeln(r"other.$var"); + await expectLater(sass.stdout, emitsInOrder([r">> other.$var", "1"])); + + sass.stdin.writeln(r"other.$derived"); + await expectLater( + sass.stdout, emitsInOrder([r">> other.$derived", "14"])); + + await sass.kill(); + }); + }); + group("gracefully handles", () { test("a parse error", () async { var sass = await runSass(["--interactive"]); @@ -129,7 +214,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await expectLater( sass.stdout, emitsInOrder( - [r">> 1 + $x + 3", r" ^^", "Error: Undefined variable."])); + [r">> 1 + $x + 3", " ^^", "Error: Undefined variable."])); await sass.kill(); }); @@ -150,6 +235,70 @@ void sharedTests(Future runSass(Iterable arguments)) { await sass.kill(); }); + group("with @use", () { + test("a module load error", () async { + var sass = await runSass(["--no-unicode", "--interactive"]); + sass.stdin.writeln('@use "non-existent"'); + await expectLater( + sass.stdout, + emitsInOrder([ + '>> @use "non-existent"', + " ^^^^^^^^^^^^^^^^^^^", + "Error: Can't find stylesheet to import." + ])); + await sass.kill(); + }); + + test("a parse error for @use", () async { + var sass = await runSass(["--no-unicode", "--interactive"]); + sass.stdin.writeln('@use "other" as'); + await expectLater( + sass.stdout, + emitsInOrder([ + '>> @use "other" as', + " ^", + "Error: Expected identifier." + ])); + await sass.kill(); + }); + + test("a parse error in a loaded module", () async { + await d.file("other.scss", r"$var: 1px +").create(); + + var sass = await runSass(["--no-unicode", "--interactive"]); + sass.stdin.writeln('@use "other"'); + await expectLater( + sass.stdout, + emitsInOrder([ + '>> @use "other"', + "Error: Expected expression.", + " ,", + r"1 | $var: 1px +", + " | ^", + " '" + ])); + await sass.kill(); + }); + + test("a runtime error in a loaded module", () async { + await d.file("other.scss", r"$var: 1px + 1s;").create(); + + var sass = await runSass(["--no-unicode", "--interactive"]); + sass.stdin.writeln('@use "other"'); + await expectLater( + sass.stdout, + emitsInOrder([ + '>> @use "other"', + "Error: Incompatible units s and px.", + " ,", + r"1 | $var: 1px + 1s;", + " | ^^^^^^^^", + " '" + ])); + await sass.kill(); + }); + }); + group("and colorizes", () { test("an error in the source text", () async { var sass = await runSass(["--interactive", "--color"]); diff --git a/test/source_map_test.dart b/test/source_map_test.dart index fb698e0f6..8e1d24432 100644 --- a/test/source_map_test.dart +++ b/test/source_map_test.dart @@ -12,6 +12,8 @@ import 'package:tuple/tuple.dart'; import 'package:sass/sass.dart'; import 'package:sass/src/utils.dart'; +import 'dart_api/test_importer.dart'; + main() { group("maps source to target for", () { group("a style rule", () { @@ -543,6 +545,25 @@ main() { """); }); + test("a @use rule with a with clause", () { + _expectScssSourceMap(r""" + $var1: {{1}}new value; + @use 'other' with ($var2: $var1); + + {{2}}a { + {{3}}b: other.$var2; + } + """, """ + {{2}}a { + {{3}}b: {{1}}new value; + } + """, + importer: TestImporter( + (url) => Uri.parse("u:$url"), + (_) => ImporterResult(r"$var2: default value !default;", + syntax: Syntax.scss))); + }); + group("a mixin argument that is", () { test("the default value", () { _expectScssSourceMap(r""" @@ -689,13 +710,14 @@ main() { /// /// This also re-indents the input strings with [_reindent]. void _expectSourceMap(String sass, String scss, String css, - {OutputStyle style}) { - _expectSassSourceMap(sass, css, style: style); - _expectScssSourceMap(scss, css, style: style); + {Importer importer, OutputStyle style}) { + _expectSassSourceMap(sass, css, importer: importer, style: style); + _expectScssSourceMap(scss, css, importer: importer, style: style); } /// Like [_expectSourceMap], but with only SCSS source. -void _expectScssSourceMap(String scss, String css, {OutputStyle style}) { +void _expectScssSourceMap(String scss, String css, + {Importer importer, OutputStyle style}) { var scssTuple = _extractLocations(_reindent(scss)); var scssText = scssTuple.item1; var scssLocations = _tuplesToMap(scssTuple.item2); @@ -705,14 +727,15 @@ void _expectScssSourceMap(String scss, String css, {OutputStyle style}) { var cssLocations = cssTuple.item2; SingleMapping scssMap; - var scssOutput = - compileString(scssText, sourceMap: (map) => scssMap = map, style: style); + var scssOutput = compileString(scssText, + sourceMap: (map) => scssMap = map, importer: importer, style: style); expect(scssOutput, equals(cssText)); _expectMapMatches(scssMap, scssText, cssText, scssLocations, cssLocations); } /// Like [_expectSourceMap], but with only indented source. -void _expectSassSourceMap(String sass, String css, {OutputStyle style}) { +void _expectSassSourceMap(String sass, String css, + {Importer importer, OutputStyle style}) { var sassTuple = _extractLocations(_reindent(sass)); var sassText = sassTuple.item1; var sassLocations = _tuplesToMap(sassTuple.item2); @@ -723,7 +746,10 @@ void _expectSassSourceMap(String sass, String css, {OutputStyle style}) { SingleMapping sassMap; var sassOutput = compileString(sassText, - indented: true, sourceMap: (map) => sassMap = map, style: style); + indented: true, + sourceMap: (map) => sassMap = map, + importer: importer, + style: style); expect(sassOutput, equals(cssText)); _expectMapMatches(sassMap, sassText, cssText, sassLocations, cssLocations); }