diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f1360f056..a3137eb60 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,12 @@ updates: - "/.github/util/*/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directories: + - "/" + - "/package" + - "/pkg/sass-parser" + ignore: + dependency-name: "sass" + schedule: + interval: "weekly" diff --git a/.github/util/initialize/action.yml b/.github/util/initialize/action.yml index cba0719ba..152cceb60 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.35.1 + - uses: bufbuild/buf-setup-action@v1.40.1 with: {github_token: "${{ inputs.github-token }}"} # This composite action requires bash, but bash is not available on windows-arm64 runner. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fe1525f0..faf1e5b18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,8 +102,8 @@ jobs: run: dart run grinder protobuf pkg-pub-deploy env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} - deploy_sub_packages: - name: Deploy Sub-Packages + deploy_sass_api: + name: Deploy sass_api runs-on: ubuntu-latest needs: [deploy_pub] @@ -113,12 +113,38 @@ jobs: with: {github-token: "${{ github.token }}"} - name: Deploy - run: dart run grinder deploy-sub-packages + run: dart run grinder deploy-sass-api env: PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}" GH_TOKEN: "${{ secrets.GH_TOKEN }}" GH_USER: sassbot + deploy_sass_parser: + name: Deploy sass-parser + runs-on: ubuntu-latest + needs: [deploy_npm] + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_TOKEN }} + # Set up .npmrc file to publish to npm + - uses: actions/setup-node@v4 + with: + version: 'lts/*' + check-latest: true + registry-url: 'https://registry.npmjs.org' + - run: npm publish + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + + - name: Get version + id: version + run: | + echo "version=$(jq .version pkg/sass-parser/package.json)" | tee --append "$GITHUB_OUTPUT" + - run: git tag sass-parser/${{ steps.version.outputs.version }} + - run: git push --tag + deploy_homebrew: name: Deploy Homebrew runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38bedad96..856313be9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -311,3 +311,75 @@ jobs: run: dart run test -p chrome -j 2 env: CHROME_EXECUTABLE: chrome + + sass_parser_tests: + name: "sass-parser Tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }}" + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dart_channel: [stable] + node-version: ['lts/*'] + include: + # Test older LTS versions + # + # TODO: Test on lts/-2 and lts/-3 once they support + # `structuredClone()` (that is, once they're v18 or later). + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-1 + # Test LTS version with dart dev channel + - os: ubuntu-latest + dart_channel: dev + node-version: 'lts/*' + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: + dart-sdk: ${{ matrix.dart_channel }} + github-token: ${{ github.token }} + node-version: ${{ matrix.node-version }} + + - run: dart run grinder pkg-npm-dev + env: {UPDATE_SASS_SASS_REPO: false} + - run: npm link + working-directory: build/npm + - run: npm install + working-directory: pkg/sass-parser/ + - run: npm link sass + working-directory: pkg/sass-parser/ + - name: Run tests + run: npm test + working-directory: pkg/sass-parser/ + + sass_parser_static_analysis: + name: "sass-parser Static Analysis" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: {node-version: 'lts/*'} + - run: npm install + working-directory: pkg/sass-parser/ + - name: Run static analysis + run: npm run check + working-directory: pkg/sass-parser/ + + # TODO - postcss/postcss#1958: Enable this once PostCSS doesn't have TypeDoc + # warnings. + + # sass_parser_typedoc: + # name: "sass-parser Typedoc" + # runs-on: ubuntu-latest + # + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-node@v4 + # with: {node-version: 'lts/*'} + # - run: npm install + # working-directory: pkg/sass-parser/ + # - run: npm run typedoc + # working-directory: pkg/sass-parser/ diff --git a/.gitignore b/.gitignore index 2c61888e5..2d01413cb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,10 @@ pubspec.lock package-lock.json /benchmark/source node_modules/ +dist/ /doc/api /pkg/*/doc/api +/pkg/sass-parser/doc # Generated protocol buffer files. *.pb*.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e64c69f..03a033cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,12 @@ `darken()`, `transaprentize()`, `fade-out()`, `opacify()`, and `fade-in()` functions should be replaced by `color.adjust()` or `color.scale()`. +* Add a `global-builtin` future deprecation, which can be opted-into with the + `--future-deprecation` flag or the `futureDeprecations` option in the JS or + Dart API. This emits warnings when any global built-in functions that are + now available in `sass:` modules are called. It will become active by default + in an upcoming release alongside the `@import` deprecation. + ### Dart API * Added a `ColorSpace` class which represents the various color spaces defined @@ -190,6 +196,23 @@ ## 1.78.0 +* The `meta.feature-exists` function is now deprecated. This deprecation is + named `feature-exists`. + +* Fix a crash when using `@at-root` without any queries or children in the + indented syntax. + +### JS API + +* Backport the deprecation options (`fatalDeprecations`, `futureDeprecations`, + and `silenceDeprecations`) to the legacy JS API. The legacy JS API is itself + deprecated, and you should move off of it if possible, but this will allow + users of bundlers and other tools that are still using the legacy API to + still control deprecation warnings. + +* Fix a bug where accessing `SourceSpan.url` would crash when a relative URL was + passed to the Sass API. + ### Embedded Sass * Explicitly expose a `sass` executable from the `sass-embedded` npm package. @@ -204,6 +227,12 @@ * Fix an edge case where the Dart VM could hang when shutting down when requests were in flight. +* Fix a race condition where the embedded host could fail to shut down if it was + closed around the same time a new compilation was started. + +* Fix a bug where parse-time deprecation warnings could not be controlled by + the deprecation options in some circumstances. + ## 1.77.8 * No user-visible changes. diff --git a/README.md b/README.md index fbc545065..de3edaad5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**.
- Sass logo + Sass logo npm statistics @@ -14,6 +14,8 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**. GitHub actions build status + @sass@front-end.social on Fediverse +
@SassCSS on Twitter
stackoverflow @@ -201,7 +203,7 @@ files, you'll need to pass a [custom importer] to [`compileString()`] or [`compile()`]: https://sass-lang.com/documentation/js-api/functions/compile [`compileAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileAsync -[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter#importer +[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/stringoptions/#importer [`compileString()`]: https://sass-lang.com/documentation/js-api/functions/compileString [`compileStringAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileStringAsync [legacy API]: #legacy-javascript-api diff --git a/bin/sass.dart b/bin/sass.dart index cc912d041..bd5684d25 100644 --- a/bin/sass.dart +++ b/bin/sass.dart @@ -15,7 +15,6 @@ import 'package:sass/src/executable/watch.dart'; import 'package:sass/src/import_cache.dart'; import 'package:sass/src/importer/filesystem.dart'; import 'package:sass/src/io.dart'; -import 'package:sass/src/logger/deprecation_processing.dart'; import 'package:sass/src/stylesheet_graph.dart'; import 'package:sass/src/utils.dart'; import 'package:sass/src/embedded/executable.dart' @@ -48,16 +47,11 @@ Future main(List args) async { var graph = StylesheetGraph(ImportCache( importers: [...options.pkgImporters, FilesystemImporter.noLoadPath], loadPaths: options.loadPaths, - // This logger is only used for handling fatal/future deprecations - // during parsing, and is re-used across parses, so we don't want to - // limit repetition. A separate DeprecationHandlingLogger is created for - // each compilation, which will limit repetition if verbose is not - // passed in addition to handling fatal/future deprecations. - logger: DeprecationProcessingLogger(options.logger, - silenceDeprecations: options.silenceDeprecations, - fatalDeprecations: options.fatalDeprecations, - futureDeprecations: options.futureDeprecations, - limitRepetition: false))); + logger: ImportCache.wrapLogger( + options.logger, + options.silenceDeprecations, + options.fatalDeprecations, + options.futureDeprecations))); if (options.watch) { await watch(options, graph); return; diff --git a/lib/sass.dart b/lib/sass.dart index 82ebf72c6..39f14f7b1 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -120,7 +120,9 @@ CompileResult compileToResult(String path, logger: logger, importCache: ImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), + logger: ImportCache.wrapLogger(logger, silenceDeprecations, + fatalDeprecations, futureDeprecations, + color: color), loadPaths: loadPaths, packageConfig: packageConfig), functions: functions, @@ -222,7 +224,9 @@ CompileResult compileStringToResult(String source, logger: logger, importCache: ImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), + logger: ImportCache.wrapLogger(logger, silenceDeprecations, + fatalDeprecations, futureDeprecations, + color: color), packageConfig: packageConfig, loadPaths: loadPaths), functions: functions, @@ -261,7 +265,9 @@ Future compileToResultAsync(String path, logger: logger, importCache: AsyncImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), + logger: AsyncImportCache.wrapLogger(logger, silenceDeprecations, + fatalDeprecations, futureDeprecations, + color: color), loadPaths: loadPaths, packageConfig: packageConfig), functions: functions, @@ -304,7 +310,9 @@ Future compileStringToResultAsync(String source, logger: logger, importCache: AsyncImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), + logger: AsyncImportCache.wrapLogger(logger, silenceDeprecations, + fatalDeprecations, futureDeprecations, + color: color), packageConfig: packageConfig, loadPaths: loadPaths), functions: functions, diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart index 051e1c269..5028d9f87 100644 --- a/lib/src/ast/sass/expression.dart +++ b/lib/src/ast/sass/expression.dart @@ -13,15 +13,20 @@ import '../../value.dart'; import '../../visitor/interface/expression.dart'; import '../sass.dart'; +// Note: despite not defining any methods here, this has to be a concrete class +// so we can expose its accept() function to the JS parser. + /// A SassScript expression in a Sass syntax tree. /// /// {@category AST} /// {@category Parsing} @sealed -abstract interface class Expression implements SassNode { +abstract class Expression implements SassNode { /// Calls the appropriate visit method on [visitor]. T accept(ExpressionVisitor visitor); + Expression(); + /// Parses an expression from [contents]. /// /// If passed, [url] is the name of the file from which [contents] comes. diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart index 15ab22bba..4e9cda229 100644 --- a/lib/src/ast/sass/expression/binary_operation.dart +++ b/lib/src/ast/sass/expression/binary_operation.dart @@ -14,7 +14,7 @@ import 'list.dart'; /// A binary operator, as in `1 + 2` or `$this and $other`. /// /// {@category AST} -final class BinaryOperationExpression implements Expression { +final class BinaryOperationExpression extends Expression { /// The operator being invoked. final BinaryOperator operator; @@ -111,6 +111,9 @@ final class BinaryOperationExpression implements Expression { /// /// {@category AST} enum BinaryOperator { + // Note: When updating these operators, also update + // pkg/sass-parser/lib/src/expression/binary-operation.ts. + /// The Microsoft equals operator, `=`. singleEquals('single equals', '=', 0), diff --git a/lib/src/ast/sass/expression/boolean.dart b/lib/src/ast/sass/expression/boolean.dart index 23474a3f6..fa79e23a7 100644 --- a/lib/src/ast/sass/expression/boolean.dart +++ b/lib/src/ast/sass/expression/boolean.dart @@ -10,7 +10,7 @@ import '../expression.dart'; /// A boolean literal, `true` or `false`. /// /// {@category AST} -final class BooleanExpression implements Expression { +final class BooleanExpression extends Expression { /// The value of this expression. final bool value; diff --git a/lib/src/ast/sass/expression/color.dart b/lib/src/ast/sass/expression/color.dart index e81a7f8b8..9713b5f75 100644 --- a/lib/src/ast/sass/expression/color.dart +++ b/lib/src/ast/sass/expression/color.dart @@ -11,7 +11,7 @@ import '../expression.dart'; /// A color literal. /// /// {@category AST} -final class ColorExpression implements Expression { +final class ColorExpression extends Expression { /// The value of this color. final SassColor value; diff --git a/lib/src/ast/sass/expression/function.dart b/lib/src/ast/sass/expression/function.dart index 64c508c19..0f2fce7eb 100644 --- a/lib/src/ast/sass/expression/function.dart +++ b/lib/src/ast/sass/expression/function.dart @@ -17,8 +17,8 @@ import '../reference.dart'; /// interpolation. /// /// {@category AST} -final class FunctionExpression - implements Expression, CallableInvocation, SassReference { +final class FunctionExpression extends Expression + implements CallableInvocation, SassReference { /// The namespace of the function being invoked, or `null` if it's invoked /// without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/expression/if.dart b/lib/src/ast/sass/expression/if.dart index 8805d4bff..95e305e47 100644 --- a/lib/src/ast/sass/expression/if.dart +++ b/lib/src/ast/sass/expression/if.dart @@ -14,7 +14,7 @@ import '../../../visitor/interface/expression.dart'; /// evaluated. /// /// {@category AST} -final class IfExpression implements Expression, CallableInvocation { +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) {"); diff --git a/lib/src/ast/sass/expression/interpolated_function.dart b/lib/src/ast/sass/expression/interpolated_function.dart index 3c97b0c9f..cd5e2abf2 100644 --- a/lib/src/ast/sass/expression/interpolated_function.dart +++ b/lib/src/ast/sass/expression/interpolated_function.dart @@ -15,8 +15,8 @@ import '../interpolation.dart'; /// This is always a plain CSS function. /// /// {@category AST} -final class InterpolatedFunctionExpression - implements Expression, CallableInvocation { +final class InterpolatedFunctionExpression extends Expression + implements CallableInvocation { /// The name of the function being invoked. final Interpolation name; diff --git a/lib/src/ast/sass/expression/list.dart b/lib/src/ast/sass/expression/list.dart index 5bf768cac..67d26880e 100644 --- a/lib/src/ast/sass/expression/list.dart +++ b/lib/src/ast/sass/expression/list.dart @@ -13,7 +13,7 @@ import 'unary_operation.dart'; /// A list literal. /// /// {@category AST} -final class ListExpression implements Expression { +final class ListExpression extends Expression { /// The elements of this list. final List contents; diff --git a/lib/src/ast/sass/expression/map.dart b/lib/src/ast/sass/expression/map.dart index 9bc234780..9bbd540f2 100644 --- a/lib/src/ast/sass/expression/map.dart +++ b/lib/src/ast/sass/expression/map.dart @@ -10,7 +10,7 @@ import '../expression.dart'; /// A map literal. /// /// {@category AST} -final class MapExpression implements Expression { +final class MapExpression extends Expression { /// The pairs in this map. /// /// This is a list of pairs rather than a map because a map may have two keys diff --git a/lib/src/ast/sass/expression/null.dart b/lib/src/ast/sass/expression/null.dart index 4155c00b0..c1f0b583e 100644 --- a/lib/src/ast/sass/expression/null.dart +++ b/lib/src/ast/sass/expression/null.dart @@ -10,7 +10,7 @@ import '../expression.dart'; /// A null literal. /// /// {@category AST} -final class NullExpression implements Expression { +final class NullExpression extends Expression { final FileSpan span; NullExpression(this.span); diff --git a/lib/src/ast/sass/expression/number.dart b/lib/src/ast/sass/expression/number.dart index 7eb2b6fd9..2078e7148 100644 --- a/lib/src/ast/sass/expression/number.dart +++ b/lib/src/ast/sass/expression/number.dart @@ -11,7 +11,7 @@ import '../expression.dart'; /// A number literal. /// /// {@category AST} -final class NumberExpression implements Expression { +final class NumberExpression extends Expression { /// The numeric value. final double value; diff --git a/lib/src/ast/sass/expression/parenthesized.dart b/lib/src/ast/sass/expression/parenthesized.dart index 3788645e3..3459756a5 100644 --- a/lib/src/ast/sass/expression/parenthesized.dart +++ b/lib/src/ast/sass/expression/parenthesized.dart @@ -10,7 +10,7 @@ import '../expression.dart'; /// An expression wrapped in parentheses. /// /// {@category AST} -final class ParenthesizedExpression implements Expression { +final class ParenthesizedExpression extends Expression { /// The internal expression. final Expression expression; diff --git a/lib/src/ast/sass/expression/selector.dart b/lib/src/ast/sass/expression/selector.dart index 81356690b..85365d84a 100644 --- a/lib/src/ast/sass/expression/selector.dart +++ b/lib/src/ast/sass/expression/selector.dart @@ -10,7 +10,7 @@ import '../expression.dart'; /// A parent selector reference, `&`. /// /// {@category AST} -final class SelectorExpression implements Expression { +final class SelectorExpression extends Expression { final FileSpan span; SelectorExpression(this.span); diff --git a/lib/src/ast/sass/expression/string.dart b/lib/src/ast/sass/expression/string.dart index a8539146a..cddb9e848 100644 --- a/lib/src/ast/sass/expression/string.dart +++ b/lib/src/ast/sass/expression/string.dart @@ -16,11 +16,12 @@ import '../interpolation.dart'; /// A string literal. /// /// {@category AST} -final class StringExpression implements Expression { +final class StringExpression extends Expression { /// Interpolation that, when evaluated, produces the contents of this string. /// - /// Unlike [asInterpolation], escapes are resolved and quotes are not - /// included. + /// If this is a quoted string, escapes are resolved and quotes are not + /// included in this text (unlike [asInterpolation]). If it's an unquoted + /// string, escapes are *not* resolved. final Interpolation text; /// Whether `this` has quotes. diff --git a/lib/src/ast/sass/expression/supports.dart b/lib/src/ast/sass/expression/supports.dart index d5de09a75..142a72e74 100644 --- a/lib/src/ast/sass/expression/supports.dart +++ b/lib/src/ast/sass/expression/supports.dart @@ -14,7 +14,7 @@ import '../supports_condition.dart'; /// doesn't include the function name wrapping the condition. /// /// {@category AST} -final class SupportsExpression implements Expression { +final class SupportsExpression extends Expression { /// The condition itself. final SupportsCondition condition; diff --git a/lib/src/ast/sass/expression/unary_operation.dart b/lib/src/ast/sass/expression/unary_operation.dart index 18e5f0c27..913d1ef9e 100644 --- a/lib/src/ast/sass/expression/unary_operation.dart +++ b/lib/src/ast/sass/expression/unary_operation.dart @@ -13,7 +13,7 @@ import 'list.dart'; /// A unary operator, as in `+$var` or `not fn()`. /// /// {@category AST} -final class UnaryOperationExpression implements Expression { +final class UnaryOperationExpression extends Expression { /// The operator being invoked. final UnaryOperator operator; diff --git a/lib/src/ast/sass/expression/value.dart b/lib/src/ast/sass/expression/value.dart index 75b01212e..4d2436555 100644 --- a/lib/src/ast/sass/expression/value.dart +++ b/lib/src/ast/sass/expression/value.dart @@ -14,7 +14,7 @@ import '../expression.dart'; /// constructed dynamically, as for the `call()` function. /// /// {@category AST} -final class ValueExpression implements Expression { +final class ValueExpression extends Expression { /// The embedded value. final Value value; diff --git a/lib/src/ast/sass/expression/variable.dart b/lib/src/ast/sass/expression/variable.dart index 7a839d867..689e72930 100644 --- a/lib/src/ast/sass/expression/variable.dart +++ b/lib/src/ast/sass/expression/variable.dart @@ -12,7 +12,7 @@ import '../reference.dart'; /// A Sass variable. /// /// {@category AST} -final class VariableExpression implements Expression, SassReference { +final class VariableExpression extends Expression implements SassReference { /// The namespace of the variable being referenced, or `null` if it's /// referenced without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/statement.dart b/lib/src/ast/sass/statement.dart index 123cf3362..d2c31bf13 100644 --- a/lib/src/ast/sass/statement.dart +++ b/lib/src/ast/sass/statement.dart @@ -5,10 +5,13 @@ import '../../visitor/interface/statement.dart'; import 'node.dart'; +// Note: despite not defining any methods here, this has to be a concrete class +// so we can expose its accept() function to the JS parser. + /// A statement in a Sass syntax tree. /// /// {@category AST} -abstract interface class Statement implements SassNode { +abstract class Statement implements SassNode { /// Calls the appropriate visit method on [visitor]. T accept(StatementVisitor visitor); } diff --git a/lib/src/ast/sass/statement/content_rule.dart b/lib/src/ast/sass/statement/content_rule.dart index a05066ef0..8d451207b 100644 --- a/lib/src/ast/sass/statement/content_rule.dart +++ b/lib/src/ast/sass/statement/content_rule.dart @@ -14,7 +14,7 @@ import '../statement.dart'; /// caller. /// /// {@category AST} -final class ContentRule implements Statement { +final class ContentRule extends Statement { /// The arguments pass to this `@content` rule. /// /// This will be an empty invocation if `@content` has no arguments. diff --git a/lib/src/ast/sass/statement/debug_rule.dart b/lib/src/ast/sass/statement/debug_rule.dart index 47c2d452d..db9d0aacb 100644 --- a/lib/src/ast/sass/statement/debug_rule.dart +++ b/lib/src/ast/sass/statement/debug_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This prints a Sass value for debugging purposes. /// /// {@category AST} -final class DebugRule implements Statement { +final class DebugRule extends Statement { /// The expression to print. final Expression expression; diff --git a/lib/src/ast/sass/statement/error_rule.dart b/lib/src/ast/sass/statement/error_rule.dart index 977567cbd..756aa32cd 100644 --- a/lib/src/ast/sass/statement/error_rule.dart +++ b/lib/src/ast/sass/statement/error_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This emits an error and stops execution. /// /// {@category AST} -final class ErrorRule implements Statement { +final class ErrorRule extends Statement { /// The expression to evaluate for the error message. final Expression expression; diff --git a/lib/src/ast/sass/statement/extend_rule.dart b/lib/src/ast/sass/statement/extend_rule.dart index 8aa4e4e33..8faa69356 100644 --- a/lib/src/ast/sass/statement/extend_rule.dart +++ b/lib/src/ast/sass/statement/extend_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This gives one selector all the styling of another. /// /// {@category AST} -final class ExtendRule implements Statement { +final class ExtendRule extends Statement { /// The interpolation for the selector that will be extended. final Interpolation selector; diff --git a/lib/src/ast/sass/statement/forward_rule.dart b/lib/src/ast/sass/statement/forward_rule.dart index eea2a226d..7a680e935 100644 --- a/lib/src/ast/sass/statement/forward_rule.dart +++ b/lib/src/ast/sass/statement/forward_rule.dart @@ -15,7 +15,7 @@ import '../statement.dart'; /// A `@forward` rule. /// /// {@category AST} -final class ForwardRule implements Statement, SassDependency { +final class ForwardRule extends Statement implements SassDependency { /// The URI of the module to forward. /// /// If this is relative, it's relative to the containing file. diff --git a/lib/src/ast/sass/statement/if_rule.dart b/lib/src/ast/sass/statement/if_rule.dart index 0e611df12..22b5a03c3 100644 --- a/lib/src/ast/sass/statement/if_rule.dart +++ b/lib/src/ast/sass/statement/if_rule.dart @@ -20,7 +20,7 @@ import 'variable_declaration.dart'; /// This conditionally executes a block of code. /// /// {@category AST} -final class IfRule implements Statement { +final class IfRule extends Statement { /// The `@if` and `@else if` clauses. /// /// The first clause whose expression evaluates to `true` will have its diff --git a/lib/src/ast/sass/statement/import_rule.dart b/lib/src/ast/sass/statement/import_rule.dart index 425c3ac42..8eefd4af4 100644 --- a/lib/src/ast/sass/statement/import_rule.dart +++ b/lib/src/ast/sass/statement/import_rule.dart @@ -11,7 +11,7 @@ import '../statement.dart'; /// An `@import` rule. /// /// {@category AST} -final class ImportRule implements Statement { +final class ImportRule extends Statement { /// The imports imported by this statement. final List imports; diff --git a/lib/src/ast/sass/statement/include_rule.dart b/lib/src/ast/sass/statement/include_rule.dart index 940716cac..98151665e 100644 --- a/lib/src/ast/sass/statement/include_rule.dart +++ b/lib/src/ast/sass/statement/include_rule.dart @@ -15,8 +15,8 @@ import 'content_block.dart'; /// A mixin invocation. /// /// {@category AST} -final class IncludeRule - implements Statement, CallableInvocation, SassReference { +final class IncludeRule extends Statement + implements CallableInvocation, SassReference { /// The namespace of the mixin being invoked, or `null` if it's invoked /// without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/statement/loud_comment.dart b/lib/src/ast/sass/statement/loud_comment.dart index 0c48e09fc..84b557558 100644 --- a/lib/src/ast/sass/statement/loud_comment.dart +++ b/lib/src/ast/sass/statement/loud_comment.dart @@ -11,7 +11,7 @@ import '../statement.dart'; /// A loud CSS-style comment. /// /// {@category AST} -final class LoudComment implements Statement { +final class LoudComment extends Statement { /// The interpolated text of this comment, including comment characters. final Interpolation text; diff --git a/lib/src/ast/sass/statement/parent.dart b/lib/src/ast/sass/statement/parent.dart index 21293019d..4067a2a18 100644 --- a/lib/src/ast/sass/statement/parent.dart +++ b/lib/src/ast/sass/statement/parent.dart @@ -18,7 +18,7 @@ import 'variable_declaration.dart'; /// /// {@category AST} abstract base class ParentStatement?> - implements Statement { + extends Statement { /// The child statements of this statement. final T children; diff --git a/lib/src/ast/sass/statement/return_rule.dart b/lib/src/ast/sass/statement/return_rule.dart index dc1efc65c..53a69af5a 100644 --- a/lib/src/ast/sass/statement/return_rule.dart +++ b/lib/src/ast/sass/statement/return_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This exits from the current function body with a return value. /// /// {@category AST} -final class ReturnRule implements Statement { +final class ReturnRule extends Statement { /// The value to return from this function. final Expression expression; diff --git a/lib/src/ast/sass/statement/silent_comment.dart b/lib/src/ast/sass/statement/silent_comment.dart index 384cd09fb..0d799e139 100644 --- a/lib/src/ast/sass/statement/silent_comment.dart +++ b/lib/src/ast/sass/statement/silent_comment.dart @@ -11,7 +11,7 @@ import '../statement.dart'; /// A silent Sass-style comment. /// /// {@category AST} -final class SilentComment implements Statement { +final class SilentComment extends Statement { /// The text of this comment, including comment characters. final String text; diff --git a/lib/src/ast/sass/statement/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart index b90cddc52..1ae060854 100644 --- a/lib/src/ast/sass/statement/stylesheet.dart +++ b/lib/src/ast/sass/statement/stylesheet.dart @@ -91,8 +91,6 @@ final class Stylesheet extends ParentStatement> { return Stylesheet.parseScss(contents, url: url, logger: logger); case Syntax.css: return Stylesheet.parseCss(contents, url: url, logger: logger); - default: - throw ArgumentError("Unknown syntax $syntax."); } } on SassException catch (error, stackTrace) { var url = error.span.sourceUrl; diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart index 244613abc..77aa81eda 100644 --- a/lib/src/ast/sass/statement/use_rule.dart +++ b/lib/src/ast/sass/statement/use_rule.dart @@ -18,7 +18,7 @@ import '../statement.dart'; /// A `@use` rule. /// /// {@category AST} -final class UseRule implements Statement, SassDependency { +final class UseRule extends Statement implements SassDependency { /// The URI of the module to use. /// /// If this is relative, it's relative to the containing file. diff --git a/lib/src/ast/sass/statement/variable_declaration.dart b/lib/src/ast/sass/statement/variable_declaration.dart index 235a41648..f94619e99 100644 --- a/lib/src/ast/sass/statement/variable_declaration.dart +++ b/lib/src/ast/sass/statement/variable_declaration.dart @@ -21,7 +21,7 @@ import 'silent_comment.dart'; /// This defines or sets a variable. /// /// {@category AST} -final class VariableDeclaration implements Statement, SassDeclaration { +final class VariableDeclaration extends Statement implements SassDeclaration { /// The namespace of the variable being set, or `null` if it's defined or set /// without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/statement/warn_rule.dart b/lib/src/ast/sass/statement/warn_rule.dart index 026f4ca34..ebc175084 100644 --- a/lib/src/ast/sass/statement/warn_rule.dart +++ b/lib/src/ast/sass/statement/warn_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This prints a Sass value—usually a string—to warn the user of something. /// /// {@category AST} -final class WarnRule implements Statement { +final class WarnRule extends Statement { /// The expression to print. final Expression expression; diff --git a/lib/src/async_compile.dart b/lib/src/async_compile.dart index 063f3d2dc..ea701f598 100644 --- a/lib/src/async_compile.dart +++ b/lib/src/async_compile.dart @@ -45,12 +45,13 @@ Future compileAsync(String path, Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) async { - DeprecationProcessingLogger deprecationLogger = logger = - DeprecationProcessingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); // If the syntax is different than the importer would default to, we have to // parse the file manually and we can't store it in the cache. @@ -111,12 +112,13 @@ Future compileStringAsync(String source, Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) async { - DeprecationProcessingLogger deprecationLogger = logger = - DeprecationProcessingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index 6704249d4..91713ca7b 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -16,6 +16,7 @@ import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; import 'logger.dart'; +import 'logger/deprecation_processing.dart'; import 'util/map.dart'; import 'util/nullable.dart'; import 'utils.dart'; @@ -61,7 +62,7 @@ final class AsyncImportCache { <(AsyncImporter, Uri, {bool forImport}), AsyncCanonicalizeResult?>{}; /// A map from the keys in [_perImporterCanonicalizeCache] that are generated - /// for relative URL loads agains the base importer to the original relative + /// for relative URL loads against the base importer to the original relative /// URLs what were loaded. /// /// This is used to invalidate the cache when files are changed. @@ -97,18 +98,18 @@ final class AsyncImportCache { PackageConfig? packageConfig, Logger? logger}) : _importers = _toImporters(importers, loadPaths, packageConfig), - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Creates an import cache without any globally-available importers. AsyncImportCache.none({Logger? logger}) : _importers = const [], - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Creates an import cache without any globally-available importers, and only /// the passed in importers. AsyncImportCache.only(Iterable importers, {Logger? logger}) : _importers = List.unmodifiable(importers), - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. @@ -173,11 +174,11 @@ final class AsyncImportCache { var key = (url, forImport: forImport); if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key]; - // Each indivudal call to a `canonicalize()` override may not be cacheable + // Each individual call to a `canonicalize()` override may not be cacheable // (specifically, if it has access to `containingUrl` it's too // context-sensitive to usefully cache). We want to cache a given URL across // the _entire_ importer chain, so we use [cacheable] to track whether _all_ - // `canonicalize()` calls we've attempted are cacheable. Only if they are do + // `canonicalize()` calls we've attempted are cacheable. Only if they are, do // we store the result in the cache. var cacheable = true; for (var i = 0; i < _importers.length; i++) { @@ -360,4 +361,25 @@ final class AsyncImportCache { _resultsCache.remove(canonicalUrl); _importCache.remove(canonicalUrl); } + + /// Wraps [logger] to process deprecations within an ImportCache. + /// + /// This wrapped logger will handle the deprecation options, but will not + /// limit repetition, as it can be re-used across parses. A logger passed to + /// an ImportCache or AsyncImportCache should generally be wrapped here first, + /// unless it's already been wrapped to process deprecations, in which case + /// this method has no effect. + static DeprecationProcessingLogger wrapLogger( + Logger? logger, + Iterable? silenceDeprecations, + Iterable? fatalDeprecations, + Iterable? futureDeprecations, + {bool color = false}) { + if (logger is DeprecationProcessingLogger) return logger; + return DeprecationProcessingLogger(logger ?? Logger.stderr(color: color), + silenceDeprecations: {...?silenceDeprecations}, + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: false); + } } diff --git a/lib/src/callable.dart b/lib/src/callable.dart index 2d2ed1e26..1fda63247 100644 --- a/lib/src/callable.dart +++ b/lib/src/callable.dart @@ -11,7 +11,8 @@ import 'utils.dart'; import 'value.dart'; export 'callable/async.dart'; -export 'callable/async_built_in.dart' show AsyncBuiltInCallable; +export 'callable/async_built_in.dart' + show AsyncBuiltInCallable, warnForGlobalBuiltIn; export 'callable/built_in.dart' show BuiltInCallable; export 'callable/plain_css.dart'; export 'callable/user_defined.dart'; diff --git a/lib/src/callable/async_built_in.dart b/lib/src/callable/async_built_in.dart index 7dba7e1cd..2c764ee0c 100644 --- a/lib/src/callable/async_built_in.dart +++ b/lib/src/callable/async_built_in.dart @@ -5,6 +5,8 @@ import 'dart:async'; import '../ast/sass.dart'; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../value.dart'; import 'async.dart'; @@ -83,4 +85,22 @@ class AsyncBuiltInCallable implements AsyncCallable { (ArgumentDeclaration, Callback) callbackFor( int positional, Set names) => (_arguments, _callback); + + /// Returns a copy of this callable that emits a deprecation warning. + AsyncBuiltInCallable withDeprecationWarning(String module, + [String? newName]) => + AsyncBuiltInCallable.parsed(name, _arguments, (args) { + warnForGlobalBuiltIn(module, newName ?? name); + return _callback(args); + }, acceptsContent: acceptsContent); +} + +/// Emits a deprecation warning for a global built-in function that is now +/// available as function [name] in built-in module [module]. +void warnForGlobalBuiltIn(String module, String name) { + warnForDeprecation( + 'Global built-in functions will be deprecated in the future.\n' + 'Remove the --future-deprecation=global-builtin flag to silence this ' + 'warning for now.', + Deprecation.globalBuiltin); } diff --git a/lib/src/callable/built_in.dart b/lib/src/callable/built_in.dart index a52662fa0..1d58df9fe 100644 --- a/lib/src/callable/built_in.dart +++ b/lib/src/callable/built_in.dart @@ -123,4 +123,20 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { /// Returns a copy of this callable with the given [name]. BuiltInCallable withName(String name) => BuiltInCallable._(name, _overloads, acceptsContent); + + /// Returns a copy of this callable that emits a deprecation warning. + BuiltInCallable withDeprecationWarning(String module, [String? newName]) => + BuiltInCallable._( + name, + [ + for (var (declaration, function) in _overloads) + ( + declaration, + (args) { + warnForGlobalBuiltIn(module, newName ?? name); + return function(args); + } + ) + ], + acceptsContent); } diff --git a/lib/src/compile.dart b/lib/src/compile.dart index 5a7fe54f2..6854dd68e 100644 --- a/lib/src/compile.dart +++ b/lib/src/compile.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_compile.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: ab2c6fa2588988a86abdbe87512134098e01b39e +// Checksum: 69b31749dc94c7f717e9d395327e4209c4d3feb0 // // ignore_for_file: unused_import @@ -54,12 +54,13 @@ CompileResult compile(String path, Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) { - DeprecationProcessingLogger deprecationLogger = logger = - DeprecationProcessingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); // If the syntax is different than the importer would default to, we have to // parse the file manually and we can't store it in the cache. @@ -120,12 +121,13 @@ CompileResult compileString(String source, Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) { - DeprecationProcessingLogger deprecationLogger = logger = - DeprecationProcessingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index fc5d678b0..097ece323 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -15,7 +15,7 @@ enum Deprecation { // DO NOT EDIT. This section was generated from the language repo. // See tool/grind/generate_deprecations.dart for details. // - // Checksum: b87f31f543f52c97431cce85ef11e92b6a71cfde + // Checksum: 5470e7252641d3eaa7093b072b52e423c3b77375 /// Deprecation for passing a string directly to meta.call(). callString('call-string', @@ -95,6 +95,10 @@ enum Deprecation { deprecatedIn: '1.77.7', description: 'Declarations after or between nested rules.'), + /// Deprecation for meta.feature-exists + featureExists('feature-exists', + deprecatedIn: '1.78.0', description: 'meta.feature-exists'), + /// Deprecation for certain uses of built-in sass:color functions. color4Api('color-4-api', deprecatedIn: '1.79.0', @@ -108,6 +112,11 @@ enum Deprecation { /// Deprecation for @import rules. import.future('import', description: '@import rules.'), + /// Deprecation for global built-in functions that are available in sass: modules. + globalBuiltin.future('global-builtin', + description: + 'Global built-in functions that are available in sass: modules.'), + // END AUTOGENERATED CODE /// Used for deprecations coming from user-authored code. diff --git a/lib/src/embedded/compilation_dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart index 8f69b2553..a8c84c8eb 100644 --- a/lib/src/embedded/compilation_dispatcher.dart +++ b/lib/src/embedded/compilation_dispatcher.dart @@ -51,28 +51,15 @@ final class CompilationDispatcher { /// This is used in outgoing messages. late Uint8List _compilationIdVarint; - /// Whether we detected a [ProtocolError] while parsing an incoming response. - /// - /// If we have, we don't want to send the final compilation result because - /// it'll just be a wrapper around the error. - var _requestError = false; - /// Creates a [CompilationDispatcher] that receives encoded protocol buffers /// through [_mailbox] and sends them through [_sendPort]. CompilationDispatcher(this._mailbox, this._sendPort); /// Listens for incoming `CompileRequests` and runs their compilations. void listen() { - do { - Uint8List packet; - try { - packet = _mailbox.take(); - } on StateError catch (_) { - break; - } - + while (true) { try { - var (compilationId, messageBuffer) = parsePacket(packet); + var (compilationId, messageBuffer) = parsePacket(_receive()); _compilationId = compilationId; _compilationIdVarint = serializeVarint(compilationId); @@ -88,9 +75,7 @@ final class CompilationDispatcher { case InboundMessage_Message.compileRequest: var request = message.compileRequest; var response = _compile(request); - if (!_requestError) { - _send(OutboundMessage()..compileResponse = response); - } + _send(OutboundMessage()..compileResponse = response); case InboundMessage_Message.versionRequest: throw paramsError("VersionRequest must have compilation ID 0."); @@ -113,7 +98,7 @@ final class CompilationDispatcher { } catch (error, stackTrace) { _handleError(error, stackTrace); } - } while (!_requestError); + } } OutboundMessage_CompileResponse _compile( @@ -287,20 +272,13 @@ final class CompilationDispatcher { void sendLog(OutboundMessage_LogEvent event) => _send(OutboundMessage()..logEvent = event); - /// Sends [error] to the host. + /// Sends [error] to the host and exit. /// /// This is used during compilation by other classes like host callable. - /// Therefore it must set _requestError = true to prevent sending a CompileFailure after - /// sending a ProtocolError. - void sendError(ProtocolError error) { - _sendError(error); - _requestError = true; + Never sendError(ProtocolError error) { + Isolate.exit(_sendPort, _serializePacket(OutboundMessage()..error = error)); } - /// Sends [error] to the host. - void _sendError(ProtocolError error) => - _send(OutboundMessage()..error = error); - InboundMessage_CanonicalizeResponse sendCanonicalizeRequest( OutboundMessage_CanonicalizeRequest request) => _sendRequest( @@ -326,19 +304,9 @@ final class CompilationDispatcher { message.id = _outboundRequestId; _send(message); - Uint8List packet; - try { - packet = _mailbox.take(); - } on StateError catch (_) { - // Compiler is shutting down, throw without calling `_handleError` as we - // don't want to report this as an actual error. - _requestError = true; - rethrow; - } - try { var messageBuffer = - Uint8List.sublistView(packet, _compilationIdVarint.length); + Uint8List.sublistView(_receive(), _compilationIdVarint.length); InboundMessage message; try { @@ -376,8 +344,6 @@ final class CompilationDispatcher { return response; } catch (error, stackTrace) { _handleError(error, stackTrace); - _requestError = true; - rethrow; } } @@ -385,12 +351,17 @@ final class CompilationDispatcher { /// /// The [messageId] indicate the IDs of the message being responded to, if /// available. - void _handleError(Object error, StackTrace stackTrace, {int? messageId}) { - _sendError(handleError(error, stackTrace, messageId: messageId)); + Never _handleError(Object error, StackTrace stackTrace, {int? messageId}) { + sendError(handleError(error, stackTrace, messageId: messageId)); } /// Sends [message] to the host with the given [wireId]. void _send(OutboundMessage message) { + _sendPort.send(_serializePacket(message)); + } + + /// Serialize [message] to [Uint8List]. + Uint8List _serializePacket(OutboundMessage message) { var protobufWriter = CodedBufferWriter(); message.writeToCodedBufferWriter(protobufWriter); @@ -407,6 +378,17 @@ final class CompilationDispatcher { }; packet.setAll(1, _compilationIdVarint); protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length); - _sendPort.send(packet); + return packet; + } + + /// Receive a packet from the host. + Uint8List _receive() { + try { + return _mailbox.take(); + } on StateError catch (_) { + // The [_mailbox] has been closed, exit the current isolate immediately + // to avoid bubble the error up as [SassException] during [_sendRequest]. + Isolate.exit(); + } } } diff --git a/lib/src/embedded/host_callable.dart b/lib/src/embedded/host_callable.dart index 06c3acb66..95e15221a 100644 --- a/lib/src/embedded/host_callable.dart +++ b/lib/src/embedded/host_callable.dart @@ -53,7 +53,6 @@ Callable hostCallable( } } on ProtocolError catch (error, stackTrace) { dispatcher.sendError(handleError(error, stackTrace)); - throw error.message; } }); return callable; diff --git a/lib/src/embedded/isolate_dispatcher.dart b/lib/src/embedded/isolate_dispatcher.dart index b208cc6b3..fe79f034c 100644 --- a/lib/src/embedded/isolate_dispatcher.dart +++ b/lib/src/embedded/isolate_dispatcher.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:ffi'; +import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; @@ -27,13 +28,13 @@ class IsolateDispatcher { /// All isolates that have been spawned to dispatch to. /// /// Only used for cleaning up the process when the underlying channel closes. - final _allIsolates = >[]; + final _allIsolates = StreamController(sync: true); /// The isolates that aren't currently running compilations final _inactiveIsolates = {}; /// A map from active compilationIds to isolates running those compilations. - final _activeIsolates = {}; + final _activeIsolates = >{}; /// A pool controlling how many isolates (and thus concurrent compilations) /// may be live at once. @@ -43,6 +44,9 @@ class IsolateDispatcher { /// See https://github.com/sass/dart-sass/pull/2019 final _isolatePool = Pool(sizeOf() <= 4 ? 7 : 15); + /// Whether [_channel] has been closed or not. + var _closed = false; + IsolateDispatcher(this._channel); void listen() { @@ -54,8 +58,12 @@ class IsolateDispatcher { (compilationId, messageBuffer) = parsePacket(packet); if (compilationId != 0) { - var isolate = _activeIsolates[compilationId] ?? - await _getIsolate(compilationId); + var isolate = await _activeIsolates.putIfAbsent( + compilationId, () => _getIsolate(compilationId!)); + + // The shutdown may have started by the time the isolate is spawned + if (_closed) return; + try { isolate.send(packet); return; @@ -87,10 +95,9 @@ class IsolateDispatcher { } }, onError: (Object error, StackTrace stackTrace) { _handleError(error, stackTrace); - }, onDone: () async { - for (var isolate in _allIsolates) { - (await isolate).kill(); - } + }, onDone: () { + _closed = true; + _allIsolates.stream.listen((isolate) => isolate.kill()); }); } @@ -105,27 +112,40 @@ class IsolateDispatcher { isolate = _inactiveIsolates.first; _inactiveIsolates.remove(isolate); } else { - var future = ReusableIsolate.spawn(_isolateMain); - _allIsolates.add(future); + var future = ReusableIsolate.spawn(_isolateMain, + onError: (Object error, StackTrace stackTrace) { + _handleError(error, stackTrace); + }); isolate = await future; + _allIsolates.add(isolate); } - _activeIsolates[compilationId] = isolate; - isolate.checkOut().listen(_channel.sink.add, - onError: (Object error, StackTrace stackTrace) { - if (error is ProtocolError) { - // Protocol errors have already been through [_handleError] in the child - // isolate, so we just send them as-is and close out the underlying - // channel. - sendError(compilationId, error); - _channel.sink.close(); - } else { - _handleError(error, stackTrace); + isolate.borrow((message) { + var fullBuffer = message as Uint8List; + + // The first byte of messages from isolates indicates whether the entire + // compilation is finished (1) or if it encountered an error (2). Sending + // this as part of the message buffer rather than a separate message + // avoids a race condition where the host might send a new compilation + // request with the same ID as one that just finished before the + // [IsolateDispatcher] receives word that the isolate with that ID is + // done. See sass/dart-sass#2004. + var category = fullBuffer[0]; + var packet = Uint8List.sublistView(fullBuffer, 1); + + switch (category) { + case 0: + _channel.sink.add(packet); + case 1: + _activeIsolates.remove(compilationId); + isolate.release(); + _inactiveIsolates.add(isolate); + resource.release(); + _channel.sink.add(packet); + case 2: + _channel.sink.add(packet); + exit(exitCode); } - }, onDone: () { - _activeIsolates.remove(compilationId); - _inactiveIsolates.add(isolate); - resource.release(); }); return isolate; diff --git a/lib/src/embedded/reusable_isolate.dart b/lib/src/embedded/reusable_isolate.dart index 5cdd6bbd8..4140f5a37 100644 --- a/lib/src/embedded/reusable_isolate.dart +++ b/lib/src/embedded/reusable_isolate.dart @@ -8,18 +8,16 @@ import 'dart:typed_data'; import 'package:native_synchronization/mailbox.dart'; import 'package:native_synchronization/sendable.dart'; -import 'embedded_sass.pb.dart'; -import 'utils.dart'; /// The entrypoint for a [ReusableIsolate]. /// /// This must be a static global function. It's run when the isolate is spawned, /// and is passed a [Mailbox] that receives messages from [ReusableIsolate.send] -/// and a [SendPort] that sends messages to the stream returned by -/// [ReusableIsolate.checkOut]. +/// and a [SendPort] that sends messages to the [ReceivePort] listened by +/// [ReusableIsolate.borrow]. /// -/// If the [sendPort] sends a message before [ReusableIsolate.checkOut] is -/// called, this will throw an unhandled [StateError]. +/// If the [sendPort] sends a message before [ReusableIsolate.borrow] is called, +/// this will throw an unhandled [StateError]. typedef ReusableIsolateEntryPoint = FutureOr Function( Mailbox mailbox, SendPort sink); @@ -33,104 +31,70 @@ class ReusableIsolate { /// The [ReceivePort] that receives messages from the wrapped isolate. final ReceivePort _receivePort; - /// The subscription to [_port]. - final StreamSubscription _subscription; + /// The subscription to [_receivePort]. + final StreamSubscription _subscription; - /// Whether [checkOut] has been called and the returned stream has not yet - /// closed. - bool _checkedOut = false; + /// Whether the current isolate has been borrowed. + bool _borrowed = false; - ReusableIsolate._(this._isolate, this._mailbox, this._receivePort) - : _subscription = _receivePort.listen(_defaultOnData); + ReusableIsolate._(this._isolate, this._mailbox, this._receivePort, + {Function? onError}) + : _subscription = _receivePort.listen(_defaultOnData, onError: onError); /// Spawns a [ReusableIsolate] that runs the given [entryPoint]. - static Future spawn( - ReusableIsolateEntryPoint entryPoint) async { + static Future spawn(ReusableIsolateEntryPoint entryPoint, + {Function? onError}) async { var mailbox = Mailbox(); var receivePort = ReceivePort(); var isolate = await Isolate.spawn( _isolateMain, (entryPoint, mailbox.asSendable, receivePort.sendPort)); - return ReusableIsolate._(isolate, mailbox, receivePort); + return ReusableIsolate._(isolate, mailbox, receivePort, onError: onError); } - /// Checks out this isolate and returns a stream of messages from it. - /// - /// This isolate is considered "checked out" until the returned stream - /// completes. While checked out, messages may be sent to the isolate using - /// [send]. - /// - /// Throws a [StateError] if this is called while the isolate is already - /// checked out. - Stream checkOut() { - if (_checkedOut) { - throw StateError( - "Can't call ResuableIsolate.checkOut until the previous stream has " - "completed."); + /// Subscribe to messages from [_receivePort]. + void borrow(void onData(dynamic event)?) { + if (_borrowed) { + throw StateError('ReusableIsolate has already been borrowed.'); + } + _borrowed = true; + _subscription.onData(onData); + } + + /// Unsubscribe to messages from [_receivePort]. + void release() { + if (!_borrowed) { + throw StateError('ReusableIsolate has not been borrowed.'); } - _checkedOut = true; - - var controller = StreamController(sync: true); - - _subscription.onData((message) { - var fullBuffer = message as Uint8List; - - // The first byte of messages from isolates indicates whether the entire - // compilation is finished (1) or if it encountered an error (2). Sending - // this as part of the message buffer rather than a separate message - // avoids a race condition where the host might send a new compilation - // request with the same ID as one that just finished before the - // [IsolateDispatcher] receives word that the isolate with that ID is - // done. See sass/dart-sass#2004. - var category = fullBuffer[0]; - var packet = Uint8List.sublistView(fullBuffer, 1); - - if (category == 2) { - // Parse out the compilation ID and surface the [ProtocolError] as an - // error. This allows the [IsolateDispatcher] to notice that an error - // has occurred and close out the underlying channel. - var (_, buffer) = parsePacket(packet); - controller.addError(OutboundMessage.fromBuffer(buffer).error); - return; - } - - controller.sink.add(packet); - if (category == 1) { - _checkedOut = false; - _subscription.onData(_defaultOnData); - _subscription.onError(null); - controller.close(); - } - }); - - _subscription.onError(controller.addError); - - return controller.stream; + _borrowed = false; + _subscription.onData(_defaultOnData); } /// Sends [message] to the isolate. /// - /// Throws a [StateError] if this is called while the isolate isn't checked - /// out, or if a second message is sent before the isolate has processed the - /// first one. + /// Throws a [StateError] if this is called while the isolate isn't borrowed, + /// or if a second message is sent before the isolate has processed the first + /// one. void send(Uint8List message) { + if (!_borrowed) { + throw StateError('Cannot send a message before being borrowed.'); + } _mailbox.put(message); } /// Shuts down the isolate. void kill() { - _isolate.kill(); - _receivePort.close(); - // If the isolate is blocking on [Mailbox.take], it won't even process a // kill event, so we closed the mailbox to nofity and wake it up. _mailbox.close(); + _isolate.kill(priority: Isolate.immediate); + _receivePort.close(); } } /// The default handler for data events from the wrapped isolate when it's not -/// checked out. -void _defaultOnData(Object? _) { - throw StateError("Shouldn't receive a message before being checked out."); +/// borrowed. +void _defaultOnData(dynamic _) { + throw StateError("Shouldn't receive a message before being borrowed."); } void _isolateMain( diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index 3dbf3dbe0..0993e63e2 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -97,7 +97,11 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, var importCache = AsyncImportCache( importers: options.pkgImporters, loadPaths: options.loadPaths, - logger: options.logger); + logger: AsyncImportCache.wrapLogger( + options.logger, + options.silenceDeprecations, + options.fatalDeprecations, + options.futureDeprecations)); result = source == null ? await compileStringAsync(await readStdin(), diff --git a/lib/src/executable/repl.dart b/lib/src/executable/repl.dart index f79e2de33..ad9ad5ecd 100644 --- a/lib/src/executable/repl.dart +++ b/lib/src/executable/repl.dart @@ -26,7 +26,8 @@ Future repl(ExecutableOptions options) async { silenceDeprecations: options.silenceDeprecations, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations, - limitRepetition: !options.verbose); + limitRepetition: !options.verbose) + ..validate(); var evaluator = Evaluator( importer: FilesystemImporter.cwd, importCache: ImportCache( diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 9c4467453..22548aa7f 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -31,10 +31,13 @@ const _specialCommaSpaces = {ColorSpace.rgb, ColorSpace.hsl}; /// The global definitions of Sass color functions. final global = UnmodifiableListView([ // ### RGB - _channelFunction("red", (color) => color.red, global: true), - _channelFunction("green", (color) => color.green, global: true), - _channelFunction("blue", (color) => color.blue, global: true), - _mix, + _channelFunction("red", (color) => color.red, global: true) + .withDeprecationWarning("color"), + _channelFunction("green", (color) => color.green, global: true) + .withDeprecationWarning("color"), + _channelFunction("blue", (color) => color.blue, global: true) + .withDeprecationWarning("color"), + _mix.withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("rgb", { r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgb", arguments), @@ -53,14 +56,18 @@ final global = UnmodifiableListView([ }), _function("invert", r"$color, $weight: 100%, $space: null", - (arguments) => _invert(arguments, global: true)), + (arguments) => _invert(arguments, global: true)) + .withDeprecationWarning("color"), // ### HSL - _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true), + _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true) + .withDeprecationWarning("color"), _channelFunction("saturation", (color) => color.saturation, - unit: '%', global: true), + unit: '%', global: true) + .withDeprecationWarning("color"), _channelFunction("lightness", (color) => color.lightness, - unit: '%', global: true), + unit: '%', global: true) + .withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -94,13 +101,16 @@ final global = UnmodifiableListView([ space: ColorSpace.hsl, name: 'channels') }), - _function( - "grayscale", - r"$color", - (arguments) => arguments[0] is SassNumber || arguments[0].isSpecialNumber - // Use the native CSS `grayscale` filter function. - ? _functionString('grayscale', arguments) - : _grayscale(arguments[0])), + _function("grayscale", r"$color", (arguments) { + if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) { + // Use the native CSS `grayscale` filter function. + return _functionString('grayscale', arguments); + } else { + warnForGlobalBuiltIn('color', 'grayscale'); + + return _grayscale(arguments[0]); + } + }), _function("adjust-hue", r"$color, $degrees", (arguments) { var color = arguments[0].assertColor("color"); @@ -122,7 +132,7 @@ final global = UnmodifiableListView([ Deprecation.colorFunctions); return color.changeHsl(hue: color.hue + degrees); - }), + }).withDeprecationWarning('color', 'adjust'), _function("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); @@ -144,7 +154,7 @@ final global = UnmodifiableListView([ "More info: https://sass-lang.com/d/color-functions", Deprecation.colorFunctions); return result; - }), + }).withDeprecationWarning('color', 'adjust'), _function("darken", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); @@ -166,7 +176,7 @@ final global = UnmodifiableListView([ "More info: https://sass-lang.com/d/color-functions", Deprecation.colorFunctions); return result; - }), + }).withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("saturate", { r"$amount": (arguments) { @@ -178,6 +188,7 @@ final global = UnmodifiableListView([ return SassString("saturate(${number.toCssString()})", quotes: false); }, r"$color, $amount": (arguments) { + warnForGlobalBuiltIn('color', 'adjust'); var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); if (!color.isLegacy) { @@ -222,29 +233,38 @@ final global = UnmodifiableListView([ "More info: https://sass-lang.com/d/color-functions", Deprecation.colorFunctions); return result; - }), + }).withDeprecationWarning('color', 'adjust'), // ### Opacity _function("opacify", r"$color, $amount", - (arguments) => _opacify("opacify", arguments)), + (arguments) => _opacify("opacify", arguments)) + .withDeprecationWarning('color', 'adjust'), _function("fade-in", r"$color, $amount", - (arguments) => _opacify("fade-in", arguments)), + (arguments) => _opacify("fade-in", arguments)) + .withDeprecationWarning('color', 'adjust'), _function("transparentize", r"$color, $amount", - (arguments) => _transparentize("transparentize", arguments)), + (arguments) => _transparentize("transparentize", arguments)) + .withDeprecationWarning('color', 'adjust'), _function("fade-out", r"$color, $amount", - (arguments) => _transparentize("fade-out", arguments)), + (arguments) => _transparentize("fade-out", arguments)) + .withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("alpha", { - r"$color": (arguments) => switch (arguments[0]) { - // Support the proprietary Microsoft alpha() function. - SassString(hasQuotes: false, :var text) - when text.contains(_microsoftFilterStart) => - _functionString("alpha", arguments), - SassColor(isLegacy: false) => throw SassScriptException( + r"$color": (arguments) { + switch (arguments[0]) { + // Support the proprietary Microsoft alpha() function. + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + return _functionString("alpha", arguments); + case SassColor(isLegacy: false): + throw SassScriptException( "alpha() is only supported for legacy colors. Please use " - "color.channel() instead."), - var argument => SassNumber(argument.assertColor("color").alpha) - }, + "color.channel() instead."); + case var argument: + warnForGlobalBuiltIn('color', 'alpha'); + SassNumber(argument.assertColor("color").alpha); + } + }, r"$args...": (arguments) { var argList = arguments[0].asList; if (argList.isNotEmpty && @@ -272,6 +292,7 @@ final global = UnmodifiableListView([ return _functionString("opacity", arguments); } + warnForGlobalBuiltIn('color', 'opacity'); var color = arguments[0].assertColor("color"); return SassNumber(color.alpha); }), @@ -314,13 +335,13 @@ final global = UnmodifiableListView([ (arguments) => _parseChannels("oklch", arguments[0], space: ColorSpace.oklch, name: 'channels')), - _complement, + _complement.withDeprecationWarning("color"), // ### Miscellaneous _ieHexStr, - _adjust.withName("adjust-color"), - _scale.withName("scale-color"), - _change.withName("change-color") + _adjust.withDeprecationWarning('color').withName("adjust-color"), + _scale.withDeprecationWarning('color').withName("scale-color"), + _change.withDeprecationWarning('color').withName("change-color") ]); /// The Sass color module. diff --git a/lib/src/functions/list.dart b/lib/src/functions/list.dart index 1d911a88d..c0fd0ac2c 100644 --- a/lib/src/functions/list.dart +++ b/lib/src/functions/list.dart @@ -13,8 +13,15 @@ import '../value.dart'; /// The global definitions of Sass list functions. final global = UnmodifiableListView([ - _length, _nth, _setNth, _join, _append, _zip, _index, _isBracketed, // - _separator.withName("list-separator") + _length.withDeprecationWarning('list'), + _nth.withDeprecationWarning('list'), + _setNth.withDeprecationWarning('list'), + _join.withDeprecationWarning('list'), + _append.withDeprecationWarning('list'), + _zip.withDeprecationWarning('list'), + _index.withDeprecationWarning('list'), + _isBracketed.withDeprecationWarning('list'), + _separator.withDeprecationWarning('list').withName("list-separator") ]); /// The Sass list module. diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart index 97628c1fd..099f20a72 100644 --- a/lib/src/functions/map.dart +++ b/lib/src/functions/map.dart @@ -15,12 +15,12 @@ import '../value.dart'; /// The global definitions of Sass map functions. final global = UnmodifiableListView([ - _get.withName("map-get"), - _merge.withName("map-merge"), - _remove.withName("map-remove"), - _keys.withName("map-keys"), - _values.withName("map-values"), - _hasKey.withName("map-has-key") + _get.withDeprecationWarning('map').withName("map-get"), + _merge.withDeprecationWarning('map').withName("map-merge"), + _remove.withDeprecationWarning('map').withName("map-remove"), + _keys.withDeprecationWarning('map').withName("map-keys"), + _values.withDeprecationWarning('map').withName("map-values"), + _hasKey.withDeprecationWarning('map').withName("map-has-key") ]); /// The Sass map module. @@ -61,7 +61,15 @@ final _set = BuiltInCallable.overloadedFunction("set", { throw SassScriptException("Expected \$args to contain a value."); case [...var keys, var value]: return _modify(map, keys, (_) => value); - default: + default: // ignore: unreachable_switch_default + // This code is unreachable, and the compiler knows it (hence the + // `unreachable_switch_default` warning being ignored above). However, + // due to architectural limitations in the Dart front end, the compiler + // doesn't understand that the code is unreachable until late in the + // compilation process (after flow analysis). So this `default` clause + // must be kept around to avoid flow analysis incorrectly concluding + // that the function fails to return. See + // https://github.com/dart-lang/language/issues/2977 for details. throw '[BUG] Unreachable code'; } }, @@ -87,7 +95,15 @@ final _merge = BuiltInCallable.overloadedFunction("merge", { if (nestedMap == null) return map2; return SassMap({...nestedMap.contents, ...map2.contents}); }); - default: + default: // ignore: unreachable_switch_default + // This code is unreachable, and the compiler knows it (hence the + // `unreachable_switch_default` warning being ignored above). However, + // due to architectural limitations in the Dart front end, the compiler + // doesn't understand that the code is unreachable until late in the + // compilation process (after flow analysis). So this `default` clause + // must be kept around to avoid flow analysis incorrectly concluding + // that the function fails to return. See + // https://github.com/dart-lang/language/issues/2977 for details. throw '[BUG] Unreachable code'; } }, diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart index bca609d0d..3f3e3bbbe 100644 --- a/lib/src/functions/math.dart +++ b/lib/src/functions/math.dart @@ -30,15 +30,23 @@ final global = UnmodifiableListView([ "To emit a CSS abs() now: abs(#{$number})\n" "More info: https://sass-lang.com/d/abs-percent", Deprecation.absPercent); + } else { + warnForGlobalBuiltIn('math', 'abs'); } return SassNumber.withUnits(number.value.abs(), numeratorUnits: number.numeratorUnits, denominatorUnits: number.denominatorUnits); }), - - _ceil, _floor, _max, _min, _percentage, _randomFunction, _round, _unit, // - _compatible.withName("comparable"), - _isUnitless.withName("unitless"), + _ceil.withDeprecationWarning('math'), + _floor.withDeprecationWarning('math'), + _max.withDeprecationWarning('math'), + _min.withDeprecationWarning('math'), + _percentage.withDeprecationWarning('math'), + _randomFunction.withDeprecationWarning('math'), + _round.withDeprecationWarning('math'), + _unit.withDeprecationWarning('math'), + _compatible.withDeprecationWarning('math').withName("comparable"), + _isUnitless.withDeprecationWarning('math').withName("unitless"), ]); /// The Sass math module. diff --git a/lib/src/functions/meta.dart b/lib/src/functions/meta.dart index 6c7c03e12..31e55433a 100644 --- a/lib/src/functions/meta.dart +++ b/lib/src/functions/meta.dart @@ -8,6 +8,8 @@ import 'package:collection/collection.dart'; import '../ast/sass/statement/mixin_rule.dart'; import '../callable.dart'; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../util/map.dart'; import '../value.dart'; import '../visitor/serialize.dart'; @@ -21,22 +23,25 @@ final _features = { "custom-property" }; -/// The global definitions of Sass introspection functions. -final global = UnmodifiableListView([ +/// Sass introspection functions that exist as both global functions and in the +/// `sass:meta` module that do not require access to context that's only +/// available at runtime. +/// +/// Additional functions are defined in the evaluator. +final _shared = UnmodifiableListView([ // This is only a partial list of meta functions. The rest are defined in the // evaluator, because they need access to context that's only available at // runtime. _function("feature-exists", r"$feature", (arguments) { + warnForDeprecation( + "The feature-exists() function is deprecated.\n\n" + "More info: https://sass-lang.com/d/feature-exists", + Deprecation.featureExists); var feature = arguments[0].assertString("feature"); return SassBoolean(_features.contains(feature.text)); }), - - _function( - "inspect", - r"$value", - (arguments) => SassString(serializeValue(arguments.first, inspect: true), - quotes: false)), - + _function("inspect", r"$value", + (arguments) => SassString(arguments.first.toString(), quotes: false)), _function( "type-of", r"$value", @@ -56,7 +61,6 @@ final global = UnmodifiableListView([ _ => throw "[BUG] Unknown value type ${arguments[0]}" }, quotes: false)), - _function("keywords", r"$args", (arguments) { if (arguments[0] case SassArgumentList(:var keywords)) { return SassMap({ @@ -69,9 +73,14 @@ final global = UnmodifiableListView([ }) ]); -/// The definitions of Sass introspection functions that are only available from -/// the `sass:meta` module, not as global functions. -final local = UnmodifiableListView([ +/// The global definitions of Sass introspection functions. +final global = UnmodifiableListView( + [for (var function in _shared) function.withDeprecationWarning('meta')]); + +/// The versions of Sass introspection functions defined in the `sass:meta` +/// module. +final moduleFunctions = UnmodifiableListView([ + ..._shared, _function("calc-name", r"$calc", (arguments) { var calculation = arguments[0].assertCalculation("calc"); return SassString(calculation.name); diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart index 08e133dbf..7a32af1a2 100644 --- a/lib/src/functions/selector.dart +++ b/lib/src/functions/selector.dart @@ -16,14 +16,14 @@ import '../value.dart'; /// The global definitions of Sass selector functions. final global = UnmodifiableListView([ - _isSuperselector, - _simpleSelectors, - _parse.withName("selector-parse"), - _nest.withName("selector-nest"), - _append.withName("selector-append"), - _extend.withName("selector-extend"), - _replace.withName("selector-replace"), - _unify.withName("selector-unify") + _isSuperselector.withDeprecationWarning('selector'), + _simpleSelectors.withDeprecationWarning('selector'), + _parse.withDeprecationWarning('selector').withName("selector-parse"), + _nest.withDeprecationWarning('selector').withName("selector-nest"), + _append.withDeprecationWarning('selector').withName("selector-append"), + _extend.withDeprecationWarning('selector').withName("selector-extend"), + _replace.withDeprecationWarning('selector').withName("selector-replace"), + _unify.withDeprecationWarning('selector').withName("selector-unify") ]); /// The Sass selector module. diff --git a/lib/src/functions/string.dart b/lib/src/functions/string.dart index 99d1ee6d2..100337de9 100644 --- a/lib/src/functions/string.dart +++ b/lib/src/functions/string.dart @@ -22,11 +22,15 @@ var _previousUniqueId = _random.nextInt(math.pow(36, 6) as int); /// The global definitions of Sass string functions. final global = UnmodifiableListView([ - _unquote, _quote, _toUpperCase, _toLowerCase, _uniqueId, // - _length.withName("str-length"), - _insert.withName("str-insert"), - _index.withName("str-index"), - _slice.withName("str-slice") + _unquote.withDeprecationWarning('string'), + _quote.withDeprecationWarning('string'), + _toUpperCase.withDeprecationWarning('string'), + _toLowerCase.withDeprecationWarning('string'), + _uniqueId.withDeprecationWarning('string'), + _length.withDeprecationWarning('string').withName("str-length"), + _insert.withDeprecationWarning('string').withName("str-insert"), + _index.withDeprecationWarning('string').withName("str-index"), + _slice.withDeprecationWarning('string').withName("str-slice") ]); /// The Sass string module. diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index 6b46bce21..f718e5d40 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: f70eea612e1613ef93bad353803ad9479cda04aa +// Checksum: e043f2a3dafb741a6f053a7c1785b1c9959242f5 // // ignore_for_file: unused_import @@ -23,6 +23,7 @@ import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; import 'logger.dart'; +import 'logger/deprecation_processing.dart'; import 'util/map.dart'; import 'util/nullable.dart'; import 'utils.dart'; @@ -63,7 +64,7 @@ final class ImportCache { <(Importer, Uri, {bool forImport}), CanonicalizeResult?>{}; /// A map from the keys in [_perImporterCanonicalizeCache] that are generated - /// for relative URL loads agains the base importer to the original relative + /// for relative URL loads against the base importer to the original relative /// URLs what were loaded. /// /// This is used to invalidate the cache when files are changed. @@ -98,18 +99,18 @@ final class ImportCache { PackageConfig? packageConfig, Logger? logger}) : _importers = _toImporters(importers, loadPaths, packageConfig), - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Creates an import cache without any globally-available importers. ImportCache.none({Logger? logger}) : _importers = const [], - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Creates an import cache without any globally-available importers, and only /// the passed in importers. ImportCache.only(Iterable importers, {Logger? logger}) : _importers = List.unmodifiable(importers), - _logger = logger ?? const Logger.stderr(); + _logger = logger ?? Logger.stderr(); /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. @@ -171,11 +172,11 @@ final class ImportCache { var key = (url, forImport: forImport); if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key]; - // Each indivudal call to a `canonicalize()` override may not be cacheable + // Each individual call to a `canonicalize()` override may not be cacheable // (specifically, if it has access to `containingUrl` it's too // context-sensitive to usefully cache). We want to cache a given URL across // the _entire_ importer chain, so we use [cacheable] to track whether _all_ - // `canonicalize()` calls we've attempted are cacheable. Only if they are do + // `canonicalize()` calls we've attempted are cacheable. Only if they are, do // we store the result in the cache. var cacheable = true; for (var i = 0; i < _importers.length; i++) { @@ -355,4 +356,25 @@ final class ImportCache { _resultsCache.remove(canonicalUrl); _importCache.remove(canonicalUrl); } + + /// Wraps [logger] to process deprecations within an ImportCache. + /// + /// This wrapped logger will handle the deprecation options, but will not + /// limit repetition, as it can be re-used across parses. A logger passed to + /// an ImportCache or AsyncImportCache should generally be wrapped here first, + /// unless it's already been wrapped to process deprecations, in which case + /// this method has no effect. + static DeprecationProcessingLogger wrapLogger( + Logger? logger, + Iterable? silenceDeprecations, + Iterable? fatalDeprecations, + Iterable? futureDeprecations, + {bool color = false}) { + if (logger is DeprecationProcessingLogger) return logger; + return DeprecationProcessingLogger(logger ?? Logger.stderr(color: color), + silenceDeprecations: {...?silenceDeprecations}, + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: false); + } } diff --git a/lib/src/importer/filesystem.dart b/lib/src/importer/filesystem.dart index cb23d3095..0c57c82d8 100644 --- a/lib/src/importer/filesystem.dart +++ b/lib/src/importer/filesystem.dart @@ -60,7 +60,7 @@ class FilesystemImporter extends Importer { "Use FilesystemImporter.noLoadPath or FilesystemImporter('.') instead.") static final cwd = FilesystemImporter._deprecated('.'); - /// Creates an importer that _only_ loads absolute `file:` URLsand URLs + /// Creates an importer that _only_ loads absolute `file:` URLs and URLs /// relative to the current file. static final noLoadPath = FilesystemImporter._noLoadPath(); diff --git a/lib/src/js.dart b/lib/src/js.dart index dc0384bc4..0dd47686f 100644 --- a/lib/src/js.dart +++ b/lib/src/js.dart @@ -14,6 +14,7 @@ import 'js/legacy.dart'; import 'js/legacy/types.dart'; import 'js/legacy/value.dart'; import 'js/logger.dart'; +import 'js/parser.dart'; import 'js/source_span.dart'; import 'js/utils.dart'; import 'js/value.dart'; @@ -58,6 +59,7 @@ void main() { exports.NodePackageImporter = nodePackageImporterClass; exports.deprecations = jsify(deprecations); exports.Version = versionClass; + exports.loadParserExports_ = allowInterop(loadParserExports); exports.info = "dart-sass\t${const String.fromEnvironment('version')}\t(Sass Compiler)\t" diff --git a/lib/src/js/compile.dart b/lib/src/js/compile.dart index ab5c57cc0..50016334e 100644 --- a/lib/src/js/compile.dart +++ b/lib/src/js/compile.dart @@ -7,9 +7,8 @@ import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart' hide futureToPromise; import 'package:term_glyph/term_glyph.dart' as glyph; import 'package:path/path.dart' as p; -import 'package:pub_semver/pub_semver.dart'; -import '../../sass.dart'; +import '../../sass.dart' hide Deprecation; import '../importer/no_op.dart'; import '../importer/js_to_dart/async.dart'; import '../importer/js_to_dart/async_file.dart'; @@ -21,7 +20,7 @@ import '../logger/js_to_dart.dart'; import '../util/nullable.dart'; import 'compile_options.dart'; import 'compile_result.dart'; -import 'deprecations.dart' as js show Deprecation; +import 'deprecations.dart'; import 'exception.dart'; import 'importer.dart'; import 'reflection.dart'; @@ -51,12 +50,12 @@ NodeCompileResult compile(String path, [CompileOptions? options]) { logger: logger, importers: options?.importers?.map(_parseImporter), functions: _parseFunctions(options?.functions).cast(), - fatalDeprecations: _parseDeprecations( - logger, options?.fatalDeprecations, supportVersions: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), silenceDeprecations: - _parseDeprecations(logger, options?.silenceDeprecations), + parseDeprecations(logger, options?.silenceDeprecations), futureDeprecations: - _parseDeprecations(logger, options?.futureDeprecations)); + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); } on SassException catch (error, stackTrace) { @@ -89,12 +88,12 @@ NodeCompileResult compileString(String text, [CompileStringOptions? options]) { importer: options?.importer.andThen(_parseImporter) ?? (options?.url == null ? NoOpImporter() : null), functions: _parseFunctions(options?.functions).cast(), - fatalDeprecations: _parseDeprecations( - logger, options?.fatalDeprecations, supportVersions: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), silenceDeprecations: - _parseDeprecations(logger, options?.silenceDeprecations), + parseDeprecations(logger, options?.silenceDeprecations), futureDeprecations: - _parseDeprecations(logger, options?.futureDeprecations)); + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); } on SassException catch (error, stackTrace) { @@ -127,12 +126,12 @@ Promise compileAsync(String path, [CompileOptions? options]) { importers: options?.importers ?.map((importer) => _parseAsyncImporter(importer)), functions: _parseFunctions(options?.functions, asynch: true), - fatalDeprecations: _parseDeprecations( - logger, options?.fatalDeprecations, supportVersions: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), silenceDeprecations: - _parseDeprecations(logger, options?.silenceDeprecations), + parseDeprecations(logger, options?.silenceDeprecations), futureDeprecations: - _parseDeprecations(logger, options?.futureDeprecations)); + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); }()), color: color, ascii: ascii); @@ -165,12 +164,12 @@ Promise compileStringAsync(String text, [CompileStringOptions? options]) { .andThen((importer) => _parseAsyncImporter(importer)) ?? (options?.url == null ? NoOpImporter() : null), functions: _parseFunctions(options?.functions, asynch: true), - fatalDeprecations: _parseDeprecations( - logger, options?.fatalDeprecations, supportVersions: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), silenceDeprecations: - _parseDeprecations(logger, options?.silenceDeprecations), + parseDeprecations(logger, options?.silenceDeprecations), futureDeprecations: - _parseDeprecations(logger, options?.futureDeprecations)); + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); }()), color: color, ascii: ascii); @@ -359,39 +358,6 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { return result; } -/// Parses a list of [deprecations] from JS into an list of Dart [Deprecation] -/// objects. -/// -/// [deprecations] can contain deprecation IDs, JS Deprecation objects, and -/// (if [supportVersions] is true) [Version]s. -Iterable? _parseDeprecations( - JSToDartLogger logger, List? deprecations, - {bool supportVersions = false}) { - if (deprecations == null) return null; - return () sync* { - for (var item in deprecations) { - switch (item) { - case String id: - var deprecation = Deprecation.fromId(id); - if (deprecation == null) { - logger.warn('Invalid deprecation "$id".'); - } else { - yield deprecation; - } - case js.Deprecation(:var id): - var deprecation = Deprecation.fromId(id); - if (deprecation == null) { - logger.warn('Invalid deprecation "$id".'); - } else { - yield deprecation; - } - case Version version when supportVersions: - yield* Deprecation.forVersion(version); - } - } - }(); -} - /// The exported `NodePackageImporter` class that can be added to the /// `importers` option to enable loading `pkg:` URLs from `node_modules`. final JSClass nodePackageImporterClass = () { diff --git a/lib/src/js/deprecations.dart b/lib/src/js/deprecations.dart index 51e3aec1a..e26fe9ea3 100644 --- a/lib/src/js/deprecations.dart +++ b/lib/src/js/deprecations.dart @@ -6,6 +6,7 @@ import 'package:js/js.dart'; import 'package:pub_semver/pub_semver.dart'; import '../deprecation.dart' as dart show Deprecation; +import '../logger/js_to_dart.dart'; import 'reflection.dart'; @JS() @@ -44,6 +45,39 @@ final Map deprecations = { obsoleteIn: deprecation.deprecatedIn), }; +/// Parses a list of [deprecations] from JS into an list of Dart [Deprecation] +/// objects. +/// +/// [deprecations] can contain deprecation IDs, JS Deprecation objects, and +/// (if [supportVersions] is true) [Version]s. +Iterable? parseDeprecations( + JSToDartLogger logger, List? deprecations, + {bool supportVersions = false}) { + if (deprecations == null) return null; + return () sync* { + for (var item in deprecations) { + switch (item) { + case String id: + var deprecation = dart.Deprecation.fromId(id); + if (deprecation == null) { + logger.warn('Invalid deprecation "$id".'); + } else { + yield deprecation; + } + case Deprecation(:var id): + var deprecation = dart.Deprecation.fromId(id); + if (deprecation == null) { + logger.warn('Invalid deprecation "$id".'); + } else { + yield deprecation; + } + case Version version when supportVersions: + yield* dart.Deprecation.forVersion(version); + } + } + }(); +} + /// The JavaScript `Version` class. final JSClass versionClass = () { var jsClass = createJSClass('sass.Version', diff --git a/lib/src/js/exports.dart b/lib/src/js/exports.dart index e106ad610..225f5fcf7 100644 --- a/lib/src/js/exports.dart +++ b/lib/src/js/exports.dart @@ -55,6 +55,9 @@ class Exports { external set NULL(value.Value sassNull); external set TRUE(value.SassBoolean sassTrue); external set FALSE(value.SassBoolean sassFalse); + + // `sass-parser` APIs + external set loadParserExports_(Function function); } @JS() diff --git a/lib/src/js/legacy.dart b/lib/src/js/legacy.dart index ed4ba7584..65ae4ca48 100644 --- a/lib/src/js/legacy.dart +++ b/lib/src/js/legacy.dart @@ -27,6 +27,7 @@ import '../util/nullable.dart'; import '../utils.dart'; import '../value.dart'; import '../visitor/serialize.dart'; +import 'deprecations.dart'; import 'function.dart'; import 'legacy/render_context.dart'; import 'legacy/render_options.dart'; @@ -76,6 +77,8 @@ Future _renderAsync(RenderOptions options) async { CompileResult result; var file = options.file.andThen(p.absolute); + var logger = + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)); if (options.data case var data?) { result = await compileStringAsync(data, nodeImporter: _parseImporter(options, start), @@ -88,11 +91,16 @@ Future _renderAsync(RenderOptions options) async { lineFeed: _parseLineFeed(options.linefeed), url: file == null ? 'stdin' : p.toUri(file).toString(), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations, + supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: - JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else if (file != null) { result = await compileAsync(file, nodeImporter: _parseImporter(options, start), @@ -104,11 +112,16 @@ Future _renderAsync(RenderOptions options) async { indentWidth: _parseIndentWidth(options.indentWidth), lineFeed: _parseLineFeed(options.linefeed), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations, + supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: - JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else { throw ArgumentError("Either options.data or options.file must be set."); } @@ -131,6 +144,8 @@ RenderResult renderSync(RenderOptions options) { CompileResult result; var file = options.file.andThen(p.absolute); + var logger = + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)); if (options.data case var data?) { result = compileString(data, nodeImporter: _parseImporter(options, start), @@ -143,11 +158,16 @@ RenderResult renderSync(RenderOptions options) { lineFeed: _parseLineFeed(options.linefeed), url: file == null ? 'stdin' : p.toUri(file).toString(), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations( + logger, options.fatalDeprecations, supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: JSToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else if (file != null) { result = compile(file, nodeImporter: _parseImporter(options, start), @@ -159,11 +179,16 @@ RenderResult renderSync(RenderOptions options) { indentWidth: _parseIndentWidth(options.indentWidth), lineFeed: _parseLineFeed(options.linefeed), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations( + logger, options.fatalDeprecations, supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: JSToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else { throw ArgumentError("Either options.data or options.file must be set."); } diff --git a/lib/src/js/legacy/render_options.dart b/lib/src/js/legacy/render_options.dart index ac8cc61b8..e63de45f1 100644 --- a/lib/src/js/legacy/render_options.dart +++ b/lib/src/js/legacy/render_options.dart @@ -30,6 +30,9 @@ class RenderOptions { external bool? get sourceMapEmbed; external String? get sourceMapRoot; external bool? get quietDeps; + external List? get fatalDeprecations; + external List? get futureDeprecations; + external List? get silenceDeprecations; external bool? get verbose; external bool? get charset; external JSLogger? get logger; @@ -54,6 +57,9 @@ class RenderOptions { bool? sourceMapEmbed, String? sourceMapRoot, bool? quietDeps, + List? fatalDeprecations, + List? futureDeprecations, + List? silenceDeprecations, bool? verbose, bool? charset, JSLogger? logger}); diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart new file mode 100644 index 000000000..7dbe81c81 --- /dev/null +++ b/lib/src/js/parser.dart @@ -0,0 +1,96 @@ +// 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. + +// ignore_for_file: non_constant_identifier_names +// See dart-lang/sdk#47374 + +import 'package:js/js.dart'; +import 'package:path/path.dart' as p; +import 'package:source_span/source_span.dart'; + +import '../ast/sass.dart'; +import '../logger.dart'; +import '../logger/js_to_dart.dart'; +import '../syntax.dart'; +import '../util/nullable.dart'; +import '../util/span.dart'; +import '../visitor/interface/expression.dart'; +import '../visitor/interface/statement.dart'; +import 'logger.dart'; +import 'reflection.dart'; +import 'visitor/expression.dart'; +import 'visitor/statement.dart'; + +@JS() +@anonymous +class ParserExports { + external factory ParserExports( + {required Function parse, + required Function createExpressionVisitor, + required Function createStatementVisitor}); + + external set parse(Function function); + external set createStatementVisitor(Function function); + external set createExpressionVisitor(Function function); +} + +/// Loads and returns all the exports needed for the `sass-parser` package. +ParserExports loadParserExports() { + _updateAstPrototypes(); + return ParserExports( + parse: allowInterop(_parse), + createExpressionVisitor: allowInterop( + (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), + createStatementVisitor: allowInterop( + (JSStatementVisitorObject inner) => JSStatementVisitor(inner))); +} + +/// Modifies the prototypes of the Sass AST classes to provide access to JS. +/// +/// This API is not intended to be used directly by end users and is subject to +/// breaking changes without notice. Instead, it's wrapped by the `sass-parser` +/// package which exposes a PostCSS-style API. +void _updateAstPrototypes() { + // We don't need explicit getters for field names, because dart2js preserves + // them as-is, so we actually need to expose very little to JS manually. + var file = SourceFile.fromString(''); + getJSClass(file).defineMethod('getText', + (SourceFile self, int start, [int? end]) => self.getText(start, end)); + getJSClass(file) + .defineGetter('codeUnits', (SourceFile self) => self.codeUnits); + var interpolation = Interpolation(const [], bogusSpan); + getJSClass(interpolation) + .defineGetter('asPlain', (Interpolation self) => self.asPlain); + getJSClass(ExtendRule(interpolation, bogusSpan)).superclass.defineMethod( + 'accept', + (Statement self, StatementVisitor visitor) => + self.accept(visitor)); + var string = StringExpression(interpolation); + getJSClass(string).superclass.defineMethod( + 'accept', + (Expression self, ExpressionVisitor visitor) => + self.accept(visitor)); + + for (var node in [ + string, + BinaryOperationExpression(BinaryOperator.plus, string, string), + SupportsExpression(SupportsAnything(interpolation, bogusSpan)), + LoudComment(interpolation) + ]) { + getJSClass(node).defineGetter('span', (SassNode self) => self.span); + } +} + +/// A JavaScript-friendly method to parse a stylesheet. +Stylesheet _parse(String css, String syntax, String? path, JSLogger? logger) => + Stylesheet.parse( + css, + switch (syntax) { + 'scss' => Syntax.scss, + 'sass' => Syntax.sass, + 'css' => Syntax.css, + _ => throw UnsupportedError('Unknown syntax "$syntax"') + }, + url: path.andThen(p.toUri), + logger: JSToDartLogger(logger, Logger.stderr())); diff --git a/lib/src/js/source_span.dart b/lib/src/js/source_span.dart index 057aec500..ddf8ee776 100644 --- a/lib/src/js/source_span.dart +++ b/lib/src/js/source_span.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import '../util/lazy_file_span.dart'; @@ -21,7 +22,8 @@ void updateSourceSpanPrototype() { getJSClass(item).defineGetters({ 'start': (FileSpan span) => span.start, 'end': (FileSpan span) => span.end, - 'url': (FileSpan span) => span.sourceUrl.andThen(dartToJSUrl), + 'url': (FileSpan span) => span.sourceUrl.andThen((url) => dartToJSUrl( + url.scheme == '' ? p.toUri(p.absolute(p.fromUri(url))) : url)), 'text': (FileSpan span) => span.text, 'context': (FileSpan span) => span.context, }); diff --git a/lib/src/js/visitor/expression.dart b/lib/src/js/visitor/expression.dart new file mode 100644 index 000000000..88fa684de --- /dev/null +++ b/lib/src/js/visitor/expression.dart @@ -0,0 +1,74 @@ +// 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 'package:js/js.dart'; + +import '../../ast/sass.dart'; +import '../../visitor/interface/expression.dart'; + +/// A wrapper around a JS object that implements the [ExpressionVisitor] methods. +class JSExpressionVisitor implements ExpressionVisitor { + final JSExpressionVisitorObject _inner; + + JSExpressionVisitor(this._inner); + + Object? visitBinaryOperationExpression(BinaryOperationExpression node) => + _inner.visitBinaryOperationExpression(node); + Object? visitBooleanExpression(BooleanExpression node) => + _inner.visitBooleanExpression(node); + Object? visitColorExpression(ColorExpression node) => + _inner.visitColorExpression(node); + Object? visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + _inner.visitInterpolatedFunctionExpression(node); + Object? visitFunctionExpression(FunctionExpression node) => + _inner.visitFunctionExpression(node); + Object? visitIfExpression(IfExpression node) => + _inner.visitIfExpression(node); + Object? visitListExpression(ListExpression node) => + _inner.visitListExpression(node); + Object? visitMapExpression(MapExpression node) => + _inner.visitMapExpression(node); + Object? visitNullExpression(NullExpression node) => + _inner.visitNullExpression(node); + Object? visitNumberExpression(NumberExpression node) => + _inner.visitNumberExpression(node); + Object? visitParenthesizedExpression(ParenthesizedExpression node) => + _inner.visitParenthesizedExpression(node); + Object? visitSelectorExpression(SelectorExpression node) => + _inner.visitSelectorExpression(node); + Object? visitStringExpression(StringExpression node) => + _inner.visitStringExpression(node); + Object? visitSupportsExpression(SupportsExpression node) => + _inner.visitSupportsExpression(node); + Object? visitUnaryOperationExpression(UnaryOperationExpression node) => + _inner.visitUnaryOperationExpression(node); + Object? visitValueExpression(ValueExpression node) => + _inner.visitValueExpression(node); + Object? visitVariableExpression(VariableExpression node) => + _inner.visitVariableExpression(node); +} + +@JS() +class JSExpressionVisitorObject { + external Object? visitBinaryOperationExpression( + BinaryOperationExpression node); + external Object? visitBooleanExpression(BooleanExpression node); + external Object? visitColorExpression(ColorExpression node); + external Object? visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node); + external Object? visitFunctionExpression(FunctionExpression node); + external Object? visitIfExpression(IfExpression node); + external Object? visitListExpression(ListExpression node); + external Object? visitMapExpression(MapExpression node); + external Object? visitNullExpression(NullExpression node); + external Object? visitNumberExpression(NumberExpression node); + external Object? visitParenthesizedExpression(ParenthesizedExpression node); + external Object? visitSelectorExpression(SelectorExpression node); + external Object? visitStringExpression(StringExpression node); + external Object? visitSupportsExpression(SupportsExpression node); + external Object? visitUnaryOperationExpression(UnaryOperationExpression node); + external Object? visitValueExpression(ValueExpression node); + external Object? visitVariableExpression(VariableExpression node); +} diff --git a/lib/src/js/visitor/statement.dart b/lib/src/js/visitor/statement.dart new file mode 100644 index 000000000..71ee96945 --- /dev/null +++ b/lib/src/js/visitor/statement.dart @@ -0,0 +1,79 @@ +// 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 'package:js/js.dart'; + +import '../../ast/sass.dart'; +import '../../visitor/interface/statement.dart'; + +/// A wrapper around a JS object that implements the [StatementVisitor] methods. +class JSStatementVisitor implements StatementVisitor { + final JSStatementVisitorObject _inner; + + JSStatementVisitor(this._inner); + + Object? visitAtRootRule(AtRootRule node) => _inner.visitAtRootRule(node); + Object? visitAtRule(AtRule node) => _inner.visitAtRule(node); + Object? visitContentBlock(ContentBlock node) => + _inner.visitContentBlock(node); + Object? visitContentRule(ContentRule node) => _inner.visitContentRule(node); + Object? visitDebugRule(DebugRule node) => _inner.visitDebugRule(node); + Object? visitDeclaration(Declaration node) => _inner.visitDeclaration(node); + Object? visitEachRule(EachRule node) => _inner.visitEachRule(node); + Object? visitErrorRule(ErrorRule node) => _inner.visitErrorRule(node); + Object? visitExtendRule(ExtendRule node) => _inner.visitExtendRule(node); + Object? visitForRule(ForRule node) => _inner.visitForRule(node); + Object? visitForwardRule(ForwardRule node) => _inner.visitForwardRule(node); + Object? visitFunctionRule(FunctionRule node) => + _inner.visitFunctionRule(node); + Object? visitIfRule(IfRule node) => _inner.visitIfRule(node); + Object? visitImportRule(ImportRule node) => _inner.visitImportRule(node); + Object? visitIncludeRule(IncludeRule node) => _inner.visitIncludeRule(node); + Object? visitLoudComment(LoudComment node) => _inner.visitLoudComment(node); + Object? visitMediaRule(MediaRule node) => _inner.visitMediaRule(node); + Object? visitMixinRule(MixinRule node) => _inner.visitMixinRule(node); + Object? visitReturnRule(ReturnRule node) => _inner.visitReturnRule(node); + Object? visitSilentComment(SilentComment node) => + _inner.visitSilentComment(node); + Object? visitStyleRule(StyleRule node) => _inner.visitStyleRule(node); + Object? visitStylesheet(Stylesheet node) => _inner.visitStylesheet(node); + Object? visitSupportsRule(SupportsRule node) => + _inner.visitSupportsRule(node); + Object? visitUseRule(UseRule node) => _inner.visitUseRule(node); + Object? visitVariableDeclaration(VariableDeclaration node) => + _inner.visitVariableDeclaration(node); + Object? visitWarnRule(WarnRule node) => _inner.visitWarnRule(node); + Object? visitWhileRule(WhileRule node) => _inner.visitWhileRule(node); +} + +@JS() +class JSStatementVisitorObject { + external Object? visitAtRootRule(AtRootRule node); + external Object? visitAtRule(AtRule node); + external Object? visitContentBlock(ContentBlock node); + external Object? visitContentRule(ContentRule node); + external Object? visitDebugRule(DebugRule node); + external Object? visitDeclaration(Declaration node); + external Object? visitEachRule(EachRule node); + external Object? visitErrorRule(ErrorRule node); + external Object? visitExtendRule(ExtendRule node); + external Object? visitForRule(ForRule node); + external Object? visitForwardRule(ForwardRule node); + external Object? visitFunctionRule(FunctionRule node); + external Object? visitIfRule(IfRule node); + external Object? visitImportRule(ImportRule node); + external Object? visitIncludeRule(IncludeRule node); + external Object? visitLoudComment(LoudComment node); + external Object? visitMediaRule(MediaRule node); + external Object? visitMixinRule(MixinRule node); + external Object? visitReturnRule(ReturnRule node); + external Object? visitSilentComment(SilentComment node); + external Object? visitStyleRule(StyleRule node); + external Object? visitStylesheet(Stylesheet node); + external Object? visitSupportsRule(SupportsRule node); + external Object? visitUseRule(UseRule node); + external Object? visitVariableDeclaration(VariableDeclaration node); + external Object? visitWarnRule(WarnRule node); + external Object? visitWhileRule(WhileRule node); +} diff --git a/lib/src/logger/deprecation_processing.dart b/lib/src/logger/deprecation_processing.dart index 37bd4464f..d83c2ef80 100644 --- a/lib/src/logger/deprecation_processing.dart +++ b/lib/src/logger/deprecation_processing.dart @@ -45,7 +45,10 @@ final class DeprecationProcessingLogger extends LoggerWithDeprecationType { {required this.silenceDeprecations, required this.fatalDeprecations, required this.futureDeprecations, - this.limitRepetition = true}) { + this.limitRepetition = true}); + + /// Warns if any of the deprecations options are incompatible or unnecessary. + void validate() { for (var deprecation in fatalDeprecations) { switch (deprecation) { case Deprecation(isFuture: true) diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 78dea3179..689131bf1 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -268,7 +268,6 @@ class SassParser extends StylesheetParser { _readIndentation(); } - if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */"); return LoudComment(buffer.interpolation(scanner.spanFrom(start))); } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 361727c1e..b619949b0 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -736,7 +736,7 @@ abstract class StylesheetParser extends Parser { whitespace(); return _withChildren(_statement, start, (children, span) => AtRootRule(children, span, query: query)); - } else if (lookingAtChildren()) { + } else if (lookingAtChildren() || (indented && atEndOfStatement())) { return _withChildren( _statement, start, (children, span) => AtRootRule(children, span)); } else { @@ -753,12 +753,12 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($lparen); whitespace(); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); buffer.writeCharCode($space); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); } scanner.expectChar($rparen); @@ -2850,7 +2850,7 @@ abstract class StylesheetParser extends Parser { /// /// If [allowColon] is `false`, this stops at top-level colons. /// - /// If [allowOpenBrace] is `false`, this stops at top-level colons. + /// If [allowOpenBrace] is `false`, this stops at opening curly braces. /// /// If [silentComments] is `true`, this will parse silent comments as /// comments. Otherwise, it will preserve two adjacent slashes and emit them @@ -2897,10 +2897,6 @@ abstract class StylesheetParser extends Parser { wroteNewline = false; } - case $slash when silentComments && scanner.peekChar(1) == $slash: - buffer.write(rawText(loudComment)); - wroteNewline = false; - // Add a full interpolated identifier to handle cases like "#{...}--1", // since "--1" isn't a valid identifier on its own. case $hash when scanner.peekChar(1) == $lbrace: @@ -3528,6 +3524,16 @@ abstract class StylesheetParser extends Parser { span()); } + /// Adds [expression] to [buffer], or if it's an unquoted string adds the + /// interpolation it contains instead. + void _addOrInject(InterpolationBuffer buffer, Expression expression) { + if (expression is StringExpression && !expression.hasQuotes) { + buffer.addInterpolation(expression.text); + } else { + buffer.add(expression); + } + } + // ## Abstract Methods /// Whether this is parsing the indented syntax. diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 7d1a489af..eabadbc52 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -576,14 +576,21 @@ final class _EvaluateVisitor ]; var metaModule = BuiltInModule("meta", - functions: [...meta.global, ...meta.local, ...metaFunctions], + functions: [...meta.moduleFunctions, ...metaFunctions], mixins: metaMixins); for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } - functions = [...?functions, ...globalFunctions, ...metaFunctions]; + functions = [ + ...?functions, + ...globalFunctions, + ...[ + for (var function in metaFunctions) + function.withDeprecationWarning('meta') + ] + ]; for (var function in functions) { _builtInFunctions[function.name.replaceAll("_", "-")] = function; } @@ -1907,8 +1914,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild(ModifiableCssComment( - await _performInterpolation(node.text), node.span)); + var text = await _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 36bb0477e..1aca4eee6 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: b40f3e7568d9e987dde6633424073afdc6a69349 +// Checksum: de25b9055a73f1c7ebe7a707139e6c789a2866dd // // ignore_for_file: unused_import @@ -578,14 +578,21 @@ final class _EvaluateVisitor ]; var metaModule = BuiltInModule("meta", - functions: [...meta.global, ...meta.local, ...metaFunctions], + functions: [...meta.moduleFunctions, ...metaFunctions], mixins: metaMixins); for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } - functions = [...?functions, ...globalFunctions, ...metaFunctions]; + functions = [ + ...?functions, + ...globalFunctions, + ...[ + for (var function in metaFunctions) + function.withDeprecationWarning('meta') + ] + ]; for (var function in functions) { _builtInFunctions[function.name.replaceAll("_", "-")] = function; } @@ -1899,8 +1906,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild( - ModifiableCssComment(_performInterpolation(node.text), node.span)); + var text = _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index dec68b275..4d036e6e3 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -1212,7 +1212,7 @@ final class _SerializeVisitor if (negative) textIndex++; while (true) { if (textIndex == text.length) { - // If we get here, [text] has no decmial point. It definitely doesn't + // If we get here, [text] has no decimal point. It definitely doesn't // need to be rounded; we can write it as-is. buffer.write(text); return; diff --git a/pkg/sass-parser/.eslintignore b/pkg/sass-parser/.eslintignore new file mode 100644 index 000000000..dca1b9aa7 --- /dev/null +++ b/pkg/sass-parser/.eslintignore @@ -0,0 +1,2 @@ +dist/ +**/*.js diff --git a/pkg/sass-parser/.eslintrc b/pkg/sass-parser/.eslintrc new file mode 100644 index 000000000..3b5b91232 --- /dev/null +++ b/pkg/sass-parser/.eslintrc @@ -0,0 +1,14 @@ +{ + "extends": "./node_modules/gts/", + "rules": { + "@typescript-eslint/explicit-function-return-type": [ + "error", + {"allowExpressions": true} + ], + "func-style": ["error", "declaration"], + "prefer-const": ["error", {"destructuring": "all"}], + // It would be nice to sort import declaration order as well, but that's not + // autofixable and it's not worth the effort of handling manually. + "sort-imports": ["error", {"ignoreDeclarationSort": true}], + } +} diff --git a/pkg/sass-parser/.prettierrc.js b/pkg/sass-parser/.prettierrc.js new file mode 100644 index 000000000..c5166c2ae --- /dev/null +++ b/pkg/sass-parser/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('gts/.prettierrc.json'), +}; diff --git a/pkg/sass-parser/CHANGELOG.md b/pkg/sass-parser/CHANGELOG.md new file mode 100644 index 000000000..924d79f86 --- /dev/null +++ b/pkg/sass-parser/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.2.0 + +* Initial unstable release. diff --git a/pkg/sass-parser/README.md b/pkg/sass-parser/README.md new file mode 100644 index 000000000..89908a492 --- /dev/null +++ b/pkg/sass-parser/README.md @@ -0,0 +1,259 @@ +A [PostCSS]-compatible CSS and [Sass] parser with full expression support. + + + + + + + + +
+ Sass logo + + npm statistics + + GitHub actions build status + + @sass@front-end.social on Fediverse +
+ @SassCSS on Twitter +
+ stackoverflow +
+ Gitter +
+ +[PostCSS]: https://postcss.org/ +[Sass]: https://sass-lang.com/ + +**Warning:** `sass-parser` is still in active development, and is not yet +suitable for production use. At time of writing it only supports a small subset +of CSS and Sass syntax. In addition, it does not yet support parsing raws +(metadata about the original formatting of the document), which makes it +unsuitable for certain source-to-source transformations. + +* [Using `sass-parser`](#using-sass-parser) +* [Why `sass-parser`?](#why-sass-parser) +* [API Documentation](#api-documentation) +* [PostCSS Compatibility](#postcss-compatibility) + * [Statement API](#statement-api) + * [Expression API](#expression-api) + * [Constructing New Nodes](#constructing-new-nodes) + +## Using `sass-parser` + +1. Install the `sass-parser` package from the npm repository: + + ```sh + npm install sass-parser + ``` + +2. Use the `scss`, `sass`, or `css` [`Syntax` objects] exports to parse a file. + + ```js + const sassParser = require('sass-parser'); + + const root = sassParser.scss.parse(` + @use 'colors'; + + body { + color: colors.$midnight-blue; + } + `); + ``` + +3. Use the standard [PostCSS API] to inspect and edit the stylesheet: + + ```js + const styleRule = root.nodes[1]; + styleRule.selector = '.container'; + + console.log(root.toString()); + // @use 'colors'; + // + // .container { + // color: colors.$midnight-blue; + // } + ``` + +4. Use new PostCSS-style APIs to inspect and edit expressions and Sass-specific + rules: + + ```js + root.nodes[0].namespace = 'c'; + const variable = styleRule.nodes[0].valueExpression; + variable.namespace = 'c'; + + console.log(root.toString()); + // @use 'colors' as c; + // + // .container { + // color: c.$midnight-blue; + // } + ``` + +[`Syntax` objects]: https://postcss.org/api/#syntax +[PostCSS API]: https://postcss.org/api/ + +## Why `sass-parser`? + +We decided to expose [Dart Sass]'s parser as a JS API because we saw two needs +that were going unmet. + +[Dart Sass]: https://sass-lang.com/dart-sass + +First, there was no fully-compatible Sass parser. Although a [`postcss-scss`] +plugin did exist, its author [requested we create this package] to fix +compatibility issues, support [the indented syntax], and provide first-class +support for Sass-specific rules without needing them to be manually parsed by +each user. + +[`postcss-scss`]: https://www.npmjs.com/package/postcss-scss +[requested we create this package]: https://github.com/sass/dart-sass/issues/88#issuecomment-270069138 +[the indented syntax]: https://sass-lang.com/documentation/syntax/#the-indented-syntax + +Moreover, there was no robust solution for parsing the expressions that are used +as the values of CSS declarations (as well as Sass variable values). This was +true even for plain CSS, and doubly problematic for Sass's particularly complex +expression syntax. The [`postcss-value-parser`] package hasn't been updated +since 2021, the [`postcss-values-parser`] since January 2022, and even the +excellent [`@csstools/css-parser-algorithms`] had limited PostCSS integration +and no intention of ever supporting Sass. + +[`postcss-value-parser`]: https://www.npmjs.com/package/postcss-value-parser +[`postcss-values-parser`]: https://www.npmjs.com/package/postcss-values-parser +[`@csstools/css-parser-algorithms`]: https://www.npmjs.com/package/@csstools/css-parser-algorithms + +The `sass-parser` package intends to solve these problems by providing a parser +that's battle-tested by millions of Sass users and flexible enough to support +use-cases that don't involve Sass at all. We intend it to be usable as a drop-in +replacement for the standard PostCSS parser, and for the new expression-level +APIs to feel highly familiar to anyone used to PostCSS. + +## API Documentation + +The source code is fully documented using [TypeDoc]. Hosted, formatted +documentation will be coming soon. + +[TypeDoc]: https://typedoc.org + +## PostCSS Compatibility + +[PostCSS] is the most popular and long-lived CSS post-processing framework in +the world, and this package aims to be fully compatible with its API. Where we +add new features, we do so in a way that's as similar to PostCSS as possible, +re-using its types and even implementation wherever possible. + +### Statement API + +All statement-level [AST] nodes produced by `sass-parser`—style rules, at-rules, +declarations, statement-level comments, and the root node—extend the +corresponding PostCSS node types ([`Rule`], [`AtRule`], [`Declaration`], +[`Comment`], and [`Root`]). However, `sass-parser` has multiple subclasses for +many of its PostCSS superclasses. For example, `sassParser.PropertyDeclaration` +extends `postcss.Declaration`, but so does `sassParser.VariableDeclaration`. The +different `sass-parser` node types may be distinguished using the +`sassParser.Node.sassType` field. + +[AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree +[`Rule`]: https://postcss.org/api/#rule +[`AtRule`]: https://postcss.org/api/#atrule +[`Declaration`]: https://postcss.org/api/#declaration +[`Comment`]: https://postcss.org/api/#comment +[`Root`]: https://postcss.org/api/#root + +In addition to supporting the standard PostCSS properties like +`Declaration.value` and `Rule.selector`, `sass-parser` provides more detailed +parsed values. For example, `sassParser.Declaration.valueExpression` provides +the declaration's value as a fully-parsed syntax tree rather than a string, and +`sassParser.Rule.selectorInterpolation` provides access to any interpolated +expressions as in `.prefix-#{$variable} { /*...*/ }`. These parsed values are +automatically kept up-to-date with the standard PostCSS properties. + +### Expression API + +The expression-level AST nodes inherit from PostCSS's [`Node`] class but not any +of its more specific nodes. Nor do expressions support all the PostCSS `Node` +APIs: unlike statements, expressions that contain other expressions don't always +contain them as a clearly-ordered list, so methods like `Node.before()` and +`Node.next` aren't available. Just like with `sass-parser` statements, you can +distinguish between expressions using the `sassType` field. + +[`Node`]: https://postcss.org/api/#node + +Just like standard PostCSS nodes, expression nodes can be modified in-place and +these modifications will be reflected in the CSS output. Each expression type +has its own specific set of properties which can be read about in the expression +documentation. + +### Constructing New Nodes + +All Sass nodes, whether expressions, statements, or miscellaneous nodes like +`Interpolation`s, can be constructed as standard JavaScript objects: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append(new sassParser.Declaration({ + prop: 'content', + valueExpression: new sassParser.StringExpression({ + quotes: true, + text: new sassParser.Interpolation({ + nodes: ["hello, world!"], + }), + }), +})); +``` + +However, the same shorthands can be used as when adding new nodes in standard +PostCSS, as well as a few new ones. Anything that takes an `Interpolation` can +be passed a string instead to represent plain text with no Sass expressions: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append(new sassParser.Declaration({ + prop: 'content', + valueExpression: new sassParser.StringExpression({ + quotes: true, + text: "hello, world!", + }), +})); +``` + +Because the mandatory properties for all node types are unambiguous, you can +leave out the `new ...()` call and just pass the properties directly: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append({ + prop: 'content', + valueExpression: {quotes: true, text: "hello, world!"}, +}); +``` + +You can even pass a string in place of a statement and PostCSS will parse it for +you! **Warning:** This currently uses the standard PostCSS parser, not the Sass +parser, and as such it does not support Sass-specific constructs. + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append('content: "hello, world!"'); +``` + +### Known Incompatibilities + +There are a few cases where an operation that's valid in PostCSS won't work with +`sass-parser`: + +* Trying to convert a Sass-specific at-rule like `@if` or `@mixin` into a + different at-rule by changing its name is not supported. + +* Trying to add child nodes to a Sass statement that doesn't support children + like `@use` or `@error` is not supported. diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts new file mode 100644 index 000000000..d7cc13f80 --- /dev/null +++ b/pkg/sass-parser/jest.config.ts @@ -0,0 +1,9 @@ +const config = { + preset: 'ts-jest', + roots: ['lib'], + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'], + verbose: false, +}; + +export default config; diff --git a/pkg/sass-parser/lib/.npmignore b/pkg/sass-parser/lib/.npmignore new file mode 100644 index 000000000..b896f526a --- /dev/null +++ b/pkg/sass-parser/lib/.npmignore @@ -0,0 +1 @@ +*.test.ts diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts new file mode 100644 index 000000000..057f7881d --- /dev/null +++ b/pkg/sass-parser/lib/index.ts @@ -0,0 +1,124 @@ +// 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 * as sassApi from 'sass'; + +import {Root} from './src/statement/root'; +import * as sassInternal from './src/sass-internal'; +import {Stringifier} from './src/stringifier'; + +export {AnyNode, Node, NodeProps, NodeType} from './src/node'; +export { + AnyExpression, + Expression, + ExpressionProps, + ExpressionType, +} from './src/expression'; +export { + BinaryOperationExpression, + BinaryOperationExpressionProps, + BinaryOperationExpressionRaws, + BinaryOperator, +} from './src/expression/binary-operation'; +export { + StringExpression, + StringExpressionProps, + StringExpressionRaws, +} from './src/expression/string'; +export { + Interpolation, + InterpolationProps, + InterpolationRaws, + NewNodeForInterpolation, +} from './src/interpolation'; +export { + CssComment, + CssCommentProps, + CssCommentRaws, +} from './src/statement/css-comment'; +export { + DebugRule, + DebugRuleProps, + DebugRuleRaws, +} from './src/statement/debug-rule'; +export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule'; +export { + ErrorRule, + ErrorRuleProps, + ErrorRuleRaws, +} from './src/statement/error-rule'; +export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule'; +export { + GenericAtRule, + GenericAtRuleProps, + GenericAtRuleRaws, +} from './src/statement/generic-at-rule'; +export {Root, RootProps, RootRaws} from './src/statement/root'; +export {Rule, RuleProps, RuleRaws} from './src/statement/rule'; +export { + SassComment, + SassCommentProps, + SassCommentRaws, +} from './src/statement/sass-comment'; +export { + AnyStatement, + AtRule, + ChildNode, + ChildProps, + ContainerProps, + NewNode, + Statement, + StatementType, + StatementWithChildren, +} from './src/statement'; + +/** Options that can be passed to the Sass parsers to control their behavior. */ +export interface SassParserOptions + extends Pick { + /** The logger that's used to log messages encountered during parsing. */ + logger?: sassApi.Logger; +} + +/** A PostCSS syntax for parsing a particular Sass syntax. */ +export interface Syntax extends postcss.Syntax { + parse(css: {toString(): string} | string, opts?: SassParserOptions): Root; + stringify: postcss.Stringifier; +} + +/** The internal implementation of the syntax. */ +class _Syntax implements Syntax { + /** The syntax with which to parse stylesheets. */ + readonly #syntax: sassInternal.Syntax; + + constructor(syntax: sassInternal.Syntax) { + this.#syntax = syntax; + } + + parse(css: {toString(): string} | string, opts?: SassParserOptions): Root { + if (opts?.map) { + // We might be able to support this as a layer on top of source spans, but + // is it worth the effort? + throw "sass-parser doesn't currently support consuming source maps."; + } + + return new Root( + undefined, + sassInternal.parse(css.toString(), this.#syntax, opts?.from, opts?.logger) + ); + } + + stringify(node: postcss.AnyNode, builder: postcss.Builder): void { + new Stringifier(builder).stringify(node, true); + } +} + +/** A PostCSS syntax for parsing SCSS. */ +export const scss: Syntax = new _Syntax('scss'); + +/** A PostCSS syntax for parsing Sass's indented syntax. */ +export const sass: Syntax = new _Syntax('sass'); + +/** A PostCSS syntax for parsing plain CSS. */ +export const css: Syntax = new _Syntax('css'); diff --git a/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap new file mode 100644 index 000000000..4f8fa453c --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an interpolation toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@foo#{bar}baz", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + "foo", + , + "baz", + ], + "raws": {}, + "sassType": "interpolation", + "source": <1:2-1:14 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap new file mode 100644 index 000000000..ea2511ded --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a binary operation toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{foo + bar}", + "hasBOM": false, + "id": "", + }, + ], + "left": , + "operator": "+", + "raws": {}, + "right": , + "sassType": "binary-operation", + "source": <1:4-1:13 in 0>, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap new file mode 100644 index 000000000..621190d86 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a string expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{"foo"}", + "hasBOM": false, + "id": "", + }, + ], + "quotes": true, + "raws": {}, + "sassType": "string", + "source": <1:4-1:9 in 0>, + "text": , +} +`; diff --git a/pkg/sass-parser/lib/src/expression/binary-operation.test.ts b/pkg/sass-parser/lib/src/expression/binary-operation.test.ts new file mode 100644 index 000000000..c03cd6c9c --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/binary-operation.test.ts @@ -0,0 +1,201 @@ +// 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 {BinaryOperationExpression, StringExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a binary operation', () => { + let node: BinaryOperationExpression; + function describeNode( + description: string, + create: () => BinaryOperationExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType binary-operation', () => + expect(node.sassType).toBe('binary-operation')); + + it('has an operator', () => expect(node.operator).toBe('+')); + + it('has a left node', () => + expect(node).toHaveStringExpression('left', 'foo')); + + it('has a right node', () => + expect(node).toHaveStringExpression('right', 'bar')); + }); + } + + describeNode('parsed', () => utils.parseExpression('foo + bar')); + + describeNode( + 'constructed manually', + () => + new BinaryOperationExpression({ + operator: '+', + left: {text: 'foo'}, + right: {text: 'bar'}, + }) + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + operator: '+', + left: {text: 'foo'}, + right: {text: 'bar'}, + }) + ); + + describe('assigned new', () => { + beforeEach(() => void (node = utils.parseExpression('foo + bar'))); + + it('operator', () => { + node.operator = '*'; + expect(node.operator).toBe('*'); + }); + + describe('left', () => { + it("removes the old left's parent", () => { + const oldLeft = node.left; + node.left = {text: 'zip'}; + expect(oldLeft.parent).toBeUndefined(); + }); + + it('assigns left explicitly', () => { + const left = new StringExpression({text: 'zip'}); + node.left = left; + expect(node.left).toBe(left); + expect(node).toHaveStringExpression('left', 'zip'); + }); + + it('assigns left as ExpressionProps', () => { + node.left = {text: 'zip'}; + expect(node).toHaveStringExpression('left', 'zip'); + }); + }); + + describe('right', () => { + it("removes the old right's parent", () => { + const oldRight = node.right; + node.right = {text: 'zip'}; + expect(oldRight.parent).toBeUndefined(); + }); + + it('assigns right explicitly', () => { + const right = new StringExpression({text: 'zip'}); + node.right = right; + expect(node.right).toBe(right); + expect(node).toHaveStringExpression('right', 'zip'); + }); + + it('assigns right as ExpressionProps', () => { + node.right = {text: 'zip'}; + expect(node).toHaveStringExpression('right', 'zip'); + }); + }); + }); + + describe('stringifies', () => { + beforeEach(() => void (node = utils.parseExpression('foo + bar'))); + + it('without raws', () => expect(node.toString()).toBe('foo + bar')); + + it('with beforeOperator', () => { + node.raws.beforeOperator = '/**/'; + expect(node.toString()).toBe('foo/**/+ bar'); + }); + + it('with afterOperator', () => { + node.raws.afterOperator = '/**/'; + expect(node.toString()).toBe('foo +/**/bar'); + }); + }); + + describe('clone', () => { + let original: BinaryOperationExpression; + beforeEach(() => { + original = utils.parseExpression('foo + bar'); + // TODO: remove this once raws are properly parsed + original.raws.beforeOperator = ' '; + }); + + describe('with no overrides', () => { + let clone: BinaryOperationExpression; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('operator', () => expect(clone.operator).toBe('+')); + + it('left', () => expect(clone).toHaveStringExpression('left', 'foo')); + + it('right', () => expect(clone).toHaveStringExpression('right', 'bar')); + + it('raws', () => expect(clone.raws).toEqual({beforeOperator: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['left', 'right', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('operator', () => { + it('defined', () => + expect(original.clone({operator: '*'}).operator).toBe('*')); + + it('undefined', () => + expect(original.clone({operator: undefined}).operator).toBe('+')); + }); + + describe('left', () => { + it('defined', () => + expect(original.clone({left: {text: 'zip'}})).toHaveStringExpression( + 'left', + 'zip' + )); + + it('undefined', () => + expect(original.clone({left: undefined})).toHaveStringExpression( + 'left', + 'foo' + )); + }); + + describe('right', () => { + it('defined', () => + expect(original.clone({right: {text: 'zip'}})).toHaveStringExpression( + 'right', + 'zip' + )); + + it('undefined', () => + expect(original.clone({right: undefined})).toHaveStringExpression( + 'right', + 'bar' + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterOperator: ' '}}).raws).toEqual({ + afterOperator: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + beforeOperator: ' ', + })); + }); + }); + }); + + it('toJSON', () => + expect(utils.parseExpression('foo + bar')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/binary-operation.ts b/pkg/sass-parser/lib/src/expression/binary-operation.ts new file mode 100644 index 000000000..12054630c --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/binary-operation.ts @@ -0,0 +1,151 @@ +// 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 {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression, ExpressionProps} from '.'; +import {convertExpression} from './convert'; +import {fromProps} from './from-props'; + +/** Different binary operations supported by Sass. */ +export type BinaryOperator = + | '=' + | 'or' + | 'and' + | '==' + | '!=' + | '>' + | '>=' + | '<' + | '<=' + | '+' + | '-' + | '*' + | '/' + | '%'; + +/** + * The initializer properties for {@link BinaryOperationExpression}. + * + * @category Expression + */ +export interface BinaryOperationExpressionProps { + operator: BinaryOperator; + left: Expression | ExpressionProps; + right: Expression | ExpressionProps; + raws?: BinaryOperationExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link BinaryOperationExpression}. + * + * @category Expression + */ +export interface BinaryOperationExpressionRaws { + /** The whitespace before the operator. */ + beforeOperator?: string; + + /** The whitespace after the operator. */ + afterOperator?: string; +} + +/** + * An expression representing an inline binary operation Sass. + * + * @category Expression + */ +export class BinaryOperationExpression extends Expression { + readonly sassType = 'binary-operation' as const; + declare raws: BinaryOperationExpressionRaws; + + /** + * Which operator this operation uses. + * + * Note that different operators have different precedence. It's the caller's + * responsibility to ensure that operations are parenthesized appropriately to + * guarantee that they're processed in AST order. + */ + get operator(): BinaryOperator { + return this._operator; + } + set operator(operator: BinaryOperator) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._operator = operator; + } + private _operator!: BinaryOperator; + + /** The expression on the left-hand side of this operation. */ + get left(): Expression { + return this._left; + } + set left(left: Expression | ExpressionProps) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._left) this._left.parent = undefined; + if (!('sassType' in left)) left = fromProps(left); + left.parent = this; + this._left = left; + } + private _left!: Expression; + + /** The expression on the right-hand side of this operation. */ + get right(): Expression { + return this._right; + } + set right(right: Expression | ExpressionProps) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._right) this._right.parent = undefined; + if (!('sassType' in right)) right = fromProps(right); + right.parent = this; + this._right = right; + } + private _right!: Expression; + + constructor(defaults: BinaryOperationExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.BinaryOperationExpression); + constructor( + defaults?: object, + inner?: sassInternal.BinaryOperationExpression + ) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.operator = inner.operator.operator; + this.left = convertExpression(inner.left); + this.right = convertExpression(inner.right); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'operator', + 'left', + 'right', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['operator', 'left', 'right'], inputs); + } + + /** @hidden */ + toString(): string { + return ( + `${this.left}${this.raws.beforeOperator ?? ' '}${this.operator}` + + `${this.raws.afterOperator ?? ' '}${this.right}` + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.left, this.right]; + } +} diff --git a/pkg/sass-parser/lib/src/expression/convert.ts b/pkg/sass-parser/lib/src/expression/convert.ts new file mode 100644 index 000000000..792a74b11 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -0,0 +1,23 @@ +// 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 sassInternal from '../sass-internal'; + +import {BinaryOperationExpression} from './binary-operation'; +import {StringExpression} from './string'; +import {Expression} from '.'; + +/** The visitor to use to convert internal Sass nodes to JS. */ +const visitor = sassInternal.createExpressionVisitor({ + visitBinaryOperationExpression: inner => + new BinaryOperationExpression(undefined, inner), + visitStringExpression: inner => new StringExpression(undefined, inner), +}); + +/** Converts an internal expression AST node into an external one. */ +export function convertExpression( + expression: sassInternal.Expression +): Expression { + return expression.accept(visitor); +} diff --git a/pkg/sass-parser/lib/src/expression/from-props.ts b/pkg/sass-parser/lib/src/expression/from-props.ts new file mode 100644 index 000000000..030684e52 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -0,0 +1,14 @@ +// 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 {BinaryOperationExpression} from './binary-operation'; +import {Expression, ExpressionProps} from '.'; +import {StringExpression} from './string'; + +/** Constructs an expression from {@link ExpressionProps}. */ +export function fromProps(props: ExpressionProps): Expression { + if ('text' in props) return new StringExpression(props); + if ('left' in props) return new BinaryOperationExpression(props); + throw new Error(`Unknown node type: ${props}`); +} diff --git a/pkg/sass-parser/lib/src/expression/index.ts b/pkg/sass-parser/lib/src/expression/index.ts new file mode 100644 index 000000000..a5f599133 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -0,0 +1,47 @@ +// 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 {Node} from '../node'; +import type { + BinaryOperationExpression, + BinaryOperationExpressionProps, +} from './binary-operation'; +import type {StringExpression, StringExpressionProps} from './string'; + +/** + * The union type of all Sass expressions. + * + * @category Expression + */ +export type AnyExpression = BinaryOperationExpression | StringExpression; + +/** + * Sass expression types. + * + * @category Expression + */ +export type ExpressionType = 'binary-operation' | 'string'; + +/** + * The union type of all properties that can be used to construct Sass + * expressions. + * + * @category Expression + */ +export type ExpressionProps = + | BinaryOperationExpressionProps + | StringExpressionProps; + +/** + * The superclass of Sass expression nodes. + * + * An expressions is anything that can appear in a variable value, + * interpolation, declaration value, and so on. + * + * @category Expression + */ +export abstract class Expression extends Node { + abstract readonly sassType: ExpressionType; + abstract clone(overrides?: object): this; +} diff --git a/pkg/sass-parser/lib/src/expression/string.test.ts b/pkg/sass-parser/lib/src/expression/string.test.ts new file mode 100644 index 000000000..eb5d99074 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/string.test.ts @@ -0,0 +1,332 @@ +// 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 {Interpolation, StringExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a string expression', () => { + let node: StringExpression; + describe('quoted', () => { + function describeNode( + description: string, + create: () => StringExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType string', () => expect(node.sassType).toBe('string')); + + it('has quotes', () => expect(node.quotes).toBe(true)); + + it('has text', () => expect(node).toHaveInterpolation('text', 'foo')); + }); + } + + 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', + }) + ); + }); + }); + + describe('unquoted', () => { + function describeNode( + description: string, + create: () => StringExpression + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType string', () => expect(node.sassType).toBe('string')); + + it('has no quotes', () => expect(node.quotes).toBe(false)); + + it('has text', () => expect(node).toHaveInterpolation('text', 'foo')); + }); + } + + 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', + }) + ); + + describeNode( + 'with string text', + () => + new StringExpression({ + text: 'foo', + }) + ); + }); + + describe('constructed from ExpressionProps', () => { + describeNode('with explicit text', () => + utils.fromExpressionProps({ + text: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with explicit quotes', () => + utils.fromExpressionProps({ + quotes: false, + text: 'foo', + }) + ); + + describeNode('with string text', () => + utils.fromExpressionProps({ + text: 'foo', + }) + ); + }); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = utils.parseExpression('"foo"'))); + + it('quotes', () => { + node.quotes = false; + expect(node.quotes).toBe(false); + }); + + describe('text', () => { + it("removes the old text's parent", () => { + const oldText = node.text; + node.text = 'zip'; + expect(oldText.parent).toBeUndefined(); + }); + + it('assigns text explicitly', () => { + const text = new Interpolation({nodes: ['zip']}); + node.text = text; + expect(node.text).toBe(text); + expect(node).toHaveInterpolation('text', 'zip'); + }); + + it('assigns text as string', () => { + node.text = 'zip'; + expect(node).toHaveInterpolation('text', 'zip'); + }); + }); + }); + + describe('stringifies', () => { + describe('quoted', () => { + describe('with no internal quotes', () => { + beforeEach(() => void (node = utils.parseExpression('"foo"'))); + + it('without raws', () => expect(node.toString()).toBe('"foo"')); + + it('with explicit double quotes', () => { + node.raws.quotes = '"'; + expect(node.toString()).toBe('"foo"'); + }); + + it('with explicit single quotes', () => { + node.raws.quotes = "'"; + expect(node.toString()).toBe("'foo'"); + }); + }); + + describe('with internal double quote', () => { + beforeEach(() => void (node = utils.parseExpression("'f\"o'"))); + + it('without raws', () => expect(node.toString()).toBe('"f\\"o"')); + + it('with explicit double quotes', () => { + node.raws.quotes = '"'; + expect(node.toString()).toBe('"f\\"o"'); + }); + + it('with explicit single quotes', () => { + node.raws.quotes = "'"; + expect(node.toString()).toBe("'f\"o'"); + }); + }); + + describe('with internal single quote', () => { + beforeEach(() => void (node = utils.parseExpression('"f\'o"'))); + + it('without raws', () => expect(node.toString()).toBe('"f\'o"')); + + it('with explicit double quotes', () => { + node.raws.quotes = '"'; + expect(node.toString()).toBe('"f\'o"'); + }); + + it('with explicit single quotes', () => { + node.raws.quotes = "'"; + expect(node.toString()).toBe("'f\\'o'"); + }); + }); + + it('with internal unprintable', () => + expect( + new StringExpression({quotes: true, text: '\x00'}).toString() + ).toBe('"\\0 "')); + + it('with internal newline', () => + expect( + new StringExpression({quotes: true, text: '\x0A'}).toString() + ).toBe('"\\a "')); + + it('with internal backslash', () => + expect( + new StringExpression({quotes: true, text: '\\'}).toString() + ).toBe('"\\\\"')); + + it('respects interpolation raws', () => + expect( + new StringExpression({ + quotes: true, + text: new Interpolation({ + nodes: ['foo'], + raws: {text: [{raw: 'f\\6f o', value: 'foo'}]}, + }), + }).toString() + ).toBe('"f\\6f o"')); + }); + + describe('unquoted', () => { + it('prints the text as-is', () => + expect(utils.parseExpression('foo').toString()).toBe('foo')); + + it('with internal quotes', () => + expect(new StringExpression({text: '"'}).toString()).toBe('"')); + + it('with internal newline', () => + expect(new StringExpression({text: '\x0A'}).toString()).toBe('\x0A')); + + it('with internal backslash', () => + expect(new StringExpression({text: '\\'}).toString()).toBe('\\')); + + it('respects interpolation raws', () => + expect( + new StringExpression({ + text: new Interpolation({ + nodes: ['foo'], + raws: {text: [{raw: 'f\\6f o', value: 'foo'}]}, + }), + }).toString() + ).toBe('f\\6f o')); + }); + }); + + describe('clone', () => { + let original: StringExpression; + beforeEach(() => { + original = utils.parseExpression('"foo"'); + // TODO: remove this once raws are properly parsed + original.raws.quotes = "'"; + }); + + describe('with no overrides', () => { + let clone: StringExpression; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('quotes', () => expect(clone.quotes).toBe(true)); + + it('text', () => expect(clone).toHaveInterpolation('text', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({quotes: "'"})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['text', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('quotes', () => { + it('defined', () => + expect(original.clone({quotes: false}).quotes).toBe(false)); + + it('undefined', () => + expect(original.clone({quotes: undefined}).quotes).toBe(true)); + }); + + describe('text', () => { + it('defined', () => + expect(original.clone({text: 'zip'})).toHaveInterpolation( + 'text', + 'zip' + )); + + it('undefined', () => + expect(original.clone({text: undefined})).toHaveInterpolation( + 'text', + 'foo' + )); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {quotes: '"'}}).raws).toEqual({ + quotes: '"', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + quotes: "'", + })); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('"foo"')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/string.ts b/pkg/sass-parser/lib/src/expression/string.ts new file mode 100644 index 000000000..de796e807 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/string.ts @@ -0,0 +1,203 @@ +// 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 {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Expression} from '.'; + +/** + * The initializer properties for {@link StringExpression}. + * + * @category Expression + */ +export interface StringExpressionProps { + text: Interpolation | string; + quotes?: boolean; + raws?: StringExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link StringExpression}. + * + * @category Expression + */ +export interface StringExpressionRaws { + /** + * The type of quotes to use (single or double). + * + * This is ignored if the string isn't quoted. + */ + quotes?: '"' | "'"; +} + +/** + * An expression representing a (quoted or unquoted) string literal in Sass. + * + * @category Expression + */ +export class StringExpression extends Expression { + readonly sassType = 'string' as const; + declare raws: StringExpressionRaws; + + /** The interpolation that represents the text of this string. */ + get text(): Interpolation { + return this._text; + } + set text(text: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._text) this._text.parent = undefined; + if (typeof text === 'string') text = new Interpolation({nodes: [text]}); + text.parent = this; + this._text = text; + } + private _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 + // identifier. + + /** + * Whether this is a quoted or unquoted string. Defaults to false. + * + * Unquoted strings are most commonly used to represent identifiers, but they + * can also be used for string-like functions such as `url()` or more unusual + * constructs like Unicode ranges. + */ + get quotes(): boolean { + return this._quotes; + } + set quotes(quotes: boolean) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._quotes = quotes; + } + private _quotes!: boolean; + + constructor(defaults: StringExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.StringExpression); + constructor(defaults?: object, inner?: sassInternal.StringExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.text = new Interpolation(undefined, inner.text); + this.quotes = inner.hasQuotes; + } else { + this._quotes ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'text', 'quotes']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'quotes'], inputs); + } + + /** @hidden */ + toString(): string { + const quote = this.quotes ? this.raws.quotes ?? '"' : ''; + let result = quote; + const rawText = this.text.raws.text; + const rawExpressions = this.text.raws.expressions; + for (let i = 0; i < this.text.nodes.length; i++) { + const element = this.text.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + // The Dart Sass AST preserves string escapes for unquoted strings + // because they serve a dual purpose at runtime of representing + // identifiers (which may contain escape codes) and being a catch-all + // representation for unquoted non-identifier values such as `url()`s. + // As such, escapes in unquoted strings are represented literally. + result += + raw?.value === element + ? raw.raw + : this.quotes + ? this.#escapeQuoted(element) + : element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + return result + quote; + } + + /** Escapes a text component of a quoted string literal. */ + #escapeQuoted(text: string): string { + const quote = this.raws.quotes ?? '"'; + let result = ''; + for (let i = 0; i < text.length; i++) { + const char = text[i]; + switch (char) { + case '"': + result += quote === '"' ? '\\"' : '"'; + break; + + case "'": + result += quote === "'" ? "\\'" : "'"; + break; + + // Write newline characters and unprintable ASCII characters as escapes. + case '\x00': + case '\x01': + case '\x02': + case '\x03': + case '\x04': + case '\x05': + case '\x06': + case '\x07': + case '\x08': + case '\x09': + case '\x0A': + case '\x0B': + case '\x0C': + case '\x0D': + case '\x0E': + case '\x0F': + case '\x10': + case '\x11': + case '\x12': + case '\x13': + case '\x14': + case '\x15': + case '\x16': + case '\x17': + case '\x18': + case '\x19': + case '\x1A': + case '\x1B': + case '\x1C': + case '\x1D': + case '\x1E': + case '\x1F': + case '\x7F': + result += '\\' + char.charCodeAt(0).toString(16) + ' '; + break; + + case '\\': + result += '\\\\'; + break; + + default: + result += char; + break; + } + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.text]; + } +} diff --git a/pkg/sass-parser/lib/src/interpolation.test.ts b/pkg/sass-parser/lib/src/interpolation.test.ts new file mode 100644 index 000000000..f5ec6684d --- /dev/null +++ b/pkg/sass-parser/lib/src/interpolation.test.ts @@ -0,0 +1,636 @@ +// 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 { + Expression, + GenericAtRule, + Interpolation, + StringExpression, + css, + scss, +} from '..'; + +type EachFn = Parameters[0]; + +let node: Interpolation; +describe('an interpolation', () => { + describe('empty', () => { + function describeNode( + description: string, + create: () => Interpolation + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType interpolation', () => + expect(node.sassType).toBe('interpolation')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + + it('is plain', () => expect(node.isPlain).toBe(true)); + + it('has a plain value', () => expect(node.asPlain).toBe('')); + }); + } + + // TODO: Are there any node types that allow empty interpolation? + + describeNode('constructed manually', () => new Interpolation()); + }); + + describe('with no expressions', () => { + function describeNode( + description: string, + create: () => Interpolation + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType interpolation', () => + expect(node.sassType).toBe('interpolation')); + + it('has a single node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBe('foo'); + }); + + it('is plain', () => expect(node.isPlain).toBe(true)); + + it('has a plain value', () => expect(node.asPlain).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@foo').nodes[0] as GenericAtRule).nameInterpolation + ); + + describeNode( + 'parsed as CSS', + () => (css.parse('@foo').nodes[0] as GenericAtRule).nameInterpolation + ); + + describeNode( + 'constructed manually', + () => new Interpolation({nodes: ['foo']}) + ); + }); + + describe('with only an expression', () => { + function describeNode( + description: string, + create: () => Interpolation + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType interpolation', () => + expect(node.sassType).toBe('interpolation')); + + it('has a single node', () => + expect(node).toHaveStringExpression(0, 'foo')); + + it('is not plain', () => expect(node.isPlain).toBe(false)); + + it('has no plain value', () => expect(node.asPlain).toBe(null)); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@#{foo}').nodes[0] as GenericAtRule).nameInterpolation + ); + + describeNode( + 'constructed manually', + () => new Interpolation({nodes: [{text: 'foo'}]}) + ); + }); + + describe('with mixed text and expressions', () => { + function describeNode( + description: string, + create: () => Interpolation + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType interpolation', () => + expect(node.sassType).toBe('interpolation')); + + it('has multiple nodes', () => { + expect(node.nodes).toHaveLength(3); + expect(node.nodes[0]).toBe('foo'); + expect(node).toHaveStringExpression(1, 'bar'); + expect(node.nodes[2]).toBe('baz'); + }); + + it('is not plain', () => expect(node.isPlain).toBe(false)); + + it('has no plain value', () => expect(node.asPlain).toBe(null)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@foo#{bar}baz').nodes[0] as GenericAtRule) + .nameInterpolation + ); + + describeNode( + 'constructed manually', + () => new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}) + ); + }); + + describe('can add', () => { + beforeEach(() => void (node = new Interpolation())); + + it('a single interpolation', () => { + const interpolation = new Interpolation({nodes: ['foo', {text: 'bar'}]}); + const string = interpolation.nodes[1]; + node.append(interpolation); + expect(node.nodes).toEqual(['foo', string]); + expect(string).toHaveProperty('parent', node); + expect(interpolation.nodes).toHaveLength(0); + }); + + it('a list of interpolations', () => { + node.append([ + new Interpolation({nodes: ['foo']}), + new Interpolation({nodes: ['bar']}), + ]); + expect(node.nodes).toEqual(['foo', 'bar']); + }); + + it('a single expression', () => { + const string = new StringExpression({text: 'foo'}); + node.append(string); + expect(node.nodes[0]).toBe(string); + expect(string.parent).toBe(node); + }); + + it('a list of expressions', () => { + const string1 = new StringExpression({text: 'foo'}); + const string2 = new StringExpression({text: 'bar'}); + node.append([string1, string2]); + expect(node.nodes[0]).toBe(string1); + expect(node.nodes[1]).toBe(string2); + expect(string1.parent).toBe(node); + expect(string2.parent).toBe(node); + }); + + it("a single expression's properties", () => { + node.append({text: 'foo'}); + expect(node).toHaveStringExpression(0, 'foo'); + }); + + it('a list of properties', () => { + node.append([{text: 'foo'}, {text: 'bar'}]); + expect(node).toHaveStringExpression(0, 'foo'); + expect(node).toHaveStringExpression(1, 'bar'); + }); + + it('a single string', () => { + node.append('foo'); + expect(node.nodes).toEqual(['foo']); + }); + + it('a list of strings', () => { + node.append(['foo', 'bar']); + expect(node.nodes).toEqual(['foo', 'bar']); + }); + + it('undefined', () => { + node.append(undefined); + expect(node.nodes).toHaveLength(0); + }); + }); + + describe('append', () => { + beforeEach(() => void (node = new Interpolation({nodes: ['foo', 'bar']}))); + + it('adds multiple children to the end', () => { + node.append('baz', 'qux'); + expect(node.nodes).toEqual(['foo', 'bar', 'baz', '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 Interpolation({nodes: ['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, 'foo', 0); + expect(fn).toHaveBeenNthCalledWith(2, '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 Interpolation({nodes: ['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 !== 'bar')).toBe(false)); + }); + + describe('index', () => { + beforeEach( + () => + void (node = new Interpolation({ + nodes: ['foo', 'bar', {text: 'baz'}, 'bar'], + })) + ); + + it('returns the first index of a given string', () => + expect(node.index('bar')).toBe(1)); + + it('returns the first index of a given expression', () => + 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 Interpolation({nodes: ['foo', 'bar', 'baz']})) + ); + + it('inserts a node after the given element', () => { + node.insertAfter('bar', 'qux'); + expect(node.nodes).toEqual(['foo', 'bar', 'qux', 'baz']); + }); + + it('inserts a node at the beginning', () => { + node.insertAfter(-1, 'qux'); + expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']); + }); + + it('inserts a node at the end', () => { + node.insertAfter(3, 'qux'); + expect(node.nodes).toEqual(['foo', 'bar', 'baz', 'qux']); + }); + + it('inserts multiple nodes', () => { + node.insertAfter(1, ['qux', 'qax', 'qix']); + expect(node.nodes).toEqual(['foo', 'bar', 'qux', 'qax', 'qix', '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('foo', 'qux')).toBe(node)); + }); + + describe('insertBefore', () => { + beforeEach( + () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']})) + ); + + it('inserts a node before the given element', () => { + node.insertBefore('bar', 'qux'); + expect(node.nodes).toEqual(['foo', 'qux', 'bar', 'baz']); + }); + + it('inserts a node at the beginning', () => { + node.insertBefore(0, 'qux'); + expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']); + }); + + it('inserts a node at the end', () => { + node.insertBefore(4, 'qux'); + expect(node.nodes).toEqual(['foo', 'bar', 'baz', 'qux']); + }); + + it('inserts multiple nodes', () => { + node.insertBefore(1, ['qux', 'qax', 'qix']); + expect(node.nodes).toEqual(['foo', 'qux', 'qax', 'qix', 'bar', '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('foo', 'qux')).toBe(node)); + }); + + describe('prepend', () => { + beforeEach( + () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']})) + ); + + it('inserts one node', () => { + node.prepend('qux'); + expect(node.nodes).toEqual(['qux', 'foo', 'bar', 'baz']); + }); + + it('inserts multiple nodes', () => { + node.prepend('qux', 'qax', 'qix'); + expect(node.nodes).toEqual(['qux', 'qax', 'qix', 'foo', 'bar', '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 Interpolation({nodes: ['foo', 'bar']}))); + + it('inserts one node', () => { + node.push('baz'); + expect(node.nodes).toEqual(['foo', 'bar', 'baz']); + }); + + it('can be called during iteration', () => + testEachMutation(['foo', 'bar', 'baz'], 0, () => node.push('baz'))); + + it('returns itself', () => expect(node.push('baz')).toBe(node)); + }); + + describe('removeAll', () => { + beforeEach( + () => + void (node = new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']})) + ); + + it('removes all nodes', () => { + node.removeAll(); + expect(node.nodes).toHaveLength(0); + }); + + it("removes a node's parents", () => { + const string = node.nodes[1]; + node.removeAll(); + expect(string).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 Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']})) + ); + + it('removes a matching node', () => { + const string = node.nodes[1]; + node.removeChild('foo'); + expect(node.nodes).toEqual([string, 'baz']); + }); + + it('removes a node at index', () => { + node.removeChild(1); + expect(node.nodes).toEqual(['foo', 'baz']); + }); + + it("removes a node's parents", () => { + const string = node.nodes[1]; + node.removeAll(); + expect(string).toHaveProperty('parent', undefined); + }); + + it('removes a node before the iterator', () => + testEachMutation(['foo', node.nodes[1], ['baz', 1]], 1, () => + node.removeChild(1) + )); + + it('removes a node after the iterator', () => + testEachMutation(['foo', node.nodes[1]], 1, () => node.removeChild(2))); + + it('returns itself', () => expect(node.removeChild(0)).toBe(node)); + }); + + describe('some', () => { + beforeEach( + () => void (node = new Interpolation({nodes: ['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 === 'bar')).toBe(true)); + }); + + describe('first', () => { + it('returns the first element', () => + expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).first).toBe( + 'foo' + )); + + it('returns undefined for an empty interpolation', () => + expect(new Interpolation().first).toBeUndefined()); + }); + + describe('last', () => { + it('returns the last element', () => + expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).last).toBe( + 'baz' + )); + + it('returns undefined for an empty interpolation', () => + expect(new Interpolation().last).toBeUndefined()); + }); + + describe('stringifies', () => { + it('with no nodes', () => expect(new Interpolation().toString()).toBe('')); + + it('with only text', () => + expect(new Interpolation({nodes: ['foo', 'bar', 'baz']}).toString()).toBe( + 'foobarbaz' + )); + + it('with only expressions', () => + expect( + new Interpolation({nodes: [{text: 'foo'}, {text: 'bar'}]}).toString() + ).toBe('#{foo}#{bar}')); + + it('with mixed text and expressions', () => + expect( + new Interpolation({nodes: ['foo', {text: 'bar'}, 'baz']}).toString() + ).toBe('foo#{bar}baz')); + + describe('with text', () => { + beforeEach( + () => void (node = new Interpolation({nodes: ['foo', 'bar', 'baz']})) + ); + + it('take precedence when the value matches', () => { + node.raws.text = [{raw: 'f\\6f o', value: 'foo'}]; + expect(node.toString()).toBe('f\\6f obarbaz'); + }); + + it("ignored when the value doesn't match", () => { + node.raws.text = [{raw: 'f\\6f o', value: 'bar'}]; + expect(node.toString()).toBe('foobarbaz'); + }); + }); + + describe('with expressions', () => { + beforeEach( + () => + void (node = new Interpolation({ + nodes: [{text: 'foo'}, {text: 'bar'}], + })) + ); + + it('with before', () => { + node.raws.expressions = [{before: '/**/'}]; + expect(node.toString()).toBe('#{/**/foo}#{bar}'); + }); + + it('with after', () => { + node.raws.expressions = [{after: '/**/'}]; + expect(node.toString()).toBe('#{foo/**/}#{bar}'); + }); + }); + }); + + describe('clone', () => { + let original: Interpolation; + beforeEach( + () => + void (original = new Interpolation({ + nodes: ['foo', {text: 'bar'}, 'baz'], + raws: {expressions: [{before: ' '}]}, + })) + ); + + describe('with no overrides', () => { + let clone: Interpolation; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nodes', () => { + expect(clone.nodes).toHaveLength(3); + expect(clone.nodes[0]).toBe('foo'); + expect(clone.nodes[1]).toHaveInterpolation('text', 'bar'); + expect(clone.nodes[1]).toHaveProperty('parent', clone); + expect(clone.nodes[2]).toBe('baz'); + }); + + it('raws', () => + expect(clone.raws).toEqual({expressions: [{before: ' '}]})); + + 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[1]).toHaveProperty('parent', clone)); + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {expressions: [{after: ' '}]}}).raws + ).toEqual({expressions: [{after: ' '}]})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + expressions: [{before: ' '}], + })); + }); + + describe('nodes', () => { + it('defined', () => + expect(original.clone({nodes: ['qux']}).nodes).toEqual(['qux'])); + + it('undefined', () => { + const clone = original.clone({nodes: undefined}); + expect(clone.nodes).toHaveLength(3); + expect(clone.nodes[0]).toBe('foo'); + expect(clone.nodes[1]).toHaveInterpolation('text', 'bar'); + expect(clone.nodes[1]).toHaveProperty('parent', clone); + expect(clone.nodes[2]).toBe('baz'); + }); + }); + }); + }); + + it('toJSON', () => + expect( + (scss.parse('@foo#{bar}baz').nodes[0] as GenericAtRule).nameInterpolation + ).toMatchSnapshot()); +}); + +/** + * Runs `node.each`, asserting that it sees each element 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 | Expression, number] | string | Expression)[], + 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 [value, index] = Array.isArray(element) ? element : [element, i]; + expect(fn).toHaveBeenNthCalledWith(i + 1, value, index); + } + expect(fn).toHaveBeenCalledTimes(elements.length); +} diff --git a/pkg/sass-parser/lib/src/interpolation.ts b/pkg/sass-parser/lib/src/interpolation.ts new file mode 100644 index 000000000..8ffbb94a2 --- /dev/null +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -0,0 +1,421 @@ +// 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 {fromProps} from './expression/from-props'; +import {Expression, ExpressionProps} from './expression'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import type * as sassInternal from './sass-internal'; +import * as utils from './utils'; + +/** + * The type of new nodes that can be passed into an interpolation. + * + * @category Expression + */ +export type NewNodeForInterpolation = + | Interpolation + | ReadonlyArray + | Expression + | ReadonlyArray + | ExpressionProps + | ReadonlyArray + | string + | ReadonlyArray + | undefined; + +/** + * The initializer properties for {@link Interpolation} + * + * @category Expression + */ +export interface InterpolationProps { + nodes: ReadonlyArray; + raws?: InterpolationRaws; +} + +/** + * Raws indicating how to precisely serialize an {@link Interpolation} node. + * + * @category Expression + */ +export interface InterpolationRaws { + /** + * The text written in the stylesheet for the plain-text portions of the + * interpolation, without any interpretation of escape sequences. + * + * `raw` is the value of the raw itself, and `value` is the parsed value + * that's required to be in the interpolation in order for this raw to be used. + * + * Any indices for which {@link Interpolation.nodes} doesn't contain a string + * are ignored. + */ + text?: Array<{raw: string; value: string} | undefined>; + + /** + * The whitespace before and after each interpolated expression. + * + * Any indices for which {@link Interpolation.nodes} doesn't contain an + * expression are ignored. + */ + expressions?: Array<{before?: string; after?: string} | undefined>; +} + +// Note: unlike the Dart Sass interpolation class, this does *not* guarantee +// that there will be no adjacent strings. Doing so for user modification would +// cause any active iterators to skip the merged string, and the collapsing +// doesn't provide a tremendous amount of user benefit. + +/** + * Sass text that can contian expressions interpolated within it. + * + * This is not itself an expression. Instead, it's used as a field of + * expressions and statements, and acts as a container for further expressions. + * + * @category Expression + */ +export class Interpolation extends Node { + readonly sassType = 'interpolation' as const; + declare raws: InterpolationRaws; + + /** + * An array containing the contents of the interpolation. + * + * Strings in this array represent the raw text in which interpolation (might) + * appear, and expressions represent the interpolated Sass expressions. + * + * This shouldn't be modified directly; instead, the various methods defined + * in {@link Interpolation} should be used to modify it. + */ + 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 _nodes?: Array; + + /** Returns whether this contains no interpolated expressions. */ + get isPlain(): boolean { + return this.asPlain !== null; + } + + /** + * If this contains no interpolated expressions, returns its text contents. + * Otherwise, returns `null`. + */ + get asPlain(): string | null { + if (this.nodes.length === 0) return ''; + if (this.nodes.some(node => typeof node !== 'string')) return null; + return this.nodes.join(''); + } + + /** + * Iterators that are currently active within this interpolation. 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?: InterpolationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Interpolation); + constructor(defaults?: object, inner?: sassInternal.Interpolation) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + // TODO: set lazy raws here to use when stringifying + this._nodes = []; + for (const child of inner.contents) { + this.append( + typeof child === 'string' ? child : convertExpression(child) + ); + } + } + 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); + } + + /** + * 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 { + 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); + } + } + + /** + * Returns `true` if {@link condition} returns `true` for all of the + * container’s children. + */ + every( + condition: ( + node: string | Expression, + index: number, + nodes: ReadonlyArray + ) => boolean + ): boolean { + 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 + ): 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; + } + + /** + * 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 + ): 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; + } + + /** 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); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + 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) { + if (typeof node !== 'string') node.parent = undefined; + } + this._nodes!.length = 0; + 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); + if (typeof child === 'object') child.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + /** + * Returns `true` if {@link condition} returns `true` for (at least) one of + * the container’s children. + */ + some( + condition: ( + node: string | Expression, + index: number, + nodes: ReadonlyArray + ) => boolean + ): boolean { + 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]; + } + + /** @hidden */ + toString(): string { + let result = ''; + + const rawText = this.raws.text; + const rawExpressions = this.raws.expressions; + for (let i = 0; i < this.nodes.length; i++) { + const element = this.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + result += raw?.value === element ? raw.raw : element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + return result; + } + + /** + * Normalizes the many types of node that can be used with Interpolation + * methods. + */ + private _normalize(nodes: NewNodeForInterpolation): (Expression | string)[] { + const result: Array = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if (typeof node === 'string') { + if (node.length === 0) continue; + result.push(node); + } else if ('sassType' in node) { + if (node.sassType === 'interpolation') { + for (const subnode of node.nodes) { + if (typeof subnode === 'string') { + if (node.nodes.length === 0) continue; + result.push(subnode); + } else { + subnode.parent = this; + result.push(subnode); + } + } + node._nodes!.length = 0; + } else { + node.parent = this; + result.push(node); + } + } else { + const constructed = fromProps(node); + constructed.parent = this; + result.push(constructed); + } + } + return result; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray + ): (Expression | string)[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes.filter( + (node): node is Expression => typeof node !== 'string' + ); + } +} diff --git a/pkg/sass-parser/lib/src/lazy-source.ts b/pkg/sass-parser/lib/src/lazy-source.ts new file mode 100644 index 000000000..5deeadb4f --- /dev/null +++ b/pkg/sass-parser/lib/src/lazy-source.ts @@ -0,0 +1,74 @@ +// 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 sass from 'sass'; +import * as postcss from 'postcss'; +import * as url from 'url'; + +import type * as sassInternal from './sass-internal'; + +/** + * An implementation of `postcss.Source` that lazily fills in the fields when + * they're first accessed. + */ +export class LazySource implements postcss.Source { + /** + * The Sass node whose source this covers. We store the whole node rather than + * just the span becasue the span itself may be computed lazily. + */ + readonly #inner: sassInternal.SassNode; + + constructor(inner: sassInternal.SassNode) { + this.#inner = inner; + } + + get start(): postcss.Position | undefined { + if (this.#start === 0) { + this.#start = locationToPosition(this.#inner.span.start); + } + return this.#start; + } + set start(value: postcss.Position | undefined) { + this.#start = value; + } + #start: postcss.Position | undefined | 0 = 0; + + get end(): postcss.Position | undefined { + if (this.#end === 0) { + this.#end = locationToPosition(this.#inner.span.end); + } + return this.#end; + } + set end(value: postcss.Position | undefined) { + this.#end = value; + } + #end: postcss.Position | undefined | 0 = 0; + + get input(): postcss.Input { + if (this.#input) return this.#input; + + const sourceFile = this.#inner.span.file; + if (sourceFile._postcssInput) return sourceFile._postcssInput; + + const spanUrl = this.#inner.span.url; + sourceFile._postcssInput = new postcss.Input( + sourceFile.getText(0), + spanUrl ? {from: url.fileURLToPath(spanUrl)} : undefined + ); + return sourceFile._postcssInput; + } + set input(value: postcss.Input) { + this.#input = value; + } + #input: postcss.Input | null = null; +} + +/** Converts a Sass SourceLocation to a PostCSS Position. */ +function locationToPosition(location: sass.SourceLocation): postcss.Position { + return { + line: location.line + 1, + column: location.column + 1, + offset: location.offset, + }; +} diff --git a/pkg/sass-parser/lib/src/node.d.ts b/pkg/sass-parser/lib/src/node.d.ts new file mode 100644 index 000000000..8841a46a0 --- /dev/null +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -0,0 +1,98 @@ +// 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 {AnyExpression, ExpressionType} from './expression'; +import {Interpolation} from './interpolation'; +import {AnyStatement, Statement, StatementType} from './statement'; + +/** The union type of all Sass nodes. */ +export type AnyNode = AnyStatement | AnyExpression | Interpolation; + +/** + * All Sass node types. + * + * This is a superset of the node types PostCSS exposes, and is provided + * alongside `Node.type` to disambiguate between the wide range of nodes that + * Sass parses as distinct types. + */ +export type NodeType = StatementType | ExpressionType | 'interpolation'; + +/** The constructor properties shared by all Sass AST nodes. */ +export type NodeProps = postcss.NodeProps; + +/** + * Any node in a Sass stylesheet. + * + * All nodes that Sass can parse implement this type, including expression-level + * nodes, selector nodes, and nodes from more domain-specific syntaxes. It aims + * to match the PostCSS API as closely as possible while still being generic + * enough to work across multiple more than just statements. + * + * This does _not_ include methods for adding and modifying siblings of this + * Node, because these only make sense for expression-level Node types. + */ +declare abstract class Node + implements + Omit< + postcss.Node, + | 'after' + | 'assign' + | 'before' + | 'clone' + | 'cloneAfter' + | 'cloneBefore' + | 'next' + | 'prev' + | 'remove' + // TODO: supporting replaceWith() would be tricky, but it does have + // well-defined semantics even without a nodes array and it's awfully + // useful. See if we can find a way. + | 'replaceWith' + | 'type' + | 'parent' + | 'toString' + > +{ + abstract readonly sassType: NodeType; + parent: Node | undefined; + source?: postcss.Source; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + raws: any; + + /** + * A list of children of this node, *not* including any {@link Statement}s it + * contains. This is used internally to traverse the full AST. + * + * @hidden + */ + abstract get nonStatementChildren(): ReadonlyArray>; + + constructor(defaults?: object); + + assign(overrides: object): this; + cleanRaws(keepBetween?: boolean): void; + error( + message: string, + options?: postcss.NodeErrorOptions + ): postcss.CssSyntaxError; + positionBy( + opts?: Pick + ): postcss.Position; + positionInside(index: number): postcss.Position; + rangeBy(opts?: Pick): { + start: postcss.Position; + end: postcss.Position; + }; + raw(prop: string, defaultType?: string): string; + root(): postcss.Root; + toJSON(): object; + warn( + result: postcss.Result, + message: string, + options?: postcss.WarningOptions + ): postcss.Warning; +} diff --git a/pkg/sass-parser/lib/src/node.js b/pkg/sass-parser/lib/src/node.js new file mode 100644 index 000000000..5ce9c3493 --- /dev/null +++ b/pkg/sass-parser/lib/src/node.js @@ -0,0 +1,47 @@ +// 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. + +const postcss = require('postcss'); + +// Define this separately from the declaration so that we can have it inherit +// all the methods of the base class and make a few of them throw without it +// showing up in the TypeScript types. +class Node extends postcss.Node { + constructor(defaults = {}) { + super(defaults); + } + + after() { + throw new Error("after() is only supported for Sass statement nodes."); + } + + before() { + throw new Error("before() is only supported for Sass statement nodes."); + } + + cloneAfter() { + throw new Error("cloneAfter() is only supported for Sass statement nodes."); + } + + cloneBefore() { + throw new Error("cloneBefore() is only supported for Sass statement nodes."); + } + + next() { + throw new Error("next() is only supported for Sass statement nodes."); + } + + prev() { + throw new Error("prev() is only supported for Sass statement nodes."); + } + + remove() { + throw new Error("remove() is only supported for Sass statement nodes."); + } + + replaceWith() { + throw new Error("replaceWith() is only supported for Sass statement nodes."); + } +} +exports.Node = Node; diff --git a/pkg/sass-parser/lib/src/postcss.d.ts b/pkg/sass-parser/lib/src/postcss.d.ts new file mode 100644 index 000000000..9d9783fe2 --- /dev/null +++ b/pkg/sass-parser/lib/src/postcss.d.ts @@ -0,0 +1,5 @@ +// 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. + +export const isClean: unique symbol; diff --git a/pkg/sass-parser/lib/src/postcss.js b/pkg/sass-parser/lib/src/postcss.js new file mode 100644 index 000000000..022a0e3f2 --- /dev/null +++ b/pkg/sass-parser/lib/src/postcss.js @@ -0,0 +1,5 @@ +// 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. + +exports.isClean = require('postcss/lib/symbols').isClean; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts new file mode 100644 index 000000000..4f18be58b --- /dev/null +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -0,0 +1,191 @@ +// 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 sass from 'sass'; +import * as postcss from 'postcss'; + +import type * as binaryOperation from './expression/binary-operation'; + +// Type definitions for internal Sass APIs we're wrapping. We cast the Sass +// module to this type to access them. + +export type Syntax = 'scss' | 'sass' | 'css'; + +export interface FileSpan extends sass.SourceSpan { + readonly file: SourceFile; +} + +export interface SourceFile { + /** Node-only extension that we use to avoid re-creating inputs. */ + _postcssInput?: postcss.Input; + + readonly codeUnits: number[]; + + getText(start: number, end?: number): string; +} + +// 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 { + function parse( + css: string, + syntax: Syntax, + path?: string, + logger?: sass.Logger + ): Stylesheet; + + class StatementVisitor { + private _fakePropertyToMakeThisAUniqueType1: T; + } + + function createStatementVisitor( + inner: StatementVisitorObject + ): StatementVisitor; + + class ExpressionVisitor { + private _fakePropertyToMakeThisAUniqueType2: T; + } + + function createExpressionVisitor( + inner: ExpressionVisitorObject + ): ExpressionVisitor; + + class SassNode { + readonly span: FileSpan; + } + + class Interpolation extends SassNode { + contents: (string | Expression)[]; + get asPlain(): string | undefined; + } + + class Statement extends SassNode { + accept(visitor: StatementVisitor): T; + } + + class ParentStatement extends Statement { + readonly children: T; + } + + class AtRootRule extends ParentStatement { + readonly name: Interpolation; + readonly query?: Interpolation; + } + + class AtRule extends ParentStatement { + readonly name: Interpolation; + readonly value?: Interpolation; + } + + class DebugRule extends Statement { + readonly expression: Expression; + } + + class EachRule extends ParentStatement { + readonly variables: string[]; + readonly list: Expression; + } + + class ErrorRule extends Statement { + readonly expression: Expression; + } + + class ExtendRule extends Statement { + readonly selector: Interpolation; + readonly isOptional: boolean; + } + + class ForRule extends ParentStatement { + readonly variable: string; + readonly from: Expression; + readonly to: Expression; + readonly isExclusive: boolean; + } + + class LoudComment extends Statement { + readonly text: Interpolation; + } + + class MediaRule extends ParentStatement { + readonly query: Interpolation; + } + + class SilentComment extends Statement { + readonly text: string; + } + + class Stylesheet extends ParentStatement {} + + class StyleRule extends ParentStatement { + readonly selector: Interpolation; + } + + class Expression extends SassNode { + accept(visitor: ExpressionVisitor): T; + } + + class BinaryOperator { + readonly operator: binaryOperation.BinaryOperator; + } + + class BinaryOperationExpression extends Expression { + readonly operator: BinaryOperator; + readonly left: Expression; + readonly right: Expression; + readonly hasQuotes: boolean; + } + + class StringExpression extends Expression { + readonly text: Interpolation; + readonly hasQuotes: boolean; + } +} + +const sassInternal = ( + sass as unknown as {loadParserExports_(): typeof SassInternal} +).loadParserExports_(); + +export type SassNode = SassInternal.SassNode; +export type Statement = SassInternal.Statement; +export type ParentStatement = + SassInternal.ParentStatement; +export type AtRootRule = SassInternal.AtRootRule; +export type AtRule = SassInternal.AtRule; +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 LoudComment = SassInternal.LoudComment; +export type MediaRule = SassInternal.MediaRule; +export type SilentComment = SassInternal.SilentComment; +export type Stylesheet = SassInternal.Stylesheet; +export type StyleRule = SassInternal.StyleRule; +export type Interpolation = SassInternal.Interpolation; +export type Expression = SassInternal.Expression; +export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; +export type StringExpression = SassInternal.StringExpression; + +export interface StatementVisitorObject { + visitAtRootRule(node: AtRootRule): T; + visitAtRule(node: AtRule): T; + visitDebugRule(node: DebugRule): T; + visitEachRule(node: EachRule): T; + visitErrorRule(node: ErrorRule): T; + visitExtendRule(node: ExtendRule): T; + visitForRule(node: ForRule): T; + visitLoudComment(node: LoudComment): T; + visitMediaRule(node: MediaRule): T; + visitSilentComment(node: SilentComment): T; + visitStyleRule(node: StyleRule): T; +} + +export interface ExpressionVisitorObject { + visitBinaryOperationExpression(node: BinaryOperationExpression): T; + visitStringExpression(node: StringExpression): T; +} + +export const parse = sassInternal.parse; +export const createStatementVisitor = sassInternal.createStatementVisitor; +export const createExpressionVisitor = sassInternal.createExpressionVisitor; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap new file mode 100644 index 000000000..1e19f31d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a CSS-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "/* foo */", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "closed": true, + "left": " ", + "right": " ", + }, + "sassType": "comment", + "source": <1:1-1:10 in 0>, + "text": "foo", + "textInterpolation": , + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap new file mode 100644 index 000000000..621628a23 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @debug rule toJSON 1`] = ` +{ + "debugExpression": , + "inputs": [ + { + "css": "@debug foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "debug", + "params": "foo", + "raws": {}, + "sassType": "debug-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap new file mode 100644 index 000000000..75dd15404 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @each rule toJSON 1`] = ` +{ + "eachExpression": , + "inputs": [ + { + "css": "@each $foo, $bar in baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "each", + "nodes": [], + "params": "$foo, $bar in baz", + "raws": {}, + "sassType": "each-rule", + "source": <1:1-1:27 in 0>, + "type": "atrule", + "variables": [ + "foo", + "bar", + ], +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap new file mode 100644 index 000000000..9ed3f5667 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @error rule toJSON 1`] = ` +{ + "errorExpression": , + "inputs": [ + { + "css": "@error foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "error", + "params": "foo", + "raws": {}, + "sassType": "error-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap new file mode 100644 index 000000000..f96b0007f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @for rule toJSON 1`] = ` +{ + "fromExpression": , + "inputs": [ + { + "css": "@for $foo from bar to baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "for", + "nodes": [], + "params": "$foo from bar to baz", + "raws": {}, + "sassType": "for-rule", + "source": <1:1-1:29 in 0>, + "to": "to", + "toExpression": , + "type": "atrule", + "variable": "foo", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap new file mode 100644 index 000000000..2f4c3dd15 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a generic @-rule toJSON with a child 1`] = ` +{ + "inputs": [ + { + "css": "@foo {@bar}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "nodes": [ + <@bar;>, + ], + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:12 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON with empty children 1`] = ` +{ + "inputs": [ + { + "css": "@foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "nodes": [], + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:8 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON with params 1`] = ` +{ + "inputs": [ + { + "css": "@foo bar", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "bar", + "paramsInterpolation": , + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:9 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON without params 1`] = ` +{ + "inputs": [ + { + "css": "@foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:5 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap new file mode 100644 index 000000000..ee16e41cf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a root node toJSON with children 1`] = ` +{ + "inputs": [ + { + "css": "@foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:5 in 0>, + "type": "atrule", +} +`; + +exports[`a root node toJSON without children 1`] = ` +{ + "inputs": [ + { + "css": "", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [], + "raws": {}, + "sassType": "root", + "source": <1:1-1:1 in 0>, + "type": "root", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap new file mode 100644 index 000000000..792fc7e23 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a style rule toJSON with a child 1`] = ` +{ + "inputs": [ + { + "css": ".foo {@bar}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <@bar;>, + ], + "raws": {}, + "sassType": "rule", + "selector": ".foo ", + "selectorInterpolation": <.foo >, + "source": <1:1-1:12 in 0>, + "type": "rule", +} +`; + +exports[`a style rule toJSON with empty children 1`] = ` +{ + "inputs": [ + { + "css": ".foo {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [], + "raws": {}, + "sassType": "rule", + "selector": ".foo ", + "selectorInterpolation": <.foo >, + "source": <1:1-1:8 in 0>, + "type": "rule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap new file mode 100644 index 000000000..dc289b9ae --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a Sass-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "// foo", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "before": "", + "beforeLines": [ + "", + ], + "left": " ", + }, + "sassType": "sass-comment", + "source": <1:1-1:7 in 0>, + "text": "foo", + "type": "comment", +} +`; 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 new file mode 100644 index 000000000..b5c7a17f7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -0,0 +1,140 @@ +// 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, Rule, scss} from '../..'; + +describe('an @at-root rule', () => { + let node: GenericAtRule; + + describe('with no params', () => { + beforeEach( + () => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + }); + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: rule) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)')); + + it('has matching params', () => expect(node.params).toBe('(with: rule)')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: #{rule}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('(with: '); + expect(params).toHaveStringExpression(1, 'rule'); + expect(params.nodes[2]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(with: #{rule})')); + }); + + describe('with style rule shorthand', () => { + beforeEach( + () => + void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + + it('contains a Rule', () => { + const rule = node.nodes[0] as Rule; + expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); + expect(rule.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with atRootShorthand: false', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: false}, + }).toString() + ).toBe('@at-root {\n .foo {}\n}')); + + describe('with atRootShorthand: true', () => { + it('with no params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root .foo {}')); + + it('with no params and multiple children', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}, {selector: '.bar'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root {\n .foo {}\n .bar {}\n}')); + + it('with no params and a non-style-rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{name: 'foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root {\n @foo\n}')); + + it('with params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + params: '(with: rule)', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-root (with: rule) {\n .foo {}\n}')); + + it("that's not @at-root", () => + expect( + new GenericAtRule({ + name: 'at-wrong', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString() + ).toBe('@at-wrong {\n .foo {}\n}')); + }); + }); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts new file mode 100644 index 000000000..7228613f8 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts @@ -0,0 +1,77 @@ +// 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 {Rule} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _AtRule extends postcss.AtRule { + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.js b/pkg/sass-parser/lib/src/statement/at-rule-internal.js new file mode 100644 index 000000000..70634ab1e --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.js @@ -0,0 +1,5 @@ +// 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. + +exports._AtRule = require('postcss').AtRule; diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.d.ts b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts new file mode 100644 index 000000000..eb49874bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts @@ -0,0 +1,31 @@ +// 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 {Root} from './root'; +import {ChildNode, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Comment extends postcss.Comment { + // Override the PostCSS types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; +} diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.js b/pkg/sass-parser/lib/src/statement/comment-internal.js new file mode 100644 index 000000000..3304da6b3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.js @@ -0,0 +1,5 @@ +// 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. + +exports._Comment = require('postcss').Comment; diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts new file mode 100644 index 000000000..46ab1fad6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/container.test.ts @@ -0,0 +1,188 @@ +// 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 {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(); + }); + + 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); + }); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.test.ts b/pkg/sass-parser/lib/src/statement/css-comment.test.ts new file mode 100644 index 000000000..ae1e5bc65 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.test.ts @@ -0,0 +1,325 @@ +// 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 {CssComment, Interpolation, Root, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a CSS-style comment', () => { + let node: CssComment; + function describeNode(description: string, create: () => CssComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType comment', () => expect(node.sassType).toBe('comment')); + + it('has matching textInterpolation', () => + expect(node).toHaveInterpolation('textInterpolation', 'foo')); + + it('has matching text', () => expect(node.text).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as CSS', + () => css.parse('/* foo */').nodes[0] as CssComment + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('/* foo').nodes[0] as CssComment + ); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new CssComment({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => new CssComment({text: 'foo'})); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }) + ); + + describeNode('with a text string', () => + utils.fromChildProps({text: 'foo'}) + ); + }); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with whitespace before and after text', () => + expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + + it('with whitespace before and after interpolation', () => + expect( + (scss.parse('/* #{foo} */').nodes[0] as CssComment).raws + ).toEqual({left: ' ', right: ' ', closed: true})); + + it('without whitespace before and after text', () => + expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('without whitespace before and after interpolation', () => + expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('with whitespace and no text', () => + expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: true, + })); + + it('with no whitespace and no text', () => + expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + }); + + describe('in Sass', () => { + // TODO: Test explicit whitespace after text and interpolation once we + // properly parse raws from somewhere other than the original text. + + it('with whitespace before text', () => + expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('with whitespace before interpolation', () => + expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('without whitespace before and after text', () => + expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('without whitespace before and after interpolation', () => + expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with no whitespace and no text', () => + expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with a trailing */', () => + expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */')); + + it('with left', () => + expect( + new CssComment({ + text: 'foo', + raws: {left: '\n'}, + }).toString() + ).toBe('/*\nfoo */')); + + it('with right', () => + expect( + new CssComment({ + text: 'foo', + raws: {right: '\n'}, + }).toString() + ).toBe('/* foo\n*/')); + + it('with before', () => + expect( + new Root({ + nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})], + }).toString() + ).toBe('/**//* foo */')); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('/* foo */').nodes[0] as CssComment; + }); + + it("removes the old text's parent", () => { + const oldText = node.textInterpolation!; + node.textInterpolation = 'bar'; + expect(oldText.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(node.textInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.textInterpolation = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + + it('assigns the interpolation as text', () => { + node.text = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + }); + + describe('clone', () => { + let original: CssComment; + beforeEach( + () => void (original = scss.parse('/* foo */').nodes[0] as CssComment) + ); + + describe('with no overrides', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + + it('text', () => expect(clone.text).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['textInterpolation', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'bar')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('textInterpolation', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({ + textInterpolation: new Interpolation({nodes: ['baz']}), + }); + }); + + it('changes text', () => expect(clone.text).toBe('baz')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'baz')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({textInterpolation: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {right: ' '}}).raws).toEqual({ + right: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts new file mode 100644 index 000000000..f2565caa0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.ts @@ -0,0 +1,164 @@ +// 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 {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 * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link CssComment}. + * + * @category Statement + */ +export interface CssCommentRaws extends CommentRaws { + /** + * In the indented syntax, this indicates whether a comment is explicitly + * closed with a `*\/`. It's ignored in other syntaxes. + * + * It defaults to false. + */ + closed?: boolean; +} + +/** + * The initializer properties for {@link CssComment}. + * + * @category Statement + */ +export type CssCommentProps = ContainerProps & { + raws?: CssCommentRaws; +} & ({text: string} | {textInterpolation: Interpolation | string}); + +/** + * A CSS-style "loud" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class CssComment + extends _Comment> + implements Statement +{ + readonly sassType = 'comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: CssCommentRaws; + + get text(): string { + return this.textInterpolation.toString(); + } + set text(value: string) { + this.textInterpolation = value; + } + + /** The interpolation that represents this selector's contents. */ + get textInterpolation(): Interpolation { + return this._textInterpolation!; + } + set textInterpolation(textInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._textInterpolation) { + this._textInterpolation.parent = undefined; + } + if (typeof textInterpolation === 'string') { + textInterpolation = new Interpolation({ + nodes: [textInterpolation], + }); + } + textInterpolation.parent = this; + this._textInterpolation = textInterpolation; + } + private _textInterpolation?: Interpolation; + + constructor(defaults: CssCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.LoudComment); + constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + const nodes = [...inner.text.contents]; + + // The interpolation's contents are guaranteed to begin with a string, + // because Sass includes the `/*`. + let first = nodes[0] as string; + const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!; + this.raws.left ??= firstMatch[1]; + first = first.substring(firstMatch[0].length); + if (first.length === 0) { + nodes.shift(); + } else { + nodes[0] = first; + } + + // The interpolation will end with `*/` in SCSS, but not necessarily in + // the indented syntax. + let last = nodes.at(-1); + if (typeof last === 'string') { + const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/); + this.raws.right ??= lastMatch?.[1] ?? ''; + this.raws.closed = !!lastMatch; + if (lastMatch) { + last = last.substring(0, last.length - lastMatch[0].length); + if (last.length === 0) { + nodes.pop(); + } else { + nodes[0] = last; + } + } + } else { + this.raws.right ??= ''; + this.raws.closed = false; + } + + this.textInterpolation = new Interpolation(); + for (const child of nodes) { + this.textInterpolation.append( + typeof child === 'string' ? child : convertExpression(child) + ); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'textInterpolation'], + ['text'] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'textInterpolation'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.textInterpolation]; + } +} + +interceptIsClean(CssComment); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts new file mode 100644 index 000000000..2ac421dbc --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts @@ -0,0 +1,205 @@ +// 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 {DebugRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @debug rule', () => { + let node: DebugRule; + function describeNode(description: string, create: () => DebugRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('debug')); + + it('has an expression', () => + expect(node).toHaveStringExpression('debugExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@debug foo').nodes[0] as DebugRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@debug foo').nodes[0] as DebugRule + ); + + describeNode( + 'constructed manually', + () => + new DebugRule({ + debugExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + debugExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new DebugRule({ + debugExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@debug foo').nodes[0] as DebugRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.debugExpression; + node.debugExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(node.debugExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.debugExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + }).toString() + ).toBe('@debug foo;')); + + it('with afterName', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@debug/**/foo;')); + + it('with between', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@debug foo/**/;')); + }); + }); + + describe('clone', () => { + let original: DebugRule; + beforeEach(() => { + original = scss.parse('@debug foo').nodes[0] as DebugRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: DebugRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', '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 ['debugExpression', '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('debugExpression', () => { + describe('defined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@debug foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts new file mode 100644 index 000000000..d0030d81d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-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 DebugRule}. + * + * @category Statement + */ +export type DebugRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link DebugRule}. + * + * @category Statement + */ +export type DebugRuleProps = postcss.NodeProps & { + raws?: DebugRuleRaws; + debugExpression: Expression | ExpressionProps; +}; + +/** + * A `@debug` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class DebugRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'debug-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: DebugRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'debug'; + } + set name(value: string) { + throw new Error("DebugRule.name can't be overwritten."); + } + + get params(): string { + return this.debugExpression.toString(); + } + set params(value: string | number | undefined) { + this.debugExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the debug rule is executed. */ + get debugExpression(): Expression { + return this._debugExpression!; + } + set debugExpression(debugExpression: Expression | ExpressionProps) { + if (this._debugExpression) this._debugExpression.parent = undefined; + if (!('sassType' in debugExpression)) { + debugExpression = fromProps(debugExpression); + } + if (debugExpression) debugExpression.parent = this; + this._debugExpression = debugExpression; + } + private _debugExpression?: Expression; + + constructor(defaults: DebugRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.DebugRule); + constructor(defaults?: DebugRuleProps, inner?: sassInternal.DebugRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.debugExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'debugExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'debugExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.debugExpression]; + } +} + +interceptIsClean(DebugRule); diff --git a/pkg/sass-parser/lib/src/statement/each-rule.test.ts b/pkg/sass-parser/lib/src/statement/each-rule.test.ts new file mode 100644 index 000000000..d70d53992 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.test.ts @@ -0,0 +1,302 @@ +// 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 {EachRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @each rule', () => { + let node: EachRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@each $foo, $bar in baz').nodes[0] as EachRule + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }) + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@each $foo, $bar in baz {@child}').nodes[0] as EachRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@each $foo, $bar in baz\n @child').nodes[0] as EachRule + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + })) + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip, $zap in qux')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.eachExpression; + node.eachExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(node.eachExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.eachExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('eachExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }).toString() + ).toBe('@each $foo, $bar in baz {}')); + + it('with afterName', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@each/**/$foo, $bar in baz {}')); + + it('with afterVariables', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterVariables: ['/**/,', '/* */']}, + }).toString() + ).toBe('@each $foo/**/,$bar/* */in baz {}')); + + it('with afterIn', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterIn: '/**/'}, + }).toString() + ).toBe('@each $foo, $bar in/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: EachRule; + beforeEach(() => { + original = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: EachRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo, $bar in baz')); + + it('variables', () => expect(clone.variables).toEqual(['foo', 'bar'])); + + it('eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + + 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 ['variables', 'eachExpression', '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('variables', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: ['zip', 'zap']}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip, $zap in baz')); + + it('changes variables', () => + expect(clone.variables).toEqual(['zip', 'zap'])); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves variables', () => + expect(clone.variables).toEqual(['foo', 'bar'])); + }); + }); + + describe('eachExpression', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo, $bar in qux')); + + it('changes eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@each $foo, $bar in baz {}').nodes[0] + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts new file mode 100644 index 000000000..e0339f961 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.ts @@ -0,0 +1,165 @@ +// 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 {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 { + 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 EachRule}. + * + * @category Statement + */ +export interface EachRuleRaws extends Omit { + /** + * The whitespace and commas after each variable in + * {@link EachRule.variables}. + * + * The element at index `i` is included after the variable at index `i`. Any + * elements beyond `variables.length` are ignored. + */ + afterVariables?: string[]; + + /** The whitespace between `in` and {@link EachRule.eachExpression}. */ + afterIn?: string; +} + +/** + * The initializer properties for {@link EachRule}. + * + * @category Statement + */ +export type EachRuleProps = ContainerProps & { + raws?: EachRuleRaws; + variables: string[]; + eachExpression: Expression | ExpressionProps; +}; + +/** + * An `@each` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class EachRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'each-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: EachRuleRaws; + declare nodes: ChildNode[]; + + /** The variable names assigned for each iteration, without `"$"`. */ + declare variables: string[]; + + get name(): string { + return 'each'; + } + set name(value: string) { + throw new Error("EachRule.name can't be overwritten."); + } + + get params(): string { + let result = ''; + for (let i = 0; i < this.variables.length; i++) { + result += + '$' + + this.variables[i] + + (this.raws?.afterVariables?.[i] ?? + (i === this.variables.length - 1 ? ' ' : ', ')); + } + return `${result}in${this.raws.afterIn ?? ' '}${this.eachExpression}`; + } + set params(value: string | number | undefined) { + throw new Error("EachRule.params can't be overwritten."); + } + + /** The expresison whose value is iterated over. */ + get eachExpression(): Expression { + return this._eachExpression!; + } + set eachExpression(eachExpression: Expression | ExpressionProps) { + if (this._eachExpression) this._eachExpression.parent = undefined; + if (!('sassType' in eachExpression)) { + eachExpression = fromProps(eachExpression); + } + if (eachExpression) eachExpression.parent = this; + this._eachExpression = eachExpression; + } + private _eachExpression?: Expression; + + constructor(defaults: EachRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.EachRule); + constructor(defaults?: EachRuleProps, inner?: sassInternal.EachRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variables = [...inner.variables]; + this.eachExpression = convertExpression(inner.list); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variables', + 'eachExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'variables', 'eachExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.eachExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(EachRule); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.test.ts b/pkg/sass-parser/lib/src/statement/error-rule.test.ts new file mode 100644 index 000000000..0524338bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-rule.test.ts @@ -0,0 +1,205 @@ +// 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 {ErrorRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @error rule', () => { + let node: ErrorRule; + function describeNode(description: string, create: () => ErrorRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('error')); + + it('has an expression', () => + expect(node).toHaveStringExpression('errorExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@error foo').nodes[0] as ErrorRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@error foo').nodes[0] as ErrorRule + ); + + describeNode( + 'constructed manually', + () => + new ErrorRule({ + errorExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + errorExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new ErrorRule({ + errorExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@error foo').nodes[0] as ErrorRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.errorExpression; + node.errorExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(node.errorExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.errorExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + }).toString() + ).toBe('@error foo;')); + + it('with afterName', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@error/**/foo;')); + + it('with between', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@error foo/**/;')); + }); + }); + + describe('clone', () => { + let original: ErrorRule; + beforeEach(() => { + original = scss.parse('@error foo').nodes[0] as ErrorRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ErrorRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', '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 ['errorExpression', '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('errorExpression', () => { + describe('defined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@error foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts new file mode 100644 index 000000000..7b55f4253 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-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 ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleProps = postcss.NodeProps & { + raws?: ErrorRuleRaws; + errorExpression: Expression | ExpressionProps; +}; + +/** + * An `@error` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ErrorRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'error-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ErrorRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'error'; + } + set name(value: string) { + throw new Error("ErrorRule.name can't be overwritten."); + } + + get params(): string { + return this.errorExpression.toString(); + } + set params(value: string | number | undefined) { + this.errorExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is thrown when the error rule is executed. */ + get errorExpression(): Expression { + return this._errorExpression!; + } + set errorExpression(errorExpression: Expression | ExpressionProps) { + if (this._errorExpression) this._errorExpression.parent = undefined; + if (!('sassType' in errorExpression)) { + errorExpression = fromProps(errorExpression); + } + if (errorExpression) errorExpression.parent = this; + this._errorExpression = errorExpression; + } + private _errorExpression?: Expression; + + constructor(defaults: ErrorRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ErrorRule); + constructor(defaults?: ErrorRuleProps, inner?: sassInternal.ErrorRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.errorExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'errorExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'errorExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.errorExpression]; + } +} + +interceptIsClean(ErrorRule); diff --git a/pkg/sass-parser/lib/src/statement/extend-rule.test.ts b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts new file mode 100644 index 000000000..15485b604 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts @@ -0,0 +1,61 @@ +// 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, Rule, scss} from '../..'; + +describe('an @extend rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .bar}').nodes[0] as Rule) + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '.bar')); + + it('has matching params', () => expect(node.params).toBe('.bar')); + }); + + describe('with interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .#{bar}}').nodes[0] as Rule) + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('.'); + expect(params).toHaveStringExpression(1, 'bar'); + }); + + it('has matching params', () => expect(node.params).toBe('.#{bar}')); + }); + + describe('with !optional', () => { + beforeEach( + () => + void (node = ( + scss.parse('.foo {@extend .bar !optional}').nodes[0] as Rule + ).nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '.bar !optional' + )); + + it('has matching params', () => expect(node.params).toBe('.bar !optional')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.test.ts b/pkg/sass-parser/lib/src/statement/for-rule.test.ts new file mode 100644 index 000000000..607dd4217 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.test.ts @@ -0,0 +1,437 @@ +// 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 {ForRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @for rule', () => { + let node: ForRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@for $foo from bar through baz {}').nodes[0] as ForRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@for $foo from bar through baz').nodes[0] as ForRule + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }) + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@for $foo from bar through baz {@child}') + .nodes[0] as ForRule + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@for $foo from bar through baz\n @child') + .nodes[0] as ForRule + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }) + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + })) + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip from zap to qux')).toThrow()); + }); + + describe('assigned a new from expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.fromExpression; + node.fromExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(node.fromExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.fromExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('fromExpression', 'qux'); + }); + }); + + describe('assigned a new to expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.toExpression; + node.toExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(node.toExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.toExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('toExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }).toString() + ).toBe('@for $foo from bar to baz {}')); + + it('with afterName', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@for/**/$foo from bar to baz {}')); + + it('with afterVariable', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterVariable: '/**/'}, + }).toString() + ).toBe('@for $foo/**/from bar to baz {}')); + + it('with afterFrom', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFrom: '/**/'}, + }).toString() + ).toBe('@for $foo from/**/bar to baz {}')); + + it('with afterFromExpression', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFromExpression: '/**/'}, + }).toString() + ).toBe('@for $foo from bar/**/to baz {}')); + + it('with afterTo', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterTo: '/**/'}, + }).toString() + ).toBe('@for $foo from bar to/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: ForRule; + beforeEach(() => { + original = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ForRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo from bar to baz')); + + it('variable', () => expect(clone.variable).toBe('foo')); + + it('to', () => expect(clone.to).toBe('to')); + + it('fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + + it('toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + + 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 [ + 'fromExpression', + 'toExpression', + '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('variable', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: 'zip'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip from bar to baz')); + + it('changes variable', () => expect(clone.variable).toBe('zip')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves variable', () => expect(clone.variable).toBe('foo')); + }); + }); + + describe('to', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: 'through'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar through baz')); + + it('changes tos', () => expect(clone.to).toBe('through')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves tos', () => expect(clone.to).toBe('to')); + }); + }); + + describe('fromExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from qux to baz')); + + it('changes fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + }); + }); + + describe('toExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar to qux')); + + it('changes toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@for $foo from bar to baz {}').nodes[0] + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts new file mode 100644 index 000000000..d67fe91f6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.ts @@ -0,0 +1,200 @@ +// 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 {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 { + 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 ForRule}. + * + * @category Statement + */ +export interface ForRuleRaws extends Omit { + /** The whitespace after {@link ForRule.variable}. */ + afterVariable?: string; + + /** The whitespace after a {@link ForRule}'s `from` keyword. */ + afterFrom?: string; + + /** The whitespace after {@link ForRule.fromExpression}. */ + afterFromExpression?: string; + + /** The whitespace after a {@link ForRule}'s `to` or `through` keyword. */ + afterTo?: string; +} + +/** + * The initializer properties for {@link ForRule}. + * + * @category Statement + */ +export type ForRuleProps = ContainerProps & { + raws?: ForRuleRaws; + variable: string; + fromExpression: Expression | ExpressionProps; + toExpression: Expression | ExpressionProps; + to?: 'to' | 'through'; +}; + +/** + * A `@for` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ForRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'for-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ForRuleRaws; + declare nodes: ChildNode[]; + + /** The variabl names assigned for for iteration, without `"$"`. */ + declare variable: string; + + /** + * The keyword that appears before {@link toExpression}. + * + * If this is `"to"`, the loop is exclusive; if it's `"through"`, the loop is + * inclusive. It defaults to `"to"` when creating a new `ForRule`. + */ + declare to: 'to' | 'through'; + + get name(): string { + return 'for'; + } + set name(value: string) { + throw new Error("ForRule.name can't be overwritten."); + } + + get params(): string { + return ( + `$${this.variable}${this.raws.afterVariable ?? ' '}from` + + `${this.raws.afterFrom ?? ' '}${this.fromExpression}` + + `${this.raws.afterFromExpression ?? ' '}${this.to}` + + `${this.raws.afterTo ?? ' '}${this.toExpression}` + ); + } + set params(value: string | number | undefined) { + throw new Error("ForRule.params can't be overwritten."); + } + + /** The expresison whose value is the starting point of the iteration. */ + get fromExpression(): Expression { + return this._fromExpression!; + } + set fromExpression(fromExpression: Expression | ExpressionProps) { + if (this._fromExpression) this._fromExpression.parent = undefined; + if (!('sassType' in fromExpression)) { + fromExpression = fromProps(fromExpression); + } + if (fromExpression) fromExpression.parent = this; + this._fromExpression = fromExpression; + } + private _fromExpression?: Expression; + + /** The expresison whose value is the ending point of the iteration. */ + get toExpression(): Expression { + return this._toExpression!; + } + set toExpression(toExpression: Expression | ExpressionProps) { + if (this._toExpression) this._toExpression.parent = undefined; + if (!('sassType' in toExpression)) { + toExpression = fromProps(toExpression); + } + if (toExpression) toExpression.parent = this; + this._toExpression = toExpression; + } + private _toExpression?: Expression; + + constructor(defaults: ForRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ForRule); + constructor(defaults?: ForRuleProps, inner?: sassInternal.ForRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variable = inner.variable; + this.to = inner.isExclusive ? 'to' : 'through'; + this.fromExpression = convertExpression(inner.from); + this.toExpression = convertExpression(inner.to); + appendInternalChildren(this, inner.children); + } + + this.to ??= 'to'; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + [ + 'name', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + 'params', + 'nodes', + ], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.fromExpression, this.toExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(ForRule); 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 new file mode 100644 index 000000000..c40dfab62 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts @@ -0,0 +1,793 @@ +// 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, Interpolation, Root, Rule, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a generic @-rule', () => { + let node: GenericAtRule; + describe('with no children', () => { + describe('with no params', () => { + function describeNode( + description: string, + create: () => GenericAtRule + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type atrule', () => expect(node.type).toBe('atrule')); + + it('has sassType atrule', () => expect(node.sassType).toBe('atrule')); + + it('has a nameInterpolation', () => + expect(node).toHaveInterpolation('nameInterpolation', 'foo')); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has empty params', () => expect(node.params).toBe('')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@foo').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as CSS', + () => css.parse('@foo').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as Sass', + () => 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('with a name string', () => + utils.fromChildProps({name: 'foo'}) + ); + }); + }); + + describe('with params', () => { + function describeNode( + description: string, + create: () => GenericAtRule + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('foo')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'bar')); + + it('has matching params', () => expect(node.params).toBe('bar')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@foo bar').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as CSS', + () => css.parse('@foo bar').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as Sass', + () => 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('with a param string', () => + utils.fromChildProps({name: 'foo', params: 'bar'}) + ); + }); + }); + }); + + describe('with empty children', () => { + describe('with no params', () => { + function describeNode( + description: string, + create: () => GenericAtRule + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has empty params', () => expect(node.params).toBe('')); + + it('has no nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@foo {}').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as CSS', + () => css.parse('@foo {}').nodes[0] as GenericAtRule + ); + + describeNode( + 'constructed manually', + () => new GenericAtRule({name: 'foo', nodes: []}) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({name: 'foo', nodes: []}) + ); + }); + + describe('with params', () => { + function describeNode( + description: string, + create: () => GenericAtRule + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('foo')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'bar ')); + + it('has matching params', () => expect(node.params).toBe('bar ')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@foo bar {}').nodes[0] as GenericAtRule + ); + + describeNode( + 'parsed as CSS', + () => css.parse('@foo bar {}').nodes[0] as GenericAtRule + ); + + describe('constructed manually', () => { + describeNode( + 'with params', + () => + new GenericAtRule({ + name: 'foo', + params: 'bar ', + nodes: [], + }) + ); + + describeNode( + 'with an interpolation', + () => + new GenericAtRule({ + name: 'foo', + paramsInterpolation: new Interpolation({nodes: ['bar ']}), + nodes: [], + }) + ); + }); + + describe('constructed from ChildProps', () => { + describeNode('with params', () => + utils.fromChildProps({ + name: 'foo', + params: 'bar ', + nodes: [], + }) + ); + + describeNode('with an interpolation', () => + utils.fromChildProps({ + name: 'foo', + paramsInterpolation: new Interpolation({nodes: ['bar ']}), + nodes: [], + }) + ); + }); + }); + }); + + describe('with a child', () => { + describe('with no params', () => { + describe('parsed as Sass', () => { + beforeEach(() => { + node = sass.parse('@foo\n .bar').nodes[0] as GenericAtRule; + }); + + it('has a name', () => expect(node.name).toBe('foo')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has empty params', () => expect(node.params).toBe('')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(Rule); + expect(node.nodes[0]).toHaveProperty('selector', '.bar\n'); + }); + }); + }); + + describe('with params', () => { + function describeNode( + description: string, + create: () => GenericAtRule + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('foo')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'bar')); + + it('has matching params', () => expect(node.params).toBe('bar')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(Rule); + expect(node.nodes[0]).toHaveProperty('selector', '.baz\n'); + }); + }); + } + + describeNode( + 'parsed as Sass', + () => sass.parse('@foo bar\n .baz').nodes[0] as GenericAtRule + ); + + describe('constructed manually', () => { + describeNode( + 'with params', + () => + new GenericAtRule({ + name: 'foo', + params: 'bar', + nodes: [{selector: '.baz\n'}], + }) + ); + }); + + describe('constructed from ChildProps', () => { + describeNode('with params', () => + utils.fromChildProps({ + name: 'foo', + params: 'bar', + nodes: [{selector: '.baz\n'}], + }) + ); + }); + }); + }); + + describe('assigned new name', () => { + beforeEach(() => { + node = scss.parse('@foo {}').nodes[0] as GenericAtRule; + }); + + it("removes the old name's parent", () => { + const oldName = node.nameInterpolation!; + node.nameInterpolation = 'bar'; + expect(oldName.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.nameInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.nameInterpolation = interpolation; + expect(node.nameInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.nameInterpolation = 'bar'; + expect(node).toHaveInterpolation('nameInterpolation', 'bar'); + }); + + it('assigns the interpolation as name', () => { + node.name = 'bar'; + expect(node).toHaveInterpolation('nameInterpolation', 'bar'); + }); + }); + + describe('assigned new params', () => { + beforeEach(() => { + node = scss.parse('@foo bar {}').nodes[0] as GenericAtRule; + }); + + it('removes the old interpolation', () => { + node.paramsInterpolation = undefined; + expect(node.paramsInterpolation).toBeUndefined(); + }); + + it('removes the old interpolation as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node.paramsInterpolation).toBeUndefined(); + }); + + it('removes the old interpolation as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node.paramsInterpolation).toBeUndefined(); + }); + + it("removes the old interpolation's parent", () => { + const oldParams = node.paramsInterpolation!; + node.paramsInterpolation = undefined; + expect(oldParams.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['baz']}); + node.paramsInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['baz']}); + node.paramsInterpolation = interpolation; + expect(node.paramsInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.paramsInterpolation = 'baz'; + expect(node).toHaveInterpolation('paramsInterpolation', 'baz'); + }); + + it('assigns the interpolation as params', () => { + node.params = 'baz'; + expect(node).toHaveInterpolation('paramsInterpolation', 'baz'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with undefined nodes', () => { + describe('without params', () => { + it('with default raws', () => + expect(new GenericAtRule({name: 'foo'}).toString()).toBe('@foo;')); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@foo/**/;')); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@foo/**/;')); + + it('with between', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {between: '/**/'}, + }).toString() + ).toBe('@foo/**/;')); + + it('with afterName and between', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/*afterName*/', between: '/*between*/'}, + }).toString() + ).toBe('@foo/*afterName*//*between*/;')); + }); + + describe('with params', () => { + it('with default raws', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + }).toString() + ).toBe('@foo baz;')); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@foo/**/baz;')); + + it('with between', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + raws: {between: '/**/'}, + }).toString() + ).toBe('@foo baz/**/;')); + }); + + it('with after', () => + expect( + new GenericAtRule({name: 'foo', raws: {after: '/**/'}}).toString() + ).toBe('@foo;')); + + it('with before', () => + expect( + new Root({ + nodes: [new GenericAtRule({name: 'foo', raws: {before: '/**/'}})], + }).toString() + ).toBe('/**/@foo')); + }); + + describe('with defined nodes', () => { + describe('without params', () => { + it('with default raws', () => + expect(new GenericAtRule({name: 'foo', nodes: []}).toString()).toBe( + '@foo {}' + )); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo/**/ {}')); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo/**/ {}')); + + it('with between', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {between: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo/**/{}')); + + it('with afterName and between', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {afterName: '/*afterName*/', between: '/*between*/'}, + nodes: [], + }).toString() + ).toBe('@foo/*afterName*//*between*/{}')); + }); + + describe('with params', () => { + it('with default raws', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + nodes: [], + }).toString() + ).toBe('@foo baz {}')); + + it('with afterName', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + raws: {afterName: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo/**/baz {}')); + + it('with between', () => + expect( + new GenericAtRule({ + name: 'foo', + paramsInterpolation: 'baz', + raws: {between: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo baz/**/{}')); + }); + + describe('with after', () => { + it('with no children', () => + expect( + new GenericAtRule({ + name: 'foo', + raws: {after: '/**/'}, + nodes: [], + }).toString() + ).toBe('@foo {/**/}')); + + it('with a child', () => + expect( + new GenericAtRule({ + name: 'foo', + nodes: [{selector: '.bar'}], + raws: {after: '/**/'}, + }).toString() + ).toBe('@foo {\n .bar {}/**/}')); + }); + + it('with before', () => + expect( + new Root({ + nodes: [ + new GenericAtRule({ + name: 'foo', + raws: {before: '/**/'}, + nodes: [], + }), + ], + }).toString() + ).toBe('/**/@foo {}')); + }); + }); + }); + + describe('clone', () => { + let original: GenericAtRule; + beforeEach(() => { + original = scss.parse('@foo bar {.baz {}}').nodes[0] as GenericAtRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: GenericAtRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('nameInterpolation', () => + expect(clone).toHaveInterpolation('nameInterpolation', 'foo')); + + it('name', () => expect(clone.name).toBe('foo')); + + it('params', () => expect(clone.params).toBe('bar ')); + + it('paramsInterpolation', () => + expect(clone).toHaveInterpolation('paramsInterpolation', 'bar ')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + + it('nodes', () => { + expect(clone.nodes).toHaveLength(1); + expect(clone.nodes[0]).toBeInstanceOf(Rule); + expect(clone.nodes[0]).toHaveProperty('selector', '.baz '); + }); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'nameInterpolation', + 'paramsInterpolation', + 'raws', + 'nodes', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => expect(clone.nodes[0].parent).toBe(clone)); + }); + }); + + describe('overrides', () => { + describe('name', () => { + describe('defined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({name: 'qux'}); + }); + + it('changes name', () => expect(clone.name).toBe('qux')); + + it('changes nameInterpolation', () => + expect(clone).toHaveInterpolation('nameInterpolation', 'qux')); + }); + + describe('undefined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({name: undefined}); + }); + + it('preserves name', () => expect(clone.name).toBe('foo')); + + it('preserves nameInterpolation', () => + expect(clone).toHaveInterpolation('nameInterpolation', 'foo')); + }); + }); + + describe('nameInterpolation', () => { + describe('defined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({ + nameInterpolation: new Interpolation({nodes: ['qux']}), + }); + }); + + it('changes name', () => expect(clone.name).toBe('qux')); + + it('changes nameInterpolation', () => + expect(clone).toHaveInterpolation('nameInterpolation', 'qux')); + }); + + describe('undefined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({nameInterpolation: undefined}); + }); + + it('preserves name', () => expect(clone.name).toBe('foo')); + + it('preserves nameInterpolation', () => + expect(clone).toHaveInterpolation('nameInterpolation', 'foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('params', () => { + describe('defined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({params: 'qux'}); + }); + + it('changes params', () => expect(clone.params).toBe('qux')); + + it('changes paramsInterpolation', () => + expect(clone).toHaveInterpolation('paramsInterpolation', 'qux')); + }); + + describe('undefined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({params: undefined}); + }); + + it('changes params', () => expect(clone.params).toBe('')); + + it('changes paramsInterpolation', () => + expect(clone.paramsInterpolation).toBeUndefined()); + }); + }); + + describe('paramsInterpolation', () => { + describe('defined', () => { + let clone: GenericAtRule; + let interpolation: Interpolation; + beforeEach(() => { + interpolation = new Interpolation({nodes: ['qux']}); + clone = original.clone({paramsInterpolation: interpolation}); + }); + + it('changes params', () => expect(clone.params).toBe('qux')); + + it('changes paramsInterpolation', () => + expect(clone).toHaveInterpolation('paramsInterpolation', 'qux')); + }); + + describe('undefined', () => { + let clone: GenericAtRule; + beforeEach(() => { + clone = original.clone({paramsInterpolation: undefined}); + }); + + it('changes params', () => expect(clone.params).toBe('')); + + it('changes paramsInterpolation', () => + expect(clone.paramsInterpolation).toBeUndefined()); + }); + }); + }); + }); + + describe('toJSON', () => { + it('without params', () => + expect(scss.parse('@foo').nodes[0]).toMatchSnapshot()); + + it('with params', () => + expect(scss.parse('@foo bar').nodes[0]).toMatchSnapshot()); + + it('with empty children', () => + expect(scss.parse('@foo {}').nodes[0]).toMatchSnapshot()); + + it('with a child', () => + expect(scss.parse('@foo {@bar}').nodes[0]).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts new file mode 100644 index 000000000..97ae32e05 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -0,0 +1,212 @@ +// 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 {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + AtRule, + 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 GenericAtRule}. + * + * Sass doesn't support PostCSS's `params` raws, since + * {@link GenericAtRule.paramInterpolation} has its own raws. + * + * @category Statement + */ +export interface GenericAtRuleRaws extends Omit { + /** + * Whether to collapse the nesting for an `@at-root` with no params that + * contains only a single style rule. + * + * This is ignored for rules that don't meet all of those criteria. + */ + atRootShorthand?: boolean; +} + +/** + * The initializer properties for {@link GenericAtRule}. + * + * @category Statement + */ +export type GenericAtRuleProps = ContainerProps & { + raws?: GenericAtRuleRaws; +} & ( + | {nameInterpolation: Interpolation | string; name?: never} + | {name: string; nameInterpolation?: never} + ) & + ( + | {paramsInterpolation?: Interpolation | string; params?: never} + | {params?: string | number; paramsInterpolation?: never} + ); + +/** + * An `@`-rule that isn't parsed as a more specific type. Extends + * [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class GenericAtRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'atrule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: GenericAtRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return this.nameInterpolation.toString(); + } + set name(value: string) { + this.nameInterpolation = value; + } + + /** + * The interpolation that represents this at-rule's name. + */ + get nameInterpolation(): Interpolation { + return this._nameInterpolation!; + } + set nameInterpolation(nameInterpolation: Interpolation | string) { + if (this._nameInterpolation) this._nameInterpolation.parent = undefined; + if (typeof nameInterpolation === 'string') { + nameInterpolation = new Interpolation({nodes: [nameInterpolation]}); + } + nameInterpolation.parent = this; + this._nameInterpolation = nameInterpolation; + } + private _nameInterpolation?: Interpolation; + + get params(): string { + if (this.name !== 'media' || !this.paramsInterpolation) { + return this.paramsInterpolation?.toString() ?? ''; + } + + // @media has special parsing in Sass, and allows raw expressions within + // parens. + let result = ''; + const rawText = this.paramsInterpolation.raws.text; + const rawExpressions = this.paramsInterpolation.raws.expressions; + for (let i = 0; i < this.paramsInterpolation.nodes.length; i++) { + const element = this.paramsInterpolation.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + result += raw?.value === element ? raw.raw : element; + } else { + if (result.match(/(\([ \t\n\f\r]*|(:|[<>]?=)[ \t\n\f\r]*)$/)) { + result += element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + } + return result; + } + set params(value: string | number | undefined) { + this.paramsInterpolation = value === '' ? undefined : value?.toString(); + } + + /** + * The interpolation that represents this at-rule's parameters, or undefined + * if it has no parameters. + */ + get paramsInterpolation(): Interpolation | undefined { + return this._paramsInterpolation; + } + set paramsInterpolation( + paramsInterpolation: Interpolation | string | undefined + ) { + if (this._paramsInterpolation) this._paramsInterpolation.parent = undefined; + if (typeof paramsInterpolation === 'string') { + paramsInterpolation = new Interpolation({nodes: [paramsInterpolation]}); + } + if (paramsInterpolation) paramsInterpolation.parent = this; + this._paramsInterpolation = paramsInterpolation; + } + private _paramsInterpolation: Interpolation | undefined; + + constructor(defaults: GenericAtRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.AtRule); + constructor(defaults?: GenericAtRuleProps, inner?: sassInternal.AtRule) { + super(defaults as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.nameInterpolation = new Interpolation(undefined, inner.name); + if (inner.value) { + this.paramsInterpolation = new Interpolation(undefined, inner.value); + } + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'nodes', + 'raws', + 'nameInterpolation', + {name: 'paramsInterpolation', explicitUndefined: true}, + ], + ['name', {name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'nameInterpolation', 'params', 'paramsInterpolation', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result = [this.nameInterpolation]; + if (this.paramsInterpolation) result.push(this.paramsInterpolation); + return result; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(GenericAtRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts new file mode 100644 index 000000000..905a3c072 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -0,0 +1,297 @@ +// 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 {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import {Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {CssComment, CssCommentProps} from './css-comment'; +import {SassComment, SassCommentChildProps} from './sass-comment'; +import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; +import {DebugRule, DebugRuleProps} from './debug-rule'; +import {EachRule, EachRuleProps} from './each-rule'; +import {ErrorRule, ErrorRuleProps} from './error-rule'; +import {ForRule, ForRuleProps} from './for-rule'; +import {Root} from './root'; +import {Rule, RuleProps} from './rule'; + +// TODO: Replace this with the corresponding Sass types once they're +// implemented. +export {Declaration} from 'postcss'; + +/** + * The union type of all Sass statements. + * + * @category Statement + */ +export type AnyStatement = Comment | Root | Rule | GenericAtRule; + +/** + * Sass statement types. + * + * This is a superset of the node types PostCSS exposes, and is provided + * alongside `Node.type` to disambiguate between the wide range of statements + * that Sass parses as distinct types. + * + * @category Statement + */ +export type StatementType = + | 'root' + | 'rule' + | 'atrule' + | 'comment' + | 'debug-rule' + | 'each-rule' + | 'for-rule' + | 'error-rule' + | 'sass-comment'; + +/** + * All Sass statements that are also at-rules. + * + * @category Statement + */ +export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; + +/** + * All Sass statements that are comments. + * + * @category Statement + */ +export type Comment = CssComment | SassComment; + +/** + * All Sass statements that are valid children of other statements. + * + * The Sass equivalent of PostCSS's `ChildNode`. + * + * @category Statement + */ +export type ChildNode = Rule | AtRule | Comment; + +/** + * The properties that can be used to construct {@link ChildNode}s. + * + * The Sass equivalent of PostCSS's `ChildProps`. + * + * @category Statement + */ +export type ChildProps = + | postcss.ChildProps + | CssCommentProps + | DebugRuleProps + | EachRuleProps + | ErrorRuleProps + | ForRuleProps + | GenericAtRuleProps + | RuleProps + | SassCommentChildProps; + +/** + * The Sass eqivalent of PostCSS's `ContainerProps`. + * + * @category Statement + */ +export interface ContainerProps extends NodeProps { + nodes?: ReadonlyArray; +} + +/** + * A {@link Statement} that has actual child nodes. + * + * @category Statement + */ +export type StatementWithChildren = postcss.Container & { + nodes: ChildNode[]; +} & Statement; + +/** + * A statement in a Sass stylesheet. + * + * In addition to implementing the standard PostCSS behavior, this provides + * extra information to help disambiguate different types that Sass parses + * differently. + * + * @category Statement + */ +export interface Statement extends postcss.Node, Node { + /** The type of this statement. */ + readonly sassType: StatementType; + + parent: StatementWithChildren | undefined; +} + +/** The visitor to use to convert internal Sass nodes to JS. */ +const visitor = sassInternal.createStatementVisitor({ + visitAtRootRule: inner => { + const rule = new GenericAtRule({ + name: 'at-root', + paramsInterpolation: inner.query + ? new Interpolation(undefined, inner.query) + : undefined, + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, + visitAtRule: inner => new GenericAtRule(undefined, inner), + visitDebugRule: inner => new DebugRule(undefined, inner), + visitErrorRule: inner => new ErrorRule(undefined, inner), + visitEachRule: inner => new EachRule(undefined, inner), + visitForRule: inner => new ForRule(undefined, inner), + visitExtendRule: inner => { + const paramsInterpolation = new Interpolation(undefined, inner.selector); + if (inner.isOptional) paramsInterpolation.append('!optional'); + return new GenericAtRule({ + name: 'extend', + paramsInterpolation, + source: new LazySource(inner), + }); + }, + visitLoudComment: inner => new CssComment(undefined, inner), + visitMediaRule: inner => { + const rule = new GenericAtRule({ + name: 'media', + paramsInterpolation: new Interpolation(undefined, inner.query), + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, + visitSilentComment: inner => new SassComment(undefined, inner), + visitStyleRule: inner => new Rule(undefined, inner), +}); + +/** Appends parsed versions of `internal`'s children to `container`. */ +export function appendInternalChildren( + container: postcss.Container, + children: sassInternal.Statement[] | null +): void { + // Make sure `container` knows it has a block. + if (children?.length === 0) container.append(undefined); + if (!children) return; + for (const child of children) { + container.append(child.accept(visitor)); + } +} + +/** + * The type of nodes that can be passed as new child nodes to PostCSS methods. + */ +export type NewNode = + | ChildProps + | ReadonlyArray + | postcss.Node + | ReadonlyArray + | string + | ReadonlyArray + | undefined; + +/** PostCSS's built-in normalize function. */ +const postcssNormalize = postcss.Container.prototype['normalize'] as ( + nodes: postcss.NewChild, + sample: postcss.Node | undefined, + type?: 'prepend' | false +) => postcss.ChildNode[]; + +/** + * A wrapper around {@link postcssNormalize} that converts the results to the + * corresponding Sass type(s) after normalizing. + */ +function postcssNormalizeAndConvertToSass( + self: StatementWithChildren, + node: string | postcss.ChildProps | postcss.Node, + sample: postcss.Node | undefined +): ChildNode[] { + return postcssNormalize.call(self, node, sample).map(postcssNode => { + // postcssNormalize sets the parent to the Sass node, but we don't want to + // mix Sass AST nodes with plain PostCSS AST nodes so we unset it in favor + // of creating a totally new node. + postcssNode.parent = undefined; + + switch (postcssNode.type) { + case 'atrule': + return new GenericAtRule({ + name: postcssNode.name, + params: postcssNode.params, + raws: postcssNode.raws, + source: postcssNode.source, + }); + case 'rule': + return new Rule({ + selector: postcssNode.selector, + raws: postcssNode.raws, + source: postcssNode.source, + }); + default: + throw new Error(`Unsupported PostCSS node type ${postcssNode.type}`); + } + }); +} + +/** + * An override of {@link postcssNormalize} that supports Sass nodes as arguments + * and converts PostCSS-style arguments to Sass. + */ +export function normalize( + self: StatementWithChildren, + node: NewNode, + sample?: postcss.Node +): ChildNode[] { + if (node === undefined) return []; + const nodes = Array.isArray(node) ? node : [node]; + + const result: ChildNode[] = []; + for (const node of nodes) { + if (typeof node === 'string') { + // We could in principle parse these as Sass. + result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } else if ('sassType' in node) { + if (node.sassType === 'root') { + result.push(...(node as Root).nodes); + } else { + result.push(node as ChildNode); + } + } else if ('type' in node) { + result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } else if ( + 'selectorInterpolation' in node || + 'selector' in node || + 'selectors' in node + ) { + result.push(new Rule(node)); + } else if ('name' in node || 'nameInterpolation' in node) { + result.push(new GenericAtRule(node as GenericAtRuleProps)); + } else if ('debugExpression' in node) { + result.push(new DebugRule(node)); + } else if ('eachExpression' in node) { + result.push(new EachRule(node)); + } else if ('fromExpression' in node) { + result.push(new ForRule(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 ('silentText' in node) { + result.push(new SassComment(node)); + } else { + result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } + } + + for (const node of result) { + if (node.parent) node.parent.removeChild(node); + if ( + node.raws.before === 'undefined' && + sample?.raws?.before !== undefined + ) { + node.raws.before = sample.raws.before.replace(/\S/g, ''); + } + node.parent = self; + } + + return result; +} diff --git a/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts b/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts new file mode 100644 index 000000000..37e7fc35a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts @@ -0,0 +1,33 @@ +// 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 {isClean} from '../postcss'; +import type {Node} from '../node'; +import * as utils from '../utils'; +import type {Statement} from '.'; + +/** + * Defines a getter/setter pair for the given {@link klass} that intercepts + * PostCSS's attempt to mark it as clean and marks any non-statement children as + * clean as well. + */ +export function interceptIsClean( + klass: utils.Constructor +): void { + Object.defineProperty(klass as typeof klass & {_isClean: boolean}, isClean, { + get(): boolean { + return this._isClean; + }, + set(value: boolean): void { + this._isClean = value; + if (value) this.nonStatementChildren.forEach(markClean); + }, + }); +} + +/** Marks {@link node} and all its children as clean. */ +function markClean(node: Node): void { + (node as Node & {[isClean]: boolean})[isClean] = true; + node.nonStatementChildren.forEach(markClean); +} diff --git a/pkg/sass-parser/lib/src/statement/media-rule.test.ts b/pkg/sass-parser/lib/src/statement/media-rule.test.ts new file mode 100644 index 000000000..7a2f8d9ac --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/media-rule.test.ts @@ -0,0 +1,61 @@ +// 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, StringExpression, scss} from '../..'; + +describe('a @media rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media screen {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'screen')); + + it('has matching params', () => expect(node.params).toBe('screen')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media (hover: #{hover}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('('); + expect(params).toHaveStringExpression(1, 'hover'); + expect(params.nodes[2]).toBe(': '); + expect(params.nodes[3]).toBeInstanceOf(StringExpression); + expect((params.nodes[3] as StringExpression).text).toHaveStringExpression( + 0, + 'hover' + ); + expect(params.nodes[4]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(hover: #{hover})')); + }); + + describe('stringifies', () => { + // TODO: Use raws technology to include the actual original text between + // interpolations. + it('to SCSS', () => + expect( + (node = scss.parse('@media #{screen} and (hover: #{hover}) {@foo}') + .nodes[0] as GenericAtRule).toString() + ).toBe('@media #{screen} and (hover: #{hover}) {\n @foo\n}')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/root-internal.d.ts b/pkg/sass-parser/lib/src/statement/root-internal.d.ts new file mode 100644 index 000000000..975b19ed7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts @@ -0,0 +1,79 @@ +// 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 {Rule} from './rule'; +import {Root, RootProps} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Root extends postcss.Root { + declare nodes: ChildNode[]; + + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/root-internal.js b/pkg/sass-parser/lib/src/statement/root-internal.js new file mode 100644 index 000000000..599781530 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root-internal.js @@ -0,0 +1,5 @@ +// 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. + +exports._Root = require('postcss').Root; diff --git a/pkg/sass-parser/lib/src/statement/root.test.ts b/pkg/sass-parser/lib/src/statement/root.test.ts new file mode 100644 index 000000000..2d8d19687 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root.test.ts @@ -0,0 +1,159 @@ +// 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, Root, css, sass, scss} from '../..'; + +describe('a root node', () => { + let node: Root; + describe('with no children', () => { + function describeNode(description: string, create: () => Root): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type root', () => expect(node.type).toBe('root')); + + it('has sassType root', () => expect(node.sassType).toBe('root')); + + it('has no child nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode('parsed as SCSS', () => scss.parse('')); + describeNode('parsed as CSS', () => css.parse('')); + describeNode('parsed as Sass', () => sass.parse('')); + describeNode('constructed manually', () => new Root()); + }); + + describe('with children', () => { + function describeNode(description: string, create: () => Root): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type root', () => expect(node.type).toBe('root')); + + it('has sassType root', () => expect(node.sassType).toBe('root')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'foo'); + }); + }); + } + + describeNode('parsed as SCSS', () => scss.parse('@foo')); + describeNode('parsed as CSS', () => css.parse('@foo')); + describeNode('parsed as Sass', () => sass.parse('@foo')); + + describeNode( + 'constructed manually', + () => new Root({nodes: [{name: 'foo'}]}) + ); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no children', () => expect(new Root().toString()).toBe('')); + + it('with a child', () => + expect(new Root({nodes: [{name: 'foo'}]}).toString()).toBe('@foo')); + }); + + describe('with after', () => { + it('with no children', () => + expect(new Root({raws: {after: '/**/'}}).toString()).toBe('/**/')); + + it('with a child', () => + expect( + new Root({ + nodes: [{name: 'foo'}], + raws: {after: '/**/'}, + }).toString() + ).toBe('@foo/**/')); + }); + + describe('with semicolon', () => { + it('with no children', () => + expect(new Root({raws: {semicolon: true}}).toString()).toBe('')); + + it('with a child', () => + expect( + new Root({ + nodes: [{name: 'foo'}], + raws: {semicolon: true}, + }).toString() + ).toBe('@foo;')); + }); + }); + }); + + describe('clone', () => { + let original: Root; + beforeEach(() => { + original = scss.parse('@foo'); + // TODO: remove this once raws are properly parsed + original.raws.after = ' '; + }); + + describe('with no overrides', () => { + let clone: Root; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('raws', () => expect(clone.raws).toEqual({after: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + + it('nodes', () => { + expect(clone.nodes).toHaveLength(1); + expect(clone.nodes[0]).toBeInstanceOf(GenericAtRule); + expect((clone.nodes[0] as GenericAtRule).name).toBe('foo'); + }); + }); + + 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].parent).toBe(clone)); + }); + }); + + describe('overrides', () => { + it('nodes', () => { + const nodes = original.clone({nodes: [{name: 'bar'}]}).nodes; + expect(nodes).toHaveLength(1); + expect(nodes[0]).toBeInstanceOf(GenericAtRule); + expect(nodes[0]).toHaveProperty('name', 'bar'); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {semicolon: true}}).raws).toEqual({ + semicolon: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + after: ' ', + })); + }); + }); + }); + + describe('toJSON', () => { + it('without children', () => expect(scss.parse('')).toMatchSnapshot()); + + it('with children', () => + expect(scss.parse('@foo').nodes[0]).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/root.ts b/pkg/sass-parser/lib/src/statement/root.ts new file mode 100644 index 000000000..f3b2471a7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root.ts @@ -0,0 +1,81 @@ +// 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 {RootRaws} from 'postcss/lib/root'; + +import * as sassParser from '../..'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + appendInternalChildren, + normalize, +} from '.'; +import {_Root} from './root-internal'; + +export type {RootRaws} from 'postcss/lib/root'; + +/** + * The initializer properties for {@link Root}. + * + * @category Statement + */ +export interface RootProps extends ContainerProps { + raws?: RootRaws; +} + +/** + * The root node of a Sass stylesheet. Extends [`postcss.Root`]. + * + * [`postcss.Root`]: https://postcss.org/api/#root + * + * @category Statement + */ +export class Root extends _Root implements Statement { + readonly sassType = 'root' as const; + declare parent: undefined; + declare raws: RootRaws; + + /** @hidden */ + readonly nonStatementChildren = [] as const; + + constructor(defaults?: RootProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Stylesheet); + constructor(defaults?: object, inner?: sassInternal.Stylesheet) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + appendInternalChildren(this, inner.children); + } + } + + 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); + } + + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts new file mode 100644 index 000000000..31bef89ac --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts @@ -0,0 +1,79 @@ +// 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 {Rule, RuleProps} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Rule extends postcss.Rule { + declare nodes: ChildNode[]; + + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.js b/pkg/sass-parser/lib/src/statement/rule-internal.js new file mode 100644 index 000000000..96d32cce0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule-internal.js @@ -0,0 +1,5 @@ +// 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. + +exports._Rule = require('postcss').Rule; diff --git a/pkg/sass-parser/lib/src/statement/rule.test.ts b/pkg/sass-parser/lib/src/statement/rule.test.ts new file mode 100644 index 000000000..3f688fad4 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule.test.ts @@ -0,0 +1,360 @@ +// 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, Interpolation, Root, Rule, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a style rule', () => { + let node: Rule; + describe('with no children', () => { + function describeNode(description: string, create: () => Rule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type rule', () => expect(node.type).toBe('rule')); + + it('has sassType rule', () => expect(node.sassType).toBe('rule')); + + it('has matching selectorInterpolation', () => + expect(node).toHaveInterpolation('selectorInterpolation', '.foo ')); + + it('has matching selector', () => expect(node.selector).toBe('.foo ')); + + it('has empty nodes', () => expect(node.nodes).toHaveLength(0)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('.foo {}').nodes[0] as Rule + ); + + describeNode('parsed as CSS', () => css.parse('.foo {}').nodes[0] as Rule); + + describe('parsed as Sass', () => { + beforeEach(() => { + node = sass.parse('.foo').nodes[0] as Rule; + }); + + it('has matching selectorInterpolation', () => + expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n')); + + it('has matching selector', () => expect(node.selector).toBe('.foo\n')); + + it('has empty nodes', () => expect(node.nodes).toHaveLength(0)); + }); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new Rule({ + selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + }) + ); + + describeNode( + 'with a selector string', + () => new Rule({selector: '.foo '}) + ); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + }) + ); + + describeNode('with a selector string', () => + utils.fromChildProps({selector: '.foo '}) + ); + }); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => Rule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has matching selectorInterpolation', () => + expect(node).toHaveInterpolation('selectorInterpolation', '.foo ')); + + it('has matching selector', () => expect(node.selector).toBe('.foo ')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'bar'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('.foo {@bar}').nodes[0] as Rule + ); + + describeNode( + 'parsed as CSS', + () => css.parse('.foo {@bar}').nodes[0] as Rule + ); + + describe('parsed as Sass', () => { + beforeEach(() => { + node = sass.parse('.foo\n @bar').nodes[0] as Rule; + }); + + it('has matching selectorInterpolation', () => + expect(node).toHaveInterpolation('selectorInterpolation', '.foo\n')); + + it('has matching selector', () => expect(node.selector).toBe('.foo\n')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'bar'); + }); + }); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new Rule({ + selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + nodes: [{name: 'bar'}], + }) + ); + + describeNode( + 'with a selector string', + () => new Rule({selector: '.foo ', nodes: [{name: 'bar'}]}) + ); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + selectorInterpolation: new Interpolation({nodes: ['.foo ']}), + nodes: [{name: 'bar'}], + }) + ); + + describeNode('with a selector string', () => + utils.fromChildProps({selector: '.foo ', nodes: [{name: 'bar'}]}) + ); + }); + }); + + describe('assigned a new selector', () => { + beforeEach(() => { + node = scss.parse('.foo {}').nodes[0] as Rule; + }); + + it("removes the old interpolation's parent", () => { + const oldSelector = node.selectorInterpolation!; + node.selectorInterpolation = '.bar'; + expect(oldSelector.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['.bar']}); + node.selectorInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['.bar']}); + node.selectorInterpolation = interpolation; + expect(node.selectorInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.selectorInterpolation = '.bar'; + expect(node).toHaveInterpolation('selectorInterpolation', '.bar'); + }); + + it('assigns the interpolation as selector', () => { + node.selector = '.bar'; + expect(node).toHaveInterpolation('selectorInterpolation', '.bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no children', () => + expect(new Rule({selector: '.foo'}).toString()).toBe('.foo {}')); + + it('with a child', () => + expect( + new Rule({selector: '.foo', nodes: [{selector: '.bar'}]}).toString() + ).toBe('.foo {\n .bar {}\n}')); + }); + + it('with between', () => + expect( + new Rule({ + selector: '.foo', + raws: {between: '/**/'}, + }).toString() + ).toBe('.foo/**/{}')); + + describe('with after', () => { + it('with no children', () => + expect( + new Rule({selector: '.foo', raws: {after: '/**/'}}).toString() + ).toBe('.foo {/**/}')); + + it('with a child', () => + expect( + new Rule({ + selector: '.foo', + nodes: [{selector: '.bar'}], + raws: {after: '/**/'}, + }).toString() + ).toBe('.foo {\n .bar {}/**/}')); + }); + + it('with before', () => + expect( + new Root({ + nodes: [new Rule({selector: '.foo', raws: {before: '/**/'}})], + }).toString() + ).toBe('/**/.foo {}')); + }); + }); + + describe('clone', () => { + let original: Rule; + beforeEach(() => { + original = scss.parse('.foo {@bar}').nodes[0] as Rule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: Rule; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('selectorInterpolation', () => + expect(clone).toHaveInterpolation('selectorInterpolation', '.foo ')); + + it('selector', () => expect(clone.selector).toBe('.foo ')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + + it('nodes', () => { + expect(clone.nodes).toHaveLength(1); + expect(clone.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(clone.nodes[0]).toHaveProperty('name', 'bar'); + }); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'selectorInterpolation', + 'raws', + 'nodes', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + + describe('sets parent for', () => { + it('nodes', () => expect(clone.nodes[0].parent).toBe(clone)); + }); + }); + + describe('overrides', () => { + describe('selector', () => { + describe('defined', () => { + let clone: Rule; + beforeEach(() => { + clone = original.clone({selector: 'qux'}); + }); + + it('changes selector', () => expect(clone.selector).toBe('qux')); + + it('changes selectorInterpolation', () => + expect(clone).toHaveInterpolation('selectorInterpolation', 'qux')); + }); + + describe('undefined', () => { + let clone: Rule; + beforeEach(() => { + clone = original.clone({selector: undefined}); + }); + + it('preserves selector', () => expect(clone.selector).toBe('.foo ')); + + it('preserves selectorInterpolation', () => + expect(clone).toHaveInterpolation( + 'selectorInterpolation', + '.foo ' + )); + }); + }); + + describe('selectorInterpolation', () => { + describe('defined', () => { + let clone: Rule; + beforeEach(() => { + clone = original.clone({ + selectorInterpolation: new Interpolation({nodes: ['.baz']}), + }); + }); + + it('changes selector', () => expect(clone.selector).toBe('.baz')); + + it('changes selectorInterpolation', () => + expect(clone).toHaveInterpolation('selectorInterpolation', '.baz')); + }); + + describe('undefined', () => { + let clone: Rule; + beforeEach(() => { + clone = original.clone({selectorInterpolation: undefined}); + }); + + it('preserves selector', () => expect(clone.selector).toBe('.foo ')); + + it('preserves selectorInterpolation', () => + expect(clone).toHaveInterpolation( + 'selectorInterpolation', + '.foo ' + )); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + }); + }); + + describe('toJSON', () => { + it('with empty children', () => + expect(scss.parse('.foo {}').nodes[0]).toMatchSnapshot()); + + it('with a child', () => + expect(scss.parse('.foo {@bar}').nodes[0]).toMatchSnapshot()); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/rule.ts b/pkg/sass-parser/lib/src/statement/rule.ts new file mode 100644 index 000000000..087a99ba1 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule.ts @@ -0,0 +1,141 @@ +// 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 {RuleRaws as PostcssRuleRaws} from 'postcss/lib/rule'; + +import {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {interceptIsClean} from './intercept-is-clean'; +import {_Rule} from './rule-internal'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by a style rule. + * + * Sass doesn't support PostCSS's `params` raws, since the selector is lexed and + * made directly available to the caller. + * + * @category Statement + */ +export type RuleRaws = Omit; + +/** + * The initializer properties for {@link Rule}. + * + * @category Statement + */ +export type RuleProps = ContainerProps & {raws?: RuleRaws} & ( + | {selectorInterpolation: Interpolation | string} + | {selector: string} + | {selectors: string[]} + ); + +/** + * A style rule. Extends [`postcss.Rule`]. + * + * [`postcss.Rule`]: https://postcss.org/api/#rule + * + * @category Statement + */ +export class Rule extends _Rule implements Statement { + readonly sassType = 'rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: RuleRaws; + + get selector(): string { + return this.selectorInterpolation.toString(); + } + set selector(value: string) { + this.selectorInterpolation = value; + } + + /** The interpolation that represents this rule's selector. */ + get selectorInterpolation(): Interpolation { + return this._selectorInterpolation!; + } + set selectorInterpolation(selectorInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._selectorInterpolation) { + this._selectorInterpolation.parent = undefined; + } + if (typeof selectorInterpolation === 'string') { + selectorInterpolation = new Interpolation({ + nodes: [selectorInterpolation], + }); + } + selectorInterpolation.parent = this; + this._selectorInterpolation = selectorInterpolation; + } + private _selectorInterpolation?: Interpolation; + + constructor(defaults: RuleProps); + constructor(_: undefined, inner: sassInternal.StyleRule); + /** @hidden */ + constructor(defaults?: RuleProps, inner?: sassInternal.StyleRule) { + // PostCSS claims that it requires either selector or selectors, but we + // define the former as a getter instead. + super(defaults as postcss.RuleProps); + if (inner) { + this.source = new LazySource(inner); + this.selectorInterpolation = new Interpolation(undefined, inner.selector); + appendInternalChildren(this, inner.children); + } + } + + // TODO: Once we make selector parsing available to JS, use it to override + // selectors() and to provide access to parsed selectors if selector is plain + // text. + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['nodes', 'raws', 'selectorInterpolation'], + ['selector', 'selectors'] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['selector', 'selectorInterpolation', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.selectorInterpolation]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(Rule); diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.test.ts b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts new file mode 100644 index 000000000..cbd88598b --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts @@ -0,0 +1,465 @@ +// 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 {Root, Rule, SassComment, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a Sass-style comment', () => { + let node: SassComment; + function describeNode(description: string, create: () => SassComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType sass-comment', () => + expect(node.sassType).toBe('sass-comment')); + + it('has matching text', () => expect(node.text).toBe('foo\nbar')); + + it('has matching silentText', () => expect(node.text).toBe('foo\nbar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('// foo\n// bar').nodes[0] as SassComment + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('// foo\n// bar').nodes[0] as SassComment + ); + + describeNode( + 'constructed manually', + () => new SassComment({text: 'foo\nbar'}) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({silentText: 'foo\nbar'}) + ); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with consistent whitespace before and after //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with an empty line', () => { + const node = scss.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = scss.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace before //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: [' ', '', ' '], + left: ' ', + }); + }); + + it('with inconsistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n // bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['\t', ' '], + left: ' ', + }); + }); + + it('with consistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n \t// bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' \t', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = scss.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = scss.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = scss.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = scss.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + + describe('in Sass', () => { + it('with an empty line', () => { + const node = sass.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = sass.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = sass.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = sass.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = sass.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = sass.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new SassComment({text: 'foo\nbar'}).toString()).toBe( + '// foo\n// bar' + )); + + it('with left', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n//\tbar')); + + it('with left and an empty line', () => + expect( + new SassComment({ + text: 'foo\n\nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n//\n//\tbar')); + + it('with left and a whitespace-only line', () => + expect( + new SassComment({ + text: 'foo\n \nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n// \n//\tbar')); + + it('with before', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {before: '\t'}, + }).toString() + ).toBe('\t// foo\n\t// bar')); + + it('with beforeLines', () => + expect( + new Root({ + nodes: [ + new SassComment({ + text: 'foo\nbar', + raws: {beforeLines: [' ', '\t']}, + }), + ], + }).toString() + ).toBe(' // foo\n\t// bar')); + + describe('with a following sibling', () => { + it('without before', () => + expect( + new Root({ + nodes: [{silentText: 'foo\nbar'}, {name: 'baz'}], + }).toString() + ).toBe('// foo\n// bar\n@baz')); + + it('with before with newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: '\n '}}, + ], + }).toString() + ).toBe('// foo\n// bar\n @baz')); + + it('with before without newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: ' '}}, + ], + }).toString() + ).toBe('// foo\n// bar\n @baz')); + }); + + describe('in a nested rule', () => { + it('without after', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + }).toString() + ).toBe('.zip {\n // foo\n// bar\n}')); + + it('with after with newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: '\n '}, + }).toString() + ).toBe('.zip {\n // foo\n// bar\n }')); + + it('with after without newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: ' '}, + }).toString() + ).toBe('.zip {\n // foo\n// bar\n }')); + }); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.text = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.text = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('assigned new silentText', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.silentText = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.silentText = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('clone', () => { + let original: SassComment; + beforeEach( + () => void (original = scss.parse('// foo').nodes[0] as SassComment) + ); + + describe('with no overrides', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('text', () => expect(clone.text).toBe('foo')); + + it('silentText', () => expect(clone.silentText).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + it('raws.beforeLines', () => + expect(clone.raws.beforeLines).not.toBe(original.raws.beforeLines)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {left: ' '}}).raws).toEqual({ + left: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + }); + }); + }); + + it('toJSON', () => expect(scss.parse('// foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.ts b/pkg/sass-parser/lib/src/statement/sass-comment.ts new file mode 100644 index 000000000..1c8bb66ec --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.ts @@ -0,0 +1,182 @@ +// 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 {CommentRaws} from 'postcss/lib/comment'; + +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link SassComment}. + * + * @category Statement + */ +export interface SassCommentRaws extends Omit { + /** + * Unlike PostCSS's, `CommentRaws.before`, this is added before `//` for + * _every_ line of this comment. If any lines have more indentation than this, + * it appears in {@link beforeLines} instead. + */ + before?: string; + + /** + * For each line in the comment, this is the whitespace that appears before + * the `//` _in addition to_ {@link before}. + */ + beforeLines?: string[]; + + /** + * Unlike PostCSS's `CommentRaws.left`, this is added after `//` for _every_ + * line in the comment that's not only whitespace. If any lines have more + * initial whitespace than this, it appears in {@link SassComment.text} + * instead. + * + * Lines that are only whitespace do not have `left` added to them, and + * instead have all their whitespace directly in {@link SassComment.text}. + */ + left?: string; +} + +/** + * The subset of {@link SassCommentProps} that can be used to construct it + * implicitly without calling `new SassComment()`. + * + * @category Statement + */ +export type SassCommentChildProps = ContainerProps & { + raws?: SassCommentRaws; + silentText: string; +}; + +/** + * The initializer properties for {@link SassComment}. + * + * @category Statement + */ +export type SassCommentProps = ContainerProps & { + raws?: SassCommentRaws; +} & ( + | { + silentText: string; + } + | {text: string} + ); + +/** + * A Sass-style "silent" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class SassComment + extends _Comment> + implements Statement +{ + readonly sassType = 'sass-comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: SassCommentRaws; + + /** + * The text of this comment, potentially spanning multiple lines. + * + * This is always the same as {@link text}, it just has a different name to + * distinguish {@link SassCommentProps} from {@link CssCommentProps}. + */ + declare silentText: string; + + get text(): string { + return this.silentText; + } + set text(value: string) { + this.silentText = value; + } + + constructor(defaults: SassCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.SilentComment); + constructor(defaults?: SassCommentProps, inner?: sassInternal.SilentComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + + const lineInfo = inner.text + .trimRight() + .split('\n') + .map(line => { + const index = line.indexOf('//'); + const before = line.substring(0, index); + const regexp = /[^ \t]/g; + regexp.lastIndex = index + 2; + const firstNonWhitespace = regexp.exec(line)?.index; + if (firstNonWhitespace === undefined) { + return {before, left: null, text: line.substring(index + 2)}; + } + + const left = line.substring(index + 2, firstNonWhitespace); + const text = line.substring(firstNonWhitespace); + return {before, left, text}; + }); + + // Dart Sass doesn't include the whitespace before the first `//` in + // SilentComment.text, so we grab it directly from the SourceFile. + let i = inner.span.start.offset - 1; + for (; i >= 0; i--) { + const char = inner.span.file.codeUnits[i]; + if (char !== 0x20 && char !== 0x09) break; + } + lineInfo[0].before = inner.span.file.getText( + i + 1, + inner.span.start.offset + ); + + const before = (this.raws.before = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.before) + )); + this.raws.beforeLines = lineInfo.map(info => + info.before.substring(before.length) + ); + const left = (this.raws.left = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.left).filter(left => left !== null) + )); + this.text = lineInfo + .map(info => (info.left?.substring(left.length) ?? '') + info.text) + .join('\n'); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'silentText'], ['text']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'text'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} + +interceptIsClean(SassComment); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts new file mode 100644 index 000000000..46374e19d --- /dev/null +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -0,0 +1,183 @@ +// 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. + +// Portions of this source file are adapted from the PostCSS codebase under the +// terms of the following license: +// +// The MIT License (MIT) +// +// Copyright 2013 Andrey Sitnik +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import * as postcss from 'postcss'; + +import {AnyStatement} from './statement'; +import {DebugRule} from './statement/debug-rule'; +import {EachRule} from './statement/each-rule'; +import {ErrorRule} from './statement/error-rule'; +import {GenericAtRule} from './statement/generic-at-rule'; +import {Rule} from './statement/rule'; +import {SassComment} from './statement/sass-comment'; + +const PostCssStringifier = require('postcss/lib/stringifier'); + +/** + * A visitor that stringifies Sass statements. + * + * Expression-level nodes are handled differently because they don't need to + * integrate into PostCSS's source map infratructure. + */ +export class Stringifier extends PostCssStringifier { + constructor(builder: postcss.Builder) { + super(builder); + } + + /** Converts `node` into a string by calling {@link this.builder}. */ + stringify(node: postcss.AnyNode, semicolon: boolean): void { + if (!('sassType' in node)) { + postcss.stringify(node, this.builder); + return; + } + + const statement = node as AnyStatement; + if (!this[statement.sassType]) { + throw new Error( + `Unknown AST node type ${statement.sassType}. ` + + 'Maybe you need to change PostCSS stringifier.' + ); + } + ( + this[statement.sassType] as ( + node: AnyStatement, + semicolon: boolean + ) => void + )(statement, semicolon); + } + + private ['debug-rule'](node: DebugRule, semicolon: boolean): void { + this.builder( + '@debug' + + (node.raws.afterName ?? ' ') + + node.debugExpression + + (node.raws.between ?? '') + + (semicolon ? ';' : ''), + node + ); + } + + private ['each-rule'](node: EachRule): void { + this.block( + node, + '@each' + + (node.raws.afterName ?? ' ') + + node.params + + (node.raws.between ?? '') + ); + } + + private ['error-rule'](node: ErrorRule, semicolon: boolean): void { + this.builder( + '@error' + + (node.raws.afterName ?? ' ') + + node.errorExpression + + (node.raws.between ?? '') + + (semicolon ? ';' : ''), + node + ); + } + + private ['for-rule'](node: EachRule): void { + this.block( + node, + '@for' + + (node.raws.afterName ?? ' ') + + node.params + + (node.raws.between ?? '') + ); + } + + private atrule(node: GenericAtRule, semicolon: boolean): void { + // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as + // `@at-root .foo {...}`. + if ( + node.raws.atRootShorthand && + node.name === 'at-root' && + node.paramsInterpolation === undefined && + node.nodes.length === 1 && + node.nodes[0].sassType === 'rule' + ) { + this.block( + node.nodes[0], + '@at-root' + + (node.raws.afterName ?? ' ') + + node.nodes[0].selectorInterpolation + ); + return; + } + + const start = + `@${node.nameInterpolation}` + + (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) + + node.params; + if (node.nodes) { + this.block(node, start); + } else { + this.builder( + start + (node.raws.between ?? '') + (semicolon ? ';' : ''), + node + ); + } + } + + private rule(node: Rule): void { + this.block(node, node.selectorInterpolation.toString()); + } + + private ['sass-comment'](node: SassComment): void { + const before = node.raws.before ?? ''; + const left = node.raws.left ?? ' '; + let text = node.text + .split('\n') + .map( + (line, i) => + before + + (node.raws.beforeLines?.[i] ?? '') + + '//' + + (/[^ \t]/.test(line) ? left : '') + + line + ) + .join('\n'); + + // Ensure that a Sass-style comment always has a newline after it unless + // it's the last node in the document. + const next = node.next(); + if (next && !this.raw(next, 'before').startsWith('\n')) { + text += '\n'; + } else if ( + !next && + node.parent && + !this.raw(node.parent, 'after').startsWith('\n') + ) { + text += '\n'; + } + + this.builder(text, node); + } +} diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts new file mode 100644 index 000000000..f022aca5d --- /dev/null +++ b/pkg/sass-parser/lib/src/utils.ts @@ -0,0 +1,219 @@ +// 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 {Node} from './node'; + +/** + * A type that matches any constructor for {@link T}. From + * https://www.typescriptlang.org/docs/handbook/mixins.html. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new (...args: any[]) => T; + +/** + * An explicit field description passed to `cloneNode` that describes in detail + * how to clone it. + */ +interface ExplicitClonableField { + /** The field's name. */ + name: Name; + + /** + * Whether the field can be set to an explicit undefined value which means + * something different than an absent field. + */ + explicitUndefined?: boolean; +} + +/** The type of field names that can be passed into `cloneNode`. */ +type ClonableField = Name | ExplicitClonableField; + +/** Makes a {@link ClonableField} explicit. */ +function parseClonableField( + field: ClonableField +): ExplicitClonableField { + return typeof field === 'string' ? {name: field} : field; +} + +/** + * Creates a copy of {@link node} by passing all the properties in {@link + * constructorFields} as an object to its constructor. + * + * If {@link overrides} is passed, it overrides any existing constructor field + * values. It's also used to assign {@link assignedFields} after the cloned + * object has been constructed. + */ +export function cloneNode>( + node: T, + overrides: Record | undefined, + constructorFields: ClonableField[], + assignedFields?: ClonableField[] +): T { + // We have to do these casts because the actual `...Prop` types that get + // passed in and used for the constructor aren't actually subtypes of + // `Partial`. They use `never` types to ensure that various properties are + // mutually exclusive, which is not compatible. + const typedOverrides = overrides as Partial | undefined; + const constructorFn = node.constructor as new (defaults: Partial) => T; + + const constructorParams: Partial = {}; + for (const field of constructorFields) { + const {name, explicitUndefined} = parseClonableField(field); + let value: T[keyof T & string] | undefined; + if ( + typedOverrides && + (explicitUndefined + ? Object.hasOwn(typedOverrides, name) + : typedOverrides[name] !== undefined) + ) { + value = typedOverrides[name]; + } else { + value = maybeClone(node[name]); + } + if (value !== undefined) constructorParams[name] = value; + } + const cloned = new constructorFn(constructorParams); + + if (typedOverrides && assignedFields) { + for (const field of assignedFields) { + const {name, explicitUndefined} = parseClonableField(field); + if ( + explicitUndefined + ? Object.hasOwn(typedOverrides, name) + : typedOverrides[name] + ) { + // This isn't actually guaranteed to be non-null, but TypeScript + // (correctly) complains that we could be passing an undefined value to + // a field that doesn't allow undefined. We don't have a good way of + // forbidding that while still allowing users to override values that do + // explicitly allow undefined, though. + cloned[name] = typedOverrides[name]!; + } + } + } + + cloned.source = node.source; + return cloned; +} + +/** + * If {@link value} is a Sass node, a record, or an array, clones it and returns + * the clone. Otherwise, returns it as-is. + */ +function maybeClone(value: T): T { + if (Array.isArray(value)) return value.map(maybeClone) as T; + if (typeof value !== 'object' || value === null) return value; + // The only records we care about are raws, which only contain primitives and + // arrays of primitives, so structued cloning is safe. + if (value.constructor === Object) return structuredClone(value); + if (value instanceof postcss.Node) return value.clone() as T; + return value; +} + +/** + * Converts {@link node} into a JSON-safe object, with the given {@link fields} + * included. + * + * This always includes the `type`, `sassType`, `raws`, and `source` fields if + * set. It converts multiple references to the same source input object into + * indexes into a top-level list. + */ +export function toJSON( + node: T, + fields: (keyof T & string)[], + inputs?: Map +): object { + // Only include the inputs field at the top level. + const includeInputs = !inputs; + inputs ??= new Map(); + let inputIndex = inputs.size; + + const result: Record = {}; + if ('type' in node) result.type = (node as {type: string}).type; + + fields = ['sassType', 'raws', ...fields]; + for (const field of fields) { + const value = node[field]; + if (value !== undefined) result[field] = toJsonField(field, value, inputs); + } + + if (node.source) { + let inputId = inputs.get(node.source.input); + if (inputId === undefined) { + inputId = inputIndex++; + inputs.set(node.source.input, inputId); + } + + result.source = { + start: node.source.start, + end: node.source.end, + inputId, + }; + } + + if (includeInputs) { + result.inputs = [...inputs.keys()].map(input => input.toJSON()); + } + return result; +} + +/** + * Converts a single field with name {@link field} and value {@link value} to a + * JSON-safe object. + * + * The {@link inputs} map works the same as it does in {@link toJSON}. + */ +function toJsonField( + field: string, + value: unknown, + inputs: Map +): unknown { + if (typeof value !== 'object' || value === null) { + return value; + } else if (Array.isArray(value)) { + return value.map((element, i) => + toJsonField(i.toString(), element, inputs) + ); + } else if ('toJSON' in value) { + if ('sassType' in value) { + return ( + value as { + toJSON: (field: string, inputs: Map) => object; + } + ).toJSON('', inputs); + } else { + return (value as {toJSON: (field: string) => object}).toJSON(field); + } + } else { + return value; + } +} + +/** + * Returns the longest string (of code units) that's an initial substring of + * every string in + * {@link strings}. + */ +export function longestCommonInitialSubstring(strings: string[]): string { + let candidate: string | undefined; + for (const string of strings) { + if (candidate === undefined) { + candidate = string; + } else { + for (let i = 0; i < candidate.length && i < string.length; i++) { + if (candidate.charCodeAt(i) !== string.charCodeAt(i)) { + candidate = candidate.substring(0, i); + break; + } + } + candidate = candidate.substring( + 0, + Math.min(candidate.length, string.length) + ); + } + } + return candidate ?? ''; +} diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json new file mode 100644 index 000000000..4f2536035 --- /dev/null +++ b/pkg/sass-parser/package.json @@ -0,0 +1,51 @@ +{ + "name": "sass-parser", + "version": "0.2.0", + "description": "A PostCSS-compatible wrapper of the official Sass parser", + "repository": "sass/sass", + "author": "Google Inc.", + "license": "MIT", + "exports": { + "types": "./dist/types/index.d.ts", + "default": "./dist/lib/index.js" + }, + "main": "dist/lib/index.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist/**/*.{js,d.ts}" + ], + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "init": "ts-node ./tool/init.ts", + "check": "npm-run-all check:gts check:tsc", + "check:gts": "gts check", + "check:tsc": "tsc --noEmit", + "clean": "gts clean", + "compile": "tsc -p tsconfig.build.json && copyfiles -u 1 \"lib/**/*.{js,d.ts}\" dist/lib/", + "prepack": "copyfiles -u 2 ../../LICENSE .", + "postpack": "rimraf LICENSE", + "typedoc": "npx typedoc --treatWarningsAsErrors", + "fix": "gts fix", + "test": "jest" + }, + "dependencies": { + "postcss": ">=8.4.41 <8.5.0", + "sass": "1.78.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "copyfiles": "^2.4.1", + "expect": "^29.7.0", + "gts": "^5.0.0", + "jest": "^29.4.1", + "jest-extended": "^4.0.2", + "npm-run-all": "^4.1.5", + "rimraf": "^6.0.1", + "ts-jest": "^29.0.5", + "ts-node": "^10.2.1", + "typedoc": "^0.26.5", + "typescript": "^5.0.2" + } +} diff --git a/pkg/sass-parser/test/setup.ts b/pkg/sass-parser/test/setup.ts new file mode 100644 index 000000000..35b17de62 --- /dev/null +++ b/pkg/sass-parser/test/setup.ts @@ -0,0 +1,285 @@ +// 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 type {ExpectationResult, MatcherContext} from 'expect'; +import * as p from 'path'; +import * as postcss from 'postcss'; +// Unclear why eslint considers this extraneous +// eslint-disable-next-line n/no-extraneous-import +import type * as pretty from 'pretty-format'; +import 'jest-extended'; + +import {Interpolation, StringExpression} from '../lib'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface AsymmetricMatchers { + /** + * Asserts that the object being matched has a property named {@link + * property} whose value is an {@link Interpolation}, that that + * interpolation's value is {@link value}, and that the interpolation's + * parent is the object being tested. + */ + toHaveInterpolation(property: string, value: string): void; + + /** + * Asserts that the object being matched has a property named {@link + * property} whose value is a {@link StringExpression}, that that string's + * value is {@link value}, and that the string's parent is the object + * being tested. + * + * If {@link property} is a number, it's treated as an index into the + * `nodes` property of the object being matched. + */ + toHaveStringExpression(property: string | number, value: string): void; + } + + interface Matchers { + toHaveInterpolation(property: string, value: string): R; + toHaveStringExpression(property: string | number, value: string): R; + } + } +} + +function toHaveInterpolation( + this: MatcherContext, + actual: unknown, + property: unknown, + value: unknown +): ExpectationResult { + if (typeof property !== 'string') { + throw new TypeError(`Property ${property} must be a string.`); + } else if (typeof value !== 'string') { + throw new TypeError(`Value ${value} must be a string.`); + } + + if (typeof actual !== 'object' || !actual || !(property in actual)) { + return { + message: () => + `expected ${this.utils.printReceived( + actual + )} to have a property ${this.utils.printExpected(property)}`, + pass: false, + }; + } + + const actualValue = (actual as Record)[property]; + const message = (): string => + `expected (${this.utils.printReceived( + actual + )}).${property} ${this.utils.printReceived( + actualValue + )} to be an Interpolation with value ${this.utils.printExpected(value)}`; + + if ( + !(actualValue instanceof Interpolation) || + actualValue.asPlain !== value + ) { + return { + message, + pass: false, + }; + } + + if (actualValue.parent !== actual) { + return { + message: () => + `expected (${this.utils.printReceived( + actual + )}).${property} ${this.utils.printReceived( + actualValue + )} to have the correct parent`, + pass: false, + }; + } + + return {message, pass: true}; +} + +expect.extend({toHaveInterpolation}); + +function toHaveStringExpression( + this: MatcherContext, + actual: unknown, + propertyOrIndex: unknown, + value: unknown +): ExpectationResult { + if ( + typeof propertyOrIndex !== 'string' && + typeof propertyOrIndex !== 'number' + ) { + throw new TypeError( + `Property ${propertyOrIndex} must be a string or number.` + ); + } else if (typeof value !== 'string') { + throw new TypeError(`Value ${value} must be a string.`); + } + + let index: number | null = null; + let property: string; + if (typeof propertyOrIndex === 'number') { + index = propertyOrIndex; + property = 'nodes'; + } else { + property = propertyOrIndex; + } + + if (typeof actual !== 'object' || !actual || !(property in actual)) { + return { + message: () => + `expected ${this.utils.printReceived( + actual + )} to have a property ${this.utils.printExpected(property)}`, + pass: false, + }; + } + + let actualValue = (actual as Record)[property]; + if (index !== null) actualValue = (actualValue as unknown[])[index]; + + const message = (): string => { + let message = `expected (${this.utils.printReceived(actual)}).${property}`; + if (index !== null) message += `[${index}]`; + + return ( + message + + ` ${this.utils.printReceived( + actualValue + )} to be a StringExpression with value ${this.utils.printExpected(value)}` + ); + }; + + if ( + !(actualValue instanceof StringExpression) || + actualValue.text.asPlain !== value + ) { + return { + message, + pass: false, + }; + } + + if (actualValue.parent !== actual) { + return { + message: () => + `expected (${this.utils.printReceived( + actual + )}).${property} ${this.utils.printReceived( + actualValue + )} to have the correct parent`, + pass: false, + }; + } + + return {message, pass: true}; +} + +expect.extend({toHaveStringExpression}); + +// Serialize nodes using toJSON(), but also updating them to avoid run- or +// machine-specific information in the inputs and to make sources and nested +// nodes more concise. +expect.addSnapshotSerializer({ + test(value: unknown): boolean { + return value instanceof postcss.Node; + }, + + serialize( + value: postcss.Node, + config: pretty.Config, + indentation: string, + depth: number, + refs: pretty.Refs, + printer: pretty.Printer + ): string { + if (depth !== 0) return `<${value}>`; + + const json = value.toJSON() as Record; + for (const input of (json as {inputs: Record[]}).inputs) { + if ('id' in input) { + input.id = input.id.replace(/ [^ >]+>$/, ' _____>'); + } + if ('file' in input) { + input.file = p + .relative(process.cwd(), input.file) + .replaceAll(p.sep, p.posix.sep); + } + } + + // Convert JSON-ified Sass nodes back into their original forms so that they + // can be serialized tersely in snapshots. + for (const [key, jsonValue] of Object.entries(json)) { + if (!jsonValue) continue; + if (Array.isArray(jsonValue)) { + const originalArray = value[key as keyof typeof value]; + if (!Array.isArray(originalArray)) continue; + + for (let i = 0; i < jsonValue.length; i++) { + const element = jsonValue[i]; + if (element && typeof element === 'object' && 'sassType' in element) { + jsonValue[i] = originalArray[i]; + } + } + } else if ( + jsonValue && + typeof jsonValue === 'object' && + 'sassType' in jsonValue + ) { + json[key] = value[key as keyof typeof value]; + } + } + + return printer(json, config, indentation, depth, refs, true); + }, +}); + +/** The JSON serialization of {@link postcss.Range}. */ +interface JsonRange { + start: JsonPosition; + end: JsonPosition; + inputId: number; +} + +/** The JSON serialization of {@link postcss.Position}. */ +interface JsonPosition { + line: number; + column: number; + offset: number; +} + +// Serialize source entries as terse strings because otherwise they take up a +// large amount of room for a small amount of information. +expect.addSnapshotSerializer({ + test(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + 'inputId' in value && + 'start' in value && + 'end' in value + ); + }, + + serialize(value: JsonRange): string { + return ( + `<${tersePosition(value.start)}-${tersePosition(value.end)} in ` + + `${value.inputId}>` + ); + }, +}); + +/** Converts a {@link JsonPosition} into a terse string representation. */ +function tersePosition(position: JsonPosition): string { + if (position.offset !== position.column - 1) { + throw new Error( + 'Expected offset to be 1 less than column. Column is ' + + `${position.column} and offset is ${position.offset}.` + ); + } + + return `${position.line}:${position.column}`; +} + +export {}; diff --git a/pkg/sass-parser/test/utils.ts b/pkg/sass-parser/test/utils.ts new file mode 100644 index 000000000..1c667d62c --- /dev/null +++ b/pkg/sass-parser/test/utils.ts @@ -0,0 +1,35 @@ +// 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 { + ChildNode, + ChildProps, + Expression, + ExpressionProps, + GenericAtRule, + Interpolation, + Root, + scss, +} from '../lib'; + +/** Parses a Sass expression from {@link text}. */ +export function parseExpression(text: string): T { + const interpolation = (scss.parse(`@#{${text}}`).nodes[0] as GenericAtRule) + .nameInterpolation; + const expression = interpolation.nodes[0] as T; + interpolation.removeChild(expression); + return expression; +} + +/** Constructs a new node from {@link props} as in child node injection. */ +export function fromChildProps(props: ChildProps): T { + return new Root({nodes: [props]}).nodes[0] as T; +} + +/** Constructs a new expression from {@link props}. */ +export function fromExpressionProps( + props: ExpressionProps +): T { + return new Interpolation({nodes: [props]}).nodes[0] as T; +} diff --git a/pkg/sass-parser/tsconfig.build.json b/pkg/sass-parser/tsconfig.build.json new file mode 100644 index 000000000..78a5d1ef7 --- /dev/null +++ b/pkg/sass-parser/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["*.ts", "**/*.test.ts", "test/**/*.ts"] +} diff --git a/pkg/sass-parser/tsconfig.json b/pkg/sass-parser/tsconfig.json new file mode 100644 index 000000000..f0cf2e4c8 --- /dev/null +++ b/pkg/sass-parser/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "lib": ["es2022"], + "allowJs": true, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "useUnknownInCatchVariables": false, + "declaration": true + }, + "include": [ + "*.ts", + "lib/**/*.ts", + "tool/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/pkg/sass-parser/typedoc.config.js b/pkg/sass-parser/typedoc.config.js new file mode 100644 index 000000000..9395d03c8 --- /dev/null +++ b/pkg/sass-parser/typedoc.config.js @@ -0,0 +1,16 @@ +/** @type {import('typedoc').TypeDocOptions} */ +module.exports = { + entryPoints: ["./lib/index.ts"], + highlightLanguages: ["cmd", "dart", "dockerfile", "js", "ts", "sh", "html"], + out: "doc", + navigation: { + includeCategories: true, + }, + hideParameterTypesInTitle: false, + categorizeByGroup: false, + categoryOrder: [ + "Statement", + "Expression", + "Other", + ] +}; diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 8061e65ab..6731f41e5 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,4 +1,4 @@ -## 11.0.0 +## 12.0.0 * **Breaking change:** Remove the `SassApiColor.hasCalculatedRgb` and `.hasCalculatedHsl` extension methods. These can now be determined by checking @@ -49,9 +49,14 @@ * Added `SassNumber.convertValueToUnit()` as a shorthand for `SassNumber.convertValue()` with a single numerator. -## 10.5.0 +## 11.1.0 -* No user-visible changes. +* Loud comments in the Sass syntax no longer automatically inject ` */` to the + end when parsed. + +## 11.0.0 + +* Remove the `CallableDeclaration()` constructor. ## 10.4.8 diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 9912eecb6..26ae0c0bd 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: 11.0.0-dev +version: 12.0.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,10 +10,10 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: - sass: 1.78.0 + sass: 1.79.0 dev_dependencies: - dartdoc: ">=6.0.0 <9.0.0" + dartdoc: ^8.0.14 dependency_overrides: sass: { path: ../.. } diff --git a/pubspec.yaml b/pubspec.yaml index 5b278ec31..f9e04c1c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.79.0-dev # TODO: update the color-4-api and color-functions deprecations when this is updated +version: 1.79.0 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -17,7 +17,7 @@ dependencies: cli_pkg: ^2.8.0 cli_repl: ^0.2.1 collection: ^1.16.0 - http: "^1.1.0" + http: ^1.1.0 js: ^0.6.3 meta: ^1.3.0 native_synchronization: ^0.3.0 @@ -32,21 +32,21 @@ dependencies: stack_trace: ^1.10.0 stream_channel: ^2.1.0 stream_transform: ^2.0.0 - string_scanner: ^1.1.0 + string_scanner: ^1.3.0 term_glyph: ^1.2.0 typed_data: ^1.1.0 watcher: ^1.0.0 dev_dependencies: - analyzer: ">=5.13.0 <7.0.0" + analyzer: ^6.8.0 archive: ^3.1.2 crypto: ^3.0.0 dart_style: ^2.0.0 - dartdoc: ">=6.0.0 <9.0.0" + dartdoc: ^8.0.14 grinder: ^0.9.0 node_preamble: ^2.0.2 - lints: ">=2.0.0 <5.0.0" - protoc_plugin: ">=20.0.0 <22.0.0" + lints: ^4.0.0 + protoc_plugin: ^21.1.2 pub_api_client: ^2.1.1 pubspec_parse: ^1.3.0 test: ^1.16.7 diff --git a/test/double_check_test.dart b/test/double_check_test.dart index 6b8f67e96..b4490e586 100644 --- a/test/double_check_test.dart +++ b/test/double_check_test.dart @@ -46,98 +46,136 @@ void main() { // newline normalization issues. testOn: "!windows"); - for (var package in [ - ".", - ...Directory("pkg").listSync().map((entry) => entry.path) - ]) { + for (var package in [".", "pkg/sass_api"]) { group("in ${p.relative(package)}", () { test("pubspec version matches CHANGELOG version", () { - var firstLine = const LineSplitter() - .convert(File("$package/CHANGELOG.md").readAsStringSync()) - .first; - expect(firstLine, startsWith("## ")); - var changelogVersion = Version.parse(firstLine.substring(3)); - var pubspec = Pubspec.parse( File("$package/pubspec.yaml").readAsStringSync(), sourceUrl: p.toUri("$package/pubspec.yaml")); - expect( - pubspec.version!.toString(), - anyOf( - equals(changelogVersion.toString()), - changelogVersion.isPreRelease - ? equals("${changelogVersion.nextPatch}-dev") - : equals("$changelogVersion-dev"))); + expect(pubspec.version!.toString(), + matchesChangelogVersion(_changelogVersion(package))); }); }); } - for (var package in Directory("pkg").listSync().map((entry) => entry.path)) { - group("in pkg/${p.basename(package)}", () { - late Pubspec sassPubspec; - late Pubspec pkgPubspec; - setUpAll(() { - sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(), - sourceUrl: Uri.parse("pubspec.yaml")); - pkgPubspec = Pubspec.parse( - File("$package/pubspec.yaml").readAsStringSync(), - sourceUrl: p.toUri("$package/pubspec.yaml")); - }); + group("in pkg/sass_api", () { + late Pubspec sassPubspec; + late Pubspec pkgPubspec; + setUpAll(() { + sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(), + sourceUrl: Uri.parse("pubspec.yaml")); + pkgPubspec = Pubspec.parse( + File("pkg/sass_api/pubspec.yaml").readAsStringSync(), + sourceUrl: p.toUri("pkg/sass_api/pubspec.yaml")); + }); - test("depends on the current sass version", () { - if (_isDevVersion(sassPubspec.version!)) return; + test("depends on the current sass version", () { + if (_isDevVersion(sassPubspec.version!)) return; - expect(pkgPubspec.dependencies, contains("sass")); - var dependency = pkgPubspec.dependencies["sass"]!; - expect(dependency, isA()); - expect((dependency as HostedDependency).version, - equals(sassPubspec.version)); - }); + expect(pkgPubspec.dependencies, contains("sass")); + var dependency = pkgPubspec.dependencies["sass"]!; + expect(dependency, isA()); + expect((dependency as HostedDependency).version, + equals(sassPubspec.version)); + }); - test("increments along with the sass version", () { - var sassVersion = sassPubspec.version!; - if (_isDevVersion(sassVersion)) return; - - var pkgVersion = pkgPubspec.version!; - expect(_isDevVersion(pkgVersion), isFalse, - reason: "sass $sassVersion isn't a dev version but " - "${pkgPubspec.name} $pkgVersion is"); - - if (sassVersion.isPreRelease) { - expect(pkgVersion.isPreRelease, isTrue, - reason: "sass $sassVersion is a pre-release version but " - "${pkgPubspec.name} $pkgVersion isn't"); - } - - // If only sass's patch version was incremented, there's not a good way - // to tell whether the sub-package's version was incremented as well - // because we don't have access to the prior version. - if (sassVersion.patch != 0) return; - - if (sassVersion.minor != 0) { - expect(pkgVersion.patch, equals(0), - reason: "sass minor version was incremented, ${pkgPubspec.name} " - "must increment at least its minor version"); - } else { - expect(pkgVersion.minor, equals(0), - reason: "sass major version was incremented, ${pkgPubspec.name} " - "must increment at its major version as well"); - } - }); + test( + "increments along with the sass version", + () => _checkVersionIncrementsAlong( + 'sass_api', sassPubspec, pkgPubspec.version!)); - test("matches SDK version", () { - expect(pkgPubspec.environment!["sdk"], - equals(sassPubspec.environment!["sdk"])); - }); + test("matches SDK version", () { + expect(pkgPubspec.environment!["sdk"], + equals(sassPubspec.environment!["sdk"])); + }); - test("matches dartdoc version", () { - expect(sassPubspec.devDependencies["dartdoc"], - equals(pkgPubspec.devDependencies["dartdoc"])); - }); + test("matches dartdoc version", () { + expect(sassPubspec.devDependencies["dartdoc"], + equals(pkgPubspec.devDependencies["dartdoc"])); }); - } + }); + + group("in pkg/sass-parser", () { + late Pubspec sassPubspec; + late Map packageJson; + setUpAll(() { + sassPubspec = Pubspec.parse(File("pubspec.yaml").readAsStringSync(), + sourceUrl: Uri.parse("pubspec.yaml")); + packageJson = + json.decode(File("pkg/sass-parser/package.json").readAsStringSync()) + as Map; + }); + + test( + "package.json version matches CHANGELOG version", + () => expect(packageJson["version"].toString(), + matchesChangelogVersion(_changelogVersion("pkg/sass-parser")))); + + test("depends on the current sass version", () { + if (_isDevVersion(sassPubspec.version!)) return; + + var dependencies = packageJson["dependencies"] as Map; + expect( + dependencies, containsPair("sass", sassPubspec.version.toString())); + }); + + test( + "increments along with the sass version", + () => _checkVersionIncrementsAlong('sass-parser', sassPubspec, + Version.parse(packageJson["version"] as String))); + }); } /// Returns whether [version] is a `-dev` version. bool _isDevVersion(Version version) => version.preRelease.length == 1 && version.preRelease.first == 'dev'; + +/// Returns the most recent version in the CHANGELOG for [package]. +Version _changelogVersion(String package) { + var firstLine = const LineSplitter() + .convert(File("$package/CHANGELOG.md").readAsStringSync()) + .first; + expect(firstLine, startsWith("## ")); + return Version.parse(firstLine.substring(3)); +} + +/// Returns a [Matcher] that matches any valid variant of the CHANGELOG version +/// [version] that the package itself can have. +Matcher matchesChangelogVersion(Version version) => anyOf( + equals(version.toString()), + version.isPreRelease + ? equals("${version.nextPatch}-dev") + : equals("$version-dev")); + +/// Verifies that [pkgVersion] loks like it was incremented when the version of +/// the main Sass version was as well. +void _checkVersionIncrementsAlong( + String pkgName, Pubspec sassPubspec, Version pkgVersion) { + var sassVersion = sassPubspec.version!; + if (_isDevVersion(sassVersion)) return; + + expect(_isDevVersion(pkgVersion), isFalse, + reason: "sass $sassVersion isn't a dev version but $pkgName $pkgVersion " + "is"); + + if (sassVersion.isPreRelease) { + expect(pkgVersion.isPreRelease, isTrue, + reason: "sass $sassVersion is a pre-release version but $pkgName " + "$pkgVersion isn't"); + } + + // If only sass's patch version was incremented, there's not a good way + // to tell whether the sub-package's version was incremented as well + // because we don't have access to the prior version. + if (sassVersion.patch != 0) return; + + if (sassVersion.minor != 0) { + expect(pkgVersion.patch, equals(0), + reason: "sass minor version was incremented, $pkgName must increment " + "at least its minor version"); + } else { + expect(pkgVersion.minor, equals(0), + reason: "sass major version was incremented, $pkgName must increment " + "at its major version as well"); + } +} diff --git a/tool/grind.dart b/tool/grind.dart index 615a22903..0cea7264b 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -20,7 +20,7 @@ export 'grind/benchmark.dart'; export 'grind/double_check.dart'; export 'grind/frameworks.dart'; export 'grind/generate_deprecations.dart'; -export 'grind/subpackages.dart'; +export 'grind/sass_api.dart'; export 'grind/synchronize.dart'; export 'grind/utils.dart'; @@ -92,6 +92,7 @@ void main(List args) { 'NodePackageImporter', 'deprecations', 'Version', + 'parser_', }; pkg.githubReleaseNotes.fn = () => diff --git a/tool/grind/double_check.dart b/tool/grind/double_check.dart index 8b1dca18f..2578b5bf5 100644 --- a/tool/grind/double_check.dart +++ b/tool/grind/double_check.dart @@ -7,7 +7,6 @@ import 'dart:io'; import 'package:cli_pkg/cli_pkg.dart' as pkg; import 'package:collection/collection.dart'; import 'package:grinder/grinder.dart'; -import 'package:path/path.dart' as p; import 'package:pub_api_client/pub_api_client.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -36,8 +35,10 @@ Future doubleCheckBeforeRelease() async { ".", ...Directory("pkg").listSync().map((entry) => entry.path) ]) { - var pubspec = Pubspec.parse(File("$dir/pubspec.yaml").readAsStringSync(), - sourceUrl: p.toUri("$dir/pubspec.yaml")); + var pubspecFile = File("$dir/pubspec.yaml"); + if (!pubspecFile.existsSync()) continue; + var pubspec = Pubspec.parse(pubspecFile.readAsStringSync(), + sourceUrl: pubspecFile.uri); var package = await client.packageInfo(pubspec.name); if (pubspec.version == package.latestPubspec.version) { diff --git a/tool/grind/sass_api.dart b/tool/grind/sass_api.dart new file mode 100644 index 000000000..a45a406e8 --- /dev/null +++ b/tool/grind/sass_api.dart @@ -0,0 +1,80 @@ +// Copyright 2021 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:io'; +import 'dart:convert'; + +import 'package:cli_pkg/cli_pkg.dart' as pkg; +import 'package:cli_util/cli_util.dart'; +import 'package:grinder/grinder.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:yaml/yaml.dart'; + +import 'utils.dart'; + +/// The path in which pub expects to find its credentials file. +final String _pubCredentialsPath = + p.join(applicationConfigHome('dart'), 'pub-credentials.json'); + +@Task('Deploy pkg/sass_api to pub.') +Future deploySassApi() async { + // Write pub credentials + Directory(p.dirname(_pubCredentialsPath)).createSync(recursive: true); + File(_pubCredentialsPath).openSync(mode: FileMode.writeOnlyAppend) + ..writeStringSync(pkg.pubCredentials.value) + ..closeSync(); + + var client = http.Client(); + var pubspecPath = "pkg/sass_api/pubspec.yaml"; + var pubspec = Pubspec.parse(File(pubspecPath).readAsStringSync(), + sourceUrl: p.toUri(pubspecPath)); + + // Remove the dependency override on `sass`, because otherwise it will block + // publishing. + var pubspecYaml = Map.of( + loadYaml(File(pubspecPath).readAsStringSync()) as YamlMap); + pubspecYaml.remove("dependency_overrides"); + File(pubspecPath).writeAsStringSync(json.encode(pubspecYaml)); + + // We use symlinks to avoid duplicating files between the main repo and + // child repos, but `pub lish` doesn't resolve these before publishing so we + // have to do so manually. + for (var entry in Directory("pkg/sass_api") + .listSync(recursive: true, followLinks: false)) { + if (entry is! Link) continue; + var target = p.join(p.dirname(entry.path), entry.targetSync()); + entry.deleteSync(); + File(entry.path).writeAsStringSync(File(target).readAsStringSync()); + } + + log("dart pub publish ${pubspec.name}"); + var process = await Process.start( + p.join(sdkDir.path, "bin/dart"), ["pub", "publish", "--force"], + workingDirectory: "pkg/sass_api"); + LineSplitter().bind(utf8.decoder.bind(process.stdout)).listen(log); + LineSplitter().bind(utf8.decoder.bind(process.stderr)).listen(log); + if (await process.exitCode != 0) { + fail("dart pub publish ${pubspec.name} failed"); + } + + var response = await client.post( + Uri.parse("https://api.github.com/repos/sass/dart-sass/git/refs"), + headers: { + "accept": "application/vnd.github.v3+json", + "content-type": "application/json", + "authorization": githubAuthorization + }, + body: jsonEncode({ + "ref": "refs/tags/${pubspec.name}/${pubspec.version}", + "sha": Platform.environment["GITHUB_SHA"]! + })); + + if (response.statusCode != 201) { + fail("${response.statusCode} error creating tag:\n${response.body}"); + } else { + log("Tagged ${pubspec.name} ${pubspec.version}."); + } +} diff --git a/tool/grind/subpackages.dart b/tool/grind/subpackages.dart deleted file mode 100644 index c58719aeb..000000000 --- a/tool/grind/subpackages.dart +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2021 Google Inc. Use of this source code is governed by an -// MIT-style license that can be found in the LICENSE file or at -// https://opensource.org/licenses/MIT. - -import 'dart:io'; -import 'dart:convert'; - -import 'package:cli_pkg/cli_pkg.dart' as pkg; -import 'package:cli_util/cli_util.dart'; -import 'package:grinder/grinder.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:pubspec_parse/pubspec_parse.dart'; -import 'package:yaml/yaml.dart'; - -import 'utils.dart'; - -/// The path in which pub expects to find its credentials file. -final String _pubCredentialsPath = - p.join(applicationConfigHome('dart'), 'pub-credentials.json'); - -@Task('Deploy sub-packages to pub.') -Future deploySubPackages() async { - // Write pub credentials - Directory(p.dirname(_pubCredentialsPath)).createSync(recursive: true); - File(_pubCredentialsPath).openSync(mode: FileMode.writeOnlyAppend) - ..writeStringSync(pkg.pubCredentials.value) - ..closeSync(); - - var client = http.Client(); - for (var package in Directory("pkg").listSync().map((dir) => dir.path)) { - var pubspecPath = "$package/pubspec.yaml"; - var pubspec = Pubspec.parse(File(pubspecPath).readAsStringSync(), - sourceUrl: p.toUri(pubspecPath)); - - // Remove the dependency override on `sass`, because otherwise it will block - // publishing. - var pubspecYaml = Map.of( - loadYaml(File(pubspecPath).readAsStringSync()) as YamlMap); - pubspecYaml.remove("dependency_overrides"); - File(pubspecPath).writeAsStringSync(json.encode(pubspecYaml)); - - // We use symlinks to avoid duplicating files between the main repo and - // child repos, but `pub lish` doesn't resolve these before publishing so we - // have to do so manually. - for (var entry - in Directory(package).listSync(recursive: true, followLinks: false)) { - if (entry is! Link) continue; - var target = p.join(p.dirname(entry.path), entry.targetSync()); - entry.deleteSync(); - File(entry.path).writeAsStringSync(File(target).readAsStringSync()); - } - - log("dart pub publish ${pubspec.name}"); - var process = await Process.start( - p.join(sdkDir.path, "bin/dart"), ["pub", "publish", "--force"], - workingDirectory: package); - LineSplitter().bind(utf8.decoder.bind(process.stdout)).listen(log); - LineSplitter().bind(utf8.decoder.bind(process.stderr)).listen(log); - if (await process.exitCode != 0) { - fail("dart pub publish ${pubspec.name} failed"); - } - - var response = await client.post( - Uri.parse("https://api.github.com/repos/sass/dart-sass/git/refs"), - headers: { - "accept": "application/vnd.github.v3+json", - "content-type": "application/json", - "authorization": githubAuthorization - }, - body: jsonEncode({ - "ref": "refs/tags/${pubspec.name}/${pubspec.version}", - "sha": Platform.environment["GITHUB_SHA"]! - })); - - if (response.statusCode != 201) { - fail("${response.statusCode} error creating tag:\n${response.body}"); - } else { - log("Tagged ${pubspec.name} ${pubspec.version}."); - } - } -}