From eb6c19e53cfe9b9fef581efa163857b4f74196b9 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Wed, 21 Aug 2024 19:44:14 -0700 Subject: [PATCH] Initial implementation of a PostCSS-compatible parser JS API (#2304) This is just a vertical slice designed to solidify the general principles of the API design and ensure that everything works as expected. It's not yet usable as a full-fledged parser. Co-authored-by: Christophe Coevoet --- .github/dependabot.yml | 9 + .github/workflows/release.yml | 27 +- .github/workflows/test.yml | 72 ++ .gitignore | 2 + README.md | 4 +- lib/src/ast/sass/expression.dart | 7 +- .../ast/sass/expression/binary_operation.dart | 5 +- lib/src/ast/sass/expression/boolean.dart | 2 +- lib/src/ast/sass/expression/color.dart | 2 +- lib/src/ast/sass/expression/function.dart | 4 +- lib/src/ast/sass/expression/if.dart | 2 +- .../expression/interpolated_function.dart | 4 +- lib/src/ast/sass/expression/list.dart | 2 +- lib/src/ast/sass/expression/map.dart | 2 +- lib/src/ast/sass/expression/null.dart | 2 +- lib/src/ast/sass/expression/number.dart | 2 +- .../ast/sass/expression/parenthesized.dart | 2 +- lib/src/ast/sass/expression/selector.dart | 2 +- lib/src/ast/sass/expression/string.dart | 7 +- lib/src/ast/sass/expression/supports.dart | 2 +- .../ast/sass/expression/unary_operation.dart | 2 +- lib/src/ast/sass/expression/value.dart | 2 +- lib/src/ast/sass/expression/variable.dart | 2 +- lib/src/ast/sass/statement.dart | 5 +- lib/src/ast/sass/statement/content_rule.dart | 2 +- lib/src/ast/sass/statement/debug_rule.dart | 2 +- lib/src/ast/sass/statement/error_rule.dart | 2 +- lib/src/ast/sass/statement/extend_rule.dart | 2 +- lib/src/ast/sass/statement/forward_rule.dart | 2 +- lib/src/ast/sass/statement/if_rule.dart | 2 +- lib/src/ast/sass/statement/import_rule.dart | 2 +- lib/src/ast/sass/statement/include_rule.dart | 4 +- lib/src/ast/sass/statement/loud_comment.dart | 2 +- lib/src/ast/sass/statement/parent.dart | 2 +- lib/src/ast/sass/statement/return_rule.dart | 2 +- .../ast/sass/statement/silent_comment.dart | 2 +- lib/src/ast/sass/statement/use_rule.dart | 2 +- .../sass/statement/variable_declaration.dart | 2 +- lib/src/ast/sass/statement/warn_rule.dart | 2 +- lib/src/js.dart | 2 + lib/src/js/exports.dart | 3 + lib/src/js/parser.dart | 94 +++ lib/src/js/visitor/expression.dart | 74 ++ lib/src/js/visitor/statement.dart | 79 ++ pkg/sass-parser/.eslintignore | 2 + pkg/sass-parser/.eslintrc | 14 + pkg/sass-parser/.prettierrc.js | 3 + pkg/sass-parser/CHANGELOG.md | 3 + pkg/sass-parser/README.md | 248 ++++++ pkg/sass-parser/jest.config.ts | 8 + pkg/sass-parser/lib/.npmignore | 1 + pkg/sass-parser/lib/index.ts | 102 +++ .../__snapshots__/interpolation.test.ts.snap | 21 + .../binary-operation.test.ts.snap | 19 + .../__snapshots__/string.test.ts.snap | 18 + .../src/expression/binary-operation.test.ts | 201 +++++ .../lib/src/expression/binary-operation.ts | 151 ++++ pkg/sass-parser/lib/src/expression/convert.ts | 23 + .../lib/src/expression/from-props.ts | 14 + pkg/sass-parser/lib/src/expression/index.ts | 47 ++ .../lib/src/expression/string.test.ts | 332 ++++++++ pkg/sass-parser/lib/src/expression/string.ts | 203 +++++ pkg/sass-parser/lib/src/interpolation.test.ts | 636 ++++++++++++++ pkg/sass-parser/lib/src/interpolation.ts | 422 ++++++++++ pkg/sass-parser/lib/src/lazy-source.ts | 74 ++ pkg/sass-parser/lib/src/node.d.ts | 98 +++ pkg/sass-parser/lib/src/node.js | 47 ++ pkg/sass-parser/lib/src/postcss.d.ts | 19 + pkg/sass-parser/lib/src/postcss.js | 5 + pkg/sass-parser/lib/src/sass-internal.ts | 129 +++ .../generic-at-rule.test.ts.snap | 82 ++ .../statement/__snapshots__/root.test.ts.snap | 37 + .../statement/__snapshots__/rule.test.ts.snap | 41 + .../lib/src/statement/at-rule-internal.d.ts | 91 ++ .../lib/src/statement/at-rule-internal.js | 5 + .../lib/src/statement/container.test.ts | 188 +++++ .../lib/src/statement/generic-at-rule.test.ts | 793 ++++++++++++++++++ .../lib/src/statement/generic-at-rule.ts | 179 ++++ pkg/sass-parser/lib/src/statement/index.ts | 213 +++++ .../lib/src/statement/intercept-is-clean.ts | 33 + .../lib/src/statement/root-internal.d.ts | 89 ++ .../lib/src/statement/root-internal.js | 5 + .../lib/src/statement/root.test.ts | 159 ++++ pkg/sass-parser/lib/src/statement/root.ts | 81 ++ .../lib/src/statement/rule-internal.d.ts | 89 ++ .../lib/src/statement/rule-internal.js | 5 + .../lib/src/statement/rule.test.ts | 360 ++++++++ pkg/sass-parser/lib/src/statement/rule.ts | 141 ++++ pkg/sass-parser/lib/src/stringifier.ts | 88 ++ pkg/sass-parser/lib/src/utils.ts | 193 +++++ pkg/sass-parser/package.json | 51 ++ pkg/sass-parser/test/setup.ts | 285 +++++++ pkg/sass-parser/test/utils.ts | 35 + pkg/sass-parser/tsconfig.build.json | 4 + pkg/sass-parser/tsconfig.json | 18 + pkg/sass-parser/typedoc.config.js | 16 + pkg/sass_api/CHANGELOG.md | 4 +- pkg/sass_api/pubspec.yaml | 2 +- pubspec.yaml | 2 +- test/double_check_test.dart | 188 +++-- tool/grind.dart | 3 +- tool/grind/sass_api.dart | 80 ++ tool/grind/subpackages.dart | 82 -- 103 files changed, 6733 insertions(+), 205 deletions(-) create mode 100644 lib/src/js/parser.dart create mode 100644 lib/src/js/visitor/expression.dart create mode 100644 lib/src/js/visitor/statement.dart create mode 100644 pkg/sass-parser/.eslintignore create mode 100644 pkg/sass-parser/.eslintrc create mode 100644 pkg/sass-parser/.prettierrc.js create mode 100644 pkg/sass-parser/CHANGELOG.md create mode 100644 pkg/sass-parser/README.md create mode 100644 pkg/sass-parser/jest.config.ts create mode 100644 pkg/sass-parser/lib/.npmignore create mode 100644 pkg/sass-parser/lib/index.ts create mode 100644 pkg/sass-parser/lib/src/__snapshots__/interpolation.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/expression/__snapshots__/binary-operation.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/expression/__snapshots__/string.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/expression/binary-operation.test.ts create mode 100644 pkg/sass-parser/lib/src/expression/binary-operation.ts create mode 100644 pkg/sass-parser/lib/src/expression/convert.ts create mode 100644 pkg/sass-parser/lib/src/expression/from-props.ts create mode 100644 pkg/sass-parser/lib/src/expression/index.ts create mode 100644 pkg/sass-parser/lib/src/expression/string.test.ts create mode 100644 pkg/sass-parser/lib/src/expression/string.ts create mode 100644 pkg/sass-parser/lib/src/interpolation.test.ts create mode 100644 pkg/sass-parser/lib/src/interpolation.ts create mode 100644 pkg/sass-parser/lib/src/lazy-source.ts create mode 100644 pkg/sass-parser/lib/src/node.d.ts create mode 100644 pkg/sass-parser/lib/src/node.js create mode 100644 pkg/sass-parser/lib/src/postcss.d.ts create mode 100644 pkg/sass-parser/lib/src/postcss.js create mode 100644 pkg/sass-parser/lib/src/sass-internal.ts create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts create mode 100644 pkg/sass-parser/lib/src/statement/at-rule-internal.js create mode 100644 pkg/sass-parser/lib/src/statement/container.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/generic-at-rule.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/generic-at-rule.ts create mode 100644 pkg/sass-parser/lib/src/statement/index.ts create mode 100644 pkg/sass-parser/lib/src/statement/intercept-is-clean.ts create mode 100644 pkg/sass-parser/lib/src/statement/root-internal.d.ts create mode 100644 pkg/sass-parser/lib/src/statement/root-internal.js create mode 100644 pkg/sass-parser/lib/src/statement/root.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/root.ts create mode 100644 pkg/sass-parser/lib/src/statement/rule-internal.d.ts create mode 100644 pkg/sass-parser/lib/src/statement/rule-internal.js create mode 100644 pkg/sass-parser/lib/src/statement/rule.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/rule.ts create mode 100644 pkg/sass-parser/lib/src/stringifier.ts create mode 100644 pkg/sass-parser/lib/src/utils.ts create mode 100644 pkg/sass-parser/package.json create mode 100644 pkg/sass-parser/test/setup.ts create mode 100644 pkg/sass-parser/test/utils.ts create mode 100644 pkg/sass-parser/tsconfig.build.json create mode 100644 pkg/sass-parser/tsconfig.json create mode 100644 pkg/sass-parser/typedoc.config.js create mode 100644 tool/grind/sass_api.dart delete mode 100644 tool/grind/subpackages.dart 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/workflows/release.yml b/.github/workflows/release.yml index 0fe1525f0..2422ab6f0 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,33 @@ 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 }} + + - 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/README.md b/README.md index 0e00067d4..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 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/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/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/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/parser.dart b/lib/src/js/parser.dart new file mode 100644 index 000000000..92359db57 --- /dev/null +++ b/lib/src/js/parser.dart @@ -0,0 +1,94 @@ +// 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)); + 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/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/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..db6e98f7e --- /dev/null +++ b/pkg/sass-parser/README.md @@ -0,0 +1,248 @@ +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!"'); +``` diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts new file mode 100644 index 000000000..bdf7ad067 --- /dev/null +++ b/pkg/sass-parser/jest.config.ts @@ -0,0 +1,8 @@ +const config = { + preset: 'ts-jest', + roots: ['lib'], + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'], +}; + +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..7201833d7 --- /dev/null +++ b/pkg/sass-parser/lib/index.ts @@ -0,0 +1,102 @@ +// 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 { + 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 { + 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..387127e85 --- /dev/null +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -0,0 +1,422 @@ +// 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.length !== 1) return null; + if (typeof this.nodes[0] !== 'string') return null; + return this.nodes[0] as string; + } + + /** + * 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..5e9bf293a --- /dev/null +++ b/pkg/sass-parser/lib/src/postcss.d.ts @@ -0,0 +1,19 @@ +// 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'; + +declare module 'postcss' { + interface Container { + // We need to be able to override this and call it as a super method. + // TODO - postcss/postcss#1957: Remove this + /** @hidden */ + normalize( + node: string | postcss.ChildProps | postcss.Node, + sample: postcss.Node | undefined + ): Child[]; + } +} + +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..eba4b41cc --- /dev/null +++ b/pkg/sass-parser/lib/src/sass-internal.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 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; + + 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 AtRule extends ParentStatement { + readonly name: Interpolation; + readonly value?: Interpolation; + } + + 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 AtRule = SassInternal.AtRule; +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 { + visitAtRule(node: AtRule): 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__/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/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts new file mode 100644 index 000000000..0cff547e9 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts @@ -0,0 +1,91 @@ +// 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, ChildProps, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _AtRule< + Props extends Partial, +> extends postcss.AtRule { + 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; + index(child: ChildNode | number): number; + insertAfter(oldNode: ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + push(child: ChildNode): this; + removeChild(child: ChildNode | number): this; + replaceWith( + ...nodes: ( + | postcss.Node + | ReadonlyArray + | ChildProps + | ReadonlyArray + )[] + ): 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/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/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..c8ec3c745 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -0,0 +1,179 @@ +// 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 the param interpolation + * is lexed and made directly available to the caller. + * + * @category Statement + */ +export type GenericAtRuleRaws = Omit; + +/** + * 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; + + 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 { + return this.paramsInterpolation?.toString() ?? ''; + } + 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..0f0b79366 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -0,0 +1,213 @@ +// 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, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; +import {Root} from './root'; +import {Rule, RuleProps} from './rule'; + +// TODO: Replace this with the corresponding Sass types once they're +// implemented. +export {Comment, Declaration} from 'postcss'; + +/** + * The union type of all Sass statements. + * + * @category Statement + */ +export type AnyStatement = 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'; + +/** + * All Sass statements that are also at-rules. + * + * @category Statement + */ +export type AtRule = GenericAtRule; + +/** + * All Sass statements that are valid children of other statements. + * + * The Sass equivalent of PostCSS's `ChildNode`. + * + * @category Statement + */ +export type ChildNode = Rule | AtRule; + +/** + * 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 | RuleProps | GenericAtRuleProps; + +/** + * The Sass eqivalent of PostCSS's `ContainerProps`. + * + * @category Statement + */ +export interface ContainerProps extends NodeProps { + nodes?: (postcss.Node | ChildProps)[]; +} + +/** + * 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({ + visitAtRule: inner => new GenericAtRule(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; + +/** + * 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)); + } 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/root-internal.d.ts b/pkg/sass-parser/lib/src/statement/root-internal.d.ts new file mode 100644 index 000000000..71a9d6b07 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts @@ -0,0 +1,89 @@ +// 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, ChildProps, 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; + index(child: ChildNode | number): number; + insertAfter(oldNode: ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + push(child: ChildNode): this; + removeChild(child: ChildNode | number): this; + replaceWith( + ...nodes: ( + | postcss.Node + | ReadonlyArray + | ChildProps + | ReadonlyArray + )[] + ): 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..93818ea91 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts @@ -0,0 +1,89 @@ +// 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, ChildProps, 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; + index(child: ChildNode | number): number; + insertAfter(oldNode: ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + push(child: ChildNode): this; + removeChild(child: ChildNode | number): this; + replaceWith( + ...nodes: ( + | postcss.Node + | ReadonlyArray + | ChildProps + | ReadonlyArray + )[] + ): 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/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts new file mode 100644 index 000000000..c732b8dde --- /dev/null +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -0,0 +1,88 @@ +// 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 {GenericAtRule} from './statement/generic-at-rule'; +import {Rule} from './statement/rule'; + +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 atrule(node: GenericAtRule, semicolon: boolean): void { + const start = + `@${node.nameInterpolation}` + + (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) + + (node.paramsInterpolation ?? ''); + 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()); + } +} diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts new file mode 100644 index 000000000..d041c27fd --- /dev/null +++ b/pkg/sass-parser/lib/src/utils.ts @@ -0,0 +1,193 @@ +// 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; + } +} diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json new file mode 100644 index 000000000..83b93ee91 --- /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.77.8" + }, + "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 cbedd7005..8284c79ae 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,6 +1,6 @@ -## 10.5.0 +## 11.0.0 -* No user-visible changes. +* Remove the `CallableDeclaration()` constructor. ## 10.4.8 diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 8ae210dbc..9912eecb6 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: 10.5.0-dev +version: 11.0.0-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass diff --git a/pubspec.yaml b/pubspec.yaml index bb851e64e..51ea914b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,7 @@ 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 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 5f71995f5..8e95575ed 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/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}."); - } - } -}