diff --git a/.github/util/initialize/action.yml b/.github/util/initialize/action.yml index 2fba83ffa..769f1b7b5 100644 --- a/.github/util/initialize/action.yml +++ b/.github/util/initialize/action.yml @@ -31,7 +31,7 @@ runs: - run: npm install shell: ${{ runner.os == 'Windows' && 'powershell' || 'bash' }} - - uses: bufbuild/buf-setup-action@v1.46.0 + - uses: bufbuild/buf-setup-action@v1.48.0 with: {github_token: "${{ inputs.github-token }}"} # This composite action requires bash, but bash is not available on windows-arm64 runner. diff --git a/CHANGELOG.md b/CHANGELOG.md index 397dc612c..4c2186b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 1.83.1 + +* Fix a bug where `--quiet-deps` would get deactivated for `@content` blocks, + even when those blocks were entirely contained within dependencies. + +* Include deprecation IDs in deprecation warnings to make it easier to determine + what to pass to `--silence-deprecation` or `--fatal-deprecation`. + +## 1.83.0 + +* Allow trailing commas in *all* argument and parameter lists. + +## 1.82.0 + +### Command-Line Interface + +* Improve `--watch` mode reliability when making multiple changes at once, such + as checking out a different Git branch. + +* Parse the `calc-size()` function as a calculation now that it's supported in + some browsers. + +### Dart API + +* Add a `SassCalculation.calcSize()` function. + +## 1.81.1 + +* No user-visible changes. + ## 1.81.0 * Fix a few cases where deprecation warnings weren't being emitted for global diff --git a/lib/src/README.md b/lib/src/README.md index e7b53cd91..b00b2b578 100644 --- a/lib/src/README.md +++ b/lib/src/README.md @@ -167,12 +167,12 @@ that we make available to users. [`FilesystemImporter`]: importer/filesystem.dart -In the Dart API, the importer root class is [`importer/async_importer.dart`]. +In the Dart API, the importer root class is [`importer/async.dart`]. The JS API and the embedded compiler wrap the Dart importer API in -[`importer/node_to_dart`] and [`embedded/importer`] respectively. +[`importer/js_to_dart`] and [`embedded/importer`] respectively. -[`importer/async_importer.dart`]: importer/async_importer.dart -[`importer/node_to_dart`]: importer/node_to_dart +[`importer/async.dart`]: importer/async.dart +[`importer/js_to_dart`]: importer/js_to_dart [`embedded/importer`]: embedded/importer ### Custom Functions diff --git a/lib/src/ast/sass.dart b/lib/src/ast/sass.dart index 149641670..f4deef43b 100644 --- a/lib/src/ast/sass.dart +++ b/lib/src/ast/sass.dart @@ -2,9 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -export 'sass/argument.dart'; -export 'sass/argument_declaration.dart'; -export 'sass/argument_invocation.dart'; +export 'sass/argument_list.dart'; export 'sass/at_root_query.dart'; export 'sass/callable_invocation.dart'; export 'sass/configured_variable.dart'; @@ -33,6 +31,8 @@ export 'sass/import/dynamic.dart'; export 'sass/import/static.dart'; export 'sass/interpolation.dart'; export 'sass/node.dart'; +export 'sass/parameter.dart'; +export 'sass/parameter_list.dart'; export 'sass/reference.dart'; export 'sass/statement.dart'; export 'sass/statement/at_root_rule.dart'; diff --git a/lib/src/ast/sass/argument_invocation.dart b/lib/src/ast/sass/argument_list.dart similarity index 95% rename from lib/src/ast/sass/argument_invocation.dart rename to lib/src/ast/sass/argument_list.dart index 92e7645cf..84a53b67e 100644 --- a/lib/src/ast/sass/argument_invocation.dart +++ b/lib/src/ast/sass/argument_list.dart @@ -13,7 +13,7 @@ import 'node.dart'; /// A set of arguments passed in to a function or mixin. /// /// {@category AST} -final class ArgumentInvocation implements SassNode { +final class ArgumentList implements SassNode { /// The arguments passed by position. final List positional; @@ -31,7 +31,7 @@ final class ArgumentInvocation implements SassNode { /// Returns whether this invocation passes no arguments. bool get isEmpty => positional.isEmpty && named.isEmpty && rest == null; - ArgumentInvocation( + ArgumentList( Iterable positional, Map named, this.span, {this.rest, this.keywordRest}) : positional = List.unmodifiable(positional), @@ -40,7 +40,7 @@ final class ArgumentInvocation implements SassNode { } /// Creates an invocation that passes no arguments. - ArgumentInvocation.empty(this.span) + ArgumentList.empty(this.span) : positional = const [], named = const {}, rest = null, diff --git a/lib/src/ast/sass/callable_invocation.dart b/lib/src/ast/sass/callable_invocation.dart index ed0a5ddb7..93aa40b2d 100644 --- a/lib/src/ast/sass/callable_invocation.dart +++ b/lib/src/ast/sass/callable_invocation.dart @@ -4,7 +4,7 @@ import 'package:meta/meta.dart'; -import 'argument_invocation.dart'; +import 'argument_list.dart'; import 'node.dart'; /// An abstract class for invoking a callable (a function or mixin). @@ -13,5 +13,5 @@ import 'node.dart'; @sealed abstract class CallableInvocation implements SassNode { /// The arguments passed to the callable. - ArgumentInvocation get arguments; + ArgumentList get arguments; } diff --git a/lib/src/ast/sass/expression/function.dart b/lib/src/ast/sass/expression/function.dart index 0f2fce7eb..78b7c734a 100644 --- a/lib/src/ast/sass/expression/function.dart +++ b/lib/src/ast/sass/expression/function.dart @@ -7,7 +7,7 @@ import 'package:source_span/source_span.dart'; import '../../../util/span.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; -import '../argument_invocation.dart'; +import '../argument_list.dart'; import '../callable_invocation.dart'; import '../reference.dart'; @@ -33,7 +33,7 @@ final class FunctionExpression extends Expression final String originalName; /// The arguments to pass to the function. - final ArgumentInvocation arguments; + final ArgumentList arguments; final FileSpan span; diff --git a/lib/src/ast/sass/expression/if.dart b/lib/src/ast/sass/expression/if.dart index 95e305e47..1cec007e0 100644 --- a/lib/src/ast/sass/expression/if.dart +++ b/lib/src/ast/sass/expression/if.dart @@ -16,11 +16,11 @@ import '../../../visitor/interface/expression.dart'; /// {@category AST} final class IfExpression extends Expression implements CallableInvocation { /// The declaration of `if()`, as though it were a normal function. - static final declaration = ArgumentDeclaration.parse( - r"@function if($condition, $if-true, $if-false) {"); + static final declaration = + ParameterList.parse(r"@function if($condition, $if-true, $if-false) {"); /// The arguments passed to `if()`. - final ArgumentInvocation arguments; + final ArgumentList arguments; final FileSpan span; diff --git a/lib/src/ast/sass/expression/interpolated_function.dart b/lib/src/ast/sass/expression/interpolated_function.dart index cd5e2abf2..8e72c71ee 100644 --- a/lib/src/ast/sass/expression/interpolated_function.dart +++ b/lib/src/ast/sass/expression/interpolated_function.dart @@ -6,7 +6,7 @@ import 'package:source_span/source_span.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; -import '../argument_invocation.dart'; +import '../argument_list.dart'; import '../callable_invocation.dart'; import '../interpolation.dart'; @@ -21,7 +21,7 @@ final class InterpolatedFunctionExpression extends Expression final Interpolation name; /// The arguments to pass to the function. - final ArgumentInvocation arguments; + final ArgumentList arguments; final FileSpan span; diff --git a/lib/src/ast/sass/argument.dart b/lib/src/ast/sass/parameter.dart similarity index 77% rename from lib/src/ast/sass/argument.dart rename to lib/src/ast/sass/parameter.dart index afd9e337c..0ddcdb748 100644 --- a/lib/src/ast/sass/argument.dart +++ b/lib/src/ast/sass/parameter.dart @@ -10,14 +10,14 @@ import 'expression.dart'; import 'declaration.dart'; import 'node.dart'; -/// An argument declared as part of an [ArgumentDeclaration]. +/// An parameter declared as part of an [ParameterList]. /// /// {@category AST} -final class Argument implements SassNode, SassDeclaration { - /// The argument name. +final class Parameter implements SassNode, SassDeclaration { + /// The parameter name. final String name; - /// The default value of this argument, or `null` if none was declared. + /// The default value of this parameter, or `null` if none was declared. final Expression? defaultValue; final FileSpan span; @@ -33,7 +33,7 @@ final class Argument implements SassNode, SassDeclaration { FileSpan get nameSpan => defaultValue == null ? span : span.initialIdentifier(includeLeading: 1); - Argument(this.name, this.span, {this.defaultValue}); + Parameter(this.name, this.span, {this.defaultValue}); String toString() => defaultValue == null ? name : "$name: $defaultValue"; } diff --git a/lib/src/ast/sass/argument_declaration.dart b/lib/src/ast/sass/parameter_list.dart similarity index 54% rename from lib/src/ast/sass/argument_declaration.dart rename to lib/src/ast/sass/parameter_list.dart index ed1951cad..ea5e3fae7 100644 --- a/lib/src/ast/sass/argument_declaration.dart +++ b/lib/src/ast/sass/parameter_list.dart @@ -9,20 +9,20 @@ import '../../parse/scss.dart'; import '../../util/character.dart'; import '../../util/span.dart'; import '../../utils.dart'; -import 'argument.dart'; +import 'parameter.dart'; import 'node.dart'; -/// An argument declaration, as for a function or mixin definition. +/// An parameter declaration, as for a function or mixin definition. /// /// {@category AST} /// {@category Parsing} -final class ArgumentDeclaration implements SassNode { - /// The arguments that are taken. - final List arguments; +final class ParameterList implements SassNode { + /// The parameters that are taken. + final List parameters; - /// The name of the rest argument (as in `$args...`), or `null` if none was + /// The name of the rest parameter (as in `$args...`), or `null` if none was /// declared. - final String? restArgument; + final String? restParameter; final FileSpan span; @@ -31,7 +31,7 @@ final class ArgumentDeclaration implements SassNode { FileSpan get spanWithName { var text = span.file.getText(0); - // Move backwards through any whitespace between the name and the arguments. + // Move backwards through any whitespace between the name and the parameters. var i = span.start.offset - 1; while (i > 0 && text.codeUnitAt(i).isWhitespace) { i--; @@ -48,60 +48,59 @@ final class ArgumentDeclaration implements SassNode { if (!text.codeUnitAt(i + 1).isNameStart) return span; // Trim because it's possible that this span is empty (for example, a mixin - // may be declared without an argument list). + // may be declared without an parameter list). return span.file.span(i + 1, span.end.offset).trim(); } - /// Returns whether this declaration takes no arguments. - bool get isEmpty => arguments.isEmpty && restArgument == null; + /// Returns whether this declaration takes no parameters. + bool get isEmpty => parameters.isEmpty && restParameter == null; - ArgumentDeclaration(Iterable arguments, this.span, - {this.restArgument}) - : arguments = List.unmodifiable(arguments); + ParameterList(Iterable parameters, this.span, {this.restParameter}) + : parameters = List.unmodifiable(parameters); - /// Creates a declaration that declares no arguments. - ArgumentDeclaration.empty(this.span) - : arguments = const [], - restArgument = null; + /// Creates a declaration that declares no parameters. + ParameterList.empty(this.span) + : parameters = const [], + restParameter = null; - /// Parses an argument declaration from [contents], which should be of the + /// Parses an parameter declaration from [contents], which should be of the /// form `@rule name(args) {`. /// /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory ArgumentDeclaration.parse(String contents, {Object? url}) => - ScssParser(contents, url: url).parseArgumentDeclaration(); + factory ParameterList.parse(String contents, {Object? url}) => + ScssParser(contents, url: url).parseParameterList(); /// Throws a [SassScriptException] if [positional] and [names] aren't valid - /// for this argument declaration. + /// for this parameter declaration. void verify(int positional, Set names) { var namedUsed = 0; - for (var i = 0; i < arguments.length; i++) { - var argument = arguments[i]; + for (var i = 0; i < parameters.length; i++) { + var parameter = parameters[i]; if (i < positional) { - if (names.contains(argument.name)) { + if (names.contains(parameter.name)) { throw SassScriptException( - "Argument ${_originalArgumentName(argument.name)} was passed " + "Argument ${_originalParameterName(parameter.name)} was passed " "both by position and by name."); } - } else if (names.contains(argument.name)) { + } else if (names.contains(parameter.name)) { namedUsed++; - } else if (argument.defaultValue == null) { + } else if (parameter.defaultValue == null) { throw MultiSpanSassScriptException( - "Missing argument ${_originalArgumentName(argument.name)}.", + "Missing argument ${_originalParameterName(parameter.name)}.", "invocation", {spanWithName: "declaration"}); } } - if (restArgument != null) return; + if (restParameter != null) return; - if (positional > arguments.length) { + if (positional > parameters.length) { throw MultiSpanSassScriptException( - "Only ${arguments.length} " + "Only ${parameters.length} " "${names.isEmpty ? '' : 'positional '}" - "${pluralize('argument', arguments.length)} allowed, but " + "${pluralize('argument', parameters.length)} allowed, but " "$positional ${pluralize('was', positional, plural: 'were')} " "passed.", "invocation", @@ -110,54 +109,54 @@ final class ArgumentDeclaration implements SassNode { if (namedUsed < names.length) { var unknownNames = Set.of(names) - ..removeAll(arguments.map((argument) => argument.name)); + ..removeAll(parameters.map((parameter) => parameter.name)); throw MultiSpanSassScriptException( - "No ${pluralize('argument', unknownNames.length)} named " + "No ${pluralize('parameter', unknownNames.length)} named " "${toSentence(unknownNames.map((name) => "\$$name"), 'or')}.", "invocation", {spanWithName: "declaration"}); } } - /// Returns the argument named [name] with a leading `$` and its original + /// Returns the parameter named [name] with a leading `$` and its original /// underscores (which are otherwise converted to hyphens). - String _originalArgumentName(String name) { - if (name == restArgument) { + String _originalParameterName(String name) { + if (name == restParameter) { var text = span.text; var fromDollar = text.substring(text.lastIndexOf("\$")); return fromDollar.substring(0, text.indexOf(".")); } - for (var argument in arguments) { - if (argument.name == name) return argument.originalName; + for (var parameter in parameters) { + if (parameter.name == name) return parameter.originalName; } - throw ArgumentError('This declaration has no argument named "\$$name".'); + throw ArgumentError('This declaration has no parameter named "\$$name".'); } - /// Returns whether [positional] and [names] are valid for this argument + /// Returns whether [positional] and [names] are valid for this parameter /// declaration. bool matches(int positional, Set names) { var namedUsed = 0; - for (var i = 0; i < arguments.length; i++) { - var argument = arguments[i]; + for (var i = 0; i < parameters.length; i++) { + var parameter = parameters[i]; if (i < positional) { - if (names.contains(argument.name)) return false; - } else if (names.contains(argument.name)) { + if (names.contains(parameter.name)) return false; + } else if (names.contains(parameter.name)) { namedUsed++; - } else if (argument.defaultValue == null) { + } else if (parameter.defaultValue == null) { return false; } } - if (restArgument != null) return true; - if (positional > arguments.length) return false; + if (restParameter != null) return true; + if (positional > parameters.length) return false; if (namedUsed < names.length) return false; return true; } String toString() => [ - for (var arg in arguments) '\$$arg', - if (restArgument != null) '\$$restArgument...' + for (var arg in parameters) '\$$arg', + if (restParameter != null) '\$$restParameter...' ].join(', '); } diff --git a/lib/src/ast/sass/statement/callable_declaration.dart b/lib/src/ast/sass/statement/callable_declaration.dart index 3ce0ec9a0..1c2154065 100644 --- a/lib/src/ast/sass/statement/callable_declaration.dart +++ b/lib/src/ast/sass/statement/callable_declaration.dart @@ -4,7 +4,7 @@ import 'package:source_span/source_span.dart'; -import '../argument_declaration.dart'; +import '../parameter_list.dart'; import '../statement.dart'; import 'parent.dart'; import 'silent_comment.dart'; @@ -24,12 +24,12 @@ abstract base class CallableDeclaration /// The comment immediately preceding this declaration. final SilentComment? comment; - /// The declared arguments this callable accepts. - final ArgumentDeclaration arguments; + /// The declared parameters this callable accepts. + final ParameterList parameters; final FileSpan span; - CallableDeclaration(this.originalName, this.arguments, + CallableDeclaration(this.originalName, this.parameters, Iterable children, this.span, {this.comment}) : name = originalName.replaceAll('_', '-'), diff --git a/lib/src/ast/sass/statement/content_block.dart b/lib/src/ast/sass/statement/content_block.dart index 618a49ea5..d29a8290b 100644 --- a/lib/src/ast/sass/statement/content_block.dart +++ b/lib/src/ast/sass/statement/content_block.dart @@ -6,20 +6,20 @@ import 'package:source_span/source_span.dart'; import '../../../visitor/interface/statement.dart'; import '../statement.dart'; -import '../argument_declaration.dart'; +import '../parameter_list.dart'; import 'callable_declaration.dart'; /// An anonymous block of code that's invoked for a [ContentRule]. /// /// {@category AST} final class ContentBlock extends CallableDeclaration { - ContentBlock(ArgumentDeclaration arguments, Iterable children, - FileSpan span) - : super("@content", arguments, children, span); + ContentBlock( + ParameterList parameters, Iterable children, FileSpan span) + : super("@content", parameters, children, span); T accept(StatementVisitor visitor) => visitor.visitContentBlock(this); String toString() => - (arguments.isEmpty ? "" : " using ($arguments)") + + (parameters.isEmpty ? "" : " using ($parameters)") + " {${children.join(' ')}}"; } diff --git a/lib/src/ast/sass/statement/content_rule.dart b/lib/src/ast/sass/statement/content_rule.dart index 8d451207b..ba835de5a 100644 --- a/lib/src/ast/sass/statement/content_rule.dart +++ b/lib/src/ast/sass/statement/content_rule.dart @@ -5,7 +5,7 @@ import 'package:source_span/source_span.dart'; import '../../../visitor/interface/statement.dart'; -import '../argument_invocation.dart'; +import '../argument_list.dart'; import '../statement.dart'; /// A `@content` rule. @@ -18,7 +18,7 @@ final class ContentRule extends Statement { /// The arguments pass to this `@content` rule. /// /// This will be an empty invocation if `@content` has no arguments. - final ArgumentInvocation arguments; + final ArgumentList arguments; final FileSpan span; diff --git a/lib/src/ast/sass/statement/function_rule.dart b/lib/src/ast/sass/statement/function_rule.dart index 885bd4ef9..15741908f 100644 --- a/lib/src/ast/sass/statement/function_rule.dart +++ b/lib/src/ast/sass/statement/function_rule.dart @@ -18,10 +18,10 @@ final class FunctionRule extends CallableDeclaration implements SassDeclaration { FileSpan get nameSpan => span.withoutInitialAtRule().initialIdentifier(); - FunctionRule(super.name, super.arguments, super.children, super.span, + FunctionRule(super.name, super.parameters, super.children, super.span, {super.comment}); T accept(StatementVisitor visitor) => visitor.visitFunctionRule(this); - String toString() => "@function $name($arguments) {${children.join(' ')}}"; + String toString() => "@function $name($parameters) {${children.join(' ')}}"; } diff --git a/lib/src/ast/sass/statement/include_rule.dart b/lib/src/ast/sass/statement/include_rule.dart index 98151665e..5331a728e 100644 --- a/lib/src/ast/sass/statement/include_rule.dart +++ b/lib/src/ast/sass/statement/include_rule.dart @@ -6,7 +6,7 @@ import 'package:source_span/source_span.dart'; import '../../../util/span.dart'; import '../../../visitor/interface/statement.dart'; -import '../argument_invocation.dart'; +import '../argument_list.dart'; import '../callable_invocation.dart'; import '../reference.dart'; import '../statement.dart'; @@ -30,7 +30,7 @@ final class IncludeRule extends Statement final String originalName; /// The arguments to pass to the mixin. - final ArgumentInvocation arguments; + final ArgumentList arguments; /// The block that will be invoked for [ContentRule]s in the mixin being /// invoked, or `null` if this doesn't pass a content block. diff --git a/lib/src/ast/sass/statement/mixin_rule.dart b/lib/src/ast/sass/statement/mixin_rule.dart index 650e64b65..a4b5e4d2d 100644 --- a/lib/src/ast/sass/statement/mixin_rule.dart +++ b/lib/src/ast/sass/statement/mixin_rule.dart @@ -28,14 +28,14 @@ final class MixinRule extends CallableDeclaration implements SassDeclaration { return startSpan.initialIdentifier(); } - MixinRule(super.name, super.arguments, super.children, super.span, + MixinRule(super.name, super.parameters, super.children, super.span, {super.comment}); T accept(StatementVisitor visitor) => visitor.visitMixinRule(this); String toString() { var buffer = StringBuffer("@mixin $name"); - if (!arguments.isEmpty) buffer.write("($arguments)"); + if (!parameters.isEmpty) buffer.write("($parameters)"); buffer.write(" {${children.join(' ')}}"); return buffer.toString(); } diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index 0bc3b4da7..b07735664 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -69,6 +69,10 @@ final class AsyncImportCache { /// The import results for each canonicalized import URL. final _resultsCache = {}; + /// A map from canonical URLs to the most recent time at which those URLs were + /// loaded from their importers. + final _loadTimes = {}; + /// Creates an import cache that resolves imports using [importers]. /// /// Imports are resolved by trying, in order: @@ -282,9 +286,11 @@ final class AsyncImportCache { Future importCanonical(AsyncImporter importer, Uri canonicalUrl, {Uri? originalUrl}) async { return await putIfAbsentAsync(_importCache, canonicalUrl, () async { + var loadTime = DateTime.now(); var result = await importer.load(canonicalUrl); if (result == null) return null; + _loadTimes[canonicalUrl] = loadTime; _resultsCache[canonicalUrl] = result; return Stylesheet.parse(result.contents, result.syntax, // For backwards-compatibility, relative canonical URLs are resolved @@ -320,17 +326,31 @@ final class AsyncImportCache { Uri sourceMapUrl(Uri canonicalUrl) => _resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl; - /// Clears the cached canonical version of the given non-canonical [url]. - /// - /// Has no effect if the canonical version of [url] has not been cached. + /// Returns the most recent time the stylesheet at [canonicalUrl] was loaded + /// from its importer, or `null` if it has never been loaded. + @internal + DateTime? loadTime(Uri canonicalUrl) => _loadTimes[canonicalUrl]; + + /// Clears all cached canonicalizations that could potentially produce + /// [canonicalUrl]. /// /// @nodoc @internal - void clearCanonicalize(Uri url) { - _canonicalizeCache.remove((url, forImport: false)); - _canonicalizeCache.remove((url, forImport: true)); - _perImporterCanonicalizeCache.removeWhere( - (key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url); + Future clearCanonicalize(Uri canonicalUrl) async { + for (var key in [..._canonicalizeCache.keys]) { + for (var importer in _importers) { + if (await importer.couldCanonicalize(key.$1, canonicalUrl)) { + _canonicalizeCache.remove(key); + break; + } + } + } + + for (var key in [..._perImporterCanonicalizeCache.keys]) { + if (await key.$1.couldCanonicalize(key.$2, canonicalUrl)) { + _perImporterCanonicalizeCache.remove(key); + } + } } /// Clears the cached parse tree for the stylesheet with the given diff --git a/lib/src/callable/async_built_in.dart b/lib/src/callable/async_built_in.dart index 4e479c148..3736f47a9 100644 --- a/lib/src/callable/async_built_in.dart +++ b/lib/src/callable/async_built_in.dart @@ -17,13 +17,13 @@ typedef Callback = FutureOr Function(List arguments); /// /// Unlike user-defined callables, built-in callables support overloads. They /// may declare multiple different callbacks with multiple different sets of -/// arguments. When the callable is invoked, the first callback with matching -/// arguments is invoked. +/// parameters. When the callable is invoked, the first callback with matching +/// parameters is invoked. class AsyncBuiltInCallable implements AsyncCallable { final String name; - /// This callable's arguments. - final ArgumentDeclaration _arguments; + /// This callable's parameters. + final ParameterList _parameters; /// The callback to run when executing this callable. final Callback _callback; @@ -33,35 +33,34 @@ class AsyncBuiltInCallable implements AsyncCallable { /// This can only be true for mixins. final bool acceptsContent; - /// Creates a function with a single [arguments] declaration and a single + /// Creates a function with a single [parameters] declaration and a single /// [callback]. /// - /// The argument declaration is parsed from [arguments], which should not + /// The parameter declaration is parsed from [parameters], which should not /// include parentheses. Throws a [SassFormatException] if parsing fails. /// /// If passed, [url] is the URL of the module in which the function is /// defined. - AsyncBuiltInCallable.function(String name, String arguments, - FutureOr callback(List arguments), {Object? url}) + AsyncBuiltInCallable.function(String name, String parameters, + FutureOr callback(List parameters), {Object? url}) : this.parsed( name, - ArgumentDeclaration.parse('@function $name($arguments) {', - url: url), + ParameterList.parse('@function $name($parameters) {', url: url), callback); - /// Creates a mixin with a single [arguments] declaration and a single + /// Creates a mixin with a single [parameters] declaration and a single /// [callback]. /// - /// The argument declaration is parsed from [arguments], which should not + /// The parameter declaration is parsed from [parameters], which should not /// include parentheses. Throws a [SassFormatException] if parsing fails. /// /// If passed, [url] is the URL of the module in which the mixin is /// defined. - AsyncBuiltInCallable.mixin(String name, String arguments, - FutureOr callback(List arguments), + AsyncBuiltInCallable.mixin(String name, String parameters, + FutureOr callback(List parameters), {Object? url, bool acceptsContent = false}) - : this.parsed(name, - ArgumentDeclaration.parse('@mixin $name($arguments) {', url: url), + : this.parsed( + name, ParameterList.parse('@mixin $name($parameters) {', url: url), (arguments) async { await callback(arguments); // We could encode the fact that functions return values and mixins @@ -71,25 +70,24 @@ class AsyncBuiltInCallable implements AsyncCallable { return sassNull; }); - /// Creates a callable with a single [arguments] declaration and a single + /// Creates a callable with a single [parameters] declaration and a single /// [callback]. - AsyncBuiltInCallable.parsed(this.name, this._arguments, this._callback, + AsyncBuiltInCallable.parsed(this.name, this._parameters, this._callback, {this.acceptsContent = false}); - /// Returns the argument declaration and Dart callback for the given - /// positional and named arguments. + /// Returns the parameter declaration and Dart callback for the given + /// positional and named parameters. /// /// If no exact match is found, finds the closest approximation. Note that this /// doesn't guarantee that [positional] and [names] are valid for the returned - /// [ArgumentDeclaration]. - (ArgumentDeclaration, Callback) callbackFor( - int positional, Set names) => - (_arguments, _callback); + /// [ParameterList]. + (ParameterList, Callback) callbackFor(int positional, Set names) => + (_parameters, _callback); /// Returns a copy of this callable that emits a deprecation warning. AsyncBuiltInCallable withDeprecationWarning(String module, [String? newName]) => - AsyncBuiltInCallable.parsed(name, _arguments, (args) { + AsyncBuiltInCallable.parsed(name, _parameters, (args) { warnForGlobalBuiltIn(module, newName ?? name); return _callback(args); }, acceptsContent: acceptsContent); diff --git a/lib/src/callable/built_in.dart b/lib/src/callable/built_in.dart index 1d58df9fe..e78e05014 100644 --- a/lib/src/callable/built_in.dart +++ b/lib/src/callable/built_in.dart @@ -13,63 +13,62 @@ typedef Callback = Value Function(List arguments); /// /// Unlike user-defined callables, built-in callables support overloads. They /// may declare multiple different callbacks with multiple different sets of -/// arguments. When the callable is invoked, the first callback with matching -/// arguments is invoked. +/// parameters. When the callable is invoked, the first callback with matching +/// parameters is invoked. final class BuiltInCallable implements Callable, AsyncBuiltInCallable { final String name; /// The overloads declared for this callable. - final List<(ArgumentDeclaration, Callback)> _overloads; + final List<(ParameterList, Callback)> _overloads; final bool acceptsContent; - /// Creates a function with a single [arguments] declaration and a single + /// Creates a function with a single [parameters] declaration and a single /// [callback]. /// - /// The argument declaration is parsed from [arguments], which should not + /// The parameter declaration is parsed from [parameters], which should not /// include parentheses. Throws a [SassFormatException] if parsing fails. /// /// If passed, [url] is the URL of the module in which the function is /// defined. BuiltInCallable.function( - String name, String arguments, Value callback(List arguments), + String name, String parameters, Value callback(List arguments), {Object? url}) : this.parsed( name, - ArgumentDeclaration.parse('@function $name($arguments) {', - url: url), + ParameterList.parse('@function $name($parameters) {', url: url), callback); - /// Creates a mixin with a single [arguments] declaration and a single + /// Creates a mixin with a single [parameters] declaration and a single /// [callback]. /// - /// The argument declaration is parsed from [arguments], which should not + /// The parameter declaration is parsed from [parameters], which should not /// include parentheses. Throws a [SassFormatException] if parsing fails. /// /// If passed, [url] is the URL of the module in which the mixin is /// defined. BuiltInCallable.mixin( - String name, String arguments, void callback(List arguments), + String name, String parameters, void callback(List arguments), {Object? url, bool acceptsContent = false}) - : this.parsed(name, - ArgumentDeclaration.parse('@mixin $name($arguments) {', url: url), + : this.parsed( + name, ParameterList.parse('@mixin $name($parameters) {', url: url), (arguments) { callback(arguments); return sassNull; }, acceptsContent: acceptsContent); - /// Creates a callable with a single [arguments] declaration and a single + /// Creates a callable with a single [parameters] declaration and a single /// [callback]. - BuiltInCallable.parsed(this.name, ArgumentDeclaration arguments, + BuiltInCallable.parsed(this.name, ParameterList parameters, Value callback(List arguments), {this.acceptsContent = false}) - : _overloads = [(arguments, callback)]; + : _overloads = [(parameters, callback)]; /// Creates a function with multiple implementations. /// - /// Each key/value pair in [overloads] defines the argument declaration for + /// Each key/value pair in [overloads] defines the parameter declaration for /// the overload (which should not include parentheses), and the callback to - /// execute if that argument declaration matches. Throws a + /// execute if that parameter declaration matches. Throws a /// [SassFormatException] if parsing fails. /// /// If passed, [url] is the URL of the module in which the function is @@ -79,7 +78,7 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { : _overloads = [ for (var (args, callback) in overloads.pairs) ( - ArgumentDeclaration.parse('@function $name($args) {', url: url), + ParameterList.parse('@function $name($args) {', url: url), callback ) ], @@ -87,29 +86,30 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { BuiltInCallable._(this.name, this._overloads, this.acceptsContent); - /// Returns the argument declaration and Dart callback for the given - /// positional and named arguments. + /// Returns the parameter declaration and Dart callback for the given + /// positional and named parameters. /// /// If no exact match is found, finds the closest approximation. Note that this /// doesn't guarantee that [positional] and [names] are valid for the returned - /// [ArgumentDeclaration]. - (ArgumentDeclaration, Callback) callbackFor( - int positional, Set names) { - (ArgumentDeclaration, Callback)? fuzzyMatch; + /// [ParameterList]. + (ParameterList, Callback) callbackFor(int positional, Set names) { + (ParameterList, Callback)? fuzzyMatch; int? minMismatchDistance; for (var overload in _overloads) { // Ideally, find an exact match. if (overload.$1.matches(positional, names)) return overload; - var mismatchDistance = overload.$1.arguments.length - positional; + var mismatchDistance = overload.$1.parameters.length - positional; if (minMismatchDistance != null) { if (mismatchDistance.abs() > minMismatchDistance.abs()) continue; // If two overloads have the same mismatch distance, favor the overload - // that has more arguments. + // that has more parameters. if (mismatchDistance.abs() == minMismatchDistance.abs() && - mismatchDistance < 0) continue; + mismatchDistance < 0) { + continue; + } } minMismatchDistance = mismatchDistance; diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index 3281e720d..1962ec627 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -191,15 +191,17 @@ enum Deprecation { String toString() => id; /// Returns the deprecation with a given ID, or null if none exists. - static Deprecation? fromId(String id) => Deprecation.values - .firstWhereOrNull((deprecation) => deprecation.id == id); + static Deprecation? fromId(String id) => Deprecation.values.firstWhereOrNull( + (deprecation) => deprecation.id == id, + ); /// Returns the set of all deprecations done in or before [version]. static Set forVersion(Version version) { var range = VersionRange(max: version, includeMax: true); return { for (var deprecation in Deprecation.values) - if (deprecation.deprecatedIn.andThen(range.allows) ?? false) deprecation + if (deprecation.deprecatedIn.andThen(range.allows) ?? false) + deprecation, }; } } diff --git a/lib/src/embedded/compilation_dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart index 9862a97c1..83456445e 100644 --- a/lib/src/embedded/compilation_dispatcher.dart +++ b/lib/src/embedded/compilation_dispatcher.dart @@ -91,7 +91,7 @@ final class CompilationDispatcher { case InboundMessage_Message.notSet: throw parseError("InboundMessage.message is not set."); - default: + default: // ignore: unreachable_switch_default throw parseError( "Unknown message type: ${message.toDebugString()}"); } diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index c2f26d260..50bdfaac3 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -124,6 +124,10 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations); } else { + // Double-check that all modified files (according to mtime) are actually + // reloaded in the graph so we don't end up with stale ASTs. + graph.reloadAllModified(); + result = source == null ? compileString(await readStdin(), syntax: syntax, diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart index 9e1db78e9..000bf3201 100644 --- a/lib/src/executable/watch.dart +++ b/lib/src/executable/watch.dart @@ -62,6 +62,10 @@ final class _Watcher { /// The graph of stylesheets being compiled. final StylesheetGraph _graph; + /// A map from source paths to destinations that need to be recompiled once + /// the current batch of events has been processed. + final Map _toRecompile = {}; + _Watcher(this._options, this._graph); /// Deletes the file at [path] and prints a message about it. @@ -82,32 +86,39 @@ final class _Watcher { /// /// Returns a future that will only complete if an unexpected error occurs. Future watch(MultiDirWatcher watcher) async { - await for (var event in _debounceEvents(watcher.events)) { - var extension = p.extension(event.path); - if (extension != '.sass' && extension != '.scss' && extension != '.css') { - continue; + await for (var batch in _debounceEvents(watcher.events)) { + for (var event in batch) { + var extension = p.extension(event.path); + if (extension != '.sass' && + extension != '.scss' && + extension != '.css') { + continue; + } + + switch (event.type) { + case ChangeType.MODIFY: + _handleModify(event.path); + + case ChangeType.ADD: + _handleAdd(event.path); + + case ChangeType.REMOVE: + _handleRemove(event.path); + } } - switch (event.type) { - case ChangeType.MODIFY: - var success = await _handleModify(event.path); - if (!success && _options.stopOnError) return; - - case ChangeType.ADD: - var success = await _handleAdd(event.path); - if (!success && _options.stopOnError) return; - - case ChangeType.REMOVE: - var success = await _handleRemove(event.path); - if (!success && _options.stopOnError) return; - } + var toRecompile = {..._toRecompile}; + _toRecompile.clear(); + var success = await compileStylesheets(_options, _graph, toRecompile, + ifModified: true); + if (!success && _options.stopOnError) return; } } /// Handles a modify event for the stylesheet at [path]. /// /// Returns whether all necessary recompilations succeeded. - Future _handleModify(String path) async { + void _handleModify(String path) { var url = _canonicalize(path); // It's important to access the node ahead-of-time because it's possible @@ -115,29 +126,27 @@ final class _Watcher { // from the graph. if (_graph.nodes[url] case var node?) { _graph.reload(url); - return await _recompileDownstream([node]); + _recompileDownstream([node]); } else { - return _handleAdd(path); + _handleAdd(path); } } /// Handles an add event for the stylesheet at [url]. /// /// Returns whether all necessary recompilations succeeded. - Future _handleAdd(String path) async { + void _handleAdd(String path) { var destination = _destinationFor(path); - var success = destination == null || - await compileStylesheets(_options, _graph, {path: destination}, - ifModified: true); + if (destination != null) _toRecompile[path] = destination; var downstream = _graph.addCanonical( FilesystemImporter.cwd, _canonicalize(path), p.toUri(path)); - return await _recompileDownstream(downstream) && success; + _recompileDownstream(downstream); } /// Handles a remove event for the stylesheet at [url]. /// /// Returns whether all necessary recompilations succeeded. - Future _handleRemove(String path) async { + void _handleRemove(String path) async { var url = _canonicalize(path); if (_graph.nodes.containsKey(url)) { @@ -145,7 +154,7 @@ final class _Watcher { } var downstream = _graph.remove(FilesystemImporter.cwd, url); - return await _recompileDownstream(downstream); + _recompileDownstream(downstream); } /// Returns the canonical URL for the stylesheet path [path]. @@ -154,9 +163,10 @@ final class _Watcher { /// Combine [WatchEvent]s that happen in quick succession. /// /// Otherwise, if a file is erased and then rewritten, we can end up reading - /// the intermediate erased version. - Stream _debounceEvents(Stream events) { - return events.debounceBuffer(Duration(milliseconds: 25)).expand((buffer) { + /// the intermediate erased version. This returns a stream of batches of + /// events that all happened in succession. + Stream> _debounceEvents(Stream events) { + return events.debounceBuffer(Duration(milliseconds: 25)).map((buffer) { var typeForPath = p.PathMap(); for (var event in buffer) { var oldType = typeForPath[event.path]; @@ -175,32 +185,20 @@ final class _Watcher { }); } - /// Recompiles [nodes] and everything that transitively imports them, if - /// necessary. - /// - /// Returns whether all recompilations succeeded. - Future _recompileDownstream(Iterable nodes) async { + /// Marks [nodes] and everything that transitively imports them for + /// recompilation, if necessary. + void _recompileDownstream(Iterable nodes) { var seen = {}; - var allSucceeded = true; while (nodes.isNotEmpty) { nodes = [ for (var node in nodes) if (seen.add(node)) node ]; - var sourcesToDestinations = _sourceEntrypointsToDestinations(nodes); - if (sourcesToDestinations.isNotEmpty) { - var success = await compileStylesheets( - _options, _graph, sourcesToDestinations, - ifModified: true); - if (!success && _options.stopOnError) return false; - - allSucceeded = allSucceeded && success; - } + _toRecompile.addAll(_sourceEntrypointsToDestinations(nodes)); nodes = [for (var node in nodes) ...node.downstream]; } - return allSucceeded; } /// Returns a sourcesToDestinations mapping for nodes that are entrypoints. diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index e9c627fb1..b6d38d15f 100644 --- a/lib/src/import_cache.dart +++ b/lib/src/import_cache.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_import_cache.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 4d09da97db4e59518d193f58963897d36ef4db00 +// Checksum: 65a7c538299527be3240f0625a7c1cd4f8cd6824 // // ignore_for_file: unused_import @@ -70,6 +70,10 @@ final class ImportCache { /// The import results for each canonicalized import URL. final _resultsCache = {}; + /// A map from canonical URLs to the most recent time at which those URLs were + /// loaded from their importers. + final _loadTimes = {}; + /// Creates an import cache that resolves imports using [importers]. /// /// Imports are resolved by trying, in order: @@ -276,9 +280,11 @@ final class ImportCache { Stylesheet? importCanonical(Importer importer, Uri canonicalUrl, {Uri? originalUrl}) { return _importCache.putIfAbsent(canonicalUrl, () { + var loadTime = DateTime.now(); var result = importer.load(canonicalUrl); if (result == null) return null; + _loadTimes[canonicalUrl] = loadTime; _resultsCache[canonicalUrl] = result; return Stylesheet.parse(result.contents, result.syntax, // For backwards-compatibility, relative canonical URLs are resolved @@ -314,17 +320,31 @@ final class ImportCache { Uri sourceMapUrl(Uri canonicalUrl) => _resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl; - /// Clears the cached canonical version of the given non-canonical [url]. - /// - /// Has no effect if the canonical version of [url] has not been cached. + /// Returns the most recent time the stylesheet at [canonicalUrl] was loaded + /// from its importer, or `null` if it has never been loaded. + @internal + DateTime? loadTime(Uri canonicalUrl) => _loadTimes[canonicalUrl]; + + /// Clears all cached canonicalizations that could potentially produce + /// [canonicalUrl]. /// /// @nodoc @internal - void clearCanonicalize(Uri url) { - _canonicalizeCache.remove((url, forImport: false)); - _canonicalizeCache.remove((url, forImport: true)); - _perImporterCanonicalizeCache.removeWhere( - (key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url); + void clearCanonicalize(Uri canonicalUrl) { + for (var key in [..._canonicalizeCache.keys]) { + for (var importer in _importers) { + if (importer.couldCanonicalize(key.$1, canonicalUrl)) { + _canonicalizeCache.remove(key); + break; + } + } + } + + for (var key in [..._perImporterCanonicalizeCache.keys]) { + if (key.$1.couldCanonicalize(key.$2, canonicalUrl)) { + _perImporterCanonicalizeCache.remove(key); + } + } } /// Clears the cached parse tree for the stylesheet with the given diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 79d9a5cc9..aa2234608 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -20,6 +20,7 @@ import '../visitor/interface/expression.dart'; import '../visitor/interface/statement.dart'; import 'reflection.dart'; import 'set.dart'; +import 'utils.dart'; import 'visitor/expression.dart'; import 'visitor/statement.dart'; @@ -32,7 +33,8 @@ class ParserExports { required Function toCssIdentifier, required Function createExpressionVisitor, required Function createStatementVisitor, - required Function setToJS}); + required Function setToJS, + required Function mapToRecord}); external set parse(Function function); external set parseIdentifier(Function function); @@ -40,6 +42,7 @@ class ParserExports { external set createStatementVisitor(Function function); external set createExpressionVisitor(Function function); external set setToJS(Function function); + external set mapToRecord(Function function); } /// An empty interpolation, used to initialize empty AST entries to modify their @@ -61,7 +64,8 @@ ParserExports loadParserExports() { (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), createStatementVisitor: allowInterop( (JSStatementVisitorObject inner) => JSStatementVisitor(inner)), - setToJS: allowInterop((Set set) => JSSet([...set]))); + setToJS: allowInterop((Set set) => JSSet([...set])), + mapToRecord: allowInterop(mapToObject)); } /// Modifies the prototypes of the Sass AST classes to provide access to JS. @@ -88,6 +92,10 @@ void _updateAstPrototypes() { 'accept', (Expression self, ExpressionVisitor visitor) => self.accept(visitor)); + var arguments = ArgumentList([], {}, bogusSpan); + var include = IncludeRule('a', arguments, bogusSpan); + getJSClass(include) + .defineGetter('arguments', (IncludeRule self) => self.arguments); _addSupportsConditionToInterpolation(); diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index e291054b5..abaf6e8ef 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -11,6 +11,7 @@ import 'package:js/js_util.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/map.dart'; import '../value.dart'; import 'array.dart'; import 'function.dart'; @@ -223,6 +224,18 @@ Map objectToMap(Object object) { return map; } +@JS("Object") +external JSClass get _jsObjectClass; + +/// Converts a JavaScript record into a map from property names to their values. +Object mapToObject(Map map) { + var result = callConstructor(_jsObjectClass, const []); + for (var (key, value) in map.pairs) { + setProperty(result, key, value); + } + return result; +} + /// Converts a JavaScript separator string into a [ListSeparator]. ListSeparator jsToDartSeparator(String? separator) => switch (separator) { ' ' => ListSeparator.space, diff --git a/lib/src/logger.dart b/lib/src/logger.dart index 7569b6e7c..da85627b4 100644 --- a/lib/src/logger.dart +++ b/lib/src/logger.dart @@ -49,6 +49,8 @@ abstract class Logger { /// [warn]. @internal abstract class LoggerWithDeprecationType implements Logger { + const LoggerWithDeprecationType(); + /// This forwards all calls to [internalWarn]. /// /// For non-user deprecation warnings, the [warnForDeprecation] extension diff --git a/lib/src/logger/stderr.dart b/lib/src/logger/stderr.dart index fc001008f..4682d8bb0 100644 --- a/lib/src/logger/stderr.dart +++ b/lib/src/logger/stderr.dart @@ -6,28 +6,33 @@ import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; +import '../deprecation.dart'; import '../io.dart'; import '../logger.dart'; import '../utils.dart'; /// A logger that prints warnings to standard error or browser console. -final class StderrLogger implements Logger { +final class StderrLogger extends LoggerWithDeprecationType { /// Whether to use terminal colors in messages. final bool color; const StderrLogger({this.color = false}); - void warn(String message, - {FileSpan? span, Trace? trace, bool deprecation = false}) { + void internalWarn(String message, + {FileSpan? span, Trace? trace, Deprecation? deprecation}) { var result = StringBuffer(); if (color) { // Bold yellow. result.write('\u001b[33m\u001b[1m'); - if (deprecation) result.write('Deprecation '); + if (deprecation != null) result.write('Deprecation '); result.write('Warning\u001b[0m'); + if (deprecation != null) { + result.write(' [\u001b[34m$deprecation\u001b[0m]'); + } } else { - if (deprecation) result.write('DEPRECATION '); + if (deprecation != null) result.write('DEPRECATION '); result.write('WARNING'); + if (deprecation != null) result.write(' [$deprecation]'); } if (span == null) { diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index 4fd9ae2df..4b28dd3e1 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -91,7 +91,7 @@ class CssParser extends ScssParser { StringExpression string => string.text, InterpolatedFunctionExpression( :var name, - arguments: ArgumentInvocation( + arguments: ArgumentList( positional: [StringExpression string], named: Map(isEmpty: true), rest: null, @@ -172,8 +172,7 @@ class CssParser extends ScssParser { return FunctionExpression( plain, - ArgumentInvocation( - arguments, const {}, scanner.spanFrom(beforeArguments)), + ArgumentList(arguments, const {}, scanner.spanFrom(beforeArguments)), scanner.spanFrom(start)); } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 94f551d2d..1bfbca99e 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -105,15 +105,15 @@ abstract class StylesheetParser extends Parser { }); } - ArgumentDeclaration parseArgumentDeclaration() => _parseSingleProduction(() { + ParameterList parseParameterList() => _parseSingleProduction(() { scanner.expectChar($at, name: "@-rule"); identifier(); whitespace(); identifier(); - var arguments = _argumentDeclaration(); + var parameters = _parameterList(); whitespace(); scanner.expectChar($lbrace); - return arguments; + return parameters; }); (Expression, List) parseExpression() => @@ -155,15 +155,14 @@ abstract class StylesheetParser extends Parser { /// option and returns its name and declaration. /// /// If [requireParens] is `false`, this allows parentheses to be omitted. - (String name, ArgumentDeclaration) parseSignature( - {bool requireParens = true}) { + (String name, ParameterList) parseSignature({bool requireParens = true}) { return wrapSpanFormatException(() { var name = identifier(); - var arguments = requireParens || scanner.peekChar() == $lparen - ? _argumentDeclaration() - : ArgumentDeclaration.empty(scanner.emptySpan); + var parameters = requireParens || scanner.peekChar() == $lparen + ? _parameterList() + : ParameterList.empty(scanner.emptySpan); scanner.expectDone(); - return (name, arguments); + return (name, parameters); }); } @@ -790,12 +789,12 @@ abstract class StylesheetParser extends Parser { var beforeWhitespace = scanner.location; whitespace(); - ArgumentInvocation arguments; + ArgumentList arguments; if (scanner.peekChar() == $lparen) { arguments = _argumentInvocation(mixin: true); whitespace(); } else { - arguments = ArgumentInvocation.empty(beforeWhitespace.pointSpan()); + arguments = ArgumentList.empty(beforeWhitespace.pointSpan()); } expectStatementSeparator("@content rule"); @@ -888,7 +887,7 @@ abstract class StylesheetParser extends Parser { } whitespace(); - var arguments = _argumentDeclaration(); + var parameters = _parameterList(); if (_inMixin || _inContentBlock) { error("Mixins may not contain function declarations.", @@ -914,7 +913,7 @@ abstract class StylesheetParser extends Parser { return _withChildren( _functionChild, start, - (children, span) => FunctionRule(name, arguments, children, span, + (children, span) => FunctionRule(name, parameters, children, span, comment: precedingComment)); } @@ -1262,24 +1261,24 @@ abstract class StylesheetParser extends Parser { whitespace(); var arguments = scanner.peekChar() == $lparen ? _argumentInvocation(mixin: true) - : ArgumentInvocation.empty(scanner.emptySpan); + : ArgumentList.empty(scanner.emptySpan); whitespace(); - ArgumentDeclaration? contentArguments; + ParameterList? contentParameters; if (scanIdentifier("using")) { whitespace(); - contentArguments = _argumentDeclaration(); + contentParameters = _parameterList(); whitespace(); } ContentBlock? content; - if (contentArguments != null || lookingAtChildren()) { - var contentArguments_ = - contentArguments ?? ArgumentDeclaration.empty(scanner.emptySpan); + if (contentParameters != null || lookingAtChildren()) { + var contentParameters_ = + contentParameters ?? ParameterList.empty(scanner.emptySpan); var wasInContentBlock = _inContentBlock; _inContentBlock = true; content = _withChildren(_statement, start, - (children, span) => ContentBlock(contentArguments_, children, span)); + (children, span) => ContentBlock(contentParameters_, children, span)); _inContentBlock = wasInContentBlock; } else { expectStatementSeparator(); @@ -1323,9 +1322,9 @@ abstract class StylesheetParser extends Parser { } whitespace(); - var arguments = scanner.peekChar() == $lparen - ? _argumentDeclaration() - : ArgumentDeclaration.empty(scanner.emptySpan); + var parameters = scanner.peekChar() == $lparen + ? _parameterList() + : ParameterList.empty(scanner.emptySpan); if (_inMixin || _inContentBlock) { error("Mixins may not contain mixin declarations.", @@ -1340,7 +1339,7 @@ abstract class StylesheetParser extends Parser { return _withChildren(_statement, start, (children, span) { _inMixin = false; - return MixinRule(name, arguments, children, span, + return MixinRule(name, parameters, children, span, comment: precedingComment); }); } @@ -1611,14 +1610,14 @@ abstract class StylesheetParser extends Parser { error("This at-rule is not allowed here.", scanner.spanFrom(start)); } - /// Consumes an argument declaration. - ArgumentDeclaration _argumentDeclaration() { + /// Consumes a parameter list. + ParameterList _parameterList() { var start = scanner.state; scanner.expectChar($lparen); whitespace(); - var arguments = []; + var parameters = []; var named = {}; - String? restArgument; + String? restParameter; while (scanner.peekChar() == $dollar) { var variableStart = scanner.state; var name = variableName(); @@ -1632,22 +1631,23 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($dot); scanner.expectChar($dot); whitespace(); - restArgument = name; + if (scanner.scanChar($comma)) whitespace(); + restParameter = name; break; } - arguments.add(Argument(name, scanner.spanFrom(variableStart), + parameters.add(Parameter(name, scanner.spanFrom(variableStart), defaultValue: defaultValue)); if (!named.add(name)) { - error("Duplicate argument.", arguments.last.span); + error("Duplicate parameter.", parameters.last.span); } if (!scanner.scanChar($comma)) break; whitespace(); } scanner.expectChar($rparen); - return ArgumentDeclaration(arguments, scanner.spanFrom(start), - restArgument: restArgument); + return ParameterList(parameters, scanner.spanFrom(start), + restParameter: restParameter); } // ## Expressions @@ -1661,7 +1661,7 @@ abstract class StylesheetParser extends Parser { /// If [allowEmptySecondArg] is `true`, this allows the second argument to be /// omitted, in which case an unquoted empty string will be passed in its /// place. - ArgumentInvocation _argumentInvocation( + ArgumentList _argumentInvocation( {bool mixin = false, bool allowEmptySecondArg = false}) { var start = scanner.state; scanner.expectChar($lparen); @@ -1689,6 +1689,7 @@ abstract class StylesheetParser extends Parser { } else { keywordRest = expression; whitespace(); + if (scanner.scanChar($comma)) whitespace(); break; } } else if (named.isNotEmpty) { @@ -1713,7 +1714,7 @@ abstract class StylesheetParser extends Parser { } scanner.expectChar($rparen); - return ArgumentInvocation(positional, named, scanner.spanFrom(start), + return ArgumentList(positional, named, scanner.spanFrom(start), rest: rest, keywordRest: keywordRest); } diff --git a/lib/src/stylesheet_graph.dart b/lib/src/stylesheet_graph.dart index 245d8b146..5e03cf458 100644 --- a/lib/src/stylesheet_graph.dart +++ b/lib/src/stylesheet_graph.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'ast/sass.dart'; import 'import_cache.dart'; import 'importer.dart'; +import 'io.dart'; import 'util/map.dart'; import 'util/nullable.dart'; import 'visitor/find_dependencies.dart'; @@ -169,6 +170,33 @@ class StylesheetGraph { return true; } + /// Re-parses all stylesheets in the graph that have been modified on disk + /// since their last known in-memory modification. + /// + /// This guards against situations where a recompilation is triggered before + /// the graph is manually informed of all changes, such as when `--poll` runs + /// slowly or native file system notifications aren't comprehensive. + void reloadAllModified() { + // Copy to a list because [reload] can modify [_nodes]. + for (var node in [..._nodes.values]) { + var modified = false; + try { + var loadTime = importCache.loadTime(node.canonicalUrl); + modified = loadTime != null && + node.importer.modificationTime(node.canonicalUrl).isAfter(loadTime); + } on FileSystemException catch (_) { + // If the file no longer exists, treat that as a modification. + modified = true; + } + + if (modified) { + if (!reload(node.canonicalUrl)) { + remove(node.importer, node.canonicalUrl); + } + } + } + } + /// Removes the stylesheet at [canonicalUrl] (loaded by [importer]) from the /// stylesheet graph. /// @@ -204,6 +232,7 @@ class StylesheetGraph { /// Returns all nodes whose imports were changed. Set _recanonicalizeImports( Importer importer, Uri canonicalUrl) { + importCache.clearCanonicalize(canonicalUrl); var changed = {}; for (var node in nodes.values) { var newUpstream = _recanonicalizeImportsForNode( @@ -242,7 +271,6 @@ class StylesheetGraph { var newMap = {}; for (var (url, upstream) in map.pairs) { if (!importer.couldCanonicalize(url, canonicalUrl)) continue; - importCache.clearCanonicalize(url); // If the import produces a different canonicalized URL than it did // before, it changed and the stylesheet needs to be recompiled. diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 2e04afd16..cafc81e51 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -452,7 +452,7 @@ StackTrace? getTrace(Object error) => /// If [requireParens] is `false`, this allows parentheses to be omitted. /// /// Throws a [SassFormatException] if parsing fails. -(String name, ArgumentDeclaration) parseSignature(String signature, +(String name, ParameterList) parseSignature(String signature, {bool requireParens = true}) { try { return ScssParser(signature).parseSignature(requireParens: requireParens); diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index c59bc893f..14e862762 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -604,6 +604,21 @@ final class SassCalculation extends Value { } } + /// Creates an `calc-size()` calculation with the given [basis] and [value]. + /// + /// The [basis] and [value] must be either a [SassNumber], a + /// [SassCalculation], an unquoted [SassString], or a [CalculationOperation]. + /// + /// This automatically simplifies the calculation. It throws an exception if + /// it can determine that the calculation will definitely produce invalid CSS. + static SassCalculation calcSize(Object basis, Object? value) { + var args = [basis, if (value != null) value]; + _verifyLength(args, 2); + basis = _simplify(basis); + value = value.andThen(_simplify); + return SassCalculation._("calc-size", [basis, if (value != null) value]); + } + /// Creates and simplifies a [CalculationOperation] with the given [operator], /// [left], and [right]. /// diff --git a/lib/src/visitor/ast_search.dart b/lib/src/visitor/ast_search.dart index d971afd23..72e45d499 100644 --- a/lib/src/visitor/ast_search.dart +++ b/lib/src/visitor/ast_search.dart @@ -30,8 +30,7 @@ mixin AstSearchVisitor on StatementSearchVisitor node.value.andThen(visitInterpolation) ?? super.visitAtRule(node); - T? visitContentRule(ContentRule node) => - visitArgumentInvocation(node.arguments); + T? visitContentRule(ContentRule node) => visitArgumentList(node.arguments); T? visitDebugRule(DebugRule node) => visitExpression(node.expression); @@ -69,7 +68,7 @@ mixin AstSearchVisitor on StatementSearchVisitor : null); T? visitIncludeRule(IncludeRule node) => - visitArgumentInvocation(node.arguments) ?? super.visitIncludeRule(node); + visitArgumentList(node.arguments) ?? super.visitIncludeRule(node); T? visitLoudComment(LoudComment node) => visitInterpolation(node.text); @@ -105,13 +104,12 @@ mixin AstSearchVisitor on StatementSearchVisitor T? visitColorExpression(ColorExpression node) => null; T? visitFunctionExpression(FunctionExpression node) => - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); T? visitInterpolatedFunctionExpression(InterpolatedFunctionExpression node) => - visitInterpolation(node.name) ?? visitArgumentInvocation(node.arguments); + visitInterpolation(node.name) ?? visitArgumentList(node.arguments); - T? visitIfExpression(IfExpression node) => - visitArgumentInvocation(node.arguments); + T? visitIfExpression(IfExpression node) => visitArgumentList(node.arguments); T? visitListExpression(ListExpression node) => node.contents.search((item) => item.accept(this)); @@ -143,8 +141,8 @@ mixin AstSearchVisitor on StatementSearchVisitor @protected T? visitCallableDeclaration(CallableDeclaration node) => - node.arguments.arguments.search( - (argument) => argument.defaultValue.andThen(visitExpression)) ?? + node.parameters.parameters.search( + (parameter) => parameter.defaultValue.andThen(visitExpression)) ?? super.visitCallableDeclaration(node); /// Visits each expression in an [invocation]. @@ -152,7 +150,7 @@ mixin AstSearchVisitor on StatementSearchVisitor /// The default implementation of the visit methods calls this to visit any /// argument invocation in a statement. @protected - T? visitArgumentInvocation(ArgumentInvocation invocation) => + T? visitArgumentList(ArgumentList invocation) => invocation.positional .search((expression) => visitExpression(expression)) ?? invocation.named.values diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index c06b45484..c03d970c2 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -477,7 +477,7 @@ final class _EvaluateVisitor var args = arguments[1] as SassArgumentList; var callableNode = _callableNode!; - var invocation = ArgumentInvocation([], {}, callableNode.span, + var invocation = ArgumentList([], {}, callableNode.span, rest: ValueExpression(args, callableNode.span), keywordRest: args.keywords.isEmpty ? null @@ -551,7 +551,7 @@ final class _EvaluateVisitor var args = arguments[1] as SassArgumentList; var callableNode = _callableNode!; - var invocation = ArgumentInvocation( + var invocation = ArgumentList( const [], const {}, callableNode.span, @@ -1211,8 +1211,7 @@ final class _EvaluateVisitor if (siblings.last != _parent && // Reproduce this condition from [_warn] so that we don't add anything to // [interleavedRules] for declarations in dependencies. - !(_quietDeps && - (_inDependency || (_currentCallable?.inDependency ?? false)))) { + !(_quietDeps && _inDependency)) { loop: for (var sibling in siblings.skip(siblings.indexOf(_parent) + 1)) { switch (sibling) { @@ -1851,7 +1850,7 @@ final class _EvaluateVisitor Future _applyMixin( AsyncCallable? mixin, UserDefinedCallable? contentCallable, - ArgumentInvocation arguments, + ArgumentList arguments, AstNode nodeWithSpan, AstNode nodeWithSpanWithoutContent) async { switch (mixin) { @@ -1887,7 +1886,7 @@ final class _EvaluateVisitor "Mixin doesn't accept a content block.", nodeWithSpanWithoutContent.span, "invocation", - {mixin.declaration.arguments.spanWithName: "declaration"}, + {mixin.declaration.parameters.spanWithName: "declaration"}, _stackTrace(nodeWithSpanWithoutContent.span)); case UserDefinedCallable(): @@ -2456,7 +2455,8 @@ final class _EvaluateVisitor (node.namespace == null && const { "calc", "clamp", "hypot", "sin", "cos", "tan", "asin", "acos", // - "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", "log" + "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", // + "log", "calc-size" }.contains(node.name.toLowerCase()) && _environment.getFunction(node.name) == null); @@ -2578,7 +2578,8 @@ final class _EvaluateVisitor "rem" || "atan2" || "pow" || - "log": + "log" || + "calc-size": return await _visitCalculation(node); } @@ -2671,6 +2672,8 @@ final class _EvaluateVisitor _warn(message, node.span, deprecation)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + "calc-size" => + SassCalculation.calcSize(arguments[0], arguments.elementAtOrNull(1)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') }; } on SassScriptException catch (error, stackTrace) { @@ -2718,7 +2721,7 @@ final class _EvaluateVisitor check(1); case "min" || "max" || "hypot": check(); - case "pow" || "atan2" || "log" || "mod" || "rem": + case "pow" || "atan2" || "log" || "mod" || "rem" || "calc-size": check(2); case "round" || "clamp": check(3); @@ -2930,7 +2933,7 @@ final class _EvaluateVisitor /// Evaluates the arguments in [arguments] as applied to [callable], and /// invokes [run] in a scope with those arguments defined. Future _runUserDefinedCallable( - ArgumentInvocation arguments, + ArgumentList arguments, UserDefinedCallable callable, AstNode nodeWithSpan, Future run()) async { @@ -2942,43 +2945,45 @@ final class _EvaluateVisitor if (name != "@content") name += "()"; var oldCallable = _currentCallable; + var oldInDependency = _inDependency; _currentCallable = callable; + _inDependency = callable.inDependency; var result = await _withStackFrame(name, nodeWithSpan, () { // Add an extra closure() call so that modifications to the environment // don't affect the underlying environment closure. return _withEnvironment(callable.environment.closure(), () { return _environment.scope(() async { _verifyArguments(evaluated.positional.length, evaluated.named, - callable.declaration.arguments, nodeWithSpan); + callable.declaration.parameters, nodeWithSpan); - var declaredArguments = callable.declaration.arguments.arguments; + var parameters = callable.declaration.parameters.parameters; var minLength = - math.min(evaluated.positional.length, declaredArguments.length); + math.min(evaluated.positional.length, parameters.length); for (var i = 0; i < minLength; i++) { - _environment.setLocalVariable(declaredArguments[i].name, + _environment.setLocalVariable(parameters[i].name, evaluated.positional[i], evaluated.positionalNodes[i]); } for (var i = evaluated.positional.length; - i < declaredArguments.length; + i < parameters.length; i++) { - var argument = declaredArguments[i]; - var value = evaluated.named.remove(argument.name) ?? + var parameter = parameters[i]; + var value = evaluated.named.remove(parameter.name) ?? _withoutSlash( - await argument.defaultValue!.accept>(this), - _expressionNode(argument.defaultValue!)); + await parameter.defaultValue!.accept>(this), + _expressionNode(parameter.defaultValue!)); _environment.setLocalVariable( - argument.name, + parameter.name, value, - evaluated.namedNodes[argument.name] ?? - _expressionNode(argument.defaultValue!)); + evaluated.namedNodes[parameter.name] ?? + _expressionNode(parameter.defaultValue!)); } SassArgumentList? argumentList; - var restArgument = callable.declaration.arguments.restArgument; - if (restArgument != null) { - var rest = evaluated.positional.length > declaredArguments.length - ? evaluated.positional.sublist(declaredArguments.length) + var restParameter = callable.declaration.parameters.restParameter; + if (restParameter != null) { + var rest = evaluated.positional.length > parameters.length + ? evaluated.positional.sublist(parameters.length) : const []; argumentList = SassArgumentList( rest, @@ -2987,7 +2992,7 @@ final class _EvaluateVisitor ? ListSeparator.comma : evaluated.separator); _environment.setLocalVariable( - restArgument, argumentList, nodeWithSpan); + restParameter, argumentList, nodeWithSpan); } var result = await run(); @@ -2996,24 +3001,26 @@ final class _EvaluateVisitor if (evaluated.named.isEmpty) return result; if (argumentList.wereKeywordsAccessed) return result; - var argumentWord = pluralize('argument', evaluated.named.keys.length); - var argumentNames = + var parameterWord = + pluralize('parameter', evaluated.named.keys.length); + var parameterNames = toSentence(evaluated.named.keys.map((name) => "\$$name"), 'or'); throw MultiSpanSassRuntimeException( - "No $argumentWord named $argumentNames.", + "No $parameterWord named $parameterNames.", nodeWithSpan.span, "invocation", - {callable.declaration.arguments.spanWithName: "declaration"}, + {callable.declaration.parameters.spanWithName: "declaration"}, _stackTrace(nodeWithSpan.span)); }); }); }); _currentCallable = oldCallable; + _inDependency = oldInDependency; return result; } /// Evaluates [arguments] as applied to [callable]. - Future _runFunctionCallable(ArgumentInvocation arguments, + Future _runFunctionCallable(ArgumentList arguments, AsyncCallable? callable, AstNode nodeWithSpan) async { if (callable is AsyncBuiltInCallable) { return _withoutSlash( @@ -3074,7 +3081,7 @@ final class _EvaluateVisitor /// Evaluates [invocation] as applied to [callable], and invokes [callable]'s /// body. - Future _runBuiltInCallable(ArgumentInvocation arguments, + Future _runBuiltInCallable(ArgumentList arguments, AsyncBuiltInCallable callable, AstNode nodeWithSpan) async { var evaluated = await _evaluateArguments(arguments); @@ -3087,23 +3094,21 @@ final class _EvaluateVisitor _addExceptionSpan(nodeWithSpan, () => overload.verify(evaluated.positional.length, namedSet)); - var declaredArguments = overload.arguments; - for (var i = evaluated.positional.length; - i < declaredArguments.length; - i++) { - var argument = declaredArguments[i]; - evaluated.positional.add(evaluated.named.remove(argument.name) ?? - _withoutSlash(await argument.defaultValue!.accept(this), - argument.defaultValue!)); + var parameters = overload.parameters; + for (var i = evaluated.positional.length; i < parameters.length; i++) { + var parameter = parameters[i]; + evaluated.positional.add(evaluated.named.remove(parameter.name) ?? + _withoutSlash(await parameter.defaultValue!.accept(this), + parameter.defaultValue!)); } SassArgumentList? argumentList; - if (overload.restArgument != null) { + if (overload.restParameter != null) { var rest = const []; - if (evaluated.positional.length > declaredArguments.length) { - rest = evaluated.positional.sublist(declaredArguments.length); + if (evaluated.positional.length > parameters.length) { + rest = evaluated.positional.sublist(parameters.length); evaluated.positional - .removeRange(declaredArguments.length, evaluated.positional.length); + .removeRange(parameters.length, evaluated.positional.length); } argumentList = SassArgumentList( @@ -3132,7 +3137,7 @@ final class _EvaluateVisitor if (argumentList.wereKeywordsAccessed) return result; throw MultiSpanSassRuntimeException( - "No ${pluralize('argument', evaluated.named.keys.length)} named " + "No ${pluralize('parameter', evaluated.named.keys.length)} named " "${toSentence(evaluated.named.keys.map((name) => "\$$name"), 'or')}.", nodeWithSpan.span, "invocation", @@ -3141,8 +3146,7 @@ final class _EvaluateVisitor } /// Returns the evaluated values of the given [arguments]. - Future<_ArgumentResults> _evaluateArguments( - ArgumentInvocation arguments) async { + Future<_ArgumentResults> _evaluateArguments(ArgumentList arguments) async { // TODO(nweiz): This used to avoid tracking source spans for arguments if // [_sourceMap]s was false or it was being called from // [_runBuiltInCallable]. We always have to track them now to produce better @@ -3318,11 +3322,11 @@ final class _EvaluateVisitor } /// Throws a [SassRuntimeException] if [positional] and [named] aren't valid - /// when applied to [arguments]. + /// when applied to [parameters]. void _verifyArguments(int positional, Map named, - ArgumentDeclaration arguments, AstNode nodeWithSpan) => + ParameterList parameters, AstNode nodeWithSpan) => _addExceptionSpan( - nodeWithSpan, () => arguments.verify(positional, MapKeySet(named))); + nodeWithSpan, () => parameters.verify(positional, MapKeySet(named))); Future visitSelectorExpression(SelectorExpression node) async => _styleRuleIgnoringAtRoot?.originalSelector.asSassList ?? sassNull; @@ -3871,10 +3875,7 @@ final class _EvaluateVisitor /// Emits a warning with the given [message] about the given [span]. void _warn(String message, FileSpan span, [Deprecation? deprecation]) { - if (_quietDeps && - (_inDependency || (_currentCallable?.inDependency ?? false))) { - return; - } + if (_quietDeps && _inDependency) return; if (!_warningsEmitted.add((message, span))) return; var trace = _stackTrace(span); diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 792326ecc..a826deb7c 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: fbffa0dbe5a1af846dc83752457d39fb2984d280 +// Checksum: 548e54482bead470a6c7931a9d3e098da8b600d6 // // ignore_for_file: unused_import @@ -484,7 +484,7 @@ final class _EvaluateVisitor var args = arguments[1] as SassArgumentList; var callableNode = _callableNode!; - var invocation = ArgumentInvocation([], {}, callableNode.span, + var invocation = ArgumentList([], {}, callableNode.span, rest: ValueExpression(args, callableNode.span), keywordRest: args.keywords.isEmpty ? null @@ -555,7 +555,7 @@ final class _EvaluateVisitor var args = arguments[1] as SassArgumentList; var callableNode = _callableNode!; - var invocation = ArgumentInvocation( + var invocation = ArgumentList( const [], const {}, callableNode.span, @@ -1211,8 +1211,7 @@ final class _EvaluateVisitor if (siblings.last != _parent && // Reproduce this condition from [_warn] so that we don't add anything to // [interleavedRules] for declarations in dependencies. - !(_quietDeps && - (_inDependency || (_currentCallable?.inDependency ?? false)))) { + !(_quietDeps && _inDependency)) { loop: for (var sibling in siblings.skip(siblings.indexOf(_parent) + 1)) { switch (sibling) { @@ -1847,7 +1846,7 @@ final class _EvaluateVisitor void _applyMixin( Callable? mixin, UserDefinedCallable? contentCallable, - ArgumentInvocation arguments, + ArgumentList arguments, AstNode nodeWithSpan, AstNode nodeWithSpanWithoutContent) { switch (mixin) { @@ -1881,7 +1880,7 @@ final class _EvaluateVisitor "Mixin doesn't accept a content block.", nodeWithSpanWithoutContent.span, "invocation", - {mixin.declaration.arguments.spanWithName: "declaration"}, + {mixin.declaration.parameters.spanWithName: "declaration"}, _stackTrace(nodeWithSpanWithoutContent.span)); case UserDefinedCallable(): @@ -2437,7 +2436,8 @@ final class _EvaluateVisitor (node.namespace == null && const { "calc", "clamp", "hypot", "sin", "cos", "tan", "asin", "acos", // - "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", "log" + "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", // + "log", "calc-size" }.contains(node.name.toLowerCase()) && _environment.getFunction(node.name) == null); @@ -2556,7 +2556,8 @@ final class _EvaluateVisitor "rem" || "atan2" || "pow" || - "log": + "log" || + "calc-size": return _visitCalculation(node); } @@ -2649,6 +2650,8 @@ final class _EvaluateVisitor _warn(message, node.span, deprecation)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + "calc-size" => + SassCalculation.calcSize(arguments[0], arguments.elementAtOrNull(1)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') }; } on SassScriptException catch (error, stackTrace) { @@ -2696,7 +2699,7 @@ final class _EvaluateVisitor check(1); case "min" || "max" || "hypot": check(); - case "pow" || "atan2" || "log" || "mod" || "rem": + case "pow" || "atan2" || "log" || "mod" || "rem" || "calc-size": check(2); case "round" || "clamp": check(3); @@ -2908,7 +2911,7 @@ final class _EvaluateVisitor /// Evaluates the arguments in [arguments] as applied to [callable], and /// invokes [run] in a scope with those arguments defined. V _runUserDefinedCallable( - ArgumentInvocation arguments, + ArgumentList arguments, UserDefinedCallable callable, AstNode nodeWithSpan, V run()) { @@ -2920,42 +2923,44 @@ final class _EvaluateVisitor if (name != "@content") name += "()"; var oldCallable = _currentCallable; + var oldInDependency = _inDependency; _currentCallable = callable; + _inDependency = callable.inDependency; var result = _withStackFrame(name, nodeWithSpan, () { // Add an extra closure() call so that modifications to the environment // don't affect the underlying environment closure. return _withEnvironment(callable.environment.closure(), () { return _environment.scope(() { _verifyArguments(evaluated.positional.length, evaluated.named, - callable.declaration.arguments, nodeWithSpan); + callable.declaration.parameters, nodeWithSpan); - var declaredArguments = callable.declaration.arguments.arguments; + var parameters = callable.declaration.parameters.parameters; var minLength = - math.min(evaluated.positional.length, declaredArguments.length); + math.min(evaluated.positional.length, parameters.length); for (var i = 0; i < minLength; i++) { - _environment.setLocalVariable(declaredArguments[i].name, + _environment.setLocalVariable(parameters[i].name, evaluated.positional[i], evaluated.positionalNodes[i]); } for (var i = evaluated.positional.length; - i < declaredArguments.length; + i < parameters.length; i++) { - var argument = declaredArguments[i]; - var value = evaluated.named.remove(argument.name) ?? - _withoutSlash(argument.defaultValue!.accept(this), - _expressionNode(argument.defaultValue!)); + var parameter = parameters[i]; + var value = evaluated.named.remove(parameter.name) ?? + _withoutSlash(parameter.defaultValue!.accept(this), + _expressionNode(parameter.defaultValue!)); _environment.setLocalVariable( - argument.name, + parameter.name, value, - evaluated.namedNodes[argument.name] ?? - _expressionNode(argument.defaultValue!)); + evaluated.namedNodes[parameter.name] ?? + _expressionNode(parameter.defaultValue!)); } SassArgumentList? argumentList; - var restArgument = callable.declaration.arguments.restArgument; - if (restArgument != null) { - var rest = evaluated.positional.length > declaredArguments.length - ? evaluated.positional.sublist(declaredArguments.length) + var restParameter = callable.declaration.parameters.restParameter; + if (restParameter != null) { + var rest = evaluated.positional.length > parameters.length + ? evaluated.positional.sublist(parameters.length) : const []; argumentList = SassArgumentList( rest, @@ -2964,7 +2969,7 @@ final class _EvaluateVisitor ? ListSeparator.comma : evaluated.separator); _environment.setLocalVariable( - restArgument, argumentList, nodeWithSpan); + restParameter, argumentList, nodeWithSpan); } var result = run(); @@ -2973,25 +2978,27 @@ final class _EvaluateVisitor if (evaluated.named.isEmpty) return result; if (argumentList.wereKeywordsAccessed) return result; - var argumentWord = pluralize('argument', evaluated.named.keys.length); - var argumentNames = + var parameterWord = + pluralize('parameter', evaluated.named.keys.length); + var parameterNames = toSentence(evaluated.named.keys.map((name) => "\$$name"), 'or'); throw MultiSpanSassRuntimeException( - "No $argumentWord named $argumentNames.", + "No $parameterWord named $parameterNames.", nodeWithSpan.span, "invocation", - {callable.declaration.arguments.spanWithName: "declaration"}, + {callable.declaration.parameters.spanWithName: "declaration"}, _stackTrace(nodeWithSpan.span)); }); }); }); _currentCallable = oldCallable; + _inDependency = oldInDependency; return result; } /// Evaluates [arguments] as applied to [callable]. Value _runFunctionCallable( - ArgumentInvocation arguments, Callable? callable, AstNode nodeWithSpan) { + ArgumentList arguments, Callable? callable, AstNode nodeWithSpan) { if (callable is BuiltInCallable) { return _withoutSlash( _runBuiltInCallable(arguments, callable, nodeWithSpan), nodeWithSpan); @@ -3049,8 +3056,8 @@ final class _EvaluateVisitor /// Evaluates [invocation] as applied to [callable], and invokes [callable]'s /// body. - Value _runBuiltInCallable(ArgumentInvocation arguments, - BuiltInCallable callable, AstNode nodeWithSpan) { + Value _runBuiltInCallable( + ArgumentList arguments, BuiltInCallable callable, AstNode nodeWithSpan) { var evaluated = _evaluateArguments(arguments); var oldCallableNode = _callableNode; @@ -3062,23 +3069,21 @@ final class _EvaluateVisitor _addExceptionSpan(nodeWithSpan, () => overload.verify(evaluated.positional.length, namedSet)); - var declaredArguments = overload.arguments; - for (var i = evaluated.positional.length; - i < declaredArguments.length; - i++) { - var argument = declaredArguments[i]; - evaluated.positional.add(evaluated.named.remove(argument.name) ?? + var parameters = overload.parameters; + for (var i = evaluated.positional.length; i < parameters.length; i++) { + var parameter = parameters[i]; + evaluated.positional.add(evaluated.named.remove(parameter.name) ?? _withoutSlash( - argument.defaultValue!.accept(this), argument.defaultValue!)); + parameter.defaultValue!.accept(this), parameter.defaultValue!)); } SassArgumentList? argumentList; - if (overload.restArgument != null) { + if (overload.restParameter != null) { var rest = const []; - if (evaluated.positional.length > declaredArguments.length) { - rest = evaluated.positional.sublist(declaredArguments.length); + if (evaluated.positional.length > parameters.length) { + rest = evaluated.positional.sublist(parameters.length); evaluated.positional - .removeRange(declaredArguments.length, evaluated.positional.length); + .removeRange(parameters.length, evaluated.positional.length); } argumentList = SassArgumentList( @@ -3107,7 +3112,7 @@ final class _EvaluateVisitor if (argumentList.wereKeywordsAccessed) return result; throw MultiSpanSassRuntimeException( - "No ${pluralize('argument', evaluated.named.keys.length)} named " + "No ${pluralize('parameter', evaluated.named.keys.length)} named " "${toSentence(evaluated.named.keys.map((name) => "\$$name"), 'or')}.", nodeWithSpan.span, "invocation", @@ -3116,7 +3121,7 @@ final class _EvaluateVisitor } /// Returns the evaluated values of the given [arguments]. - _ArgumentResults _evaluateArguments(ArgumentInvocation arguments) { + _ArgumentResults _evaluateArguments(ArgumentList arguments) { // TODO(nweiz): This used to avoid tracking source spans for arguments if // [_sourceMap]s was false or it was being called from // [_runBuiltInCallable]. We always have to track them now to produce better @@ -3292,11 +3297,11 @@ final class _EvaluateVisitor } /// Throws a [SassRuntimeException] if [positional] and [named] aren't valid - /// when applied to [arguments]. + /// when applied to [parameters]. void _verifyArguments(int positional, Map named, - ArgumentDeclaration arguments, AstNode nodeWithSpan) => + ParameterList parameters, AstNode nodeWithSpan) => _addExceptionSpan( - nodeWithSpan, () => arguments.verify(positional, MapKeySet(named))); + nodeWithSpan, () => parameters.verify(positional, MapKeySet(named))); Value visitSelectorExpression(SelectorExpression node) => _styleRuleIgnoringAtRoot?.originalSelector.asSassList ?? sassNull; @@ -3834,10 +3839,7 @@ final class _EvaluateVisitor /// Emits a warning with the given [message] about the given [span]. void _warn(String message, FileSpan span, [Deprecation? deprecation]) { - if (_quietDeps && - (_inDependency || (_currentCallable?.inDependency ?? false))) { - return; - } + if (_quietDeps && _inDependency) return; if (!_warningsEmitted.add((message, span))) return; var trace = _stackTrace(span); diff --git a/lib/src/visitor/expression_to_calc.dart b/lib/src/visitor/expression_to_calc.dart index aca554355..497aafd6c 100644 --- a/lib/src/visitor/expression_to_calc.dart +++ b/lib/src/visitor/expression_to_calc.dart @@ -13,7 +13,7 @@ import 'replace_expression.dart'; FunctionExpression expressionToCalc(Expression expression) => FunctionExpression( "calc", - ArgumentInvocation( + ArgumentList( [expression.accept(const _MakeExpressionCalculationSafe())], const {}, expression.span), @@ -31,7 +31,7 @@ class _MakeExpressionCalculationSafe with ReplaceExpressionVisitor { // `mod()` calculation function because there's no browser support, so we have // to work around it by wrapping the call in a Sass function. ? FunctionExpression( - 'max', ArgumentInvocation([node], const {}, node.span), node.span, + 'max', ArgumentList([node], const {}, node.span), node.span, namespace: 'math') : super.visitBinaryOperationExpression(node); diff --git a/lib/src/visitor/recursive_ast.dart b/lib/src/visitor/recursive_ast.dart index 290572697..f2ac25fec 100644 --- a/lib/src/visitor/recursive_ast.dart +++ b/lib/src/visitor/recursive_ast.dart @@ -15,7 +15,7 @@ import 'recursive_statement.dart'; /// This extends [RecursiveStatementVisitor] to traverse each expression in /// addition to each statement. It adds even more protected methods: /// -/// * [visitArgumentInvocation] +/// * [visitArgumentList] /// * [visitSupportsCondition] /// * [visitInterpolation] /// @@ -34,7 +34,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } void visitContentRule(ContentRule node) { - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); } void visitDebugRule(DebugRule node) { @@ -91,7 +91,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } void visitIncludeRule(IncludeRule node) { - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); super.visitIncludeRule(node); } @@ -157,17 +157,17 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } void visitFunctionExpression(FunctionExpression node) { - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); } void visitInterpolatedFunctionExpression( InterpolatedFunctionExpression node) { visitInterpolation(node.name); - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); } void visitIfExpression(IfExpression node) { - visitArgumentInvocation(node.arguments); + visitArgumentList(node.arguments); } void visitListExpression(ListExpression node) { @@ -211,8 +211,8 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor @protected void visitCallableDeclaration(CallableDeclaration node) { - for (var argument in node.arguments.arguments) { - argument.defaultValue.andThen(visitExpression); + for (var parameter in node.parameters.parameters) { + parameter.defaultValue.andThen(visitExpression); } super.visitCallableDeclaration(node); } @@ -222,7 +222,7 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor /// The default implementation of the visit methods calls this to visit any /// argument invocation in a statement. @protected - void visitArgumentInvocation(ArgumentInvocation invocation) { + void visitArgumentList(ArgumentList invocation) { for (var expression in invocation.positional) { visitExpression(expression); } diff --git a/lib/src/visitor/replace_expression.dart b/lib/src/visitor/replace_expression.dart index 8c06423de..f32184b8b 100644 --- a/lib/src/visitor/replace_expression.dart +++ b/lib/src/visitor/replace_expression.dart @@ -16,7 +16,7 @@ import 'interface/expression.dart'; /// protected methods that can be overridden to add behavior for a wide variety /// of AST nodes: /// -/// * [visitArgumentInvocation] +/// * [visitArgumentList] /// * [visitSupportsCondition] /// * [visitInterpolation] /// @@ -33,16 +33,16 @@ mixin ReplaceExpressionVisitor implements ExpressionVisitor { Expression visitFunctionExpression( FunctionExpression node) => FunctionExpression( - node.originalName, visitArgumentInvocation(node.arguments), node.span, + node.originalName, visitArgumentList(node.arguments), node.span, namespace: node.namespace); Expression visitInterpolatedFunctionExpression( InterpolatedFunctionExpression node) => InterpolatedFunctionExpression(visitInterpolation(node.name), - visitArgumentInvocation(node.arguments), node.span); + visitArgumentList(node.arguments), node.span); Expression visitIfExpression(IfExpression node) => - IfExpression(visitArgumentInvocation(node.arguments), node.span); + IfExpression(visitArgumentList(node.arguments), node.span); Expression visitListExpression(ListExpression node) => ListExpression( node.contents.map((item) => item.accept(this)), node.separator, node.span, @@ -81,16 +81,15 @@ mixin ReplaceExpressionVisitor implements ExpressionVisitor { /// The default implementation of the visit methods calls this to replace any /// argument invocation in an expression. @protected - ArgumentInvocation visitArgumentInvocation(ArgumentInvocation invocation) => - ArgumentInvocation( - invocation.positional.map((expression) => expression.accept(this)), - { - for (var (name, value) in invocation.named.pairs) - name: value.accept(this) - }, - invocation.span, - rest: invocation.rest?.accept(this), - keywordRest: invocation.keywordRest?.accept(this)); + ArgumentList visitArgumentList(ArgumentList invocation) => ArgumentList( + invocation.positional.map((expression) => expression.accept(this)), + { + for (var (name, value) in invocation.named.pairs) + name: value.accept(this) + }, + invocation.span, + rest: invocation.rest?.accept(this), + keywordRest: invocation.keywordRest?.accept(this)); /// Replaces each expression in [condition]. /// diff --git a/lib/src/visitor/source_interpolation.dart b/lib/src/visitor/source_interpolation.dart index aadb3e1e5..9a8e70634 100644 --- a/lib/src/visitor/source_interpolation.dart +++ b/lib/src/visitor/source_interpolation.dart @@ -37,7 +37,7 @@ class SourceInterpolationVisitor implements ExpressionVisitor { /// Visits the positional arguments in [arguments] with [visitor], if it's /// valid interpolated plain CSS. - void _visitArguments(ArgumentInvocation arguments, + void _visitArguments(ArgumentList arguments, [ExpressionVisitor? visitor]) { if (arguments.named.isNotEmpty || arguments.rest != null) return; diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md index 3f6138e4b..af044c012 100644 --- a/pkg/sass-parser/CHANGELOG.md +++ b/pkg/sass-parser/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.4.9 + +* No user-visible changes. + +## 0.4.8 + +Add support for parsing the `@include` rule. + +Add support for parsing the `@mixin` rule. + +Add support for parsing the `@return` rule. + +## 0.4.7 + +* No user-visible changes. + +## 0.4.6 + +* No user-visible changes. + ## 0.4.5 * Add support for parsing the `@forward` rule. diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index 99dfa5283..1fb3a90a2 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -8,6 +8,20 @@ import {Root} from './src/statement/root'; import * as sassInternal from './src/sass-internal'; import {Stringifier} from './src/stringifier'; +export { + Argument, + ArgumentExpressionProps, + ArgumentObjectProps, + ArgumentProps, + ArgumentRaws, +} from './src/argument'; +export { + ArgumentList, + ArgumentListObjectProps, + ArgumentListProps, + ArgumentListRaws, + NewArguments, +} from './src/argument-list'; export { Configuration, ConfigurationProps, @@ -20,6 +34,7 @@ export { ConfiguredVariableProps, ConfiguredVariableRaws, } from './src/configured-variable'; +export {Container} from './src/container'; export {AnyNode, Node, NodeProps, NodeType} from './src/node'; export {RawWithValue} from './src/raw-with-value'; export { @@ -49,12 +64,31 @@ export { NumberExpressionProps, NumberExpressionRaws, } from './src/expression/number'; +export { + IncludeRule, + IncludeRuleProps, + IncludeRuleRaws, +} from './src/statement/include-rule'; export { Interpolation, InterpolationProps, InterpolationRaws, NewNodeForInterpolation, } from './src/interpolation'; +export { + NewParameters, + ParameterListObjectProps, + ParameterListProps, + ParameterListRaws, + ParameterList, +} from './src/parameter-list'; +export { + ParameterObjectProps, + ParameterRaws, + ParameterExpressionProps, + ParameterProps, + Parameter, +} from './src/parameter'; export { CssComment, CssCommentProps, @@ -79,11 +113,26 @@ export { ForwardRuleProps, ForwardRuleRaws, } from './src/statement/forward-rule'; +export { + FunctionRuleRaws, + FunctionRuleProps, + FunctionRule, +} from './src/statement/function-rule'; export { GenericAtRule, GenericAtRuleProps, GenericAtRuleRaws, } from './src/statement/generic-at-rule'; +export { + MixinRule, + MixinRuleProps, + MixinRuleRaws, +} from './src/statement/mixin-rule'; +export { + ReturnRule, + ReturnRuleProps, + ReturnRuleRaws, +} from './src/statement/return-rule'; export {Root, RootProps, RootRaws} from './src/statement/root'; export {Rule, RuleProps, RuleRaws} from './src/statement/rule'; export { diff --git a/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap new file mode 100644 index 000000000..dd74492f2 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/argument-list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an argument list toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@include x(foo, bar...)", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + , + , + ], + "raws": {}, + "sassType": "argument-list", + "source": <1:11-1:24 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap new file mode 100644 index 000000000..85af46cc3 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/argument.test.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a argument toJSON with a name 1`] = ` +{ + "inputs": [ + { + "css": "@include x($baz: qux)", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "rest": false, + "sassType": "argument", + "value": , +} +`; + +exports[`a argument toJSON with no name 1`] = ` +{ + "inputs": [ + { + "css": "@include x(qux)", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "rest": false, + "sassType": "argument", + "value": , +} +`; + +exports[`a argument toJSON with rest 1`] = ` +{ + "inputs": [ + { + "css": "@include x(qux...)", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "rest": true, + "sassType": "argument", + "value": , +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap index 2b5609937..ec5132c40 100644 --- a/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap +++ b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap @@ -11,9 +11,9 @@ exports[`a configured variable toJSON 1`] = ` "id": "", }, ], + "name": "baz", "raws": {}, "sassType": "configured-variable", "source": <1:18-1:29 in 0>, - "variableName": "baz", } `; diff --git a/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap new file mode 100644 index 000000000..96fc0f068 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/parameter-list.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a parameter list toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@function x($foo, $bar...) {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <$foo>, + <$bar...>, + ], + "raws": {}, + "sassType": "parameter-list", + "source": <1:12-1:27 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap new file mode 100644 index 000000000..56d9b6793 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/parameter.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a parameter toJSON with a default 1`] = ` +{ + "defaultValue": <"qux">, + "inputs": [ + { + "css": "@function x($baz: "qux") {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "rest": false, + "sassType": "parameter", + "source": <1:13-1:24 in 0>, +} +`; + +exports[`a parameter toJSON with no default 1`] = ` +{ + "inputs": [ + { + "css": "@function x($baz) {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "baz", + "raws": {}, + "rest": false, + "sassType": "parameter", + "source": <1:13-1:17 in 0>, +} +`; + +exports[`a parameter toJSON with rest = true 1`] = ` +{ + "inputs": [], + "name": "baz", + "raws": {}, + "rest": true, + "sassType": "parameter", +} +`; diff --git a/pkg/sass-parser/lib/src/argument-list.test.ts b/pkg/sass-parser/lib/src/argument-list.test.ts new file mode 100644 index 000000000..3d1a77d88 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument-list.test.ts @@ -0,0 +1,903 @@ +// Copyright 2024 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 { + Argument, + ArgumentList, + IncludeRule, + StringExpression, + sass, + scss, +} from '..'; + +type EachFn = Parameters[0]; + +let node: ArgumentList; +describe('an argument list', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@include x()').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@include x()').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describeNode('with no arguments', () => new ArgumentList()); + + describeNode('with an array', () => new ArgumentList([])); + + describeNode('with an object', () => new ArgumentList({})); + + describeNode( + 'with an object with an array', + () => new ArgumentList({nodes: []}), + ); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => new IncludeRule({includeName: 'x', arguments: {}}).arguments, + ); + + describeNode( + 'an array', + () => new IncludeRule({includeName: 'x', arguments: []}).arguments, + ); + }); + }); + + describe('with an argument with no name', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBeUndefined; + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0].parent).toBe(node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@include x(bar)').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@include x(bar)').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => new ArgumentList([new StringExpression({text: 'bar'})]), + ); + + describeNode( + 'with an Argument', + () => new ArgumentList([new Argument({text: 'bar'})]), + ); + + describeNode( + 'with ArgumentProps', + () => new ArgumentList([{value: {text: 'bar'}}]), + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList([{text: 'bar'}]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ArgumentList({nodes: [new StringExpression({text: 'bar'})]}), + ); + + describeNode( + 'with an Argument', + () => new ArgumentList({nodes: [new Argument({text: 'bar'})]}), + ); + + describeNode( + 'with ArgumentProps', + () => new ArgumentList({nodes: [{value: {text: 'bar'}}]}), + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList({nodes: [{text: 'bar'}]}), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new IncludeRule({ + includeName: 'x', + arguments: {nodes: [{text: 'bar'}]}, + }).arguments, + ); + + describeNode( + 'an array', + () => + new IncludeRule({includeName: 'x', arguments: [{text: 'bar'}]}) + .arguments, + ); + }); + }); + + describe('with an argument with a name', () => { + function describeNode( + description: string, + create: () => ArgumentList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include x($foo: bar)').nodes[0] as IncludeRule).arguments, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include x($foo: bar)').nodes[0] as IncludeRule).arguments, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with a sub-array', + () => new ArgumentList([['foo', {text: 'bar'}]]), + ); + + describeNode( + 'with an object', + () => new ArgumentList([{name: 'foo', value: {text: 'bar'}}]), + ); + + describeNode( + 'with a Argument', + () => + new ArgumentList([ + new Argument({name: 'foo', value: {text: 'bar'}}), + ]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a sub-array', + () => new ArgumentList({nodes: [['foo', {text: 'bar'}]]}), + ); + + describeNode( + 'with an object', + () => + new ArgumentList({ + nodes: [{name: 'foo', value: {text: 'bar'}}], + }), + ); + + describeNode( + 'with a Argument', + () => + new ArgumentList({ + nodes: [new Argument({name: 'foo', value: {text: 'bar'}})], + }), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new IncludeRule({ + includeName: 'x', + arguments: {nodes: [['foo', {text: 'bar'}]]}, + }).arguments, + ); + + describeNode( + 'an array', + () => + new IncludeRule({ + includeName: 'x', + arguments: [['foo', {text: 'bar'}]], + }).arguments, + ); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ArgumentList())); + + it('a single argument', () => { + const argument = new Argument({text: 'foo'}); + node.append(argument); + expect(node.nodes).toEqual([argument]); + expect(argument).toHaveProperty('parent', node); + }); + + it('a list of arguments', () => { + const foo = new Argument({text: 'foo'}); + const bar = new Argument({text: 'bar'}); + node.append([foo, bar]); + expect(node.nodes).toEqual([foo, bar]); + }); + + it('ExpressionProps', () => { + node.append({text: 'bar'}); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('an array of ExpressionProps', () => { + node.append([{text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a single pair', () => { + node.append(['foo', {text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a list of pairs', () => { + node.append([ + ['foo', {text: 'bar'}], + ['baz', {text: 'qux'}], + ]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Argument); + expect(node.nodes[1].name).toBe('baz'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('a single ArgumentProps', () => { + node.append({value: {text: 'bar'}}); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('multiple ArgumentProps', () => { + node.append([{value: {text: 'bar'}}, {value: {text: 'baz'}}]); + expect(node.nodes[0]).toBeInstanceOf(Argument); + expect(node.nodes[0].name).toBeUndefined(); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Argument); + expect(node.nodes[1].name).toBeUndefined(); + expect(node.nodes[1]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: 'bar'}])), + ); + + it('adds multiple children to the end', () => { + node.append({text: 'baz'}, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.append({text: 'baz'}), + )); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: 'bar'}])), + ); + + it('calls the callback for each node', () => { + const fn: EachFn = jest.fn(); + node.each(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith( + 1, + expect.toHaveStringExpression('value', 'foo'), + 0, + ); + expect(fn).toHaveBeenNthCalledWith( + 2, + expect.toHaveStringExpression('value', 'bar'), + 1, + ); + }); + + it('returns undefined if the callback is void', () => + expect(node.each(() => {})).toBeUndefined()); + + it('returns false and stops iterating if the callback returns false', () => { + const fn: EachFn = jest.fn(() => false); + expect(node.each(fn)).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('every', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('returns true if the callback returns true for all elements', () => + expect(node.every(() => true)).toBe(true)); + + it('returns false if the callback returns false for any element', () => + expect( + node.every( + element => (element.value as StringExpression).text.asPlain !== 'bar', + ), + ).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('returns the first index of a given argument', () => + expect(node.index(node.nodes[2])).toBe(2)); + + it('returns a number as-is', () => expect(node.index(3)).toBe(3)); + }); + + describe('insertAfter', () => { + beforeEach( + () => + void (node = new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + })), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[4]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertAfter(0, {text: 'qux'})).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, {text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[3]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[4]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, [{text: 'qux'}, {text: 'qax'}, {text: 'qix'}]), + )); + + it('returns itself', () => + expect(node.insertBefore(0, {text: 'qux'})).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('inserts one node', () => { + node.prepend({text: 'qux'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[2]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[3]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend({text: 'qux'}, {text: 'qax'}, {text: 'qix'}); + expect(node.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.nodes[1]).toHaveStringExpression('value', 'qax'); + expect(node.nodes[2]).toHaveStringExpression('value', 'qix'); + expect(node.nodes[3]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[4]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[5]).toHaveStringExpression('value', 'baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend({text: 'qux'}, {text: 'qax'}, {text: 'qix'}), + )); + + it('returns itself', () => expect(node.prepend({text: 'qux'})).toBe(node)); + }); + + describe('push', () => { + beforeEach( + () => void (node = new ArgumentList([{text: 'foo'}, {text: 'bar'}])), + ); + + it('inserts one node', () => { + node.push(new Argument({text: 'baz'})); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[2]).toHaveStringExpression('value', 'baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new Argument({text: 'baz'})), + )); + + it('returns itself', () => + expect(node.push(new Argument({text: 'baz'}))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeAll(); + expect(child).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation(['foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('removes a matching node', () => { + node.removeChild(node.nodes[0]); + expect(node.nodes[0]).toHaveStringExpression('value', 'bar'); + expect(node.nodes[1]).toHaveStringExpression('value', 'baz'); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(node.nodes[1]).toHaveStringExpression('value', 'baz'); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeChild(1); + expect(child).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation(['foo', 'bar', ['baz', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', 'bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => + void (node = new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ])), + ); + + it('returns false if the callback returns false for all elements', () => + expect(node.some(() => false)).toBe(false)); + + it('returns true if the callback returns true for any element', () => + expect( + node.some( + element => (element.value as StringExpression).text.asPlain === 'bar', + ), + ).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect( + new ArgumentList([{text: 'foo'}, {text: 'bar'}, {text: 'baz'}]).first, + ).toHaveStringExpression('value', 'foo')); + + it('returns undefined for an empty list', () => + expect(new ArgumentList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect( + new ArgumentList({nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}]}) + .last, + ).toHaveStringExpression('value', 'baz')); + + it('returns undefined for an empty list', () => + expect(new ArgumentList().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with no nodes', () => { + it('with default raws', () => + expect(new ArgumentList().toString()).toBe('()')); + + it('ignores comma', () => + expect(new ArgumentList({raws: {comma: true}}).toString()).toBe('()')); + + it('with after', () => + expect(new ArgumentList({raws: {after: '/**/'}}).toString()).toBe( + '(/**/)', + )); + }); + + describe('with arguments', () => { + it('with default raws', () => + expect( + new ArgumentList([ + {text: 'foo'}, + {text: 'bar'}, + {text: 'baz'}, + ]).toString(), + ).toBe('(foo, bar, baz)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {comma: true}, + }).toString(), + ).toBe('(foo, bar, baz,)')); + + describe('with after', () => { + it('with comma: false', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz/**/)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}, {text: 'baz'}], + raws: {comma: true, after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz,/**/)')); + }); + + describe('with a argument with after', () => { + it('with comma: false and no after', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + }).toString(), + ).toBe('(foo, bar, baz )')); + + it('with comma: false and after', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + raws: {after: '/**/'}, + }).toString(), + ).toBe('(foo, bar, baz /**/)')); + + it('with comma: true', () => + expect( + new ArgumentList({ + nodes: [ + {text: 'foo'}, + {text: 'bar'}, + new Argument({value: {text: 'baz'}, raws: {after: ' '}}), + ], + raws: {comma: true}, + }).toString(), + ).toBe('(foo, bar, baz ,)')); + }); + }); + }); + + describe('clone', () => { + let original: ArgumentList; + beforeEach( + () => + void (original = new ArgumentList({ + nodes: [{text: 'foo'}, {text: 'bar'}], + raws: {after: ' '}, + })), + ); + + describe('with no overrides', () => { + let clone: ArgumentList; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(clone.nodes[0].parent).toBe(clone); + expect(clone.nodes[1]).toHaveStringExpression('value', 'bar'); + expect(clone.nodes[1].parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({after: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws', 'nodes'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => + expect(clone.nodes[0]).toHaveProperty('parent', clone)); + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {comma: true}}).raws).toEqual({ + comma: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + after: ' ', + })); + }); + + describe('nodes', () => { + it('defined', () => { + const clone = original.clone({nodes: [{text: 'qux'}]}); + expect(clone.nodes[0]).toHaveStringExpression('value', 'qux'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0]).toHaveStringExpression('value', 'foo'); + expect(clone.nodes[1]).toHaveStringExpression('value', 'bar'); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@include x(foo, bar...)').nodes[0] as IncludeRule).arguments, + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees an argument with each string value + * and index in {@link elements} in order. If an index isn't explicitly + * provided, it defaults to the index in {@link elements}. + * + * When it reaches {@link indexToModify}, it calls {@link modify}, which is + * expected to modify `node.nodes`. + */ +function testEachMutation( + elements: ([string, number] | string)[], + indexToModify: number, + modify: () => void, +): void { + const fn: EachFn = jest.fn((child, i) => { + if (i === indexToModify) modify(); + }); + node.each(fn); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const [text, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.toHaveStringExpression('value', text), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/argument-list.ts b/pkg/sass-parser/lib/src/argument-list.ts new file mode 100644 index 000000000..383474a41 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument-list.ts @@ -0,0 +1,331 @@ +// Copyright 2024 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 * as postcss from 'postcss'; + +import {Argument, ArgumentProps} from './argument'; +import {Container} from './container'; +import {convertExpression} from './expression/convert'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new nodes that can be passed into a argument list, either a + * single argument or multiple arguments. + * + * @category Expression + */ +export type NewArguments = + | Argument + | ArgumentProps + | ReadonlyArray + | undefined; + +/** + * The initializer properties for {@link ArgumentList} passed as an options + * object. + * + * @category Expression + */ +export interface ArgumentListObjectProps { + nodes?: ReadonlyArray; + raws?: ArgumentListRaws; +} + +/** + * The initializer properties for {@link ArgumentList}. + * + * @category Expression + */ +export type ArgumentListProps = + | ArgumentListObjectProps + | ReadonlyArray; + +/** + * Raws indicating how to precisely serialize a {@link ArgumentList} node. + * + * @category Expression + */ +export interface ArgumentListRaws { + /** + * Whether the final argument has a trailing comma. + * + * Ignored if {@link ArgumentList.nodes} is empty. + */ + comma?: boolean; + + /** + * The whitespace between the final argument (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * A list of arguments, as in an `@include` rule or a function call. + * + * @category Expression + */ +export class ArgumentList + extends Node + implements Container +{ + readonly sassType = 'argument-list' as const; + declare raws: ArgumentListRaws; + + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private declare _nodes?: Array; + + /** + * Iterators that are currently active within this argument list. Their + * indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults?: ArgumentListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ArgumentList); + constructor(defaults?: object, inner?: sassInternal.ArgumentList) { + super(Array.isArray(defaults) ? {nodes: defaults} : defaults); + if (inner) { + this.source = new LazySource(inner); + // TODO: set lazy raws here to use when stringifying + this._nodes = []; + for (const expression of inner.positional) { + this.append(new Argument(convertExpression(expression))); + } + for (const [name, expression] of Object.entries( + sassInternal.mapToRecord(inner.named), + )) { + this.append(new Argument({name, value: convertExpression(expression)})); + } + if (inner.rest) { + // TODO: Provide source information for this argument. + this.append({value: convertExpression(inner.rest), rest: true}); + } + if (inner.keywordRest) { + // TODO: Provide source information for this argument. + this.append({value: convertExpression(inner.keywordRest), rest: true}); + } + } + if (this._nodes === undefined) this._nodes = []; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['nodes', 'raws']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewArguments[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: Argument, index: number) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + every( + condition: ( + node: Argument, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: Argument | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter(oldNode: Argument | number, newNode: NewArguments): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + insertBefore(oldNode: Argument | number, newNode: NewArguments): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + prepend(...nodes: NewArguments[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + push(child: Argument): this { + return this.append(child); + } + + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + removeChild(child: Argument | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const argument = this._nodes![index]; + if (argument) argument.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: Argument, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): Argument | undefined { + return this.nodes[0]; + } + + get last(): Argument | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const argument of this.nodes) { + if (first) { + result += argument.raws.before ?? ''; + first = false; + } else { + result += ','; + result += argument.raws.before ?? ' '; + } + result += argument.toString(); + result += argument.raws.after ?? ''; + } + if (this.raws.comma && this.nodes.length) result += ','; + return result + (this.raws.after ?? '') + ')'; + } + + /** + * Normalizes a single argument declaration or list of arguments. + */ + private _normalize(nodes: NewArguments): Argument[] { + const normalized = this._normalizeBeforeParent(nodes); + for (const node of normalized) { + node.parent = this; + } + return normalized; + } + + /** Like {@link _normalize}, but doesn't set the argument's parents. */ + private _normalizeBeforeParent(nodes: NewArguments): Argument[] { + if (nodes === undefined) return []; + if (Array.isArray(nodes)) { + if ( + nodes.length === 2 && + typeof nodes[0] === 'string' && + typeof nodes[1] === 'object' && + !('name' in nodes[1]) + ) { + return [new Argument(nodes)]; + } else { + return (nodes as ReadonlyArray).map(node => + typeof node === 'object' && + 'sassType' in node && + node.sassType === 'argument' + ? (node as Argument) + : new Argument(node), + ); + } + } else { + return [ + typeof nodes === 'object' && + 'sassType' in nodes && + nodes.sassType === 'argument' + ? (nodes as Argument) + : new Argument(nodes as ArgumentProps), + ]; + } + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList(nodes: ReadonlyArray): Argument[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/argument.test.ts b/pkg/sass-parser/lib/src/argument.test.ts new file mode 100644 index 000000000..b800f934a --- /dev/null +++ b/pkg/sass-parser/lib/src/argument.test.ts @@ -0,0 +1,514 @@ +// Copyright 2024 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 { + Argument, + ArgumentList, + IncludeRule, + StringExpression, + sass, + scss, +} from '..'; + +describe('a argument', () => { + let node: Argument; + beforeEach( + () => + void (node = new Argument({ + name: 'foo', + value: {text: 'bar', quotes: true}, + })), + ); + + describe('with no name', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('argument')); + + it('has no name', () => expect(node.name).toBeUndefined()); + + it('has a value', () => + expect(node).toHaveStringExpression('value', 'bar')); + + it('is not a rest parameter', () => expect(node.rest).toBe(false)); + }); + } + + describeNode('parsed as SCSS', () => { + const rule = scss.parse('@include a(bar)').nodes[0] as IncludeRule; + return rule.arguments.nodes[0]; + }); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a(bar)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describe('constructed manually', () => { + describeNode( + 'with an expression', + () => new Argument(new StringExpression({text: 'bar'})), + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => new Argument({value: new StringExpression({text: 'bar'})}), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument({value: {text: 'bar'}}), + ); + }); + + describeNode('with ExpressionProps', () => new Argument({text: 'bar'})); + }); + + describe('constructed from properties', () => { + describeNode( + 'with an expression', + () => new ArgumentList([new StringExpression({text: 'bar'})]).nodes[0], + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => + new ArgumentList([{value: new StringExpression({text: 'bar'})}]) + .nodes[0], + ); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList([{value: {text: 'bar'}}]).nodes[0], + ); + }); + + describeNode( + 'with ExpressionProps', + () => new ArgumentList({nodes: [{text: 'bar'}]}).nodes[0], + ); + }); + }); + + describe('with a name', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('argument')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has value', () => + expect(node).toHaveStringExpression('value', 'bar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include a($foo: "bar")').nodes[0] as IncludeRule) + .arguments.nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a($foo: "bar")').nodes[0] as IncludeRule) + .arguments.nodes[0], + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => new Argument(['foo', new StringExpression({text: 'bar'})]), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument(['foo', {text: 'bar'}]), + ); + + describe('with ArgumentProps', () => { + describeNode( + 'with an expression', + () => + new Argument([ + 'foo', + { + value: new StringExpression({text: 'bar'}), + }, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument(['foo', {value: {text: 'bar'}}]), + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Argument({ + name: 'foo', + value: new StringExpression({text: 'bar'}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => new Argument({name: 'foo', value: {text: 'bar'}}), + ); + }); + }); + + describe('constructed from properties', () => { + describe('an array', () => { + describeNode( + 'with ExpressionProps', + () => + new ArgumentList({ + nodes: [['foo', {text: 'bar'}]], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ArgumentList({ + nodes: [['foo', new StringExpression({text: 'bar'})]], + }).nodes[0], + ); + + describeNode( + 'with ArgumentObjectProps', + () => + new ArgumentList({ + nodes: [['foo', {value: {text: 'bar'}}]], + }).nodes[0], + ); + }); + + describe('an object', () => { + describeNode( + 'with ExpressionProps', + () => + new ArgumentList({ + nodes: [{name: 'foo', value: {text: 'bar'}}], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ArgumentList({ + nodes: [ + { + name: 'foo', + value: new StringExpression({text: 'bar'}), + }, + ], + }).nodes[0], + ); + }); + }); + }); + + describe('as a rest argument', () => { + function describeNode(description: string, create: () => Argument): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('argument')); + + it('has no name', () => expect(node.name).toBeUndefined()); + + it('has a value', () => + expect(node).toHaveStringExpression('value', 'bar')); + + it('is a rest argument', () => expect(node.rest).toBe(true)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@include a(bar...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@include a(bar...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ); + + describeNode( + 'constructed manually', + () => new Argument({value: {text: 'bar'}, rest: true}), + ); + + describeNode( + 'constructed from properties', + () => + new ArgumentList({nodes: [{value: {text: 'bar'}, rest: true}]}) + .nodes[0], + ); + }); + + describe('assigned a new name', () => { + it('updates the name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); + }); + + it('sets rest to false', () => { + node.rest = true; + node.name = 'baz'; + expect(node.rest).toBe(false); + }); + + it('leaves rest alone if name is undefined', () => { + node.rest = true; + node.name = undefined; + expect(node.rest).toBe(true); + }); + }); + + describe('assigned a new rest', () => { + it('updates the value of rest', () => { + node.rest = true; + expect(node.rest).toBe(true); + }); + + it('sets name to undefined', () => { + node.rest = true; + expect(node.name).toBe(undefined); + }); + + it('leaves defaultValue alone if rest is false', () => { + node.rest = false; + expect(node.name).toBe('foo'); + }); + }); + + it('assigned a new value', () => { + const old = node.value; + node.value = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('value', 'baz'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no name', () => + expect(new Argument({text: 'bar'}).toString()).toBe('bar')); + + it('with a name', () => + expect(new Argument(['foo', {text: 'bar'}]).toString()).toBe( + '$foo: bar', + )); + + it('with rest = true', () => + expect( + new Argument({value: {text: 'bar'}, rest: true}).toString(), + ).toBe('bar...')); + + it('with a non-identifier name', () => + expect(new Argument(['f o', {text: 'bar'}]).toString()).toBe( + '$f\\20o: bar', + )); + }); + + // raws.before is only used as part of a ArgumentList + it('ignores before', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {before: '/**/'}, + }).toString(), + ).toBe('bar')); + + it('with matching name', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, + }).toString(), + ).toBe('$f\\6fo: bar')); + + it('with non-matching name', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {name: {raw: 'f\\41o', value: 'fao'}}, + }).toString(), + ).toBe('$foo: bar')); + + it('with between', () => + expect( + new Argument({ + name: 'foo', + value: {text: 'bar'}, + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo : bar')); + + it('ignores between with no name', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {between: ' : '}, + }).toString(), + ).toBe('bar')); + + it('with beforeRest', () => + expect( + new Argument({ + value: {text: 'bar'}, + rest: true, + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('bar/**/...')); + + it('ignores beforeRest with rest = false', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('bar')); + + // raws.before is only used as part of a Configuration + describe('ignores after', () => { + it('with rest = false', () => + expect( + new Argument({ + value: {text: 'bar'}, + raws: {after: '/**/'}, + }).toString(), + ).toBe('bar')); + + it('with rest = true', () => + expect( + new Argument({ + value: {text: 'bar'}, + rest: true, + raws: {after: '/**/'}, + }).toString(), + ).toBe('bar...')); + }); + }); + }); + + describe('clone()', () => { + let original: Argument; + beforeEach(() => { + original = (scss.parse('@include x($foo: bar)').nodes[0] as IncludeRule) + .arguments.nodes[0]; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: Argument; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('name', () => expect(clone.name).toBe('foo')); + + it('value', () => expect(clone).toHaveStringExpression('value', 'bar')); + + it('rest', () => expect(clone.rest).toBe(false)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['value', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' : ', + })); + }); + + describe('name', () => { + it('defined', () => + expect(original.clone({name: 'baz'}).name).toBe('baz')); + + it('undefined', () => + expect(original.clone({name: undefined}).name).toBeUndefined()); + }); + + describe('rest', () => { + it('defined', () => + expect(original.clone({rest: true}).rest).toBe(true)); + + it('undefined', () => + expect(original.clone({rest: undefined}).rest).toBe(false)); + }); + + describe('value', () => { + it('defined', () => + expect(original.clone({value: {text: 'baz'}})).toHaveStringExpression( + 'value', + 'baz', + )); + + it('undefined', () => + expect(original.clone({value: undefined})).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + }); + + describe('toJSON', () => { + it('with a name', () => + expect( + (scss.parse('@include x($baz: qux)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + + it('with no name', () => + expect( + (scss.parse('@include x(qux)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + + it('with rest', () => + expect( + (scss.parse('@include x(qux...)').nodes[0] as IncludeRule).arguments + .nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/argument.ts b/pkg/sass-parser/lib/src/argument.ts new file mode 100644 index 000000000..353198ed9 --- /dev/null +++ b/pkg/sass-parser/lib/src/argument.ts @@ -0,0 +1,199 @@ +// Copyright 2024 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 * as postcss from 'postcss'; + +import {Expression, ExpressionProps} from './expression'; +import {fromProps} from './expression/from-props'; +import {Node} from './node'; +import {ArgumentList} from './argument-list'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link Argument}. + * + * @category Expression + */ +export interface ArgumentRaws { + /** + * The whitespace before the argument name (if it has one) or value (if it + * doesn't). + */ + before?: string; + + /** + * The argument's name, not including the `$`. + * + * This may be different than {@link Argument.name} if the name contains + * escape codes or underscores. It's ignored unless {@link Argument.name} is + * defined. + */ + name?: RawWithValue; + + /** + * The whitespace and colon between the argument name and its value. This is + * ignored unless the argument {@link Argument.name} is defined. + */ + between?: string; + + /** + * The whitespace between the argument and the `...`, if {@link + * Argument.rest} is true. + */ + beforeRest?: string; + + /** + * The space symbols between the end of the argument value and the comma + * afterwards. Always empty for a argument that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link Argument} passed as an + * options object. + * + * @category Expression + */ +export type ArgumentObjectProps = { + raws?: ArgumentRaws; + value: Expression | ExpressionProps; +} & ({name?: string; rest?: never} | {name?: never; rest?: boolean}); + +/** + * Properties used to initialize a {@link Argument} without an explicit name. + * This is used when the name is given elsewhere, either in the array form of + * {@link ArgumentProps} or the record form of [@link + * ArgumentDeclarationProps}. + */ +export type ArgumentExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link Argument}. + * + * @category Expression + */ +export type ArgumentProps = + | ArgumentObjectProps + | Expression + | ExpressionProps + | [string, ArgumentExpressionProps]; + +/** + * A single argument passed to an `@include` or `@content` rule or a function + * invocation. This is always included in a {@link ArgumentList}. + * + * @category Expression + */ +export class Argument extends Node { + readonly sassType = 'argument' as const; + declare raws: ArgumentRaws; + declare parent: ArgumentList | undefined; + + /** + * The argument name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + * + * Setting this to a value automatically sets {@link rest} to + * `undefined`. + */ + get name(): string | undefined { + return this._name; + } + set name(name: string | undefined) { + if (name) this._rest = undefined; + this._name = name; + } + private declare _name?: string; + + /** The argument's value. */ + get value(): Expression { + return this._value!; + } + set value(value: Expression | ExpressionProps) { + if (this._value) this._value.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._value = value; + } + private declare _value?: Expression; + + /** + * Whether this is a rest argument (indicated by `...` in Sass). + * + * Setting this to true automatically sets {@link name} to + * `undefined`. + */ + get rest(): boolean { + return this._rest ?? false; + } + set rest(value: boolean) { + if (value) this._name = undefined; + this._rest = value; + } + private declare _rest?: boolean; + + constructor(defaults: ArgumentProps) { + if (Array.isArray(defaults)) { + const [name, props] = defaults; + if ('sassType' in props || !('value' in props)) { + defaults = { + name, + value: props as Expression | ExpressionProps, + }; + } else { + defaults = {name, ...props} as ArgumentObjectProps; + } + } else if ('sassType' in defaults || !('value' in defaults)) { + defaults = { + value: defaults as Expression | ExpressionProps, + }; + } + super(defaults); + this.raws ??= {}; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'name', explicitUndefined: true}, + 'value', + 'rest', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'value', 'rest'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + (this.name === undefined + ? '' + : '$' + + (this.raws.name?.value === this.name + ? this.raws.name!.raw + : sassInternal.toCssIdentifier(this.name)) + + (this.raws.between ?? ': ')) + + this.value + + (this.rest ? (this.raws.beforeRest ?? '') + '...' : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.value]; + } +} diff --git a/pkg/sass-parser/lib/src/configuration.test.ts b/pkg/sass-parser/lib/src/configuration.test.ts index e0f88aa6d..5882d64ee 100644 --- a/pkg/sass-parser/lib/src/configuration.test.ts +++ b/pkg/sass-parser/lib/src/configuration.test.ts @@ -76,7 +76,7 @@ describe('a configuration map', () => { it('contains the variable', () => { expect(node.size).toBe(1); const variable = [...node.variables()][0]; - expect(variable.variableName).toEqual('bar'); + expect(variable.name).toEqual('bar'); expect(variable).toHaveStringExpression('expression', 'baz'); }); }); @@ -101,9 +101,7 @@ describe('a configuration map', () => { 'variables array', () => new Configuration({ - variables: [ - {variableName: 'bar', expression: {text: 'baz', quotes: true}}, - ], + variables: [{name: 'bar', expression: {text: 'baz', quotes: true}}], }), ); @@ -127,7 +125,7 @@ describe('a configuration map', () => { describe('add()', () => { test('with a ConfiguredVariable', () => { const variable = new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }); expect(node.add(variable)).toBe(node); @@ -137,20 +135,20 @@ describe('a configuration map', () => { }); test('with a ConfiguredVariableProps', () => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); expect(node.size).toBe(1); const variable = node.get('foo'); - expect(variable?.variableName).toBe('foo'); + expect(variable?.name).toBe('foo'); expect(variable).toHaveStringExpression('expression', 'bar'); expect(variable?.parent).toBe(node); }); test('overwrites on old variable', () => { - node.add({variableName: 'foo', expression: {text: 'old', quotes: true}}); + node.add({name: 'foo', expression: {text: 'old', quotes: true}}); const old = node.get('foo'); expect(old?.parent).toBe(node); - node.add({variableName: 'foo', expression: {text: 'new', quotes: true}}); + node.add({name: 'foo', expression: {text: 'new', quotes: true}}); expect(node.size).toBe(1); expect(old?.parent).toBeUndefined(); expect(node.get('foo')).toHaveStringExpression('expression', 'new'); @@ -158,8 +156,8 @@ describe('a configuration map', () => { }); test('clear() removes all variables', () => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); - node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'baz', expression: {text: 'bang', quotes: true}}); const foo = node.get('foo'); const bar = node.get('bar'); node.clear(); @@ -172,8 +170,8 @@ describe('a configuration map', () => { describe('delete()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); - node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'baz', expression: {text: 'bang', quotes: true}}); }); test('removes a matching variable', () => { @@ -192,12 +190,12 @@ describe('a configuration map', () => { describe('get()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); }); test('returns a variable in the configuration', () => { const variable = node.get('foo'); - expect(variable?.variableName).toBe('foo'); + expect(variable?.name).toBe('foo'); expect(variable).toHaveStringExpression('expression', 'bar'); }); @@ -208,7 +206,7 @@ describe('a configuration map', () => { describe('has()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); }); test('returns true for a variable in the configuration', () => @@ -220,7 +218,7 @@ describe('a configuration map', () => { describe('set()', () => { beforeEach(() => { - node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({name: 'foo', expression: {text: 'bar', quotes: true}}); }); describe('adds a new variable', () => { @@ -233,7 +231,7 @@ describe('a configuration map', () => { expect(node.size).toBe(2); const variable = node.get('baz'); expect(variable?.parent).toBe(node); - expect(variable?.variableName).toBe('baz'); + expect(variable?.name).toBe('baz'); expect(variable).toHaveStringExpression('expression', 'bang'); }); } @@ -285,7 +283,7 @@ describe('a configuration map', () => { }).toString(), ).toBe('($foo: "bar", $baz: "bang",)')); - it('with comma: true and afterValue', () => + it('with comma: true and after', () => expect( new Configuration({ raws: {comma: true}, @@ -293,7 +291,7 @@ describe('a configuration map', () => { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }, }, }).toString(), @@ -310,7 +308,7 @@ describe('a configuration map', () => { }).toString(), ).toBe('($foo: "bar", $baz: "bang"/**/)')); - it('with after and afterValue', () => + it('with after and after', () => expect( new Configuration({ raws: {after: '/**/'}, @@ -318,20 +316,20 @@ describe('a configuration map', () => { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: ' '}, + raws: {after: ' '}, }, }, }).toString(), ).toBe('($foo: "bar", $baz: "bang" /**/)')); - it('with afterValue and a guard', () => + it('with after and a guard', () => expect( new Configuration({ variables: { foo: {text: 'bar', quotes: true}, baz: { expression: {text: 'bang', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, guarded: true, }, }, @@ -359,10 +357,10 @@ describe('a configuration map', () => { it('variables', () => { expect(clone.size).toBe(2); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.name).toBe('foo'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'bar'); - expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.name).toBe('baz'); expect(variables[1]?.parent).toBe(clone); expect(variables[1]).toHaveStringExpression('expression', 'bang'); }); @@ -399,7 +397,7 @@ describe('a configuration map', () => { }); expect(clone.size).toBe(1); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('zip'); + expect(variables[0]?.name).toBe('zip'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); @@ -408,10 +406,10 @@ describe('a configuration map', () => { const clone = original.clone({variables: undefined}); expect(clone.size).toBe(2); const variables = [...clone.variables()]; - expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.name).toBe('foo'); expect(variables[0]?.parent).toBe(clone); expect(variables[0]).toHaveStringExpression('expression', 'bar'); - expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.name).toBe('baz'); expect(variables[1]?.parent).toBe(clone); expect(variables[1]).toHaveStringExpression('expression', 'bang'); }); diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts index e9c025563..82a76d2a3 100644 --- a/pkg/sass-parser/lib/src/configuration.ts +++ b/pkg/sass-parser/lib/src/configuration.ts @@ -44,6 +44,9 @@ export interface ConfigurationProps { | Array; } +// TODO: This should probably implement a similar interface to `ParameterList` +// as well as or instead of its current map-like interface. + /** * A configuration map for a `@use` or `@forward` rule. * @@ -101,9 +104,9 @@ export class Configuration extends Node { const realVariable = 'sassType' in variable ? variable : new ConfiguredVariable(variable); realVariable.parent = this; - const old = this._variables.get(realVariable.variableName); + const old = this._variables.get(realVariable.name); if (old) old.parent = undefined; - this._variables.set(realVariable.variableName, realVariable); + this._variables.set(realVariable.name, realVariable); return this; } @@ -189,7 +192,7 @@ export class Configuration extends Node { result += variable.raws.before ?? ' '; } result += variable.toString(); - result += variable.raws.afterValue ?? ''; + result += variable.raws.after ?? ''; } return result + `${this.raws.comma ? ',' : ''}${this.raws.after ?? ''})`; } diff --git a/pkg/sass-parser/lib/src/configured-variable.test.ts b/pkg/sass-parser/lib/src/configured-variable.test.ts index c673670a8..4675c3c22 100644 --- a/pkg/sass-parser/lib/src/configured-variable.test.ts +++ b/pkg/sass-parser/lib/src/configured-variable.test.ts @@ -2,14 +2,21 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import {ConfiguredVariable, StringExpression, UseRule, sass, scss} from '..'; +import { + ConfiguredVariable, + ForwardRule, + StringExpression, + UseRule, + sass, + scss, +} from '..'; describe('a configured variable', () => { let node: ConfiguredVariable; beforeEach( () => void (node = new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, })), ); @@ -25,7 +32,7 @@ describe('a configured variable', () => { it('has a sassType', () => expect(node.sassType.toString()).toBe('configured-variable')); - it('has a name', () => expect(node.variableName).toBe('foo')); + it('has a name', () => expect(node.name).toBe('foo')); it('has a value', () => expect(node).toHaveStringExpression('expression', 'bar')); @@ -92,7 +99,7 @@ describe('a configured variable', () => { 'with an expression', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: new StringExpression({text: 'bar', quotes: true}), }), ); @@ -101,7 +108,7 @@ describe('a configured variable', () => { 'with ExpressionProps', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }), ); @@ -120,7 +127,7 @@ describe('a configured variable', () => { it('has a sassType', () => expect(node.sassType.toString()).toBe('configured-variable')); - it('has a name', () => expect(node.variableName).toBe('foo')); + it('has a name', () => expect(node.name).toBe('foo')); it('has a value', () => expect(node).toHaveStringExpression('expression', 'bar')); @@ -129,24 +136,23 @@ describe('a configured variable', () => { }); } - // We can re-enable these once ForwardRule exists. - // describeNode( - // 'parsed as SCSS', - // () => - // ( - // scss.parse('@forward "baz" with ($foo: "bar" !default)') - // .nodes[0] as ForwardRule - // ).configuration.get('foo')! - // ); - // - // describeNode( - // 'parsed as Sass', - // () => - // ( - // sass.parse('@forward "baz" with ($foo: "bar" !default)') - // .nodes[0] as ForwardRule - // ).configuration.get('foo')! - // ); + describeNode( + 'parsed as SCSS', + () => + ( + scss.parse('@forward "baz" with ($foo: "bar" !default)') + .nodes[0] as ForwardRule + ).configuration.get('foo')!, + ); + + describeNode( + 'parsed as Sass', + () => + ( + sass.parse('@forward "baz" with ($foo: "bar" !default)') + .nodes[0] as ForwardRule + ).configuration.get('foo')!, + ); describe('constructed manually', () => { describe('with an array', () => { @@ -177,7 +183,7 @@ describe('a configured variable', () => { 'with an expression', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: new StringExpression({text: 'bar', quotes: true}), guarded: true, }), @@ -187,7 +193,7 @@ describe('a configured variable', () => { 'with ExpressionProps', () => new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, }), @@ -196,9 +202,9 @@ describe('a configured variable', () => { }); }); - it('assigned a new variableName', () => { - node.variableName = 'baz'; - expect(node.variableName).toBe('baz'); + it('assigned a new name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); }); it('assigned a new expression', () => { @@ -219,7 +225,7 @@ describe('a configured variable', () => { it('unguarded', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, }).toString(), ).toBe('$foo: "bar"')); @@ -227,7 +233,7 @@ describe('a configured variable', () => { it('guarded', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, }).toString(), @@ -236,7 +242,7 @@ describe('a configured variable', () => { it('with a non-identifier name', () => expect( new ConfiguredVariable({ - variableName: 'f o', + name: 'f o', expression: {text: 'bar', quotes: true}, }).toString(), ).toBe('$f\\20o: "bar"')); @@ -246,7 +252,7 @@ describe('a configured variable', () => { it('ignores before', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, raws: {before: '/**/'}, }).toString(), @@ -255,25 +261,25 @@ describe('a configured variable', () => { it('with matching name', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {variableName: {raw: 'f\\6fo', value: 'foo'}}, + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, }).toString(), ).toBe('$f\\6fo: "bar"')); it('with non-matching name', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {variableName: {raw: 'f\\41o', value: 'fao'}}, + raws: {name: {raw: 'f\\41o', value: 'fao'}}, }).toString(), ).toBe('$foo: "bar"')); it('with between', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, raws: {between: ' : '}, }).toString(), @@ -282,7 +288,7 @@ describe('a configured variable', () => { it('with beforeGuard and a guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, raws: {beforeGuard: '/**/'}, @@ -292,30 +298,30 @@ describe('a configured variable', () => { it('with beforeGuard and no guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, raws: {beforeGuard: '/**/'}, }).toString(), ).toBe('$foo: "bar"')); - // raws.before is only used as part of a Configuration - describe('ignores afterValue', () => { + // raws.after is only used as part of a Configuration + describe('ignores after', () => { it('with no guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }).toString(), ).toBe('$foo: "bar"')); it('with a guard', () => expect( new ConfiguredVariable({ - variableName: 'foo', + name: 'foo', expression: {text: 'bar', quotes: true}, guarded: true, - raws: {afterValue: '/**/'}, + raws: {after: '/**/'}, }).toString(), ).toBe('$foo: "bar" !default')); }); @@ -336,7 +342,7 @@ describe('a configured variable', () => { beforeEach(() => void (clone = original.clone())); describe('has the same properties:', () => { - it('variableName', () => expect(clone.variableName).toBe('foo')); + it('name', () => expect(clone.name).toBe('foo')); it('expression', () => expect(clone).toHaveStringExpression('expression', 'bar')); @@ -366,16 +372,12 @@ describe('a configured variable', () => { })); }); - describe('variableName', () => { + describe('name', () => { it('defined', () => - expect(original.clone({variableName: 'baz'}).variableName).toBe( - 'baz', - )); + expect(original.clone({name: 'baz'}).name).toBe('baz')); it('undefined', () => - expect(original.clone({variableName: undefined}).variableName).toBe( - 'foo', - )); + expect(original.clone({name: undefined}).name).toBe('foo')); }); describe('expression', () => { diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts index 17dcea6f7..c8b81b97c 100644 --- a/pkg/sass-parser/lib/src/configured-variable.ts +++ b/pkg/sass-parser/lib/src/configured-variable.ts @@ -29,7 +29,7 @@ export interface ConfiguredVariableRaws { * This may be different than {@link ConfiguredVariable.variable} if the name * contains escape codes or underscores. */ - variableName?: RawWithValue; + name?: RawWithValue; /** The whitespace and colon between the variable name and value. */ between?: string; @@ -44,7 +44,7 @@ export interface ConfiguredVariableRaws { * The space symbols between the end of the variable declaration and the comma * afterwards. Always empty for a variable that doesn't have a trailing comma. */ - afterValue?: string; + after?: string; } /** @@ -55,7 +55,7 @@ export interface ConfiguredVariableRaws { */ export interface ConfiguredVariableObjectProps { raws?: ConfiguredVariableRaws; - variableName: string; + name: string; expression: Expression | ExpressionProps; guarded?: boolean; } @@ -72,7 +72,7 @@ export interface ConfiguredVariableObjectProps { export type ConfiguredVariableExpressionProps = | Expression | ExpressionProps - | Omit; + | Omit; /** * The initializer properties for {@link ConfiguredVariable}. @@ -100,7 +100,7 @@ export class ConfiguredVariable extends Node { * This is the parsed and normalized value, with underscores converted to * hyphens and escapes resolved to the characters they represent. */ - variableName!: string; + declare name: string; /** The expresison whose value the variable is assigned. */ get expression(): Expression { @@ -112,10 +112,10 @@ export class ConfiguredVariable extends Node { if (value) value.parent = this; this._expression = value; } - private _expression!: Expression; + private declare _expression: Expression; /** Whether this has a `!default` guard. */ - guarded!: boolean; + declare guarded: boolean; constructor(defaults: ConfiguredVariableProps); /** @hidden */ @@ -125,14 +125,14 @@ export class ConfiguredVariable extends Node { inner?: sassInternal.ConfiguredVariable, ) { if (Array.isArray(defaults!)) { - const [variableName, rest] = defaults; + const [name, rest] = defaults; if ('sassType' in rest || !('expression' in rest)) { defaults = { - variableName, + name, expression: rest as Expression | ExpressionProps, }; } else { - defaults = {variableName, ...rest}; + defaults = {name, ...rest}; } } super(defaults); @@ -140,7 +140,7 @@ export class ConfiguredVariable extends Node { if (inner) { this.source = new LazySource(inner); - this.variableName = inner.name; + this.name = inner.name; this.expression = convertExpression(inner.expression); this.guarded = inner.isGuarded; } else { @@ -151,7 +151,7 @@ export class ConfiguredVariable extends Node { clone(overrides?: Partial): this { return utils.cloneNode(this, overrides, [ 'raws', - 'variableName', + 'name', 'expression', 'guarded', ]); @@ -161,20 +161,16 @@ export class ConfiguredVariable extends Node { /** @hidden */ toJSON(_: string, inputs: Map): object; toJSON(_?: string, inputs?: Map): object { - return utils.toJSON( - this, - ['variableName', 'expression', 'guarded'], - inputs, - ); + return utils.toJSON(this, ['name', 'expression', 'guarded'], inputs); } /** @hidden */ toString(): string { return ( '$' + - (this.raws.variableName?.value === this.variableName - ? this.raws.variableName.raw - : sassInternal.toCssIdentifier(this.variableName)) + + (this.raws.name?.value === this.name + ? this.raws.name.raw + : sassInternal.toCssIdentifier(this.name)) + (this.raws.between ?? ': ') + this.expression + (this.guarded ? `${this.raws.beforeGuard ?? ' '}!default` : '') diff --git a/pkg/sass-parser/lib/src/container.ts b/pkg/sass-parser/lib/src/container.ts new file mode 100644 index 000000000..035aeaeec --- /dev/null +++ b/pkg/sass-parser/lib/src/container.ts @@ -0,0 +1,129 @@ +// Copyright 2024 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. + +// Used in TypeDoc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type * as postcss from 'postcss'; + +// Used in TypeDoc +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type {Interpolation} from './interpolation'; + +/** + * A Sass AST container. While this tries to maintain the general shape of the + * {@link postcss.Container} interface, it's more broadly used to contain + * other node types (and even strings in the case of {@link Interpolation}. + * + * @typeParam Child - The type of child nodes that this container can contain. + * @typeParam NewChild - The type of values that can be passed in to create one + * or more new child nodes for this container. + */ +export interface Container { + /** + * The nodes in this container. + * + * This shouldn't be modified directly; instead, the various methods defined + * in {@link Container} should be used to modify it. + */ + get nodes(): ReadonlyArray; + + /** Inserts new nodes at the end of this interpolation. */ + append(...nodes: NewChild[]): this; + + /** + * Iterates through {@link nodes}, calling `callback` for each child. + * + * Returning `false` in the callback will break iteration. + * + * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while + * modifying the interpolation's children. + * + * @param callback The iterator callback, which is passed each child + * @return Returns `false` if any call to `callback` returned false + */ + each( + callback: (node: Child, index: number) => false | void, + ): false | undefined; + + /** + * Returns `true` if {@link condition} returns `true` for all of the + * container’s children. + */ + every( + condition: ( + node: Child, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean; + + /** + * Returns the first index of {@link child} in {@link nodes}. + * + * If {@link child} is a number, returns it as-is. + */ + index(child: Child | number): number; + + /** + * Inserts {@link newNode} immediately after the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} immediately after + * that index instead. + */ + insertAfter(oldNode: Child | number, newNode: NewChild): this; + + /** + * Inserts {@link newNode} immediately before the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} at that index + * instead. + */ + insertBefore(oldNode: Child | number, newNode: NewChild): this; + + /** Inserts {@link nodes} at the beginning of the container. */ + prepend(...nodes: NewChild[]): this; + + /** Adds {@link child} to the end of this interpolation. */ + push(child: Child): this; + + /** + * Removes all {@link nodes} from this container and cleans their {@link + * Node.parent} properties. + */ + removeAll(): this; + + /** + * Removes the first occurance of {@link child} from the container and cleans + * the parent properties from the node and its children. + * + * If {@link child} is a number, removes the child at that index. + */ + removeChild(child: Child | number): this; + + /** + * Returns `true` if {@link condition} returns `true` for (at least) one of + * the container’s children. + */ + some( + condition: ( + node: Child, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean; + + /** The first node in {@link nodes}. */ + get first(): Child | undefined; + + /** + * The container’s last child. + * + * ```js + * rule.last === rule.nodes[rule.nodes.length - 1] + * ``` + */ + get last(): Child | undefined; +} diff --git a/pkg/sass-parser/lib/src/expression/binary-operation.ts b/pkg/sass-parser/lib/src/expression/binary-operation.ts index e6f180521..adaf7ac47 100644 --- a/pkg/sass-parser/lib/src/expression/binary-operation.ts +++ b/pkg/sass-parser/lib/src/expression/binary-operation.ts @@ -76,7 +76,7 @@ export class BinaryOperationExpression extends Expression { // TODO - postcss/postcss#1957: Mark this as dirty this._operator = operator; } - private _operator!: BinaryOperator; + private declare _operator: BinaryOperator; /** The expression on the left-hand side of this operation. */ get left(): Expression { @@ -89,7 +89,7 @@ export class BinaryOperationExpression extends Expression { left.parent = this; this._left = left; } - private _left!: Expression; + private declare _left: Expression; /** The expression on the right-hand side of this operation. */ get right(): Expression { @@ -102,7 +102,7 @@ export class BinaryOperationExpression extends Expression { right.parent = this; this._right = right; } - private _right!: Expression; + private declare _right: Expression; constructor(defaults: BinaryOperationExpressionProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/expression/boolean.ts b/pkg/sass-parser/lib/src/expression/boolean.ts index a75c567ed..9713c279e 100644 --- a/pkg/sass-parser/lib/src/expression/boolean.ts +++ b/pkg/sass-parser/lib/src/expression/boolean.ts @@ -44,7 +44,7 @@ export class BooleanExpression extends Expression { // TODO - postcss/postcss#1957: Mark this as dirty this._value = value; } - private _value!: boolean; + private declare _value: boolean; constructor(defaults: BooleanExpressionProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/expression/number.ts b/pkg/sass-parser/lib/src/expression/number.ts index 0a5efbebb..8f05bb607 100644 --- a/pkg/sass-parser/lib/src/expression/number.ts +++ b/pkg/sass-parser/lib/src/expression/number.ts @@ -54,7 +54,7 @@ export class NumberExpression extends Expression { // TODO - postcss/postcss#1957: Mark this as dirty this._value = value; } - private _value!: number; + private declare _value: number; /** The denominator units of this number. */ get unit(): string | null { @@ -64,7 +64,7 @@ export class NumberExpression extends Expression { // TODO - postcss/postcss#1957: Mark this as dirty this._unit = unit; } - private _unit!: string | null; + private declare _unit: string | null; /** Whether the number is unitless. */ isUnitless(): boolean { diff --git a/pkg/sass-parser/lib/src/expression/string.test.ts b/pkg/sass-parser/lib/src/expression/string.test.ts index 39dae45d8..6ac1dd5ba 100644 --- a/pkg/sass-parser/lib/src/expression/string.test.ts +++ b/pkg/sass-parser/lib/src/expression/string.test.ts @@ -25,41 +25,14 @@ describe('a string expression', () => { describeNode('parsed', () => utils.parseExpression('"foo"')); - describe('constructed manually', () => { - describeNode( - 'with explicit text', - () => - new StringExpression({ - quotes: true, - text: new Interpolation({nodes: ['foo']}), - }), - ); - - describeNode( - 'with string text', - () => - new StringExpression({ - quotes: true, - text: 'foo', - }), - ); - }); - - describe('constructed from ExpressionProps', () => { - describeNode('with explicit text', () => - utils.fromExpressionProps({ - quotes: true, - text: new Interpolation({nodes: ['foo']}), - }), - ); - - describeNode('with string text', () => - utils.fromExpressionProps({ - quotes: true, - text: 'foo', - }), - ); - }); + describeNode( + 'constructed manually', + () => new StringExpression({quotes: true, text: 'foo'}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({quotes: true, text: 'foo'}), + ); }); describe('unquoted', () => { @@ -81,29 +54,14 @@ describe('a string expression', () => { describeNode('parsed', () => utils.parseExpression('foo')); describe('constructed manually', () => { - describeNode( - 'with explicit text', - () => - new StringExpression({ - text: new Interpolation({nodes: ['foo']}), - }), - ); - describeNode( 'with explicit quotes', - () => - new StringExpression({ - quotes: false, - text: 'foo', - }), + () => new StringExpression({quotes: false, text: 'foo'}), ); describeNode( - 'with string text', - () => - new StringExpression({ - text: 'foo', - }), + 'with default quotes', + () => new StringExpression({text: 'foo'}), ); }); @@ -122,9 +80,7 @@ describe('a string expression', () => { ); describeNode('with string text', () => - utils.fromExpressionProps({ - text: 'foo', - }), + utils.fromExpressionProps({text: 'foo'}), ); }); }); @@ -145,7 +101,7 @@ describe('a string expression', () => { }); it('assigns text explicitly', () => { - const text = new Interpolation({nodes: ['zip']}); + const text = new Interpolation('zip'); node.text = text; expect(node.text).toBe(text); expect(node).toHaveInterpolation('text', 'zip'); diff --git a/pkg/sass-parser/lib/src/expression/string.ts b/pkg/sass-parser/lib/src/expression/string.ts index e1638da62..6adb0c4d0 100644 --- a/pkg/sass-parser/lib/src/expression/string.ts +++ b/pkg/sass-parser/lib/src/expression/string.ts @@ -4,7 +4,7 @@ import * as postcss from 'postcss'; -import {Interpolation} from '../interpolation'; +import {Interpolation, InterpolationProps} from '../interpolation'; import {LazySource} from '../lazy-source'; import type * as sassInternal from '../sass-internal'; import * as utils from '../utils'; @@ -16,7 +16,7 @@ import {Expression} from '.'; * @category Expression */ export interface StringExpressionProps { - text: Interpolation | string; + text: Interpolation | InterpolationProps; quotes?: boolean; raws?: StringExpressionRaws; } @@ -48,14 +48,15 @@ export class StringExpression extends Expression { get text(): Interpolation { return this._text; } - set text(text: Interpolation | string) { + set text(value: Interpolation | InterpolationProps) { // TODO - postcss/postcss#1957: Mark this as dirty if (this._text) this._text.parent = undefined; - if (typeof text === 'string') text = new Interpolation({nodes: [text]}); + const text = + value instanceof Interpolation ? value : new Interpolation(value); text.parent = this; this._text = text; } - private _text!: Interpolation; + private declare _text: Interpolation; // TODO: provide a utility asPlainIdentifier method that returns the value of // an identifier with any escapes resolved, if this is indeed a valid unquoted @@ -75,7 +76,7 @@ export class StringExpression extends Expression { // TODO - postcss/postcss#1957: Mark this as dirty this._quotes = quotes; } - private _quotes!: boolean; + private declare _quotes: boolean; constructor(defaults: StringExpressionProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/interpolation.test.ts b/pkg/sass-parser/lib/src/interpolation.test.ts index fd0b81e29..a224cc194 100644 --- a/pkg/sass-parser/lib/src/interpolation.test.ts +++ b/pkg/sass-parser/lib/src/interpolation.test.ts @@ -71,10 +71,32 @@ describe('an interpolation', () => { () => (css.parse('@foo').nodes[0] as GenericAtRule).nameInterpolation, ); - describeNode( - 'constructed manually', - () => new Interpolation({nodes: ['foo']}), - ); + describe('constructed manually', () => { + describeNode('with an object', () => new Interpolation({nodes: ['foo']})); + + describeNode('with an array', () => new Interpolation(['foo'])); + + describeNode('with a string', () => new Interpolation('foo')); + }); + + describe('constructed from properties', () => { + describeNode( + 'with an object', + () => + new GenericAtRule({nameInterpolation: {nodes: ['foo']}}) + .nameInterpolation, + ); + + describeNode( + 'with an array', + () => new GenericAtRule({nameInterpolation: ['foo']}).nameInterpolation, + ); + + describeNode( + 'with a string', + () => new GenericAtRule({nameInterpolation: 'foo'}).nameInterpolation, + ); + }); }); describe('with only an expression', () => { @@ -102,10 +124,30 @@ describe('an interpolation', () => { () => (scss.parse('@#{foo}').nodes[0] as GenericAtRule).nameInterpolation, ); - describeNode( - 'constructed manually', - () => new Interpolation({nodes: [{text: 'foo'}]}), - ); + describe('constructed manually', () => { + describeNode( + 'with an object', + () => new Interpolation({nodes: [{text: 'foo'}]}), + ); + + describeNode('with an array', () => new Interpolation([{text: 'foo'}])); + }); + + describe('constructed from properties', () => { + describeNode( + 'with an object', + () => + new GenericAtRule({nameInterpolation: {nodes: [{text: 'foo'}]}}) + .nameInterpolation, + ); + + describeNode( + 'with an array', + () => + new GenericAtRule({nameInterpolation: [{text: 'foo'}]}) + .nameInterpolation, + ); + }); }); describe('with mixed text and expressions', () => { @@ -139,10 +181,34 @@ describe('an interpolation', () => { .nameInterpolation, ); - describeNode( - 'constructed manually', - () => new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}), - ); + describe('constructed manually', () => { + describeNode( + 'with an object', + () => new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}), + ); + + describeNode( + 'with an array', + () => new Interpolation(['foo', {text: 'bar'}, 'baz']), + ); + }); + + describe('constructed from properties', () => { + describeNode( + 'with an object', + () => + new GenericAtRule({ + nameInterpolation: {nodes: ['foo', {text: 'bar'}, 'baz']}, + }).nameInterpolation, + ); + + describeNode( + 'with an array', + () => + new GenericAtRule({nameInterpolation: ['foo', {text: 'bar'}, 'baz']}) + .nameInterpolation, + ); + }); }); describe('can add', () => { @@ -430,7 +496,7 @@ describe('an interpolation', () => { it("removes a node's parents", () => { const string = node.nodes[1]; - node.removeAll(); + node.removeChild(1); expect(string).toHaveProperty('parent', undefined); }); diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts index c051decc8..698a2b8de 100644 --- a/pkg/sass-parser/lib/src/interpolation.ts +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -4,6 +4,7 @@ import * as postcss from 'postcss'; +import {Container} from './container'; import {convertExpression} from './expression/convert'; import {fromProps} from './expression/from-props'; import {Expression, ExpressionProps} from './expression'; @@ -16,6 +17,9 @@ import * as utils from './utils'; /** * The type of new nodes that can be passed into an interpolation. * + * Note that unlike in PostCSS, a `string` here is treated as a raw string for + * interpolation rather than parsed as an expression. + * * @category Expression */ export type NewNodeForInterpolation = @@ -30,15 +34,28 @@ export type NewNodeForInterpolation = | undefined; /** - * The initializer properties for {@link Interpolation} + * The initializer properties for {@link Interpolation} passed as an options + * object. * * @category Expression */ -export interface InterpolationProps { +export interface InterpolationObjectProps { nodes: ReadonlyArray; raws?: InterpolationRaws; } +/** + * The initializer properties for {@link Interpolation} passed. + * + * A plain string is interpreted as a plain-text interpolation. + * + * @category Expression + */ +export type InterpolationProps = + | InterpolationObjectProps + | ReadonlyArray + | string; + /** * Raws indicating how to precisely serialize an {@link Interpolation} node. * @@ -76,7 +93,10 @@ export interface InterpolationRaws { * * @category Expression */ -export class Interpolation extends Node { +export class Interpolation + extends Node + implements Container +{ readonly sassType = 'interpolation' as const; declare raws: InterpolationRaws; @@ -97,7 +117,7 @@ export class Interpolation extends Node { // This *should* only ever be called by the superclass constructor. this._nodes = nodes; } - private _nodes?: Array; + private declare _nodes?: Array; /** Returns whether this contains no interpolated expressions. */ get isPlain(): boolean { @@ -124,8 +144,14 @@ export class Interpolation extends Node { constructor(defaults?: InterpolationProps); /** @hidden */ constructor(_: undefined, inner: sassInternal.Interpolation); - constructor(defaults?: object, inner?: sassInternal.Interpolation) { - super(defaults); + constructor(defaults?: object | string, inner?: sassInternal.Interpolation) { + super( + typeof defaults === 'string' + ? {nodes: [defaults]} + : Array.isArray(defaults) + ? {nodes: defaults} + : defaults, + ); if (inner) { this.source = new LazySource(inner); // TODO: set lazy raws here to use when stringifying @@ -139,7 +165,7 @@ export class Interpolation extends Node { if (this._nodes === undefined) this._nodes = []; } - clone(overrides?: Partial): this { + clone(overrides?: Partial): this { return utils.cloneNode(this, overrides, ['nodes', 'raws']); } @@ -150,31 +176,12 @@ export class Interpolation extends Node { return utils.toJSON(this, ['nodes'], inputs); } - /** - * Inserts new nodes at the end of this interpolation. - * - * Note: unlike PostCSS's [`Container.append()`], this treats strings as raw - * text rather than parsing them into new nodes. - * - * [`Container.append()`]: https://postcss.org/api/#container-append - */ append(...nodes: NewNodeForInterpolation[]): this { // TODO - postcss/postcss#1957: Mark this as dirty this._nodes!.push(...this._normalizeList(nodes)); return this; } - /** - * Iterates through {@link nodes}, calling `callback` for each child. - * - * Returning `false` in the callback will break iteration. - * - * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while - * modifying the interpolation's children. - * - * @param callback The iterator callback, which is passed each child - * @return Returns `false` if any call to `callback` returned false - */ each( callback: (node: string | Expression, index: number) => false | void, ): false | undefined { @@ -193,10 +200,6 @@ export class Interpolation extends Node { } } - /** - * Returns `true` if {@link condition} returns `true` for all of the - * container’s children. - */ every( condition: ( node: string | Expression, @@ -207,22 +210,10 @@ export class Interpolation extends Node { return this.nodes.every(condition); } - /** - * Returns the first index of {@link child} in {@link nodes}. - * - * If {@link child} is a number, returns it as-is. - */ index(child: string | Expression | number): number { return typeof child === 'number' ? child : this.nodes.indexOf(child); } - /** - * Inserts {@link newNode} immediately after the first occurance of - * {@link oldNode} in {@link nodes}. - * - * If {@link oldNode} is a number, inserts {@link newNode} immediately after - * that index instead. - */ insertAfter( oldNode: string | Expression | number, newNode: NewNodeForInterpolation, @@ -239,13 +230,6 @@ export class Interpolation extends Node { return this; } - /** - * Inserts {@link newNode} immediately before the first occurance of - * {@link oldNode} in {@link nodes}. - * - * If {@link oldNode} is a number, inserts {@link newNode} at that index - * instead. - */ insertBefore( oldNode: string | Expression | number, newNode: NewNodeForInterpolation, @@ -262,7 +246,6 @@ export class Interpolation extends Node { return this; } - /** Inserts {@link nodes} at the beginning of the interpolation. */ prepend(...nodes: NewNodeForInterpolation[]): this { // TODO - postcss/postcss#1957: Mark this as dirty const normalized = this._normalizeList(nodes); @@ -275,15 +258,10 @@ export class Interpolation extends Node { return this; } - /** Adds {@link child} to the end of this interpolation. */ push(child: string | Expression): this { return this.append(child); } - /** - * Removes all {@link nodes} from this interpolation and cleans their {@link - * Node.parent} properties. - */ removeAll(): this { // TODO - postcss/postcss#1957: Mark this as dirty for (const node of this.nodes) { @@ -293,15 +271,10 @@ export class Interpolation extends Node { return this; } - /** - * Removes the first occurance of {@link child} from the container and cleans - * the parent properties from the node and its children. - * - * If {@link child} is a number, removes the child at that index. - */ removeChild(child: string | Expression | number): this { // TODO - postcss/postcss#1957: Mark this as dirty const index = this.index(child); + child = this._nodes![index]; if (typeof child === 'object') child.parent = undefined; this._nodes!.splice(index, 1); @@ -312,10 +285,6 @@ export class Interpolation extends Node { return this; } - /** - * Returns `true` if {@link condition} returns `true` for (at least) one of - * the container’s children. - */ some( condition: ( node: string | Expression, @@ -326,18 +295,10 @@ export class Interpolation extends Node { return this.nodes.some(condition); } - /** The first node in {@link nodes}. */ get first(): string | Expression | undefined { return this.nodes[0]; } - /** - * The container’s last child. - * - * ```js - * rule.last === rule.nodes[rule.nodes.length - 1] - * ``` - */ get last(): string | Expression | undefined { return this.nodes[this.nodes.length - 1]; } diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts index 11f8d9ae2..2c28227c2 100644 --- a/pkg/sass-parser/lib/src/node.d.ts +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -21,9 +21,13 @@ export type AnyNode = AnyStatement | AnyExpression | Interpolation; export type NodeType = | StatementType | ExpressionType - | 'interpolation' + | 'argument' + | 'argument-list' | 'configuration' - | 'configured-variable'; + | 'configured-variable' + | 'interpolation' + | 'parameter' + | 'parameter-list'; /** The constructor properties shared by all Sass AST nodes. */ export type NodeProps = postcss.NodeProps; diff --git a/pkg/sass-parser/lib/src/parameter-list.test.ts b/pkg/sass-parser/lib/src/parameter-list.test.ts new file mode 100644 index 000000000..6cbf1617e --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter-list.test.ts @@ -0,0 +1,814 @@ +// Copyright 2024 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 {FunctionRule, Parameter, ParameterList, sass, scss} from '..'; + +type EachFn = Parameters[0]; + +let node: ParameterList; +describe('a parameter list', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x() {}').nodes[0] as FunctionRule).parameters, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@function x()').nodes[0] as FunctionRule).parameters, + ); + + describe('constructed manually', () => { + describeNode('with no arguments', () => new ParameterList()); + + describeNode('with an array', () => new ParameterList([])); + + describeNode('with an object', () => new ParameterList({})); + + describeNode( + 'with an object with an array', + () => new ParameterList({nodes: []}), + ); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => new FunctionRule({functionName: 'x', parameters: {}}).parameters, + ); + + describeNode( + 'an array', + () => new FunctionRule({functionName: 'x', parameters: []}).parameters, + ); + }); + }); + + describe('with an argument with no default', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0].parent).toBe(node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x($foo) {}').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x($foo)').nodes[0] as FunctionRule).parameters, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode('with a string', () => new ParameterList(['foo'])); + + describeNode( + 'with an object', + () => new ParameterList([{name: 'foo'}]), + ); + + describeNode( + 'with a Parameter', + () => new ParameterList([new Parameter('foo')]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a string', + () => new ParameterList({nodes: ['foo']}), + ); + + describeNode( + 'with an object', + () => new ParameterList({nodes: [{name: 'foo'}]}), + ); + + describeNode( + 'with a Parameter', + () => new ParameterList({nodes: [new Parameter('foo')]}), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new FunctionRule({functionName: 'x', parameters: {nodes: ['foo']}}) + .parameters, + ); + + describeNode( + 'an array', + () => + new FunctionRule({functionName: 'x', parameters: ['foo']}).parameters, + ); + }); + }); + + describe('with an argument with a default', () => { + function describeNode( + description: string, + create: () => ParameterList, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => + expect(node.sassType).toBe('parameter-list')); + + it('has a node', () => { + expect(node.nodes.length).toBe(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x($foo: "bar") {}').nodes[0] as FunctionRule) + .parameters, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x($foo: "bar")').nodes[0] as FunctionRule) + .parameters, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with a sub-array', + () => new ParameterList([['foo', {text: 'bar'}]]), + ); + + describeNode( + 'with an object', + () => new ParameterList([{name: 'foo', defaultValue: {text: 'bar'}}]), + ); + + describeNode( + 'with a Parameter', + () => + new ParameterList([ + new Parameter({name: 'foo', defaultValue: {text: 'bar'}}), + ]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with a sub-array', + () => new ParameterList({nodes: [['foo', {text: 'bar'}]]}), + ); + + describeNode( + 'with an object', + () => + new ParameterList({ + nodes: [{name: 'foo', defaultValue: {text: 'bar'}}], + }), + ); + + describeNode( + 'with a Parameter', + () => + new ParameterList({ + nodes: [ + new Parameter({name: 'foo', defaultValue: {text: 'bar'}}), + ], + }), + ); + }); + }); + + describe('constructed from properties', () => { + describeNode( + 'an object', + () => + new FunctionRule({ + functionName: 'x', + parameters: {nodes: [['foo', {text: 'bar'}]]}, + }).parameters, + ); + + describeNode( + 'an array', + () => + new FunctionRule({ + functionName: 'x', + parameters: [['foo', {text: 'bar'}]], + }).parameters, + ); + }); + }); + + describe('can add', () => { + beforeEach(() => void (node = new ParameterList())); + + it('a single parameter', () => { + const parameter = new Parameter('foo'); + node.append(parameter); + expect(node.nodes).toEqual([parameter]); + expect(parameter).toHaveProperty('parent', node); + }); + + it('a list of parameters', () => { + const foo = new Parameter('foo'); + const bar = new Parameter('bar'); + node.append([foo, bar]); + expect(node.nodes).toEqual([foo, bar]); + }); + + it('a single string', () => { + node.append('foo'); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a string array', () => { + node.append(['foo']); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a single pair', () => { + node.append(['foo', {text: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it('a list of pairs', () => { + node.append([ + ['foo', {text: 'bar'}], + ['baz', {text: 'qux'}], + ]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0]).toHaveStringExpression('defaultValue', 'bar'); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Parameter); + expect(node.nodes[1].name).toBe('baz'); + expect(node.nodes[1]).toHaveStringExpression('defaultValue', 'qux'); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it("a single parameter's properties", () => { + node.append({name: 'foo'}); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + }); + + it("multiple parameters' properties", () => { + node.append([{name: 'foo'}, {name: 'bar'}]); + expect(node.nodes[0]).toBeInstanceOf(Parameter); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[0].defaultValue).toBeUndefined(); + expect(node.nodes[0]).toHaveProperty('parent', node); + expect(node.nodes[1]).toBeInstanceOf(Parameter); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[1].defaultValue).toBeUndefined(); + expect(node.nodes[1]).toHaveProperty('parent', node); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('adds multiple children to the end', () => { + node.append('baz', 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => node.append('baz'))); + + it('returns itself', () => expect(node.append()).toBe(node)); + }); + + describe('each', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('calls the callback for each node', () => { + const fn: EachFn = jest.fn(); + node.each(fn); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({name: 'foo'}), + 0, + ); + expect(fn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({name: 'bar'}), + 1, + ); + }); + + it('returns undefined if the callback is void', () => + expect(node.each(() => {})).toBeUndefined()); + + it('returns false and stops iterating if the callback returns false', () => { + const fn: EachFn = jest.fn(() => false); + expect(node.each(fn)).toBe(false); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('every', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns true if the callback returns true for all elements', () => + expect(node.every(() => true)).toBe(true)); + + it('returns false if the callback returns false for any element', () => + expect(node.every(element => element.name !== 'bar')).toBe(false)); + }); + + describe('index', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns the first index of a given parameter', () => + expect(node.index(node.nodes[2])).toBe(2)); + + it('returns a number as-is', () => expect(node.index(3)).toBe(3)); + }); + + describe('insertAfter', () => { + beforeEach( + () => void (node = new ParameterList({nodes: ['foo', 'bar', 'baz']})), + ); + + it('inserts a node after the given element', () => { + node.insertAfter(node.nodes[1], 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('qux'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, 'qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, ['qux', 'qax', 'qix']); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('qux'); + expect(node.nodes[3].name).toBe('qax'); + expect(node.nodes[4].name).toBe('qix'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertAfter(0, ['qux', 'qax', 'qix']), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertAfter(1, ['qux', 'qax', 'qix']), + )); + + it('returns itself', () => expect(node.insertAfter(0, 'qux')).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('inserts a node before the given element', () => { + node.insertBefore(node.nodes[1], 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('qux'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, 'qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, 'qux'); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + expect(node.nodes[3].name).toBe('qux'); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, ['qux', 'qax', 'qix']); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('qux'); + expect(node.nodes[2].name).toBe('qax'); + expect(node.nodes[3].name).toBe('qix'); + expect(node.nodes[4].name).toBe('bar'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.insertBefore(1, ['qux', 'qax', 'qix']), + )); + + it('inserts after an iterator', () => + testEachMutation(['foo', 'bar', 'qux', 'qax', 'qix', 'baz'], 1, () => + node.insertBefore(2, ['qux', 'qax', 'qix']), + )); + + it('returns itself', () => expect(node.insertBefore(0, 'qux')).toBe(node)); + }); + + describe('prepend', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('inserts one node', () => { + node.prepend('qux'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('foo'); + expect(node.nodes[2].name).toBe('bar'); + expect(node.nodes[3].name).toBe('baz'); + }); + + it('inserts multiple nodes', () => { + node.prepend('qux', 'qax', 'qix'); + expect(node.nodes[0].name).toBe('qux'); + expect(node.nodes[1].name).toBe('qax'); + expect(node.nodes[2].name).toBe('qix'); + expect(node.nodes[3].name).toBe('foo'); + expect(node.nodes[4].name).toBe('bar'); + expect(node.nodes[5].name).toBe('baz'); + }); + + it('inserts before an iterator', () => + testEachMutation(['foo', 'bar', ['baz', 5]], 1, () => + node.prepend('qux', 'qax', 'qix'), + )); + + it('returns itself', () => expect(node.prepend('qux')).toBe(node)); + }); + + describe('push', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar']))); + + it('inserts one node', () => { + node.push(new Parameter('baz')); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('bar'); + expect(node.nodes[2].name).toBe('baz'); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => + node.push(new Parameter('baz')), + )); + + it('returns itself', () => + expect(node.push(new Parameter('baz'))).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeAll(); + expect(child).toHaveProperty('parent', undefined); + }); + + it('can be called during iteration', () => + testEachMutation(['foo'], 0, () => node.removeAll())); + + it('returns itself', () => expect(node.removeAll()).toBe(node)); + }); + + describe('removeChild', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('removes a matching node', () => { + node.removeChild(node.nodes[0]); + expect(node.nodes[0].name).toBe('bar'); + expect(node.nodes[1].name).toBe('baz'); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes[0].name).toBe('foo'); + expect(node.nodes[1].name).toBe('baz'); + }); + + it("removes a node's parents", () => { + const child = node.nodes[1]; + node.removeChild(1); + expect(child).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation(['foo', 'bar', ['baz', 1]], 1, () => + node.removeChild(1), + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', 'bar'], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach(() => void (node = new ParameterList(['foo', 'bar', 'baz']))); + + it('returns false if the callback returns false for all elements', () => + expect(node.some(() => false)).toBe(false)); + + it('returns true if the callback returns true for any element', () => + expect(node.some(element => element.name === 'bar')).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect(new ParameterList(['foo', 'bar', 'baz']).first!.name).toBe('foo')); + + it('returns undefined for an empty list', () => + expect(new ParameterList().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect(new ParameterList({nodes: ['foo', 'bar', 'baz']}).last!.name).toBe( + 'baz', + )); + + it('returns undefined for an empty list', () => + expect(new ParameterList().last).toBeUndefined()); + }); + + describe('stringifies', () => { + describe('with no nodes', () => { + it('with default raws', () => + expect(new ParameterList().toString()).toBe('()')); + + it('ignores comma', () => + expect(new ParameterList({raws: {comma: true}}).toString()).toBe('()')); + + it('with after', () => + expect(new ParameterList({raws: {after: '/**/'}}).toString()).toBe( + '(/**/)', + )); + }); + + describe('with parameters', () => { + it('with default raws', () => + expect(new ParameterList(['foo', 'bar', 'baz']).toString()).toBe( + '($foo, $bar, $baz)', + )); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {comma: true}, + }).toString(), + ).toBe('($foo, $bar, $baz,)')); + + describe('with after', () => { + it('with comma: false', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz/**/)')); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: ['foo', 'bar', 'baz'], + raws: {comma: true, after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz,/**/)')); + }); + + describe('with a parameter with after', () => { + it('with comma: false and no after', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + }).toString(), + ).toBe('($foo, $bar, $baz )')); + + it('with comma: false and after', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + raws: {after: '/**/'}, + }).toString(), + ).toBe('($foo, $bar, $baz /**/)')); + + it('with comma: true', () => + expect( + new ParameterList({ + nodes: [ + 'foo', + 'bar', + new Parameter({name: 'baz', raws: {after: ' '}}), + ], + raws: {comma: true}, + }).toString(), + ).toBe('($foo, $bar, $baz ,)')); + }); + }); + }); + + describe('clone', () => { + let original: ParameterList; + beforeEach( + () => + void (original = new ParameterList({ + nodes: ['foo', 'bar'], + raws: {after: ' '}, + })), + ); + + describe('with no overrides', () => { + let clone: ParameterList; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes[0].name).toBe('foo'); + expect(clone.nodes[0].parent).toBe(clone); + expect(clone.nodes[1].name).toBe('bar'); + expect(clone.nodes[1].parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({after: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws', 'nodes'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => + expect(clone.nodes[0]).toHaveProperty('parent', clone)); + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {comma: true}}).raws).toEqual({ + comma: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + after: ' ', + })); + }); + + describe('nodes', () => { + it('defined', () => { + const clone = original.clone({nodes: ['qux']}); + expect(clone.nodes[0].name).toBe('qux'); + }); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(2); + expect(clone.nodes[0].name).toBe('foo'); + expect(clone.nodes[1].name).toBe('bar'); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@function x($foo, $bar...) {}').nodes[0] as FunctionRule) + .parameters, + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees a parameter with each name and index + * in {@link elements} in order. If an index isn't explicitly provided, it + * defaults to the index in {@link elements}. + * + * When it reaches {@link indexToModify}, it calls {@link modify}, which is + * expected to modify `node.nodes`. + */ +function testEachMutation( + elements: ([string, number] | string)[], + indexToModify: number, + modify: () => void, +): void { + const fn: EachFn = jest.fn((child, i) => { + if (i === indexToModify) modify(); + }); + node.each(fn); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const [name, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith( + i + 1, + expect.objectContaining({name}), + index, + ); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/parameter-list.ts b/pkg/sass-parser/lib/src/parameter-list.ts new file mode 100644 index 000000000..0f78b9738 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter-list.ts @@ -0,0 +1,319 @@ +// Copyright 2024 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 * as postcss from 'postcss'; + +import {Container} from './container'; +import {Parameter, ParameterProps} from './parameter'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new nodes that can be passed into a parameter list, either a + * single parameter or multiple parameters. + * + * @category Statement + */ +export type NewParameters = + | Parameter + | ParameterProps + | ReadonlyArray + | undefined; + +/** + * The initializer properties for {@link ParameterList} passed as an options + * object. + * + * @category Statement + */ +export interface ParameterListObjectProps { + nodes?: ReadonlyArray; + raws?: ParameterListRaws; +} + +/** + * The initializer properties for {@link ParameterList}. + * + * @category Statement + */ +export type ParameterListProps = + | ParameterListObjectProps + | ReadonlyArray; + +/** + * Raws indicating how to precisely serialize a {@link ParameterList} node. + * + * @category Statement + */ +export interface ParameterListRaws { + /** + * Whether the final parameter has a trailing comma. + * + * Ignored if {@link ParameterList.nodes} is empty. + */ + comma?: boolean; + + /** + * The whitespace between the final parameter (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * A list of parameters, as in a `@function` or `@mixin` rule. + * + * @category Statement + */ +export class ParameterList + extends Node + implements Container +{ + readonly sassType = 'parameter-list' as const; + declare raws: ParameterListRaws; + + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private declare _nodes?: Array; + + /** + * Iterators that are currently active within this parameter list. Their + * indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults?: ParameterListProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ParameterList); + constructor(defaults?: object, inner?: sassInternal.ParameterList) { + super(Array.isArray(defaults) ? {nodes: defaults} : defaults); + if (inner) { + this.source = new LazySource(inner); + // TODO: set lazy raws here to use when stringifying + this._nodes = []; + for (const parameter of inner.parameters) { + this.append(new Parameter(undefined, parameter)); + } + if (inner.restParameter) { + // TODO: Provide source information for this parameter. + this.append({name: inner.restParameter, rest: true}); + } + } + if (this._nodes === undefined) this._nodes = []; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['nodes', 'raws']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + append(...nodes: NewParameters[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + each( + callback: (node: Parameter, index: number) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + every( + condition: ( + node: Parameter, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + index(child: Parameter | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + insertAfter(oldNode: Parameter | number, newNode: NewParameters): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + insertBefore(oldNode: Parameter | number, newNode: NewParameters): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + prepend(...nodes: NewParameters[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + push(child: Parameter): this { + return this.append(child); + } + + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + removeChild(child: Parameter | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + const parameter = this._nodes![index]; + if (parameter) parameter.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + some( + condition: ( + node: Parameter, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + get first(): Parameter | undefined { + return this.nodes[0]; + } + + get last(): Parameter | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const parameter of this.nodes) { + if (first) { + result += parameter.raws.before ?? ''; + first = false; + } else { + result += ','; + result += parameter.raws.before ?? ' '; + } + result += parameter.toString(); + result += parameter.raws.after ?? ''; + } + if (this.raws.comma && this.nodes.length) { + result += ','; + } + return result + (this.raws.after ?? '') + ')'; + } + + /** + * Normalizes a single parameter declaration or list of parameters. + */ + private _normalize(nodes: NewParameters): Parameter[] { + const normalized = this._normalizeBeforeParent(nodes); + for (const node of normalized) { + node.parent = this; + } + return normalized; + } + + /** Like {@link _normalize}, but doesn't set the parameter's parents. */ + private _normalizeBeforeParent(nodes: NewParameters): Parameter[] { + if (nodes === undefined) return []; + if (Array.isArray(nodes)) { + if ( + nodes.length === 2 && + typeof nodes[0] === 'string' && + typeof nodes[1] === 'object' && + !('name' in nodes[1]) + ) { + return [new Parameter(nodes)]; + } else { + return (nodes as ReadonlyArray).map(node => + typeof node === 'object' && 'sassType' in node + ? (node as Parameter) + : new Parameter(node), + ); + } + } else { + return [ + typeof nodes === 'object' && 'sassType' in nodes + ? (nodes as Parameter) + : new Parameter(nodes as ParameterProps), + ]; + } + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList(nodes: ReadonlyArray): Parameter[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes; + } +} diff --git a/pkg/sass-parser/lib/src/parameter.test.ts b/pkg/sass-parser/lib/src/parameter.test.ts new file mode 100644 index 000000000..97c8dbd6f --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter.test.ts @@ -0,0 +1,522 @@ +// Copyright 2024 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 { + FunctionRule, + Parameter, + ParameterList, + StringExpression, + sass, + scss, +} from '..'; + +describe('a parameter', () => { + let node: Parameter; + beforeEach( + () => + void (node = new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + })), + ); + + describe('with no default', () => { + function describeNode(description: string, create: () => Parameter): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('parameter')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no default value', () => + expect(node.defaultValue).toBeUndefined()); + + it('is not a rest parameter', () => expect(node.rest).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function a($foo) {}').nodes[0] as FunctionRule).parameters + .nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function a($foo)').nodes[0] as FunctionRule).parameters + .nodes[0], + ); + + describe('constructed manually', () => { + describeNode('with a string', () => new Parameter('foo')); + + describeNode( + 'with an array', + () => new Parameter(['foo', {raws: {before: '/**/'}}]), + ); + + describeNode('with an object', () => new Parameter({name: 'foo'})); + }); + + describe('constructed from properties', () => { + describeNode( + 'a string', + () => new ParameterList({nodes: ['foo']}).nodes[0], + ); + + describeNode( + 'an object', + () => new ParameterList({nodes: [{name: 'foo'}]}).nodes[0], + ); + }); + }); + + describe('with a default', () => { + function describeNode(description: string, create: () => Parameter): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('parameter')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has a default value', () => + expect(node).toHaveStringExpression('defaultValue', 'bar')); + + it('is not a rest parameter', () => expect(node.rest).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function a($foo: "bar") {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function a($foo: "bar")').nodes[0] as FunctionRule) + .parameters.nodes[0], + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an Expression', + () => + new Parameter([ + 'foo', + new StringExpression({text: 'bar', quotes: true}), + ]), + ); + + describeNode( + 'with ExpressionProps', + () => new Parameter(['foo', {text: 'bar', quotes: true}]), + ); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Parameter([ + 'foo', + { + defaultValue: new StringExpression({ + text: 'bar', + quotes: true, + }), + }, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => + new Parameter([ + 'foo', + {defaultValue: {text: 'bar', quotes: true}}, + ]), + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new Parameter({ + name: 'foo', + defaultValue: new StringExpression({text: 'bar', quotes: true}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + }), + ); + }); + }); + + describe('constructed from properties', () => { + describe('an array', () => { + describeNode( + 'with ExpressionProps', + () => + new ParameterList({ + nodes: [['foo', {text: 'bar', quotes: true}]], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ParameterList({ + nodes: [ + ['foo', new StringExpression({text: 'bar', quotes: true})], + ], + }).nodes[0], + ); + + describeNode( + 'with ParameterObjectProps', + () => + new ParameterList({ + nodes: [['foo', {defaultValue: {text: 'bar', quotes: true}}]], + }).nodes[0], + ); + }); + + describe('an object', () => { + describeNode( + 'with ExpressionProps', + () => + new ParameterList({ + nodes: [{name: 'foo', defaultValue: {text: 'bar', quotes: true}}], + }).nodes[0], + ); + + describeNode( + 'with an Expression', + () => + new ParameterList({ + nodes: [ + { + name: 'foo', + defaultValue: new StringExpression({ + text: 'bar', + quotes: true, + }), + }, + ], + }).nodes[0], + ); + }); + }); + }); + + describe('as a rest parameter', () => { + function describeNode(description: string, create: () => Parameter): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('parameter')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no default value', () => + expect(node.defaultValue).toBeUndefined()); + + it('is a rest parameter', () => expect(node.rest).toBe(true)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function a($foo...) {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function a($foo...)').nodes[0] as FunctionRule).parameters + .nodes[0], + ); + + describe('constructed manually', () => { + describeNode('with an array', () => new Parameter(['foo', {rest: true}])); + + describeNode( + 'with an object', + () => new Parameter({name: 'foo', rest: true}), + ); + }); + + describeNode( + 'constructed from properties', + () => new ParameterList({nodes: [{name: 'foo', rest: true}]}).nodes[0], + ); + }); + + it('assigned a new name', () => { + node.name = 'baz'; + expect(node.name).toBe('baz'); + }); + + describe('assigned a new default', () => { + it('updates the default', () => { + const old = node.defaultValue!; + node.defaultValue = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('defaultValue', 'baz'); + }); + + it('sets rest to false', () => { + node.rest = true; + node.defaultValue = {text: 'baz'}; + expect(node.rest).toBe(false); + }); + + it('leaves rest alone if defaultValue is undefined', () => { + node.rest = true; + node.defaultValue = undefined; + expect(node.rest).toBe(true); + }); + }); + + describe('assigned a new rest', () => { + it('updates the value of rest', () => { + node.rest = true; + expect(node.rest).toBe(true); + }); + + it('sets defaultValue to undefined', () => { + node.rest = true; + expect(node.defaultValue).toBe(undefined); + }); + + it('leaves defaultValue alone if rest is false', () => { + node.rest = false; + expect(node).toHaveStringExpression('defaultValue', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no default', () => + expect(new Parameter('foo').toString()).toBe('$foo')); + + it('with a default', () => + expect( + new Parameter(['foo', {text: 'bar', quotes: true}]).toString(), + ).toBe('$foo: "bar"')); + + it('with rest', () => + expect(new Parameter(['foo', {rest: true}]).toString()).toBe( + '$foo...', + )); + + it('with a non-identifier name', () => + expect(new Parameter('f o').toString()).toBe('$f\\20o')); + }); + + // raws.before is only used as part of a ParameterList + it('ignores before', () => + expect( + new Parameter({ + name: 'foo', + raws: {before: '/**/'}, + }).toString(), + ).toBe('$foo')); + + it('with matching name', () => + expect( + new Parameter({ + name: 'foo', + raws: {name: {raw: 'f\\6fo', value: 'foo'}}, + }).toString(), + ).toBe('$f\\6fo')); + + it('with non-matching name', () => + expect( + new Parameter({ + name: 'foo', + raws: {name: {raw: 'f\\41o', value: 'fao'}}, + }).toString(), + ).toBe('$foo')); + + it('with between', () => + expect( + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo : "bar"')); + + it('ignores between with no defaultValue', () => + expect( + new Parameter({ + name: 'foo', + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo')); + + it('with beforeRest', () => + expect( + new Parameter({ + name: 'foo', + rest: true, + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('$foo/**/...')); + + it('ignores beforeRest with rest = false', () => + expect( + new Parameter({ + name: 'foo', + raws: {beforeRest: '/**/'}, + }).toString(), + ).toBe('$foo')); + + // raws.before is only used as part of a Configuration + describe('ignores after', () => { + it('with no default', () => + expect( + new Parameter({ + name: 'foo', + raws: {after: '/**/'}, + }).toString(), + ).toBe('$foo')); + + it('with a default', () => + expect( + new Parameter({ + name: 'foo', + defaultValue: {text: 'bar', quotes: true}, + raws: {after: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"')); + + it('with rest = true', () => + expect( + new Parameter({ + name: 'foo', + rest: true, + raws: {after: '/**/'}, + }).toString(), + ).toBe('$foo...')); + }); + }); + }); + + describe('clone()', () => { + let original: Parameter; + beforeEach(() => { + original = ( + scss.parse('@function x($foo: "bar") {}').nodes[0] as FunctionRule + ).parameters.nodes[0]; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: Parameter; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('name', () => expect(clone.name).toBe('foo')); + + it('defaultValue', () => + expect(clone).toHaveStringExpression('defaultValue', 'bar')); + + it('rest', () => expect(clone.rest).toBe(false)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['defaultValue', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' : ', + })); + }); + + describe('name', () => { + it('defined', () => + expect(original.clone({name: 'baz'}).name).toBe('baz')); + + it('undefined', () => + expect(original.clone({name: undefined}).name).toBe('foo')); + }); + + describe('defaultValue', () => { + it('defined', () => + expect( + original.clone({defaultValue: {text: 'baz', quotes: true}}), + ).toHaveStringExpression('defaultValue', 'baz')); + + it('undefined', () => + expect( + original.clone({defaultValue: undefined}).defaultValue, + ).toBeUndefined()); + }); + + describe('rest', () => { + it('defined', () => + expect(original.clone({rest: true}).rest).toBe(true)); + + it('undefined', () => + expect(original.clone({rest: undefined}).rest).toBe(false)); + }); + }); + }); + + describe('toJSON', () => { + it('with a default', () => + expect( + (scss.parse('@function x($baz: "qux") {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ).toMatchSnapshot()); + + it('with no default', () => + expect( + (scss.parse('@function x($baz) {}').nodes[0] as FunctionRule).parameters + .nodes[0], + ).toMatchSnapshot()); + + it('with rest = true', () => + expect( + (scss.parse('@function x($baz...) {}').nodes[0] as FunctionRule) + .parameters.nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/parameter.ts b/pkg/sass-parser/lib/src/parameter.ts new file mode 100644 index 000000000..bd6ae2f32 --- /dev/null +++ b/pkg/sass-parser/lib/src/parameter.ts @@ -0,0 +1,226 @@ +// Copyright 2024 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 * as postcss from 'postcss'; + +import {convertExpression} from './expression/convert'; +import {Expression, ExpressionProps} from './expression'; +import {fromProps} from './expression/from-props'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import {ParameterList} from './parameter-list'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link Parameter}. + * + * @category Statement + */ +export interface ParameterRaws { + /** The whitespace before the parameter name. */ + before?: string; + + /** + * The parameter's name, not including the `$`. + * + * This may be different than {@link Parameter.name} if the name contains + * escape codes or underscores. + */ + name?: RawWithValue; + + /** + * The whitespace and colon between the parameter name and default value, if + * it has one. + */ + between?: string; + + /** + * The whitespace between the parameter name and the `...`, if {@link + * Parameter.rest} is true. + */ + beforeRest?: string; + + /** + * The space symbols between the end of the parameter (after the default value + * if it has one or the parameter name if it doesn't) and the comma afterwards. + * Always empty for a parameter that doesn't have a trailing comma. + */ + after?: string; +} + +/** + * The initializer properties for {@link Parameter} passed as an + * options object. + * + * @category Statement + */ +export type ParameterObjectProps = { + raws?: ParameterRaws; + name: string; +} & ( + | { + defaultValue?: Expression | ExpressionProps; + rest?: never; + } + | { + defaultValue?: never; + rest?: boolean; + } +); + +/** + * Properties used to initialize a {@link Parameter} without an explicit name. + * This is used when the name is given elsewhere, either in the array form of + * {@link ParameterProps} or the record form of [@link + * ParameterDeclarationProps}. + */ +export type ParameterExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link Parameter}. + * + * @category Statement + */ +export type ParameterProps = + | ParameterObjectProps + | string + | [string, ParameterExpressionProps]; + +/** + * A single parameter defined in the parameter declaration of a `@mixin` or + * `@function` rule. This is always included in a {@link ParameterList}. + * + * @category Statement + */ +export class Parameter extends Node { + readonly sassType = 'parameter' as const; + declare raws: ParameterRaws; + declare parent: ParameterList | undefined; + + /** + * The parameter name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare name: string; + + /** + * The expression that provides the default value for the parameter. + * + * Setting this to a value automatically sets {@link rest} to `false`. + */ + get defaultValue(): Expression | undefined { + return this._defaultValue!; + } + set defaultValue(value: Expression | ExpressionProps | undefined) { + if (this._defaultValue) this._defaultValue.parent = undefined; + if (!value) { + this._defaultValue = undefined; + } else { + this._rest = false; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._defaultValue = value; + } + } + private declare _defaultValue?: Expression; + + /** + * Whether this is a rest parameter (indicated by `...` in Sass). + * + * Setting this to true automatically sets {@link defaultValue} to + * `undefined`. + */ + get rest(): boolean { + return this._rest ?? false; + } + set rest(value: boolean) { + if (value) this.defaultValue = undefined; + this._rest = value; + } + private declare _rest?: boolean; + + constructor(defaults: ParameterProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Parameter); + constructor(defaults?: ParameterProps, inner?: sassInternal.Parameter) { + if (typeof defaults === 'string') { + defaults = {name: defaults}; + } else if (Array.isArray(defaults)) { + const [name, props] = defaults; + defaults = _isParameterObjectProps(props) + ? ({name, ...props} as ParameterObjectProps) + : { + name, + defaultValue: props as Expression | ExpressionProps, + }; + } + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.name = inner.name; + this.defaultValue = inner.defaultValue + ? convertExpression(inner.defaultValue) + : undefined; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'name', + {name: 'defaultValue', explicitUndefined: true}, + 'rest', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['name', 'defaultValue', 'rest'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + '$' + + (this.raws.name?.value === this.name + ? this.raws.name.raw + : sassInternal.toCssIdentifier(this.name)) + + (this.defaultValue + ? (this.raws.between ?? ': ') + this.defaultValue + : '') + + (this.rest ? (this.raws.beforeRest ?? '') + '...' : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.defaultValue ? [this.defaultValue] : []; + } +} + +/** Returns whether {@link props} is a {@link ParameterObjectProps}. */ +function _isParameterObjectProps( + props: ParameterExpressionProps, +): props is Omit { + if ('sassType' in props) return false; + if ('defaultValue' in props) return true; + if ('rest' in props) return true; + const length = Object.keys(props).length; + // `raws` can appear in initializers for expressions, so we only treat it as + // a parameter initializer if it's passed alongside `defaultValue` or `rest` + // or on its own. + if ('raws' in props && length === 1) return true; + return false; +} diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index e0ba70554..5ff29808f 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -32,6 +32,14 @@ export interface DartSet { _unique: 'DartSet'; } +export interface DartMap { + _keyType: K; + _valueType: V; + + // A brand to make this function as a nominal type. + _unique: 'DartMap'; +} + // There may be a better way to declare this, but I can't figure it out. // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace SassInternal { @@ -46,6 +54,8 @@ declare namespace SassInternal { function setToJS(set: DartSet): Set; + function mapToRecord(set: DartMap): Record; + class StatementVisitor { private _fakePropertyToMakeThisAUniqueType1: T; } @@ -66,11 +76,28 @@ declare namespace SassInternal { readonly span: FileSpan; } + class ArgumentList extends SassNode { + readonly positional: Expression[]; + readonly named: DartMap; + readonly rest?: Expression; + readonly keywordRest?: Expression; + } + class Interpolation extends SassNode { contents: (string | Expression)[]; get asPlain(): string | undefined; } + class ParameterList extends SassNode { + readonly parameters: Parameter[]; + readonly restParameter?: string; + } + + class Parameter extends SassNode { + readonly name: string; + readonly defaultValue?: Expression; + } + class Statement extends SassNode { accept(visitor: StatementVisitor): T; } @@ -89,6 +116,11 @@ declare namespace SassInternal { readonly value?: Interpolation; } + class ContentBlock extends ParentStatement { + readonly name: string; + readonly parameters: ParameterList; + } + class DebugRule extends Statement { readonly expression: Expression; } @@ -124,6 +156,18 @@ declare namespace SassInternal { readonly configuration: ConfiguredVariable[]; } + class FunctionRule extends ParentStatement { + readonly name: string; + readonly parameters: ParameterList; + } + + class IncludeRule extends Statement { + readonly namespace: string | null; + readonly name: string; + readonly arguments: ArgumentList; + readonly content: ContentBlock | null; + } + class LoudComment extends Statement { readonly text: Interpolation; } @@ -132,6 +176,15 @@ declare namespace SassInternal { readonly query: Interpolation; } + class MixinRule extends ParentStatement { + readonly name: string; + readonly parameters: ParameterList; + } + + class ReturnRule extends Statement { + readonly expression: Expression; + } + class SilentComment extends Statement { readonly text: string; } @@ -259,16 +312,22 @@ export type SassNode = SassInternal.SassNode; export type Statement = SassInternal.Statement; export type ParentStatement = SassInternal.ParentStatement; +export type ArgumentList = SassInternal.ArgumentList; export type AtRootRule = SassInternal.AtRootRule; export type AtRule = SassInternal.AtRule; +export type ContentBlock = SassInternal.ContentBlock; export type DebugRule = SassInternal.DebugRule; export type EachRule = SassInternal.EachRule; export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type ForwardRule = SassInternal.ForwardRule; +export type FunctionRule = SassInternal.FunctionRule; +export type IncludeRule = SassInternal.IncludeRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; +export type MixinRule = SassInternal.MixinRule; +export type ReturnRule = SassInternal.ReturnRule; export type SilentComment = SassInternal.SilentComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; @@ -277,6 +336,8 @@ export type UseRule = SassInternal.UseRule; export type VariableDeclaration = SassInternal.VariableDeclaration; export type WarnRule = SassInternal.WarnRule; export type WhileRule = SassInternal.WhileRule; +export type Parameter = SassInternal.Parameter; +export type ParameterList = SassInternal.ParameterList; export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; @@ -294,8 +355,12 @@ export interface StatementVisitorObject { visitExtendRule(node: ExtendRule): T; visitForRule(node: ForRule): T; visitForwardRule(node: ForwardRule): T; + visitFunctionRule(node: FunctionRule): T; + visitIncludeRule(node: IncludeRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; + visitMixinRule(node: MixinRule): T; + visitReturnRule(node: ReturnRule): T; visitSilentComment(node: SilentComment): T; visitStyleRule(node: StyleRule): T; visitSupportsRule(node: SupportsRule): T; @@ -312,9 +377,10 @@ export interface ExpressionVisitorObject { visitNumberExpression(node: NumberExpression): T; } +export const createExpressionVisitor = sassInternal.createExpressionVisitor; +export const createStatementVisitor = sassInternal.createStatementVisitor; +export const mapToRecord = sassInternal.mapToRecord; export const parse = sassInternal.parse; export const parseIdentifier = sassInternal.parseIdentifier; -export const toCssIdentifier = sassInternal.toCssIdentifier; -export const createStatementVisitor = sassInternal.createStatementVisitor; -export const createExpressionVisitor = sassInternal.createExpressionVisitor; export const setToJS = sassInternal.setToJS; +export const toCssIdentifier = sassInternal.toCssIdentifier; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap new file mode 100644 index 000000000..c482b974f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/function-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @function rule toJSON 1`] = ` +{ + "functionName": "foo", + "inputs": [ + { + "css": "@function foo($bar) {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "function", + "nodes": [], + "parameters": <($bar)>, + "raws": {}, + "sassType": "function-rule", + "source": <1:1-1:23 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap new file mode 100644 index 000000000..672e8a29a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/include-rule.test.ts.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @include rule toJSON with a child 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar) {@qux}", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "nodes": [ + <@qux;>, + ], + "params": "foo(bar)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:25 in 0>, + "type": "atrule", +} +`; + +exports[`a @include rule toJSON with no children 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar)", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "params": "foo(bar)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:18 in 0>, + "type": "atrule", +} +`; + +exports[`a @include rule toJSON with using and a child 1`] = ` +{ + "arguments": <(bar)>, + "includeName": "foo", + "inputs": [ + { + "css": "@include foo(bar) using ($baz) {@qux}", + "hasBOM": false, + "id": "", + }, + ], + "name": "include", + "nodes": [ + <@qux;>, + ], + "params": "foo(bar) using ($baz)", + "raws": {}, + "sassType": "include-rule", + "source": <1:1-1:38 in 0>, + "type": "atrule", + "using": <($baz)>, +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap new file mode 100644 index 000000000..553da4b9e --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/mixin-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @mixin rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@mixin foo($bar) {}", + "hasBOM": false, + "id": "", + }, + ], + "mixinName": "foo", + "name": "mixin", + "nodes": [], + "parameters": <($bar)>, + "raws": {}, + "sassType": "mixin-rule", + "source": <1:1-1:20 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap new file mode 100644 index 000000000..e7880d8b5 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/return-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @return rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@function x() {@return foo}", + "hasBOM": false, + "id": "", + }, + ], + "name": "return", + "params": "foo", + "raws": {}, + "returnExpression": , + "sassType": "return-rule", + "source": <1:16-1:27 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts index 5f7440c3b..937f0d332 100644 --- a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -71,7 +71,7 @@ describe('an @at-root rule', () => { it('has no params', () => expect(node.params).toBe('')); it('contains a Rule', () => { - const rule = node.nodes[0] as Rule; + const rule = node.nodes![0] as Rule; expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); expect(rule.parent).toBe(node); }); diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts index 52e736787..d6ca1da91 100644 --- a/pkg/sass-parser/lib/src/statement/container.test.ts +++ b/pkg/sass-parser/lib/src/statement/container.test.ts @@ -6,183 +6,207 @@ import * as postcss from 'postcss'; import {GenericAtRule, Root, Rule} from '../..'; -let root: Root; describe('a container node', () => { - beforeEach(() => { - root = new Root(); - }); - - describe('can add', () => { - it('a single Sass node', () => { - const rule = new Rule({selector: '.foo'}); - root.append(rule); - expect(root.nodes).toEqual([rule]); - expect(rule.parent).toBe(root); - }); - - it('a list of Sass nodes', () => { - const rule1 = new Rule({selector: '.foo'}); - const rule2 = new Rule({selector: '.bar'}); - root.append([rule1, rule2]); - expect(root.nodes).toEqual([rule1, rule2]); - expect(rule1.parent).toBe(root); - expect(rule2.parent).toBe(root); - }); - - it('a Sass root node', () => { - const rule1 = new Rule({selector: '.foo'}); - const rule2 = new Rule({selector: '.bar'}); - const otherRoot = new Root({nodes: [rule1, rule2]}); - root.append(otherRoot); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); + describe('with nodes', () => { + let root: Root; + beforeEach(() => { + root = new Root(); }); - it('a PostCSS rule node', () => { - const node = postcss.parse('.foo {}').nodes[0]; - root.append(node); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[0].source).toBe(node.source); - expect(node.parent).toBeUndefined(); - }); - - it('a PostCSS at-rule node', () => { - const node = postcss.parse('@foo bar').nodes[0]; - root.append(node); - expect(root.nodes[0]).toBeInstanceOf(GenericAtRule); - expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo'); - expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar'); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[0].source).toBe(node.source); - expect(node.parent).toBeUndefined(); - }); - - it('a list of PostCSS nodes', () => { - const rule1 = new postcss.Rule({selector: '.foo'}); - const rule2 = new postcss.Rule({selector: '.bar'}); - root.append([rule1, rule2]); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); - }); - - it('a PostCSS root node', () => { - const rule1 = new postcss.Rule({selector: '.foo'}); - const rule2 = new postcss.Rule({selector: '.bar'}); - const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); - root.append(otherRoot); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - expect(rule1.parent).toBeUndefined(); - expect(rule2.parent).toBeUndefined(); - }); - - it("a single Sass node's properties", () => { - root.append({selectorInterpolation: '.foo'}); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - }); - - it("a single PostCSS node's properties", () => { - root.append({selector: '.foo'}); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); - }); - - it('a list of properties', () => { - root.append( - {selectorInterpolation: '.foo'}, - {selectorInterpolation: '.bar'}, - ); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); - }); - - it('a plain CSS string', () => { - root.append('.foo {}'); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[0].parent).toBe(root); + describe('can add', () => { + it('a single Sass node', () => { + const rule = new Rule({selector: '.foo'}); + root.append(rule); + expect(root.nodes).toEqual([rule]); + expect(rule.parent).toBe(root); + }); + + it('a list of Sass nodes', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes).toEqual([rule1, rule2]); + expect(rule1.parent).toBe(root); + expect(rule2.parent).toBe(root); + }); + + it('a Sass root node', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + const otherRoot = new Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS rule node', () => { + const node = postcss.parse('.foo {}').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a PostCSS at-rule node', () => { + const node = postcss.parse('@foo bar').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo'); + expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar'); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a list of PostCSS nodes', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS root node', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it("a single Sass node's properties", () => { + root.append({selectorInterpolation: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it("a single PostCSS node's properties", () => { + root.append({selector: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of properties', () => { + root.append( + {selectorInterpolation: '.foo'}, + {selectorInterpolation: '.bar'}, + ); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('a plain CSS string', () => { + root.append('.foo {}'); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of plain CSS strings', () => { + root.append(['.foo {}', '.bar {}']); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('undefined', () => { + root.append(undefined); + expect(root.nodes).toHaveLength(0); + }); }); + }); - it('a list of plain CSS strings', () => { - root.append(['.foo {}', '.bar {}']); - expect(root.nodes[0]).toBeInstanceOf(Rule); - expect(root.nodes[0]).toHaveInterpolation( - 'selectorInterpolation', - '.foo', - ); - expect(root.nodes[1]).toBeInstanceOf(Rule); - expect(root.nodes[1]).toHaveInterpolation( - 'selectorInterpolation', - '.bar', - ); - expect(root.nodes[0].parent).toBe(root); - expect(root.nodes[1].parent).toBe(root); + describe('without nodes', () => { + let rule: GenericAtRule; + beforeEach(() => { + rule = new GenericAtRule({name: 'foo'}); }); - it('undefined', () => { - root.append(undefined); - expect(root.nodes).toHaveLength(0); + describe('can add', () => { + it('a node', () => { + rule.append('@bar'); + expect(rule.nodes).not.toBeUndefined(); + expect(rule.nodes![0]).toBeInstanceOf(GenericAtRule); + expect(rule.nodes![0]).toHaveInterpolation('nameInterpolation', 'bar'); + }); + + it('undefined', () => { + rule.append(undefined); + expect(rule.nodes).not.toBeUndefined(); + expect(rule.nodes).toHaveLength(0); + }); }); }); }); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts index 432520ddb..f72c32d0c 100644 --- a/pkg/sass-parser/lib/src/statement/css-comment.ts +++ b/pkg/sass-parser/lib/src/statement/css-comment.ts @@ -8,7 +8,7 @@ import type {CommentRaws} from 'postcss/lib/comment'; import {convertExpression} from '../expression/convert'; import {LazySource} from '../lazy-source'; import type * as sassInternal from '../sass-internal'; -import {Interpolation} from '../interpolation'; +import {Interpolation, InterpolationProps} from '../interpolation'; import * as utils from '../utils'; import {ContainerProps, Statement, StatementWithChildren} from '.'; import {_Comment} from './comment-internal'; @@ -37,7 +37,7 @@ export interface CssCommentRaws extends CommentRaws { */ export type CssCommentProps = ContainerProps & { raws?: CssCommentRaws; -} & ({text: string} | {textInterpolation: Interpolation | string}); +} & ({text: string} | {textInterpolation: Interpolation | InterpolationProps}); /** * A CSS-style "loud" comment. Extends [`postcss.Comment`]. @@ -65,20 +65,17 @@ export class CssComment get textInterpolation(): Interpolation { return this._textInterpolation!; } - set textInterpolation(textInterpolation: Interpolation | string) { + set textInterpolation(value: Interpolation | InterpolationProps) { // TODO - postcss/postcss#1957: Mark this as dirty if (this._textInterpolation) { this._textInterpolation.parent = undefined; } - if (typeof textInterpolation === 'string') { - textInterpolation = new Interpolation({ - nodes: [textInterpolation], - }); - } + const textInterpolation = + value instanceof Interpolation ? value : new Interpolation(value); textInterpolation.parent = this; this._textInterpolation = textInterpolation; } - private _textInterpolation?: Interpolation; + private declare _textInterpolation?: Interpolation; constructor(defaults: CssCommentProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts index aa4154b63..e76f74488 100644 --- a/pkg/sass-parser/lib/src/statement/debug-rule.ts +++ b/pkg/sass-parser/lib/src/statement/debug-rule.ts @@ -78,7 +78,7 @@ export class DebugRule if (debugExpression) debugExpression.parent = this; this._debugExpression = debugExpression; } - private _debugExpression?: Expression; + private declare _debugExpression?: Expression; constructor(defaults: DebugRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts index ea2c812d6..486eb56d1 100644 --- a/pkg/sass-parser/lib/src/statement/each-rule.ts +++ b/pkg/sass-parser/lib/src/statement/each-rule.ts @@ -107,7 +107,7 @@ export class EachRule if (eachExpression) eachExpression.parent = this; this._eachExpression = eachExpression; } - private _eachExpression?: Expression; + private declare _eachExpression?: Expression; constructor(defaults: EachRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts index 3d7b369c0..2883d2bec 100644 --- a/pkg/sass-parser/lib/src/statement/error-rule.ts +++ b/pkg/sass-parser/lib/src/statement/error-rule.ts @@ -78,7 +78,7 @@ export class ErrorRule if (errorExpression) errorExpression.parent = this; this._errorExpression = errorExpression; } - private _errorExpression?: Expression; + private declare _errorExpression?: Expression; constructor(defaults: ErrorRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts index 8147f83b0..186116f26 100644 --- a/pkg/sass-parser/lib/src/statement/for-rule.ts +++ b/pkg/sass-parser/lib/src/statement/for-rule.ts @@ -114,7 +114,7 @@ export class ForRule if (fromExpression) fromExpression.parent = this; this._fromExpression = fromExpression; } - private _fromExpression?: Expression; + private declare _fromExpression?: Expression; /** The expresison whose value is the ending point of the iteration. */ get toExpression(): Expression { @@ -128,7 +128,7 @@ export class ForRule if (toExpression) toExpression.parent = this; this._toExpression = toExpression; } - private _toExpression?: Expression; + private declare _toExpression?: Expression; constructor(defaults: ForRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts index 56f22600c..08a81f843 100644 --- a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts @@ -291,7 +291,7 @@ describe('a @forward rule', () => { expect(node.configuration.size).toBe(1); expect(node.configuration.parent).toBe(node); const variables = [...node.configuration.variables()]; - expect(variables[0].variableName).toBe('baz'); + expect(variables[0].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -714,7 +714,7 @@ describe('a @forward rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('zip'); + expect(variables[0].name).toBe('zip'); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); @@ -912,7 +912,7 @@ describe('a @forward rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('zip'); + expect(variables[0].name).toBe('zip'); expect(variables[0]).toHaveStringExpression('expression', 'zap'); }); diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.ts b/pkg/sass-parser/lib/src/statement/forward-rule.ts index f1b316049..e7dd30e88 100644 --- a/pkg/sass-parser/lib/src/statement/forward-rule.ts +++ b/pkg/sass-parser/lib/src/statement/forward-rule.ts @@ -78,7 +78,7 @@ export interface ForwardRuleRaws extends Omit { afterWith?: string; } -/** The initilaizer properties for {@link ForwardMemberList}. */ +/** The initializer properties for {@link ForwardMemberList}. */ export interface ForwardMemberProps { mixinsAndFunctions?: Iterable; variables?: Iterable; @@ -229,7 +229,7 @@ export class ForwardRule : new Configuration(configuration); this._configuration.parent = this; } - private _configuration!: Configuration; + private declare _configuration: Configuration; constructor(defaults: ForwardRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/function-rule.test.ts b/pkg/sass-parser/lib/src/statement/function-rule.test.ts new file mode 100644 index 000000000..1110c7092 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/function-rule.test.ts @@ -0,0 +1,316 @@ +// Copyright 2024 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 {FunctionRule, ParameterList, ReturnRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @function rule', () => { + let node: FunctionRule; + describe('with empty children', () => { + function describeNode( + description: string, + create: () => FunctionRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('function-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('function')); + + it('has a function name', () => + expect(node.functionName.toString()).toBe('foo')); + + it('has a parameter', () => + expect(node.parameters.nodes[0].name).toEqual('bar')); + + it('has matching params', () => expect(node.params).toBe('foo($bar)')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@function foo($bar) {}').nodes[0] as FunctionRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@function foo($bar)').nodes[0] as FunctionRule, + ); + + describeNode( + 'constructed manually', + () => new FunctionRule({functionName: 'foo', parameters: ['bar']}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({functionName: 'foo', parameters: ['bar']}), + ); + }); + + describe('with a child', () => { + function describeNode( + description: string, + create: () => FunctionRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('function-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('function')); + + it('has a function name', () => + expect(node.functionName.toString()).toBe('foo')); + + it('has a parameter', () => + expect(node.parameters.nodes[0].name).toEqual('bar')); + + it('has matching params', () => expect(node.params).toBe('foo($bar)')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(ReturnRule); + expect(node.nodes[0]).toHaveStringExpression( + 'returnExpression', + 'baz', + ); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@function foo($bar) {@return "baz"}') + .nodes[0] as FunctionRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@function foo($bar)\n @return "baz"') + .nodes[0] as FunctionRule, + ); + + describeNode( + 'constructed manually', + () => + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + nodes: [{returnExpression: {text: 'baz'}}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + functionName: 'foo', + parameters: ['bar'], + nodes: [{returnExpression: {text: 'baz'}}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@function foo($bar) {}') + .nodes[0] as FunctionRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip($zap)')).toThrow()); + }); + + describe('assigned new parameters', () => { + beforeEach( + () => + void (node = scss.parse('@function foo($bar) {}') + .nodes[0] as FunctionRule), + ); + + it("removes the old parameters' parent", () => { + const oldParameters = node.parameters; + node.parameters = ['qux']; + expect(oldParameters.parent).toBeUndefined(); + }); + + it("assigns the new parameters' parent", () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(parameters.parent).toBe(node); + }); + + it('assigns the parameters explicitly', () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(node.parameters).toBe(parameters); + }); + + it('assigns the expression as ParametersProps', () => { + node.parameters = ['qux']; + expect(node.parameters.nodes[0].name).toBe('qux'); + expect(node.parameters.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + }).toString(), + ).toBe('@function foo($bar) {}')); + + it('with a non-identifier name', () => + expect( + new FunctionRule({ + functionName: 'f o', + parameters: ['bar'], + }).toString(), + ).toBe('@function f\\20o($bar) {}')); + + it('with afterName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@function/**/foo($bar) {}')); + + it('with matching functionName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {functionName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@function f\\6fo($bar) {}')); + + it('with non-matching functionName', () => + expect( + new FunctionRule({ + functionName: 'foo', + parameters: ['bar'], + raws: {functionName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@function foo($bar) {}')); + }); + }); + + describe('clone', () => { + let original: FunctionRule; + beforeEach(() => { + original = scss.parse('@function foo($bar) {}').nodes[0] as FunctionRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: FunctionRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo($bar)')); + + it('functionName', () => expect(clone.functionName).toBe('foo')); + + it('parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('bar'); + expect(clone.parameters.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['parameters', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('functionName', () => { + describe('defined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({functionName: 'baz'}); + }); + + it('changes params', () => expect(clone.params).toBe('baz($bar)')); + + it('changes functionName', () => + expect(clone.functionName).toEqual('baz')); + }); + + describe('undefined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({functionName: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves functionName', () => + expect(clone.functionName).toEqual('foo')); + }); + }); + + describe('parameters', () => { + describe('defined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({parameters: ['baz']}); + }); + + it('changes params', () => expect(clone.params).toBe('foo($baz)')); + + it('changes parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('baz'); + expect(clone.parameters.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: FunctionRule; + beforeEach(() => { + clone = original.clone({parameters: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves parameters', () => + expect(clone.parameters.nodes[0].name).toBe('bar')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@function foo($bar) {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/function-rule.ts b/pkg/sass-parser/lib/src/statement/function-rule.ts new file mode 100644 index 000000000..30ef902f7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/function-rule.ts @@ -0,0 +1,161 @@ +// Copyright 2024 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 * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {LazySource} from '../lazy-source'; +import {ParameterList, ParameterListProps} from '../parameter-list'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link FunctionRule}. + * + * @category Statement + */ +export interface FunctionRuleRaws extends Omit { + /** + * The function's name. + * + * This may be different than {@link FunctionRule.functionName} if the name + * contains escape codes or underscores. + */ + functionName?: RawWithValue; +} + +/** + * The initializer properties for {@link FunctionRule}. + * + * @category Statement + */ +export type FunctionRuleProps = ContainerProps & { + raws?: FunctionRuleRaws; + functionName: string; + parameters?: ParameterList | ParameterListProps; +}; + +/** + * A `@function` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class FunctionRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'function-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: FunctionRuleRaws; + declare nodes: ChildNode[]; + + /** + * The name of the function. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare functionName: string; + + /** The parameters that this function takes. */ + get parameters(): ParameterList { + return this._parameters!; + } + set parameters(parameters: ParameterList | ParameterListProps) { + if (this._parameters) { + this._parameters.parent = undefined; + } + this._parameters = + 'sassType' in parameters ? parameters : new ParameterList(parameters); + this._parameters.parent = this; + } + private declare _parameters: ParameterList; + + get name(): string { + return 'function'; + } + set name(value: string) { + throw new Error("FunctionRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.raws.functionName?.value === this.functionName + ? this.raws.functionName!.raw + : sassInternal.toCssIdentifier(this.functionName)) + this.parameters + ); + } + set params(value: string | number | undefined) { + throw new Error("FunctionRule.params can't be overwritten."); + } + + constructor(defaults: FunctionRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.FunctionRule); + constructor(defaults?: FunctionRuleProps, inner?: sassInternal.FunctionRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.functionName = inner.name; + this.parameters = new ParameterList(undefined, inner.parameters); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'functionName', + 'parameters', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'functionName', 'parameters', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.parameters]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(FunctionRule); diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts index e75d06519..9d6494e52 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts @@ -49,32 +49,14 @@ describe('a generic @-rule', () => { () => sass.parse('@foo').nodes[0] as GenericAtRule, ); - describe('constructed manually', () => { - describeNode( - 'with a name interpolation', - () => - new GenericAtRule({ - nameInterpolation: new Interpolation({nodes: ['foo']}), - }), - ); - - describeNode( - 'with a name string', - () => new GenericAtRule({name: 'foo'}), - ); - }); - - describe('constructed from ChildProps', () => { - describeNode('with a name interpolation', () => - utils.fromChildProps({ - nameInterpolation: new Interpolation({nodes: ['foo']}), - }), - ); + describeNode( + 'constructed manually', + () => new GenericAtRule({nameInterpolation: 'foo'}), + ); - describeNode('with a name string', () => - utils.fromChildProps({name: 'foo'}), - ); - }); + describeNode('constructed from ChildProps', () => + utils.fromChildProps({nameInterpolation: 'foo'}), + ); }); describe('with params', () => { @@ -111,34 +93,14 @@ describe('a generic @-rule', () => { () => sass.parse('@foo bar').nodes[0] as GenericAtRule, ); - describe('constructed manually', () => { - describeNode( - 'with an interpolation', - () => - new GenericAtRule({ - name: 'foo', - paramsInterpolation: new Interpolation({nodes: ['bar']}), - }), - ); - - describeNode( - 'with a param string', - () => new GenericAtRule({name: 'foo', params: 'bar'}), - ); - }); - - describe('constructed from ChildProps', () => { - describeNode('with an interpolation', () => - utils.fromChildProps({ - name: 'foo', - paramsInterpolation: new Interpolation({nodes: ['bar']}), - }), - ); + describeNode( + 'constructed manually', + () => new GenericAtRule({name: 'foo', paramsInterpolation: 'bar'}), + ); - describeNode('with a param string', () => - utils.fromChildProps({name: 'foo', params: 'bar'}), - ); - }); + describeNode('constructed from ChildProps', () => + utils.fromChildProps({name: 'foo', paramsInterpolation: 'bar'}), + ); }); }); @@ -225,7 +187,7 @@ describe('a generic @-rule', () => { () => new GenericAtRule({ name: 'foo', - paramsInterpolation: new Interpolation({nodes: ['bar ']}), + paramsInterpolation: 'bar ', nodes: [], }), ); @@ -243,7 +205,7 @@ describe('a generic @-rule', () => { describeNode('with an interpolation', () => utils.fromChildProps({ name: 'foo', - paramsInterpolation: new Interpolation({nodes: ['bar ']}), + paramsInterpolation: 'bar ', nodes: [], }), ); @@ -267,8 +229,8 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); - expect(node.nodes[0]).toBeInstanceOf(Rule); - expect(node.nodes[0]).toHaveProperty('selector', '.bar\n'); + expect(node.nodes![0]).toBeInstanceOf(Rule); + expect(node.nodes![0]).toHaveProperty('selector', '.bar\n'); }); }); }); @@ -290,8 +252,8 @@ describe('a generic @-rule', () => { it('has a child node', () => { expect(node.nodes).toHaveLength(1); - expect(node.nodes[0]).toBeInstanceOf(Rule); - expect(node.nodes[0]).toHaveProperty('selector', '.baz\n'); + expect(node.nodes![0]).toBeInstanceOf(Rule); + expect(node.nodes![0]).toHaveProperty('selector', '.baz\n'); }); }); } @@ -631,8 +593,8 @@ describe('a generic @-rule', () => { it('nodes', () => { expect(clone.nodes).toHaveLength(1); - expect(clone.nodes[0]).toBeInstanceOf(Rule); - expect(clone.nodes[0]).toHaveProperty('selector', '.baz '); + expect(clone.nodes![0]).toBeInstanceOf(Rule); + expect(clone.nodes![0]).toHaveProperty('selector', '.baz '); }); }); @@ -650,7 +612,7 @@ describe('a generic @-rule', () => { }); describe('sets parent for', () => { - it('nodes', () => expect(clone.nodes[0].parent).toBe(clone)); + it('nodes', () => expect(clone.nodes![0].parent).toBe(clone)); }); }); diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts index 0ccb6e586..02e1c5a32 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -5,7 +5,7 @@ import * as postcss from 'postcss'; import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; -import {Interpolation} from '../interpolation'; +import {Interpolation, InterpolationProps} from '../interpolation'; import {LazySource} from '../lazy-source'; import type * as sassInternal from '../sass-internal'; import * as utils from '../utils'; @@ -49,11 +49,11 @@ export interface GenericAtRuleRaws extends Omit { export type GenericAtRuleProps = ContainerProps & { raws?: GenericAtRuleRaws; } & ( - | {nameInterpolation: Interpolation | string; name?: never} + | {nameInterpolation: Interpolation | InterpolationProps; name?: never} | {name: string; nameInterpolation?: never} ) & ( - | {paramsInterpolation?: Interpolation | string; params?: never} + | {paramsInterpolation?: Interpolation | InterpolationProps; params?: never} | {params?: string | number; paramsInterpolation?: never} ); @@ -72,7 +72,7 @@ export class GenericAtRule readonly sassType = 'atrule' as const; declare parent: StatementWithChildren | undefined; declare raws: GenericAtRuleRaws; - declare nodes: ChildNode[]; + declare nodes: ChildNode[] | undefined; get name(): string { return this.nameInterpolation.toString(); @@ -87,15 +87,14 @@ export class GenericAtRule get nameInterpolation(): Interpolation { return this._nameInterpolation!; } - set nameInterpolation(nameInterpolation: Interpolation | string) { + set nameInterpolation(value: Interpolation | InterpolationProps) { if (this._nameInterpolation) this._nameInterpolation.parent = undefined; - if (typeof nameInterpolation === 'string') { - nameInterpolation = new Interpolation({nodes: [nameInterpolation]}); - } + const nameInterpolation = + value instanceof Interpolation ? value : new Interpolation(value); nameInterpolation.parent = this; this._nameInterpolation = nameInterpolation; } - private _nameInterpolation?: Interpolation; + private declare _nameInterpolation?: Interpolation; get params(): string { if (this.name !== 'media' || !this.paramsInterpolation) { @@ -136,16 +135,19 @@ export class GenericAtRule return this._paramsInterpolation; } set paramsInterpolation( - paramsInterpolation: Interpolation | string | undefined, + value: Interpolation | InterpolationProps | undefined, ) { if (this._paramsInterpolation) this._paramsInterpolation.parent = undefined; - if (typeof paramsInterpolation === 'string') { - paramsInterpolation = new Interpolation({nodes: [paramsInterpolation]}); + if (value === undefined) { + this._paramsInterpolation = undefined; + } else { + const paramsInterpolation = + value instanceof Interpolation ? value : new Interpolation(value); + paramsInterpolation.parent = this; + this._paramsInterpolation = paramsInterpolation; } - if (paramsInterpolation) paramsInterpolation.parent = this; - this._paramsInterpolation = paramsInterpolation; } - private _paramsInterpolation: Interpolation | undefined; + private declare _paramsInterpolation: Interpolation | undefined; constructor(defaults: GenericAtRuleProps); /** @hidden */ @@ -205,7 +207,7 @@ export class GenericAtRule /** @hidden */ normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { - return normalize(this, node, sample); + return normalize(this as StatementWithChildren, node, sample); } } diff --git a/pkg/sass-parser/lib/src/statement/include-rule.test.ts b/pkg/sass-parser/lib/src/statement/include-rule.test.ts new file mode 100644 index 000000000..724df29d5 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/include-rule.test.ts @@ -0,0 +1,581 @@ +// Copyright 2024 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 { + ArgumentList, + GenericAtRule, + IncludeRule, + ParameterList, + sass, + scss, +} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @include rule', () => { + let node: IncludeRule; + describe('with no block', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has an include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has no using', () => expect(node.using).toBe(undefined)); + + it('has matching params', () => expect(node.params).toBe('foo(bar)')); + + it('has no nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@include foo(bar)').nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@include foo(bar)').nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => new IncludeRule({includeName: 'foo', arguments: [{text: 'bar'}]}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({includeName: 'foo', arguments: [{text: 'bar'}]}), + ); + }); + + describe('with a child', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has a include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has no using', () => expect(node.using).toBe(undefined)); + + it('has matching params', () => expect(node.params).toBe('foo(bar)')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes![0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes![0]).toHaveInterpolation( + 'nameInterpolation', + 'baz', + ); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@include foo(bar) {@baz}').nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@include foo(bar)\n @baz').nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + nodes: [{name: 'baz'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + includeName: 'foo', + arguments: [{text: 'bar'}], + nodes: [{name: 'baz'}], + }), + ); + }); + + describe('with using', () => { + function describeNode( + description: string, + create: () => IncludeRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a sassType', () => expect(node.sassType).toBe('include-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('include')); + + it('has a include name', () => + expect(node.includeName.toString()).toBe('foo')); + + it('has an argument', () => + expect(node.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + + it('has a using parameter', () => + expect(node.using!.nodes[0].name).toBe('baz')); + + it('has matching params', () => + expect(node.params).toBe('foo(bar) using ($baz)')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes![0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes![0]).toHaveInterpolation( + 'nameInterpolation', + 'qux', + ); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@include foo(bar) using ($baz) {@qux}') + .nodes[0] as IncludeRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@include foo(bar) using ($baz)\n @qux') + .nodes[0] as IncludeRule, + ); + + describeNode( + 'constructed manually', + () => + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [{name: 'qux'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [{name: 'qux'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar)').nodes[0] as IncludeRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip(zap)')).toThrow()); + }); + + describe('assigned new arguments', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar)').nodes[0] as IncludeRule), + ); + + it("removes the old arguments' parent", () => { + const oldArguments = node.arguments; + node.arguments = [{text: 'qux'}]; + expect(oldArguments.parent).toBeUndefined(); + }); + + it("assigns the new arguments' parent", () => { + const args = new ArgumentList([{text: 'qux'}]); + node.arguments = args; + expect(args.parent).toBe(node); + }); + + it('assigns the arguments explicitly', () => { + const args = new ArgumentList([{text: 'qux'}]); + node.arguments = args; + expect(node.arguments).toBe(args); + }); + + it('assigns the expression as ArgumentProps', () => { + node.arguments = [{text: 'qux'}]; + expect(node.arguments.nodes[0]).toHaveStringExpression('value', 'qux'); + expect(node.arguments.parent).toBe(node); + }); + }); + + describe('assigned new using', () => { + beforeEach( + () => + void (node = scss.parse('@include foo(bar) using ($baz) {}') + .nodes[0] as IncludeRule), + ); + + it("removes the old using' parent", () => { + const oldUsing = node.using!; + node.using = ['qux']; + expect(oldUsing.parent).toBeUndefined(); + }); + + it("assigns the new using' parent", () => { + const using = new ParameterList(['qux']); + node.using = using; + expect(using.parent).toBe(node); + }); + + it('assigns the using explicitly', () => { + const using = new ParameterList(['qux']); + node.using = using; + expect(node.using).toBe(using); + }); + + it('assigns the expression as ParameterProps', () => { + node.using = ['qux']; + expect(node.using!.nodes[0].name).toBe('qux'); + expect(node.using!.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no arguments', () => + expect(new IncludeRule({includeName: 'foo'}).toString()).toBe( + '@include foo;', + )); + + it('with an argument', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('@include foo(bar);')); + + it('with empty using', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: [], + nodes: [], + }).toString(), + ).toBe('@include foo using () {}')); + + it('with a using parameter', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['bar'], + nodes: [], + }).toString(), + ).toBe('@include foo using ($bar) {}')); + + it('with a non-identifier name', () => + expect( + new IncludeRule({ + includeName: 'f o', + arguments: [{text: 'bar'}], + }).toString(), + ).toBe('@include f\\20o(bar);')); + }); + + it('with afterName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@include/**/foo;')); + + it('with matching includeName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {includeName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@include f\\6fo;')); + + it('with non-matching includeName', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {includeName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@include foo;')); + + it('with showArguments = true', () => + expect( + new IncludeRule({ + includeName: 'foo', + raws: {showArguments: true}, + }).toString(), + ).toBe('@include foo();')); + + it('ignores showArguments with an argument', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {showArguments: true}, + }).toString(), + ).toBe('@include foo(bar);')); + + describe('with afterArguments', () => { + it('with no using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo(bar);')); + + it('with using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + using: ['baz'], + nodes: [], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo(bar)/**/using ($baz) {}')); + + it('with no arguments', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['baz'], + raws: {afterArguments: '/**/'}, + }).toString(), + ).toBe('@include foo/**/using ($baz);')); + }); + + describe('with afterUsing', () => { + it('with no using', () => + expect( + new IncludeRule({ + includeName: 'foo', + arguments: [{text: 'bar'}], + raws: {afterUsing: '/**/'}, + }).toString(), + ).toBe('@include foo(bar);')); + + it('with using', () => + expect( + new IncludeRule({ + includeName: 'foo', + using: ['baz'], + raws: {afterUsing: '/**/'}, + }).toString(), + ).toBe('@include foo using/**/($baz);')); + }); + }); + }); + + describe('clone', () => { + let original: IncludeRule; + beforeEach(() => { + original = scss.parse('@include foo(bar) using ($baz) {}') + .nodes[0] as IncludeRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: IncludeRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('includeName', () => expect(clone.includeName).toBe('foo')); + + it('arguments', () => { + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + ); + expect(clone.arguments.parent).toBe(clone); + }); + + it('using', () => { + expect(clone.using!.nodes[0].name).toBe('baz'); + expect(clone.using!.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['arguments', 'using', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('includeName', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({includeName: 'qux'}); + }); + + it('changes params', () => + expect(clone.params).toBe('qux(bar) using ($baz)')); + + it('changes includeName', () => + expect(clone.includeName).toEqual('qux')); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({includeName: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('preserves includeName', () => + expect(clone.includeName).toEqual('foo')); + }); + }); + + describe('arguments', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({arguments: [{text: 'qux'}]}); + }); + + it('changes params', () => + expect(clone.params).toBe('foo(qux) using ($baz)')); + + it('changes arguments', () => { + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'qux', + ); + expect(clone.arguments.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({arguments: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('foo(bar) using ($baz)')); + + it('preserves arguments', () => + expect(clone.arguments.nodes[0]).toHaveStringExpression( + 'value', + 'bar', + )); + }); + }); + + describe('using', () => { + describe('defined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({using: ['qux']}); + }); + + it('changes params', () => + expect(clone.params).toBe('foo(bar) using ($qux)')); + + it('changes arguments', () => { + expect(clone.using!.nodes[0].name).toBe('qux'); + expect(clone.using!.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: IncludeRule; + beforeEach(() => { + clone = original.clone({using: undefined}); + }); + + it('changes params', () => expect(clone.params).toBe('foo(bar)')); + + it('changes using', () => expect(clone.using).toBeUndefined()); + }); + }); + }); + }); + + describe('toJSON', () => { + it('with no children', () => + expect(scss.parse('@include foo(bar)').nodes[0]).toMatchSnapshot()); + + it('with a child', () => + expect( + scss.parse('@include foo(bar) {@qux}').nodes[0], + ).toMatchSnapshot()); + + it('with using and a child', () => + expect( + scss.parse('@include foo(bar) using ($baz) {@qux}').nodes[0], + ).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/include-rule.ts b/pkg/sass-parser/lib/src/statement/include-rule.ts new file mode 100644 index 000000000..74290fcf6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/include-rule.ts @@ -0,0 +1,251 @@ +// Copyright 2024 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 * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {ArgumentList, ArgumentListProps} from '../argument-list'; +import {LazySource} from '../lazy-source'; +import {Node} from '../node'; +import {ParameterList, ParameterListProps} from '../parameter-list'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link IncludeRule}. + * + * @category Statement + */ +export interface IncludeRuleRaws extends Omit { + /** + * The mixin's namespace. + * + * This may be different than {@link IncludeRule.namespace} if the name + * contains escape codes or underscores. + */ + namespace?: RawWithValue; + + /** + * The mixin's name. + * + * This may be different than {@link IncludeRule.includeName} if the name + * contains escape codes or underscores. + */ + includeName?: RawWithValue; + + /** + * Whether to include an empty argument list. If the argument list isn't + * empty, this is ignored. + */ + showArguments?: boolean; + + /** + * The whitespace between the argument list and the `using` identifier. + * + * This is ignored if {@link IncludeRule.usingParameters} isn't defined. + */ + afterArguments?: string; + + /** + * The whitespace between the `using` identifier and the using parameters. + * + * This is ignored if {@link IncludeRule.usingParameters} isn't defined. + */ + afterUsing?: string; +} + +/** + * The initializer properties for {@link IncludeRule}. + * + * @category Statement + */ +export type IncludeRuleProps = ContainerProps & { + raws?: IncludeRuleRaws; + includeName: string; + arguments?: ArgumentList | ArgumentListProps; + using?: ParameterList | ParameterListProps; +}; + +/** + * An `@include` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class IncludeRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'include-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: IncludeRuleRaws; + declare nodes: ChildNode[] | undefined; + + /** + * The mixin's namespace. + * + * This is the parsed value, with escapes resolved to the characters they + * represent. + */ + declare namespace: string | undefined; + + /** + * The name of the mixin being included. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare includeName: string; + + /** The arguments to pass to the mixin. */ + get arguments(): ArgumentList { + return this._arguments!; + } + set arguments(args: ArgumentList | ArgumentListProps) { + if (this._arguments) { + this._arguments.parent = undefined; + } + this._arguments = 'sassType' in args ? args : new ArgumentList(args); + this._arguments.parent = this; + } + private declare _arguments: ArgumentList; + + /** The parameters that the `@content` block takes. */ + get using(): ParameterList | undefined { + return this._using; + } + set using(parameters: ParameterList | ParameterListProps | undefined) { + if (this._using) { + this._using.parent = undefined; + } + if (parameters) { + this._using = + 'sassType' in parameters ? parameters : new ParameterList(parameters); + this._using.parent = this; + } else { + this._using = undefined; + } + } + private declare _using?: ParameterList; + + get name(): string { + return 'include'; + } + set name(value: string) { + throw new Error("IncludeRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.namespace + ? (this.raws.namespace?.value === this.namespace + ? this.raws.namespace.raw + : sassInternal.toCssIdentifier(this.namespace)) + '.' + : '') + + (this.raws.includeName?.value === this.includeName + ? this.raws.includeName!.raw + : sassInternal.toCssIdentifier(this.includeName)) + + (!this.raws.showArguments && this.arguments.nodes.length === 0 + ? '' + : this.arguments) + + (this.using + ? (this.raws.afterArguments ?? ' ') + + 'using' + + (this.raws.afterUsing ?? ' ') + + this.using + : '') + ); + } + set params(value: string | number | undefined) { + throw new Error("IncludeRule.params can't be overwritten."); + } + + constructor(defaults: IncludeRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.IncludeRule); + constructor(defaults?: IncludeRuleProps, inner?: sassInternal.IncludeRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.namespace = inner.namespace ?? undefined; + this.includeName = inner.name; + this.arguments = new ArgumentList(undefined, inner.arguments); + if (inner.content) { + if (inner.content.parameters.parameters.length > 0) { + this.using = new ParameterList(undefined, inner.content.parameters); + } + this.nodes = []; + appendInternalChildren(this, inner.content.children); + } + } + this._arguments ??= new ArgumentList(); + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'includeName', + 'arguments', + {name: 'using', explicitUndefined: true}, + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + [ + 'name', + 'params', + 'namespace', + 'includeName', + 'arguments', + 'using', + 'nodes', + ], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result: Node[] = [this.arguments]; + if (this.using) result.push(this.using); + return result; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + this.nodes ??= []; + return normalize(this as StatementWithChildren, node, sample); + } +} + +interceptIsClean(IncludeRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 416352f0f..92dd22f48 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -4,6 +4,7 @@ import * as postcss from 'postcss'; +import {Container} from '../container'; import {Interpolation} from '../interpolation'; import {LazySource} from '../lazy-source'; import {Node, NodeProps} from '../node'; @@ -16,6 +17,10 @@ import {EachRule, EachRuleProps} from './each-rule'; import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; import {ForwardRule, ForwardRuleProps} from './forward-rule'; +import {FunctionRule, FunctionRuleProps} from './function-rule'; +import {IncludeRule, IncludeRuleProps} from './include-rule'; +import {MixinRule, MixinRuleProps} from './mixin-rule'; +import {ReturnRule, ReturnRuleProps} from './return-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; import {UseRule, UseRuleProps} from './use-rule'; @@ -53,11 +58,15 @@ export type StatementType = | 'comment' | 'debug-rule' | 'each-rule' + | 'error-rule' | 'for-rule' | 'forward-rule' - | 'error-rule' - | 'use-rule' + | 'function-rule' + | 'include-rule' + | 'mixin-rule' + | 'return-rule' | 'sass-comment' + | 'use-rule' | 'variable-declaration' | 'warn-rule' | 'while-rule'; @@ -73,7 +82,11 @@ export type AtRule = | ErrorRule | ForRule | ForwardRule + | FunctionRule | GenericAtRule + | IncludeRule + | MixinRule + | ReturnRule | UseRule | WarnRule | WhileRule; @@ -109,7 +122,11 @@ export type ChildProps = | ErrorRuleProps | ForRuleProps | ForwardRuleProps + | FunctionRuleProps | GenericAtRuleProps + | IncludeRuleProps + | MixinRuleProps + | ReturnRuleProps | RuleProps | SassCommentChildProps | UseRuleProps @@ -131,9 +148,9 @@ export interface ContainerProps extends NodeProps { * * @category Statement */ -export type StatementWithChildren = postcss.Container & { - nodes: ChildNode[]; -} & Statement; +export type StatementWithChildren = postcss.Container & + Container & + Statement; /** * A statement in a Sass stylesheet. @@ -170,6 +187,8 @@ const visitor = sassInternal.createStatementVisitor({ visitEachRule: inner => new EachRule(undefined, inner), visitForRule: inner => new ForRule(undefined, inner), visitForwardRule: inner => new ForwardRule(undefined, inner), + visitFunctionRule: inner => new FunctionRule(undefined, inner), + visitIncludeRule: inner => new IncludeRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); if (inner.isOptional) paramsInterpolation.append('!optional'); @@ -189,6 +208,8 @@ const visitor = sassInternal.createStatementVisitor({ appendInternalChildren(rule, inner.children); return rule; }, + visitMixinRule: inner => new MixinRule(undefined, inner), + visitReturnRule: inner => new ReturnRule(undefined, inner), visitSilentComment: inner => new SassComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), visitSupportsRule: inner => { @@ -313,16 +334,24 @@ export function normalize( result.push(new DebugRule(node)); } else if ('eachExpression' in node) { result.push(new EachRule(node)); + } else if ('errorExpression' in node) { + result.push(new ErrorRule(node)); + } else if ('includeName' in node) { + result.push(new IncludeRule(node)); } else if ('fromExpression' in node) { result.push(new ForRule(node)); } else if ('forwardUrl' in node) { result.push(new ForwardRule(node)); - } else if ('errorExpression' in node) { - result.push(new ErrorRule(node)); - } else if ('text' in node || 'textInterpolation' in node) { - result.push(new CssComment(node as CssCommentProps)); + } else if ('functionName' in node) { + result.push(new FunctionRule(node)); + } else if ('mixinName' in node) { + result.push(new MixinRule(node)); + } else if ('returnExpression' in node) { + result.push(new ReturnRule(node)); } else if ('silentText' in node) { result.push(new SassComment(node)); + } else if ('text' in node || 'textInterpolation' in node) { + result.push(new CssComment(node as CssCommentProps)); } else if ('useUrl' in node) { result.push(new UseRule(node)); } else if ('variableName' in node) { diff --git a/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts b/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts new file mode 100644 index 000000000..a1e7665fb --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/mixin-rule.test.ts @@ -0,0 +1,296 @@ +// Copyright 2024 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 {GenericAtRule, MixinRule, ParameterList, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @mixin rule', () => { + let node: MixinRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => MixinRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('mixin')); + + it('has a mixin name', () => + expect(node.mixinName.toString()).toBe('foo')); + + it('has a parameter', () => + expect(node.parameters.nodes[0].name).toEqual('bar')); + + it('has matching params', () => expect(node.params).toBe('foo($bar)')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@mixin foo($bar)').nodes[0] as MixinRule, + ); + + describeNode( + 'constructed manually', + () => new MixinRule({mixinName: 'foo', parameters: ['bar']}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({mixinName: 'foo', parameters: ['bar']}), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => MixinRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('mixin')); + + it('has a mixin name', () => + expect(node.mixinName.toString()).toBe('foo')); + + it('has a parameter', () => + expect(node.parameters.nodes[0].name).toEqual('bar')); + + it('has matching params', () => expect(node.params).toBe('foo($bar)')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveInterpolation('nameInterpolation', 'baz'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@mixin foo($bar) {@baz}').nodes[0] as MixinRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@mixin foo($bar)\n @baz').nodes[0] as MixinRule, + ); + + describeNode( + 'constructed manually', + () => + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + nodes: [{nameInterpolation: 'baz'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + mixinName: 'foo', + parameters: ['bar'], + nodes: [{nameInterpolation: 'baz'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => expect(() => (node.params = 'zip($zap)')).toThrow()); + }); + + describe('assigned new parameters', () => { + beforeEach( + () => + void (node = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule), + ); + + it("removes the old parameters' parent", () => { + const oldParameters = node.parameters; + node.parameters = ['qux']; + expect(oldParameters.parent).toBeUndefined(); + }); + + it("assigns the new parameters' parent", () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(parameters.parent).toBe(node); + }); + + it('assigns the parameters explicitly', () => { + const parameters = new ParameterList(['qux']); + node.parameters = parameters; + expect(node.parameters).toBe(parameters); + }); + + it('assigns the expression as ParametersProps', () => { + node.parameters = ['qux']; + expect(node.parameters.nodes[0].name).toBe('qux'); + expect(node.parameters.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + }).toString(), + ).toBe('@mixin foo($bar) {}')); + + it('with a non-identifier name', () => + expect( + new MixinRule({ + mixinName: 'f o', + parameters: ['bar'], + }).toString(), + ).toBe('@mixin f\\20o($bar) {}')); + + it('with afterName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@mixin/**/foo($bar) {}')); + + it('with matching mixinName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {mixinName: {value: 'foo', raw: 'f\\6fo'}}, + }).toString(), + ).toBe('@mixin f\\6fo($bar) {}')); + + it('with non-matching mixinName', () => + expect( + new MixinRule({ + mixinName: 'foo', + parameters: ['bar'], + raws: {mixinName: {value: 'fao', raw: 'f\\41o'}}, + }).toString(), + ).toBe('@mixin foo($bar) {}')); + }); + }); + + describe('clone', () => { + let original: MixinRule; + beforeEach(() => { + original = scss.parse('@mixin foo($bar) {}').nodes[0] as MixinRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: MixinRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo($bar)')); + + it('mixinName', () => expect(clone.mixinName).toBe('foo')); + + it('parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('bar'); + expect(clone.parameters.parent).toBe(clone); + }); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['parameters', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('mixinName', () => { + describe('defined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({mixinName: 'baz'}); + }); + + it('changes params', () => expect(clone.params).toBe('baz($bar)')); + + it('changes mixinName', () => expect(clone.mixinName).toEqual('baz')); + }); + + describe('undefined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({mixinName: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves mixinName', () => + expect(clone.mixinName).toEqual('foo')); + }); + }); + + describe('parameters', () => { + describe('defined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({parameters: ['baz']}); + }); + + it('changes params', () => expect(clone.params).toBe('foo($baz)')); + + it('changes parameters', () => { + expect(clone.parameters.nodes[0].name).toBe('baz'); + expect(clone.parameters.parent).toBe(clone); + }); + }); + + describe('undefined', () => { + let clone: MixinRule; + beforeEach(() => { + clone = original.clone({parameters: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo($bar)')); + + it('preserves parameters', () => + expect(clone.parameters.nodes[0].name).toBe('bar')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@mixin foo($bar) {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/mixin-rule.ts b/pkg/sass-parser/lib/src/statement/mixin-rule.ts new file mode 100644 index 000000000..90712d090 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/mixin-rule.ts @@ -0,0 +1,161 @@ +// Copyright 2024 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 * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {LazySource} from '../lazy-source'; +import {ParameterList, ParameterListProps} from '../parameter-list'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link MixinRule}. + * + * @category Statement + */ +export interface MixinRuleRaws extends Omit { + /** + * The mixin's name. + * + * This may be different than {@link Mixin.mixinName} if the name contains + * escape codes or underscores. + */ + mixinName?: RawWithValue; +} + +/** + * The initializer properties for {@link MixinRule}. + * + * @category Statement + */ +export type MixinRuleProps = ContainerProps & { + raws?: MixinRuleRaws; + mixinName: string; + parameters?: ParameterList | ParameterListProps; +}; + +/** + * A `@mixin` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class MixinRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'mixin-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: MixinRuleRaws; + declare nodes: ChildNode[]; + + /** + * The name of the mixin. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare mixinName: string; + + /** The parameters that this mixin takes. */ + get parameters(): ParameterList { + return this._parameters!; + } + set parameters(parameters: ParameterList | ParameterListProps) { + if (this._parameters) { + this._parameters.parent = undefined; + } + this._parameters = + 'sassType' in parameters ? parameters : new ParameterList(parameters); + this._parameters.parent = this; + } + private declare _parameters: ParameterList; + + get name(): string { + return 'mixin'; + } + set name(value: string) { + throw new Error("MixinRule.name can't be overwritten."); + } + + get params(): string { + return ( + (this.raws.mixinName?.value === this.mixinName + ? this.raws.mixinName!.raw + : sassInternal.toCssIdentifier(this.mixinName)) + this.parameters + ); + } + set params(value: string | number | undefined) { + throw new Error("MixinRule.params can't be overwritten."); + } + + constructor(defaults: MixinRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.MixinRule); + constructor(defaults?: MixinRuleProps, inner?: sassInternal.MixinRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.mixinName = inner.name; + this.parameters = new ParameterList(undefined, inner.parameters); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'mixinName', + 'parameters', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'mixinName', 'parameters', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.parameters]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(MixinRule); diff --git a/pkg/sass-parser/lib/src/statement/return-rule.test.ts b/pkg/sass-parser/lib/src/statement/return-rule.test.ts new file mode 100644 index 000000000..eaf371870 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/return-rule.test.ts @@ -0,0 +1,216 @@ +// Copyright 2024 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 {FunctionRule, ReturnRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @return rule', () => { + let node: ReturnRule; + function describeNode(description: string, create: () => ReturnRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('return')); + + it('has an expression', () => + expect(node).toHaveStringExpression('returnExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule) + .nodes[0] as ReturnRule, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@function x()\n @return foo').nodes[0] as FunctionRule) + .nodes[0] as ReturnRule, + ); + + describeNode( + 'constructed manually', + () => + new ReturnRule({ + returnExpression: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + returnExpression: {text: 'foo'}, + }), + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new ReturnRule({ + returnExpression: {text: 'foo'}, + }).name = 'bar'), + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = ( + scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule + ).nodes[0] as ReturnRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('returnExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('returnExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.returnExpression; + node.returnExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.returnExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.returnExpression = expression; + expect(node.returnExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.returnExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('returnExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('returnExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + }).toString(), + ).toBe('@return foo;')); + + it('with afterName', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@return/**/foo;')); + + it('with between', () => + expect( + new ReturnRule({ + returnExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@return foo/**/;')); + }); + }); + + describe('clone', () => { + let original: ReturnRule; + beforeEach(() => { + original = ( + scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule + ).nodes[0] as ReturnRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ReturnRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['returnExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('returnExpression', () => { + describe('defined', () => { + let clone: ReturnRule; + beforeEach(() => { + clone = original.clone({returnExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: ReturnRule; + beforeEach(() => { + clone = original.clone({returnExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves returnExpression', () => + expect(clone).toHaveStringExpression('returnExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@function x() {@return foo}').nodes[0] as FunctionRule) + .nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/return-rule.ts b/pkg/sass-parser/lib/src/statement/return-rule.ts new file mode 100644 index 000000000..cc5c5aaec --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/return-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 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 * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ReturnRule}. + * + * @category Statement + */ +export type ReturnRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link ReturnRule}. + * + * @category Statement + */ +export type ReturnRuleProps = postcss.NodeProps & { + raws?: ReturnRuleRaws; + returnExpression: Expression | ExpressionProps; +}; + +/** + * A `@return` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ReturnRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'return-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ReturnRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'return'; + } + set name(value: string) { + throw new Error("ReturnRule.name can't be overwritten."); + } + + get params(): string { + return this.returnExpression.toString(); + } + set params(value: string | number | undefined) { + this.returnExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the return rule is executed. */ + get returnExpression(): Expression { + return this._returnExpression!; + } + set returnExpression(returnExpression: Expression | ExpressionProps) { + if (this._returnExpression) this._returnExpression.parent = undefined; + if (!('sassType' in returnExpression)) { + returnExpression = fromProps(returnExpression); + } + if (returnExpression) returnExpression.parent = this; + this._returnExpression = returnExpression; + } + declare _returnExpression?: Expression; + + constructor(defaults: ReturnRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ReturnRule); + constructor(defaults?: ReturnRuleProps, inner?: sassInternal.ReturnRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.returnExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'returnExpression'], + [{name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'returnExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.returnExpression]; + } +} + +interceptIsClean(ReturnRule); diff --git a/pkg/sass-parser/lib/src/statement/rule.ts b/pkg/sass-parser/lib/src/statement/rule.ts index b53605a09..eb0b3553b 100644 --- a/pkg/sass-parser/lib/src/statement/rule.ts +++ b/pkg/sass-parser/lib/src/statement/rule.ts @@ -79,7 +79,7 @@ export class Rule extends _Rule implements Statement { selectorInterpolation.parent = this; this._selectorInterpolation = selectorInterpolation; } - private _selectorInterpolation?: Interpolation; + private declare _selectorInterpolation?: Interpolation; constructor(defaults: RuleProps); constructor(_: undefined, inner: sassInternal.StyleRule); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts index fa079fb76..2786a3033 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -134,7 +134,7 @@ describe('a @use rule', () => { expect(node.configuration.size).toBe(1); expect(node.configuration.parent).toBe(node); const variables = [...node.configuration.variables()]; - expect(variables[0].variableName).toBe('baz'); + expect(variables[0].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -418,7 +418,7 @@ describe('a @use rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('baz'); + expect(variables[0].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); @@ -536,7 +536,7 @@ describe('a @use rule', () => { expect(clone.configuration.size).toBe(1); expect(clone.configuration.parent).toBe(clone); const variables = [...clone.configuration.variables()]; - expect(variables[0].variableName).toBe('baz'); + expect(variables[0].name).toBe('baz'); expect(variables[0]).toHaveStringExpression('expression', 'qux'); }); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.ts b/pkg/sass-parser/lib/src/statement/use-rule.ts index d70b3dff7..7404e100d 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.ts @@ -151,7 +151,7 @@ export class UseRule : new Configuration(configuration); this._configuration.parent = this; } - private _configuration!: Configuration; + private declare _configuration: Configuration; constructor(defaults: UseRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts index 50377d802..6525abf26 100644 --- a/pkg/sass-parser/lib/src/statement/variable-declaration.ts +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -81,7 +81,7 @@ export class VariableDeclaration declare raws: VariableDeclarationRaws; /** - * The variable name, not including `$`. + * The variable's namespace. * * This is the parsed value, with escapes resolved to the characters they * represent. @@ -106,7 +106,7 @@ export class VariableDeclaration if (value) value.parent = this; this._expression = value; } - private _expression!: Expression; + private declare _expression: Expression; /** Whether the variable has a `!default` flag. */ declare guarded: boolean; diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.ts b/pkg/sass-parser/lib/src/statement/warn-rule.ts index cc2529ece..ff6e93f82 100644 --- a/pkg/sass-parser/lib/src/statement/warn-rule.ts +++ b/pkg/sass-parser/lib/src/statement/warn-rule.ts @@ -78,7 +78,7 @@ export class WarnRule if (warnExpression) warnExpression.parent = this; this._warnExpression = warnExpression; } - private _warnExpression?: Expression; + private declare _warnExpression?: Expression; constructor(defaults: WarnRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/statement/while-rule.ts b/pkg/sass-parser/lib/src/statement/while-rule.ts index 9284454d4..e652d7c80 100644 --- a/pkg/sass-parser/lib/src/statement/while-rule.ts +++ b/pkg/sass-parser/lib/src/statement/while-rule.ts @@ -83,7 +83,7 @@ export class WhileRule if (whileCondition) whileCondition.parent = this; this._whileCondition = whileCondition; } - private _whileCondition?: Expression; + private declare _whileCondition?: Expression; constructor(defaults: WhileRuleProps); /** @hidden */ diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 8ad173e58..a2862ccaa 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -34,7 +34,10 @@ import {EachRule} from './statement/each-rule'; import {ErrorRule} from './statement/error-rule'; import {ForRule} from './statement/for-rule'; import {ForwardRule} from './statement/forward-rule'; +import {FunctionRule} from './statement/function-rule'; import {GenericAtRule} from './statement/generic-at-rule'; +import {MixinRule} from './statement/mixin-rule'; +import {ReturnRule} from './statement/return-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; @@ -96,6 +99,18 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['function-rule'](node: FunctionRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['include-rule'](node: FunctionRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['mixin-rule'](node: MixinRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private atrule(node: GenericAtRule, semicolon: boolean): void { // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as // `@at-root .foo {...}`. @@ -103,6 +118,7 @@ export class Stringifier extends PostCssStringifier { node.raws.atRootShorthand && node.name === 'at-root' && node.paramsInterpolation === undefined && + node.nodes && node.nodes.length === 1 && node.nodes[0].sassType === 'rule' ) { @@ -129,6 +145,10 @@ export class Stringifier extends PostCssStringifier { } } + private ['return-rule'](node: ReturnRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private rule(node: Rule): void { this.block(node, node.selectorInterpolation.toString()); } diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json index d3232dd2c..d23090d84 100644 --- a/pkg/sass-parser/package.json +++ b/pkg/sass-parser/package.json @@ -1,6 +1,6 @@ { "name": "sass-parser", - "version": "0.4.5", + "version": "0.4.9", "description": "A PostCSS-compatible wrapper of the official Sass parser", "repository": "sass/sass", "author": "Google Inc.", @@ -45,7 +45,7 @@ "rimraf": "^6.0.1", "ts-jest": "^29.0.5", "ts-node": "^10.2.1", - "typedoc": "^0.26.5", + "typedoc": "^0.27.2", "typescript": "^5.0.2" } } diff --git a/pkg/sass-parser/tsconfig.json b/pkg/sass-parser/tsconfig.json index 50261e574..60764451b 100644 --- a/pkg/sass-parser/tsconfig.json +++ b/pkg/sass-parser/tsconfig.json @@ -7,13 +7,7 @@ "resolveJsonModule": true, "rootDir": ".", "useUnknownInCatchVariables": false, - "useDefineForClassFields": false, "declaration": true }, - "include": [ - "*.ts", - "lib/**/*.ts", - "tool/**/*.ts", - "test/**/*.ts" - ] + "include": ["*.ts", "lib/**/*.ts", "tool/**/*.ts", "test/**/*.ts"] } diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index b65d12b7d..a981bb724 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,30 @@ +## 15.0.1 + +* No user-visible changes. + +## 15.0.0 + +* Rename `ArgumentInvocation` to `ArgumentList`, `ArgumentDeclaration` to + `ParameterList`, and `Argument` to `Parameter` to better match the + conventional distinction between "arguments" and "parameters". + +* Rename `ArgumentDeclaration.arguments` to `ParameterList.parameters`, + `ArgumentDeclaration.restArgument` to `ParameterList.restParameter`, + `CallableDeclaration.arguments` to `.parameters`. + +## 14.4.1-dev + +* No user-visible changes. + +## 14.4.0 + +* No user-visible changes. + +## 14.3.0 + +* Add `NodePackageImporter`, which loads `pkg:` URLs from `node_modules` within + the provided `entryPointDirectory`. + ## 14.2.0 * No user-visible changes. @@ -17,7 +44,7 @@ ## 14.1.0 * Add `Expression.isCalculationSafe`, which returns true when this expression - can safely be used in a calcuation. + can safely be used in a calculation. ## 14.0.0 diff --git a/pkg/sass_api/lib/sass_api.dart b/pkg/sass_api/lib/sass_api.dart index 7190a49fd..62c8f862e 100644 --- a/pkg/sass_api/lib/sass_api.dart +++ b/pkg/sass_api/lib/sass_api.dart @@ -16,6 +16,7 @@ export 'package:sass/src/ast/selector.dart'; export 'package:sass/src/async_import_cache.dart'; export 'package:sass/src/exception.dart' show SassFormatException; export 'package:sass/src/import_cache.dart'; +export 'package:sass/src/importer/node_package.dart'; export 'package:sass/src/interpolation_map.dart'; export 'package:sass/src/value.dart' hide ColorFormat, SpanColorFormat; export 'package:sass/src/visitor/find_dependencies.dart'; diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index fd8ce6638..dd0c08570 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 14.2.0 +version: 15.0.1 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.3.0 <4.0.0" dependencies: - sass: 1.81.0 + sass: 1.83.1 dev_dependencies: dartdoc: ^8.0.14 diff --git a/pubspec.yaml b/pubspec.yaml index 9acd22205..9af9300f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.81.0 +version: 1.83.1 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -41,13 +41,13 @@ dev_dependencies: analyzer: ^6.8.0 archive: ^3.1.2 crypto: ^3.0.0 - dart_style: ^2.0.0 + dart_style: ^3.0.0 dartdoc: ^8.0.14 grinder: ^0.9.0 node_preamble: ^2.0.2 - lints: ^4.0.0 + lints: ">=4.0.0 <6.0.0" protoc_plugin: ^21.1.2 - pub_api_client: ^2.1.1 + pub_api_client: ">=2.1.1 <4.0.0" pubspec_parse: ^1.3.0 test: ^1.16.7 test_descriptor: ^2.0.0 diff --git a/test/cli/shared.dart b/test/cli/shared.dart index d9264bde2..f66f40599 100644 --- a/test/cli/shared.dart +++ b/test/cli/shared.dart @@ -487,6 +487,29 @@ void sharedTests( expect(sass.stderr, emitsDone); await sass.shouldExit(0); }); + + // Regression test for sass/dart-sass#2418 + test("doesn't emit runner warnings in content blocks from local @include", + () async { + await d.file("test.scss", """ + @use 'other'; + @include other.foo; + """).create(); + await d.dir("dir", [ + d.file("_other.scss", """ + @mixin bar {@content} + @mixin foo { + @include bar { + #{blue} {x: y} + } + } + """) + ]).create(); + + var sass = await runSass(["--quiet-deps", "-I", "dir", "test.scss"]); + expect(sass.stderr, emitsDone); + await sass.shouldExit(0); + }); }); group("silences warnings through @import", () { diff --git a/test/double_check_test.dart b/test/double_check_test.dart index ab1332345..c36886443 100644 --- a/test/double_check_test.dart +++ b/test/double_check_test.dart @@ -85,8 +85,8 @@ void main() { 'sass_api', sassPubspec, pkgPubspec.version!)); test("matches SDK version", () { - expect(pkgPubspec.environment!["sdk"], - equals(sassPubspec.environment!["sdk"])); + expect(pkgPubspec.environment["sdk"], + equals(sassPubspec.environment["sdk"])); }); test("matches dartdoc version", () { diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index 694341405..584151e79 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -165,7 +165,7 @@ void main() { ..success = _true)); var failure = await getCompileFailure(_process); - expect(failure.message, equals(r"No argument named $arg.")); + expect(failure.message, equals(r"No parameter named $arg.")); await _process.close(); }); diff --git a/tool/grind/generate_deprecations.dart b/tool/grind/generate_deprecations.dart index 21ce6f58e..5590f465c 100644 --- a/tool/grind/generate_deprecations.dart +++ b/tool/grind/generate_deprecations.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:dart_style/dart_style.dart'; import 'package:grinder/grinder.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:yaml/yaml.dart'; import 'utils.dart'; @@ -78,5 +79,7 @@ void deprecations() { fail("Couldn't find block for generated code in lib/src/deprecation.dart"); } var newCode = dartText.replaceFirst(_blockRegex, buffer.toString()); - dartFile.writeAsStringSync(DartFormatter().format(newCode)); + dartFile.writeAsStringSync(DartFormatter( + languageVersion: Version.parse(Platform.version.split(' ').first)) + .format(newCode)); } diff --git a/tool/grind/synchronize.dart b/tool/grind/synchronize.dart index de18066d2..8581eccc8 100644 --- a/tool/grind/synchronize.dart +++ b/tool/grind/synchronize.dart @@ -16,6 +16,7 @@ import 'package:crypto/crypto.dart'; import 'package:dart_style/dart_style.dart'; import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; import 'package:source_span/source_span.dart'; import 'package:sass/src/util/nullable.dart'; @@ -58,7 +59,9 @@ String synchronizeFile(String source) { parseFile(path: source, featureSet: FeatureSet.latestLanguageVersion()) .unit .accept(visitor); - return DartFormatter().format(visitor.result); + return DartFormatter( + languageVersion: Version.parse(Platform.version.split(' ').first)) + .format(visitor.result); } /// The visitor that traverses the asynchronous parse tree and converts it to