diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 44ca4f044..c181c1c92 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,17 @@ updates: schedule: interval: "weekly" - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/util/*/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directories: + - "/" + - "/package" + - "/pkg/sass-parser" + ignore: + - dependency-name: "sass" schedule: interval: "weekly" diff --git a/.github/util/initialize/action.yml b/.github/util/initialize/action.yml index ad2abda5e..2fba83ffa 100644 --- a/.github/util/initialize/action.yml +++ b/.github/util/initialize/action.yml @@ -2,7 +2,7 @@ name: Initialize description: Check out Dart Sass and build the embedded protocol buffer. inputs: github-token: {required: true} - node-version: {required: false, default: 18} + node-version: {required: false, default: 'lts/*'} dart-sdk: {required: false, default: stable} architecture: {required: false} runs: @@ -13,24 +13,42 @@ runs: sdk: "${{ inputs.dart-sdk }}" architecture: "${{ inputs.architecture }}" - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: "${{ inputs.node-version }}" + # See: https://github.com/dart-lang/sdk/issues/52266 + - run: Invoke-WebRequest https://pub.dev + if: runner.os == 'Windows' + shell: powershell + + # See: https://github.com/orgs/community/discussions/131594 + # The composite action requires an explict shell, but bash is not available on windows-arm64 runner. + # For the following commands conditionally use bash or powershell based on the runner.os: - run: dart pub get - shell: bash + shell: ${{ runner.os == 'Windows' && 'powershell' || 'bash' }} - run: npm install - shell: bash + shell: ${{ runner.os == 'Windows' && 'powershell' || 'bash' }} - - uses: bufbuild/buf-setup-action@v1.13.1 + - uses: bufbuild/buf-setup-action@v1.46.0 with: {github_token: "${{ inputs.github-token }}"} + # This composite action requires bash, but bash is not available on windows-arm64 runner. + # Avoid running this composite action on non-PR, so that we can release on windows-arm64. - name: Check out the language repo + if: github.event_name == 'pull_request' uses: sass/clone-linked-repo@v1 with: {repo: sass/sass, path: build/language} + # Git is not pre-installed on windows-arm64 runner, however actions/checkout support + # downloading repo via GitHub API. + - name: Check out the language repo + if: github.event_name != 'pull_request' + uses: actions/checkout@v4 + with: {repository: sass/sass, path: build/language} + - name: Generate Dart from protobuf run: dart run grinder protobuf - env: {UPDATE_SASS_PROTOCOL: false} - shell: bash + env: {UPDATE_SASS_SASS_REPO: false} + shell: ${{ runner.os == 'Windows' && 'powershell' || 'bash' }} diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 000000000..4aa12a3bc --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,136 @@ +name: Build for linux + +on: + workflow_call: + workflow_dispatch: + +jobs: + build: + name: Build + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - image: docker.io/library/dart + platform: linux/amd64 + target: linux-x64 + - image: docker.io/library/dart + platform: linux/amd64 + target: linux-ia32 + - image: docker.io/library/dart + platform: linux/arm64 + target: linux-arm64 + - image: docker.io/library/dart + platform: linux/arm/v7 + target: linux-arm + - image: docker.io/library/debian:unstable-slim + platform: linux/riscv64 + target: linux-riscv64 + - image: ghcr.io/dart-musl/dart + platform: linux/amd64 + target: linux-x64-musl + - image: ghcr.io/dart-musl/dart + platform: linux/amd64 + target: linux-ia32-musl + - image: ghcr.io/dart-musl/dart + platform: linux/arm64 + target: linux-arm64-musl + - image: ghcr.io/dart-musl/dart + platform: linux/arm/v7 + target: linux-arm-musl + - image: ghcr.io/dart-musl/dart + platform: linux/riscv64 + target: linux-riscv64-musl + - image: ghcr.io/dart-android/dart + platform: linux/amd64 + target: android-x64 + - image: ghcr.io/dart-android/dart + platform: linux/amd64 + target: android-ia32 + - image: ghcr.io/dart-android/dart + platform: linux/arm64 + target: android-arm64 + - image: ghcr.io/dart-android/dart + platform: linux/arm64 + target: android-arm + - image: ghcr.io/dart-android/dart + platform: linux/riscv64 + target: android-riscv64 + + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Set up QEMU + run: docker run --privileged --rm registry.fedoraproject.org/fedora-minimal /bin/sh -c "microdnf install --assumeyes --nodocs --setopt=install_weak_deps=False qemu-user-static systemd-udev && mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc && /usr/lib/systemd/systemd-binfmt --unregister && /usr/lib/systemd/systemd-binfmt" + + - name: Build + if: matrix.image != 'ghcr.io/dart-android/dart' && matrix.image != 'docker.io/library/debian:unstable-slim' + run: | + docker run --rm -i \ + --platform ${{ matrix.platform }} \ + --volume "$PWD:$PWD" \ + --workdir "$PWD" \ + ${{ matrix.image }} <<'EOF' + set -e + dart pub get + dart run grinder pkg-standalone-${{ matrix.target }} + EOF + + - name: Build + if: matrix.image == 'ghcr.io/dart-android/dart' + run: | + docker run --rm -i \ + --privileged \ + --platform ${{ matrix.platform }} \ + --volume "$PWD:$PWD" \ + --workdir "$PWD" \ + ${{ matrix.image }} <<'EOF' + set -e + export DART_SDK=/system/${{ endsWith(matrix.target, '64') && 'lib64' || 'lib' }}/dart + export PATH=$DART_SDK/bin:$PATH + dart pub get + dart run grinder pkg-standalone-${{ matrix.target }} + EOF + + # https://github.com/dart-lang/dart-docker/issues/96#issuecomment-1669860829 + # There is no official riscv64 dart container image yet, build on debian:unstable instead. + # The setup is adopted from: https://github.com/dart-lang/dart-docker/blob/main/Dockerfile-debian.template + - name: Build + if: matrix.image == 'docker.io/library/debian:unstable-slim' + run: | + DART_CHANNEL=stable + DART_VERSION=$(curl -fsSL https://storage.googleapis.com/dart-archive/channels/$DART_CHANNEL/release/latest/VERSION | yq .version) + curl -fsSLO "https://storage.googleapis.com/dart-archive/channels/$DART_CHANNEL/release/$DART_VERSION/sdk/dartsdk-${{ matrix.target }}-release.zip" + + docker run --rm -i \ + --platform ${{ matrix.platform }} \ + --volume "$PWD:$PWD" \ + --workdir "$PWD" \ + ${{ matrix.image }} <<'EOF' + set -e + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl dnsutils git openssh-client unzip + + export DART_SDK=/usr/lib/dart + export PATH=$DART_SDK/bin:/root/.pub-cache/bin:$PATH + + SDK="dartsdk-${{ matrix.target }}-release.zip" + unzip "$SDK" && mv dart-sdk "$DART_SDK" && rm "$SDK" + + dart pub get + dart run grinder pkg-standalone-${{ matrix.target }} + EOF + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.target }} + path: build/*.tar.gz + if-no-files-found: error + compression-level: 0 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 000000000..660ceadfa --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,37 @@ +name: Build for macos + +on: + workflow_call: + workflow_dispatch: + +jobs: + build: + name: Build + + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: macos-13 + - arch: arm64 + runner: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Build + run: dart run grinder pkg-standalone-macos-${{ matrix.arch }} + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: build-macos-${{ matrix.arch }} + path: build/*.tar.gz + if-no-files-found: error + compression-level: 0 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 000000000..86ea17c22 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -0,0 +1,39 @@ +name: Build for windows + +on: + workflow_call: + workflow_dispatch: + +jobs: + build: + name: Build + + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: windows-latest + - arch: ia32 + runner: windows-latest + - arch: arm64 + runner: windows-arm64 + + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Build + run: dart run grinder pkg-standalone-windows-${{ matrix.arch }} + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: build-windows-${{ matrix.arch }} + path: build/*.zip + if-no-files-found: error + compression-level: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16c4ec7ec..cabc84e11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,5 @@ name: CI -defaults: - run: {shell: bash} - -# The default Node version lives in ../util/initialize/action.yml. It should be -# kept up-to-date with the latest Node LTS releases, along with the various -# node-version matrices below. -# -# Next update: April 2021 - on: push: branches: [main, feature.*] @@ -16,634 +7,34 @@ on: pull_request: jobs: - format: - name: Code formatting - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart format --fix . - - run: git diff --exit-code - - static_analysis: - name: Static analysis - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Analyze Dart - run: dart analyze --fatal-warnings ./ - - dartdoc: - name: Dartdoc - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: dartdoc sass - run: dart run dartdoc --quiet --no-generate-docs - --errors ambiguous-doc-reference,broken-link,deprecated - --errors unknown-directive,unknown-macro,unresolved-doc-reference - - name: dartdoc sass_api - run: cd pkg/sass_api && dart run dartdoc --quiet --no-generate-docs - --errors ambiguous-doc-reference,broken-link,deprecated - --errors unknown-directive,unknown-macro,unresolved-doc-reference - - sass_spec_language: - name: "Language Tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.async_label }}" - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - dart_channel: [stable, dev] - async_label: [synchronous] - async_args: [''] - include: - - dart_channel: stable - async_label: asynchronous - async_args: '--cmd-args --async' - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: - dart-sdk: ${{ matrix.dart_channel }} - github-token: ${{ github.token }} - - uses: ./.github/util/sass-spec - - - name: Run specs - run: npm run sass-spec -- --dart .. $extra_args - working-directory: sass-spec - env: {extra_args: "${{ matrix.async_args }}"} - - # The versions should be kept up-to-date with the latest LTS Node releases. - # They next need to be rotated April 2021. See - # https://github.com/nodejs/Release. - sass_spec_js: - name: "JS API Tests | Pure JS | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" - runs-on: "${{ matrix.os }}" - - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - dart_channel: [stable] - node-version: [18] - include: - # Include LTS versions on Ubuntu - - os: ubuntu-latest - dart_channel: stable - node-version: 16 - - os: ubuntu-latest - dart_channel: stable - node-version: 14 - - os: ubuntu-latest - dart_channel: dev - node-version: 18 - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: - dart-sdk: ${{ matrix.dart_channel }} - github-token: ${{ github.token }} - node-version: ${{ matrix.node-version }} - - uses: ./.github/util/sass-spec - - - name: Build JS - run: dart run grinder pkg-npm-dev - - - name: Check out Sass specification - uses: sass/clone-linked-repo@v1 - with: - repo: sass/sass - path: language - - - name: Run tests - run: npm run js-api-spec -- --sassSassRepo ../language --sassPackage ../build/npm - working-directory: sass-spec - - # The versions should be kept up-to-date with the latest LTS Node releases. - # They next need to be rotated October 2021. See - # https://github.com/nodejs/Release. - sass_spec_js_embedded: - name: 'JS API Tests | Embedded | Node ${{ matrix.node-version }} | ${{ matrix.os }}' - runs-on: ${{ matrix.os }}-latest - if: "github.event_name != 'pull_request' || !contains(github.event.pull_request.body, 'skip sass-embedded')" - - strategy: - fail-fast: false - matrix: - os: [ubuntu, windows, macos] - node-version: [18] - include: - # Include LTS versions on Ubuntu - - os: ubuntu - node-version: 16 - - os: ubuntu - node-version: 14 - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: - github-token: ${{ github.token }} - node-version: ${{ matrix.node-version }} - - uses: ./.github/util/sass-spec - - - name: Check out the embedded host - uses: sass/clone-linked-repo@v1 - with: {repo: sass/embedded-host-node} - - - name: Check out the language repo - uses: sass/clone-linked-repo@v1 - with: {repo: sass/sass, path: build/language} - - - name: Initialize embedded host - run: | - npm install - npm run init -- --compiler-path=.. --language-path=../build/language - npm run compile - mv {`pwd`/,dist/}lib/src/vendor/dart-sass - working-directory: embedded-host-node - - - name: Version info - run: | - path=embedded-host-node/dist/lib/src/vendor/dart-sass/sass - if [[ -f "$path.cmd" ]]; then "./$path.cmd" --version - elif [[ -f "$path.bat" ]]; then "./$path.bat" --version - elif [[ -f "$path.exe" ]]; then "./$path.exe" --version - else "./$path" --version - fi - - - name: Run tests - run: npm run js-api-spec -- --sassPackage ../embedded-host-node --sassSassRepo ../build/language - working-directory: sass-spec - - sass_spec_js_browser: - name: "JS API Tests | Browser | Dart ${{ matrix.dart_channel }}" - - strategy: - matrix: - dart_channel: [stable] - fail-fast: false - - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: browser-actions/setup-chrome@v1 - - uses: ./.github/util/initialize - with: - dart-sdk: ${{ matrix.dart_channel }} - github-token: ${{ github.token }} - - uses: ./.github/util/sass-spec - - - name: Build JS - run: dart run grinder pkg-npm-dev - - - name: Install built dependencies - run: npm install - working-directory: build/npm - - - name: Check out Sass specification - uses: sass/clone-linked-repo@v1 - with: - repo: sass/sass - path: language - - - name: Run tests - run: npm run js-api-spec -- --sassSassRepo ../language --sassPackage ../build/npm --browser - working-directory: sass-spec - env: - CHROME_EXECUTABLE: chrome - - dart_tests: - name: "Dart tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.os }}" - runs-on: "${{ matrix.os }}" - - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - dart_channel: [stable] - # TODO(nweiz): Re-enable this when - # https://github.com/dart-lang/sdk/issues/52121#issuecomment-1728534228 - # is addressed. - # include: [{os: ubuntu-latest, dart_channel: dev}] - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: - dart-sdk: ${{ matrix.dart_channel }} - github-token: ${{ github.token }} - - - run: dart run grinder pkg-standalone-dev - - name: Run tests - run: dart run test -x node - - # Unit tests that use Node.js, defined in test/. - # - # The versions should be kept up-to-date with the latest LTS Node releases. - # They next need to be rotated April 2021. See - # https://github.com/nodejs/Release. - node_tests: - name: "Node tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" - runs-on: "${{ matrix.os }}" - - strategy: - fail-fast: false - - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - dart_channel: [stable] - node-version: [18] - include: - # Include LTS versions on Ubuntu - - os: ubuntu-latest - dart_channel: stable - node-version: 16 - - os: ubuntu-latest - dart_channel: stable - node-version: 14 - - os: ubuntu-latest - dart_channel: dev - node-version: 18 - steps: - - uses: actions/checkout@v3 - - 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 - - name: Run tests - run: dart run test -t node -j 2 - - browser_tests: - name: "Browser Tests | Dart ${{ matrix.dart_channel }}" - - strategy: - matrix: - dart_channel: [stable] - fail-fast: false - - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: browser-actions/setup-chrome@v1 - - uses: ./.github/util/initialize - with: - dart-sdk: ${{ matrix.dart_channel }} - github-token: ${{ github.token }} - - - run: dart run grinder pkg-npm-dev - - name: Run tests - run: dart run test -p chrome -j 2 - env: - CHROME_EXECUTABLE: chrome + test: + uses: ./.github/workflows/test.yml + secrets: inherit double_check: name: Double-check runs-on: ubuntu-latest - needs: - - sass_spec_language - - sass_spec_js - - sass_spec_js_browser - - sass_spec_js_embedded - - dart_tests - - node_tests - - browser_tests - - static_analysis - - dartdoc - - format - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" + needs: [test] + if: "startsWith(github.ref, 'refs/tags/') && github.event.repository.fork == false" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/util/initialize with: {github-token: "${{ github.token }}"} - name: Run checks run: dart run grinder double-check-before-release - bootstrap: - name: "Bootstrap ${{ matrix.bootstrap_version }}" - runs-on: ubuntu-latest - needs: [double_check] - - strategy: - fail-fast: false - matrix: - bootstrap_version: [4, 5] - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - run: dart run grinder fetch-bootstrap${{matrix.bootstrap_version}} - env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - - name: Build - run: dart bin/sass.dart --quiet build/bootstrap/scss:build/bootstrap-output - - bourbon: - name: Bourbon - runs-on: ubuntu-latest - needs: [double_check] - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - run: dart run grinder fetch-bourbon - env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - - name: Test - run: | - dart bin/sass.dart --quiet -I build/bourbon -I build/bourbon/spec/fixtures \ - build/bourbon/spec/fixtures:build/bourbon-output - - foundation: - name: Foundation - runs-on: ubuntu-latest - needs: [double_check] - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - run: dart run grinder fetch-foundation - env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - # TODO(nweiz): Foundation has proper Sass tests, but they're currently not - # compatible with Dart Sass. Once they are, we should run those rather - # than just building the CSS output. - - name: Build - run: dart bin/sass.dart --quiet build/foundation-sites/assets:build/foundation-output - - bulma: - name: Bulma - runs-on: ubuntu-latest + test_vendor: needs: [double_check] - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - run: dart run grinder fetch-bulma - env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - - name: Build - run: dart bin/sass.dart --quiet build/bulma/bulma.sass build/bulma-output.css - - deploy_github_linux: - name: "Deploy Github: linux-ia32, linux-x64" - runs-on: ubuntu-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder pkg-github-release pkg-github-linux-ia32 pkg-github-linux-x64 - env: - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_github_linux_qemu: - name: "Deploy Github: linux-${{ matrix.arch }}" - runs-on: ubuntu-latest - strategy: - matrix: - include: - - arch: arm - platform: linux/arm/v7 - - arch: arm64 - platform: linux/arm64 - needs: [deploy_github_linux] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - uses: docker/setup-qemu-action@v2 - - name: Deploy - run: | - docker run --rm \ - --env "GH_TOKEN=$GH_TOKEN" \ - --env "GH_USER=$GH_USER" \ - --platform ${{ matrix.platform }} \ - --volume "$PWD:$PWD" \ - --workdir "$PWD" \ - docker.io/library/dart:latest \ - /bin/sh -c "dart pub get && dart run grinder pkg-github-linux-${{ matrix.arch }}" - env: - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_github: - name: "Deploy Github: ${{ matrix.platform }}" - runs-on: ${{ matrix.runner }} - needs: [deploy_github_linux] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - strategy: - matrix: - include: - - runner: macos-latest - platform: macos-x64 - architecture: x64 - - runner: self-hosted - platform: macos-arm64 - architecture: arm64 - - runner: windows-latest - platform: windows - architecture: x64 - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - # Workaround for dart-lang/setup-dart#59 - with: - github-token: ${{ github.token }} - architecture: ${{ matrix.architecture }} - - - name: Deploy - run: dart run grinder pkg-github-${{ matrix.platform }} - env: - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_npm: - name: Deploy npm - runs-on: ubuntu-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder pkg-npm-deploy - env: - NPM_TOKEN: "${{ secrets.NPM_TOKEN }}" - - deploy_bazel: - name: Deploy Bazel - runs-on: ubuntu-latest - needs: [deploy_npm] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder update-bazel - env: - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_pub: - name: "Deploy Pub" - runs-on: ubuntu-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder protobuf pkg-pub-deploy - env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} - - deploy_sub_packages: - name: "Deploy Sub-Packages" - runs-on: ubuntu-latest - needs: [deploy_pub] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder deploy-sub-packages - env: - PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}" - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_homebrew: - name: "Deploy Homebrew" - runs-on: ubuntu-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - - name: Deploy - run: dart run grinder pkg-homebrew-update - env: - GH_TOKEN: "${{ secrets.GH_TOKEN }}" - GH_USER: sassbot - - deploy_chocolatey: - name: "Deploy Chocolatey" - runs-on: windows-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - - steps: - - uses: actions/checkout@v3 - - uses: ./.github/util/initialize - with: {github-token: "${{ github.token }}"} - - - name: Deploy - run: dart run grinder pkg-chocolatey-deploy - env: {CHOCOLATEY_TOKEN: "${{ secrets.CHOCOLATEY_TOKEN }}"} - - deploy_website: - name: "Deploy sass-lang.com" - runs-on: ubuntu-latest - needs: [bootstrap, bourbon, foundation, bulma] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - steps: - - uses: actions/checkout@v3 - with: - repository: sass/sass-site - token: ${{ secrets.SASS_SITE_TOKEN }} - - - uses: EndBug/add-and-commit@v9 - with: - author_name: Sass Bot - author_email: sass.bot.beep.boop@gmail.com - message: Cut a release for a new Dart Sass version - commit: --allow-empty - - release_embedded_host: - name: "Release Embedded Host" - runs-on: ubuntu-latest - needs: [deploy_github_linux, deploy_github_linux_qemu, deploy_github] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - steps: - - uses: actions/checkout@v3 - with: - repository: sass/embedded-host-node - token: ${{ secrets.GH_TOKEN }} - - - name: Get version - id: version - run: echo "version=${GITHUB_REF##*/}" | tee --append "$GITHUB_OUTPUT" - - - name: Update version - run: | - # Update binary package versions - for dir in $(ls npm); do - cat "npm/$dir/package.json" | - jq --arg version ${{ steps.version.outputs.version }} ' - .version |= $version - ' > package.json.tmp && - mv package.json.tmp "npm/$dir/package.json" - done - - # Update main package version and dependencies on binary packages - cat package.json | - jq --arg version ${{ steps.version.outputs.version }} ' - .version |= $version | - ."compiler-version" |= $version | - .optionalDependencies = (.optionalDependencies | .[] |= $version) - ' > package.json.tmp && - mv package.json.tmp package.json - curl https://raw.githubusercontent.com/sass/dart-sass/${{ steps.version.outputs.version }}/CHANGELOG.md > CHANGELOG.md - shell: bash - - - uses: EndBug/add-and-commit@v9 - with: - author_name: Sass Bot - author_email: sass.bot.beep.boop@gmail.com - message: Update Dart Sass version and release - tag: ${{ steps.version.outputs.version }} + if: "startsWith(github.ref, 'refs/tags/') && github.event.repository.fork == false" + uses: ./.github/workflows/test-vendor.yml + secrets: inherit + + release: + needs: [test_vendor] + if: "startsWith(github.ref, 'refs/tags/') && github.event.repository.fork == false" + permissions: + contents: write + uses: ./.github/workflows/release.yml + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..28fc39218 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,248 @@ +name: Release + +on: + workflow_call: + +jobs: + build_linux: + uses: ./.github/workflows/build-linux.yml + secrets: inherit + + build_macos: + uses: ./.github/workflows/build-macos.yml + secrets: inherit + + build_windows: + uses: ./.github/workflows/build-windows.yml + secrets: inherit + + release_github: + name: Release Github + runs-on: ubuntu-latest + needs: [build_linux, build_macos, build_windows] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder pkg-github-release + env: + GH_TOKEN: "${{ secrets.GH_TOKEN }}" + GH_USER: sassbot + + deploy_github: + name: Deploy Github + runs-on: ubuntu-latest + needs: [release_github] + + permissions: + contents: write + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: | + build-*/* + + deploy_npm: + name: Deploy npm + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder pkg-npm-deploy + env: + UPDATE_SASS_SASS_REPO: false + NPM_TOKEN: "${{ secrets.NPM_TOKEN }}" + + deploy_bazel: + name: Deploy Bazel + runs-on: ubuntu-latest + needs: [deploy_npm] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder update-bazel + env: + GH_TOKEN: "${{ secrets.GH_TOKEN }}" + GH_USER: sassbot + + deploy_pub: + name: Deploy Pub + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder protobuf pkg-pub-deploy + env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} + + deploy_sass_api: + name: Deploy sass_api + runs-on: ubuntu-latest + needs: [deploy_pub] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder deploy-sass-api + env: + PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}" + GH_TOKEN: "${{ secrets.GH_TOKEN }}" + GH_USER: sassbot + + deploy_sass_parser: + name: Deploy sass-parser + runs-on: ubuntu-latest + needs: [deploy_npm] + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_TOKEN }} + # Set up .npmrc file to publish to npm + - uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + check-latest: true + registry-url: 'https://registry.npmjs.org' + + # The repo package has a file dependency, but the released version needs + # a real dependency on the released version of Sass. + - run: npm install sass@${{ steps.version.outputs.version }} + working-directory: pkg/sass-parser/ + + - run: npm publish + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + working-directory: pkg/sass-parser/ + + - 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 + + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + - run: dart pub get + + - name: Deploy + run: dart run grinder pkg-homebrew-update + env: + GH_TOKEN: "${{ secrets.GH_TOKEN }}" + GH_USER: sassbot + + deploy_chocolatey: + name: Deploy Chocolatey + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Deploy + run: dart run grinder pkg-chocolatey-deploy + env: {CHOCOLATEY_TOKEN: "${{ secrets.CHOCOLATEY_TOKEN }}"} + + deploy_website: + name: Deploy sass-lang.com + runs-on: ubuntu-latest + needs: [deploy_npm] + + steps: + - uses: actions/checkout@v4 + with: + repository: sass/sass-site + token: ${{ secrets.SASS_SITE_TOKEN }} + + - name: Get version + id: version + run: echo "version=${GITHUB_REF##*/}" | tee --append "$GITHUB_OUTPUT" + + - name: Wait for npm registry's CDN to catch up on replications + run: sleep 600 + + - name: Update Dart Sass version + run: npm install sass@${{ steps.version.outputs.version }} + + - uses: EndBug/add-and-commit@v9 + with: + author_name: Sass Bot + author_email: sass.bot.beep.boop@gmail.com + message: Cut a release for a new Dart Sass version + commit: --allow-empty + + release_embedded_host: + name: Release Embedded Host + runs-on: ubuntu-latest + needs: [deploy_github] + + steps: + - uses: actions/checkout@v4 + with: + repository: sass/embedded-host-node + token: ${{ secrets.GH_TOKEN }} + + - name: Get version + id: version + run: | + echo "version=${GITHUB_REF##*/}" | tee --append "$GITHUB_OUTPUT" + echo "protocol_version=$(curl -fsSL -H "Authorization: Bearer ${{ github.token }}" https://raw.githubusercontent.com/sass/sass/HEAD/spec/EMBEDDED_PROTOCOL_VERSION)" | tee --append "$GITHUB_OUTPUT" + + - name: Update version + run: | + # Update binary package versions + for dir in $(ls npm); do + cat "npm/$dir/package.json" | + jq --arg version ${{ steps.version.outputs.version }} ' + .version |= $version + ' > package.json.tmp && + mv package.json.tmp "npm/$dir/package.json" + done + + # Update main package version and dependencies on binary packages + cat package.json | + jq --arg version ${{ steps.version.outputs.version }} --arg protocol_version ${{ steps.version.outputs.protocol_version }} ' + .version |= $version | + ."compiler-version" |= $version | + ."protocol-version" |= $protocol_version | + .optionalDependencies = (.optionalDependencies | .[] |= $version) + ' > package.json.tmp && + mv package.json.tmp package.json + curl -fsSL -H "Authorization: Bearer ${{ github.token }}" https://raw.githubusercontent.com/sass/dart-sass/${{ steps.version.outputs.version }}/CHANGELOG.md > CHANGELOG.md + shell: bash + + - uses: EndBug/add-and-commit@v9 + with: + author_name: Sass Bot + author_email: sass.bot.beep.boop@gmail.com + message: Update Dart Sass version and release + tag: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/test-vendor.yml b/.github/workflows/test-vendor.yml new file mode 100644 index 000000000..5e71017a1 --- /dev/null +++ b/.github/workflows/test-vendor.yml @@ -0,0 +1,72 @@ +name: Test Vendor + +on: + workflow_call: + workflow_dispatch: + +jobs: + bootstrap: + name: "Bootstrap ${{ matrix.bootstrap_version }}" + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + bootstrap_version: [4, 5] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - run: dart run grinder fetch-bootstrap${{matrix.bootstrap_version}} + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} + - name: Build + run: dart bin/sass.dart --quiet build/bootstrap/scss:build/bootstrap-output + + bourbon: + name: Bourbon + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - run: dart run grinder fetch-bourbon + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} + - name: Test + run: | + dart bin/sass.dart --quiet -I build/bourbon -I build/bourbon/spec/fixtures \ + build/bourbon/spec/fixtures:build/bourbon-output + + foundation: + name: Foundation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - run: dart run grinder fetch-foundation + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} + # TODO(nweiz): Foundation has proper Sass tests, but they're currently not + # compatible with Dart Sass. Once they are, we should run those rather + # than just building the CSS output. + - name: Build + run: dart bin/sass.dart --quiet build/foundation-sites/assets:build/foundation-output + + bulma: + name: Bulma + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - run: dart run grinder fetch-bulma + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} + - name: Build + run: dart bin/sass.dart --quiet build/bulma/bulma.scss build/bulma-output.css diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..4ee23575b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,391 @@ +name: Test + +defaults: + run: {shell: bash} + +on: + workflow_call: + workflow_dispatch: + +jobs: + format: + name: Code formatting + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + - run: dart format --fix . + - run: git diff --exit-code + + static_analysis: + name: Static analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: Analyze Dart + run: dart analyze --fatal-warnings ./ + + dartdoc: + name: Dartdoc + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - name: dartdoc sass + run: dart run dartdoc --quiet --no-generate-docs + --errors ambiguous-doc-reference,broken-link,deprecated + --errors unknown-directive,unknown-macro,unresolved-doc-reference + - name: dartdoc sass_api + run: cd pkg/sass_api && dart run dartdoc --quiet --no-generate-docs + --errors ambiguous-doc-reference,broken-link,deprecated + --errors unknown-directive,unknown-macro,unresolved-doc-reference + + sass_spec_language: + name: "Language Tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.async_label }}" + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dart_channel: [stable, dev] + async_label: [synchronous] + async_args: [''] + include: + - dart_channel: stable + async_label: asynchronous + async_args: '--cmd-args --async' + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: + dart-sdk: ${{ matrix.dart_channel }} + github-token: ${{ github.token }} + - uses: ./.github/util/sass-spec + + - name: Run specs + run: npm run sass-spec -- --dart .. $extra_args + working-directory: sass-spec + env: {extra_args: "${{ matrix.async_args }}"} + + sass_spec_js: + name: "JS API Tests | Pure JS | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" + runs-on: "${{ matrix.os }}" + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + dart_channel: [stable] + node-version: ['lts/*'] + include: + # Test older LTS versions + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-1 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-2 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-3 + # 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 }} + - uses: ./.github/util/sass-spec + + - name: Build JS + run: dart run grinder pkg-npm-dev + env: {UPDATE_SASS_SASS_REPO: false} + + - name: Check out Sass specification + uses: sass/clone-linked-repo@v1 + with: + repo: sass/sass + path: language + + - name: Run tests + run: npm run js-api-spec -- --sassSassRepo ../language --sassPackage ../build/npm + working-directory: sass-spec + + sass_spec_js_embedded: + name: 'JS API Tests | Embedded | Node ${{ matrix.node-version }} | ${{ matrix.os }}' + runs-on: ${{ matrix.os }} + if: "github.event_name != 'pull_request' || !contains(github.event.pull_request.body, 'skip sass-embedded')" + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: ['lts/*'] + include: + # Test older LTS versions + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-1 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-2 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-3 + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: + github-token: ${{ github.token }} + node-version: ${{ matrix.node-version }} + - uses: ./.github/util/sass-spec + + - name: Check out the embedded host + uses: sass/clone-linked-repo@v1 + with: {repo: sass/embedded-host-node} + + - name: Check out the language repo + uses: sass/clone-linked-repo@v1 + with: {repo: sass/sass, path: build/language} + + - name: Initialize embedded host + run: | + npm install + npm run init -- --compiler-path=.. --language-path=../build/language + npm run compile + mv {`pwd`/,dist/}lib/src/vendor/dart-sass + working-directory: embedded-host-node + + - name: Version info + run: | + path=embedded-host-node/dist/lib/src/vendor/dart-sass/sass + if [[ -f "$path.cmd" ]]; then "./$path.cmd" --version + elif [[ -f "$path.bat" ]]; then "./$path.bat" --version + elif [[ -f "$path.exe" ]]; then "./$path.exe" --version + else "./$path" --version + fi + + - name: Run tests + run: npm run js-api-spec -- --sassPackage ../embedded-host-node --sassSassRepo ../build/language + working-directory: sass-spec + + sass_spec_js_browser: + name: "JS API Tests | Browser | Dart ${{ matrix.dart_channel }}" + + strategy: + matrix: + dart_channel: [stable] + fail-fast: false + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: browser-actions/setup-chrome@v1 + - uses: ./.github/util/initialize + with: + dart-sdk: ${{ matrix.dart_channel }} + github-token: ${{ github.token }} + - uses: ./.github/util/sass-spec + + - name: Build JS + run: dart run grinder pkg-npm-dev + env: {UPDATE_SASS_SASS_REPO: false} + + - name: Install built dependencies + run: npm install + working-directory: build/npm + + - name: Check out Sass specification + uses: sass/clone-linked-repo@v1 + with: + repo: sass/sass + path: language + + - name: Run tests + run: npm run js-api-spec -- --sassSassRepo ../language --sassPackage ../build/npm --browser + working-directory: sass-spec + env: + CHROME_EXECUTABLE: chrome + + dart_tests: + name: "Dart tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.os }}" + runs-on: "${{ matrix.os }}" + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + dart_channel: [stable] + # TODO(nweiz): Re-enable this when + # https://github.com/dart-lang/sdk/issues/52121#issuecomment-1728534228 + # is addressed. + # include: [{os: ubuntu-latest, dart_channel: dev}] + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/util/initialize + with: + dart-sdk: ${{ matrix.dart_channel }} + github-token: ${{ github.token }} + + - run: dart run grinder pkg-standalone-dev + - name: Run tests + run: dart run test -x node + + # Unit tests that use Node.js, defined in test/. + node_tests: + name: "Node tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" + runs-on: "${{ matrix.os }}" + + strategy: + fail-fast: false + + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + dart_channel: [stable] + node-version: ['lts/*'] + include: + # Test older LTS versions + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-1 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-2 + - os: ubuntu-latest + dart_channel: stable + node-version: lts/-3 + # 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} + - name: Run tests + run: dart run test -t node -j 2 + + browser_tests: + name: "Browser Tests | Dart ${{ matrix.dart_channel }}" + + strategy: + matrix: + dart_channel: [stable] + fail-fast: false + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: browser-actions/setup-chrome@v1 + - uses: ./.github/util/initialize + with: + dart-sdk: ${{ matrix.dart_channel }} + github-token: ${{ github.token }} + + - run: dart run grinder pkg-npm-dev + env: {UPDATE_SASS_SASS_REPO: false} + - run: sudo chmod 4755 /opt/google/chrome/chrome-sandbox + - name: Run tests + run: dart run test -p chrome -j 2 + env: + CHROME_EXECUTABLE: chrome + # See https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md#option-3_the-safest-way + CHROME_DEVEL_SANDBOX: /opt/google/chrome/chrome-sandbox + + 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 install + 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/*'} + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + + - run: dart run grinder pkg-npm-dev + env: {UPDATE_SASS_SASS_REPO: false} + - run: npm install + working-directory: build/npm/ + - 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/.pubignore b/.pubignore index 2fbab300a..08992ce75 100644 --- a/.pubignore +++ b/.pubignore @@ -1,5 +1,5 @@ # This should be identical to .gitignore except that it doesn't exclude -# generated protobuf files. +# generated Dart files. .buildlog .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e56a37ea6..397dc612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,726 @@ +## 1.81.0 + +* Fix a few cases where deprecation warnings weren't being emitted for global + built-in functions whose names overlap with CSS calculations. + +* Add support for the CSS `round()` calculation with a single argument, as long + as that argument might be a unitless number. + +## 1.80.7 + +### Embedded Host + +* Don't treat `0` as `undefined` for the `green` and `blue` channels in the + `LegacyColor` constructor. + +## 1.80.6 + +### Command-Line Interface + +* Make `@parcel/watcher` an optional dependency so this can still be installed + on operating systems where it's unavailable. + +## 1.80.5 + +### Embedded Host + +* Don't produce phantom `@import` deprecations when using an importer with the + legacy API. + +## 1.80.4 + +* No user-visible changes. + +## 1.80.3 + +* Fix a bug where `@import url("...")` would crash in plain CSS files. + +* Improve consistency of how warnings are emitted by different parts of the + compiler. This should result in minimal user-visible changes, but different + types of warnings should now respond more reliably to flags like `--quiet`, + `--verbose`, and `--silence-deprecation`. + +## 1.80.2 + +* Fix a bug where deprecation warnings were incorrectly emitted for the + plain-CSS `invert()` function. + +## 1.80.1 + +* Fix a bug where repeated deprecation warnings were not automatically limited. + +## 1.80.0 + +* `@import` is now officially deprecated, as are global built-in functions that + are available within built-in modules. See [the Sass blog post] for more + details on the deprecation process. + +[the Sass blog post]: https://sass-lang.com/blog/import-is-deprecated/ + +### Embedded Host + +* Fix an error that would sometimes occur when deprecation warnings were + emitted when using a custom importer with the legacy API. + +## 1.79.6 + +* Fix a bug where Sass would add an extra `*/` after loud comments with + whitespace after an explicit `*/` in the indented syntax. + +* **Potentially breaking bug fix:** Adding text after an explicit `*/` in the + indented syntax is now an error, rather than silently generating invalid CSS. + +### Embedded Host + +* Properly export the `SassBoolean` type. + +## 1.79.5 + +* Changes to how `selector.unify()` and `@extend` combine selectors: + + * The relative order of pseudo-classes (like `:hover`) and pseudo-elements + (like `::before`) within each original selector is now preserved when + they're combined. + + * Pseudo selectors are now consistently placed at the end of the combined + selector, regardless of which selector they came from. Previously, this + reordering only applied to pseudo-selectors in the second selector. + +* Tweak the color transformation matrices for OKLab and OKLCH to match the + newer, more accurate values in the CSS spec. + +* Fix a slight inaccuracy case when converting to `srgb-linear` and + `display-p3`. + +* **Potentially breaking bug fix:** `math.unit()` now wraps multiple denominator + units in parentheses. For example, `px/(em*em)` instead of `px/em*em`. + +### Command-Line Interface + +* Use `@parcel/watcher` to watch the filesystem when running from JavaScript and + not using `--poll`. This should mitigate more frequent failures users have + been seeing since version 4.0.0 of Chokidar, our previous watching tool, was + released. + +### JS API + +* Fix `SassColor.interpolate()` to allow an undefined `options` parameter, as + the types indicate. + +### Embedded Sass + +* Properly pass missing color channel values to and from custom functions. + +## 1.79.4 + +### JS API + +* Fix a bug where passing `green` or `blue` to `color.change()` for legacy + colors would fail. + +## 1.79.3 + +* Update the `$channel` parameter in the suggested replacement for + `color.red()`, `color.green()`, `color.blue()`, `color.hue()`, + `color.saturation()`, `color.lightness()`, `color.whiteness()`, and + `color.blackness()` to use a quoted string. + +## 1.79.2 + +* Add a `$space` parameter to the suggested replacement for `color.red()`, + `color.green()`, `color.blue()`, `color.hue()`, `color.saturation()`, + `color.lightness()`, `color.whiteness()`, and `color.blackness()`. + +* Update deprecation warnings for the legacy JS API to include a link to + [relevant documentation]. + +[relevant documentation]: https://sass-lang.com/d/legacy-js-api + +## 1.79.1 + +* No user-visible changes. + +## 1.79.0 + +* **Breaking change**: Passing a number with unit `%` to the `$alpha` parameter + of `color.change()`, `color.adjust()`, `change-color()`, and `adjust-color()` + is now interpreted as a percentage, instead of ignoring the unit. For example, + `color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`. + +* **Potentially breaking compatibility fix**: Sass no longer rounds RGB channels + to the nearest integer. This means that, for example, `rgb(0 0 1) != rgb(0 0 + 0.6)`. This matches the latest version of the CSS spec and browser behavior. + +* **Potentially breaking compatibility fix**: Passing large positive or negative + values to `color.adjust()` can now cause a color's channels to go outside that + color's gamut. In most cases this will currently be clipped by the browser and + end up showing the same color as before, but once browsers implement gamut + mapping it may produce a different result. + +* Add support for CSS Color Level 4 [color spaces]. Each color value now tracks + its color space along with the values of each channel in that color space. + There are two general principles to keep in mind when dealing with new color + spaces: + + 1. With the exception of legacy color spaces (`rgb`, `hsl`, and `hwb`), colors + will always be emitted in the color space they were defined in unless + they're explicitly converted. + + 2. The `color.to-space()` function is the only way to convert a color to + another color space. Some built-in functions may do operations in a + different color space, but they'll always convert back to the original space + afterwards. + +* `rgb` colors can now have non-integer channels and channels outside the normal + gamut of 0-255. These colors are always emitted using the `rgb()` syntax so + that modern browsers that are being displayed on wide-gamut devices can + display the most accurate color possible. + +* Add support for all the new color syntax defined in Color Level 4, including: + + * `oklab()`, `oklch()`, `lab()`, and `lch()` functions; + * a top-level `hwb()` function that matches the space-separated CSS syntax; + * and a `color()` function that supports the `srgb`, `srgb-linear`, + `display-p3`, `a98-rgb`, `prophoto-rgb`, `rec2020`, `xyz`, `xyz-d50`, and + `xyz-d65` color spaces. + +* Add new functions for working with color spaces: + + * `color.to-space($color, $space)` converts `$color` to the given `$space`. In + most cases this conversion is lossless—the color may end up out-of-gamut for + the destination color space, but browsers will generally display it as best + they can regardless. However, the `hsl` and `hwb` spaces can't represent + out-of-gamut colors and so will be clamped. + + * `color.channel($color, $channel, $space: null)` returns the value of the + given `$channel` in `$color`, after converting it to `$space` if necessary. + It should be used instead of the old channel-specific functions such as + `color.red()` and `color.hue()`. + + * `color.same($color1, $color2)` returns whether two colors represent the same + color even across color spaces. It differs from `$color1 == $color2` because + `==` never consider colors in different (non-legacy) spaces as equal. + + * `color.is-in-gamut($color, $space: null)` returns whether `$color` is + in-gamut for its color space (or `$space` if it's passed). + + * `color.to-gamut($color, $space: null)` returns `$color` constrained to its + space's gamut (or to `$space`'s gamut, if passed). This is generally not + recommended since even older browsers will display out-of-gamut colors as + best they can, but it may be necessary in some cases. + + * `color.space($color)`: Returns the name of `$color`'s color space. + + * `color.is-legacy($color)`: Returns whether `$color` is in a legacy color + space (`rgb`, `hsl`, or `hwb`). + + * `color.is-powerless($color, $channel, $space: null)`: Returns whether the + given `$channel` of `$color` is powerless in `$space` (or its own color + space). A channel is "powerless" if its value doesn't affect the way the + color is displayed, such as hue for a color with 0 chroma. + + * `color.is-missing($color, $channel)`: Returns whether `$channel`'s value is + missing in `$color`. Missing channels can be explicitly specified using the + special value `none` and can appear automatically when `color.to-space()` + returns a color with a powerless channel. Missing channels are usually + treated as 0, except when interpolating between two colors and in + `color.mix()` where they're treated as the same value as the other color. + +* Update existing functions to support color spaces: + + * `hsl()` and `color.hwb()` no longer forbid out-of-bounds values. Instead, + they follow the CSS spec by clamping them to within the allowed range. + + * `color.change()`, `color.adjust()`, and `color.scale()` now support all + channels of all color spaces. However, if you want to modify a channel + that's not in `$color`'s own color space, you have to explicitly specify the + space with the `$space` parameter. (For backwards-compatibility, this + doesn't apply to legacy channels of legacy colors—for example, you can still + adjust an `rgb` color's saturation without passing `$space: hsl`). + + * `color.mix()` and `color.invert()` now support the standard CSS algorithm + for interpolating between two colors (the same one that's used for gradients + and animations). To use this, pass the color space to use for interpolation + to the `$method` parameter. For polar color spaces like `hsl` and `oklch`, + this parameter also allows you to specify how hue interpolation is handled. + + * `color.complement()` now supports a `$space` parameter that indicates which + color space should be used to take the complement. + + * `color.grayscale()` now operates in the `oklch` space for non-legacy colors. + + * `color.ie-hex-str()` now automatically converts its color to the `rgb` space + and gamut-maps it so that it can continue to take colors from any color + space. + +[color spaces]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value + +* The following functions are now deprecated, and uses should be replaced with + the new color-space-aware functions defined above: + + * The `color.red()`, `color.green()`, `color.blue()`, `color.hue()`, + `color.saturation()`, `color.lightness()`, `color.whiteness()`, and + `color.blackness()` functions, as well as their global counterparts, should + be replaced with calls to `color.channel()`. + + * The global `adjust-hue()`, `saturate()`, `desaturate()`, `lighten()`, + `darken()`, `transaprentize()`, `fade-out()`, `opacify()`, and `fade-in()` + functions should be replaced by `color.adjust()` or `color.scale()`. + +* Add a `global-builtin` future deprecation, which can be opted-into with the + `--future-deprecation` flag or the `futureDeprecations` option in the JS or + Dart API. This emits warnings when any global built-in functions that are + now available in `sass:` modules are called. It will become active by default + in an upcoming release alongside the `@import` deprecation. + +### Dart API + +* Added a `ColorSpace` class which represents the various color spaces defined + in the CSS spec. + +* Added `SassColor.space` which returns a color's color space. + +* Added `SassColor.channels` and `.channelsOrNull` which returns a list + of channel values, with missing channels converted to 0 or exposed as null, + respectively. + +* Added `SassColor.isLegacy`, `.isInGamut`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.changeChannels()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* `SassColor.rgb()` now allows out-of-bounds and non-integer arguments. + +* `SassColor.hsl()` and `.hwb()` now allow out-of-bounds arguments. + +* Added `SassColor.hwb()`, `.srgb()`, `.srgbLinear()`, `.displayP3()`, + `.a98Rgb()`, `.prophotoRgb()`, `.rec2020()`, `.xyzD50()`, `.xyzD65()`, + `.lab()`, `.lch()`, `.oklab()`, `.oklch()`, and `.forSpace()` constructors. + +* Deprecated `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +* Deprecated `SassColor.changeRgb()`, `.changeHsl()`, and `.changeHwb()` in + favor of `SassColor.changeChannels()`. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + +* Added `InterpolationMethod` and `HueInterpolationMethod` which collectively + represent the method to use to interpolate two colors. + +### JS API + +* While the legacy API has been deprecated since we released the modern API, we + now emit warnings when the legacy API is used to make sure users are aware + that it will be removed in Dart Sass 2.0.0. In the meantime, you can silence + these warnings by passing `legacy-js-api` in `silenceDeprecations` when using + the legacy API. + +* Modify `SassColor` to accept a new `space` option, with support for all the + new color spaces defined in Color Level 4. + +* Add `SassColor.space` which returns a color's color space. + +* Add `SassColor.channels` and `.channelsOrNull` which returns a list of channel + values, with missing channels converted to 0 or exposed as null, respectively. + +* Add `SassColor.isLegacy`, `.isInGamut()`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.change()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* Deprecate `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +### Embedded Sass + +* Add `Color` SassScript value, with support for all the new color spaces + defined in Color Level 4. + +* Remove `RgbColor`, `HslColor` and `HwbColor` SassScript values. + +## 1.78.0 + +* The `meta.feature-exists` function is now deprecated. This deprecation is + named `feature-exists`. + +* Fix a crash when using `@at-root` without any queries or children in the + indented syntax. + +### JS API + +* Backport the deprecation options (`fatalDeprecations`, `futureDeprecations`, + and `silenceDeprecations`) to the legacy JS API. The legacy JS API is itself + deprecated, and you should move off of it if possible, but this will allow + users of bundlers and other tools that are still using the legacy API to + still control deprecation warnings. + +* Fix a bug where accessing `SourceSpan.url` would crash when a relative URL was + passed to the Sass API. + +### Embedded Sass + +* Explicitly expose a `sass` executable from the `sass-embedded` npm package. + This was intended to be included in 1.63.0, but due to the way + platform-specific dependency executables are installed it did not work as + intended. Now users can run `npx sass` for local installs or just `sass` when + `sass-embedded` is installed globally. + +* Add linux-riscv64, linux-musl-riscv64, and android-riscv64 support for the + `sass-embedded` npm package. + +* Fix an edge case where the Dart VM could hang when shutting down when requests + were in flight. + +* Fix a race condition where the embedded host could fail to shut down if it was + closed around the same time a new compilation was started. + +* Fix a bug where parse-time deprecation warnings could not be controlled by + the deprecation options in some circumstances. + +## 1.77.8 + +* No user-visible changes. + +## 1.77.7 + +* Declarations that appear after nested rules are deprecated, because the + semantics Sass has historically used are different from the semantics + specified by CSS. In the future, Sass will adopt the standard CSS semantics. + + See [the Sass website](https://sass-lang.com/d/mixed-decls) for details. + +* **Potentially breaking bug fix:** `//` in certain places such as unknown + at-rule values was being preserved in the CSS output, leading to potentially + invalid CSS. It's now properly parsed as a silent comment and omitted from the + CSS output. + +## 1.77.6 + +* Fix a few cases where comments and occasionally even whitespace wasn't allowed + between the end of Sass statements and the following semicolon. + +## 1.77.5 + +* Fully trim redundant selectors generated by `@extend`. + +## 1.77.4 + +### Embedded Sass + +* Support passing `Version` input for `fatalDeprecations` as string over + embedded protocol. + +* Fix a bug in the JS Embedded Host where `Version` could be incorrectly accepted + as input for `silenceDeprecations` and `futureDeprecations` in pure JS. + +## 1.77.3 + +### Dart API + +* `Deprecation.duplicateVariableFlags` has been deprecated and replaced with + `Deprecation.duplicateVarFlags` to make it consistent with the + `duplicate-var-flags` name used on the command line and in the JS API. + +## 1.77.2 + +* Don't emit deprecation warnings for functions and mixins beginning with `__`. + +* Allow user-defined functions whose names begin with `_` and otherwise look + like vendor-prefixed functions with special CSS syntax. + +### Command-Line Interface + +* Properly handle the `--silence-deprecation` flag. + +* Handle the `--fatal-deprecation` and `--future-deprecation` flags for + `--interactive` mode. + +## 1.77.1 + +* Fix a crash that could come up with importers in certain contexts. + +## 1.77.0 + +* *Don't* throw errors for at-rules in keyframe blocks. + +## 1.76.0 + +* Throw errors for misplaced statements in keyframe blocks. + +* Mixins and functions whose names begin with `--` are now deprecated for + forwards-compatibility with the in-progress CSS functions and mixins spec. + This deprecation is named `css-function-mixin`. + +## 1.75.0 + +* Fix a bug in which stylesheet canonicalization could be cached incorrectly + when custom importers or the Node.js package importer made decisions based on + the URL of the containing stylesheet. + +### JS API + +* Allow `importer` to be passed without `url` in `StringOptionsWithImporter`. + +## 1.74.1 + +* No user-visible changes. + +## 1.74.0 + +### JS API + +* Add a new top-level `deprecations` object, which contains various + `Deprecation` objects that define the different types of deprecation used by + the Sass compiler and can be passed to the options below. + +* Add a new `fatalDeprecations` compiler option that causes the compiler to + error if any deprecation warnings of the provided types are encountered. You + can also pass in a `Version` object to treat all deprecations that were active + in that Dart Sass version as fatal. + +* Add a new `futureDeprecations` compiler option that allows you to opt-in to + certain deprecations early (currently just `import`). + +* Add a new `silenceDeprecations` compiler option to ignore any deprecation + warnings of the provided types. + +### Command-Line Interface + +* Add a new `--silence-deprecation` flag, which causes the compiler to ignore + any deprecation warnings of the provided types. + +* Previously, if a future deprecation was passed to `--fatal-deprecation` but + not `--future-deprecation`, it would be treated as fatal despite not being + enabled. Both flags are now required to treat a future deprecation as fatal + with a warning emitted if `--fatal-deprecation` is passed without + `--future-deprecation`, matching the JS API's behavior. + +### Dart API + +* The `compile` methods now take in a `silenceDeprecations` parameter, which + causes the compiler to ignore any deprecation warnings of the provided types. + +* Add `Deprecation.obsoleteIn` to match the JS API. This is currently null for + all deprecations, but will be used once some deprecations become obsolete in + Dart Sass 2.0.0. + +* **Potentially breaking bug fix:** Fix a bug where `compileStringToResultAsync` + ignored `fatalDeprecations` and `futureDeprecations`. + +* The behavior around making future deprecations fatal mentioned in the CLI + section above has also been changed in the Dart API. + +## 1.73.0 + +* Add support for nesting in plain CSS files. This is not processed by Sass at + all; it's emitted exactly as-is in the CSS. + +* In certain circumstances, the current working directory was unintentionally + being made available as a load path. This is now deprecated. Anyone relying on + this should explicitly pass in `.` as a load path or `FilesystemImporter('.')` + as the current importer. + +* Add linux-riscv64 and windows-arm64 releases. + +### Command-Line Interface + +* Fix a bug where absolute `file:` URLs weren't loaded for files compiled via + the command line unless an unrelated load path was also passed. + +* Fix a bug where `--update` would always update files that were specified via + absolute path unless an unrelated load path was also passed. + +### Dart API + +* Add `FilesystemImporter.noLoadPath`, which is a `FilesystemImporter` that can + load absolute `file:` URLs and resolve URLs relative to the base file but + doesn't load relative URLs from a load path. + +* `FilesystemImporter.cwd` is now deprecated. Either use + `FilesystemImporter.noLoadPath` if you weren't intending to rely on the load + path, or `FilesystemImporter('.')` if you were. + +## 1.72.0 + +* Support adjacent `/`s without whitespace in between when parsing plain CSS + expressions. + +* Allow the Node.js `pkg:` importer to load Sass stylesheets for `package.json` + `exports` field entries without extensions. + +* When printing suggestions for variables, use underscores in variable names + when the original usage used underscores. + +### JavaScript API + +* Properly resolve `pkg:` imports with the Node.js package importer when + arguments are passed to the JavaScript process. + +## 1.71.1 + +### Command-Line Interface + +* Ship the musl Linux release with the proper Dart executable. + +### JavaScript API + +* Export the `NodePackageImporter` class in ESM mode. + +* Allow `NodePackageImporter` to locate a default directory even when the + entrypoint is an ESM module. + +### Dart API + +* Make passing a null argument to `NodePackageImporter()` a static error rather + than just a runtime error. + +### Embedded Sass + +* In the JS Embedded Host, properly install the musl Linux embedded compiler + when running on musl Linux. + +## 1.71.0 + +For more information about `pkg:` importers, see [the +announcement][pkg-importers] on the Sass blog. + +[pkg-importers]: https://sass-lang.com/blog/announcing-pkg-importers + +### Command-Line Interface + +* Add a `--pkg-importer` flag to enable built-in `pkg:` importers. Currently + this only supports the Node.js package resolution algorithm, via + `--pkg-importer=node`. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. + +### JavaScript API + +* Add a `NodePackageImporter` importer that can be passed to the `importers` + option. This loads files using the `pkg:` URL scheme according to the Node.js + package resolution algorithm. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. The constructor takes a single + optional argument, which indicates the base directory to use when locating + `node_modules` directories. It defaults to + `path.dirname(require.main.filename)`. + +### Dart API + +* Add a `NodePackageImporter` importer that can be passed to the `importers` + option. This loads files using the `pkg:` URL scheme according to the Node.js + package resolution algorithm. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. The constructor takes a single + argument, which indicates the base directory to use when locating + `node_modules` directories. + +## 1.70.0 + +### JavaScript API + +* Add a `sass.initCompiler()` function that returns a `sass.Compiler` object + which supports `compile()` and `compileString()` methods with the same API as + the global Sass object. On the Node.js embedded host, each `sass.Compiler` + object uses a single long-lived subprocess, making compiling multiple + stylesheets much more efficient. + +* Add a `sass.initAsyncCompiler()` function that returns a `sass.AsyncCompiler` + object which supports `compileAsync()` and `compileStringAsync()` methods with + the same API as the global Sass object. On the Node.js embedded host, each + `sass.AsynCompiler` object uses a single long-lived subprocess, making + compiling multiple stylesheets much more efficient. + +### Embedded Sass + +* Support the `CompileRequest.silent` field. This allows compilations with no + logging to avoid unnecessary request/response cycles. + +* The Dart Sass embedded compiler now reports its name as "dart-sass" rather + than "Dart Sass", to match the JS API's `info` field. + +## 1.69.7 + +### Embedded Sass + +* In the JS Embedded Host, properly install the x64 Dart Sass executable on + ARM64 Windows. + +## 1.69.6 + +* Produce better output for numbers with complex units in `meta.inspect()` and + debugging messages. + +* Escape U+007F DELETE when serializing strings. + +* When generating CSS error messages to display in-browser, escape all code + points that aren't in the US-ASCII region. Previously only code points U+0100 + LATIN CAPITAL LETTER A WITH MACRON were escaped. + +* Provide official releases for musl LibC and for Android. + +* Don't crash when running `meta.apply()` in asynchronous mode. + +### JS API + +* Fix a bug where certain exceptions could produce `SourceSpan`s that didn't + follow the documented `SourceSpan` API. + +## 1.69.5 + +### JS API + +* Compatibility with Node.js 21.0.0. + +## 1.69.4 + +* No user-visible changes. + +## 1.69.3 + +### Embedded Sass + +* Fix TypeScript type locations in `package.json`. + +## 1.69.2 + +### JS API + +* Fix a bug where Sass crashed when running in the browser if there was a global + variable named `process`. + +## 1.69.1 + +* No user-visible changes. + +## 1.69.0 + +* Add a `meta.get-mixin()` function that returns a mixin as a first-class Sass + value. + +* Add a `meta.apply()` mixin that includes a mixin value. + +* Add a `meta.module-mixins()` function which returns a map from mixin names in + a module to the first-class mixins that belong to those names. + +* Add a `meta.accepts-content()` function which returns whether or not a mixin + value can take a content block. + +* Add support for the relative color syntax from CSS Color 5. This syntax + cannot be used to create Sass color values. It is always emitted as-is in the + CSS output. + +### Dart API + +* Deprecate `Deprecation.calcInterp` since it was never actually emitted as a + deprecation. + +### Embedded Sass + +* Fix a rare race condition where the embedded compiler could freeze when a + protocol error was immediately followed by another request. + ## 1.68.0 * Fix the source spans associated with the `abs-percent` deprecation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 934a0576a..268e118b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,7 @@ Want to contribute? Great! First, read this page. * [Before You Contribute](#before-you-contribute) * [The Small Print](#the-small-print) +* [Large Language Models](#large-language-models) * [Development Dependencies](#development-dependencies) * [Writing Code](#writing-code) * [Changing the Language](#changing-the-language) @@ -9,6 +10,7 @@ Want to contribute? Great! First, read this page. * [Synchronizing](#synchronizing) * [File Headers](#file-headers) * [Release Process](#release-process) +* [Package Structure](#package-structure) ## Before You Contribute @@ -37,6 +39,17 @@ one above, the [corporate cla]: https://developers.google.com/open-source/cla/corporate +## Large Language Models + +Do not submit any code or prose written or modified by large language models or +"artificial intelligence" such as GitHub Copilot or ChatGPT to this project. +These tools produce code that looks plausible, which means that not only is it +likely to contain bugs those bugs are likely to be difficult to notice on +review. In addition, because these models were trained indiscriminately and +non-consensually on open-source code with a variety of licenses, it's not +obvious that we have the moral or legal right to redistribute code they +generate. + ## Development Dependencies 1. [Install the Dart SDK][]. If you download an archive manually rather than @@ -233,3 +246,13 @@ few things to do before pushing that tag: You *don't* need to create tags for packages in `pkg`; that will be handled automatically by GitHub actions. + +## Package Structure + +The structure of the Sass package is documented in README.md files in most +directories under `lib/`. This documentation is intended to help contributors +quickly build a basic understanding of the structure of the compiler and how its +various pieces fit together. [`lib/src/README.md`] is a good starting point to get +an overview of the compiler as a whole. + +[`lib/src/README.md`]: lib/src/README.md diff --git a/README.md b/README.md index 64bbb851e..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 @@ -42,6 +44,7 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**. * [Compatibility Policy](#compatibility-policy) * [Browser Compatibility](#browser-compatibility) * [Node.js Compatibility](#nodejs-compatibility) + * [Invalid CSS](#invalid-css) * [Embedded Dart Sass](#embedded-dart-sass) * [Usage](#usage) * [Behavioral Differences from Ruby Sass](#behavioral-differences-from-ruby-sass) @@ -200,7 +203,7 @@ files, you'll need to pass a [custom importer] to [`compileString()`] or [`compile()`]: https://sass-lang.com/documentation/js-api/functions/compile [`compileAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileAsync -[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter#importer +[custom importer]: https://sass-lang.com/documentation/js-api/interfaces/stringoptions/#importer [`compileString()`]: https://sass-lang.com/documentation/js-api/functions/compileString [`compileStringAsync()`]: https://sass-lang.com/documentation/js-api/functions/compileStringAsync [legacy API]: #legacy-javascript-api @@ -403,7 +406,19 @@ releases listed as Current, Active LTS, or Maintenance LTS according to [the Node.js release page][]. Once a Node.js version is out of LTS, Dart Sass considers itself free to break support if necessary. -[the Node.js release page]: https://nodejs.org/en/about/releases/ +[the Node.js release page]: https://nodejs.org/en/about/previous-releases + +### Invalid CSS + +Changes to the behavior of Sass stylesheets that produce invalid CSS output are +_not_ considered breaking changes. Such changes are almost always necessary when +adding support for new CSS features, and delaying all such features until a new +major version would be unduly burdensome for most users. + +For example, when Sass began parsing `calc()` expressions, the invalid +expression `calc(1 +)` became a Sass error where before it was passed through +as-is. This was not considered a breaking change, because `calc(1 +)` was never +valid CSS to begin with. ## Embedded Dart Sass diff --git a/analysis/README.md b/analysis/README.md index 87427c39e..6d3352009 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -4,8 +4,7 @@ packages. To use it, add it as a Git dependency to your `pubspec.yaml`: ```yaml dev_dependencies: sass_analysis: - git: {url: - https://github.com/sass/dart-sass.git, path: analysis} + git: {url: https://github.com/sass/dart-sass.git, path: analysis} ``` and include it in your `analysis_options.yaml`: diff --git a/analysis/pubspec.yaml b/analysis/pubspec.yaml index 4e9257b82..cbf04dedc 100644 --- a/analysis/pubspec.yaml +++ b/analysis/pubspec.yaml @@ -6,7 +6,7 @@ homepage: https://github.com/sass/dart-sass/tree/master/analysis publish_to: none environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: - lints: ^2.0.0 + lints: ^4.0.0 diff --git a/bin/sass.dart b/bin/sass.dart index ad23649d4..78ac31370 100644 --- a/bin/sass.dart +++ b/bin/sass.dart @@ -13,8 +13,8 @@ import 'package:sass/src/executable/options.dart'; import 'package:sass/src/executable/repl.dart'; import 'package:sass/src/executable/watch.dart'; import 'package:sass/src/import_cache.dart'; +import 'package:sass/src/importer/filesystem.dart'; import 'package:sass/src/io.dart'; -import 'package:sass/src/logger/deprecation_handling.dart'; import 'package:sass/src/stylesheet_graph.dart'; import 'package:sass/src/utils.dart'; import 'package:sass/src/embedded/executable.dart' @@ -44,17 +44,14 @@ Future main(List args) async { return; } + // Eagerly check these so that we fail here and don't hang in watch mode. + options.silenceDeprecations; + options.futureDeprecations; + options.fatalDeprecations; + var graph = StylesheetGraph(ImportCache( - loadPaths: options.loadPaths, - // This logger is only used for handling fatal/future deprecations - // during parsing, and is re-used across parses, so we don't want to - // limit repetition. A separate DeprecationHandlingLogger is created for - // each compilation, which will limit repetition if verbose is not - // passed in addition to handling fatal/future deprecations. - logger: DeprecationHandlingLogger(options.logger, - fatalDeprecations: options.fatalDeprecations, - futureDeprecations: options.futureDeprecations, - limitRepetition: false))); + importers: [...options.pkgImporters, FilesystemImporter.noLoadPath], + loadPaths: options.loadPaths)); if (options.watch) { await watch(options, graph); return; diff --git a/lib/sass.dart b/lib/sass.dart index dc94a90fd..19c6f8f87 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -31,7 +31,12 @@ export 'src/importer.dart'; export 'src/logger.dart' show Logger; export 'src/syntax.dart'; export 'src/value.dart' - hide ColorFormat, SassApiColor, SassApiValue, SpanColorFormat; + hide + ColorChannel, + ColorFormat, + LinearChannel, + SassApiColorSpace, + SpanColorFormat; export 'src/visitor/serialize.dart' show OutputStyle; export 'src/evaluation_context.dart' show warn; @@ -108,13 +113,13 @@ CompileResult compileToResult(String path, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) => c.compile(path, logger: logger, importCache: ImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), loadPaths: loadPaths, packageConfig: packageConfig), functions: functions, @@ -123,6 +128,7 @@ CompileResult compileToResult(String path, verbose: verbose, sourceMap: sourceMap, charset: charset, + silenceDeprecations: silenceDeprecations, fatalDeprecations: fatalDeprecations, futureDeprecations: futureDeprecations); @@ -207,6 +213,7 @@ CompileResult compileStringToResult(String source, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) => c.compileString(source, @@ -214,7 +221,6 @@ CompileResult compileStringToResult(String source, logger: logger, importCache: ImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), packageConfig: packageConfig, loadPaths: loadPaths), functions: functions, @@ -225,6 +231,7 @@ CompileResult compileStringToResult(String source, verbose: verbose, sourceMap: sourceMap, charset: charset, + silenceDeprecations: silenceDeprecations, fatalDeprecations: fatalDeprecations, futureDeprecations: futureDeprecations); @@ -245,13 +252,13 @@ Future compileToResultAsync(String path, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) => c.compileAsync(path, logger: logger, importCache: AsyncImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), loadPaths: loadPaths, packageConfig: packageConfig), functions: functions, @@ -260,6 +267,7 @@ Future compileToResultAsync(String path, verbose: verbose, sourceMap: sourceMap, charset: charset, + silenceDeprecations: silenceDeprecations, fatalDeprecations: fatalDeprecations, futureDeprecations: futureDeprecations); @@ -285,6 +293,7 @@ Future compileStringToResultAsync(String source, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) => c.compileStringAsync(source, @@ -292,7 +301,6 @@ Future compileStringToResultAsync(String source, logger: logger, importCache: AsyncImportCache( importers: importers, - logger: logger ?? Logger.stderr(color: color), packageConfig: packageConfig, loadPaths: loadPaths), functions: functions, @@ -302,7 +310,10 @@ Future compileStringToResultAsync(String source, quietDeps: quietDeps, verbose: verbose, sourceMap: sourceMap, - charset: charset); + charset: charset, + silenceDeprecations: silenceDeprecations, + fatalDeprecations: fatalDeprecations, + futureDeprecations: futureDeprecations); /// Like [compileToResult], but returns [CompileResult.css] rather than /// returning [CompileResult] directly. diff --git a/lib/src/README.md b/lib/src/README.md new file mode 100644 index 000000000..e7b53cd91 --- /dev/null +++ b/lib/src/README.md @@ -0,0 +1,230 @@ +# The Sass Compiler + +* [Life of a Compilation](#life-of-a-compilation) + * [Late Parsing](#late-parsing) + * [Early Serialization](#early-serialization) +* [JS Support](#js-support) +* [APIs](#apis) + * [Importers](#importers) + * [Custom Functions](#custom-functions) + * [Loggers](#loggers) +* [Built-In Functions](#built-in-functions) +* [`@extend`](#extend) + +This is the root directory of Dart Sass's private implementation libraries. This +contains essentially all the business logic defining how Sass is actually +compiled, as well as the APIs that users use to interact with Sass. There are +two exceptions: + +* [`../../bin/sass.dart`] is the entrypoint for the Dart Sass CLI (on all + platforms). While most of the logic it runs exists in this directory, it does + contain some logic to drive the basic compilation logic and handle errors. All + the most complex parts of the CLI, such as option parsing and the `--watch` + command, are handled in the [`executable`] directory. Even Embedded Sass runs + through this entrypoint, although it gets immediately gets handed off to [the + embedded compiler]. + + [`../../bin/sass.dart`]: ../../bin/sass.dart + [`executable`]: executable + [the embedded compiler]: embedded/README.md + +* [`../sass.dart`] is the entrypoint for the public Dart API. This is what's + loaded when a Dart package imports Sass. It just contains the basic + compilation functions, and exports the rest of the public APIs from this + directory. + + [`../sass.dart`]: ../sass.dart + +Everything else is contained here, and each file and some subdirectories have +their own documentation. But before you dive into those, let's take a look at +the general lifecycle of a Sass compilation. + +## Life of a Compilation + +Whether it's invoked through the Dart API, the JS API, the CLI, or the embedded +host, the basic process of a Sass compilation is the same. Sass is implemented +as an AST-walking [interpreter] that operates in roughly three passes: + +[interpreter]: https://en.wikipedia.org/wiki/Interpreter_(computing) + +1. **Parsing**. The first step of a Sass compilation is always to parse the + source file, whether it's SCSS, the indented syntax, or CSS. The parsing + logic lives in the [`parse`] directory, while the abstract syntax tree that + represents the parsed file lives in [`ast/sass`]. + + [`parse`]: parse/README.md + [`ast/sass`]: ast/sass/README.md + +2. **Evaluation**. Once a Sass file is parsed, it's evaluated by + [`visitor/async_evaluate.dart`]. (Why is there both an async and a sync + version of this file? See [Synchronizing] for details!) The evaluator handles + all the Sass-specific logic: it resolves variables, includes mixins, executes + control flow, and so on. As it goes, it builds up a new AST that represents + the plain CSS that is the compilation result, which is defined in + [`ast/css`]. + + [`visitor/async_evaluate.dart`]: visitor/async_evaluate.dart + [Synchronizing]: ../../CONTRIBUTING.md#synchronizing + [`ast/css`]: ast/css/README.md + + Sass evaluation is almost entirely linear: it begins at the first statement + of the file, evaluates it (which may involve evaluating its nested children), + adds its result to the CSS AST, and then moves on to the second statement. On + it goes until it reaches the end of the file, at which point it's done. The + only exception is module resolution: every Sass module has its own compiled + CSS AST, and once the entrypoint file is done compiling the evaluator will go + back through these modules, resolve `@extend`s across them as necessary, and + stitch them together into the final stylesheet. + + SassScript, the expression-level syntax, is handled by the same evaluator. + The main difference between SassScript and statement-level evaluation is that + the same SassScript values are used during evaluation _and_ as part of the + CSS AST. This means that it's possible to end up with a Sass-specific value, + such as a map or a first-class function, as the value of a CSS declaration. + If that happens, the Serialization phase will signal an error when it + encounters the invalid value. + +3. **Serialization**. Once we have the CSS AST that represents the compiled + stylesheet, we need to convert it into actual CSS text. This is done by + [`visitor/serialize.dart`], which walks the AST and builds up a big buffer of + the resulting CSS. It uses [a special string buffer] that tracks source and + destination locations in order to generate [source maps] as well. + + [`visitor/serialize.dart`]: visitor/serialize.dart + [a special string buffer]: util/source_map_buffer.dart + [source maps]: https://web.dev/source-maps/ + +There's actually one slight complication here: the first and second pass aren't +as separate as they appear. When one Sass stylesheet loads another with `@use`, +`@forward`, or `@import`, that rule is handled by the evaluator and _only at +that point_ is the loaded file parsed. So in practice, compilation actually +switches between parsing and evaluation, although each individual stylesheet +naturally has to be parsed before it can be evaluated. + +### Late Parsing + +Some syntax within a stylesheet is only parsed _during_ evaluation. This allows +authors to use `#{}` interpolation to inject Sass variables and other dynamic +values into various locations, such as selectors, while still allowing Sass to +parse them to support features like nesting and `@extend`. The following +syntaxes are parsed during evaluation: + +* [Selectors](parse/selector.dart) +* [`@keyframes` frames](parse/keyframe_selector.dart) +* [Media queries](parse/media_query.dart) (for historical reasons, these are + parsed before evaluation and then _reparsed_ after they've been fully + evaluated) + +### Early Serialization + +There are also some cases where the evaluator can serialize values before the +main serialization pass. For example, if you inject a variable into a selector +using `#{}`, that variable's value has to be converted to a string during +evaluation so that the evaluator can then parse and handle the newly-generated +selector. The evaluator does this by invoking the serializer _just_ for that +specific value. As a rule of thumb, this happens anywhere interpolation is used +in the original stylesheet, although there are a few other circumstances as +well. + +## JS Support + +One of the main benefits of Dart as an implementation language is that it allows +us to distribute Dart Sass both as an extremely efficient stand-alone executable +_and_ an easy-to-install pure-JavaScript package, using the dart2js compilation +tool. However, properly supporting JS isn't seamless. There are two major places +where we need to think about JS support: + +1. When interfacing with the filesystem. None of Dart's IO APIs are natively + supported on JS, so for anything that needs to work on both the Dart VM _and_ + Node.js we define a shim in the [`io`] directory that will be implemented in + terms of `dart:io` if we're running on the Dart VM or the `fs` or `process` + modules if we're running on Node. (We don't support IO at all on the browser + except to print messages to the console.) + + [`io`]: io/README.md + +2. When exposing an API. Dart's JS interop is geared towards _consuming_ JS + libraries from Dart, not producing a JS library written in Dart, so we have + to jump through some hoops to make it work. This is all handled in the [`js`] + directory. + + [`js`]: js/README.md + +## APIs + +One of Sass's core features is its APIs, which not only compile stylesheets but +also allow users to provide plugins that can be invoked from within Sass. In +both the JS API, the Dart API, and the embedded compiler, Sass provides three +types of plugins: importers, custom functions, and loggers. + +### Importers + +Importers control how Sass loads stylesheets through `@use`, `@forward`, and +`@import`. Internally, _all_ stylesheet loads are modeled as importers. When a +user passes a load path to an API or compiles a stylesheet through the CLI, we +just use the built-in [`FilesystemImporter`] which implements the same interface +that we make available to users. + +[`FilesystemImporter`]: importer/filesystem.dart + +In the Dart API, the importer root class is [`importer/async_importer.dart`]. +The JS API and the embedded compiler wrap the Dart importer API in +[`importer/node_to_dart`] and [`embedded/importer`] respectively. + +[`importer/async_importer.dart`]: importer/async_importer.dart +[`importer/node_to_dart`]: importer/node_to_dart +[`embedded/importer`]: embedded/importer + +### Custom Functions + +Custom functions are defined by users of the Sass API but invoked by Sass +stylesheets. To a Sass stylesheet, they look like any other built-in function: +users pass SassScript values to them and get SassScript values back. In fact, +all the core Sass functions are implemented using the Dart custom function API. + +Because custom functions take and return SassScript values, that means we need +to make _all_ values available to the various APIs. For Dart, this is +straightforward: we need to have objects to represent those values anyway, so we +just expose those objects publicly (with a few `@internal` annotations here and +there to hide APIs we don't want users relying on). These value types live in +the [`value`] directory. + +[`value`]: value/README.md + +Exposing values is a bit more complex for other platforms. For the JS API, we do +a bit of metaprogramming in [`js/value`] so that we can return the +same Dart values we use internally while still having them expose a JS API that +feels native to that language. For the embedded host, we convert them to and +from a protocol buffer representation in [`embedded/protofier.dart`]. + +[`js/value`]: js/value/README.md +[`embedded/value.dart`]: embedded/value.dart + +### Loggers + +Loggers are the simplest of the plugins. They're just callbacks that are invoked +any time Dart Sass would emit a warning (from the language or from `@warn`) or a +debug message from `@debug`. They're defined in: + +* [`logger.dart`](logger.dart) for Dart +* [`js/logger.dart`](js/logger.dart) for Node +* [`embedded/logger.dart`](embedded/logger.dart) for the embedded compiler + +## Built-In Functions + +All of Sass's built-in functions are defined in the [`functions`] directory, +including both global functions and functions defined in core modules like +`sass:math`. As mentioned before, these are defined using the standard custom +function API, although in a few cases they use additional private features like +the ability to define multiple overloads of the same function name. + +[`functions`]: functions/README.md + +## `@extend` + +The logic for Sass's `@extend` rule is particularly complex, since it requires +Sass to not only parse selectors but to understand how to combine them and when +they can be safely optimized away. Most of the logic for this is contained +within the [`extend`] directory. + +[`extend`]: extend/README.md diff --git a/lib/src/ast/css/README.md b/lib/src/ast/css/README.md new file mode 100644 index 000000000..e75c3ff5c --- /dev/null +++ b/lib/src/ast/css/README.md @@ -0,0 +1,52 @@ +# CSS Abstract Syntax Tree + +This directory contains the abstract syntax tree that represents a plain CSS +file generated by Sass compilation. It differs from other Sass ASTs in two major +ways: + +1. Instead of being created by [a parser], it's created by [the evaluator] as it + traverses the [Sass AST]. + + [a parser]: ../../parse/README.md + [the evaluator]: ../../visitor/async_evaluate.dart + [Sass AST]: ../sass/README.md + +2. Because of various Sass features like `@extend` and at-rule hoisting, the CSS + AST is mutable even though all other ASTs are immutable. + +**Note:** the CSS AST doesn't have its own representation of declaration values. +Instead, declaration values are represented as [`Value`] objects. This does mean +that a CSS AST can be in a state where some of its values aren't representable +in plain CSS (such as maps)—in this case, [the serializer] will emit an error. + +[`Value`]: ../../value/README.md +[the serializer]: ../../visitor/serialize.dart + +## Mutable and Immutable Views + +Internally, the CSS AST is mutable to allow for operations like hoisting rules +to the root of the AST and updating existing selectors when `@extend` rules are +encountered. However, because mutability poses a high risk for "spooky [action +at a distance]", we limit access to mutating APIs exclusively to the evaluator. + +[action at a distance]: https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming) + +We do this by having an _unmodifiable_ interface (written in this directory) for +each CSS AST node which only exposes members that don't modify the node in +question. The implementations of those interfaces, which _do_ have modifying +methods, live in the [`modifiable`] directory. We then universally refer to the +immutable node interfaces except specifically in the evaluator, and the type +system automatically ensures we don't accidentally mutate anything we don't +intend to. + +[`modifiable`]: modifiable + +(Of course, it's always possible to cast an immutable node type to a mutable +one, but that's a very clear code smell that a reviewer can easily identify.) + +## CSS Source Files + +A lesser-known fact about Sass is that it actually supports _three_ syntaxes for +its source files: SCSS, the indented syntax, and plain CSS. But even when it +parses plain CSS, it uses the Sass AST rather than the CSS AST to represent it +so that parsing logic can easily be shared with the other stylesheet parsers. diff --git a/lib/src/ast/css/declaration.dart b/lib/src/ast/css/declaration.dart index 4d5e906cd..2d8785dbf 100644 --- a/lib/src/ast/css/declaration.dart +++ b/lib/src/ast/css/declaration.dart @@ -2,10 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import 'package:stack_trace/stack_trace.dart'; import '../../value.dart'; import 'node.dart'; +import 'style_rule.dart'; import 'value.dart'; /// A plain CSS declaration (that is, a `name: value` pair). @@ -16,6 +19,22 @@ abstract interface class CssDeclaration implements CssNode { /// The value of this declaration. CssValue get value; + /// A list of style rules that appeared before this declaration in the Sass + /// input but after it in the CSS output. + /// + /// These are used to emit mixed declaration deprecation warnings during + /// serialization, so we can check based on specificity whether the warnings + /// are really necessary without worrying about `@extend` potentially changing + /// things up. + @internal + List get interleavedRules; + + /// The stack trace indicating where this node was created. + /// + /// This is used to emit interleaved declaration warnings, and is only set if + /// [interleavedRules] isn't empty. + Trace? get trace; + /// The span for [value] that should be emitted to the source map. /// /// When the declaration's expression is just a variable, this is the span diff --git a/lib/src/ast/css/media_query.dart b/lib/src/ast/css/media_query.dart index dc2d5d532..bad97ada6 100644 --- a/lib/src/ast/css/media_query.dart +++ b/lib/src/ast/css/media_query.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import '../../interpolation_map.dart'; -import '../../logger.dart'; import '../../parse/media_query.dart'; import '../../utils.dart'; @@ -44,9 +43,8 @@ final class CssMediaQuery { /// /// Throws a [SassFormatException] if parsing fails. static List parseList(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) => - MediaQueryParser(contents, - url: url, logger: logger, interpolationMap: interpolationMap) + {Object? url, InterpolationMap? interpolationMap}) => + MediaQueryParser(contents, url: url, interpolationMap: interpolationMap) .parse(); /// Creates a media query specifies a type and, optionally, conditions. diff --git a/lib/src/ast/css/modifiable/declaration.dart b/lib/src/ast/css/modifiable/declaration.dart index 1acb292a7..3a0279307 100644 --- a/lib/src/ast/css/modifiable/declaration.dart +++ b/lib/src/ast/css/modifiable/declaration.dart @@ -3,11 +3,13 @@ // https://opensource.org/licenses/MIT. import 'package:source_span/source_span.dart'; +import 'package:stack_trace/stack_trace.dart'; import '../../../value.dart'; import '../../../visitor/interface/modifiable_css.dart'; import '../declaration.dart'; import '../value.dart'; +import '../style_rule.dart'; import 'node.dart'; /// A modifiable version of [CssDeclaration] for use in the evaluation step. @@ -16,6 +18,8 @@ final class ModifiableCssDeclaration extends ModifiableCssNode final CssValue name; final CssValue value; final bool parsedAsCustomProperty; + final List interleavedRules; + final Trace? trace; final FileSpan valueSpanForMap; final FileSpan span; @@ -23,8 +27,14 @@ final class ModifiableCssDeclaration extends ModifiableCssNode /// Returns a new CSS declaration with the given properties. ModifiableCssDeclaration(this.name, this.value, this.span, - {required this.parsedAsCustomProperty, FileSpan? valueSpanForMap}) - : valueSpanForMap = valueSpanForMap ?? value.span { + {required this.parsedAsCustomProperty, + Iterable? interleavedRules, + this.trace, + FileSpan? valueSpanForMap}) + : interleavedRules = interleavedRules == null + ? const [] + : List.unmodifiable(interleavedRules), + valueSpanForMap = valueSpanForMap ?? value.span { if (parsedAsCustomProperty) { if (!isCustomProperty) { throw ArgumentError( diff --git a/lib/src/ast/css/modifiable/node.dart b/lib/src/ast/css/modifiable/node.dart index bef0be821..230cab2e7 100644 --- a/lib/src/ast/css/modifiable/node.dart +++ b/lib/src/ast/css/modifiable/node.dart @@ -13,11 +13,10 @@ import '../node.dart'; /// modification should only be done within the evaluation step, so the /// unmodifiable types are used elsewhere to enforce that constraint. abstract base class ModifiableCssNode extends CssNode { - /// The node that contains this, or `null` for the root [CssStylesheet] node. ModifiableCssParentNode? get parent => _parent; ModifiableCssParentNode? _parent; - /// The index of [this] in `parent.children`. + /// The index of `this` in `parent.children`. /// /// This makes [remove] more efficient. int? _indexInParent; @@ -33,7 +32,7 @@ abstract base class ModifiableCssNode extends CssNode { T accept(ModifiableCssVisitor visitor); - /// Removes [this] from [parent]'s child list. + /// Removes `this` from [parent]'s child list. /// /// Throws a [StateError] if [parent] is `null`. void remove() { @@ -65,10 +64,10 @@ abstract base class ModifiableCssParentNode extends ModifiableCssNode : _children = children, children = UnmodifiableListView(children); - /// Returns whether [this] is equal to [other], ignoring their child nodes. + /// Returns whether `this` is equal to [other], ignoring their child nodes. bool equalsIgnoringChildren(ModifiableCssNode other); - /// Returns a copy of [this] with an empty [children] list. + /// Returns a copy of `this` with an empty [children] list. /// /// This is *not* a deep copy. If other parts of this node are modifiable, /// they are shared between the new and old nodes. diff --git a/lib/src/ast/css/modifiable/style_rule.dart b/lib/src/ast/css/modifiable/style_rule.dart index a5d2b1f0c..6e242d36c 100644 --- a/lib/src/ast/css/modifiable/style_rule.dart +++ b/lib/src/ast/css/modifiable/style_rule.dart @@ -21,12 +21,13 @@ final class ModifiableCssStyleRule extends ModifiableCssParentNode final SelectorList originalSelector; final FileSpan span; + final bool fromPlainCss; /// Creates a new [ModifiableCssStyleRule]. /// /// If [originalSelector] isn't passed, it defaults to [_selector.value]. ModifiableCssStyleRule(this._selector, this.span, - {SelectorList? originalSelector}) + {SelectorList? originalSelector, this.fromPlainCss = false}) : originalSelector = originalSelector ?? _selector.value; T accept(ModifiableCssVisitor visitor) => diff --git a/lib/src/ast/css/node.dart b/lib/src/ast/css/node.dart index 29daba28d..04b6b839d 100644 --- a/lib/src/ast/css/node.dart +++ b/lib/src/ast/css/node.dart @@ -15,6 +15,10 @@ import 'style_rule.dart'; /// A statement in a plain CSS syntax tree. @sealed abstract class CssNode implements AstNode { + /// The node that contains this, or `null` for the root [CssStylesheet] node. + @internal + CssParentNode? get parent; + /// Whether this was generated from the last node in a nested Sass tree that /// got flattened during evaluation. bool get isGroupEnd; diff --git a/lib/src/ast/css/style_rule.dart b/lib/src/ast/css/style_rule.dart index ccce74fdb..58bbe5424 100644 --- a/lib/src/ast/css/style_rule.dart +++ b/lib/src/ast/css/style_rule.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; + import '../selector.dart'; import 'node.dart'; @@ -16,4 +18,10 @@ abstract interface class CssStyleRule implements CssParentNode { /// The selector for this rule, before any extensions were applied. SelectorList get originalSelector; + + /// Whether this style rule was originally defined in a plain CSS stylesheet. + /// + /// @nodoc + @internal + bool get fromPlainCss; } diff --git a/lib/src/ast/css/stylesheet.dart b/lib/src/ast/css/stylesheet.dart index 08a671cde..45d4d97bb 100644 --- a/lib/src/ast/css/stylesheet.dart +++ b/lib/src/ast/css/stylesheet.dart @@ -13,6 +13,7 @@ import 'node.dart'; /// /// This is the root plain CSS node. It contains top-level statements. class CssStylesheet extends CssParentNode { + CssParentNode? get parent => null; final List children; final FileSpan span; bool get isGroupEnd => false; diff --git a/lib/src/ast/sass/README.md b/lib/src/ast/sass/README.md new file mode 100644 index 000000000..415a4b6ae --- /dev/null +++ b/lib/src/ast/sass/README.md @@ -0,0 +1,34 @@ +# Sass Abstract Syntax Tree + +This directory contains the abstract syntax tree that represents a Sass source +file, regardless of which syntax it was written in (SCSS, the indented syntax, +or plain CSS). The AST is constructed recursively by [a parser] from the leaf +nodes in towards the root, which allows it to be fully immutable. + +[a parser]: ../../parse/README.md + +The Sass AST is broken up into three categories: + +1. The [statement AST], which represents statement-level constructs like + variable assignments, style rules, and at-rules. + + [statement AST]: statement + +2. The [expression AST], which represents SassScript expressions like function + calls, operations, and value literals. + + [expression AST]: exprssion + +3. Miscellaneous AST nodes that are used by both statements and expressions or + don't fit cleanly into either category that live directly in this directory. + +The Sass AST nodes are processed (usually from the root [`Stylesheet`]) by [the +evaluator], which runs the logic they encode and builds up a [CSS AST] that +represents the compiled stylesheet. They can also be transformed back into Sass +source using the `toString()` method. Since this is only ever used for debugging +and doesn't need configuration or full-featured indentation tracking, it doesn't +use a full visitor. + +[`Stylesheet`]: statement/stylesheet.dart +[the evaluator]: ../../visitor/async_evaluate.dart +[CSS AST]: ../css/README.md diff --git a/lib/src/ast/sass/argument_declaration.dart b/lib/src/ast/sass/argument_declaration.dart index 7ab73c3a5..ed1951cad 100644 --- a/lib/src/ast/sass/argument_declaration.dart +++ b/lib/src/ast/sass/argument_declaration.dart @@ -5,7 +5,6 @@ import 'package:source_span/source_span.dart'; import '../../exception.dart'; -import '../../logger.dart'; import '../../parse/scss.dart'; import '../../util/character.dart'; import '../../util/span.dart'; @@ -71,9 +70,8 @@ final class ArgumentDeclaration implements SassNode { /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory ArgumentDeclaration.parse(String contents, - {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parseArgumentDeclaration(); + factory ArgumentDeclaration.parse(String contents, {Object? url}) => + ScssParser(contents, url: url).parseArgumentDeclaration(); /// Throws a [SassScriptException] if [positional] and [names] aren't valid /// for this argument declaration. diff --git a/lib/src/ast/sass/at_root_query.dart b/lib/src/ast/sass/at_root_query.dart index 3bad9cf20..0ed522c39 100644 --- a/lib/src/ast/sass/at_root_query.dart +++ b/lib/src/ast/sass/at_root_query.dart @@ -7,7 +7,6 @@ import 'package:collection/collection.dart'; import '../../exception.dart'; import '../../interpolation_map.dart'; -import '../../logger.dart'; import '../../parse/at_root_query.dart'; import '../css.dart'; @@ -58,10 +57,10 @@ final class AtRootQuery { /// /// Throws a [SassFormatException] if parsing fails. factory AtRootQuery.parse(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) => - AtRootQueryParser(contents, url: url, logger: logger).parse(); + {Object? url, InterpolationMap? interpolationMap}) => + AtRootQueryParser(contents, url: url).parse(); - /// Returns whether [this] excludes [node]. + /// Returns whether `this` excludes [node]. /// /// @nodoc @internal @@ -76,6 +75,6 @@ final class AtRootQuery { }; } - /// Returns whether [this] excludes an at-rule with the given [name]. + /// Returns whether `this` excludes an at-rule with the given [name]. bool excludesName(String name) => (_all || names.contains(name)) != include; } diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart index 051e1c269..74a4888db 100644 --- a/lib/src/ast/sass/expression.dart +++ b/lib/src/ast/sass/expression.dart @@ -2,113 +2,50 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; import '../../exception.dart'; -import '../../logger.dart'; import '../../parse/scss.dart'; -import '../../util/nullable.dart'; -import '../../value.dart'; import '../../visitor/interface/expression.dart'; +import '../../visitor/is_calculation_safe.dart'; +import '../../visitor/source_interpolation.dart'; import '../sass.dart'; +// Note: 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); - /// Parses an expression from [contents]. - /// - /// If passed, [url] is the name of the file from which [contents] comes. - /// - /// Throws a [SassFormatException] if parsing fails. - factory Expression.parse(String contents, {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parseExpression(); -} + Expression(); -// Use an extension class rather than a method so we don't have to make -// [Expression] a concrete base class for something we'll get rid of anyway once -// we remove the global math functions that make this necessary. -extension ExpressionExtensions on Expression { /// Whether this expression can be used in a calculation context. + bool get isCalculationSafe => accept(const IsCalculationSafeVisitor()); + + /// If this expression is valid interpolated plain CSS, returns the equivalent + /// of parsing its source as an interpolated unknown value. + /// + /// Otherwise, returns null. /// /// @nodoc @internal - bool get isCalculationSafe => accept(_IsCalculationSafeVisitor()); -} - -// We could use [AstSearchVisitor] to implement this more tersely, but that -// would default to returning `true` if we added a new expression type and -// forgot to update this class. -class _IsCalculationSafeVisitor implements ExpressionVisitor { - const _IsCalculationSafeVisitor(); - - bool visitBinaryOperationExpression(BinaryOperationExpression node) => - (const { - BinaryOperator.times, - BinaryOperator.dividedBy, - BinaryOperator.plus, - BinaryOperator.minus - }).contains(node.operator) && - (node.left.accept(this) || node.right.accept(this)); - - bool visitBooleanExpression(BooleanExpression node) => false; - - bool visitColorExpression(ColorExpression node) => false; - - bool visitFunctionExpression(FunctionExpression node) => true; - - bool visitInterpolatedFunctionExpression( - InterpolatedFunctionExpression node) => - true; - - bool visitIfExpression(IfExpression node) => true; - - bool visitListExpression(ListExpression node) => - node.separator == ListSeparator.space && - !node.hasBrackets && - node.contents.length > 1 && - node.contents.every((expression) => expression.accept(this)); - - bool visitMapExpression(MapExpression node) => false; - - bool visitNullExpression(NullExpression node) => false; - - bool visitNumberExpression(NumberExpression node) => true; - - bool visitParenthesizedExpression(ParenthesizedExpression node) => - node.expression.accept(this); - - bool visitSelectorExpression(SelectorExpression node) => false; - - bool visitStringExpression(StringExpression node) { - if (node.hasQuotes) return false; - - // Exclude non-identifier constructs that are parsed as [StringExpression]s. - // We could just check if they parse as valid identifiers, but this is - // cheaper. - var text = node.text.initialPlain; - return - // !important - !text.startsWith("!") && - // ID-style identifiers - !text.startsWith("#") && - // Unicode ranges - text.codeUnitAtOrNull(1) != $plus && - // url() - text.codeUnitAtOrNull(3) != $lparen; + Interpolation? get sourceInterpolation { + var visitor = SourceInterpolationVisitor(); + accept(visitor); + return visitor.buffer?.interpolation(span); } - bool visitSupportsExpression(SupportsExpression node) => false; - - bool visitUnaryOperationExpression(UnaryOperationExpression node) => false; - - bool visitValueExpression(ValueExpression node) => false; - - bool visitVariableExpression(VariableExpression node) => true; + /// Parses an expression from [contents]. + /// + /// If passed, [url] is the name of the file from which [contents] comes. + /// + /// Throws a [SassFormatException] if parsing fails. + factory Expression.parse(String contents, {Object? url}) => + ScssParser(contents, url: url).parseExpression().$1; } diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart index dc750900a..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), @@ -153,13 +156,13 @@ enum BinaryOperator { /// The modulo operator, `%`. modulo('modulo', '%', 6); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The Sass syntax for [this]. + /// The Sass syntax for `this`. final String operator; - /// The precedence of [this]. + /// The precedence of `this`. /// /// An operator with higher precedence binds tighter. final int precedence; 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 398a2ff03..0f2fce7eb 100644 --- a/lib/src/ast/sass/expression/function.dart +++ b/lib/src/ast/sass/expression/function.dart @@ -17,12 +17,18 @@ 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; + /// The name of the function being invoked, with underscores converted to + /// hyphens. + /// + /// If this function is a plain CSS function, use [originalName] instead. + final String name; + /// The name of the function being invoked, with underscores left as-is. final String originalName; @@ -31,12 +37,6 @@ final class FunctionExpression final FileSpan span; - /// The name of the function being invoked, with underscores converted to - /// hyphens. - /// - /// If this function is a plain CSS function, use [originalName] instead. - String get name => originalName.replaceAll('_', '-'); - FileSpan get nameSpan { if (namespace == null) return span.initialIdentifier(); return span.withoutNamespace().initialIdentifier(); @@ -46,7 +46,8 @@ final class FunctionExpression namespace == null ? null : span.initialIdentifier(); FunctionExpression(this.originalName, this.arguments, this.span, - {this.namespace}); + {this.namespace}) + : name = originalName.replaceAll('_', '-'); T accept(ExpressionVisitor visitor) => visitor.visitFunctionExpression(this); 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 01416afa4..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; @@ -58,7 +58,7 @@ final class ListExpression implements Expression { return buffer.toString(); } - /// Returns whether [expression], contained in [this], needs parentheses when + /// Returns whether [expression], contained in `this`, needs parentheses when /// printed as Sass source. bool _elementNeedsParens(Expression expression) => switch (expression) { ListExpression( 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 2e7824345..f9f05d98e 100644 --- a/lib/src/ast/sass/expression/string.dart +++ b/lib/src/ast/sass/expression/string.dart @@ -16,14 +16,15 @@ 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. + /// Whether `this` has quotes. final bool hasQuotes; FileSpan get span => text.span; @@ -43,7 +44,7 @@ final class StringExpression implements Expression { /// Returns a string expression with no interpolation. StringExpression.plain(String text, FileSpan span, {bool quotes = false}) - : text = Interpolation([text], span), + : text = Interpolation.plain(text, span), hasQuotes = quotes; T accept(ExpressionVisitor visitor) => @@ -63,11 +64,12 @@ final class StringExpression implements Expression { quote ??= _bestQuote(text.contents.whereType()); var buffer = InterpolationBuffer(); buffer.writeCharCode(quote); - for (var value in text.contents) { + for (var i = 0; i < text.contents.length; i++) { + var value = text.contents[i]; assert(value is Expression || value is String); switch (value) { case Expression(): - buffer.add(value); + buffer.add(value, text.spanForElement(i)); case String(): _quoteInnerText(value, quote, buffer, static: static); } 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 d437fafc2..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; @@ -63,10 +63,10 @@ enum UnaryOperator { /// The boolean negation operator, `not`. not('not', 'not'); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The Sass syntax for [this]. + /// The Sass syntax for `this`. final String operator; const UnaryOperator(this.name, this.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 c07ffbc5a..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; @@ -35,5 +35,5 @@ final class VariableExpression implements Expression, SassReference { T accept(ExpressionVisitor visitor) => visitor.visitVariableExpression(this); - String toString() => namespace == null ? '\$$name' : '$namespace.\$$name'; + String toString() => span.text; } diff --git a/lib/src/ast/sass/interpolation.dart b/lib/src/ast/sass/interpolation.dart index 075b3344f..345819d21 100644 --- a/lib/src/ast/sass/interpolation.dart +++ b/lib/src/ast/sass/interpolation.dart @@ -5,7 +5,6 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; -import '../../interpolation_buffer.dart'; import 'expression.dart'; import 'node.dart'; @@ -19,6 +18,15 @@ final class Interpolation implements SassNode { /// [String]s. final List contents; + /// The source spans for each [Expression] in [contents]. + /// + /// Unlike [Expression.span], which just covers the expresssion itself, this + /// should go from `#{` through `}`. + /// + /// @nodoc + @internal + final List spans; + final FileSpan span; /// Returns whether this contains no interpolated expressions. @@ -37,42 +45,62 @@ final class Interpolation implements SassNode { String get initialPlain => switch (contents) { [String first, ...] => first, _ => '' }; - /// Creates a new [Interpolation] by concatenating a sequence of [String]s, - /// [Expression]s, or nested [Interpolation]s. - static Interpolation concat( - Iterable contents, - FileSpan span) { - var buffer = InterpolationBuffer(); - for (var element in contents) { - switch (element) { - case String(): - buffer.write(element); - case Expression(): - buffer.add(element); - case Interpolation(): - buffer.addInterpolation(element); - case _: - throw ArgumentError.value(contents, "contents", - "May only contains Strings, Expressions, or Interpolations."); - } - } + /// Returns the [FileSpan] covering the element of the interpolation at + /// [index]. + /// + /// Unlike `contents[index].span`, which only covers the text of the + /// expression itself, this typically covers the entire `#{}` that surrounds + /// the expression. However, this is not a strong guarantee—there are cases + /// where interpolations are constructed when the source uses Sass expressions + /// directly where this may return the same value as `contents[index].span`. + /// + /// For string elements, this is the span that covers the entire text of the + /// string, including the quote for text at the beginning or end of quoted + /// strings. Note that the quote is *never* included for expressions. + FileSpan spanForElement(int index) => switch (contents[index]) { + String() => span.file.span( + (index == 0 ? span.start : spans[index - 1]!.end).offset, + (index == spans.length ? span.end : spans[index + 1]!.start) + .offset), + _ => spans[index]! + }; - return buffer.interpolation(span); - } + Interpolation.plain(String text, this.span) + : contents = List.unmodifiable([text]), + spans = const [null]; + + /// Creates a new [Interpolation] with the given [contents]. + /// + /// The [spans] must include a [FileSpan] for each [Expression] in [contents]. + /// These spans should generally cover the entire `#{}` surrounding the + /// expression. + /// + /// The single [span] must cover the entire interpolation. + Interpolation(Iterable contents, + Iterable spans, this.span) + : contents = List.unmodifiable(contents), + spans = List.unmodifiable(spans) { + if (spans.length != contents.length) { + throw ArgumentError.value( + this.spans, "spans", "Must be the same length as contents."); + } - Interpolation(Iterable contents, this.span) - : contents = List.unmodifiable(contents) { for (var i = 0; i < this.contents.length; i++) { - if (this.contents[i] is! String && this.contents[i] is! Expression) { + var isString = this.contents[i] is String; + if (!isString && this.contents[i] is! Expression) { throw ArgumentError.value(this.contents, "contents", - "May only contains Strings or Expressions."); - } - - if (i != 0 && - this.contents[i - 1] is String && - this.contents[i] is String) { - throw ArgumentError.value( - this.contents, "contents", "May not contain adjacent Strings."); + "May only contain Strings or Expressions."); + } else if (isString) { + if (i != 0 && this.contents[i - 1] is String) { + throw ArgumentError.value( + this.contents, "contents", "May not contain adjacent Strings."); + } else if (i < spans.length && this.spans[i] != null) { + throw ArgumentError.value(this.spans, "spans", + "May not have a value for string elements (at index $i)."); + } + } else if (i >= spans.length || this.spans[i] == null) { + throw ArgumentError.value(this.spans, "spans", + "Must not have a value for expression elements (at index $i)."); } } } 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/callable_declaration.dart b/lib/src/ast/sass/statement/callable_declaration.dart index e39b9c035..3ce0ec9a0 100644 --- a/lib/src/ast/sass/statement/callable_declaration.dart +++ b/lib/src/ast/sass/statement/callable_declaration.dart @@ -18,6 +18,9 @@ abstract base class CallableDeclaration /// The name of this callable, with underscores converted to hyphens. final String name; + /// The callable's original name, without underscores converted to hyphens. + final String originalName; + /// The comment immediately preceding this declaration. final SilentComment? comment; @@ -26,8 +29,9 @@ abstract base class CallableDeclaration final FileSpan span; - CallableDeclaration( - this.name, this.arguments, Iterable children, this.span, + CallableDeclaration(this.originalName, this.arguments, + Iterable children, this.span, {this.comment}) - : super(List.unmodifiable(children)); + : name = originalName.replaceAll('_', '-'), + super(List.unmodifiable(children)); } 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/function_rule.dart b/lib/src/ast/sass/statement/function_rule.dart index 9242bf858..885bd4ef9 100644 --- a/lib/src/ast/sass/statement/function_rule.dart +++ b/lib/src/ast/sass/statement/function_rule.dart @@ -6,11 +6,8 @@ import 'package:source_span/source_span.dart'; import '../../../util/span.dart'; import '../../../visitor/interface/statement.dart'; -import '../argument_declaration.dart'; import '../declaration.dart'; -import '../statement.dart'; import 'callable_declaration.dart'; -import 'silent_comment.dart'; /// A function declaration. /// @@ -21,10 +18,8 @@ final class FunctionRule extends CallableDeclaration implements SassDeclaration { FileSpan get nameSpan => span.withoutInitialAtRule().initialIdentifier(); - FunctionRule(String name, ArgumentDeclaration arguments, - Iterable children, FileSpan span, - {SilentComment? comment}) - : super(name, arguments, children, span, comment: comment); + FunctionRule(super.name, super.arguments, super.children, super.span, + {super.comment}); T accept(StatementVisitor visitor) => visitor.visitFunctionRule(this); diff --git a/lib/src/ast/sass/statement/if_rule.dart b/lib/src/ast/sass/statement/if_rule.dart index 2a92ac28c..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 @@ -94,7 +94,7 @@ final class IfClause extends IfRuleClause { /// /// {@category AST} final class ElseClause extends IfRuleClause { - ElseClause(Iterable children) : super(children); + ElseClause(super.children); String toString() => "@else {${children.join(' ')}}"; } 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 d3c9ceba6..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; @@ -25,6 +25,10 @@ final class IncludeRule /// hyphens. final String name; + /// The original name of the mixin being invoked, without underscores + /// converted to hyphens. + final String originalName; + /// The arguments to pass to the mixin. final ArgumentInvocation arguments; @@ -55,8 +59,9 @@ final class IncludeRule return startSpan.initialIdentifier(); } - IncludeRule(this.name, this.arguments, this.span, - {this.namespace, this.content}); + IncludeRule(this.originalName, this.arguments, this.span, + {this.namespace, this.content}) + : name = originalName.replaceAll('_', '-'); T accept(StatementVisitor visitor) => visitor.visitIncludeRule(this); 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/mixin_rule.dart b/lib/src/ast/sass/statement/mixin_rule.dart index 624eff53e..650e64b65 100644 --- a/lib/src/ast/sass/statement/mixin_rule.dart +++ b/lib/src/ast/sass/statement/mixin_rule.dart @@ -7,12 +7,9 @@ import 'package:source_span/source_span.dart'; import '../../../util/span.dart'; import '../../../visitor/interface/statement.dart'; import '../../../visitor/statement_search.dart'; -import '../argument_declaration.dart'; import '../declaration.dart'; -import '../statement.dart'; import 'callable_declaration.dart'; import 'content_rule.dart'; -import 'silent_comment.dart'; /// A mixin declaration. /// @@ -31,10 +28,8 @@ final class MixinRule extends CallableDeclaration implements SassDeclaration { return startSpan.initialIdentifier(); } - MixinRule(String name, ArgumentDeclaration arguments, - Iterable children, FileSpan span, - {SilentComment? comment}) - : super(name, arguments, children, span, comment: comment); + MixinRule(super.name, super.arguments, super.children, super.span, + {super.comment}); T accept(StatementVisitor visitor) => visitor.visitMixinRule(this); diff --git a/lib/src/ast/sass/statement/parent.dart b/lib/src/ast/sass/statement/parent.dart index 21293019d..4067a2a18 100644 --- a/lib/src/ast/sass/statement/parent.dart +++ b/lib/src/ast/sass/statement/parent.dart @@ -18,7 +18,7 @@ import 'variable_declaration.dart'; /// /// {@category AST} abstract base class ParentStatement?> - implements Statement { + extends Statement { /// The child statements of this statement. final T children; diff --git a/lib/src/ast/sass/statement/return_rule.dart b/lib/src/ast/sass/statement/return_rule.dart index dc1efc65c..53a69af5a 100644 --- a/lib/src/ast/sass/statement/return_rule.dart +++ b/lib/src/ast/sass/statement/return_rule.dart @@ -13,7 +13,7 @@ import '../statement.dart'; /// This exits from the current function body with a return value. /// /// {@category AST} -final class ReturnRule implements Statement { +final class ReturnRule extends Statement { /// The value to return from this function. final Expression expression; diff --git a/lib/src/ast/sass/statement/silent_comment.dart b/lib/src/ast/sass/statement/silent_comment.dart index 384cd09fb..0d799e139 100644 --- a/lib/src/ast/sass/statement/silent_comment.dart +++ b/lib/src/ast/sass/statement/silent_comment.dart @@ -11,7 +11,7 @@ import '../statement.dart'; /// A silent Sass-style comment. /// /// {@category AST} -final class SilentComment implements Statement { +final class SilentComment extends Statement { /// The text of this comment, including comment characters. final String text; diff --git a/lib/src/ast/sass/statement/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart index b90cddc52..a030a71aa 100644 --- a/lib/src/ast/sass/statement/stylesheet.dart +++ b/lib/src/ast/sass/statement/stylesheet.dart @@ -7,8 +7,8 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../deprecation.dart'; import '../../../exception.dart'; -import '../../../logger.dart'; import '../../../parse/css.dart'; import '../../../parse/sass.dart'; import '../../../parse/scss.dart'; @@ -46,16 +46,33 @@ final class Stylesheet extends ParentStatement> { List get forwards => UnmodifiableListView(_forwards); final _forwards = []; + /// List of warnings discovered while parsing this stylesheet, to be emitted + /// during evaluation once we have a proper logger to use. + /// + /// @nodoc + @internal + final List parseTimeWarnings; + + /// The set of (normalized) global variable names defined by this stylesheet + /// to the spans where they're defined. + @internal + final Map globalVariables; + Stylesheet(Iterable children, FileSpan span) - : this.internal(children, span); + : this.internal(children, span, []); /// A separate internal constructor that allows [plainCss] to be set. /// /// @nodoc @internal Stylesheet.internal(Iterable children, this.span, - {this.plainCss = false}) - : super(List.unmodifiable(children)) { + List parseTimeWarnings, + {this.plainCss = false, Map? globalVariables}) + : parseTimeWarnings = UnmodifiableListView(parseTimeWarnings), + globalVariables = globalVariables == null + ? const {} + : Map.unmodifiable(globalVariables), + super(List.unmodifiable(children)) { loop: for (var child in this.children) { switch (child) { @@ -81,18 +98,15 @@ final class Stylesheet extends ParentStatement> { /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parse(String contents, Syntax syntax, - {Object? url, Logger? logger}) { + factory Stylesheet.parse(String contents, Syntax syntax, {Object? url}) { try { switch (syntax) { case Syntax.sass: - return Stylesheet.parseSass(contents, url: url, logger: logger); + return Stylesheet.parseSass(contents, url: url); case Syntax.scss: - return Stylesheet.parseScss(contents, url: url, logger: logger); + return Stylesheet.parseScss(contents, url: url); case Syntax.css: - return Stylesheet.parseCss(contents, url: url, logger: logger); - default: - throw ArgumentError("Unknown syntax $syntax."); + return Stylesheet.parseCss(contents, url: url); } } on SassException catch (error, stackTrace) { var url = error.span.sourceUrl; @@ -108,28 +122,33 @@ final class Stylesheet extends ParentStatement> { /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseSass(String contents, - {Object? url, Logger? logger}) => - SassParser(contents, url: url, logger: logger).parse(); + factory Stylesheet.parseSass(String contents, {Object? url}) => + SassParser(contents, url: url).parse(); /// Parses an SCSS stylesheet from [contents]. /// /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseScss(String contents, - {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parse(); + factory Stylesheet.parseScss(String contents, {Object? url}) => + ScssParser(contents, url: url).parse(); /// Parses a plain CSS stylesheet from [contents]. /// /// If passed, [url] is the name of the file from which [contents] comes. /// /// Throws a [SassFormatException] if parsing fails. - factory Stylesheet.parseCss(String contents, {Object? url, Logger? logger}) => - CssParser(contents, url: url, logger: logger).parse(); + factory Stylesheet.parseCss(String contents, {Object? url}) => + CssParser(contents, url: url).parse(); T accept(StatementVisitor visitor) => visitor.visitStylesheet(this); String toString() => children.join(" "); } + +/// Record type for a warning discovered while parsing a stylesheet. +typedef ParseTimeWarning = ({ + Deprecation? deprecation, + FileSpan span, + String message +}); diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart index 244613abc..70005f8fe 100644 --- a/lib/src/ast/sass/statement/use_rule.dart +++ b/lib/src/ast/sass/statement/use_rule.dart @@ -6,7 +6,6 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../exception.dart'; -import '../../../logger.dart'; import '../../../parse/scss.dart'; import '../../../util/span.dart'; import '../../../visitor/interface/statement.dart'; @@ -18,7 +17,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. @@ -56,8 +55,8 @@ final class UseRule implements Statement, SassDependency { /// /// @nodoc @internal - factory UseRule.parse(String contents, {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parseUseRule(); + factory UseRule.parse(String contents, {Object? url}) => + ScssParser(contents, url: url).parseUseRule().$1; T accept(StatementVisitor visitor) => visitor.visitUseRule(this); diff --git a/lib/src/ast/sass/statement/variable_declaration.dart b/lib/src/ast/sass/statement/variable_declaration.dart index 235a41648..56f75b12c 100644 --- a/lib/src/ast/sass/statement/variable_declaration.dart +++ b/lib/src/ast/sass/statement/variable_declaration.dart @@ -6,7 +6,6 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../exception.dart'; -import '../../../logger.dart'; import '../../../parse/scss.dart'; import '../../../utils.dart'; import '../../../util/span.dart'; @@ -21,7 +20,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; @@ -81,9 +80,8 @@ final class VariableDeclaration implements Statement, SassDeclaration { /// /// @nodoc @internal - factory VariableDeclaration.parse(String contents, - {Object? url, Logger? logger}) => - ScssParser(contents, url: url, logger: logger).parseVariableDeclaration(); + factory VariableDeclaration.parse(String contents, {Object? url}) => + ScssParser(contents, url: url).parseVariableDeclaration().$1; T accept(StatementVisitor visitor) => visitor.visitVariableDeclaration(this); 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/ast/sass/supports_condition.dart b/lib/src/ast/sass/supports_condition.dart index 4b38d304e..e078c955e 100644 --- a/lib/src/ast/sass/supports_condition.dart +++ b/lib/src/ast/sass/supports_condition.dart @@ -2,9 +2,26 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; + +import 'interpolation.dart'; import 'node.dart'; /// An abstract class for defining the condition a `@supports` rule selects. /// /// {@category AST} -abstract interface class SupportsCondition implements SassNode {} +abstract interface class SupportsCondition implements SassNode { + /// Converts this condition into an interpolation that produces the same + /// value. + /// + /// @nodoc + @internal + Interpolation toInterpolation(); + + /// Returns a copy of this condition with [span] as its span. + /// + /// @nodoc + @internal + SupportsCondition withSpan(FileSpan span); +} diff --git a/lib/src/ast/sass/supports_condition/anything.dart b/lib/src/ast/sass/supports_condition/anything.dart index 91d90024a..dae51e424 100644 --- a/lib/src/ast/sass/supports_condition/anything.dart +++ b/lib/src/ast/sass/supports_condition/anything.dart @@ -3,7 +3,10 @@ // https://opensource.org/licenses/MIT. import 'package:source_span/source_span.dart'; +import 'package:meta/meta.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../interpolation.dart'; import '../supports_condition.dart'; @@ -19,5 +22,17 @@ final class SupportsAnything implements SupportsCondition { SupportsAnything(this.contents, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(contents.span).text) + ..addInterpolation(contents) + ..write(span.after(contents.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsAnything withSpan(FileSpan span) => SupportsAnything(contents, span); + String toString() => "($contents)"; } diff --git a/lib/src/ast/sass/supports_condition/declaration.dart b/lib/src/ast/sass/supports_condition/declaration.dart index 322731018..f81d37f2c 100644 --- a/lib/src/ast/sass/supports_condition/declaration.dart +++ b/lib/src/ast/sass/supports_condition/declaration.dart @@ -5,8 +5,11 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../expression.dart'; import '../expression/string.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; /// A condition that selects for browsers where a given declaration is @@ -40,5 +43,32 @@ final class SupportsDeclaration implements SupportsCondition { SupportsDeclaration(this.name, this.value, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() { + var buffer = InterpolationBuffer(); + buffer.write(span.before(name.span).text); + if (name case StringExpression(hasQuotes: false, :var text)) { + buffer.addInterpolation(text); + } else { + buffer.add(name, name.span); + } + + buffer.write(name.span.between(value.span).text); + if (value.sourceInterpolation case var interpolation?) { + buffer.addInterpolation(interpolation); + } else { + buffer.add(value, value.span); + } + + buffer.write(span.after(value.span).text); + return buffer.interpolation(span); + } + + /// @nodoc + @internal + SupportsDeclaration withSpan(FileSpan span) => + SupportsDeclaration(name, value, span); + String toString() => "($name: $value)"; } diff --git a/lib/src/ast/sass/supports_condition/function.dart b/lib/src/ast/sass/supports_condition/function.dart index dd9ac5b29..f31a4d054 100644 --- a/lib/src/ast/sass/supports_condition/function.dart +++ b/lib/src/ast/sass/supports_condition/function.dart @@ -2,8 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; import '../interpolation.dart'; import '../supports_condition.dart'; @@ -21,5 +24,19 @@ final class SupportsFunction implements SupportsCondition { SupportsFunction(this.name, this.arguments, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..addInterpolation(name) + ..write(name.span.between(arguments.span).text) + ..addInterpolation(arguments) + ..write(span.after(arguments.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsFunction withSpan(FileSpan span) => + SupportsFunction(name, arguments, span); + String toString() => "$name($arguments)"; } diff --git a/lib/src/ast/sass/supports_condition/interpolation.dart b/lib/src/ast/sass/supports_condition/interpolation.dart index 839fccf9f..4814826ec 100644 --- a/lib/src/ast/sass/supports_condition/interpolation.dart +++ b/lib/src/ast/sass/supports_condition/interpolation.dart @@ -2,9 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../expression.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; /// An interpolated condition. @@ -18,5 +20,14 @@ final class SupportsInterpolation implements SupportsCondition { SupportsInterpolation(this.expression, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => Interpolation([expression], [span], span); + + /// @nodoc + @internal + SupportsInterpolation withSpan(FileSpan span) => + SupportsInterpolation(expression, span); + String toString() => "#{$expression}"; } diff --git a/lib/src/ast/sass/supports_condition/negation.dart b/lib/src/ast/sass/supports_condition/negation.dart index 23cd7193e..7658c868e 100644 --- a/lib/src/ast/sass/supports_condition/negation.dart +++ b/lib/src/ast/sass/supports_condition/negation.dart @@ -2,8 +2,12 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; import 'operation.dart'; @@ -18,6 +22,18 @@ final class SupportsNegation implements SupportsCondition { SupportsNegation(this.condition, this.span); + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(condition.span).text) + ..addInterpolation(condition.toInterpolation()) + ..write(span.after(condition.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsNegation withSpan(FileSpan span) => SupportsNegation(condition, span); + String toString() { if (condition is SupportsNegation || condition is SupportsOperation) { return "not ($condition)"; diff --git a/lib/src/ast/sass/supports_condition/operation.dart b/lib/src/ast/sass/supports_condition/operation.dart index f072fc2e3..71fed25c9 100644 --- a/lib/src/ast/sass/supports_condition/operation.dart +++ b/lib/src/ast/sass/supports_condition/operation.dart @@ -2,8 +2,12 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; +import '../../../interpolation_buffer.dart'; +import '../../../util/span.dart'; +import '../interpolation.dart'; import '../supports_condition.dart'; import 'negation.dart'; @@ -32,6 +36,21 @@ final class SupportsOperation implements SupportsCondition { } } + /// @nodoc + @internal + Interpolation toInterpolation() => (InterpolationBuffer() + ..write(span.before(left.span).text) + ..addInterpolation(left.toInterpolation()) + ..write(left.span.between(right.span).text) + ..addInterpolation(right.toInterpolation()) + ..write(span.after(right.span).text)) + .interpolation(span); + + /// @nodoc + @internal + SupportsOperation withSpan(FileSpan span) => + SupportsOperation(left, right, operator, span); + String toString() => "${_parenthesize(left)} $operator ${_parenthesize(right)}"; diff --git a/lib/src/ast/selector.dart b/lib/src/ast/selector.dart index 953ccf7aa..fcabcdc25 100644 --- a/lib/src/ast/selector.dart +++ b/lib/src/ast/selector.dart @@ -83,7 +83,7 @@ abstract base class Selector implements AstNode { Selector(this.span); - /// Prints a warning if [this] is a bogus selector. + /// Prints a warning if `this` is a bogus selector. /// /// This may only be called from within a custom Sass function. This will /// throw a [SassException] in Dart Sass 2.0.0. diff --git a/lib/src/ast/selector/README.md b/lib/src/ast/selector/README.md new file mode 100644 index 000000000..721644977 --- /dev/null +++ b/lib/src/ast/selector/README.md @@ -0,0 +1,24 @@ +# Selector Abstract Syntax Tree + +This directory contains the abstract syntax tree that represents a parsed CSS +selector. This AST is constructed recursively by [the selector parser]. It's +fully immutable. + +[the selector parser]: ../../parse/selector.dart + +Unlike the [Sass AST], which is parsed from a raw source string before being +evaluated, the selector AST is parsed _during evaluation_. This is necessary to +ensure that there's a chance to resolve interpolation before fully parsing the +selectors in question. + +[Sass AST]: ../sass/README.md + +Although this AST doesn't include any SassScript, it _does_ include a few +Sass-specific constructs: the [parent selector] `&` and [placeholder selectors]. +Parent selectors are resolved by [the evaluator] before it hands the AST off to +[the serializer], while placeholders are omitted in the serializer itself. + +[parent selector]: parent.dart +[placeholder selectors]: placeholder.dart +[the evaluator]: ../../visitor/async_evaluate.dart +[the serializer]: ../../visitor/serialize.dart diff --git a/lib/src/ast/selector/complex.dart b/lib/src/ast/selector/complex.dart index 3d97729ca..bc0e057fa 100644 --- a/lib/src/ast/selector/complex.dart +++ b/lib/src/ast/selector/complex.dart @@ -6,7 +6,6 @@ import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../extend/functions.dart'; -import '../../logger.dart'; import '../../parse/selector.dart'; import '../../utils.dart'; import '../../visitor/interface/selector.dart'; @@ -70,11 +69,10 @@ final class ComplexSelector extends Selector { } ComplexSelector(Iterable> leadingCombinators, - Iterable components, FileSpan span, + Iterable components, super.span, {this.lineBreak = false}) : leadingCombinators = List.unmodifiable(leadingCombinators), - components = List.unmodifiable(components), - super(span) { + components = List.unmodifiable(components) { if (this.leadingCombinators.isEmpty && this.components.isEmpty) { throw ArgumentError( "leadingCombinators and components may not both be empty."); @@ -89,9 +87,8 @@ final class ComplexSelector extends Selector { /// /// Throws a [SassFormatException] if parsing fails. factory ComplexSelector.parse(String contents, - {Object? url, Logger? logger, bool allowParent = true}) => - SelectorParser(contents, - url: url, logger: logger, allowParent: allowParent) + {Object? url, bool allowParent = true}) => + SelectorParser(contents, url: url, allowParent: allowParent) .parseComplexSelector(); T accept(SelectorVisitor visitor) => visitor.visitComplexSelector(this); diff --git a/lib/src/ast/selector/compound.dart b/lib/src/ast/selector/compound.dart index c36662cb0..94cadc6df 100644 --- a/lib/src/ast/selector/compound.dart +++ b/lib/src/ast/selector/compound.dart @@ -3,10 +3,8 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; import '../../extend/functions.dart'; -import '../../logger.dart'; import '../../parse/selector.dart'; import '../../utils.dart'; import '../../visitor/interface/selector.dart'; @@ -43,9 +41,20 @@ final class CompoundSelector extends Selector { SimpleSelector? get singleSimple => components.length == 1 ? components.first : null; - CompoundSelector(Iterable components, FileSpan span) - : components = List.unmodifiable(components), - super(span) { + /// Whether any simple selector in this contains a selector that requires + /// complex non-local reasoning to determine whether it's a super- or + /// sub-selector. + /// + /// This includes both pseudo-elements and pseudo-selectors that take + /// selectors as arguments. + /// + /// #nodoc + @internal + late final bool hasComplicatedSuperselectorSemantics = components + .any((component) => component.hasComplicatedSuperselectorSemantics); + + CompoundSelector(Iterable components, super.span) + : components = List.unmodifiable(components) { if (this.components.isEmpty) { throw ArgumentError("components may not be empty."); } @@ -59,9 +68,8 @@ final class CompoundSelector extends Selector { /// /// Throws a [SassFormatException] if parsing fails. factory CompoundSelector.parse(String contents, - {Object? url, Logger? logger, bool allowParent = true}) => - SelectorParser(contents, - url: url, logger: logger, allowParent: allowParent) + {Object? url, bool allowParent = true}) => + SelectorParser(contents, url: url, allowParent: allowParent) .parseCompoundSelector(); T accept(SelectorVisitor visitor) => diff --git a/lib/src/ast/selector/list.dart b/lib/src/ast/selector/list.dart index d432bbfaa..96f3c4f27 100644 --- a/lib/src/ast/selector/list.dart +++ b/lib/src/ast/selector/list.dart @@ -3,12 +3,10 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; import '../../exception.dart'; import '../../extend/functions.dart'; import '../../interpolation_map.dart'; -import '../../logger.dart'; import '../../parse/selector.dart'; import '../../utils.dart'; import '../../util/iterable.dart'; @@ -49,9 +47,8 @@ final class SelectorList extends Selector { }), ListSeparator.comma); } - SelectorList(Iterable components, FileSpan span) - : components = List.unmodifiable(components), - super(span) { + SelectorList(Iterable components, super.span) + : components = List.unmodifiable(components) { if (this.components.isEmpty) { throw ArgumentError("components may not be empty."); } @@ -59,9 +56,10 @@ final class SelectorList extends Selector { /// Parses a selector list from [contents]. /// - /// If passed, [url] is the name of the file from which [contents] comes. - /// [allowParent] and [allowPlaceholder] control whether [ParentSelector]s or - /// [PlaceholderSelector]s are allowed in this selector, respectively. + /// If passed, [url] is the name of the file from which [contents] comes. If + /// [allowParent] is false, this doesn't allow [ParentSelector]s. If + /// [plainCss] is true, this parses the selector as plain CSS rather than + /// unresolved Sass. /// /// If passed, [interpolationMap] maps the text of [contents] back to the /// original location of the selector in the source file. @@ -69,16 +67,14 @@ final class SelectorList extends Selector { /// Throws a [SassFormatException] if parsing fails. factory SelectorList.parse(String contents, {Object? url, - Logger? logger, InterpolationMap? interpolationMap, bool allowParent = true, - bool allowPlaceholder = true}) => + bool plainCss = false}) => SelectorParser(contents, url: url, - logger: logger, interpolationMap: interpolationMap, allowParent: allowParent, - allowPlaceholder: allowPlaceholder) + plainCss: plainCss) .parse(); T accept(SelectorVisitor visitor) => visitor.visitSelectorList(this); @@ -97,17 +93,24 @@ final class SelectorList extends Selector { return contents.isEmpty ? null : SelectorList(contents, span); } - /// Returns a new list with all [ParentSelector]s replaced with [parent]. + /// Returns a new selector list that represents `this` nested within [parent]. /// - /// If [implicitParent] is true, this treats [ComplexSelector]s that don't - /// contain an explicit [ParentSelector] as though they began with one. + /// By default, this replaces [ParentSelector]s in `this` with [parent]. If + /// [preserveParentSelectors] is true, this instead preserves those selectors + /// as parent selectors. + /// + /// If [implicitParent] is true, this prepends [parent] to any + /// [ComplexSelector]s in this that don't contain explicit [ParentSelector]s, + /// or to _all_ [ComplexSelector]s if [preserveParentSelectors] is true. /// /// The given [parent] may be `null`, indicating that this has no parents. If /// so, this list is returned as-is if it doesn't contain any explicit - /// [ParentSelector]s. If it does, this throws a [SassScriptException]. - SelectorList resolveParentSelectors(SelectorList? parent, - {bool implicitParent = true}) { + /// [ParentSelector]s or if [preserveParentSelectors] is true. Otherwise, this + /// throws a [SassScriptException]. + SelectorList nestWithin(SelectorList? parent, + {bool implicitParent = true, bool preserveParentSelectors = false}) { if (parent == null) { + if (preserveParentSelectors) return this; var parentSelector = accept(const _ParentSelectorVisitor()); if (parentSelector == null) return this; throw SassException( @@ -116,7 +119,7 @@ final class SelectorList extends Selector { } return SelectorList(flattenVertically(components.map((complex) { - if (!_containsParentSelector(complex)) { + if (preserveParentSelectors || !_containsParentSelector(complex)) { if (!implicitParent) return [complex]; return parent.components.map((parentComplex) => parentComplex.concatenate(complex, complex.span)); @@ -124,7 +127,7 @@ final class SelectorList extends Selector { var newComplexes = []; for (var component in complex.components) { - var resolved = _resolveParentSelectorsCompound(component, parent); + var resolved = _nestWithinCompound(component, parent); if (resolved == null) { if (newComplexes.isEmpty) { newComplexes.add(ComplexSelector( @@ -167,7 +170,7 @@ final class SelectorList extends Selector { /// [ParentSelector]s replaced with [parent]. /// /// Returns `null` if [component] doesn't contain any [ParentSelector]s. - Iterable? _resolveParentSelectorsCompound( + Iterable? _nestWithinCompound( ComplexSelectorComponent component, SelectorList parent) { var simples = component.selector.components; var containsSelectorPseudo = simples.any((simple) { @@ -183,8 +186,8 @@ final class SelectorList extends Selector { ? simples.map((simple) => switch (simple) { PseudoSelector(:var selector?) when _containsParentSelector(selector) => - simple.withSelector(selector.resolveParentSelectors(parent, - implicitParent: false)), + simple.withSelector( + selector.nestWithin(parent, implicitParent: false)), _ => simple }) : simples; @@ -263,6 +266,8 @@ final class SelectorList extends Selector { /// Returns a copy of `this` with [combinators] added to the end of each /// complex selector in [components]. + /// + /// @nodoc @internal SelectorList withAdditionalCombinators( List> combinators) => diff --git a/lib/src/ast/selector/parent.dart b/lib/src/ast/selector/parent.dart index 18e898652..06f013f05 100644 --- a/lib/src/ast/selector/parent.dart +++ b/lib/src/ast/selector/parent.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; import '../../visitor/interface/selector.dart'; import '../selector.dart'; @@ -22,7 +21,7 @@ final class ParentSelector extends SimpleSelector { /// indicating that the parent selector will not be modified. final String? suffix; - ParentSelector(FileSpan span, {this.suffix}) : super(span); + ParentSelector(super.span, {this.suffix}); T accept(SelectorVisitor visitor) => visitor.visitParentSelector(this); diff --git a/lib/src/ast/selector/pseudo.dart b/lib/src/ast/selector/pseudo.dart index 44a263d15..930d49e08 100644 --- a/lib/src/ast/selector/pseudo.dart +++ b/lib/src/ast/selector/pseudo.dart @@ -67,6 +67,10 @@ final class PseudoSelector extends SimpleSelector { bool get isHostContext => isClass && name == 'host-context' && selector != null; + @internal + bool get hasComplicatedSuperselectorSemantics => + isElement || selector != null; + /// The non-selector argument passed to this selector. /// /// This is `null` if there's no argument. If [argument] and [selector] are @@ -170,7 +174,7 @@ final class PseudoSelector extends SimpleSelector { for (var simple in compound) { if (simple case PseudoSelector(isElement: true)) { // A given compound selector may only contain one pseudo element. If - // [compound] has a different one than [this], unification fails. + // [compound] has a different one than `this`, unification fails. if (isElement) return null; // Otherwise, this is a pseudo selector and should come before pseudo diff --git a/lib/src/ast/selector/simple.dart b/lib/src/ast/selector/simple.dart index 0526eed72..8440b6642 100644 --- a/lib/src/ast/selector/simple.dart +++ b/lib/src/ast/selector/simple.dart @@ -3,10 +3,8 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; import '../../exception.dart'; -import '../../logger.dart'; import '../../parse/selector.dart'; import '../selector.dart'; @@ -35,7 +33,17 @@ abstract base class SimpleSelector extends Selector { /// sequence will contain 1000 simple selectors. int get specificity => 1000; - SimpleSelector(FileSpan span) : super(span); + /// Whether this requires complex non-local reasoning to determine whether + /// it's a super- or sub-selector. + /// + /// This includes both pseudo-elements and pseudo-selectors that take + /// selectors as arguments. + /// + /// #nodoc + @internal + bool get hasComplicatedSuperselectorSemantics => false; + + SimpleSelector(super.span); /// Parses a simple selector from [contents]. /// @@ -45,12 +53,11 @@ abstract base class SimpleSelector extends Selector { /// /// Throws a [SassFormatException] if parsing fails. factory SimpleSelector.parse(String contents, - {Object? url, Logger? logger, bool allowParent = true}) => - SelectorParser(contents, - url: url, logger: logger, allowParent: allowParent) + {Object? url, bool allowParent = true}) => + SelectorParser(contents, url: url, allowParent: allowParent) .parseSimpleSelector(); - /// Returns a new [SimpleSelector] based on [this], as though it had been + /// Returns a new [SimpleSelector] based on `this`, as though it had been /// written with [suffix] at the end. /// /// Assumes [suffix] is a valid identifier suffix. If this wouldn't produce a diff --git a/lib/src/ast/selector/type.dart b/lib/src/ast/selector/type.dart index d65f94a0c..a5ada4b8f 100644 --- a/lib/src/ast/selector/type.dart +++ b/lib/src/ast/selector/type.dart @@ -32,7 +32,7 @@ final class TypeSelector extends SimpleSelector { /// @nodoc @internal List? unify(List compound) { - if (compound.first case UniversalSelector() || TypeSelector()) { + if (compound.firstOrNull case UniversalSelector() || TypeSelector()) { var unified = unifyUniversalAndElement(this, compound.first); if (unified == null) return null; return [unified, ...compound.skip(1)]; diff --git a/lib/src/ast/selector/universal.dart b/lib/src/ast/selector/universal.dart index d714dcb6a..937377164 100644 --- a/lib/src/ast/selector/universal.dart +++ b/lib/src/ast/selector/universal.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:source_span/source_span.dart'; import '../../extend/functions.dart'; import '../../visitor/interface/selector.dart'; @@ -23,7 +22,7 @@ final class UniversalSelector extends SimpleSelector { int get specificity => 0; - UniversalSelector(FileSpan span, {this.namespace}) : super(span); + UniversalSelector(super.span, {this.namespace}); T accept(SelectorVisitor visitor) => visitor.visitUniversalSelector(this); diff --git a/lib/src/async_compile.dart b/lib/src/async_compile.dart index 0d95a5dd7..dcbd7337e 100644 --- a/lib/src/async_compile.dart +++ b/lib/src/async_compile.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:cli_pkg/js.dart'; import 'package:path/path.dart' as p; import 'ast/sass.dart'; @@ -16,7 +17,7 @@ import 'importer/legacy_node.dart'; import 'importer/no_op.dart'; import 'io.dart'; import 'logger.dart'; -import 'logger/deprecation_handling.dart'; +import 'logger/deprecation_processing.dart'; import 'syntax.dart'; import 'utils.dart'; import 'visitor/async_evaluate.dart'; @@ -25,7 +26,8 @@ import 'visitor/serialize.dart'; /// Like [compileAsync] in `lib/sass.dart`, but provides more options to support /// the node-sass compatible API and the executable. /// -/// At most one of `importCache` and `nodeImporter` may be provided at once. +/// If both `importCache` and `nodeImporter` are provided, the importers in +/// `importCache` will be evaluated before `nodeImporter`. Future compileAsync(String path, {Syntax? syntax, Logger? logger, @@ -40,27 +42,30 @@ Future compileAsync(String path, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) async { - DeprecationHandlingLogger deprecationLogger = logger = - DeprecationHandlingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), + silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); // If the syntax is different than the importer would default to, we have to // parse the file manually and we can't store it in the cache. Stylesheet? stylesheet; if (nodeImporter == null && (syntax == null || syntax == Syntax.forPath(path))) { - importCache ??= AsyncImportCache.none(logger: logger); + importCache ??= AsyncImportCache.none(); stylesheet = (await importCache.importCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(path)), + FilesystemImporter.cwd, p.toUri(canonicalize(path)), originalUrl: p.toUri(path)))!; } else { stylesheet = Stylesheet.parse( readFile(path), syntax ?? Syntax.forPath(path), - url: p.toUri(path), logger: logger); + url: p.toUri(path)); } var result = await _compileStylesheet( @@ -68,7 +73,7 @@ Future compileAsync(String path, logger, importCache, nodeImporter, - FilesystemImporter('.'), + FilesystemImporter.cwd, functions, style, useSpaces, @@ -104,23 +109,25 @@ Future compileStringAsync(String source, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) async { - DeprecationHandlingLogger deprecationLogger = logger = - DeprecationHandlingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), + silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); - var stylesheet = - Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); + var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url); var result = await _compileStylesheet( stylesheet, logger, importCache, nodeImporter, - importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter.cwd), functions, style, useSpaces, @@ -151,6 +158,13 @@ Future _compileStylesheet( bool quietDeps, bool sourceMap, bool charset) async { + if (nodeImporter != null) { + logger?.warnForDeprecation( + Deprecation.legacyJsApi, + 'The legacy JS API is deprecated and will be removed in ' + 'Dart Sass 2.0.0.\n\n' + 'More info: https://sass-lang.com/d/legacy-js-api'); + } var evaluateResult = await evaluateAsync(stylesheet, importCache: importCache, nodeImporter: nodeImporter, @@ -165,6 +179,7 @@ Future _compileStylesheet( useSpaces: useSpaces, indentWidth: indentWidth, lineFeed: lineFeed, + logger: logger, sourceMap: sourceMap, charset: charset); diff --git a/lib/src/async_environment.dart b/lib/src/async_environment.dart index 96cbecc18..5313d8c45 100644 --- a/lib/src/async_environment.dart +++ b/lib/src/async_environment.dart @@ -790,7 +790,7 @@ final class AsyncEnvironment { return Configuration.implicit(configuration); } - /// Returns a module that represents the top-level members defined in [this], + /// Returns a module that represents the top-level members defined in `this`, /// that contains [css] and [preModuleComments] as its CSS, which can be /// extended using [extensionStore]. Module toModule( @@ -802,7 +802,7 @@ final class AsyncEnvironment { forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } - /// Returns a module with the same members and upstream modules as [this], but + /// Returns a module with the same members and upstream modules as `this`, but /// an empty stylesheet and extension store. /// /// This is used when resolving imports, since they need to inject forwarded diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index 33fc26cce..0bc3b4da7 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -2,18 +2,19 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:cli_pkg/js.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; import 'package:path/path.dart' as p; import 'ast/sass.dart'; -import 'deprecation.dart'; import 'importer.dart'; +import 'importer/canonicalize_context.dart'; import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; -import 'logger.dart'; +import 'util/map.dart'; import 'util/nullable.dart'; import 'utils.dart'; @@ -34,38 +35,33 @@ final class AsyncImportCache { /// The importers to use when loading new Sass files. final List _importers; - /// The logger to use to emit warnings when parsing stylesheets. - final Logger _logger; - /// The canonicalized URLs for each non-canonical URL. /// /// The `forImport` in each key is true when this canonicalization is for an /// `@import` rule. Otherwise, it's for a `@use` or `@forward` rule. /// - /// This cache isn't used for relative imports, because they depend on the - /// specific base importer. That's stored separately in - /// [_relativeCanonicalizeCache]. + /// This cache covers loads that go through the entire chain of [_importers], + /// but it doesn't cover individual loads or loads in which any importer + /// accesses `containingUrl`. See also [_perImporterCanonicalizeCache]. final _canonicalizeCache = <(Uri, {bool forImport}), AsyncCanonicalizeResult?>{}; - /// The canonicalized URLs for each non-canonical URL that's resolved using a - /// relative importer. - /// - /// The map's keys have four parts: + /// Like [_canonicalizeCache] but also includes the specific importer in the + /// key. /// - /// 1. The URL passed to [canonicalize] (the same as in [_canonicalizeCache]). - /// 2. Whether the canonicalization is for an `@import` rule. - /// 3. The `baseImporter` passed to [canonicalize]. - /// 4. The `baseUrl` passed to [canonicalize]. + /// This is used to cache both relative imports from the base importer and + /// individual importer results in the case where some other component of the + /// importer chain isn't cacheable. + final _perImporterCanonicalizeCache = + <(AsyncImporter, Uri, {bool forImport}), AsyncCanonicalizeResult?>{}; + + /// A map from the keys in [_perImporterCanonicalizeCache] that are generated + /// for relative URL loads against the base importer to the original relative + /// URLs what were loaded. /// - /// The map's values are the same as the return value of [canonicalize]. - final _relativeCanonicalizeCache = <( - Uri, { - bool forImport, - AsyncImporter baseImporter, - Uri? baseUrl - }), - AsyncCanonicalizeResult?>{}; + /// This is used to invalidate the cache when files are changed. + final _nonCanonicalRelativeUrls = + <(AsyncImporter, Uri, {bool forImport}), Uri>{}; /// The parsed stylesheets for each canonicalized import URL. final _importCache = {}; @@ -93,15 +89,16 @@ final class AsyncImportCache { AsyncImportCache( {Iterable? importers, Iterable? loadPaths, - PackageConfig? packageConfig, - Logger? logger}) - : _importers = _toImporters(importers, loadPaths, packageConfig), - _logger = logger ?? const Logger.stderr(); + PackageConfig? packageConfig}) + : _importers = _toImporters(importers, loadPaths, packageConfig); /// Creates an import cache without any globally-available importers. - AsyncImportCache.none({Logger? logger}) - : _importers = const [], - _logger = logger ?? const Logger.stderr(); + AsyncImportCache.none() : _importers = const []; + + /// Creates an import cache without any globally-available importers, and only + /// the passed in importers. + AsyncImportCache.only(Iterable importers) + : _importers = List.unmodifiable(importers); /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. @@ -147,64 +144,105 @@ final class AsyncImportCache { } if (baseImporter != null && url.scheme == '') { - var relativeResult = await putIfAbsentAsync( - _relativeCanonicalizeCache, - ( - url, - forImport: forImport, - baseImporter: baseImporter, - baseUrl: baseUrl - ), - () => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url, - baseUrl, forImport)); + var resolvedUrl = baseUrl?.resolveUri(url) ?? url; + var key = (baseImporter, resolvedUrl, forImport: forImport); + var relativeResult = + await putIfAbsentAsync(_perImporterCanonicalizeCache, key, () async { + var (result, cacheable) = + await _canonicalize(baseImporter, resolvedUrl, baseUrl, forImport); + assert( + cacheable, + "Relative loads should always be cacheable because they never " + "provide access to the containing URL."); + if (baseUrl != null) _nonCanonicalRelativeUrls[key] = url; + return result; + }); if (relativeResult != null) return relativeResult; } - return await putIfAbsentAsync( - _canonicalizeCache, (url, forImport: forImport), () async { - for (var importer in _importers) { - if (await _canonicalize(importer, url, baseUrl, forImport) - case var result?) { + var key = (url, forImport: forImport); + if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key]; + + // Each individual call to a `canonicalize()` override may not be cacheable + // (specifically, if it has access to `containingUrl` it's too + // context-sensitive to usefully cache). We want to cache a given URL across + // the _entire_ importer chain, so we use [cacheable] to track whether _all_ + // `canonicalize()` calls we've attempted are cacheable. Only if they are, do + // we store the result in the cache. + var cacheable = true; + for (var i = 0; i < _importers.length; i++) { + var importer = _importers[i]; + var perImporterKey = (importer, url, forImport: forImport); + switch (_perImporterCanonicalizeCache.getOption(perImporterKey)) { + case (var result?,): return result; - } + case (null,): + continue; } - return null; - }); + switch (await _canonicalize(importer, url, baseUrl, forImport)) { + case (var result?, true) when cacheable: + _canonicalizeCache[key] = result; + return result; + + case (var result, true) when !cacheable: + _perImporterCanonicalizeCache[perImporterKey] = result; + if (result != null) return result; + + case (var result, false): + if (cacheable) { + // If this is the first uncacheable result, add all previous results + // to the per-importer cache so we don't have to re-run them for + // future uses of this importer. + for (var j = 0; j < i; j++) { + _perImporterCanonicalizeCache[( + _importers[j], + url, + forImport: forImport + )] = null; + } + cacheable = false; + } + + if (result != null) return result; + } + } + + if (cacheable) _canonicalizeCache[key] = null; + return null; } /// Calls [importer.canonicalize] and prints a deprecation warning if it /// returns a relative URL. /// - /// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl] - /// before passing it to [importer]. - Future _canonicalize( - AsyncImporter importer, Uri url, Uri? baseUrl, bool forImport, - {bool resolveUrl = false}) async { - var resolved = - resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url; - var canonicalize = forImport - ? () => inImportRule(() => importer.canonicalize(resolved)) - : () => importer.canonicalize(resolved); - + /// This returns both the result of the call to `canonicalize()` and whether + /// that result is cacheable at all. + Future<(AsyncCanonicalizeResult?, bool cacheable)> _canonicalize( + AsyncImporter importer, Uri url, Uri? baseUrl, bool forImport) async { var passContainingUrl = baseUrl != null && (url.scheme == '' || await importer.isNonCanonicalScheme(url.scheme)); - var result = await withContainingUrl( - passContainingUrl ? baseUrl : null, canonicalize); - if (result == null) return null; - - if (result.scheme == '') { - _logger.warnForDeprecation( - Deprecation.relativeCanonical, - "Importer $importer canonicalized $resolved to $result.\n" - "Relative canonical URLs are deprecated and will eventually be " - "disallowed."); - } else if (await importer.isNonCanonicalScheme(result.scheme)) { - throw "Importer $importer canonicalized $resolved to $result, which " - "uses a scheme declared as non-canonical."; + + var canonicalizeContext = + CanonicalizeContext(passContainingUrl ? baseUrl : null, forImport); + + var result = await withCanonicalizeContext( + canonicalizeContext, () => importer.canonicalize(url)); + + var cacheable = + !passContainingUrl || !canonicalizeContext.wasContainingUrlAccessed; + + if (result == null) return (null, cacheable); + + // Relative canonical URLs (empty scheme) should throw an error starting in + // Dart Sass 2.0.0, but for now, they only emit a deprecation warning in + // the evaluator. + if (result.scheme != '' && + await importer.isNonCanonicalScheme(result.scheme)) { + throw "Importer $importer canonicalized $url to $result, which uses a " + "scheme declared as non-canonical."; } - return (importer, result, originalUrl: resolved); + return ((importer, result, originalUrl: url), cacheable); } /// Tries to import [url] using one of this cache's importers. @@ -240,12 +278,9 @@ final class AsyncImportCache { /// into [canonicalUrl]. It's used to resolve a relative canonical URL, which /// importers may return for legacy reasons. /// - /// If [quiet] is `true`, this will disable logging warnings when parsing the - /// newly imported stylesheet. - /// /// Caches the result of the import and uses cached results if possible. Future importCanonical(AsyncImporter importer, Uri canonicalUrl, - {Uri? originalUrl, bool quiet = false}) async { + {Uri? originalUrl}) async { return await putIfAbsentAsync(_importCache, canonicalUrl, () async { var result = await importer.load(canonicalUrl); if (result == null) return null; @@ -256,8 +291,7 @@ final class AsyncImportCache { // relative to [originalUrl]. url: originalUrl == null ? canonicalUrl - : originalUrl.resolveUri(canonicalUrl), - logger: quiet ? Logger.quiet : _logger); + : originalUrl.resolveUri(canonicalUrl)); }); } @@ -268,8 +302,7 @@ final class AsyncImportCache { // If multiple original URLs canonicalize to the same thing, choose the // shortest one. minBy( - _canonicalizeCache.values - .whereNotNull() + _canonicalizeCache.values.nonNulls .where((result) => result.$2 == canonicalUrl) .map((result) => result.originalUrl), (url) => url.path.length) @@ -287,7 +320,7 @@ final class AsyncImportCache { Uri sourceMapUrl(Uri canonicalUrl) => _resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl; - /// Clears the cached canonical version of the given [url]. + /// Clears the cached canonical version of the given non-canonical [url]. /// /// Has no effect if the canonical version of [url] has not been cached. /// @@ -296,7 +329,8 @@ final class AsyncImportCache { void clearCanonicalize(Uri url) { _canonicalizeCache.remove((url, forImport: false)); _canonicalizeCache.remove((url, forImport: true)); - _relativeCanonicalizeCache.removeWhere((key, _) => key.$1 == url); + _perImporterCanonicalizeCache.removeWhere( + (key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url); } /// Clears the cached parse tree for the stylesheet with the given diff --git a/lib/src/callable.dart b/lib/src/callable.dart index 2d2ed1e26..1fda63247 100644 --- a/lib/src/callable.dart +++ b/lib/src/callable.dart @@ -11,7 +11,8 @@ import 'utils.dart'; import 'value.dart'; export 'callable/async.dart'; -export 'callable/async_built_in.dart' show AsyncBuiltInCallable; +export 'callable/async_built_in.dart' + show AsyncBuiltInCallable, warnForGlobalBuiltIn; export 'callable/built_in.dart' show BuiltInCallable; export 'callable/plain_css.dart'; export 'callable/user_defined.dart'; diff --git a/lib/src/callable/async_built_in.dart b/lib/src/callable/async_built_in.dart index 0132b787f..4e479c148 100644 --- a/lib/src/callable/async_built_in.dart +++ b/lib/src/callable/async_built_in.dart @@ -5,6 +5,8 @@ import 'dart:async'; import '../ast/sass.dart'; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../value.dart'; import 'async.dart'; @@ -26,6 +28,11 @@ class AsyncBuiltInCallable implements AsyncCallable { /// The callback to run when executing this callable. final Callback _callback; + /// Whether this callable could potentially accept an `@content` block. + /// + /// This can only be true for mixins. + final bool acceptsContent; + /// Creates a function with a single [arguments] declaration and a single /// [callback]. /// @@ -52,7 +59,7 @@ class AsyncBuiltInCallable implements AsyncCallable { /// defined. AsyncBuiltInCallable.mixin(String name, String arguments, FutureOr callback(List arguments), - {Object? url}) + {Object? url, bool acceptsContent = false}) : this.parsed(name, ArgumentDeclaration.parse('@mixin $name($arguments) {', url: url), (arguments) async { @@ -66,7 +73,8 @@ class AsyncBuiltInCallable implements AsyncCallable { /// Creates a callable with a single [arguments] declaration and a single /// [callback]. - AsyncBuiltInCallable.parsed(this.name, this._arguments, this._callback); + AsyncBuiltInCallable.parsed(this.name, this._arguments, this._callback, + {this.acceptsContent = false}); /// Returns the argument declaration and Dart callback for the given /// positional and named arguments. @@ -77,4 +85,23 @@ class AsyncBuiltInCallable implements AsyncCallable { (ArgumentDeclaration, Callback) callbackFor( int positional, Set names) => (_arguments, _callback); + + /// Returns a copy of this callable that emits a deprecation warning. + AsyncBuiltInCallable withDeprecationWarning(String module, + [String? newName]) => + AsyncBuiltInCallable.parsed(name, _arguments, (args) { + warnForGlobalBuiltIn(module, newName ?? name); + return _callback(args); + }, acceptsContent: acceptsContent); +} + +/// Emits a deprecation warning for a global built-in function that is now +/// available as function [name] in built-in module [module]. +void warnForGlobalBuiltIn(String module, String name) { + warnForDeprecation( + 'Global built-in functions are deprecated and will be removed in Dart ' + 'Sass 3.0.0.\n' + 'Use $module.$name instead.\n\n' + 'More info and automated migrator: https://sass-lang.com/d/import', + Deprecation.globalBuiltin); } diff --git a/lib/src/callable/built_in.dart b/lib/src/callable/built_in.dart index 905d11e56..1d58df9fe 100644 --- a/lib/src/callable/built_in.dart +++ b/lib/src/callable/built_in.dart @@ -21,6 +21,8 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { /// The overloads declared for this callable. final List<(ArgumentDeclaration, Callback)> _overloads; + final bool acceptsContent; + /// Creates a function with a single [arguments] declaration and a single /// [callback]. /// @@ -48,18 +50,19 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { /// defined. BuiltInCallable.mixin( String name, String arguments, void callback(List arguments), - {Object? url}) + {Object? url, bool acceptsContent = false}) : this.parsed(name, ArgumentDeclaration.parse('@mixin $name($arguments) {', url: url), (arguments) { callback(arguments); return sassNull; - }); + }, acceptsContent: acceptsContent); /// Creates a callable with a single [arguments] declaration and a single /// [callback]. BuiltInCallable.parsed(this.name, ArgumentDeclaration arguments, - Value callback(List arguments)) + Value callback(List arguments), + {this.acceptsContent = false}) : _overloads = [(arguments, callback)]; /// Creates a function with multiple implementations. @@ -79,9 +82,10 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { ArgumentDeclaration.parse('@function $name($args) {', url: url), callback ) - ]; + ], + acceptsContent = false; - BuiltInCallable._(this.name, this._overloads); + BuiltInCallable._(this.name, this._overloads, this.acceptsContent); /// Returns the argument declaration and Dart callback for the given /// positional and named arguments. @@ -117,5 +121,22 @@ final class BuiltInCallable implements Callable, AsyncBuiltInCallable { } /// Returns a copy of this callable with the given [name]. - BuiltInCallable withName(String name) => BuiltInCallable._(name, _overloads); + BuiltInCallable withName(String name) => + BuiltInCallable._(name, _overloads, acceptsContent); + + /// Returns a copy of this callable that emits a deprecation warning. + BuiltInCallable withDeprecationWarning(String module, [String? newName]) => + BuiltInCallable._( + name, + [ + for (var (declaration, function) in _overloads) + ( + declaration, + (args) { + warnForGlobalBuiltIn(module, newName ?? name); + return function(args); + } + ) + ], + acceptsContent); } diff --git a/lib/src/compile.dart b/lib/src/compile.dart index 2b24def1b..35bc75872 100644 --- a/lib/src/compile.dart +++ b/lib/src/compile.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_compile.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: c2982db43bcd56f81cab3f51b5669e0edd3cfafb +// Checksum: 9dcf6641342288ad16f44b134ad9a555b4e1e992 // // ignore_for_file: unused_import @@ -13,6 +13,7 @@ export 'async_compile.dart'; import 'dart:convert'; +import 'package:cli_pkg/js.dart'; import 'package:path/path.dart' as p; import 'ast/sass.dart'; @@ -25,7 +26,7 @@ import 'importer/legacy_node.dart'; import 'importer/no_op.dart'; import 'io.dart'; import 'logger.dart'; -import 'logger/deprecation_handling.dart'; +import 'logger/deprecation_processing.dart'; import 'syntax.dart'; import 'utils.dart'; import 'visitor/evaluate.dart'; @@ -34,7 +35,8 @@ import 'visitor/serialize.dart'; /// Like [compile] in `lib/sass.dart`, but provides more options to support /// the node-sass compatible API and the executable. /// -/// At most one of `importCache` and `nodeImporter` may be provided at once. +/// If both `importCache` and `nodeImporter` are provided, the importers in +/// `importCache` will be evaluated before `nodeImporter`. CompileResult compile(String path, {Syntax? syntax, Logger? logger, @@ -49,27 +51,30 @@ CompileResult compile(String path, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) { - DeprecationHandlingLogger deprecationLogger = logger = - DeprecationHandlingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), + silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); // If the syntax is different than the importer would default to, we have to // parse the file manually and we can't store it in the cache. Stylesheet? stylesheet; if (nodeImporter == null && (syntax == null || syntax == Syntax.forPath(path))) { - importCache ??= ImportCache.none(logger: logger); + importCache ??= ImportCache.none(); stylesheet = importCache.importCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(path)), + FilesystemImporter.cwd, p.toUri(canonicalize(path)), originalUrl: p.toUri(path))!; } else { stylesheet = Stylesheet.parse( readFile(path), syntax ?? Syntax.forPath(path), - url: p.toUri(path), logger: logger); + url: p.toUri(path)); } var result = _compileStylesheet( @@ -77,7 +82,7 @@ CompileResult compile(String path, logger, importCache, nodeImporter, - FilesystemImporter('.'), + FilesystemImporter.cwd, functions, style, useSpaces, @@ -113,23 +118,25 @@ CompileResult compileString(String source, bool verbose = false, bool sourceMap = false, bool charset = true, + Iterable? silenceDeprecations, Iterable? fatalDeprecations, Iterable? futureDeprecations}) { - DeprecationHandlingLogger deprecationLogger = logger = - DeprecationHandlingLogger(logger ?? Logger.stderr(), + DeprecationProcessingLogger deprecationLogger = + logger = DeprecationProcessingLogger(logger ?? Logger.stderr(), + silenceDeprecations: {...?silenceDeprecations}, fatalDeprecations: {...?fatalDeprecations}, futureDeprecations: {...?futureDeprecations}, - limitRepetition: !verbose); + limitRepetition: !verbose) + ..validate(); - var stylesheet = - Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); + var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url); var result = _compileStylesheet( stylesheet, logger, importCache, nodeImporter, - importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter.cwd), functions, style, useSpaces, @@ -160,6 +167,13 @@ CompileResult _compileStylesheet( bool quietDeps, bool sourceMap, bool charset) { + if (nodeImporter != null) { + logger?.warnForDeprecation( + Deprecation.legacyJsApi, + 'The legacy JS API is deprecated and will be removed in ' + 'Dart Sass 2.0.0.\n\n' + 'More info: https://sass-lang.com/d/legacy-js-api'); + } var evaluateResult = evaluate(stylesheet, importCache: importCache, nodeImporter: nodeImporter, @@ -174,6 +188,7 @@ CompileResult _compileStylesheet( useSpaces: useSpaces, indentWidth: indentWidth, lineFeed: lineFeed, + logger: logger, sourceMap: sourceMap, charset: charset); diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index 1a65d236a..2bb069480 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -119,8 +119,7 @@ final class ExplicitConfiguration extends Configuration { /// Creates a base [ExplicitConfiguration] with a [values] map and a /// [nodeWithSpan]. - ExplicitConfiguration(Map values, this.nodeWithSpan) - : super.implicit(values); + ExplicitConfiguration(super.values, this.nodeWithSpan) : super.implicit(); /// Creates an [ExplicitConfiguration] with a [values] map, a [nodeWithSpan] /// and if this is a copy a reference to the [_originalConfiguration]. diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart index 007fbb152..3281e720d 100644 --- a/lib/src/deprecation.dart +++ b/lib/src/deprecation.dart @@ -1,43 +1,51 @@ -// Copyright 2022 Google LLC. Use of this source code is governed by an +// Copyright 2024 Google LLC. 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:cli_pkg/js.dart'; import 'package:collection/collection.dart'; import 'package:pub_semver/pub_semver.dart'; -import 'io.dart'; import 'util/nullable.dart'; /// A deprecated feature in the language. enum Deprecation { - /// Deprecation for passing a string to `call` instead of `get-function`. + // START AUTOGENERATED CODE + // + // DO NOT EDIT. This section was generated from the language repo. + // See tool/grind/generate_deprecations.dart for details. + // + // Checksum: 47c97f7824eb25d7f1e64e3230938b88330d40b4 + + /// Deprecation for passing a string directly to meta.call(). callString('call-string', deprecatedIn: '0.0.0', description: 'Passing a string directly to meta.call().'), - /// Deprecation for `@elseif`. + /// Deprecation for @elseif. elseif('elseif', deprecatedIn: '1.3.2', description: '@elseif.'), - /// Deprecation for parsing `@-moz-document`. + /// Deprecation for @-moz-document. mozDocument('moz-document', deprecatedIn: '1.7.2', description: '@-moz-document.'), - /// Deprecation for importers using relative canonical URLs. - relativeCanonical('relative-canonical', deprecatedIn: '1.14.2'), + /// Deprecation for imports using relative canonical URLs. + relativeCanonical('relative-canonical', + deprecatedIn: '1.14.2', + description: 'Imports using relative canonical URLs.'), - /// Deprecation for declaring new variables with `!global`. + /// Deprecation for declaring new variables with !global. newGlobal('new-global', deprecatedIn: '1.17.2', description: 'Declaring new variables with !global.'), - /// Deprecation for certain functions in the color module matching the - /// behavior of their global counterparts for compatiblity reasons. + /// Deprecation for using color module functions in place of plain CSS functions. colorModuleCompat('color-module-compat', deprecatedIn: '1.23.0', description: 'Using color module functions in place of plain CSS functions.'), - /// Deprecation for treating `/` as division. + /// Deprecation for / operator for division. slashDiv('slash-div', deprecatedIn: '1.33.0', description: '/ operator for division.'), @@ -46,39 +54,84 @@ enum Deprecation { deprecatedIn: '1.54.0', description: 'Leading, trailing, and repeated combinators.'), - /// Deprecation for ambiguous `+` and `-` operators. + /// Deprecation for ambiguous + and - operators. strictUnary('strict-unary', deprecatedIn: '1.55.0', description: 'Ambiguous + and - operators.'), - /// Deprecation for passing invalid units to certain built-in functions. + /// Deprecation for passing invalid units to built-in functions. functionUnits('function-units', deprecatedIn: '1.56.0', description: 'Passing invalid units to built-in functions.'), - /// Deprecation for passing percentages to the Sass abs() function. - absPercent('abs-percent', - deprecatedIn: '1.65.0', - description: 'Passing percentages to the Sass abs() function.'), - - duplicateVariableFlags('duplicate-var-flags', + /// Deprecation for using !default or !global multiple times for one variable. + duplicateVarFlags('duplicate-var-flags', deprecatedIn: '1.62.0', description: 'Using !default or !global multiple times for one variable.'), + /// Deprecation for passing null as alpha in the ${isJS ? 'JS': 'Dart'} API. nullAlpha('null-alpha', deprecatedIn: '1.62.3', description: 'Passing null as alpha in the ${isJS ? 'JS' : 'Dart'} API.'), - calcInterp('calc-interp', - deprecatedIn: '1.67.0', - description: 'Using interpolation in a calculation outside a value ' - 'position.'), + /// Deprecation for passing percentages to the Sass abs() function. + absPercent('abs-percent', + deprecatedIn: '1.65.0', + description: 'Passing percentages to the Sass abs() function.'), + + /// Deprecation for using the current working directory as an implicit load path. + fsImporterCwd('fs-importer-cwd', + deprecatedIn: '1.73.0', + description: + 'Using the current working directory as an implicit load path.'), + + /// Deprecation for function and mixin names beginning with --. + cssFunctionMixin('css-function-mixin', + deprecatedIn: '1.76.0', + description: 'Function and mixin names beginning with --.'), + + /// Deprecation for declarations after or between nested rules. + mixedDecls('mixed-decls', + deprecatedIn: '1.77.7', + description: 'Declarations after or between nested rules.'), + + /// Deprecation for meta.feature-exists + featureExists('feature-exists', + deprecatedIn: '1.78.0', description: 'meta.feature-exists'), + + /// Deprecation for certain uses of built-in sass:color functions. + color4Api('color-4-api', + deprecatedIn: '1.79.0', + description: 'Certain uses of built-in sass:color functions.'), + + /// Deprecation for using global color functions instead of sass:color. + colorFunctions('color-functions', + deprecatedIn: '1.79.0', + description: 'Using global color functions instead of sass:color.'), + + /// Deprecation for legacy JS API. + legacyJsApi('legacy-js-api', + deprecatedIn: '1.79.0', description: 'Legacy JS API.'), + + /// Deprecation for @import rules. + import('import', deprecatedIn: '1.80.0', description: '@import rules.'), + + /// Deprecation for global built-in functions that are available in sass: modules. + globalBuiltin('global-builtin', + deprecatedIn: '1.80.0', + description: + 'Global built-in functions that are available in sass: modules.'), - /// Deprecation for `@import` rules. - import.future('import', description: '@import rules.'), + // END AUTOGENERATED CODE /// Used for deprecations coming from user-authored code. - userAuthored('user-authored', deprecatedIn: null); + userAuthored('user-authored', deprecatedIn: null), + + @Deprecated('This deprecation name was never actually used.') + calcInterp('calc-interp', deprecatedIn: null); + + @Deprecated('Use duplicateVarFlags instead.') + static const duplicateVariableFlags = duplicateVarFlags; /// A unique ID for this deprecation in kebab case. /// @@ -109,14 +162,29 @@ enum Deprecation { /// what version of Dart Sass this deprecation will be live in. final bool isFuture; + /// Underlying version string used by [obsoleteIn]. + /// + /// This is necessary because [Version] doesn't have a constant constructor, + /// so we can't use it directly as an enum property. + final String? _obsoleteIn; + + /// The Dart Sass version this feature was fully removed in, making the + /// deprecation obsolete. + /// + /// For deprecations that are not yet obsolete, this should be null. + Version? get obsoleteIn => _obsoleteIn?.andThen(Version.parse); + /// Constructs a regular deprecation. const Deprecation(this.id, {required String? deprecatedIn, this.description}) : _deprecatedIn = deprecatedIn, + _obsoleteIn = null, isFuture = false; /// Constructs a future deprecation. + // ignore: unused_element const Deprecation.future(this.id, {this.description}) : _deprecatedIn = null, + _obsoleteIn = null, isFuture = true; @override diff --git a/lib/src/embedded/README.md b/lib/src/embedded/README.md new file mode 100644 index 000000000..b892e4818 --- /dev/null +++ b/lib/src/embedded/README.md @@ -0,0 +1,28 @@ +# Embedded Sass Compiler + +This directory contains the Dart Sass embedded compiler. This is a special mode +of the Dart Sass command-line executable, only supported on the Dart VM, in +which it uses stdin and stdout to communicate with another endpoint, the +"embedded host", using a protocol buffer-based protocol. See [the embedded +protocol specification] for details. + +[the embedded protocol specification]: https://github.com/sass/sass/blob/main/spec/embedded-protocol.md + +The embedded compiler has two different levels of dispatchers for handling +incoming messages from the embedded host: + +1. The [`IsolateDispatcher`] is the first recipient of each packet. It decodes + the packets _just enough_ to determine which compilation they belong to, and + forwards them to the appropriate compilation dispatcher. It also parses and + handles messages that aren't compilation specific, such as `VersionRequest`. + + [`IsolateDispatcher`]: isolate_dispatcher.dart + +2. The [`CompilationDispatcher`] fully parses and handles messages for a single + compilation. Each `CompilationDispatcher` runs in a separate isolate so that + the embedded compiler can run multiple compilations in parallel. + + [`CompilationDispatcher`]: compilation_dispatcher.dart + +Otherwise, most of the code in this directory just wraps Dart APIs to +communicate with their protocol buffer equivalents. diff --git a/lib/src/embedded/dispatcher.dart b/lib/src/embedded/compilation_dispatcher.dart similarity index 76% rename from lib/src/embedded/dispatcher.dart rename to lib/src/embedded/compilation_dispatcher.dart index e9e723bfc..9862a97c1 100644 --- a/lib/src/embedded/dispatcher.dart +++ b/lib/src/embedded/compilation_dispatcher.dart @@ -10,10 +10,15 @@ import 'dart:typed_data'; import 'package:native_synchronization/mailbox.dart'; import 'package:path/path.dart' as p; import 'package:protobuf/protobuf.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:sass/sass.dart' as sass; +import 'package:sass/src/importer/node_package.dart' as npi; +import '../logger.dart'; +import '../value/function.dart'; +import '../value/mixin.dart'; import 'embedded_sass.pb.dart'; -import 'function_registry.dart'; +import 'opaque_registry.dart'; import 'host_callable.dart'; import 'importer/file.dart'; import 'importer/host.dart'; @@ -29,7 +34,7 @@ final _outboundRequestId = 0; /// A class that dispatches messages to and from the host for a single /// compilation. -final class Dispatcher { +final class CompilationDispatcher { /// The mailbox for receiving messages from the host. final Mailbox _mailbox; @@ -46,24 +51,15 @@ final class Dispatcher { /// This is used in outgoing messages. late Uint8List _compilationIdVarint; - /// Whether we detected a [ProtocolError] while parsing an incoming response. - /// - /// If we have, we don't want to send the final compilation result because - /// it'll just be a wrapper around the error. - var _requestError = false; - - /// Creates a [Dispatcher] that receives encoded protocol buffers through - /// [_mailbox] and sends them through [_sendPort]. - Dispatcher(this._mailbox, this._sendPort); + /// Creates a [CompilationDispatcher] that receives encoded protocol buffers + /// through [_mailbox] and sends them through [_sendPort]. + CompilationDispatcher(this._mailbox, this._sendPort); /// Listens for incoming `CompileRequests` and runs their compilations. void listen() { - do { - var packet = _mailbox.take(); - if (packet.isEmpty) break; - + while (true) { try { - var (compilationId, messageBuffer) = parsePacket(packet); + var (compilationId, messageBuffer) = parsePacket(_receive()); _compilationId = compilationId; _compilationIdVarint = serializeVarint(compilationId); @@ -79,9 +75,7 @@ final class Dispatcher { case InboundMessage_Message.compileRequest: var request = message.compileRequest; var response = _compile(request); - if (!_requestError) { - _send(OutboundMessage()..compileResponse = response); - } + _send(OutboundMessage()..compileResponse = response); case InboundMessage_Message.versionRequest: throw paramsError("VersionRequest must have compilation ID 0."); @@ -104,26 +98,58 @@ final class Dispatcher { } catch (error, stackTrace) { _handleError(error, stackTrace); } - } while (!_requestError); + } } OutboundMessage_CompileResponse _compile( InboundMessage_CompileRequest request) { - var functions = FunctionRegistry(); + var functions = OpaqueRegistry(); + var mixins = OpaqueRegistry(); var style = request.style == OutputStyle.COMPRESSED ? sass.OutputStyle.compressed : sass.OutputStyle.expanded; - var logger = EmbeddedLogger(this, - color: request.alertColor, ascii: request.alertAscii); + var logger = request.silent + ? Logger.quiet + : EmbeddedLogger(this, + color: request.alertColor, ascii: request.alertAscii); + + Iterable? parseDeprecationsOrWarn( + Iterable deprecations, + {bool supportVersions = false}) { + return () sync* { + for (var item in deprecations) { + var deprecation = sass.Deprecation.fromId(item); + if (deprecation == null) { + if (supportVersions) { + try { + yield* sass.Deprecation.forVersion(Version.parse(item)); + } on FormatException { + logger.warn('Invalid deprecation id or version "$item".'); + } + } else { + logger.warn('Invalid deprecation id "$item".'); + } + } else { + yield deprecation; + } + } + }(); + } + + var fatalDeprecations = parseDeprecationsOrWarn(request.fatalDeprecation, + supportVersions: true); + var silenceDeprecations = + parseDeprecationsOrWarn(request.silenceDeprecation); + var futureDeprecations = parseDeprecationsOrWarn(request.futureDeprecation); try { var importers = request.importers.map((importer) => - _decodeImporter(request, importer) ?? + _decodeImporter(importer) ?? (throw mandatoryError("Importer.importer"))); var globalFunctions = request.globalFunctions - .map((signature) => hostCallable(this, functions, signature)); + .map((signature) => hostCallable(this, functions, mixins, signature)); late sass.CompileResult result; switch (request.whichInput()) { @@ -133,7 +159,7 @@ final class Dispatcher { color: request.alertColor, logger: logger, importers: importers, - importer: _decodeImporter(request, input.importer) ?? + importer: _decodeImporter(input.importer) ?? (input.url.startsWith("file:") ? null : sass.Importer.noOp), functions: globalFunctions, syntax: syntaxToSyntax(input.syntax), @@ -141,6 +167,9 @@ final class Dispatcher { url: input.url.isEmpty ? null : input.url, quietDeps: request.quietDeps, verbose: request.verbose, + fatalDeprecations: fatalDeprecations, + silenceDeprecations: silenceDeprecations, + futureDeprecations: futureDeprecations, sourceMap: request.sourceMap, charset: request.charset); @@ -158,6 +187,9 @@ final class Dispatcher { style: style, quietDeps: request.quietDeps, verbose: request.verbose, + fatalDeprecations: fatalDeprecations, + silenceDeprecations: silenceDeprecations, + futureDeprecations: futureDeprecations, sourceMap: request.sourceMap, charset: request.charset); } on FileSystemException catch (error) { @@ -202,7 +234,7 @@ final class Dispatcher { } /// Converts [importer] into a [sass.Importer]. - sass.Importer? _decodeImporter(InboundMessage_CompileRequest request, + sass.Importer? _decodeImporter( InboundMessage_CompileRequest_Importer importer) { switch (importer.whichImporter()) { case InboundMessage_CompileRequest_Importer_Importer.path: @@ -217,6 +249,10 @@ final class Dispatcher { _checkNoNonCanonicalScheme(importer); return FileImporter(this, importer.fileImporterId); + case InboundMessage_CompileRequest_Importer_Importer.nodePackageImporter: + return npi.NodePackageImporter( + importer.nodePackageImporter.entryPointDirectory); + case InboundMessage_CompileRequest_Importer_Importer.notSet: _checkNoNonCanonicalScheme(importer); return null; @@ -236,9 +272,12 @@ final class Dispatcher { void sendLog(OutboundMessage_LogEvent event) => _send(OutboundMessage()..logEvent = event); - /// Sends [error] to the host. - void sendError(ProtocolError error) => - _send(OutboundMessage()..error = error); + /// Sends [error] to the host and exit. + /// + /// This is used during compilation by other classes like host callable. + Never sendError(ProtocolError error) { + Isolate.exit(_sendPort, _serializePacket(OutboundMessage()..error = error)); + } InboundMessage_CanonicalizeResponse sendCanonicalizeRequest( OutboundMessage_CanonicalizeRequest request) => @@ -265,17 +304,9 @@ final class Dispatcher { message.id = _outboundRequestId; _send(message); - var packet = _mailbox.take(); - if (packet.isEmpty) { - // Compiler is shutting down, throw without calling `_handleError` as we - // don't want to report this as an actual error. - _requestError = true; - throw StateError('Compiler is shutting down.'); - } - try { var messageBuffer = - Uint8List.sublistView(packet, _compilationIdVarint.length); + Uint8List.sublistView(_receive(), _compilationIdVarint.length); InboundMessage message; try { @@ -313,8 +344,6 @@ final class Dispatcher { return response; } catch (error, stackTrace) { _handleError(error, stackTrace); - _requestError = true; - rethrow; } } @@ -322,12 +351,17 @@ final class Dispatcher { /// /// The [messageId] indicate the IDs of the message being responded to, if /// available. - void _handleError(Object error, StackTrace stackTrace, {int? messageId}) { + Never _handleError(Object error, StackTrace stackTrace, {int? messageId}) { sendError(handleError(error, stackTrace, messageId: messageId)); } /// Sends [message] to the host with the given [wireId]. void _send(OutboundMessage message) { + _sendPort.send(_serializePacket(message)); + } + + /// Serialize [message] to [Uint8List]. + Uint8List _serializePacket(OutboundMessage message) { var protobufWriter = CodedBufferWriter(); message.writeToCodedBufferWriter(protobufWriter); @@ -344,6 +378,17 @@ final class Dispatcher { }; packet.setAll(1, _compilationIdVarint); protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length); - _sendPort.send(packet); + return packet; + } + + /// Receive a packet from the host. + Uint8List _receive() { + try { + return _mailbox.take(); + } on StateError catch (_) { + // The [_mailbox] has been closed, exit the current isolate immediately + // to avoid bubble the error up as [SassException] during [_sendRequest]. + Isolate.exit(); + } } } diff --git a/lib/src/embedded/function_registry.dart b/lib/src/embedded/function_registry.dart deleted file mode 100644 index b288fbd8d..000000000 --- a/lib/src/embedded/function_registry.dart +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2019 Google Inc. Use of this source code is governed by an -// MIT-style license that can be found in the LICENSE file or at -// https://opensource.org/licenses/MIT. - -import '../value/function.dart'; -import 'embedded_sass.pb.dart'; - -/// A registry of [SassFunction]s indexed by ID so that the host can invoke -/// them. -final class FunctionRegistry { - /// First-class functions that have been sent to the host. - /// - /// The functions are located at indexes in the list matching their IDs. - final _functionsById = []; - - /// A reverse map from functions to their indexes in [_functionsById]. - final _idsByFunction = {}; - - /// Converts [function] to a protocol buffer to send to the host. - Value_CompilerFunction protofy(SassFunction function) { - var id = _idsByFunction.putIfAbsent(function, () { - _functionsById.add(function); - return _functionsById.length - 1; - }); - - return Value_CompilerFunction()..id = id; - } - - /// Returns the compiler-side function associated with [id]. - /// - /// If no such function exists, returns `null`. - SassFunction? operator [](int id) => _functionsById[id]; -} diff --git a/lib/src/embedded/host_callable.dart b/lib/src/embedded/host_callable.dart index 448cce217..95e15221a 100644 --- a/lib/src/embedded/host_callable.dart +++ b/lib/src/embedded/host_callable.dart @@ -4,9 +4,11 @@ import '../callable.dart'; import '../exception.dart'; -import 'dispatcher.dart'; +import '../value/function.dart'; +import '../value/mixin.dart'; +import 'compilation_dispatcher.dart'; import 'embedded_sass.pb.dart'; -import 'function_registry.dart'; +import 'opaque_registry.dart'; import 'protofier.dart'; import 'utils.dart'; @@ -19,11 +21,14 @@ import 'utils.dart'; /// /// Throws a [SassException] if [signature] is invalid. Callable hostCallable( - Dispatcher dispatcher, FunctionRegistry functions, String signature, + CompilationDispatcher dispatcher, + OpaqueRegistry functions, + OpaqueRegistry mixins, + String signature, {int? id}) { late Callable callable; callable = Callable.fromSignature(signature, (arguments) { - var protofier = Protofier(dispatcher, functions); + var protofier = Protofier(dispatcher, functions, mixins); var request = OutboundMessage_FunctionCallRequest() ..arguments.addAll( [for (var argument in arguments) protofier.protofy(argument)]); @@ -48,7 +53,6 @@ Callable hostCallable( } } on ProtocolError catch (error, stackTrace) { dispatcher.sendError(handleError(error, stackTrace)); - throw error.message; } }); return callable; diff --git a/lib/src/embedded/importer/base.dart b/lib/src/embedded/importer/base.dart index 1de597da5..07c363cca 100644 --- a/lib/src/embedded/importer/base.dart +++ b/lib/src/embedded/importer/base.dart @@ -5,14 +5,14 @@ import 'package:meta/meta.dart'; import '../../importer.dart'; -import '../dispatcher.dart'; +import '../compilation_dispatcher.dart'; /// An abstract base class for importers that communicate with the host in some /// way. abstract base class ImporterBase extends Importer { - /// The [Dispatcher] to which to send requests. + /// The [CompilationDispatcher] to which to send requests. @protected - final Dispatcher dispatcher; + final CompilationDispatcher dispatcher; ImporterBase(this.dispatcher); diff --git a/lib/src/embedded/importer/file.dart b/lib/src/embedded/importer/file.dart index 0dbccfed5..7c4d9975c 100644 --- a/lib/src/embedded/importer/file.dart +++ b/lib/src/embedded/importer/file.dart @@ -3,35 +3,30 @@ // https://opensource.org/licenses/MIT. import '../../importer.dart'; -import '../dispatcher.dart'; import '../embedded_sass.pb.dart' hide SourceSpan; import 'base.dart'; -/// A filesystem importer to use for most implementation details of -/// [FileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// An importer that asks the host to resolve imports in a simplified, /// file-system-centric way. final class FileImporter extends ImporterBase { /// The host-provided ID of the importer to invoke. final int _importerId; - FileImporter(Dispatcher dispatcher, this._importerId) : super(dispatcher); + FileImporter(super.dispatcher, this._importerId); Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); var request = OutboundMessage_FileImportRequest() ..importerId = _importerId ..url = url.toString() ..fromImport = fromImport; - if (containingUrl case var containingUrl?) { + if (canonicalizeContext.containingUrlWithoutMarking + case var containingUrl?) { request.containingUrl = containingUrl.toString(); } var response = dispatcher.sendFileImportRequest(request); + if (!response.containingUrlUnused) canonicalizeContext.containingUrl; switch (response.whichResult()) { case InboundMessage_FileImportResponse_Result.fileUrl: @@ -40,7 +35,7 @@ final class FileImporter extends ImporterBase { throw 'The file importer must return a file: URL, was "$url"'; } - return _filesystemImporter.canonicalize(url); + return FilesystemImporter.cwd.canonicalize(url); case InboundMessage_FileImportResponse_Result.error: throw response.error; @@ -50,7 +45,7 @@ final class FileImporter extends ImporterBase { } } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; diff --git a/lib/src/embedded/importer/host.dart b/lib/src/embedded/importer/host.dart index 5819be1ec..e5342dc31 100644 --- a/lib/src/embedded/importer/host.dart +++ b/lib/src/embedded/importer/host.dart @@ -6,7 +6,6 @@ import '../../exception.dart'; import '../../importer.dart'; import '../../importer/utils.dart'; import '../../util/span.dart'; -import '../dispatcher.dart'; import '../embedded_sass.pb.dart' hide SourceSpan; import '../utils.dart'; import 'base.dart'; @@ -20,10 +19,9 @@ final class HostImporter extends ImporterBase { /// [canonicalize]. final Set _nonCanonicalSchemes; - HostImporter(Dispatcher dispatcher, this._importerId, - Iterable nonCanonicalSchemes) - : _nonCanonicalSchemes = Set.unmodifiable(nonCanonicalSchemes), - super(dispatcher) { + HostImporter( + super.dispatcher, this._importerId, Iterable nonCanonicalSchemes) + : _nonCanonicalSchemes = Set.unmodifiable(nonCanonicalSchemes) { for (var scheme in _nonCanonicalSchemes) { if (isValidUrlScheme(scheme)) continue; throw SassException( @@ -37,10 +35,12 @@ final class HostImporter extends ImporterBase { ..importerId = _importerId ..url = url.toString() ..fromImport = fromImport; - if (containingUrl case var containingUrl?) { + if (canonicalizeContext.containingUrlWithoutMarking + case var containingUrl?) { request.containingUrl = containingUrl.toString(); } var response = dispatcher.sendCanonicalizeRequest(request); + if (!response.containingUrlUnused) canonicalizeContext.containingUrl; return switch (response.whichResult()) { InboundMessage_CanonicalizeResponse_Result.url => diff --git a/lib/src/embedded/isolate_dispatcher.dart b/lib/src/embedded/isolate_dispatcher.dart index 044e2b9c2..fe79f034c 100644 --- a/lib/src/embedded/isolate_dispatcher.dart +++ b/lib/src/embedded/isolate_dispatcher.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:ffi'; +import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; @@ -12,7 +13,7 @@ import 'package:pool/pool.dart'; import 'package:protobuf/protobuf.dart'; import 'package:stream_channel/stream_channel.dart'; -import 'dispatcher.dart'; +import 'compilation_dispatcher.dart'; import 'embedded_sass.pb.dart'; import 'reusable_isolate.dart'; import 'util/proto_extensions.dart'; @@ -27,13 +28,13 @@ class IsolateDispatcher { /// All isolates that have been spawned to dispatch to. /// /// Only used for cleaning up the process when the underlying channel closes. - final _allIsolates = >[]; + final _allIsolates = StreamController(sync: true); /// The isolates that aren't currently running compilations final _inactiveIsolates = {}; /// A map from active compilationIds to isolates running those compilations. - final _activeIsolates = {}; + final _activeIsolates = >{}; /// A pool controlling how many isolates (and thus concurrent compilations) /// may be live at once. @@ -43,6 +44,9 @@ class IsolateDispatcher { /// See https://github.com/sass/dart-sass/pull/2019 final _isolatePool = Pool(sizeOf() <= 4 ? 7 : 15); + /// Whether [_channel] has been closed or not. + var _closed = false; + IsolateDispatcher(this._channel); void listen() { @@ -54,8 +58,12 @@ class IsolateDispatcher { (compilationId, messageBuffer) = parsePacket(packet); if (compilationId != 0) { - var isolate = _activeIsolates[compilationId] ?? - await _getIsolate(compilationId); + var isolate = await _activeIsolates.putIfAbsent( + compilationId, () => _getIsolate(compilationId!)); + + // The shutdown may have started by the time the isolate is spawned + if (_closed) return; + try { isolate.send(packet); return; @@ -87,10 +95,9 @@ class IsolateDispatcher { } }, onError: (Object error, StackTrace stackTrace) { _handleError(error, stackTrace); - }, onDone: () async { - for (var isolate in _allIsolates) { - (await isolate).kill(); - } + }, onDone: () { + _closed = true; + _allIsolates.stream.listen((isolate) => isolate.kill()); }); } @@ -105,27 +112,40 @@ class IsolateDispatcher { isolate = _inactiveIsolates.first; _inactiveIsolates.remove(isolate); } else { - var future = ReusableIsolate.spawn(_isolateMain); - _allIsolates.add(future); + var future = ReusableIsolate.spawn(_isolateMain, + onError: (Object error, StackTrace stackTrace) { + _handleError(error, stackTrace); + }); isolate = await future; + _allIsolates.add(isolate); } - _activeIsolates[compilationId] = isolate; - isolate.checkOut().listen(_channel.sink.add, - onError: (Object error, StackTrace stackTrace) { - if (error is ProtocolError) { - // Protocol errors have already been through [_handleError] in the child - // isolate, so we just send them as-is and close out the underlying - // channel. - sendError(compilationId, error); - _channel.sink.close(); - } else { - _handleError(error, stackTrace); + isolate.borrow((message) { + var fullBuffer = message as Uint8List; + + // The first byte of messages from isolates indicates whether the entire + // compilation is finished (1) or if it encountered an error (2). Sending + // this as part of the message buffer rather than a separate message + // avoids a race condition where the host might send a new compilation + // request with the same ID as one that just finished before the + // [IsolateDispatcher] receives word that the isolate with that ID is + // done. See sass/dart-sass#2004. + var category = fullBuffer[0]; + var packet = Uint8List.sublistView(fullBuffer, 1); + + switch (category) { + case 0: + _channel.sink.add(packet); + case 1: + _activeIsolates.remove(compilationId); + isolate.release(); + _inactiveIsolates.add(isolate); + resource.release(); + _channel.sink.add(packet); + case 2: + _channel.sink.add(packet); + exit(exitCode); } - }, onDone: () { - _activeIsolates.remove(compilationId); - _inactiveIsolates.add(isolate); - resource.release(); }); return isolate; @@ -137,7 +157,7 @@ class IsolateDispatcher { ..protocolVersion = const String.fromEnvironment("protocol-version") ..compilerVersion = const String.fromEnvironment("compiler-version") ..implementationVersion = const String.fromEnvironment("compiler-version") - ..implementationName = "Dart Sass"; + ..implementationName = "dart-sass"; } /// Handles an error thrown by the dispatcher or code it dispatches to. @@ -161,5 +181,5 @@ class IsolateDispatcher { } void _isolateMain(Mailbox mailbox, SendPort sendPort) { - Dispatcher(mailbox, sendPort).listen(); + CompilationDispatcher(mailbox, sendPort).listen(); } diff --git a/lib/src/embedded/logger.dart b/lib/src/embedded/logger.dart index 8da614cc5..dd1f2a223 100644 --- a/lib/src/embedded/logger.dart +++ b/lib/src/embedded/logger.dart @@ -6,17 +6,18 @@ import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; +import '../deprecation.dart'; import '../logger.dart'; import '../util/nullable.dart'; import '../utils.dart'; -import 'dispatcher.dart'; +import 'compilation_dispatcher.dart'; import 'embedded_sass.pb.dart' hide SourceSpan; import 'utils.dart'; /// A Sass logger that sends log messages as `LogEvent`s. -final class EmbeddedLogger implements Logger { - /// The [Dispatcher] to which to send events. - final Dispatcher _dispatcher; +final class EmbeddedLogger extends LoggerWithDeprecationType { + /// The [CompilationDispatcher] to which to send events. + final CompilationDispatcher _dispatcher; /// Whether the formatted message should contain terminal colors. final bool _color; @@ -39,16 +40,16 @@ final class EmbeddedLogger implements Logger { ': $message\n'); } - void warn(String message, - {FileSpan? span, Trace? trace, bool deprecation = false}) { + void internalWarn(String message, + {FileSpan? span, Trace? trace, Deprecation? deprecation}) { var formatted = withGlyphs(() { var buffer = StringBuffer(); if (_color) { buffer.write('\u001b[33m\u001b[1m'); - if (deprecation) buffer.write('Deprecation '); + if (deprecation != null) buffer.write('Deprecation '); buffer.write('Warning\u001b[0m'); } else { - if (deprecation) buffer.write('DEPRECATION '); + if (deprecation != null) buffer.write('DEPRECATION '); buffer.write('WARNING'); } if (span == null) { @@ -65,12 +66,14 @@ final class EmbeddedLogger implements Logger { }, ascii: _ascii); var event = OutboundMessage_LogEvent() - ..type = - deprecation ? LogEventType.DEPRECATION_WARNING : LogEventType.WARNING + ..type = deprecation != null + ? LogEventType.DEPRECATION_WARNING + : LogEventType.WARNING ..message = message ..formatted = formatted; if (span != null) event.span = protofySpan(span); if (trace != null) event.stackTrace = trace.toString(); + if (deprecation != null) event.deprecationType = deprecation.id; _dispatcher.sendLog(event); } } diff --git a/lib/src/embedded/opaque_registry.dart b/lib/src/embedded/opaque_registry.dart new file mode 100644 index 000000000..bf9adaab2 --- /dev/null +++ b/lib/src/embedded/opaque_registry.dart @@ -0,0 +1,30 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +/// A registry of some `T` indexed by ID so that the host can invoke +/// them. +final class OpaqueRegistry { + /// Instantiations of `T` that have been sent to the host. + /// + /// The values are located at indexes in the list matching their IDs. + final _elementsById = []; + + /// A reverse map from elements to their indexes in [_elementsById]. + final _idsByElement = {}; + + /// Returns the compiler-side id associated with [element]. + int getId(T element) { + var id = _idsByElement.putIfAbsent(element, () { + _elementsById.add(element); + return _elementsById.length - 1; + }); + + return id; + } + + /// Returns the compiler-side element associated with [id]. + /// + /// If no such element exists, returns `null`. + T? operator [](int id) => _elementsById[id]; +} diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart index 6ad083ca4..58a496be3 100644 --- a/lib/src/embedded/protofier.dart +++ b/lib/src/embedded/protofier.dart @@ -5,11 +5,11 @@ import '../util/map.dart'; import '../util/nullable.dart'; import '../value.dart'; -import 'dispatcher.dart'; +import 'compilation_dispatcher.dart'; import 'embedded_sass.pb.dart' as proto; import 'embedded_sass.pb.dart' hide Value, ListSeparator, CalculationOperator; -import 'function_registry.dart'; import 'host_callable.dart'; +import 'opaque_registry.dart'; import 'utils.dart'; /// A class that converts Sass [Value] objects into [Value] protobufs. @@ -18,10 +18,13 @@ import 'utils.dart'; /// custom function call. final class Protofier { /// The dispatcher, for invoking deprotofied [Value_HostFunction]s. - final Dispatcher _dispatcher; + final CompilationDispatcher _dispatcher; /// The IDs of first-class functions. - final FunctionRegistry _functions; + final OpaqueRegistry _functions; + + /// The IDs of first-class mixins. + final OpaqueRegistry _mixins; /// Any argument lists transitively contained in [value]. /// @@ -35,7 +38,10 @@ final class Protofier { /// /// The [functions] tracks the IDs of first-class functions so that the host /// can pass them back to the compiler. - Protofier(this._dispatcher, this._functions); + /// + /// Similarly, the [mixins] tracks the IDs of first-class mixins so that the + /// host can pass them back to the compiler. + Protofier(this._dispatcher, this._functions, this._mixins); /// Converts [value] to its protocol buffer representation. proto.Value protofy(Value value) { @@ -47,18 +53,13 @@ final class Protofier { ..quoted = value.hasQuotes; case SassNumber(): result.number = _protofyNumber(value); - case SassColor(hasCalculatedHsl: true): - result.hslColor = Value_HslColor() - ..hue = value.hue * 1.0 - ..saturation = value.saturation * 1.0 - ..lightness = value.lightness * 1.0 - ..alpha = value.alpha * 1.0; case SassColor(): - result.rgbColor = Value_RgbColor() - ..red = value.red - ..green = value.green - ..blue = value.blue - ..alpha = value.alpha * 1.0; + result.color = Value_Color( + space: value.space.name, + channel1: value.channel0OrNull, + channel2: value.channel1OrNull, + channel3: value.channel2OrNull, + alpha: value.alphaOrNull); case SassArgumentList(): _argumentLists.add(value); result.argumentList = Value_ArgumentList() @@ -84,7 +85,10 @@ final class Protofier { case SassCalculation(): result.calculation = _protofyCalculation(value); case SassFunction(): - result.compilerFunction = _functions.protofy(value); + result.compilerFunction = + Value_CompilerFunction(id: _functions.getId(value)); + case SassMixin(): + result.compilerMixin = Value_CompilerMixin(id: _mixins.getId(value)); case sassTrue: result.singleton = SingletonValue.TRUE; case sassFalse: @@ -172,17 +176,56 @@ final class Protofier { case Value_Value.number: return _deprotofyNumber(value.number); - case Value_Value.rgbColor: - return SassColor.rgb(value.rgbColor.red, value.rgbColor.green, - value.rgbColor.blue, value.rgbColor.alpha); - - case Value_Value.hslColor: - return SassColor.hsl(value.hslColor.hue, value.hslColor.saturation, - value.hslColor.lightness, value.hslColor.alpha); - - case Value_Value.hwbColor: - return SassColor.hwb(value.hwbColor.hue, value.hwbColor.whiteness, - value.hwbColor.blackness, value.hwbColor.alpha); + case Value_Value.color: + var space = ColorSpace.fromName(value.color.space); + var channel1 = + value.color.hasChannel1() ? value.color.channel1 : null; + var channel2 = + value.color.hasChannel2() ? value.color.channel2 : null; + var channel3 = + value.color.hasChannel3() ? value.color.channel3 : null; + var alpha = value.color.hasAlpha() ? value.color.alpha : null; + switch (space) { + case ColorSpace.rgb: + return SassColor.rgb(channel1, channel2, channel3, alpha); + + case ColorSpace.hsl: + return SassColor.hsl(channel1, channel2, channel3, alpha); + + case ColorSpace.hwb: + return SassColor.hwb(channel1, channel2, channel3, alpha); + + case ColorSpace.lab: + return SassColor.lab(channel1, channel2, channel3, alpha); + case ColorSpace.oklab: + return SassColor.oklab(channel1, channel2, channel3, alpha); + + case ColorSpace.lch: + return SassColor.lch(channel1, channel2, channel3, alpha); + case ColorSpace.oklch: + return SassColor.oklch(channel1, channel2, channel3, alpha); + + case ColorSpace.srgb: + return SassColor.srgb(channel1, channel2, channel3, alpha); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear(channel1, channel2, channel3, alpha); + case ColorSpace.displayP3: + return SassColor.displayP3(channel1, channel2, channel3, alpha); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb(channel1, channel2, channel3, alpha); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb(channel1, channel2, channel3, alpha); + case ColorSpace.rec2020: + return SassColor.rec2020(channel1, channel2, channel3, alpha); + + case ColorSpace.xyzD50: + return SassColor.xyzD50(channel1, channel2, channel3, alpha); + case ColorSpace.xyzD65: + return SassColor.xyzD65(channel1, channel2, channel3, alpha); + + default: + throw "Unreachable"; + } case Value_Value.argumentList: if (value.argumentList.id != 0) { @@ -238,9 +281,15 @@ final class Protofier { case Value_Value.hostFunction: return SassFunction(hostCallable( - _dispatcher, _functions, value.hostFunction.signature, + _dispatcher, _functions, _mixins, value.hostFunction.signature, id: value.hostFunction.id)); + case Value_Value.compilerMixin: + var id = value.compilerMixin.id; + if (_mixins[id] case var mixin?) return mixin; + throw paramsError( + "CompilerMixin.id $id doesn't match any known mixins"); + case Value_Value.calculation: return _deprotofyCalculation(value.calculation); @@ -261,10 +310,8 @@ final class Protofier { throw paramsError(error.toString()); } - if (value.whichValue() == Value_Value.rgbColor) { - name = 'RgbColor.$name'; - } else if (value.whichValue() == Value_Value.hslColor) { - name = 'HslColor.$name'; + if (value.whichValue() == Value_Value.color) { + name = 'Color.$name'; } throw paramsError( diff --git a/lib/src/embedded/reusable_isolate.dart b/lib/src/embedded/reusable_isolate.dart index 0ed9eec8c..4140f5a37 100644 --- a/lib/src/embedded/reusable_isolate.dart +++ b/lib/src/embedded/reusable_isolate.dart @@ -8,18 +8,16 @@ import 'dart:typed_data'; import 'package:native_synchronization/mailbox.dart'; import 'package:native_synchronization/sendable.dart'; -import 'embedded_sass.pb.dart'; -import 'utils.dart'; /// The entrypoint for a [ReusableIsolate]. /// /// This must be a static global function. It's run when the isolate is spawned, /// and is passed a [Mailbox] that receives messages from [ReusableIsolate.send] -/// and a [SendPort] that sends messages to the stream returned by -/// [ReusableIsolate.checkOut]. +/// and a [SendPort] that sends messages to the [ReceivePort] listened by +/// [ReusableIsolate.borrow]. /// -/// If the [sendPort] sends a message before [ReusableIsolate.checkOut] is -/// called, this will throw an unhandled [StateError]. +/// If the [sendPort] sends a message before [ReusableIsolate.borrow] is called, +/// this will throw an unhandled [StateError]. typedef ReusableIsolateEntryPoint = FutureOr Function( Mailbox mailbox, SendPort sink); @@ -33,106 +31,70 @@ class ReusableIsolate { /// The [ReceivePort] that receives messages from the wrapped isolate. final ReceivePort _receivePort; - /// The subscription to [_port]. - final StreamSubscription _subscription; + /// The subscription to [_receivePort]. + final StreamSubscription _subscription; - /// Whether [checkOut] has been called and the returned stream has not yet - /// closed. - bool _checkedOut = false; + /// Whether the current isolate has been borrowed. + bool _borrowed = false; - ReusableIsolate._(this._isolate, this._mailbox, this._receivePort) - : _subscription = _receivePort.listen(_defaultOnData); + ReusableIsolate._(this._isolate, this._mailbox, this._receivePort, + {Function? onError}) + : _subscription = _receivePort.listen(_defaultOnData, onError: onError); /// Spawns a [ReusableIsolate] that runs the given [entryPoint]. - static Future spawn( - ReusableIsolateEntryPoint entryPoint) async { + static Future spawn(ReusableIsolateEntryPoint entryPoint, + {Function? onError}) async { var mailbox = Mailbox(); var receivePort = ReceivePort(); var isolate = await Isolate.spawn( _isolateMain, (entryPoint, mailbox.asSendable, receivePort.sendPort)); - return ReusableIsolate._(isolate, mailbox, receivePort); + return ReusableIsolate._(isolate, mailbox, receivePort, onError: onError); } - /// Checks out this isolate and returns a stream of messages from it. - /// - /// This isolate is considered "checked out" until the returned stream - /// completes. While checked out, messages may be sent to the isolate using - /// [send]. - /// - /// Throws a [StateError] if this is called while the isolate is already - /// checked out. - Stream checkOut() { - if (_checkedOut) { - throw StateError( - "Can't call ResuableIsolate.checkOut until the previous stream has " - "completed."); + /// Subscribe to messages from [_receivePort]. + void borrow(void onData(dynamic event)?) { + if (_borrowed) { + throw StateError('ReusableIsolate has already been borrowed.'); + } + _borrowed = true; + _subscription.onData(onData); + } + + /// Unsubscribe to messages from [_receivePort]. + void release() { + if (!_borrowed) { + throw StateError('ReusableIsolate has not been borrowed.'); } - _checkedOut = true; - - var controller = StreamController(sync: true); - - _subscription.onData((message) { - var fullBuffer = message as Uint8List; - - // The first byte of messages from isolates indicates whether the entire - // compilation is finished (1) or if it encountered an error (2). Sending - // this as part of the message buffer rather than a separate message - // avoids a race condition where the host might send a new compilation - // request with the same ID as one that just finished before the - // [IsolateDispatcher] receives word that the isolate with that ID is - // done. See sass/dart-sass#2004. - var category = fullBuffer[0]; - var packet = Uint8List.sublistView(fullBuffer, 1); - - if (category == 2) { - // Parse out the compilation ID and surface the [ProtocolError] as an - // error. This allows the [IsolateDispatcher] to notice that an error - // has occurred and close out the underlying channel. - var (_, buffer) = parsePacket(packet); - controller.addError(OutboundMessage.fromBuffer(buffer).error); - return; - } - - controller.sink.add(packet); - if (category == 1) { - _checkedOut = false; - _subscription.onData(_defaultOnData); - _subscription.onError(null); - controller.close(); - } - }); - - _subscription.onError(controller.addError); - - return controller.stream; + _borrowed = false; + _subscription.onData(_defaultOnData); } /// Sends [message] to the isolate. /// - /// Throws a [StateError] if this is called while the isolate isn't checked - /// out, or if a second message is sent before the isolate has processed the - /// first one. + /// Throws a [StateError] if this is called while the isolate isn't borrowed, + /// or if a second message is sent before the isolate has processed the first + /// one. void send(Uint8List message) { + if (!_borrowed) { + throw StateError('Cannot send a message before being borrowed.'); + } _mailbox.put(message); } /// Shuts down the isolate. void kill() { - _isolate.kill(); - _receivePort.close(); - // If the isolate is blocking on [Mailbox.take], it won't even process a - // kill event, so we send an empty message to make sure it wakes up. - try { - _mailbox.put(Uint8List(0)); - } on StateError catch (_) {} + // kill event, so we closed the mailbox to nofity and wake it up. + _mailbox.close(); + _isolate.kill(priority: Isolate.immediate); + _receivePort.close(); } } /// The default handler for data events from the wrapped isolate when it's not -/// checked out. -void _defaultOnData(Object? _) { - throw StateError("Shouldn't receive a message before being checked out."); +/// borrowed. +void _defaultOnData(dynamic _) { + throw StateError("Shouldn't receive a message before being borrowed."); } void _isolateMain( diff --git a/lib/src/environment.dart b/lib/src/environment.dart index 623f67828..3aa0fa45d 100644 --- a/lib/src/environment.dart +++ b/lib/src/environment.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_environment.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: f7172be68e0a19c4dc2d2ad04fc32a843a98a6bd +// Checksum: e1beeae58a4d5b97cd7d4f01c7d46b0586b508b9 // // ignore_for_file: unused_import @@ -796,7 +796,7 @@ final class Environment { return Configuration.implicit(configuration); } - /// Returns a module that represents the top-level members defined in [this], + /// Returns a module that represents the top-level members defined in `this`, /// that contains [css] and [preModuleComments] as its CSS, which can be /// extended using [extensionStore]. Module toModule( @@ -808,7 +808,7 @@ final class Environment { forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } - /// Returns a module with the same members and upstream modules as [this], but + /// Returns a module with the same members and upstream modules as `this`, but /// an empty stylesheet and extension store. /// /// This is used when resolving imports, since they need to inject forwarded diff --git a/lib/src/evaluation_context.dart b/lib/src/evaluation_context.dart index 5c1b074a9..1f831cd87 100644 --- a/lib/src/evaluation_context.dart +++ b/lib/src/evaluation_context.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:source_span/source_span.dart'; import 'deprecation.dart'; +import 'logger.dart'; /// An interface that exposes information about the current Sass evaluation. /// @@ -17,11 +18,24 @@ abstract interface class EvaluationContext { /// /// Throws [StateError] if there isn't a Sass stylesheet currently being /// evaluated. - static EvaluationContext get current { + static EvaluationContext get current => + currentOrNull ?? + (throw StateError("No Sass stylesheet is currently being evaluated.")); + + /// The current evaluation context, or `null` if none exists. + static EvaluationContext? get currentOrNull => + switch (Zone.current[#_evaluationContext]) { + EvaluationContext context => context, + _ => null + }; + + /// The current evaluation context, or null if there isn't a Sass stylesheet + /// currently being evaluated. + static EvaluationContext? get _currentOrNull { if (Zone.current[#_evaluationContext] case EvaluationContext context) { return context; } else { - throw StateError("No Sass stylesheet is currently being evaluated."); + return null; } } @@ -50,12 +64,29 @@ abstract interface class EvaluationContext { /// This may only be called within a custom function or importer callback. /// {@category Compile} void warn(String message, {bool deprecation = false}) => - EvaluationContext.current - .warn(message, deprecation ? Deprecation.userAuthored : null); + switch (EvaluationContext.currentOrNull) { + var context? => + context.warn(message, deprecation ? Deprecation.userAuthored : null), + _ when deprecation => (const Logger.stderr()) + .warnForDeprecation(Deprecation.userAuthored, message), + _ => (const Logger.stderr()).warn(message) + }; /// Prints a deprecation warning with [message] of type [deprecation]. -void warnForDeprecation(String message, Deprecation deprecation) { - EvaluationContext.current.warn(message, deprecation); +void warnForDeprecation(String message, Deprecation deprecation) => + switch (EvaluationContext.currentOrNull) { + var context? => context.warn(message, deprecation), + _ => (const Logger.stderr()).warnForDeprecation(deprecation, message) + }; + +/// Prints a deprecation warning with [message] of type [deprecation], +/// using stderr if there is no [EvaluationContext.current]. +void warnForDeprecationFromApi(String message, Deprecation deprecation) { + if (EvaluationContext._currentOrNull case var context?) { + context.warn(message, deprecation); + } else { + Logger.stderr().warnForDeprecation(deprecation, message); + } } /// Runs [callback] with [context] as [EvaluationContext.current]. diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 38a1a057e..898258c88 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -28,10 +28,9 @@ class SassException extends SourceSpanException { /// compilation, before it failed. final Set loadedUrls; - SassException(String message, FileSpan span, [Iterable? loadedUrls]) + SassException(super.message, FileSpan super.span, [Iterable? loadedUrls]) : loadedUrls = - loadedUrls == null ? const {} : Set.unmodifiable(loadedUrls), - super(message, span); + loadedUrls == null ? const {} : Set.unmodifiable(loadedUrls); /// Converts this to a [MultiSpanSassException] with the additional [span] and /// [label]. @@ -83,12 +82,12 @@ class SassException extends SourceSpanException { .replaceAll("\r\n", "\n"); term_glyph.ascii = wasAscii; - // For the string comment, render all non-ASCII characters as escape + // For the string comment, render all non-US-ASCII characters as escape // sequences so that they'll show up even if the HTTP headers are set // incorrectly. var stringMessage = StringBuffer(); for (var rune in SassString(toString(color: false)).toString().runes) { - if (rune > 0xFF) { + if (rune > 0x7F) { stringMessage ..writeCharCode($backslash) ..write(rune.toRadixString(16)) @@ -224,9 +223,7 @@ class SassFormatException extends SassException SassFormatException withLoadedUrls(Iterable loadedUrls) => SassFormatException(message, span, loadedUrls); - SassFormatException(String message, FileSpan span, - [Iterable? loadedUrls]) - : super(message, span, loadedUrls); + SassFormatException(super.message, super.span, [super.loadedUrls]); } /// A [SassFormatException] that's also a [MultiSpanFormatException]. @@ -248,10 +245,9 @@ class MultiSpanSassFormatException extends MultiSpanSassException MultiSpanSassFormatException( message, span, primaryLabel, secondarySpans, loadedUrls); - MultiSpanSassFormatException(String message, FileSpan span, - String primaryLabel, Map secondarySpans, - [Iterable? loadedUrls]) - : super(message, span, primaryLabel, secondarySpans, loadedUrls); + MultiSpanSassFormatException( + super.message, super.span, super.primaryLabel, super.secondarySpans, + [super.loadedUrls]); } /// An exception thrown by SassScript. @@ -287,9 +283,8 @@ class MultiSpanSassScriptException extends SassScriptException { final Map secondarySpans; MultiSpanSassScriptException( - String message, this.primaryLabel, Map secondarySpans) - : secondarySpans = Map.unmodifiable(secondarySpans), - super(message); + super.message, this.primaryLabel, Map secondarySpans) + : secondarySpans = Map.unmodifiable(secondarySpans); /// Converts this to a [SassException] with the given primary [span]. MultiSpanSassException withSpan(FileSpan span) => diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index 70b52ba10..c2f26d260 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -68,13 +68,13 @@ Future<(int, String, String?)?> compileStylesheet(ExecutableOptions options, Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, StylesheetGraph graph, String? source, String? destination, {bool ifModified = false}) async { - var importer = FilesystemImporter('.'); + var importer = FilesystemImporter.cwd; if (ifModified) { try { if (source != null && destination != null && - !graph.modifiedSince( - p.toUri(source), modificationTime(destination), importer)) { + !graph.modifiedSince(p.toUri(p.absolute(source)), + modificationTime(destination), importer)) { return; } } on FileSystemException catch (_) { @@ -95,19 +95,20 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, try { if (options.asynchronous) { var importCache = AsyncImportCache( - loadPaths: options.loadPaths, logger: options.logger); + importers: options.pkgImporters, loadPaths: options.loadPaths); result = source == null ? await compileStringAsync(await readStdin(), syntax: syntax, logger: options.logger, importCache: importCache, - importer: FilesystemImporter('.'), + importer: FilesystemImporter.cwd, style: options.style, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, charset: options.charset, + silenceDeprecations: options.silenceDeprecations, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations) : await compileAsync(source, @@ -119,6 +120,7 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, verbose: options.verbose, sourceMap: options.emitSourceMap, charset: options.charset, + silenceDeprecations: options.silenceDeprecations, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations); } else { @@ -127,12 +129,13 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, syntax: syntax, logger: options.logger, importCache: graph.importCache, - importer: FilesystemImporter('.'), + importer: FilesystemImporter.cwd, style: options.style, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, charset: options.charset, + silenceDeprecations: options.silenceDeprecations, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations) : compile(source, @@ -144,6 +147,7 @@ Future _compileStylesheetWithoutErrorHandling(ExecutableOptions options, verbose: options.verbose, sourceMap: options.emitSourceMap, charset: options.charset, + silenceDeprecations: options.silenceDeprecations, fatalDeprecations: options.fatalDeprecations, futureDeprecations: options.futureDeprecations); } diff --git a/lib/src/executable/options.dart b/lib/src/executable/options.dart index 504999c49..c0aeaa077 100644 --- a/lib/src/executable/options.dart +++ b/lib/src/executable/options.dart @@ -10,6 +10,7 @@ import 'package:pub_semver/pub_semver.dart'; import 'package:term_glyph/term_glyph.dart' as term_glyph; import '../../sass.dart'; +import '../importer/node_package.dart'; import '../io.dart'; import '../util/character.dart'; @@ -47,6 +48,12 @@ final class ExecutableOptions { help: 'A path to use when resolving imports.\n' 'May be passed multiple times.', splitCommas: false) + ..addMultiOption('pkg-importer', + abbr: 'p', + valueHelp: 'TYPE', + allowed: ['node'], + help: 'Built-in importer(s) to use for pkg: URLs.', + allowedHelp: {'node': 'Load files like Node.js package resolution.'}) ..addOption('style', abbr: 's', valueHelp: 'NAME', @@ -88,29 +95,10 @@ final class ExecutableOptions { help: 'Deprecations to treat as errors. You may also pass a Sass\n' 'version to include any behavior deprecated in or before it.\n' 'See https://sass-lang.com/documentation/breaking-changes for \n' - 'a complete list.', - allowedHelp: { - for (var deprecation in Deprecation.values) - if (deprecation - case Deprecation( - deprecatedIn: _?, - :var id, - :var description? - )) - id: description - }) + 'a complete list.') + ..addMultiOption('silence-deprecation', help: 'Deprecations to ignore.') ..addMultiOption('future-deprecation', - help: 'Opt in to a deprecation early.', - allowedHelp: { - for (var deprecation in Deprecation.values) - if (deprecation - case Deprecation( - deprecatedIn: null, - :var id, - :var description? - )) - id: description - }); + help: 'Opt in to a deprecation early.'); parser ..addSeparator(_separator('Other')) @@ -218,6 +206,12 @@ final class ExecutableOptions { /// The set of paths Sass in which should look for imported files. List get loadPaths => _options['load-path'] as List; + /// The list of built-in importers to use to load `pkg:` URLs. + List get pkgImporters => [ + for (var _ in _options['pkg-importer'] as List) + NodePackageImporter('.') + ]; + /// Whether to run the evaluator in asynchronous mode, for debugging purposes. bool get asynchronous => _options['async'] as bool; @@ -508,6 +502,12 @@ final class ExecutableOptions { : p.absolute(path)); } + /// The set of deprecations whose warnings should be silenced. + Set get silenceDeprecations => { + for (var id in _options['silence-deprecation'] as List) + Deprecation.fromId(id) ?? _fail('Invalid deprecation "$id".') + }; + /// The set of deprecations that cause errors. Set get fatalDeprecations => _fatalDeprecations ??= () { var deprecations = {}; diff --git a/lib/src/executable/repl.dart b/lib/src/executable/repl.dart index d460b40e0..726279bd8 100644 --- a/lib/src/executable/repl.dart +++ b/lib/src/executable/repl.dart @@ -12,39 +12,63 @@ import '../exception.dart'; import '../executable/options.dart'; import '../import_cache.dart'; import '../importer/filesystem.dart'; +import '../logger/deprecation_processing.dart'; import '../logger/tracking.dart'; +import '../logger.dart'; import '../parse/parser.dart'; +import '../parse/scss.dart'; import '../utils.dart'; import '../visitor/evaluate.dart'; /// Runs an interactive SassScript shell according to [options]. Future repl(ExecutableOptions options) async { var repl = Repl(prompt: '>> '); - var logger = TrackingLogger(options.logger); + var trackingLogger = TrackingLogger(options.logger); + var logger = DeprecationProcessingLogger(trackingLogger, + silenceDeprecations: options.silenceDeprecations, + fatalDeprecations: options.fatalDeprecations, + futureDeprecations: options.futureDeprecations, + limitRepetition: !options.verbose) + ..validate(); + + void warn(ParseTimeWarning warning) { + switch (warning) { + case (:var message, :var span, :var deprecation?): + logger.warnForDeprecation(deprecation, message, span: span); + case (:var message, :var span, deprecation: null): + logger.warn(message, span: span); + } + } + var evaluator = Evaluator( - importer: FilesystemImporter('.'), - importCache: ImportCache(loadPaths: options.loadPaths, logger: logger), + importer: FilesystemImporter.cwd, + importCache: ImportCache( + importers: options.pkgImporters, loadPaths: options.loadPaths), logger: logger); await for (String line in repl.runAsync()) { if (line.trim().isEmpty) continue; try { if (line.startsWith("@")) { - evaluator.use(UseRule.parse(line, logger: logger)); + var (node, warnings) = ScssParser(line).parseUseRule(); + warnings.forEach(warn); + evaluator.use(node); continue; } if (Parser.isVariableDeclarationLike(line)) { - var declaration = VariableDeclaration.parse(line, logger: logger); - evaluator.setVariable(declaration); - print(evaluator.evaluate(VariableExpression( - declaration.name, declaration.span, - namespace: declaration.namespace))); + var (node, warnings) = ScssParser(line).parseVariableDeclaration(); + warnings.forEach(warn); + evaluator.setVariable(node); + print(evaluator.evaluate(VariableExpression(node.name, node.span, + namespace: node.namespace))); } else { - print(evaluator.evaluate(Expression.parse(line, logger: logger))); + var (node, warnings) = ScssParser(line).parseExpression(); + warnings.forEach(warn); + print(evaluator.evaluate(node)); } } on SassException catch (error, stackTrace) { - _logError( - error, getTrace(error) ?? stackTrace, line, repl, options, logger); + _logError(error, getTrace(error) ?? stackTrace, line, repl, options, + trackingLogger); } } } diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart index c8a222b0b..9e1db78e9 100644 --- a/lib/src/executable/watch.dart +++ b/lib/src/executable/watch.dart @@ -39,7 +39,7 @@ Future watch(ExecutableOptions options, StylesheetGraph graph) async { var sourcesToDestinations = _sourcesToDestinations(options); for (var source in sourcesToDestinations.keys) { graph.addCanonical( - FilesystemImporter('.'), p.toUri(canonicalize(source)), p.toUri(source), + FilesystemImporter.cwd, p.toUri(canonicalize(source)), p.toUri(source), recanonicalize: false); } var success = await compileStylesheets(options, graph, sourcesToDestinations, @@ -130,7 +130,7 @@ final class _Watcher { await compileStylesheets(_options, _graph, {path: destination}, ifModified: true); var downstream = _graph.addCanonical( - FilesystemImporter('.'), _canonicalize(path), p.toUri(path)); + FilesystemImporter.cwd, _canonicalize(path), p.toUri(path)); return await _recompileDownstream(downstream) && success; } @@ -144,7 +144,7 @@ final class _Watcher { if (_destinationFor(path) case var destination?) _delete(destination); } - var downstream = _graph.remove(FilesystemImporter('.'), url); + var downstream = _graph.remove(FilesystemImporter.cwd, url); return await _recompileDownstream(downstream); } diff --git a/lib/src/extend/README.md b/lib/src/extend/README.md new file mode 100644 index 000000000..e27304b6b --- /dev/null +++ b/lib/src/extend/README.md @@ -0,0 +1,35 @@ +# `@extend` Logic + +This directory contains most of the logic for running Sass's `@extend` rule. +This rule is probably the most complex corner of the Sass language, since it +involves both understanding the semantics of selectors _and_ being able to +combine them. + +The high-level lifecycle of extensions is as follows: + +1. When [the evaluator] encounters a style rule, it registers its selector in + the [`ExtensionStore`] for the current module. This applies any extensions + that have already been registered, then returns a _mutable_ + `Box` that will get updated as extensions are applied. + + [the evaluator]: ../visitor/async_evaluate.dart + [`ExtensionStore`]: extension_store.dart + +2. When the evaluator encounters an `@extend`, it registers that in the current + module's `ExtensionStore` as well. This updates any selectors that have + already been registered with that extension, _and_ updates the extension's + own extender (the selector that gets injected when the extension is applied, + which is stored along with the extension). Note that the extender has to be + extended separately from the selector in the style rule, because the latter + gets redundant selectors trimmed eagerly and the former does not. + +3. When the entrypoint stylesheet has been fully executed, the evaluator + determines which extensions are visible from which modules and adds + extensions from one store to one another accordingly using + `ExtensionStore.addExtensions()`. + +Otherwise, the process of [extending a selector] as described in the Sass spec +matches the logic here fairly closely. See `ExtensionStore._extendList()` for +the primary entrypoint for that logic. + +[extending a selector]: https://github.com/sass/sass/blob/main/spec/at-rules/extend.md#extending-a-selector diff --git a/lib/src/extend/extension_store.dart b/lib/src/extend/extension_store.dart index a3f276ee9..c450bb7e8 100644 --- a/lib/src/extend/extension_store.dart +++ b/lib/src/extend/extension_store.dart @@ -355,11 +355,6 @@ class ExtensionStore { } } } - - // If [selectors] doesn't contain [extension.extender], for example if it - // was replaced due to :not() expansion, we must get rid of the old - // version. - if (!containsExtension) sources.remove(extension.extender); } return additionalExtensions; @@ -391,12 +386,12 @@ class ExtensionStore { } } - /// Extends [this] with all the extensions in [extensions]. + /// Extends `this` with all the extensions in [extensions]. /// - /// These extensions will extend all selectors already in [this], but they + /// These extensions will extend all selectors already in `this`, but they /// will *not* extend other extensions from [extensionStores]. void addExtensions(Iterable extensionStores) { - // Extensions already in [this] whose extenders are extended by + // Extensions already in `this` whose extenders are extended by // [extensions], and thus which need to be updated. List? extensionsToExtend; @@ -906,13 +901,6 @@ class ExtensionStore { // document, and thus should never be trimmed. List _trim(List selectors, bool isOriginal(ComplexSelector complex)) { - // Avoid truly horrific quadratic behavior. - // - // TODO(nweiz): I think there may be a way to get perfect trimming without - // going quadratic by building some sort of trie-like data structure that - // can be used to look up superselectors. - if (selectors.length > 100) return selectors; - // This is n² on the sequences, but only comparing between separate // sequences should limit the quadratic behavior. We iterate from last to // first and reverse the result so that, if two selectors are identical, we @@ -978,8 +966,8 @@ class ExtensionStore { return specificity; } - /// Returns a copy of [this] that extends new selectors, as well as a map - /// (with reference equality) from the selectors extended by [this] to the + /// Returns a copy of `this` that extends new selectors, as well as a map + /// (with reference equality) from the selectors extended by `this` to the /// selectors extended by the new [ExtensionStore]. (ExtensionStore, Map>) clone() { var newSelectors = >>{}; diff --git a/lib/src/extend/functions.dart b/lib/src/extend/functions.dart index 6299c5fcf..190bf4429 100644 --- a/lib/src/extend/functions.dart +++ b/lib/src/extend/functions.dart @@ -9,6 +9,7 @@ /// aren't instance methods on other objects because their APIs aren't a good /// fit—usually because they deal with raw component lists rather than selector /// classes, to reduce allocations. +library; import 'dart:collection'; @@ -35,7 +36,7 @@ List? unifyComplex( List complexes, FileSpan span) { if (complexes.length == 1) return complexes; - List? unifiedBase; + CompoundSelector? unifiedBase; CssValue? leadingCombinator; CssValue? trailingCombinator; for (var complex in complexes) { @@ -66,12 +67,10 @@ List? unifyComplex( } if (unifiedBase == null) { - unifiedBase = base.selector.components; + unifiedBase = base.selector; } else { - for (var simple in base.selector.components) { - unifiedBase = simple.unify(unifiedBase!); // dart-lang/sdk#45348 - if (unifiedBase == null) return null; - } + unifiedBase = unifyCompound(unifiedBase, base.selector); + if (unifiedBase == null) return null; } } @@ -86,7 +85,7 @@ List? unifyComplex( var base = ComplexSelector( leadingCombinator == null ? const [] : [leadingCombinator], [ - ComplexSelectorComponent(CompoundSelector(unifiedBase!, span), + ComplexSelectorComponent(unifiedBase!, trailingCombinator == null ? const [] : [trailingCombinator], span) ], span, @@ -105,19 +104,44 @@ List? unifyComplex( /// Returns a [CompoundSelector] that matches only elements that are matched by /// both [compound1] and [compound2]. /// -/// The [span] will be used for the new unified selector. +/// The [compound1]'s `span` will be used for the new unified selector. +/// +/// This function ensures that the relative order of pseudo-classes (`:`) and +/// pseudo-elements (`::`) within each input selector is preserved in the +/// resulting combined selector. +/// +/// This function enforces a general rule that pseudo-classes (`:`) should come +/// before pseudo-elements (`::`), but it won't change their order if they were +/// originally interleaved within a single input selector. This prevents +/// unintended changes to the selector's meaning. For example, unifying +/// `::foo:bar` and `:baz` results in `:baz::foo:bar`. `:baz` is a pseudo-class, +/// so it is moved before the pseudo-class `::foo`. Meanwhile, `:bar` is not +/// moved before `::foo` because it appeared after `::foo` in the original +/// selector. /// /// If no such selector can be produced, returns `null`. CompoundSelector? unifyCompound( CompoundSelector compound1, CompoundSelector compound2) { - var result = compound2.components; - for (var simple in compound1.components) { - var unified = simple.unify(result); - if (unified == null) return null; - result = unified; + var result = compound1.components; + var pseudoResult = []; + var pseudoElementFound = false; + + for (var simple in compound2.components) { + // All pseudo-classes are unified separately after a pseudo-element to + // preserve their relative order with the pseudo-element. + if (pseudoElementFound && simple is PseudoSelector) { + var unified = simple.unify(pseudoResult); + if (unified == null) return null; + pseudoResult = unified; + } else { + pseudoElementFound |= simple is PseudoSelector && simple.isElement; + var unified = simple.unify(result); + if (unified == null) return null; + result = unified; + } } - return CompoundSelector(result, compound1.span); + return CompoundSelector([...result, ...pseudoResult], compound1.span); } /// Returns a [SimpleSelector] that matches only elements that are matched by @@ -645,24 +669,28 @@ bool complexIsSuperselector(List complex1, var component1 = complex1[i1]; if (component1.combinators.length > 1) return false; if (remaining1 == 1) { - var parents = complex2.sublist(i2, complex2.length - 1); - if (parents.any((parent) => parent.combinators.length > 1)) return false; - - return compoundIsSuperselector( - component1.selector, complex2.last.selector, - parents: parents); + if (complex2.any((parent) => parent.combinators.length > 1)) { + return false; + } else { + return compoundIsSuperselector( + component1.selector, complex2.last.selector, + parents: component1.selector.hasComplicatedSuperselectorSemantics + ? complex2.sublist(i2, complex2.length - 1) + : null); + } } // Find the first index [endOfSubselector] in [complex2] such that // `complex2.sublist(i2, endOfSubselector + 1)` is a subselector of // [component1.selector]. var endOfSubselector = i2; - List? parents; while (true) { var component2 = complex2[endOfSubselector]; if (component2.combinators.length > 1) return false; if (compoundIsSuperselector(component1.selector, component2.selector, - parents: parents)) { + parents: component1.selector.hasComplicatedSuperselectorSemantics + ? complex2.sublist(i2, endOfSubselector) + : null)) { break; } @@ -674,13 +702,10 @@ bool complexIsSuperselector(List complex1, // to match. return false; } - - parents ??= []; - parents.add(component2); } if (!_compatibleWithPreviousCombinator( - previousCombinator, parents ?? const [])) { + previousCombinator, complex2.take(endOfSubselector).skip(i2))) { return false; } @@ -716,8 +741,8 @@ bool complexIsSuperselector(List complex1, /// Returns whether [parents] are valid intersitial components between one /// complex superselector and another, given that the earlier complex /// superselector had the combinator [previous]. -bool _compatibleWithPreviousCombinator( - CssValue? previous, List parents) { +bool _compatibleWithPreviousCombinator(CssValue? previous, + Iterable parents) { if (parents.isEmpty) return true; if (previous == null) return true; @@ -753,6 +778,13 @@ bool _isSupercombinator( bool compoundIsSuperselector( CompoundSelector compound1, CompoundSelector compound2, {Iterable? parents}) { + if (!compound1.hasComplicatedSuperselectorSemantics && + !compound2.hasComplicatedSuperselectorSemantics) { + if (compound1.components.length > compound2.components.length) return false; + return compound1.components + .every((simple1) => compound2.components.any(simple1.isSuperselector)); + } + // Pseudo elements effectively change the target of a compound selector rather // than narrowing the set of elements to which it applies like other // selectors. As such, if either selector has a pseudo element, they both must @@ -907,4 +939,4 @@ Iterable _selectorPseudoArgs( .whereType() .where((pseudo) => pseudo.isClass == isClass && pseudo.name == name) .map((pseudo) => pseudo.selector) - .whereNotNull(); + .nonNulls; diff --git a/lib/src/functions/README.md b/lib/src/functions/README.md new file mode 100644 index 000000000..6aa87f6b4 --- /dev/null +++ b/lib/src/functions/README.md @@ -0,0 +1,24 @@ +# Built-In Functions + +This directory contains the standard functions that are built into Sass itself, +both those that are available globally and those that are available only through +built-in modules. Each of the files here exports a corresponding +[`BuiltInModule`], and most define a list of global functions as well. + +[`BuiltInModule`]: ../module/built_in.dart + +There are a few functions that Sass supports that aren't defined here: + +* The `if()` function is defined directly in the [`functions.dart`] file, + although in most cases this is actually parsed as an [`IfExpression`] and + handled directly by [the evaluator] since it has special behavior about when + its arguments are evaluated. The function itself only exists for edge cases + like `if(...$args)` or `meta.get-function("if")`. + + [`functions.dart`]: ../functions.dart + [`IfExpression`]: ../ast/sass/expression/if.dart + [the evaluator]: ../visitor/async_evaluate.dart + +* Certain functions in the `sass:meta` module require runtime information that's + only available to the evaluator. These functions are defined in the evaluator + itself so that they have access to its private variables. diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index 3e6ce871b..abbf9d00a 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -2,7 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:collection'; +import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -11,6 +11,8 @@ import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../parse/scss.dart'; +import '../util/map.dart'; import '../util/nullable.dart'; import '../util/number.dart'; import '../utils.dart'; @@ -20,56 +22,56 @@ import '../value.dart'; /// filter declaration. final _microsoftFilterStart = RegExp(r'^[a-zA-Z]+\s*='); +/// If a special number string is detected in these color spaces, even if they +/// were using the one-argument function syntax, we convert it to the three- or +/// four- argument comma-separated syntax for broader browser compatibility. +const _specialCommaSpaces = {ColorSpace.rgb, ColorSpace.hsl}; + /// The global definitions of Sass color functions. final global = UnmodifiableListView([ // ### RGB - _red, _green, _blue, _mix, + _channelFunction("red", ColorSpace.rgb, (color) => color.red, global: true) + .withDeprecationWarning("color"), + _channelFunction("green", ColorSpace.rgb, (color) => color.green, + global: true) + .withDeprecationWarning("color"), + _channelFunction("blue", ColorSpace.rgb, (color) => color.blue, global: true) + .withDeprecationWarning("color"), + _mix.withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("rgb", { r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgb", arguments), r"$red, $green, $blue": (arguments) => _rgb("rgb", arguments), r"$color, $alpha": (arguments) => _rgbTwoArg("rgb", arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "rgb", [r"$red", r"$green", r"$blue"], arguments.first); - return parsed is SassString ? parsed : _rgb("rgb", parsed as List); - } + r"$channels": (arguments) => _parseChannels("rgb", arguments[0], + space: ColorSpace.rgb, name: 'channels') }), BuiltInCallable.overloadedFunction("rgba", { r"$red, $green, $blue, $alpha": (arguments) => _rgb("rgba", arguments), r"$red, $green, $blue": (arguments) => _rgb("rgba", arguments), r"$color, $alpha": (arguments) => _rgbTwoArg("rgba", arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "rgba", [r"$red", r"$green", r"$blue"], arguments.first); - return parsed is SassString - ? parsed - : _rgb("rgba", parsed as List); - } + r"$channels": (arguments) => _parseChannels('rgba', arguments[0], + space: ColorSpace.rgb, name: 'channels') }), - _function("invert", r"$color, $weight: 100%", (arguments) { - var weight = arguments[1].assertNumber("weight"); - if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) { - if (weight.value != 100 || !weight.hasUnit("%")) { - throw "Only one argument may be passed to the plain-CSS invert() " - "function."; - } - - // Use the native CSS `invert` filter function. - return _functionString("invert", arguments.take(1)); + _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { + if (arguments[0] is! SassNumber && !arguments[0].isSpecialNumber) { + warnForGlobalBuiltIn("color", "invert"); } - - var color = arguments[0].assertColor("color"); - var inverse = color.changeRgb( - red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue); - - return _mixColors(inverse, color, weight); + return _invert(arguments, global: true); }), // ### HSL - _hue, _saturation, _lightness, _complement, + _channelFunction("hue", ColorSpace.hsl, (color) => color.hue, + unit: 'deg', global: true) + .withDeprecationWarning("color"), + _channelFunction("saturation", ColorSpace.hsl, (color) => color.saturation, + unit: '%', global: true) + .withDeprecationWarning("color"), + _channelFunction("lightness", ColorSpace.hsl, (color) => color.lightness, + unit: '%', global: true) + .withDeprecationWarning("color"), BuiltInCallable.overloadedFunction("hsl", { r"$hue, $saturation, $lightness, $alpha": (arguments) => @@ -84,11 +86,8 @@ final global = UnmodifiableListView([ throw SassScriptException(r"Missing argument $lightness."); } }, - r"$channels": (arguments) { - var parsed = _parseChannels( - "hsl", [r"$hue", r"$saturation", r"$lightness"], arguments.first); - return parsed is SassString ? parsed : _hsl("hsl", parsed as List); - } + r"$channels": (arguments) => _parseChannels('hsl', arguments[0], + space: ColorSpace.hsl, name: 'channels') }), BuiltInCallable.overloadedFunction("hsla", { @@ -102,46 +101,86 @@ final global = UnmodifiableListView([ throw SassScriptException(r"Missing argument $lightness."); } }, - r"$channels": (arguments) { - var parsed = _parseChannels( - "hsla", [r"$hue", r"$saturation", r"$lightness"], arguments.first); - return parsed is SassString - ? parsed - : _hsl("hsla", parsed as List); - } + r"$channels": (arguments) => _parseChannels('hsla', arguments[0], + space: ColorSpace.hsl, name: 'channels') }), _function("grayscale", r"$color", (arguments) { if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) { // Use the native CSS `grayscale` filter function. return _functionString('grayscale', arguments); - } + } else { + warnForGlobalBuiltIn('color', 'grayscale'); - var color = arguments[0].assertColor("color"); - return color.changeHsl(saturation: 0); + return _grayscale(arguments[0]); + } }), _function("adjust-hue", r"$color, $degrees", (arguments) { var color = arguments[0].assertColor("color"); var degrees = _angleValue(arguments[1], "degrees"); + + if (!color.isLegacy) { + throw SassScriptException( + "adjust-hue() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var suggestedValue = SassNumber(degrees, 'deg'); + warnForDeprecation( + "adjust-hue() is deprecated. Suggestion:\n" + "\n" + "color.adjust(\$color, \$hue: $suggestedValue)\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return color.changeHsl(hue: color.hue + degrees); - }), + }).withDeprecationWarning('color', 'adjust'), _function("lighten", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - lightness: (color.lightness + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); - }), + if (!color.isLegacy) { + throw SassScriptException( + "lighten() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + lightness: clampLikeCss( + color.lightness + amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "lighten() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; + }).withDeprecationWarning('color', 'adjust'), _function("darken", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - lightness: (color.lightness - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); - }), + if (!color.isLegacy) { + throw SassScriptException( + "darken() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + lightness: clampLikeCss( + color.lightness - amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "darken() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'lightness')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; + }).withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("saturate", { r"$amount": (arguments) { @@ -153,40 +192,82 @@ final global = UnmodifiableListView([ return SassString("saturate(${number.toCssString()})", quotes: false); }, r"$color, $amount": (arguments) { + warnForGlobalBuiltIn('color', 'adjust'); var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - saturation: (color.saturation + amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); + if (!color.isLegacy) { + throw SassScriptException( + "saturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + saturation: clampLikeCss( + color.saturation + amount.valueInRange(0, 100, "amount"), + 0, + 100)); + + warnForDeprecation( + "saturate() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; } }), _function("desaturate", r"$color, $amount", (arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); - return color.changeHsl( - saturation: (color.saturation - amount.valueInRange(0, 100, "amount")) - .clamp(0, 100)); - }), + if (!color.isLegacy) { + throw SassScriptException( + "desaturate() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } + + var result = color.changeHsl( + saturation: clampLikeCss( + color.saturation - amount.valueInRange(0, 100, "amount"), 0, 100)); + + warnForDeprecation( + "desaturate() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'saturation')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; + }).withDeprecationWarning('color', 'adjust'), // ### Opacity - _function("opacify", r"$color, $amount", _opacify), - _function("fade-in", r"$color, $amount", _opacify), - _function("transparentize", r"$color, $amount", _transparentize), - _function("fade-out", r"$color, $amount", _transparentize), + _function("opacify", r"$color, $amount", + (arguments) => _opacify("opacify", arguments)) + .withDeprecationWarning('color', 'adjust'), + _function("fade-in", r"$color, $amount", + (arguments) => _opacify("fade-in", arguments)) + .withDeprecationWarning('color', 'adjust'), + _function("transparentize", r"$color, $amount", + (arguments) => _transparentize("transparentize", arguments)) + .withDeprecationWarning('color', 'adjust'), + _function("fade-out", r"$color, $amount", + (arguments) => _transparentize("fade-out", arguments)) + .withDeprecationWarning('color', 'adjust'), BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { + switch (arguments[0]) { // Support the proprietary Microsoft alpha() function. - return _functionString("alpha", arguments); + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + return _functionString("alpha", arguments); + case SassColor(isLegacy: false): + throw SassScriptException( + "alpha() is only supported for legacy colors. Please use " + "color.channel() instead."); + case var argument: + warnForGlobalBuiltIn('color', 'alpha'); + return SassNumber(argument.assertColor("color").alpha); } - - var color = argument.assertColor("color"); - return SassNumber(color.alpha); }, r"$args...": (arguments) { var argList = arguments[0].asList; @@ -215,49 +296,85 @@ final global = UnmodifiableListView([ return _functionString("opacity", arguments); } + warnForGlobalBuiltIn('color', 'opacity'); var color = arguments[0].assertColor("color"); return SassNumber(color.alpha); }), + // ### Color Spaces + + _function( + "color", + r"$description", + (arguments) => + _parseChannels("color", arguments[0], name: 'description')), + + _function( + "hwb", + r"$channels", + (arguments) => _parseChannels("hwb", arguments[0], + space: ColorSpace.hwb, name: 'channels')), + + _function( + "lab", + r"$channels", + (arguments) => _parseChannels("lab", arguments[0], + space: ColorSpace.lab, name: 'channels')), + + _function( + "lch", + r"$channels", + (arguments) => _parseChannels("lch", arguments[0], + space: ColorSpace.lch, name: 'channels')), + + _function( + "oklab", + r"$channels", + (arguments) => _parseChannels("oklab", arguments[0], + space: ColorSpace.oklab, name: 'channels')), + + _function( + "oklch", + r"$channels", + (arguments) => _parseChannels("oklch", arguments[0], + space: ColorSpace.oklch, name: 'channels')), + + _complement.withDeprecationWarning("color"), + // ### Miscellaneous _ieHexStr, - _adjust.withName("adjust-color"), - _scale.withName("scale-color"), - _change.withName("change-color") + _adjust.withDeprecationWarning('color').withName("adjust-color"), + _scale.withDeprecationWarning('color').withName("scale-color"), + _change.withDeprecationWarning('color').withName("change-color") ]); /// The Sass color module. final module = BuiltInModule("color", functions: [ // ### RGB - _red, _green, _blue, _mix, - - _function("invert", r"$color, $weight: 100%", (arguments) { - var weight = arguments[1].assertNumber("weight"); - if (arguments[0] is SassNumber) { - if (weight.value != 100 || !weight.hasUnit("%")) { - throw "Only one argument may be passed to the plain-CSS invert() " - "function."; - } - - var result = _functionString("invert", arguments.take(1)); + _channelFunction("red", ColorSpace.rgb, (color) => color.red), + _channelFunction("green", ColorSpace.rgb, (color) => color.green), + _channelFunction("blue", ColorSpace.rgb, (color) => color.blue), + _mix, + + _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { + var result = _invert(arguments); + if (result is SassString) { warnForDeprecation( "Passing a number (${arguments[0]}) to color.invert() is " "deprecated.\n" "\n" "Recommendation: $result", Deprecation.colorModuleCompat); - return result; } - - var color = arguments[0].assertColor("color"); - var inverse = color.changeRgb( - red: 255 - color.red, green: 255 - color.green, blue: 255 - color.blue); - - return _mixColors(inverse, color, weight); + return result; }), // ### HSL - _hue, _saturation, _lightness, _complement, + _channelFunction("hue", ColorSpace.hsl, (color) => color.hue, unit: 'deg'), + _channelFunction("saturation", ColorSpace.hsl, (color) => color.saturation, + unit: '%'), + _channelFunction("lightness", ColorSpace.hsl, (color) => color.lightness, + unit: '%'), _removedColorFunction("adjust-hue", "hue"), _removedColorFunction("lighten", "lightness"), _removedColorFunction("darken", "lightness", negative: true), @@ -276,37 +393,27 @@ final module = BuiltInModule("color", functions: [ return result; } - var color = arguments[0].assertColor("color"); - return color.changeHsl(saturation: 0); + return _grayscale(arguments[0]); }), // ### HWB BuiltInCallable.overloadedFunction("hwb", { - r"$hue, $whiteness, $blackness, $alpha: 1": (arguments) => _hwb(arguments), - r"$channels": (arguments) { - var parsed = _parseChannels( - "hwb", [r"$hue", r"$whiteness", r"$blackness"], arguments.first); - - // `hwb()` doesn't (currently) support special number or variable strings. - if (parsed is SassString) { - throw SassScriptException('Expected numeric channels, got "$parsed".'); - } else { - return _hwb(parsed as List); - } - } + r"$hue, $whiteness, $blackness, $alpha: 1": (arguments) => _parseChannels( + 'hwb', + SassList([ + SassList( + [arguments[0], arguments[1], arguments[2]], ListSeparator.space), + arguments[3] + ], ListSeparator.slash), + space: ColorSpace.hwb), + r"$channels": (arguments) => _parseChannels('hwb', arguments[0], + space: ColorSpace.hwb, name: 'channels') }), - _function( - "whiteness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").whiteness, "%")), - - _function( - "blackness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").blackness, "%")), + _channelFunction("whiteness", ColorSpace.hwb, (color) => color.whiteness, + unit: '%'), + _channelFunction("blackness", ColorSpace.hwb, (color) => color.blackness, + unit: '%'), // ### Opacity _removedColorFunction("opacify", "alpha"), @@ -316,21 +423,26 @@ final module = BuiltInModule("color", functions: [ BuiltInCallable.overloadedFunction("alpha", { r"$color": (arguments) { - var argument = arguments[0]; - if (argument is SassString && - !argument.hasQuotes && - argument.text.contains(_microsoftFilterStart)) { - var result = _functionString("alpha", arguments); - warnForDeprecation( - "Using color.alpha() for a Microsoft filter is deprecated.\n" - "\n" - "Recommendation: $result", - Deprecation.colorModuleCompat); - return result; + switch (arguments[0]) { + // Support the proprietary Microsoft alpha() function. + case SassString(hasQuotes: false, :var text) + when text.contains(_microsoftFilterStart): + var result = _functionString("alpha", arguments); + warnForDeprecation( + "Using color.alpha() for a Microsoft filter is deprecated.\n" + "\n" + "Recommendation: $result", + Deprecation.colorModuleCompat); + return result; + + case SassColor(isLegacy: false): + throw SassScriptException( + "color.alpha() is only supported for legacy colors. Please use " + "color.channel() instead."); + + case var argument: + return SassNumber(argument.assertColor("color").alpha); } - - var color = argument.assertColor("color"); - return SassNumber(color.alpha); }, r"$args...": (arguments) { if (arguments[0].asList.every((argument) => @@ -369,53 +481,308 @@ final module = BuiltInModule("color", functions: [ return SassNumber(color.alpha); }), + // ### Color Spaces + _function( + "space", + r"$color", + (arguments) => SassString(arguments.first.assertColor("color").space.name, + quotes: false)), + + // `color.to-space()` never returns missing channels for legacy color spaces + // because they're less compatible and users are probably using a legacy space + // because they want a highly compatible color. + _function( + "to-space", + r"$color, $space", + (arguments) => + _colorInSpace(arguments[0], arguments[1], legacyMissing: false)), + + _function("is-legacy", r"$color", + (arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)), + + _function( + "is-missing", + r"$color, $channel", + (arguments) => SassBoolean(arguments[0] + .assertColor("color") + .isChannelMissing(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), + + _function( + "is-in-gamut", + r"$color, $space: null", + (arguments) => + SassBoolean(_colorInSpace(arguments[0], arguments[1]).isInGamut)), + + _function("to-gamut", r"$color, $space: null, $method: null", (arguments) { + var color = arguments[0].assertColor("color"); + var space = _spaceOrDefault(color, arguments[1], "space"); + if (arguments[2] == sassNull) { + throw SassScriptException( + "color.to-gamut() requires a \$method argument for forwards-" + "compatibility with changes in the CSS spec. Suggestion:\n" + "\n" + "\$method: local-minde", + "method"); + } + + // Assign this before checking [space.isBounded] so that invalid method + // names consistently produce errors. + var method = GamutMapMethod.fromName( + (arguments[2].assertString("method")..assertUnquoted("method")).text); + if (!space.isBounded) return color; + + return color + .toSpace(space) + .toGamut(method) + .toSpace(color.space, legacyMissing: false); + }), + + _function("channel", r"$color, $channel, $space: null", (arguments) { + var color = _colorInSpace(arguments[0], arguments[2]); + var channelName = _channelName(arguments[1]); + if (channelName == "alpha") return SassNumber(color.alpha); + + var channelIndex = color.space.channels + .indexWhere((channel) => channel.name == channelName); + if (channelIndex == -1) { + throw SassScriptException( + "Color $color has no channel named $channelName.", "channel"); + } + + var channelInfo = color.space.channels[channelIndex]; + var channelValue = color.channels[channelIndex]; + var unit = channelInfo.associatedUnit; + if (unit == '%') { + channelValue = channelValue * 100 / (channelInfo as LinearChannel).max; + } + + return SassNumber(channelValue, unit); + }), + + _function("same", r"$color1, $color2", (arguments) { + var color1 = arguments[0].assertColor('color1'); + var color2 = arguments[1].assertColor('color2'); + + /// Converts [color] to the xyz-d65 space without any mising channels. + SassColor toXyzNoMissing(SassColor color) => switch (color) { + SassColor(space: ColorSpace.xyzD65, hasMissingChannel: false) => + color, + SassColor( + space: ColorSpace.xyzD65, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + SassColor.xyzD65(channel0, channel1, channel2, alpha), + SassColor( + :var space, + :var channel0, + :var channel1, + :var channel2, + :var alpha + ) => + // Use [ColorSpace.convert] manually so that we can convert missing + // channels to 0 without having to create new intermediate color + // objects. + space.convert( + ColorSpace.xyzD65, channel0, channel1, channel2, alpha) + }; + + return SassBoolean(color1.space == color2.space + ? fuzzyEquals(color1.channel0, color2.channel0) && + fuzzyEquals(color1.channel1, color2.channel1) && + fuzzyEquals(color1.channel2, color2.channel2) && + fuzzyEquals(color1.alpha, color2.alpha) + : toXyzNoMissing(color1) == toXyzNoMissing(color2)); + }), + + _function( + "is-powerless", + r"$color, $channel, $space: null", + (arguments) => SassBoolean(_colorInSpace(arguments[0], arguments[2]) + .isChannelPowerless(_channelName(arguments[1]), + colorName: "color", channelName: "channel"))), + + _complement, + // Miscellaneous _adjust, _scale, _change, _ieHexStr ]); // ### RGB -final _red = _function("red", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").red); -}); - -final _green = _function("green", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").green); -}); - -final _blue = _function("blue", r"$color", (arguments) { - return SassNumber(arguments.first.assertColor("color").blue); -}); - -final _mix = _function("mix", r"$color1, $color2, $weight: 50%", (arguments) { +final _mix = _function("mix", r"$color1, $color2, $weight: 50%, $method: null", + (arguments) { var color1 = arguments[0].assertColor("color1"); var color2 = arguments[1].assertColor("color2"); var weight = arguments[2].assertNumber("weight"); - return _mixColors(color1, color2, weight); -}); -// ### HSL + if (arguments[3] != sassNull) { + return color1.interpolate( + color2, InterpolationMethod.fromValue(arguments[3], "method"), + weight: weight.valueInRangeWithUnit(0, 100, "weight", "%") / 100, + legacyMissing: false); + } -final _hue = _function("hue", r"$color", - (arguments) => SassNumber(arguments.first.assertColor("color").hue, "deg")); + _checkPercent(weight, "weight"); + if (!color1.isLegacy) { + throw SassScriptException( + "To use color.mix() with non-legacy color $color1, you must provide a " + "\$method.", + "color1"); + } else if (!color2.isLegacy) { + throw SassScriptException( + "To use color.mix() with non-legacy color $color2, you must provide a " + "\$method.", + "color2"); + } -final _saturation = _function( - "saturation", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").saturation, "%")); + return _mixLegacy(color1, color2, weight); +}); -final _lightness = _function( - "lightness", - r"$color", - (arguments) => - SassNumber(arguments.first.assertColor("color").lightness, "%")); +// ### Color Spaces -final _complement = _function("complement", r"$color", (arguments) { +final _complement = + _function("complement", r"$color, $space: null", (arguments) { var color = arguments[0].assertColor("color"); - return color.changeHsl(hue: color.hue + 180); + var space = color.isLegacy && arguments[1] == sassNull + ? ColorSpace.hsl + : ColorSpace.fromName( + (arguments[1].assertString("space")..assertUnquoted("space")).text, + "space"); + + if (!space.isPolar) { + throw SassScriptException( + "Color space $space doesn't have a hue channel.", 'space'); + } + + var colorInSpace = + color.toSpace(space, legacyMissing: arguments[1] != sassNull); + return (space.isLegacy + ? SassColor.forSpaceInternal( + space, + _adjustChannel(colorInSpace, space.channels[0], + colorInSpace.channel0OrNull, SassNumber(180)), + colorInSpace.channel1OrNull, + colorInSpace.channel2OrNull, + colorInSpace.alphaOrNull) + : SassColor.forSpaceInternal( + space, + colorInSpace.channel0OrNull, + colorInSpace.channel1OrNull, + _adjustChannel(colorInSpace, space.channels[2], + colorInSpace.channel2OrNull, SassNumber(180)), + colorInSpace.alphaOrNull)) + .toSpace(color.space, legacyMissing: false); }); +/// The implementation of the `invert()` function. +/// +/// If [global] is true, that indicates that this is being called from the +/// global `invert()` function. +Value _invert(List arguments, {bool global = false}) { + var weightNumber = arguments[1].assertNumber("weight"); + if (arguments[0] is SassNumber || (global && arguments[0].isSpecialNumber)) { + if (weightNumber.value != 100 || !weightNumber.hasUnit("%")) { + throw "Only one argument may be passed to the plain-CSS invert() " + "function."; + } + + // Use the native CSS `invert` filter function. + return _functionString("invert", arguments.take(1)); + } + + var color = arguments[0].assertColor("color"); + if (arguments[2] == sassNull) { + if (!color.isLegacy) { + throw SassScriptException( + "To use color.invert() with non-legacy color $color, you must provide " + "a \$space.", + "color"); + } + + _checkPercent(weightNumber, "weight"); + var rgb = color.toSpace(ColorSpace.rgb); + var [channel0, channel1, channel2] = ColorSpace.rgb.channels; + return _mixLegacy( + SassColor.rgb( + _invertChannel(rgb, channel0, rgb.channel0OrNull), + _invertChannel(rgb, channel1, rgb.channel1OrNull), + _invertChannel(rgb, channel2, rgb.channel2OrNull), + color.alphaOrNull), + color, + weightNumber) + .toSpace(color.space); + } + + var space = ColorSpace.fromName( + (arguments[2].assertString('space')..assertUnquoted('space')).text, + 'space'); + var weight = weightNumber.valueInRangeWithUnit(0, 100, 'weight', '%') / 100; + if (fuzzyEquals(weight, 0)) return color; + + var inSpace = color.toSpace(space); + var inverted = switch (space) { + ColorSpace.hwb => SassColor.hwb( + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel2OrNull, + inSpace.channel1OrNull, + inSpace.alpha), + ColorSpace.hsl || + ColorSpace.lch || + ColorSpace.oklch => + SassColor.forSpaceInternal( + space, + _invertChannel(inSpace, space.channels[0], inSpace.channel0OrNull), + inSpace.channel1OrNull, + _invertChannel(inSpace, space.channels[2], inSpace.channel2OrNull), + inSpace.alpha), + ColorSpace(channels: [var channel0, var channel1, var channel2]) => + SassColor.forSpaceInternal( + space, + _invertChannel(inSpace, channel0, inSpace.channel0OrNull), + _invertChannel(inSpace, channel1, inSpace.channel1OrNull), + _invertChannel(inSpace, channel2, inSpace.channel2OrNull), + inSpace.alpha), + _ => throw UnsupportedError("Unknown color space $space.") + }; + + return fuzzyEquals(weight, 1) + ? inverted.toSpace(color.space, legacyMissing: false) + : color.interpolate(inverted, InterpolationMethod(space), + weight: 1 - weight, legacyMissing: false); +} + +/// Returns the inverse of the given [value] in a linear color channel. +double _invertChannel(SassColor color, ColorChannel channel, double? value) { + if (value == null) _missingChannelError(color, channel.name); + return switch (channel) { + LinearChannel(min: < 0) => -value, + LinearChannel(min: 0, :var max) => max - value, + ColorChannel(isPolarAngle: true) => (value + 180) % 360, + _ => throw UnsupportedError("Unknown channel $channel.") + }; +} + +/// The implementation of the `grayscale()` function, without any logic for the +/// plain-CSS `grayscale()` syntax. +Value _grayscale(Value colorArg) { + var color = colorArg.assertColor("color"); + + if (color.isLegacy) { + var hsl = color.toSpace(ColorSpace.hsl); + return SassColor.hsl(hsl.channel0OrNull, 0, hsl.channel2OrNull, hsl.alpha) + .toSpace(color.space, legacyMissing: false); + } else { + var oklch = color.toSpace(ColorSpace.oklch); + return SassColor.oklch( + oklch.channel0OrNull, 0, oklch.channel2OrNull, oklch.alpha) + .toSpace(color.space); + } +} + // Miscellaneous final _adjust = _function("adjust", r"$color, $kwargs...", @@ -428,12 +795,15 @@ final _change = _function("change", r"$color, $kwargs...", (arguments) => _updateComponents(arguments, change: true)); final _ieHexStr = _function("ie-hex-str", r"$color", (arguments) { - var color = arguments[0].assertColor("color"); - String hexString(int component) => - component.toRadixString(16).padLeft(2, '0').toUpperCase(); + var color = arguments[0] + .assertColor("color") + .toSpace(ColorSpace.rgb) + .toGamut(GamutMapMethod.localMinde); + String hexString(double component) => + fuzzyRound(component).toRadixString(16).padLeft(2, '0').toUpperCase(); return SassString( - "#${hexString(fuzzyRound(color.alpha * 255))}${hexString(color.red)}" - "${hexString(color.green)}${hexString(color.blue)}", + "#${hexString(color.alpha * 255)}${hexString(color.channel0)}" + "${hexString(color.channel1)}${hexString(color.channel2)}", quotes: false); }); @@ -445,7 +815,6 @@ SassColor _updateComponents(List arguments, {bool change = false, bool adjust = false, bool scale = false}) { assert([change, adjust, scale].where((x) => x).length == 1); - var color = arguments[0].assertColor("color"); var argumentList = arguments[1] as SassArgumentList; if (argumentList.asList.isNotEmpty) { throw SassScriptException( @@ -454,106 +823,232 @@ SassColor _updateComponents(List arguments, } var keywords = Map.of(argumentList.keywords); - - /// Gets and validates the parameter with [name] from keywords. - /// - /// [max] should be 255 for RGB channels, 1 for the alpha channel, and 100 - /// for saturation, lightness, whiteness, and blackness. - double? getParam(String name, num max, - {bool checkPercent = false, - bool assertPercent = false, - bool checkUnitless = false}) { - var number = keywords.remove(name)?.assertNumber(name); - if (number == null) return null; - if (!scale && checkUnitless) { - if (number.hasUnits) { - warnForDeprecation( - "\$$name: Passing a number with unit ${number.unitString} is " - "deprecated.\n" - "\n" - "To preserve current behavior: ${number.unitSuggestion(name)}\n" - "\n" - "More info: https://sass-lang.com/d/function-units", - Deprecation.functionUnits); - } + var originalColor = arguments[0].assertColor("color"); + var spaceKeyword = keywords.remove("space")?.assertString("space") + ?..assertUnquoted("space"); + + var alphaArg = keywords.remove('alpha'); + + // For backwards-compatibility, we allow legacy colors to modify channels in + // any legacy color space and we their powerless channels as 0. + var color = spaceKeyword == null && + originalColor.isLegacy && + keywords.isNotEmpty + ? _sniffLegacyColorSpace(keywords).andThen( + (space) => originalColor.toSpace(space, legacyMissing: false)) ?? + originalColor + : _colorInSpace(originalColor, spaceKeyword ?? sassNull); + + var oldChannels = color.channels; + var channelArgs = List.filled(oldChannels.length, null); + var channelInfo = color.space.channels; + for (var (name, value) in keywords.pairs) { + var channelIndex = channelInfo.indexWhere((info) => name == info.name); + if (channelIndex == -1) { + throw SassScriptException( + "Color space ${color.space} doesn't have a channel with this name.", + name); } - if (!scale && checkPercent) _checkPercent(number, name); - if (scale || assertPercent) number.assertUnit("%", name); - if (scale) max = 100; - return scale || assertPercent - ? number.valueInRange(change ? 0 : -max, max, name) - : number.valueInRangeWithUnit( - change ? 0 : -max, max, name, checkPercent ? '%' : ''); + + channelArgs[channelIndex] = value; } - var alpha = getParam("alpha", 1, checkUnitless: true); - var red = getParam("red", 255); - var green = getParam("green", 255); - var blue = getParam("blue", 255); + SassColor result; + if (change) { + result = _changeColor(color, channelArgs, alphaArg); + } else { + var channelNumbers = [ + for (var i = 0; i < channelInfo.length; i++) + channelArgs[i]?.assertNumber(channelInfo[i].name) + ]; + var alphaNumber = alphaArg?.assertNumber("alpha"); + result = scale + ? _scaleColor(color, channelNumbers, alphaNumber) + : _adjustColor(color, channelNumbers, alphaNumber); + } - var hue = scale - ? null - : keywords.remove("hue").andThen((hue) => _angleValue(hue, "hue")); + return result.toSpace(originalColor.space, legacyMissing: false); +} - var saturation = getParam("saturation", 100, checkPercent: true); - var lightness = getParam("lightness", 100, checkPercent: true); - var whiteness = getParam("whiteness", 100, assertPercent: true); - var blackness = getParam("blackness", 100, assertPercent: true); +/// Returns a copy of [color] with its channel values replaced by those in +/// [channelArgs] and [alphaArg], if specified. +SassColor _changeColor( + SassColor color, List channelArgs, Value? alphaArg) => + _colorFromChannels( + color.space, + _channelForChange(channelArgs[0], color, 0), + _channelForChange(channelArgs[1], color, 1), + _channelForChange(channelArgs[2], color, 2), + switch (alphaArg) { + null => color.alpha, + _ when _isNone(alphaArg) => null, + SassNumber(hasUnits: false) => alphaArg.valueInRange(0, 1, "alpha"), + SassNumber() when alphaArg.hasUnit('%') => + alphaArg.valueInRangeWithUnit(0, 100, "alpha", "%") / 100, + SassNumber() => () { + warnForDeprecation( + "\$alpha: Passing a unit other than % ($alphaArg) is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${alphaArg.unitSuggestion('alpha')}\n" + "\n" + "See https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + return alphaArg.valueInRange(0, 1, "alpha"); + }(), + _ => throw SassScriptException( + '$alphaArg is not a number or unquoted "none".', 'alpha') + }, + clamp: false); + +/// Returns the value for a single channel in `color.change()`. +/// +/// The [channelArg] is the argument passed in by the user, if one exists. If no +/// argument is passed, the channel at [index] in [color] is used instead. +SassNumber? _channelForChange(Value? channelArg, SassColor color, int channel) { + if (channelArg == null) { + return switch (color.channelsOrNull[channel]) { + var value? => SassNumber( + value, + (color.space == ColorSpace.hsl || color.space == ColorSpace.hwb) && + channel > 0 + ? '%' + : null), + _ => null + }; + } + if (_isNone(channelArg)) return null; + if (channelArg is SassNumber) return channelArg; + throw SassScriptException('$channelArg is not a number or unquoted "none".', + color.space.channels[channel].name); +} - if (keywords.isNotEmpty) { - throw SassScriptException( - "No ${pluralize('argument', keywords.length)} named " - "${toSentence(keywords.keys.map((name) => '\$$name'), 'or')}."); +/// Returns a copy of [color] with its channel values scaled by the values in +/// [channelArgs] and [alphaArg], if specified. +SassColor _scaleColor( + SassColor color, List channelArgs, SassNumber? alphaArg) => + SassColor.forSpaceInternal( + color.space, + _scaleChannel(color, color.space.channels[0], color.channel0OrNull, + channelArgs[0]), + _scaleChannel(color, color.space.channels[1], color.channel1OrNull, + channelArgs[1]), + _scaleChannel(color, color.space.channels[2], color.channel2OrNull, + channelArgs[2]), + _scaleChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg)); + +/// Returns [oldValue] scaled by [factorArg] according to the definition in +/// [channel]. +double? _scaleChannel(SassColor color, ColorChannel channel, double? oldValue, + SassNumber? factorArg) { + if (factorArg == null) return oldValue; + if (channel is! LinearChannel) { + throw SassScriptException("Channel isn't scalable.", channel.name); } - var hasRgb = red != null || green != null || blue != null; - var hasSL = saturation != null || lightness != null; - var hasWB = whiteness != null || blackness != null; + if (oldValue == null) _missingChannelError(color, channel.name); + + var factor = (factorArg..assertUnit('%', channel.name)) + .valueInRangeWithUnit(-100, 100, channel.name, '%') / + 100; + return switch (factor) { + 0 => oldValue, + > 0 => oldValue >= channel.max + ? oldValue + : oldValue + (channel.max - oldValue) * factor, + _ => oldValue <= channel.min + ? oldValue + : oldValue + (oldValue - channel.min) * factor + }; +} - if (hasRgb && (hasSL || hasWB || hue != null)) { - throw SassScriptException("RGB parameters may not be passed along with " - "${hasWB ? 'HWB' : 'HSL'} parameters."); +/// Returns a copy of [color] with its channel values adjusted by the values in +/// [channelArgs] and [alphaArg], if specified. +SassColor _adjustColor( + SassColor color, List channelArgs, SassNumber? alphaArg) => + SassColor.forSpaceInternal( + color.space, + _adjustChannel(color, color.space.channels[0], color.channel0OrNull, + channelArgs[0]), + _adjustChannel(color, color.space.channels[1], color.channel1OrNull, + channelArgs[1]), + _adjustChannel(color, color.space.channels[2], color.channel2OrNull, + channelArgs[2]), + // The color space doesn't matter for alpha, as long as it's not + // strictly bounded. + _adjustChannel(color, ColorChannel.alpha, color.alphaOrNull, alphaArg) + .andThen((alpha) => clampLikeCss(alpha, 0, 1))); + +/// Returns [oldValue] adjusted by [adjustmentArg] according to the definition +/// in [color]'s space's [channel]. +double? _adjustChannel(SassColor color, ColorChannel channel, double? oldValue, + SassNumber? adjustmentArg) { + if (adjustmentArg == null) return oldValue; + + if (oldValue == null) _missingChannelError(color, channel.name); + + switch ((color.space, channel)) { + case (ColorSpace.hsl || ColorSpace.hwb, ColorChannel(isPolarAngle: true)): + // `_channelFromValue` expects all hue values to be compatible with `deg`, + // but we're still in the deprecation period where we allow non-`deg` + // values for HSL and HWB so we have to handle that ahead-of-time. + adjustmentArg = SassNumber(_angleValue(adjustmentArg, 'hue')); + + case (ColorSpace.hsl, LinearChannel(name: 'saturation' || 'lightness')): + // `_channelFromValue` expects lightness/saturation to be `%`, but we're + // still in the deprecation period where we allow non-`%` values so we + // have to handle that ahead-of-time. + _checkPercent(adjustmentArg, channel.name); + adjustmentArg = SassNumber(adjustmentArg.value, '%'); + + case (_, ColorChannel.alpha) when adjustmentArg.hasUnits: + // `_channelFromValue` expects alpha to be unitless or `%`, but we're + // still in the deprecation period where we allow other values (and + // interpret `%` as unitless) so we have to handle that ahead-of-time. + warnForDeprecation( + "\$alpha: Passing a number with unit ${adjustmentArg.unitString} is " + "deprecated.\n" + "\n" + "To preserve current behavior: " + "${adjustmentArg.unitSuggestion('alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/function-units", + Deprecation.functionUnits); + adjustmentArg = SassNumber(adjustmentArg.value); } - if (hasSL && hasWB) { - throw SassScriptException( - "HSL parameters may not be passed along with HWB parameters."); - } + var result = + oldValue + _channelFromValue(channel, adjustmentArg, clamp: false)!; + return switch (channel) { + LinearChannel(lowerClamped: true, :var min) when result < min => + oldValue < min ? math.max(oldValue, result) : min, + LinearChannel(upperClamped: true, :var max) when result > max => + oldValue > max ? math.min(oldValue, result) : max, + _ => result + }; +} - /// Updates [current] based on [param], clamped within [max]. - double updateValue(double current, double? param, num max) { - if (param == null) return current; - if (change) return param; - if (adjust) return (current + param).clamp(0, max).toDouble(); - return current + (param > 0 ? max - current : current) * (param / 100); +/// Given a map of arguments passed to [_updateComponents] for a legacy color, +/// determines whether it's updating the color as RGB, HSL, or HWB. +/// +/// Returns `null` if [keywords] contains no keywords for any of the legacy +/// color spaces. +ColorSpace? _sniffLegacyColorSpace(Map keywords) { + for (var key in keywords.keys) { + switch (key) { + case "red" || "green" || "blue": + return ColorSpace.rgb; + + case "saturation" || "lightness": + return ColorSpace.hsl; + + case "whiteness" || "blackness": + return ColorSpace.hwb; + } } - int updateRgb(int current, double? param) => - fuzzyRound(updateValue(current.toDouble(), param, 255)); - - if (hasRgb) { - return color.changeRgb( - red: updateRgb(color.red, red), - green: updateRgb(color.green, green), - blue: updateRgb(color.blue, blue), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (hasWB) { - return color.changeHwb( - hue: change ? hue : color.hue + (hue ?? 0), - whiteness: updateValue(color.whiteness, whiteness, 100), - blackness: updateValue(color.blackness, blackness, 100), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (hue != null || hasSL) { - return color.changeHsl( - hue: change ? hue : color.hue + (hue ?? 0), - saturation: updateValue(color.saturation, saturation, 100), - lightness: updateValue(color.lightness, lightness, 100), - alpha: updateValue(color.alpha, alpha, 1)); - } else if (alpha != null) { - return color.changeAlpha(updateValue(color.alpha, alpha, 1)); - } else { - return color; - } + return keywords.containsKey("hue") ? ColorSpace.hsl : null; } /// Returns a string representation of [name] called with [arguments], as though @@ -582,6 +1077,8 @@ BuiltInCallable _removedColorFunction(String name, String argument, "More info: https://sass-lang.com/documentation/functions/color#$name"); }); +/// The implementation of the three- and four-argument `rgb()` and `rgba()` +/// functions. Value _rgb(String name, List arguments) { var alpha = arguments.length > 3 ? arguments[3] : null; if (arguments[0].isSpecialNumber || @@ -591,39 +1088,56 @@ Value _rgb(String name, List arguments) { return _functionString(name, arguments); } - var red = arguments[0].assertNumber("red"); - var green = arguments[1].assertNumber("green"); - var blue = arguments[2].assertNumber("blue"); - - return SassColor.rgbInternal( - fuzzyRound(_percentageOrUnitless(red, 255, "red")), - fuzzyRound(_percentageOrUnitless(green, 255, "green")), - fuzzyRound(_percentageOrUnitless(blue, 255, "blue")), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha")) ?? + return _colorFromChannels( + ColorSpace.rgb, + arguments[0].assertNumber("red"), + arguments[1].assertNumber("green"), + arguments[2].assertNumber("blue"), + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? 1, - ColorFormat.rgbFunction); + fromRgbFunction: true); } +/// The implementation of the two-argument `rgb()` and `rgba()` functions. Value _rgbTwoArg(String name, List arguments) { // rgba(var(--foo), 0.5) is valid CSS because --foo might be `123, 456, 789` // and functions are parsed after variable substitution. - if (arguments[0].isVar || - (arguments[0] is! SassColor && arguments[1].isVar)) { + var first = arguments[0]; + var second = arguments[1]; + if (first.isVar || (first is! SassColor && second.isVar)) { return _functionString(name, arguments); - } else if (arguments[1].isSpecialNumber) { - var color = arguments[0].assertColor("color"); - return SassString( - "$name(${color.red}, ${color.green}, ${color.blue}, " - "${arguments[1].toCssString()})", - quotes: false); } - var color = arguments[0].assertColor("color"); + var color = first.assertColor("color"); + if (!color.isLegacy) { + throw SassScriptException( + 'Expected $color to be in the legacy RGB, HSL, or HWB color space.\n' + '\n' + 'Recommendation: color.change($color, \$alpha: $second)', + name); + } + + color.assertLegacy("color"); + color = color.toSpace(ColorSpace.rgb); + if (second.isSpecialNumber) { + return _functionString(name, [ + SassNumber(color.channel('red')), + SassNumber(color.channel('green')), + SassNumber(color.channel('blue')), + arguments[1] + ]); + } + var alpha = arguments[1].assertNumber("alpha"); - return color.changeAlpha(_percentageOrUnitless(alpha, 1, "alpha")); + return color.changeAlpha( + clampLikeCss(_percentageOrUnitless(alpha, 1, "alpha"), 0, 1)); } +/// The implementation of the three- and four-argument `hsl()` and `hsla()` +/// functions. Value _hsl(String name, List arguments) { var alpha = arguments.length > 3 ? arguments[3] : null; if (arguments[0].isSpecialNumber || @@ -633,21 +1147,16 @@ Value _hsl(String name, List arguments) { return _functionString(name, arguments); } - var hue = _angleValue(arguments[0], "hue"); - var saturation = arguments[1].assertNumber("saturation"); - var lightness = arguments[2].assertNumber("lightness"); - - _checkPercent(saturation, "saturation"); - _checkPercent(lightness, "lightness"); - - return SassColor.hslInternal( - hue, - saturation.value.clamp(0, 100), - lightness.value.clamp(0, 100), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha")) ?? - 1, - ColorFormat.hslFunction); + return _colorFromChannels( + ColorSpace.hsl, + arguments[0].assertNumber("hue"), + arguments[1].assertNumber("saturation"), + arguments[2].assertNumber("lightness"), + alpha.andThen((alpha) => clampLikeCss( + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha"), + 0, + 1)) ?? + 1); } /// Asserts that [angle] is a number and returns its value in degrees. @@ -680,107 +1189,15 @@ void _checkPercent(SassNumber number, String name) { Deprecation.functionUnits); } -/// Create an HWB color from the given [arguments]. -Value _hwb(List arguments) { - var alpha = arguments.length > 3 ? arguments[3] : null; - var hue = _angleValue(arguments[0], "hue"); - var whiteness = arguments[1].assertNumber("whiteness"); - var blackness = arguments[2].assertNumber("blackness"); - - whiteness.assertUnit("%", "whiteness"); - blackness.assertUnit("%", "blackness"); - - return SassColor.hwb( - hue, - whiteness.valueInRange(0, 100, "whiteness"), - blackness.valueInRange(0, 100, "blackness"), - alpha.andThen((alpha) => - _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha")) ?? - 1); -} - -Object /* SassString | List */ _parseChannels( - String name, List argumentNames, Value channels) { - if (channels.isVar) return _functionString(name, [channels]); - - var originalChannels = channels; - Value? alphaFromSlashList; - if (channels.separator == ListSeparator.slash) { - var list = channels.asList; - if (list.length != 2) { - throw SassScriptException( - "Only 2 slash-separated elements allowed, but ${list.length} " - "${pluralize('was', list.length, plural: 'were')} passed."); - } - - channels = list[0]; - - alphaFromSlashList = list[1]; - if (!alphaFromSlashList.isSpecialNumber) { - alphaFromSlashList.assertNumber("alpha"); - } - if (list[0].isVar) return _functionString(name, [originalChannels]); - } - - var isCommaSeparated = channels.separator == ListSeparator.comma; - var isBracketed = channels.hasBrackets; - if (isCommaSeparated || isBracketed) { - var buffer = StringBuffer(r"$channels must be"); - if (isBracketed) buffer.write(" an unbracketed"); - if (isCommaSeparated) { - buffer.write(isBracketed ? "," : " a"); - buffer.write(" space-separated"); - } - buffer.write(" list."); - throw SassScriptException(buffer.toString()); - } - - var list = channels.asList; - if (list.length > 3) { - throw SassScriptException("Only 3 elements allowed, but ${list.length} " - "were passed."); - } else if (list.length < 3) { - if (list.any((value) => value.isVar) || - (list.isNotEmpty && _isVarSlash(list.last))) { - return _functionString(name, [originalChannels]); - } else { - var argument = argumentNames[list.length]; - throw SassScriptException("Missing element $argument."); - } - } - - if (alphaFromSlashList != null) return [...list, alphaFromSlashList]; - - return switch (list[2]) { - SassNumber(asSlash: (var channel3, var alpha)) => [ - list[0], - list[1], - channel3, - alpha - ], - SassString(hasQuotes: false, :var text) when text.contains("/") => - _functionString(name, [channels]), - _ => list - }; -} - -/// Returns whether [value] is an unquoted string that start with `var(` and -/// contains `/`. -bool _isVarSlash(Value value) => - value is SassString && - value.hasQuotes && - startsWithIgnoreCase(value.text, "var(") && - value.text.contains("/"); - /// Asserts that [number] is a percentage or has no units, and normalizes the /// value. /// -/// If [number] has no units, its value is clamped to be greater than `0` or -/// less than [max] and returned. If [number] is a percentage, it's scaled to be -/// within `0` and [max]. Otherwise, this throws a [SassScriptException]. +/// If [number] has no units, it's returned as-id. If it's a percentage, it's +/// scaled so that `0%` is `0` and `100%` is [max]. Otherwise, this throws a +/// [SassScriptException]. /// /// [name] is used to identify the argument in the error message. -double _percentageOrUnitless(SassNumber number, num max, String name) { +double _percentageOrUnitless(SassNumber number, double max, [String? name]) { double value; if (!number.hasUnits) { value = number.value; @@ -788,15 +1205,20 @@ double _percentageOrUnitless(SassNumber number, num max, String name) { value = max * number.value / 100; } else { throw SassScriptException( - '\$$name: Expected $number to have no units or "%".'); + 'Expected $number to have unit "%" or no units.', name); } - return value.clamp(0, max).toDouble(); + return value; } -/// Returns [color1] and [color2], mixed together and weighted by [weight]. -SassColor _mixColors(SassColor color1, SassColor color2, SassNumber weight) { - _checkPercent(weight, 'weight'); +/// Returns [color1] and [color2], mixed together and weighted by [weight] using +/// Sass's legacy color-mixing algorithm. +SassColor _mixLegacy(SassColor color1, SassColor color2, SassNumber weight) { + assert(color1.isLegacy, "[BUG] $color1 should be a legacy color."); + assert(color2.isLegacy, "[BUG] $color2 should be a legacy color."); + + var rgb1 = color1.toSpace(ColorSpace.rgb); + var rgb2 = color2.toSpace(ColorSpace.rgb); // This algorithm factors in both the user-provided weight (w) and the // difference between the alpha values of the two colors (a) to decide how @@ -830,32 +1252,432 @@ SassColor _mixColors(SassColor color1, SassColor color2, SassNumber weight) { var weight2 = 1 - weight1; return SassColor.rgb( - fuzzyRound(color1.red * weight1 + color2.red * weight2), - fuzzyRound(color1.green * weight1 + color2.green * weight2), - fuzzyRound(color1.blue * weight1 + color2.blue * weight2), - color1.alpha * weightScale + color2.alpha * (1 - weightScale)); + rgb1.channel0 * weight1 + rgb2.channel0 * weight2, + rgb1.channel1 * weight1 + rgb2.channel1 * weight2, + rgb1.channel2 * weight1 + rgb2.channel2 * weight2, + rgb1.alpha * weightScale + rgb2.alpha * (1 - weightScale)); } /// The definition of the `opacify()` and `fade-in()` functions. -SassColor _opacify(List arguments) { +SassColor _opacify(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } - return color.changeAlpha( - (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + var result = color.changeAlpha(clampLikeCss( + (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); + + warnForDeprecation( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; } /// The definition of the `transparentize()` and `fade-out()` functions. -SassColor _transparentize(List arguments) { +SassColor _transparentize(String name, List arguments) { var color = arguments[0].assertColor("color"); var amount = arguments[1].assertNumber("amount"); + if (!color.isLegacy) { + throw SassScriptException( + "$name() is only supported for legacy colors. Please use " + "color.adjust() instead with an explicit \$space argument."); + } - return color.changeAlpha( - (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")) - .clamp(0, 1)); + var result = color.changeAlpha(clampLikeCss( + (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")), 0, 1)); + + warnForDeprecation( + "$name() is deprecated. " + "${_suggestScaleAndAdjust(color, -amount.value, 'alpha')}\n" + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + return result; +} + +/// Returns the [colorUntyped] as a [SassColor] in the color space specified by +/// [spaceUntyped]. +/// +/// If [legacyMissing] is false, this will convert missing channels in legacy +/// color spaces to zero if a conversion occurs. +/// +/// Throws a [SassScriptException] if either argument isn't the expected type or +/// if [spaceUntyped] isn't the name of a color space. If [spaceUntyped] is +/// `sassNull`, it defaults to the color's existing space. +SassColor _colorInSpace(Value colorUntyped, Value spaceUntyped, + {bool legacyMissing = true}) { + var color = colorUntyped.assertColor("color"); + if (spaceUntyped == sassNull) return color; + + return color.toSpace( + ColorSpace.fromName( + (spaceUntyped.assertString("space")..assertUnquoted("space")).text, + "space"), + legacyMissing: legacyMissing); +} + +/// Returns the color space named by [space], or throws a [SassScriptException] +/// if [space] isn't the name of a color space. +/// +/// If [space] is `sassNull`, this returns [color]'s space instead. +/// +/// If [space] came from a function argument, [name] is the argument name +/// (without the `$`). It's used for error reporting. +ColorSpace _spaceOrDefault(SassColor color, Value space, [String? name]) => + space == sassNull + ? color.space + : ColorSpace.fromName( + (space.assertString(name)..assertUnquoted(name)).text, name); + +/// Parses the color components specified by [input] into a [SassColor], or +/// returns an unquoted [SassString] representing the plain CSS function call if +/// they contain a construct that can only be resolved at browse time. +/// +/// If [space] is passed, it's used as the color space to parse. Otherwise, this +/// expects the color space to be specified in [input] as for the `color()` +/// function. +/// +/// Throws a [SassScriptException] if [input] is invalid. If [input] came from a +/// function argument, [name] is the argument name (without the `$`). It's used +/// for error reporting. +Value _parseChannels(String functionName, Value input, + {ColorSpace? space, String? name}) { + if (input.isVar) return _functionString(functionName, [input]); + + var parsedSlash = _parseSlashChannels(input, name: name); + if (parsedSlash == null) return _functionString(functionName, [input]); + var (components, alphaValue) = parsedSlash; + + List channels; + SassString? spaceName; + switch (components.assertCommonListStyle(name, allowSlash: false)) { + case []: + throw SassScriptException('Color component list may not be empty.', name); + + case [SassString(:var text, hasQuotes: false), ...] + when text.toLowerCase() == "from": + return _functionString(functionName, [input]); + + case _ when components.isVar: + channels = [components]; + + case [var first, ...var rest] && var componentList: + if (space == null) { + spaceName = first.assertString(name)..assertUnquoted(name); + space = + spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); + channels = rest; + + if (space + case ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch) { + throw SassScriptException( + "The color() function doesn't support the color space $space. Use " + "the $space() function instead.", + name); + } + } else { + channels = componentList; + } + + for (var i = 0; i < channels.length; i++) { + var channel = channels[i]; + if (!channel.isSpecialNumber && + channel is! SassNumber && + !_isNone(channel)) { + var channelName = space?.channels + .elementAtOrNull(i) + ?.name + .andThen((name) => '$name channel') ?? + 'channel ${i + 1}'; + throw SassScriptException( + 'Expected $channelName to be a number, was $channel.', name); + } + } + + // dart-lang/sdk#51926 + case _: + throw "unreachable"; + } + + if (alphaValue?.isSpecialNumber ?? false) { + return channels.length == 3 && _specialCommaSpaces.contains(space) + ? _functionString(functionName, [...channels, alphaValue!]) + : _functionString(functionName, [input]); + } + + var alpha = switch (alphaValue) { + null => 1.0, + SassString(hasQuotes: false, text: 'none') => null, + _ => clampLikeCss( + _percentageOrUnitless(alphaValue.assertNumber(name), 1, 'alpha'), + 0, + 1) + .toDouble() + }; + + // `space` will be null if either `components` or `spaceName` is a `var()`. + // Again, we check this here rather than returning early in those cases so + // that we can verify `alphaValue` even for colors we can't fully parse. + if (space == null) return _functionString(functionName, [input]); + if (channels.any((channel) => channel.isSpecialNumber)) { + return channels.length == 3 && _specialCommaSpaces.contains(space) + ? _functionString( + functionName, [...channels, if (alphaValue != null) alphaValue]) + : _functionString(functionName, [input]); + } + + if (channels.length != 3) { + throw SassScriptException( + 'The $space color space has 3 channels but $input has ' + '${channels.length}.', + name); + } + + return _colorFromChannels( + space, + // If a channel isn't a number, it must be `none`. + castOrNull(channels[0]), + castOrNull(channels[1]), + castOrNull(channels[2]), + alpha, + fromRgbFunction: space == ColorSpace.rgb); } +/// Parses [input]'s slash-separated third number and alpha value, if one +/// exists. +/// +/// Returns a single value that contains the space-separated list of components, +/// and an alpha value if one was specified. If this channel set couldn't be +/// parsed and should be returned as-is, returns null. +/// +/// Throws a [SassScriptException] if [input] is invalid. If [input] came from a +/// function argument, [name] is the argument name (without the `$`). It's used +/// for error reporting. +(Value components, Value? alpha)? _parseSlashChannels(Value input, + {String? name}) => + switch (input.assertCommonListStyle(name, allowSlash: true)) { + [var components, var alphaValue] + when input.separator == ListSeparator.slash => + (components, alphaValue), + var inputList when input.separator == ListSeparator.slash => + throw SassScriptException( + "Only 2 slash-separated elements allowed, but ${inputList.length} " + "${pluralize('was', inputList.length, plural: 'were')} passed.", + name), + [...var initial, SassString(hasQuotes: false, :var text)] => switch ( + text.split('/')) { + [_] => (input, null), + [var channel3, var alpha] => ( + SassList([...initial, _parseNumberOrString(channel3)], + ListSeparator.space), + _parseNumberOrString(alpha) + ), + _ => null + }, + [...var initial, SassNumber(asSlash: (var before, var after))] => ( + SassList([...initial, before], ListSeparator.space), + after + ), + _ => (input, null) + }; + +/// Parses [text] as either a Sass number or an unquoted Sass string. +Value _parseNumberOrString(String text) { + try { + return ScssParser(text).parseNumber(); + } on SassFormatException { + return SassString(text, quotes: false); + } +} + +/// Creates a [SassColor] for the given [space] from the given channel values, +/// or throws a [SassScriptException] if the channel values are invalid. +/// +/// If [clamp] is true, this will clamp any clamped channels. +SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, + SassNumber? channel1, SassNumber? channel2, double? alpha, + {bool clamp = true, bool fromRgbFunction = false}) { + switch (space) { + case ColorSpace.hsl: + if (channel1 != null) _checkPercent(channel1, 'saturation'); + if (channel2 != null) _checkPercent(channel2, 'lightness'); + return SassColor.hsl( + channel0.andThen((channel0) => _angleValue(channel0, 'hue')), + _channelFromValue(space.channels[1], _forcePercent(channel1), + clamp: clamp), + _channelFromValue(space.channels[2], _forcePercent(channel2), + clamp: clamp), + alpha); + + case ColorSpace.hwb: + channel1?.assertUnit('%', 'whiteness'); + channel2?.assertUnit('%', 'blackness'); + var whiteness = channel1?.value.toDouble(); + var blackness = channel2?.value.toDouble(); + + if (whiteness != null && + blackness != null && + whiteness + blackness > 100) { + var oldWhiteness = whiteness; + whiteness = whiteness / (whiteness + blackness) * 100; + blackness = blackness / (oldWhiteness + blackness) * 100; + } + + return SassColor.hwb( + channel0.andThen((channel0) => _angleValue(channel0, 'hue')), + whiteness, + blackness, + alpha); + + case ColorSpace.rgb: + return SassColor.rgbInternal( + _channelFromValue(space.channels[0], channel0, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), + alpha, + fromRgbFunction ? ColorFormat.rgbFunction : null); + + default: + return SassColor.forSpaceInternal( + space, + _channelFromValue(space.channels[0], channel0, clamp: clamp), + _channelFromValue(space.channels[1], channel1, clamp: clamp), + _channelFromValue(space.channels[2], channel2, clamp: clamp), + alpha); + } +} + +/// Returns [number] with unit `'%'` regardless of its original unit. +SassNumber? _forcePercent(SassNumber? number) => switch (number) { + null => null, + SassNumber(numeratorUnits: ['%'], denominatorUnits: []) => number, + _ => SassNumber(number.value, '%') + }; + +/// Converts a channel value from a [SassNumber] into a [double] according to +/// [channel]. +/// +/// If [clamp] is true, this clamps [value] according to [channel]'s clamping +/// rules. +double? _channelFromValue(ColorChannel channel, SassNumber? value, + {bool clamp = true}) => + value.andThen((value) => switch (channel) { + LinearChannel(requiresPercent: true) when !value.hasUnit('%') => + throw SassScriptException( + 'Expected $value to have unit "%".', channel.name), + LinearChannel(lowerClamped: false, upperClamped: false) => + _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel() when !clamp => + _percentageOrUnitless(value, channel.max, channel.name), + LinearChannel(:var lowerClamped, :var upperClamped) => clampLikeCss( + _percentageOrUnitless(value, channel.max, channel.name), + lowerClamped ? channel.min : double.negativeInfinity, + upperClamped ? channel.max : double.infinity), + _ => value.coerceValueToUnit('deg', channel.name) % 360 + }); + +/// Returns whether [value] is an unquoted string case-insensitively equal to +/// "none". +bool _isNone(Value value) => + value is SassString && + !value.hasQuotes && + value.text.toLowerCase() == 'none'; + +/// Returns the implementation of a deprecated function that returns the value +/// of the channel named [name], implemented with [getter]. +/// +/// If [unit] is passed, the channel is returned with that unit. The [global] +/// parameter indicates whether this was called using the legacy global syntax. +BuiltInCallable _channelFunction( + String name, ColorSpace space, num Function(SassColor color) getter, + {String? unit, bool global = false}) { + return _function(name, r"$color", (arguments) { + var result = SassNumber(getter(arguments.first.assertColor("color")), unit); + + warnForDeprecation( + "${global ? '' : 'color.'}$name() is deprecated. Suggestion:\n" + "\n" + 'color.channel(\$color, "$name", \$space: $space)\n' + "\n" + "More info: https://sass-lang.com/d/color-functions", + Deprecation.colorFunctions); + + return result; + }); +} + +/// Returns suggested translations for deprecated color modification functions +/// in terms of both `color.scale()` and `color.adjust()`. +/// +/// [original] is the color that was passed in, [adjustment] is the requested +/// change, and [channelName] is the name of the modified channel. +String _suggestScaleAndAdjust( + SassColor original, double adjustment, String channelName) { + assert(original.isLegacy); + var channel = channelName == 'alpha' + ? ColorChannel.alpha + : ColorSpace.hsl.channels + .firstWhere((channel) => channel.name == channelName) + as LinearChannel; + + var oldValue = channel == ColorChannel.alpha + ? original.alpha + : original.toSpace(ColorSpace.hsl).channel(channelName); + var newValue = oldValue + adjustment; + + var suggestion = "Suggestion"; + if (adjustment != 0) { + late double factor; + if (newValue > channel.max) { + factor = 1; + } else if (newValue < channel.min) { + factor = -1; + } else if (adjustment > 0) { + factor = adjustment / (channel.max - oldValue); + } else { + factor = (newValue - oldValue) / (oldValue - channel.min); + } + var factorNumber = SassNumber(factor * 100, '%'); + suggestion += "s:\n" + "\n" + "color.scale(\$color, \$$channelName: $factorNumber)\n"; + } else { + suggestion += ":\n\n"; + } + + var difference = + SassNumber(adjustment, channel == ColorChannel.alpha ? null : '%'); + return suggestion + "color.adjust(\$color, \$$channelName: $difference)"; +} + +/// Throws an error indicating that a missing channel named [name] can't be +/// modified. +Never _missingChannelError(SassColor color, String channel) => + throw SassScriptException( + "Because the CSS working group is still deciding on the best behavior, " + "Sass doesn't currently support modifying missing channels (color: " + "$color).", + channel); + +/// Asserts that `value` is an unquoted string and throws an error if it's not. +/// +/// Assumes that `value` comes from a parameter named `$channel`. +String _channelName(Value value) => + (value.assertString("channel")..assertQuoted("channel")).text; + /// Like [BuiltInCallable.function], but always sets the URL to /// `sass:color`. BuiltInCallable _function( diff --git a/lib/src/functions/list.dart b/lib/src/functions/list.dart index 1d911a88d..c0fd0ac2c 100644 --- a/lib/src/functions/list.dart +++ b/lib/src/functions/list.dart @@ -13,8 +13,15 @@ import '../value.dart'; /// The global definitions of Sass list functions. final global = UnmodifiableListView([ - _length, _nth, _setNth, _join, _append, _zip, _index, _isBracketed, // - _separator.withName("list-separator") + _length.withDeprecationWarning('list'), + _nth.withDeprecationWarning('list'), + _setNth.withDeprecationWarning('list'), + _join.withDeprecationWarning('list'), + _append.withDeprecationWarning('list'), + _zip.withDeprecationWarning('list'), + _index.withDeprecationWarning('list'), + _isBracketed.withDeprecationWarning('list'), + _separator.withDeprecationWarning('list').withName("list-separator") ]); /// The Sass list module. diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart index 97628c1fd..099f20a72 100644 --- a/lib/src/functions/map.dart +++ b/lib/src/functions/map.dart @@ -15,12 +15,12 @@ import '../value.dart'; /// The global definitions of Sass map functions. final global = UnmodifiableListView([ - _get.withName("map-get"), - _merge.withName("map-merge"), - _remove.withName("map-remove"), - _keys.withName("map-keys"), - _values.withName("map-values"), - _hasKey.withName("map-has-key") + _get.withDeprecationWarning('map').withName("map-get"), + _merge.withDeprecationWarning('map').withName("map-merge"), + _remove.withDeprecationWarning('map').withName("map-remove"), + _keys.withDeprecationWarning('map').withName("map-keys"), + _values.withDeprecationWarning('map').withName("map-values"), + _hasKey.withDeprecationWarning('map').withName("map-has-key") ]); /// The Sass map module. @@ -61,7 +61,15 @@ final _set = BuiltInCallable.overloadedFunction("set", { throw SassScriptException("Expected \$args to contain a value."); case [...var keys, var value]: return _modify(map, keys, (_) => value); - default: + default: // ignore: unreachable_switch_default + // This code is unreachable, and the compiler knows it (hence the + // `unreachable_switch_default` warning being ignored above). However, + // due to architectural limitations in the Dart front end, the compiler + // doesn't understand that the code is unreachable until late in the + // compilation process (after flow analysis). So this `default` clause + // must be kept around to avoid flow analysis incorrectly concluding + // that the function fails to return. See + // https://github.com/dart-lang/language/issues/2977 for details. throw '[BUG] Unreachable code'; } }, @@ -87,7 +95,15 @@ final _merge = BuiltInCallable.overloadedFunction("merge", { if (nestedMap == null) return map2; return SassMap({...nestedMap.contents, ...map2.contents}); }); - default: + default: // ignore: unreachable_switch_default + // This code is unreachable, and the compiler knows it (hence the + // `unreachable_switch_default` warning being ignored above). However, + // due to architectural limitations in the Dart front end, the compiler + // doesn't understand that the code is unreachable until late in the + // compilation process (after flow analysis). So this `default` clause + // must be kept around to avoid flow analysis incorrectly concluding + // that the function fails to return. See + // https://github.com/dart-lang/language/issues/2977 for details. throw '[BUG] Unreachable code'; } }, diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart index bca609d0d..3f3e3bbbe 100644 --- a/lib/src/functions/math.dart +++ b/lib/src/functions/math.dart @@ -30,15 +30,23 @@ final global = UnmodifiableListView([ "To emit a CSS abs() now: abs(#{$number})\n" "More info: https://sass-lang.com/d/abs-percent", Deprecation.absPercent); + } else { + warnForGlobalBuiltIn('math', 'abs'); } return SassNumber.withUnits(number.value.abs(), numeratorUnits: number.numeratorUnits, denominatorUnits: number.denominatorUnits); }), - - _ceil, _floor, _max, _min, _percentage, _randomFunction, _round, _unit, // - _compatible.withName("comparable"), - _isUnitless.withName("unitless"), + _ceil.withDeprecationWarning('math'), + _floor.withDeprecationWarning('math'), + _max.withDeprecationWarning('math'), + _min.withDeprecationWarning('math'), + _percentage.withDeprecationWarning('math'), + _randomFunction.withDeprecationWarning('math'), + _round.withDeprecationWarning('math'), + _unit.withDeprecationWarning('math'), + _compatible.withDeprecationWarning('math').withName("comparable"), + _isUnitless.withDeprecationWarning('math').withName("unitless"), ]); /// The Sass math module. diff --git a/lib/src/functions/meta.dart b/lib/src/functions/meta.dart index 41537d55d..03cfe4c87 100644 --- a/lib/src/functions/meta.dart +++ b/lib/src/functions/meta.dart @@ -6,9 +6,13 @@ import 'dart:collection'; import 'package:collection/collection.dart'; +import '../ast/sass/statement/mixin_rule.dart'; import '../callable.dart'; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../util/map.dart'; import '../value.dart'; +import '../visitor/serialize.dart'; /// Feature names supported by Dart sass. final _features = { @@ -19,18 +23,29 @@ final _features = { "custom-property" }; -/// The global definitions of Sass introspection functions. -final global = UnmodifiableListView([ +/// Sass introspection functions that exist as both global functions and in the +/// `sass:meta` module that do not require access to context that's only +/// available at runtime. +/// +/// Additional functions are defined in the evaluator. +final _shared = UnmodifiableListView([ // This is only a partial list of meta functions. The rest are defined in the // evaluator, because they need access to context that's only available at // runtime. _function("feature-exists", r"$feature", (arguments) { + warnForDeprecation( + "The feature-exists() function is deprecated.\n\n" + "More info: https://sass-lang.com/d/feature-exists", + Deprecation.featureExists); var feature = arguments[0].assertString("feature"); return SassBoolean(_features.contains(feature.text)); }), - _function("inspect", r"$value", - (arguments) => SassString(arguments.first.toString(), quotes: false)), + _function( + "inspect", + r"$value", + (arguments) => SassString(serializeValue(arguments.first, inspect: true), + quotes: false)), _function( "type-of", @@ -45,12 +60,12 @@ final global = UnmodifiableListView([ sassNull => "null", SassNumber() => "number", SassFunction() => "function", + SassMixin() => "mixin", SassCalculation() => "calculation", SassString() => "string", _ => throw "[BUG] Unknown value type ${arguments[0]}" }, quotes: false)), - _function("keywords", r"$args", (arguments) { if (arguments[0] case SassArgumentList(:var keywords)) { return SassMap({ @@ -63,9 +78,14 @@ final global = UnmodifiableListView([ }) ]); -/// The definitions of Sass introspection functions that are only available from -/// the `sass:meta` module, not as global functions. -final local = UnmodifiableListView([ +/// The global definitions of Sass introspection functions. +final global = UnmodifiableListView( + [for (var function in _shared) function.withDeprecationWarning('meta')]); + +/// The versions of Sass introspection functions defined in the `sass:meta` +/// module. +final moduleFunctions = UnmodifiableListView([ + ..._shared, _function("calc-name", r"$calc", (arguments) { var calculation = arguments[0].assertCalculation("calc"); return SassString(calculation.name); @@ -77,6 +97,17 @@ final local = UnmodifiableListView([ ? argument : SassString(argument.toString(), quotes: false)), ListSeparator.comma); + }), + _function("accepts-content", r"$mixin", (arguments) { + var mixin = arguments[0].assertMixin("mixin"); + return SassBoolean(switch (mixin.callable) { + AsyncBuiltInCallable(acceptsContent: var acceptsContent) || + BuiltInCallable(acceptsContent: var acceptsContent) => + acceptsContent, + UserDefinedCallable(declaration: MixinRule(hasContent: var hasContent)) => + hasContent, + _ => throw UnsupportedError("Unknown callable type $mixin.") + }); }) ]); diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart index d6fddf9de..7a32af1a2 100644 --- a/lib/src/functions/selector.dart +++ b/lib/src/functions/selector.dart @@ -16,14 +16,14 @@ import '../value.dart'; /// The global definitions of Sass selector functions. final global = UnmodifiableListView([ - _isSuperselector, - _simpleSelectors, - _parse.withName("selector-parse"), - _nest.withName("selector-nest"), - _append.withName("selector-append"), - _extend.withName("selector-extend"), - _replace.withName("selector-replace"), - _unify.withName("selector-unify") + _isSuperselector.withDeprecationWarning('selector'), + _simpleSelectors.withDeprecationWarning('selector'), + _parse.withDeprecationWarning('selector').withName("selector-parse"), + _nest.withDeprecationWarning('selector').withName("selector-nest"), + _append.withDeprecationWarning('selector').withName("selector-append"), + _extend.withDeprecationWarning('selector').withName("selector-extend"), + _replace.withDeprecationWarning('selector').withName("selector-replace"), + _unify.withDeprecationWarning('selector').withName("selector-unify") ]); /// The Sass selector module. @@ -52,7 +52,7 @@ final _nest = _function("nest", r"$selectors...", (arguments) { first = false; return result; }) - .reduce((parent, child) => child.resolveParentSelectors(parent)) + .reduce((parent, child) => child.nestWithin(parent)) .asSassList; }); @@ -83,7 +83,7 @@ final _append = _function("append", r"$selectors...", (arguments) { ...rest ], span); }), span) - .resolveParentSelectors(parent); + .nestWithin(parent); }).asSassList; }); diff --git a/lib/src/functions/string.dart b/lib/src/functions/string.dart index 99d1ee6d2..100337de9 100644 --- a/lib/src/functions/string.dart +++ b/lib/src/functions/string.dart @@ -22,11 +22,15 @@ var _previousUniqueId = _random.nextInt(math.pow(36, 6) as int); /// The global definitions of Sass string functions. final global = UnmodifiableListView([ - _unquote, _quote, _toUpperCase, _toLowerCase, _uniqueId, // - _length.withName("str-length"), - _insert.withName("str-insert"), - _index.withName("str-index"), - _slice.withName("str-slice") + _unquote.withDeprecationWarning('string'), + _quote.withDeprecationWarning('string'), + _toUpperCase.withDeprecationWarning('string'), + _toLowerCase.withDeprecationWarning('string'), + _uniqueId.withDeprecationWarning('string'), + _length.withDeprecationWarning('string').withName("str-length"), + _insert.withDeprecationWarning('string').withName("str-insert"), + _index.withDeprecationWarning('string').withName("str-index"), + _slice.withDeprecationWarning('string').withName("str-slice") ]); /// The Sass string module. diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index bc526d7a3..e9c627fb1 100644 --- a/lib/src/import_cache.dart +++ b/lib/src/import_cache.dart @@ -5,22 +5,23 @@ // DO NOT EDIT. This file was generated from async_import_cache.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: ff52307a3bc93358ddc46f1e76120894fa3e071f +// Checksum: 4d09da97db4e59518d193f58963897d36ef4db00 // // ignore_for_file: unused_import +import 'package:cli_pkg/js.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; import 'package:path/path.dart' as p; import 'ast/sass.dart'; -import 'deprecation.dart'; import 'importer.dart'; +import 'importer/canonicalize_context.dart'; import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; -import 'logger.dart'; +import 'util/map.dart'; import 'util/nullable.dart'; import 'utils.dart'; @@ -37,37 +38,31 @@ final class ImportCache { /// The importers to use when loading new Sass files. final List _importers; - /// The logger to use to emit warnings when parsing stylesheets. - final Logger _logger; - /// The canonicalized URLs for each non-canonical URL. /// /// The `forImport` in each key is true when this canonicalization is for an /// `@import` rule. Otherwise, it's for a `@use` or `@forward` rule. /// - /// This cache isn't used for relative imports, because they depend on the - /// specific base importer. That's stored separately in - /// [_relativeCanonicalizeCache]. + /// This cache covers loads that go through the entire chain of [_importers], + /// but it doesn't cover individual loads or loads in which any importer + /// accesses `containingUrl`. See also [_perImporterCanonicalizeCache]. final _canonicalizeCache = <(Uri, {bool forImport}), CanonicalizeResult?>{}; - /// The canonicalized URLs for each non-canonical URL that's resolved using a - /// relative importer. - /// - /// The map's keys have four parts: + /// Like [_canonicalizeCache] but also includes the specific importer in the + /// key. /// - /// 1. The URL passed to [canonicalize] (the same as in [_canonicalizeCache]). - /// 2. Whether the canonicalization is for an `@import` rule. - /// 3. The `baseImporter` passed to [canonicalize]. - /// 4. The `baseUrl` passed to [canonicalize]. + /// This is used to cache both relative imports from the base importer and + /// individual importer results in the case where some other component of the + /// importer chain isn't cacheable. + final _perImporterCanonicalizeCache = + <(Importer, Uri, {bool forImport}), CanonicalizeResult?>{}; + + /// A map from the keys in [_perImporterCanonicalizeCache] that are generated + /// for relative URL loads against the base importer to the original relative + /// URLs what were loaded. /// - /// The map's values are the same as the return value of [canonicalize]. - final _relativeCanonicalizeCache = <( - Uri, { - bool forImport, - Importer baseImporter, - Uri? baseUrl - }), - CanonicalizeResult?>{}; + /// This is used to invalidate the cache when files are changed. + final _nonCanonicalRelativeUrls = <(Importer, Uri, {bool forImport}), Uri>{}; /// The parsed stylesheets for each canonicalized import URL. final _importCache = {}; @@ -95,15 +90,16 @@ final class ImportCache { ImportCache( {Iterable? importers, Iterable? loadPaths, - PackageConfig? packageConfig, - Logger? logger}) - : _importers = _toImporters(importers, loadPaths, packageConfig), - _logger = logger ?? const Logger.stderr(); + PackageConfig? packageConfig}) + : _importers = _toImporters(importers, loadPaths, packageConfig); /// Creates an import cache without any globally-available importers. - ImportCache.none({Logger? logger}) - : _importers = const [], - _logger = logger ?? const Logger.stderr(); + ImportCache.none() : _importers = const []; + + /// Creates an import cache without any globally-available importers, and only + /// the passed in importers. + ImportCache.only(Iterable importers) + : _importers = List.unmodifiable(importers); /// Converts the user's [importers], [loadPaths], and [packageConfig] /// options into a single list of importers. @@ -147,61 +143,103 @@ final class ImportCache { } if (baseImporter != null && url.scheme == '') { - var relativeResult = _relativeCanonicalizeCache.putIfAbsent( - ( - url, - forImport: forImport, - baseImporter: baseImporter, - baseUrl: baseUrl - ), - () => _canonicalize(baseImporter, baseUrl?.resolveUri(url) ?? url, - baseUrl, forImport)); + var resolvedUrl = baseUrl?.resolveUri(url) ?? url; + var key = (baseImporter, resolvedUrl, forImport: forImport); + var relativeResult = _perImporterCanonicalizeCache.putIfAbsent(key, () { + var (result, cacheable) = + _canonicalize(baseImporter, resolvedUrl, baseUrl, forImport); + assert( + cacheable, + "Relative loads should always be cacheable because they never " + "provide access to the containing URL."); + if (baseUrl != null) _nonCanonicalRelativeUrls[key] = url; + return result; + }); if (relativeResult != null) return relativeResult; } - return _canonicalizeCache.putIfAbsent((url, forImport: forImport), () { - for (var importer in _importers) { - if (_canonicalize(importer, url, baseUrl, forImport) case var result?) { + var key = (url, forImport: forImport); + if (_canonicalizeCache.containsKey(key)) return _canonicalizeCache[key]; + + // Each individual call to a `canonicalize()` override may not be cacheable + // (specifically, if it has access to `containingUrl` it's too + // context-sensitive to usefully cache). We want to cache a given URL across + // the _entire_ importer chain, so we use [cacheable] to track whether _all_ + // `canonicalize()` calls we've attempted are cacheable. Only if they are, do + // we store the result in the cache. + var cacheable = true; + for (var i = 0; i < _importers.length; i++) { + var importer = _importers[i]; + var perImporterKey = (importer, url, forImport: forImport); + switch (_perImporterCanonicalizeCache.getOption(perImporterKey)) { + case (var result?,): return result; - } + case (null,): + continue; } - return null; - }); + switch (_canonicalize(importer, url, baseUrl, forImport)) { + case (var result?, true) when cacheable: + _canonicalizeCache[key] = result; + return result; + + case (var result, true) when !cacheable: + _perImporterCanonicalizeCache[perImporterKey] = result; + if (result != null) return result; + + case (var result, false): + if (cacheable) { + // If this is the first uncacheable result, add all previous results + // to the per-importer cache so we don't have to re-run them for + // future uses of this importer. + for (var j = 0; j < i; j++) { + _perImporterCanonicalizeCache[( + _importers[j], + url, + forImport: forImport + )] = null; + } + cacheable = false; + } + + if (result != null) return result; + } + } + + if (cacheable) _canonicalizeCache[key] = null; + return null; } /// Calls [importer.canonicalize] and prints a deprecation warning if it /// returns a relative URL. /// - /// If [resolveUrl] is `true`, this resolves [url] relative to [baseUrl] - /// before passing it to [importer]. - CanonicalizeResult? _canonicalize( - Importer importer, Uri url, Uri? baseUrl, bool forImport, - {bool resolveUrl = false}) { - var resolved = - resolveUrl && baseUrl != null ? baseUrl.resolveUri(url) : url; - var canonicalize = forImport - ? () => inImportRule(() => importer.canonicalize(resolved)) - : () => importer.canonicalize(resolved); - + /// This returns both the result of the call to `canonicalize()` and whether + /// that result is cacheable at all. + (CanonicalizeResult?, bool cacheable) _canonicalize( + Importer importer, Uri url, Uri? baseUrl, bool forImport) { var passContainingUrl = baseUrl != null && (url.scheme == '' || importer.isNonCanonicalScheme(url.scheme)); - var result = - withContainingUrl(passContainingUrl ? baseUrl : null, canonicalize); - if (result == null) return null; - - if (result.scheme == '') { - _logger.warnForDeprecation( - Deprecation.relativeCanonical, - "Importer $importer canonicalized $resolved to $result.\n" - "Relative canonical URLs are deprecated and will eventually be " - "disallowed."); - } else if (importer.isNonCanonicalScheme(result.scheme)) { - throw "Importer $importer canonicalized $resolved to $result, which " - "uses a scheme declared as non-canonical."; + + var canonicalizeContext = + CanonicalizeContext(passContainingUrl ? baseUrl : null, forImport); + + var result = withCanonicalizeContext( + canonicalizeContext, () => importer.canonicalize(url)); + + var cacheable = + !passContainingUrl || !canonicalizeContext.wasContainingUrlAccessed; + + if (result == null) return (null, cacheable); + + // Relative canonical URLs (empty scheme) should throw an error starting in + // Dart Sass 2.0.0, but for now, they only emit a deprecation warning in + // the evaluator. + if (result.scheme != '' && importer.isNonCanonicalScheme(result.scheme)) { + throw "Importer $importer canonicalized $url to $result, which uses a " + "scheme declared as non-canonical."; } - return (importer, result, originalUrl: resolved); + return ((importer, result, originalUrl: url), cacheable); } /// Tries to import [url] using one of this cache's importers. @@ -234,12 +272,9 @@ final class ImportCache { /// into [canonicalUrl]. It's used to resolve a relative canonical URL, which /// importers may return for legacy reasons. /// - /// If [quiet] is `true`, this will disable logging warnings when parsing the - /// newly imported stylesheet. - /// /// Caches the result of the import and uses cached results if possible. Stylesheet? importCanonical(Importer importer, Uri canonicalUrl, - {Uri? originalUrl, bool quiet = false}) { + {Uri? originalUrl}) { return _importCache.putIfAbsent(canonicalUrl, () { var result = importer.load(canonicalUrl); if (result == null) return null; @@ -250,8 +285,7 @@ final class ImportCache { // relative to [originalUrl]. url: originalUrl == null ? canonicalUrl - : originalUrl.resolveUri(canonicalUrl), - logger: quiet ? Logger.quiet : _logger); + : originalUrl.resolveUri(canonicalUrl)); }); } @@ -262,8 +296,7 @@ final class ImportCache { // If multiple original URLs canonicalize to the same thing, choose the // shortest one. minBy( - _canonicalizeCache.values - .whereNotNull() + _canonicalizeCache.values.nonNulls .where((result) => result.$2 == canonicalUrl) .map((result) => result.originalUrl), (url) => url.path.length) @@ -281,7 +314,7 @@ final class ImportCache { Uri sourceMapUrl(Uri canonicalUrl) => _resultsCache[canonicalUrl]?.sourceMapUrl ?? canonicalUrl; - /// Clears the cached canonical version of the given [url]. + /// Clears the cached canonical version of the given non-canonical [url]. /// /// Has no effect if the canonical version of [url] has not been cached. /// @@ -290,7 +323,8 @@ final class ImportCache { void clearCanonicalize(Uri url) { _canonicalizeCache.remove((url, forImport: false)); _canonicalizeCache.remove((url, forImport: true)); - _relativeCanonicalizeCache.removeWhere((key, _) => key.$1 == url); + _perImporterCanonicalizeCache.removeWhere( + (key, _) => key.$2 == url || _nonCanonicalRelativeUrls[key] == url); } /// Clears the cached parse tree for the stylesheet with the given diff --git a/lib/src/importer/async.dart b/lib/src/importer/async.dart index d7d6951fe..d777d84f5 100644 --- a/lib/src/importer/async.dart +++ b/lib/src/importer/async.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'canonicalize_context.dart'; import 'result.dart'; import 'utils.dart' as utils; @@ -54,7 +55,19 @@ abstract class AsyncImporter { /// Outside of that context, its value is undefined and subject to change. @protected @nonVirtual - Uri? get containingUrl => utils.containingUrl; + Uri? get containingUrl => utils.canonicalizeContext.containingUrl; + + /// The canonicalize context of the stylesheet that caused the current + /// [canonicalize] invocation. + /// + /// Subclasses should only access this from within calls to [canonicalize]. + /// Outside of that context, its value is undefined and subject to change. + /// + /// @nodoc + @internal + @protected + @nonVirtual + CanonicalizeContext get canonicalizeContext => utils.canonicalizeContext; /// If [url] is recognized by this importer, returns its canonical format. /// diff --git a/lib/src/importer/canonicalize_context.dart b/lib/src/importer/canonicalize_context.dart new file mode 100644 index 000000000..e28e69e8d --- /dev/null +++ b/lib/src/importer/canonicalize_context.dart @@ -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 'dart:async'; + +import 'package:meta/meta.dart'; + +/// Contextual information used by importers' `canonicalize` method. +@internal +final class CanonicalizeContext { + /// Whether the Sass compiler is currently evaluating an `@import` rule. + bool get fromImport => _fromImport; + bool _fromImport; + + /// The URL of the stylesheet that contains the current load. + Uri? get containingUrl { + _wasContainingUrlAccessed = true; + return _containingUrl; + } + + final Uri? _containingUrl; + + /// Returns the same value as [containingUrl], but doesn't mark it accessed. + Uri? get containingUrlWithoutMarking => _containingUrl; + + /// Whether [containingUrl] has been accessed. + /// + /// This is used to determine whether canonicalize result is cacheable. + bool get wasContainingUrlAccessed => _wasContainingUrlAccessed; + var _wasContainingUrlAccessed = false; + + /// Runs [callback] in a context with specificed [fromImport]. + T withFromImport(bool fromImport, T callback()) { + assert(Zone.current[#_canonicalizeContext] == this); + + var oldFromImport = _fromImport; + _fromImport = fromImport; + try { + return callback(); + } finally { + _fromImport = oldFromImport; + } + } + + CanonicalizeContext(this._containingUrl, this._fromImport); +} diff --git a/lib/src/importer/filesystem.dart b/lib/src/importer/filesystem.dart index 31af69829..0c57c82d8 100644 --- a/lib/src/importer/filesystem.dart +++ b/lib/src/importer/filesystem.dart @@ -5,27 +5,86 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; +import '../deprecation.dart'; +import '../evaluation_context.dart'; import '../importer.dart'; import '../io.dart' as io; import '../syntax.dart'; import '../util/nullable.dart'; import 'utils.dart'; -/// An importer that loads files from a load path on the filesystem. +/// An importer that loads files from a load path on the filesystem, either +/// relative to the path passed to [FilesystemImporter.new] or absolute `file:` +/// URLs. +/// +/// Use [FilesystemImporter.noLoadPath] to _only_ load absolute `file:` URLs and +/// URLs relative to the current file. /// /// {@category Importer} @sealed class FilesystemImporter extends Importer { /// The path relative to which this importer looks for files. - final String _loadPath; + /// + /// If this is `null`, this importer will _only_ load absolute `file:` URLs + /// and URLs relative to the current file. + final String? _loadPath; + + /// Whether loading from files from this importer's [_loadPath] is deprecated. + final bool _loadPathDeprecated; /// Creates an importer that loads files relative to [loadPath]. - FilesystemImporter(String loadPath) : _loadPath = p.absolute(loadPath); + FilesystemImporter(String loadPath) + : _loadPath = p.absolute(loadPath), + _loadPathDeprecated = false; + + FilesystemImporter._deprecated(String loadPath) + : _loadPath = p.absolute(loadPath), + _loadPathDeprecated = true; + + /// Creates an importer that _only_ loads absolute `file:` URLs and URLs + /// relative to the current file. + FilesystemImporter._noLoadPath() + : _loadPath = null, + _loadPathDeprecated = false; + + /// A [FilesystemImporter] that loads files relative to the current working + /// directory. + /// + /// Historically, this was the best default for supporting `file:` URL loads + /// when the load path didn't matter. However, adding the current working + /// directory to the load path wasn't always desirable, so it's no longer + /// recommended. Instead, either use [FilesystemImporter.noLoadPath] if the + /// load path doesn't matter, or `FilesystemImporter('.')` to explicitly + /// preserve the existing behavior. + @Deprecated( + "Use FilesystemImporter.noLoadPath or FilesystemImporter('.') instead.") + static final cwd = FilesystemImporter._deprecated('.'); + + /// Creates an importer that _only_ loads absolute `file:` URLs and URLs + /// relative to the current file. + static final noLoadPath = FilesystemImporter._noLoadPath(); Uri? canonicalize(Uri url) { - if (url.scheme != 'file' && url.scheme != '') return null; - return resolveImportPath(p.join(_loadPath, p.fromUri(url))) - .andThen((resolved) => p.toUri(io.canonicalize(resolved))); + String? resolved; + if (url.scheme == 'file') { + resolved = resolveImportPath(p.fromUri(url)); + } else if (url.scheme != '') { + return null; + } else if (_loadPath case var loadPath?) { + resolved = resolveImportPath(p.join(loadPath, p.fromUri(url))); + + if (resolved != null && _loadPathDeprecated) { + warnForDeprecation( + "Using the current working directory as an implicit load path is " + "deprecated. Either add it as an explicit load path or importer, or " + "load this stylesheet from a different URL.", + Deprecation.fsImporterCwd); + } + } else { + return null; + } + + return resolved.andThen((resolved) => p.toUri(io.canonicalize(resolved))); } ImporterResult? load(Uri url) { @@ -50,5 +109,5 @@ class FilesystemImporter extends Importer { basename == p.url.withoutExtension(canonicalBasename); } - String toString() => _loadPath; + String toString() => _loadPath ?? ''; } diff --git a/lib/src/importer/js_to_dart/async.dart b/lib/src/importer/js_to_dart/async.dart index 11ffbd735..c6d26cbe2 100644 --- a/lib/src/importer/js_to_dart/async.dart +++ b/lib/src/importer/js_to_dart/async.dart @@ -13,6 +13,7 @@ import '../../js/url.dart'; import '../../js/utils.dart'; import '../../util/nullable.dart'; import '../async.dart'; +import '../canonicalize_context.dart'; import '../result.dart'; import 'utils.dart'; @@ -38,11 +39,8 @@ final class JSToDartAsyncImporter extends AsyncImporter { } FutureOr canonicalize(Uri url) async { - var result = wrapJSExceptions(() => _canonicalize( - url.toString(), - CanonicalizeContext( - fromImport: fromImport, - containingUrl: containingUrl.andThen(dartToJSUrl)))); + var result = wrapJSExceptions( + () => _canonicalize(url.toString(), canonicalizeContext)); if (isPromise(result)) result = await promiseToFuture(result as Promise); if (result == null) return null; diff --git a/lib/src/importer/js_to_dart/async_file.dart b/lib/src/importer/js_to_dart/async_file.dart index e984531dc..95b2af908 100644 --- a/lib/src/importer/js_to_dart/async_file.dart +++ b/lib/src/importer/js_to_dart/async_file.dart @@ -8,21 +8,14 @@ import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart'; -import '../../js/importer.dart'; import '../../js/url.dart'; import '../../js/utils.dart'; -import '../../util/nullable.dart'; import '../async.dart'; +import '../canonicalize_context.dart'; import '../filesystem.dart'; import '../result.dart'; import '../utils.dart'; -/// A filesystem importer to use for most implementation details of -/// [JSToDartAsyncFileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// A wrapper for a potentially-asynchronous JS API file importer that exposes /// it as a Dart [AsyncImporter]. final class JSToDartAsyncFileImporter extends AsyncImporter { @@ -32,13 +25,10 @@ final class JSToDartAsyncFileImporter extends AsyncImporter { JSToDartAsyncFileImporter(this._findFileUrl); FutureOr canonicalize(Uri url) async { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); - var result = wrapJSExceptions(() => _findFileUrl( - url.toString(), - CanonicalizeContext( - fromImport: fromImport, - containingUrl: containingUrl.andThen(dartToJSUrl)))); + var result = wrapJSExceptions( + () => _findFileUrl(url.toString(), canonicalizeContext)); if (isPromise(result)) result = await promiseToFuture(result as Promise); if (result == null) return null; if (!isJSUrl(result)) { @@ -52,16 +42,16 @@ final class JSToDartAsyncFileImporter extends AsyncImporter { '"$url".')); } - return _filesystemImporter.canonicalize(resultUrl); + return FilesystemImporter.cwd.canonicalize(resultUrl); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => - _filesystemImporter.couldCanonicalize(url, canonicalUrl); + FilesystemImporter.cwd.couldCanonicalize(url, canonicalUrl); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; } diff --git a/lib/src/importer/js_to_dart/file.dart b/lib/src/importer/js_to_dart/file.dart index 9ad474d00..555c9df16 100644 --- a/lib/src/importer/js_to_dart/file.dart +++ b/lib/src/importer/js_to_dart/file.dart @@ -6,18 +6,11 @@ import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import '../../importer.dart'; -import '../../js/importer.dart'; import '../../js/url.dart'; import '../../js/utils.dart'; -import '../../util/nullable.dart'; +import '../canonicalize_context.dart'; import '../utils.dart'; -/// A filesystem importer to use for most implementation details of -/// [JSToDartAsyncFileImporter]. -/// -/// This allows us to avoid duplicating logic between the two importers. -final _filesystemImporter = FilesystemImporter('.'); - /// A wrapper for a potentially-asynchronous JS API file importer that exposes /// it as a Dart [AsyncImporter]. final class JSToDartFileImporter extends Importer { @@ -27,13 +20,10 @@ final class JSToDartFileImporter extends Importer { JSToDartFileImporter(this._findFileUrl); Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); - var result = wrapJSExceptions(() => _findFileUrl( - url.toString(), - CanonicalizeContext( - fromImport: fromImport, - containingUrl: containingUrl.andThen(dartToJSUrl)))); + var result = wrapJSExceptions( + () => _findFileUrl(url.toString(), canonicalizeContext)); if (result == null) return null; if (isPromise(result)) { @@ -51,16 +41,16 @@ final class JSToDartFileImporter extends Importer { '"$url".')); } - return _filesystemImporter.canonicalize(resultUrl); + return FilesystemImporter.cwd.canonicalize(resultUrl); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => - _filesystemImporter.couldCanonicalize(url, canonicalUrl); + FilesystemImporter.cwd.couldCanonicalize(url, canonicalUrl); bool isNonCanonicalScheme(String scheme) => scheme != 'file'; } diff --git a/lib/src/importer/js_to_dart/sync.dart b/lib/src/importer/js_to_dart/sync.dart index f69dafb35..06b91310a 100644 --- a/lib/src/importer/js_to_dart/sync.dart +++ b/lib/src/importer/js_to_dart/sync.dart @@ -10,6 +10,7 @@ import '../../js/importer.dart'; import '../../js/url.dart'; import '../../js/utils.dart'; import '../../util/nullable.dart'; +import '../canonicalize_context.dart'; import 'utils.dart'; /// A wrapper for a synchronous JS API importer that exposes it as a Dart @@ -34,11 +35,8 @@ final class JSToDartImporter extends Importer { } Uri? canonicalize(Uri url) { - var result = wrapJSExceptions(() => _canonicalize( - url.toString(), - CanonicalizeContext( - fromImport: fromImport, - containingUrl: containingUrl.andThen(dartToJSUrl)))); + var result = wrapJSExceptions( + () => _canonicalize(url.toString(), canonicalizeContext)); if (result == null) return null; if (isJSUrl(result)) return jsToDartUrl(result as JSUrl); diff --git a/lib/src/importer/node_package.dart b/lib/src/importer/node_package.dart new file mode 100644 index 000000000..1d142fa4d --- /dev/null +++ b/lib/src/importer/node_package.dart @@ -0,0 +1,383 @@ +// 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:cli_pkg/js.dart'; +import 'package:sass/src/util/map.dart'; +import 'package:sass/src/util/nullable.dart'; + +import '../importer.dart'; +import './utils.dart'; +import 'dart:convert'; +import '../io.dart'; +import 'package:path/path.dart' as p; + +/// An [Importer] that resolves `pkg:` URLs using the Node resolution algorithm. +class NodePackageImporter extends Importer { + /// The starting path for canonicalizations without a containing URL. + late final String _entryPointDirectory; + + /// Creates a Node package importer with the associated entry point. + NodePackageImporter(String entryPointDirectory) { + if (isBrowser) { + throw "The Node package importer cannot be used without a filesystem."; + } + _entryPointDirectory = p.absolute(entryPointDirectory); + } + + @override + bool isNonCanonicalScheme(String scheme) => scheme == 'pkg'; + + @override + Uri? canonicalize(Uri url) { + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); + if (url.scheme != 'pkg') return null; + + if (url.hasAuthority) { + throw "A pkg: URL must not have a host, port, username or password."; + } else if (p.url.isAbsolute(url.path)) { + throw "A pkg: URL's path must not begin with /."; + } else if (url.path.isEmpty) { + throw "A pkg: URL must not have an empty path."; + } else if (url.hasQuery || url.hasFragment) { + throw "A pkg: URL must not have a query or fragment."; + } + + var baseDirectory = containingUrl?.scheme == 'file' + ? p.dirname(p.fromUri(containingUrl!)) + : _entryPointDirectory; + + var (packageName, subpath) = _packageNameAndSubpath(url.path); + + // If the package name is not a valid Node package name, return null in case + // another importer can handle. + if (packageName.startsWith('.') || + packageName.contains('\\') || + packageName.contains('%') || + (packageName.startsWith('@') && + !packageName.contains(p.url.separator))) { + return null; + } + + var packageRoot = _resolvePackageRoot(packageName, baseDirectory); + + if (packageRoot == null) return null; + var jsonPath = p.join(packageRoot, 'package.json'); + + var jsonString = readFile(jsonPath); + Map packageManifest; + try { + packageManifest = json.decode(jsonString) as Map; + } catch (e) { + throw "Failed to parse $jsonPath for \"pkg:$packageName\": $e"; + } + + if (_resolvePackageExports( + packageRoot, subpath, packageManifest, packageName) + case var resolved?) { + if (_validExtensions.contains(p.extension(resolved))) { + return p.toUri(p.canonicalize(resolved)); + } else { + throw "The export for '${subpath ?? "root"}' in " + "'$packageName' resolved to '${resolved.toString()}', " + "which is not a '.scss', '.sass', or '.css' file."; + } + } + // If no subpath, attempt to resolve `sass` or `style` key in package.json, + // then `index` file at package root, resolved for file extensions and + // partials. + if (subpath == null) { + var rootPath = _resolvePackageRootValues(packageRoot, packageManifest); + return rootPath != null ? p.toUri(p.canonicalize(rootPath)) : null; + } + + // If there is a subpath, attempt to resolve the path relative to the + // package root, and resolve for file extensions and partials. + var subpathInRoot = p.join(packageRoot, subpath); + return FilesystemImporter.cwd.canonicalize(p.toUri(subpathInRoot)); + } + + @override + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); + + /// Splits a [bare import + /// specifier](https://nodejs.org/api/esm.html#import-specifiers) `specifier` + /// into its package name and subpath, if one exists. + /// + /// Because this is a bare import specifier and not a path, we always use `/` + /// to avoid invalid values on non-Posix machines. + (String, String?) _packageNameAndSubpath(String specifier) { + var parts = p.url.split(specifier); + var name = p.fromUri(parts.removeAt(0)); + + if (name.startsWith('@')) { + if (parts.isNotEmpty) name = p.url.join(name, parts.removeAt(0)); + } + var subpath = parts.isNotEmpty ? p.fromUri(p.url.joinAll(parts)) : null; + return (name, subpath); + } + + /// Returns an absolute path to the root directory for the most proximate + /// installed `packageName`. + /// + /// Implementation of `PACKAGE_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _resolvePackageRoot(String packageName, String baseDirectory) { + while (true) { + var potentialPackage = p.join(baseDirectory, 'node_modules', packageName); + if (dirExists(potentialPackage)) return potentialPackage; + // baseDirectory has now reached root without finding a match. + if (p.split(baseDirectory).length == 1) return null; + baseDirectory = p.dirname(baseDirectory); + } + } + + /// Returns a file path specified by the `sass` or `style` values in a package + /// manifest, or an `index` file relative to the package root. + String? _resolvePackageRootValues( + String packageRoot, Map packageManifest) { + if (packageManifest['sass'] case String sassValue + when _validExtensions.contains(p.url.extension(sassValue))) { + return p.join(packageRoot, sassValue); + } else if (packageManifest['style'] case String styleValue + when _validExtensions.contains(p.url.extension(styleValue))) { + return p.join(packageRoot, styleValue); + } + + var result = resolveImportPath(p.join(packageRoot, 'index')); + return result; + } + + /// Returns a file path specified by a `subpath` in the `exports` section of + /// package.json. + /// + /// `packageName` is used for error reporting. + String? _resolvePackageExports(String packageRoot, String? subpath, + Map packageManifest, String packageName) { + var exports = packageManifest['exports'] as Object?; + if (exports == null) return null; + var subpathVariants = _exportsToCheck(subpath); + if (_nodePackageExportsResolve( + packageRoot, subpathVariants, exports, subpath, packageName) + case var path?) { + return path; + } + + if (subpath != null && p.url.extension(subpath).isNotEmpty) return null; + + var subpathIndexVariants = _exportsToCheck(subpath, addIndex: true); + if (_nodePackageExportsResolve( + packageRoot, subpathIndexVariants, exports, subpath, packageName) + case var path?) { + return path; + } + + return null; + } + + /// Returns the path to one subpath variant, resolved in the `exports` of a + /// package manifest. + /// + /// Throws an error if multiple `subpathVariants` match, and null if none + /// match. + /// + /// Implementation of `PACKAGE_EXPORTS_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _nodePackageExportsResolve( + String packageRoot, + List subpathVariants, + Object exports, + String? subpath, + String packageName) { + if (exports is Map && + exports.keys.any((key) => key.startsWith('.')) && + exports.keys.any((key) => !key.startsWith('.'))) { + throw '`exports` in $packageName can not have both conditions and paths ' + 'at the same level.\n' + 'Found ${exports.keys.map((key) => '"$key"').join(',')} in ' + '${p.join(packageRoot, 'package.json')}.'; + } + + var matches = subpathVariants + .map((String? variant) { + if (variant == null) { + return _getMainExport(exports).andThen((mainExport) => + _packageTargetResolve(variant, mainExport, packageRoot)); + } else if (exports is! Map || + exports.keys.every((key) => !key.startsWith('.'))) { + return null; + } + var matchKey = "./${p.toUri(variant)}"; + if (exports.containsKey(matchKey) && + exports[matchKey] != null && + !matchKey.contains('*')) { + return _packageTargetResolve( + matchKey, exports[matchKey] as Object, packageRoot); + } + + var expansionKeys = [ + for (var key in exports.keys) + if ('*'.allMatches(key).length == 1) key + ]..sort(_compareExpansionKeys); + + for (var expansionKey in expansionKeys) { + var [patternBase, patternTrailer] = expansionKey.split('*'); + if (!matchKey.startsWith(patternBase)) continue; + if (matchKey == patternBase) continue; + if (patternTrailer.isEmpty || + (matchKey.endsWith(patternTrailer) && + matchKey.length >= expansionKey.length)) { + var target = exports[expansionKey] as Object?; + if (target == null) continue; + var patternMatch = matchKey.substring( + patternBase.length, matchKey.length - patternTrailer.length); + return _packageTargetResolve( + variant, target, packageRoot, patternMatch); + } + } + + return null; + }) + .nonNulls + .toList(); + + return switch (matches) { + [var path] => path, + [] => null, + var paths => + throw "Unable to determine which of multiple potential resolutions " + "found for ${subpath ?? 'root'} in $packageName should be used. " + "\n\nFound:\n" + "${paths.join('\n')}" + }; + } + + /// Implementation of the `PATTERN_KEY_COMPARE` comparator from + /// https://nodejs.org/api/esm.html#resolution-algorithm-specification. + int _compareExpansionKeys(String keyA, String keyB) { + var baseLengthA = keyA.contains('*') ? keyA.indexOf('*') + 1 : keyA.length; + var baseLengthB = keyB.contains('*') ? keyB.indexOf('*') + 1 : keyB.length; + if (baseLengthA > baseLengthB) return -1; + if (baseLengthB > baseLengthA) return 1; + if (!keyA.contains("*")) return 1; + if (!keyB.contains("*")) return -1; + if (keyA.length > keyB.length) return -1; + if (keyB.length > keyA.length) return 1; + return 0; + } + + /// Returns a file path for `subpath`, as resolved in the `exports` object. + /// + /// Verifies the file exists relative to `packageRoot`. Instances of `*` will + /// be replaced with `patternMatch`. + /// + /// `subpath` and `packageRoot` are native paths, and `patternMatch` is a URL + /// path. + /// + /// Implementation of `PACKAGE_TARGET_RESOLVE` from the [Resolution Algorithm + /// Specification](https://nodejs.org/api/esm.html#resolution-algorithm-specification). + String? _packageTargetResolve( + String? subpath, Object exports, String packageRoot, + [String? patternMatch]) { + switch (exports) { + case String string when !string.startsWith('./'): + throw "Export '$string' must be a path relative to the package root at '$packageRoot'."; + case String string when patternMatch != null: + var replaced = p.fromUri(string.replaceFirst('*', patternMatch)); + var path = p.normalize(p.join(packageRoot, replaced)); + return fileExists(path) ? path : null; + case String string: + return p.join(packageRoot, p.fromUri(string)); + case Map map: + for (var (key, value) in map.pairs) { + if (!const {'sass', 'style', 'default'}.contains(key)) continue; + if (value == null) continue; + if (_packageTargetResolve( + subpath, value as Object, packageRoot, patternMatch) + case var result?) { + return result; + } + } + return null; + + case []: + return null; + + case List array: + for (var value in array) { + if (value == null) continue; + if (_packageTargetResolve( + subpath, value as Object, packageRoot, patternMatch) + case var result?) { + return result; + } + } + + return null; + + default: + throw "Invalid 'exports' value $exports in " + "${p.join(packageRoot, 'package.json')}."; + } + } + + /// Returns a path to a package's export without a subpath. + Object? _getMainExport(Object exports) { + return switch (exports) { + String string => string, + List list => list, + Map map + when !map.keys.any((key) => key.startsWith('.')) => + map, + {'.': var export?} => export, + _ => null + }; + } + + /// Returns a list of all possible variations of `subpath` with extensions and + /// partials. + /// + /// If there is no subpath, returns a single `null` value, which is used in + /// `_nodePackageExportsResolve` to denote the main package export. + List _exportsToCheck(String? subpath, {bool addIndex = false}) { + var paths = []; + + if (subpath == null && addIndex) { + subpath = 'index'; + } else if (subpath != null && addIndex) { + subpath = p.join(subpath, 'index'); + } + if (subpath == null) return [null]; + + if (_validExtensions.contains(p.url.extension(subpath))) { + paths.add(subpath); + } else { + paths.addAll([ + subpath, + '$subpath.scss', + '$subpath.sass', + '$subpath.css', + ]); + } + var basename = p.basename(subpath); + var dirname = p.dirname(subpath); + + if (basename.startsWith('_')) return paths; + + return [ + ...paths, + for (var path in paths) + if (dirname == '.') + '_${p.basename(path)}' + else + p.join(dirname, '_${p.basename(path)}') + ]; + } +} + +/// The set of file extensions that Sass can parse. +/// +/// `NodePackageImporter` will only resolve files with these extensions, and +/// uses these extensions to check for matches if no extension is provided in +/// the Url to canonicalize. +const _validExtensions = {'.scss', '.sass', '.css'}; diff --git a/lib/src/importer/package.dart b/lib/src/importer/package.dart index 21f41509f..39d09ac63 100644 --- a/lib/src/importer/package.dart +++ b/lib/src/importer/package.dart @@ -7,12 +7,6 @@ import 'package:package_config/package_config_types.dart'; import '../importer.dart'; -/// A filesystem importer to use when resolving the results of `package:` URLs. -/// -/// This allows us to avoid duplicating the logic for choosing an extension and -/// looking for partials. -final _filesystemImporter = FilesystemImporter('.'); - /// An importer that loads stylesheets from `package:` imports. /// /// {@category Importer} @@ -29,7 +23,7 @@ class PackageImporter extends Importer { PackageImporter(PackageConfig packageConfig) : _packageConfig = packageConfig; Uri? canonicalize(Uri url) { - if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + if (url.scheme == 'file') return FilesystemImporter.cwd.canonicalize(url); if (url.scheme != 'package') return null; var resolved = _packageConfig.resolve(url); @@ -39,17 +33,18 @@ class PackageImporter extends Importer { throw "Unsupported URL $resolved."; } - return _filesystemImporter.canonicalize(resolved); + return FilesystemImporter.cwd.canonicalize(resolved); } - ImporterResult? load(Uri url) => _filesystemImporter.load(url); + ImporterResult? load(Uri url) => FilesystemImporter.cwd.load(url); DateTime modificationTime(Uri url) => - _filesystemImporter.modificationTime(url); + FilesystemImporter.cwd.modificationTime(url); bool couldCanonicalize(Uri url, Uri canonicalUrl) => (url.scheme == 'file' || url.scheme == 'package' || url.scheme == '') && - _filesystemImporter.couldCanonicalize(Uri(path: url.path), canonicalUrl); + FilesystemImporter.cwd + .couldCanonicalize(Uri(path: url.path), canonicalUrl); String toString() => "package:..."; } diff --git a/lib/src/importer/utils.dart b/lib/src/importer/utils.dart index a68ae6f5e..4c5c8106c 100644 --- a/lib/src/importer/utils.dart +++ b/lib/src/importer/utils.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:path/path.dart' as p; import '../io.dart'; +import './canonicalize_context.dart'; /// Whether the Sass compiler is currently evaluating an `@import` rule. /// @@ -15,30 +16,35 @@ import '../io.dart'; /// canonicalization should be identical for `@import` and `@use` rules. It's /// admittedly hacky to set this globally, but `@import` will eventually be /// removed, at which point we can delete this and have one consistent behavior. -bool get fromImport => Zone.current[#_inImportRule] as bool? ?? false; - -/// The URL of the stylesheet that contains the current load. -Uri? get containingUrl => switch (Zone.current[#_containingUrl]) { +bool get fromImport => + ((Zone.current[#_canonicalizeContext] as CanonicalizeContext?) + ?.fromImport ?? + false); + +/// The CanonicalizeContext of the current load. +CanonicalizeContext get canonicalizeContext => + switch (Zone.current[#_canonicalizeContext]) { null => throw StateError( - "containingUrl may only be accessed within a call to canonicalize()."), - #_none => null, - Uri url => url, + "canonicalizeContext may only be accessed within a call to canonicalize()."), + CanonicalizeContext context => context, var value => throw StateError( - "Unexpected Zone.current[#_containingUrl] value $value.") + "Unexpected Zone.current[#_canonicalizeContext] value $value.") }; /// Runs [callback] in a context where [fromImport] returns `true` and /// [resolveImportPath] uses `@import` semantics rather than `@use` semantics. T inImportRule(T callback()) => - runZoned(callback, zoneValues: {#_inImportRule: true}); + switch (Zone.current[#_canonicalizeContext]) { + null => runZoned(callback, + zoneValues: {#_canonicalizeContext: CanonicalizeContext(null, true)}), + CanonicalizeContext context => context.withFromImport(true, callback), + var value => throw StateError( + "Unexpected Zone.current[#_canonicalizeContext] value $value.") + }; -/// Runs [callback] in a context where [containingUrl] returns [url]. -/// -/// If [when] is `false`, runs [callback] without setting [containingUrl]. -T withContainingUrl(Uri? url, T callback()) => - // Use #_none as a sentinel value so we can distinguish a containing URL - // that's set to null from one that's unset at all. - runZoned(callback, zoneValues: {#_containingUrl: url ?? #_none}); +/// Runs [callback] in the given context. +T withCanonicalizeContext(CanonicalizeContext? context, T callback()) => + runZoned(callback, zoneValues: {#_canonicalizeContext: context}); /// Resolves an imported path using the same logic as the filesystem importer. /// diff --git a/lib/src/interpolation_buffer.dart b/lib/src/interpolation_buffer.dart index 014c31fec..e80080a68 100644 --- a/lib/src/interpolation_buffer.dart +++ b/lib/src/interpolation_buffer.dart @@ -20,6 +20,12 @@ final class InterpolationBuffer implements StringSink { /// This contains [String]s and [Expression]s. final _contents = []; + /// The spans of the expressions in [_contents]. + /// + /// These spans cover from the beginning to the end of `#{}`, rather than just + /// the expressions themselves. + final _spans = []; + /// Returns whether this buffer has no contents. bool get isEmpty => _contents.isEmpty && _text.isEmpty; @@ -39,9 +45,12 @@ final class InterpolationBuffer implements StringSink { void writeln([Object? obj = '']) => _text.writeln(obj); /// Adds [expression] to this buffer. - void add(Expression expression) { + /// + /// The [span] should cover from the beginning of `#{` through `}`. + void add(Expression expression, FileSpan span) { _flushText(); _contents.add(expression); + _spans.add(span); } /// Adds the contents of [interpolation] to this buffer. @@ -49,28 +58,37 @@ final class InterpolationBuffer implements StringSink { if (interpolation.contents.isEmpty) return; Iterable toAdd = interpolation.contents; + Iterable spansToAdd = interpolation.spans; if (interpolation.contents case [String first, ...var rest]) { _text.write(first); toAdd = rest; + assert(interpolation.spans.first == null); + spansToAdd = interpolation.spans.skip(1); } _flushText(); _contents.addAll(toAdd); - if (_contents.last is String) _text.write(_contents.removeLast()); + _spans.addAll(spansToAdd); + if (_contents.last is String) { + _text.write(_contents.removeLast()); + var lastSpan = _spans.removeLast(); + assert(lastSpan == null); + } } /// Flushes [_text] to [_contents] if necessary. void _flushText() { if (_text.isEmpty) return; _contents.add(_text.toString()); + _spans.add(null); _text.clear(); } /// Creates an [Interpolation] with the given [span] from the contents of this /// buffer. Interpolation interpolation(FileSpan span) { - return Interpolation( - [..._contents, if (_text.isNotEmpty) _text.toString()], span); + return Interpolation([..._contents, if (_text.isNotEmpty) _text.toString()], + [..._spans, if (_text.isNotEmpty) null], span); } String toString() { diff --git a/lib/src/io/README.md b/lib/src/io/README.md new file mode 100644 index 000000000..08306bfc9 --- /dev/null +++ b/lib/src/io/README.md @@ -0,0 +1,17 @@ +# Input/Output Shim + +This directory contains an API shim for doing various forms of IO across +different platforms. Dart chooses at compile time which of the three files to +use: + +* `interface.dart` is used by the Dart Analyzer for static checking. It defines + the "expected" interface of the other two files, although there aren't strong + checks that their interfaces are exactly the same. + +* `vm.dart` is used by the Dart VM, and defines IO operations in terms of the + `dart:io` library. + +* `js.dart` is used by JS platforms. On Node.js, it will use Node's `fs` and + `process` APIs for IO operations. On other JS platforms, most IO operations + won't work at all, although messages will still be emitted with + `console.log()` and `console.error()`. diff --git a/lib/src/io/interface.dart b/lib/src/io/interface.dart index be0ec574c..29182eb45 100644 --- a/lib/src/io/interface.dart +++ b/lib/src/io/interface.dart @@ -19,15 +19,6 @@ bool get isMacOS => throw ''; /// Returns whether or not stdout is connected to an interactive terminal. bool get hasTerminal => throw ''; -/// Whether we're running as JS (browser or Node.js). -const bool isJS = false; - -/// Whether we're running as Node.js (not browser or Dart VM). -bool get isNode => throw ''; - -/// Whether we're running as browser (not Node.js or Dart VM). -bool get isBrowser => throw ''; - /// Whether this process is connected to a terminal that supports ANSI escape /// sequences. bool get supportsAnsiEscapes => throw ''; diff --git a/lib/src/io/js.dart b/lib/src/io/js.dart index c806dc181..ce730d142 100644 --- a/lib/src/io/js.dart +++ b/lib/src/io/js.dart @@ -4,20 +4,27 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:js_util'; +import 'package:cli_pkg/js.dart'; import 'package:js/js.dart'; import 'package:node_interop/fs.dart'; import 'package:node_interop/node_interop.dart' hide process; +import 'package:node_interop/util.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:watcher/watcher.dart'; import '../exception.dart'; import '../js/chokidar.dart'; +import '../js/parcel_watcher.dart'; @JS('process') -external final Process? process; // process is null in the browser +external final Process? _nodeJsProcess; // process is null in the browser + +/// The Node.JS [Process] global variable. +/// +/// This value is `null` when running the script is not run from Node.JS +Process? get _process => isNodeJs ? _nodeJsProcess : null; class FileSystemException { final String message; @@ -29,7 +36,7 @@ class FileSystemException { } void safePrint(Object? message) { - if (process case var process?) { + if (_process case var process?) { process.stdout.write("${message ?? ''}\n"); } else { console.log(message ?? ''); @@ -37,7 +44,7 @@ void safePrint(Object? message) { } void printError(Object? message) { - if (process case var process?) { + if (_process case var process?) { process.stderr.write("${message ?? ''}\n"); } else { console.error(message ?? ''); @@ -45,7 +52,7 @@ void printError(Object? message) { } String readFile(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("readFile() is only supported on Node.js"); } // TODO(nweiz): explicitly decode the bytes as UTF-8 like we do in the VM when @@ -69,7 +76,7 @@ Object? _readFile(String path, [String? encoding]) => _systemErrorToFileSystemException(() => fs.readFileSync(path, encoding)); void writeFile(String path, String contents) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("writeFile() is only supported on Node.js"); } return _systemErrorToFileSystemException( @@ -77,15 +84,15 @@ void writeFile(String path, String contents) { } void deleteFile(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("deleteFile() is only supported on Node.js"); } return _systemErrorToFileSystemException(() => fs.unlinkSync(path)); } Future readStdin() async { - var process_ = process; - if (process_ == null) { + var process = _process; + if (process == null) { throw UnsupportedError("readStdin() is only supported on Node.js"); } var completer = Completer(); @@ -96,15 +103,15 @@ Future readStdin() async { }); // Node defaults all buffers to 'utf8'. var sink = utf8.decoder.startChunkedConversion(innerSink); - process_.stdin.on('data', allowInterop(([Object? chunk]) { + process.stdin.on('data', allowInterop(([Object? chunk]) { sink.add(chunk as List); })); - process_.stdin.on('end', allowInterop(([Object? _]) { + process.stdin.on('end', allowInterop(([Object? arg]) { // Callback for 'end' receives no args. - assert(_ == null); + assert(arg == null); sink.close(); })); - process_.stdin.on('error', allowInterop(([Object? e]) { + process.stdin.on('error', allowInterop(([Object? e]) { printError('Failed to read from stdin'); printError(e); completer.completeError(e!); @@ -121,7 +128,7 @@ String _cleanErrorMessage(JsSystemError error) { } bool fileExists(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("fileExists() is only supported on Node.js"); } return _systemErrorToFileSystemException(() { @@ -142,7 +149,7 @@ bool fileExists(String path) { } bool dirExists(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("dirExists() is only supported on Node.js"); } return _systemErrorToFileSystemException(() { @@ -163,7 +170,7 @@ bool dirExists(String path) { } void ensureDir(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("ensureDir() is only supported on Node.js"); } return _systemErrorToFileSystemException(() { @@ -180,7 +187,7 @@ void ensureDir(String path) { } Iterable listDir(String path, {bool recursive = false}) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("listDir() is only supported on Node.js"); } return _systemErrorToFileSystemException(() { @@ -202,7 +209,7 @@ Iterable listDir(String path, {bool recursive = false}) { } DateTime modificationTime(String path) { - if (!isNode) { + if (!isNodeJs) { throw UnsupportedError("modificationTime() is only supported on Node.js"); } return _systemErrorToFileSystemException(() => @@ -210,7 +217,7 @@ DateTime modificationTime(String path) { } String? getEnvironmentVariable(String name) { - var env = process?.env; + var env = _process?.env; return env == null ? null : getProperty(env as Object, name) as String?; } @@ -229,68 +236,78 @@ T _systemErrorToFileSystemException(T callback()) { /// from `node_interop` declares `isTTY` as always non-nullably available, but /// in practice it's undefined if stdout isn't a TTY. /// See: https://github.com/pulyaevskiy/node-interop/issues/93 -bool get hasTerminal => process?.stdout.isTTY == true; - -bool get isWindows => process?.platform == 'win32'; - -bool get isMacOS => process?.platform == 'darwin'; - -const bool isJS = true; - -/// The fs module object, used to check whether this has been loaded as Node. -/// -/// It's safest to check for a library we load in manually rather than one -/// that's ambiently available so that we don't get into a weird state in -/// environments like VS Code that support some Node.js libraries but don't load -/// Node.js entrypoints for dependencies. -@JS('fs') -external final Object? _fsNullable; +bool get hasTerminal => _process?.stdout.isTTY == true; -bool get isNode => _fsNullable != null; +bool get isWindows => _process?.platform == 'win32'; -bool get isBrowser => isJS && !isNode; +bool get isMacOS => _process?.platform == 'darwin'; // Node seems to support ANSI escapes on all terminals. bool get supportsAnsiEscapes => hasTerminal; -int get exitCode => process?.exitCode ?? 0; +int get exitCode => _process?.exitCode ?? 0; -set exitCode(int code) => process?.exitCode = code; +set exitCode(int code) => _process?.exitCode = code; -Future> watchDir(String path, {bool poll = false}) { - if (!isNode) { +Future> watchDir(String path, {bool poll = false}) async { + if (!isNodeJs) { throw UnsupportedError("watchDir() is only supported on Node.js"); } - var watcher = chokidar.watch( - path, ChokidarOptions(disableGlobbing: true, usePolling: poll)); // Don't assign the controller until after the ready event fires. Otherwise, // Chokidar will give us a bunch of add events for files that already exist. StreamController? controller; - watcher - ..on( - 'add', - allowInterop((String path, [void _]) => - controller?.add(WatchEvent(ChangeType.ADD, path)))) - ..on( - 'change', - allowInterop((String path, [void _]) => - controller?.add(WatchEvent(ChangeType.MODIFY, path)))) - ..on( - 'unlink', - allowInterop((String path) => - controller?.add(WatchEvent(ChangeType.REMOVE, path)))) - ..on('error', allowInterop((Object error) => controller?.addError(error))); - - var completer = Completer>(); - watcher.on('ready', allowInterop(() { - // dart-lang/sdk#45348 - var stream = (controller = StreamController(onCancel: () { - watcher.close(); + if (parcelWatcher case var parcel? when !poll) { + var subscription = await parcel.subscribe(path, + (Object? error, List events) { + if (error != null) { + controller?.addError(error); + } else { + for (var event in events) { + switch (event.type) { + case 'create': + controller?.add(WatchEvent(ChangeType.ADD, event.path)); + case 'update': + controller?.add(WatchEvent(ChangeType.MODIFY, event.path)); + case 'delete': + controller?.add(WatchEvent(ChangeType.REMOVE, event.path)); + } + } + } + }); + + return (controller = StreamController(onCancel: () { + subscription.unsubscribe(); })) .stream; - completer.complete(stream); - })); - - return completer.future; + } else { + var watcher = chokidar.watch(path, ChokidarOptions(usePolling: poll)); + watcher + ..on( + 'add', + allowInterop((String path, [void _]) => + controller?.add(WatchEvent(ChangeType.ADD, path)))) + ..on( + 'change', + allowInterop((String path, [void _]) => + controller?.add(WatchEvent(ChangeType.MODIFY, path)))) + ..on( + 'unlink', + allowInterop((String path) => + controller?.add(WatchEvent(ChangeType.REMOVE, path)))) + ..on( + 'error', allowInterop((Object error) => controller?.addError(error))); + + var completer = Completer>(); + watcher.on('ready', allowInterop(() { + // dart-lang/sdk#45348 + var stream = (controller = StreamController(onCancel: () { + watcher.close(); + })) + .stream; + completer.complete(stream); + })); + + return completer.future; + } } diff --git a/lib/src/io/vm.dart b/lib/src/io/vm.dart index cba88e40f..1d5c7b561 100644 --- a/lib/src/io/vm.dart +++ b/lib/src/io/vm.dart @@ -22,12 +22,6 @@ bool get isMacOS => io.Platform.isMacOS; bool get hasTerminal => io.stdout.hasTerminal; -const bool isJS = false; - -bool get isNode => false; - -bool get isBrowser => false; - bool get supportsAnsiEscapes { if (!hasTerminal) return false; diff --git a/lib/src/js.dart b/lib/src/js.dart index cd1480719..0dd47686f 100644 --- a/lib/src/js.dart +++ b/lib/src/js.dart @@ -2,13 +2,19 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:js/js_util.dart'; + import 'js/exception.dart'; +import 'js/deprecations.dart'; import 'js/exports.dart'; +import 'js/importer/canonicalize_context.dart'; import 'js/compile.dart'; +import 'js/compiler.dart'; 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'; @@ -24,6 +30,11 @@ void main() { exports.compileAsync = allowInteropNamed('sass.compileAsync', compileAsync); exports.compileStringAsync = allowInteropNamed('sass.compileStringAsync', compileStringAsync); + exports.initCompiler = allowInteropNamed('sass.initCompiler', initCompiler); + exports.initAsyncCompiler = + allowInteropNamed('sass.initAsyncCompiler', initAsyncCompiler); + exports.Compiler = compilerClass; + exports.AsyncCompiler = asyncCompilerClass; exports.Value = valueClass; exports.SassBoolean = booleanClass; exports.SassArgumentList = argumentListClass; @@ -32,6 +43,7 @@ void main() { exports.CalculationInterpolation = calculationInterpolationClass; exports.SassColor = colorClass; exports.SassFunction = functionClass; + exports.SassMixin = mixinClass; exports.SassList = listClass; exports.SassMap = mapClass; exports.SassNumber = numberClass; @@ -44,6 +56,10 @@ void main() { silent: JSLogger( warn: allowInteropNamed('sass.Logger.silent.warn', (_, __) {}), debug: allowInteropNamed('sass.Logger.silent.debug', (_, __) {}))); + 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" @@ -51,6 +67,7 @@ void main() { "dart2js\t${const String.fromEnvironment('dart-version')}\t" "(Dart Compiler)\t[Dart]"; + updateCanonicalizeContextPrototype(); updateSourceSpanPrototype(); // Legacy API diff --git a/lib/src/js/README.md b/lib/src/js/README.md new file mode 100644 index 000000000..d6cb699d2 --- /dev/null +++ b/lib/src/js/README.md @@ -0,0 +1,58 @@ +# JavaScript API + +This directory contains Dart Sass's implementation of the Sass JS API. Dart's JS +interop support is primarily intended for _consuming_ JS libraries from Dart, so +we have to jump through some hoops in order to effectively _produce_ a JS +library with the desired API. + +JS support has its own dedicated entrypoint in [`../js.dart`]. The [`cli_pkg` +package] ensures that when users load Dart Sass _as a library_, this entrypoint +is run instead of the CLI entrypoint, but otherwise it's up to us to set up the +library appropriately. To do so, we use JS interop to define an [`Exports`] +class that is in practice implemented by a CommonJS-like[^1] `exports` object, +and then assign various values to this object. + +[`../js.dart`]: ../js.dart +[`cli_pkg` package]: https://github.com/google/dart_cli_pkg +[`Exports`]: exports.dart + +[^1]: It's not _literally_ CommonJS because it needs to run directly on browsers + as well, but it's still an object named `exports` that we can hang names + off of. + +## Value Types + +The JS API value types pose a particular challenge from Dart. Although every +Dart class is represented by a JavaScript class when compiled to JS, Dart has no +way of specifying what the JS API of those classes should be. What's more, in +order to make the JS API as efficient as possible, we want to be able to pass +the existing Dart [`Value`] objects as-is to custom functions rather than +wrapping them with JS-only wrappers. + +[`Value`]: ../value.dart + +To solve the first problem, in [`reflection.dart`] we use JS interop to wrap the +manual method of defining a JavaScript class. We use this to create a +JS-specific class for each value type, with all the JS-specific methods and +properties defined by Sass's JS API spec. However, while normal JS constructors +just set some properties on `this`, our constructors for these classes return +Dart `Value` objects instead. + +[`reflection.dart`]: reflection.dart + +"But wait," I hear you say, "those `Value` objects aren't instances of the new +JS class you've created!" This is where the deep magic comes in. Once we've +defined our class with its phony constructor, we create a single Dart object of +the given `Value` subclass and _edit its JavaScript prototype chain_ to include +the new class we just created. Once that's done, all the Dart value types will +have exactly the right JS API (including responding correctly to `instanceof`!) +and the constructor will now correctly return an instance of the JS class. + +## Legacy API + +Dart Sass also supports the legacy JS API in the [`legacy`] directory. This hews +as close as possible to the API of the old `node-sass` package which wrapped the +old LibSass implementation. It's no longer being actively updated, but we still +need to support it at least until the next major version release of Dart Sass. + +[`legacy`]: legacy diff --git a/lib/src/js/chokidar.dart b/lib/src/js/chokidar.dart index e94b9def8..1ff3658a7 100644 --- a/lib/src/js/chokidar.dart +++ b/lib/src/js/chokidar.dart @@ -12,10 +12,9 @@ class Chokidar { @JS() @anonymous class ChokidarOptions { - external bool? get disableGlobbing; external bool? get usePolling; - external factory ChokidarOptions({bool? disableGlobbing, bool? usePolling}); + external factory ChokidarOptions({bool? usePolling}); } @JS() diff --git a/lib/src/js/compile.dart b/lib/src/js/compile.dart index 0e734dc77..50016334e 100644 --- a/lib/src/js/compile.dart +++ b/lib/src/js/compile.dart @@ -6,20 +6,24 @@ import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart' hide futureToPromise; import 'package:term_glyph/term_glyph.dart' as glyph; +import 'package:path/path.dart' as p; -import '../../sass.dart'; +import '../../sass.dart' hide Deprecation; import '../importer/no_op.dart'; import '../importer/js_to_dart/async.dart'; import '../importer/js_to_dart/async_file.dart'; import '../importer/js_to_dart/file.dart'; import '../importer/js_to_dart/sync.dart'; +import '../importer/node_package.dart'; import '../io.dart'; import '../logger/js_to_dart.dart'; import '../util/nullable.dart'; import 'compile_options.dart'; import 'compile_result.dart'; +import 'deprecations.dart'; import 'exception.dart'; import 'importer.dart'; +import 'reflection.dart'; import 'utils.dart'; /// The JS API `compile` function. @@ -27,11 +31,13 @@ import 'utils.dart'; /// See https://github.com/sass/sass/spec/tree/main/js-api/compile.d.ts for /// details. NodeCompileResult compile(String path, [CompileOptions? options]) { - if (!isNode) { + if (!isNodeJs) { jsThrow(JsError("The compile() method is only available in Node.js.")); } var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; + var logger = JSToDartLogger(options?.logger, Logger.stderr(color: color), + ascii: ascii); try { var result = compileToResult(path, color: color, @@ -41,10 +47,15 @@ NodeCompileResult compile(String path, [CompileOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), - ascii: ascii), + logger: logger, importers: options?.importers?.map(_parseImporter), - functions: _parseFunctions(options?.functions).cast()); + functions: _parseFunctions(options?.functions).cast(), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), + silenceDeprecations: + parseDeprecations(logger, options?.silenceDeprecations), + futureDeprecations: + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); } on SassException catch (error, stackTrace) { @@ -59,6 +70,8 @@ NodeCompileResult compile(String path, [CompileOptions? options]) { NodeCompileResult compileString(String text, [CompileStringOptions? options]) { var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; + var logger = JSToDartLogger(options?.logger, Logger.stderr(color: color), + ascii: ascii); try { var result = compileStringToResult(text, syntax: parseSyntax(options?.syntax), @@ -70,12 +83,17 @@ NodeCompileResult compileString(String text, [CompileStringOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), - ascii: ascii), + logger: logger, importers: options?.importers?.map(_parseImporter), importer: options?.importer.andThen(_parseImporter) ?? (options?.url == null ? NoOpImporter() : null), - functions: _parseFunctions(options?.functions).cast()); + functions: _parseFunctions(options?.functions).cast(), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), + silenceDeprecations: + parseDeprecations(logger, options?.silenceDeprecations), + futureDeprecations: + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); } on SassException catch (error, stackTrace) { @@ -88,11 +106,13 @@ NodeCompileResult compileString(String text, [CompileStringOptions? options]) { /// See https://github.com/sass/sass/spec/tree/main/js-api/compile.d.ts for /// details. Promise compileAsync(String path, [CompileOptions? options]) { - if (!isNode) { + if (!isNodeJs) { jsThrow(JsError("The compileAsync() method is only available in Node.js.")); } var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; + var logger = JSToDartLogger(options?.logger, Logger.stderr(color: color), + ascii: ascii); return _wrapAsyncSassExceptions(futureToPromise(() async { var result = await compileToResultAsync(path, color: color, @@ -102,11 +122,16 @@ Promise compileAsync(String path, [CompileOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), - ascii: ascii), + logger: logger, importers: options?.importers ?.map((importer) => _parseAsyncImporter(importer)), - functions: _parseFunctions(options?.functions, asynch: true)); + functions: _parseFunctions(options?.functions, asynch: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), + silenceDeprecations: + parseDeprecations(logger, options?.silenceDeprecations), + futureDeprecations: + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); }()), color: color, ascii: ascii); @@ -119,6 +144,8 @@ Promise compileAsync(String path, [CompileOptions? options]) { Promise compileStringAsync(String text, [CompileStringOptions? options]) { var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; + var logger = JSToDartLogger(options?.logger, Logger.stderr(color: color), + ascii: ascii); return _wrapAsyncSassExceptions(futureToPromise(() async { var result = await compileStringToResultAsync(text, syntax: parseSyntax(options?.syntax), @@ -130,14 +157,19 @@ Promise compileStringAsync(String text, [CompileStringOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), - ascii: ascii), + logger: logger, importers: options?.importers ?.map((importer) => _parseAsyncImporter(importer)), importer: options?.importer .andThen((importer) => _parseAsyncImporter(importer)) ?? (options?.url == null ? NoOpImporter() : null), - functions: _parseFunctions(options?.functions, asynch: true)); + functions: _parseFunctions(options?.functions, asynch: true), + fatalDeprecations: parseDeprecations(logger, options?.fatalDeprecations, + supportVersions: true), + silenceDeprecations: + parseDeprecations(logger, options?.silenceDeprecations), + futureDeprecations: + parseDeprecations(logger, options?.futureDeprecations)); return _convertResult(result, includeSourceContents: options?.sourceMapIncludeSources ?? false); }()), color: color, ascii: ascii); @@ -182,6 +214,8 @@ OutputStyle _parseOutputStyle(String? style) => switch (style) { /// Converts [importer] into an [AsyncImporter] that can be used with /// [compileAsync] or [compileStringAsync]. AsyncImporter _parseAsyncImporter(Object? importer) { + if (importer is NodePackageImporter) return importer; + if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as JSImporter; @@ -207,6 +241,8 @@ AsyncImporter _parseAsyncImporter(Object? importer) { /// Converts [importer] into a synchronous [Importer]. Importer _parseImporter(Object? importer) { + if (importer is NodePackageImporter) return importer; + if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as JSImporter; @@ -243,7 +279,7 @@ List? _normalizeNonCanonicalSchemes(Object? schemes) => }; /// Implements the simplification algorithm for custom function return `Value`s. -/// {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue} +/// See https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue Value _simplifyValue(Value value) => switch (value) { SassCalculation() => switch (( // Match against... @@ -321,3 +357,19 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { }); return result; } + +/// The exported `NodePackageImporter` class that can be added to the +/// `importers` option to enable loading `pkg:` URLs from `node_modules`. +final JSClass nodePackageImporterClass = () { + var jsClass = createJSClass( + 'sass.NodePackageImporter', + (Object self, [String? entrypointDirectory]) => NodePackageImporter( + switch ((entrypointDirectory, entrypointFilename)) { + ((var directory?, _)) => directory, + (_, var filename?) => p.dirname(filename), + _ => throw "The Node package importer cannot determine an entry " + "point because `require.main.filename` is not defined. Please " + "provide an `entryPointDirectory` to the `NodePackageImporter`." + })); + return jsClass; +}(); diff --git a/lib/src/js/compile_options.dart b/lib/src/js/compile_options.dart index 1789539d5..c53a1893d 100644 --- a/lib/src/js/compile_options.dart +++ b/lib/src/js/compile_options.dart @@ -23,6 +23,9 @@ class CompileOptions { external JSLogger? get logger; external List? get importers; external Object? get functions; + external List? get fatalDeprecations; + external List? get silenceDeprecations; + external List? get futureDeprecations; } @JS() diff --git a/lib/src/js/compiler.dart b/lib/src/js/compiler.dart new file mode 100644 index 000000000..ab1886b3f --- /dev/null +++ b/lib/src/js/compiler.dart @@ -0,0 +1,113 @@ +// Copyright 2024 Google LLC. 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:js_util'; + +import 'package:async/async.dart'; +import 'package:node_interop/js.dart'; + +import 'compile.dart'; +import 'compile_options.dart'; +import 'reflection.dart'; +import 'utils.dart'; + +/// The Dart Compiler class. +class Compiler { + /// A flag signifying whether the instance has been disposed. + bool _disposed = false; + + /// Checks if `dispose()` has been called on this instance, and throws an + /// error if it has. Used to verify that compilation methods are not called + /// after disposal. + void _throwIfDisposed() { + if (_disposed) { + jsThrow(JsError('Compiler has already been disposed.')); + } + } +} + +/// The Dart Async Compiler class. +class AsyncCompiler extends Compiler { + /// A set of all compilations, tracked to ensure all compilations complete + /// before async disposal resolves. + final FutureGroup compilations = FutureGroup(); + + /// Adds a compilation to the FutureGroup. + void addCompilation(Promise compilation) { + Future comp = promiseToFuture(compilation); + var wrappedComp = comp.catchError((err) { + /// Ignore errors so FutureGroup doesn't close when a compilation fails. + }); + compilations.add(wrappedComp); + } +} + +/// The JavaScript `Compiler` class. +final JSClass compilerClass = () { + var jsClass = createJSClass( + 'sass.Compiler', + (Object self) => { + jsThrow(JsError(("Compiler can not be directly constructed. " + "Please use `sass.initCompiler()` instead."))) + }); + + jsClass.defineMethods({ + 'compile': (Compiler self, String path, [CompileOptions? options]) { + self._throwIfDisposed(); + return compile(path, options); + }, + 'compileString': (Compiler self, String source, + [CompileStringOptions? options]) { + self._throwIfDisposed(); + return compileString(source, options); + }, + 'dispose': (Compiler self) { + self._disposed = true; + }, + }); + + getJSClass(Compiler()).injectSuperclass(jsClass); + return jsClass; +}(); + +Compiler initCompiler() => Compiler(); + +/// The JavaScript `AsyncCompiler` class. +final JSClass asyncCompilerClass = () { + var jsClass = createJSClass( + 'sass.AsyncCompiler', + (Object self) => { + jsThrow(JsError(("AsyncCompiler can not be directly constructed. " + "Please use `sass.initAsyncCompiler()` instead."))) + }); + + jsClass.defineMethods({ + 'compileAsync': (AsyncCompiler self, String path, + [CompileOptions? options]) { + self._throwIfDisposed(); + var compilation = compileAsync(path, options); + self.addCompilation(compilation); + return compilation; + }, + 'compileStringAsync': (AsyncCompiler self, String source, + [CompileStringOptions? options]) { + self._throwIfDisposed(); + var compilation = compileStringAsync(source, options); + self.addCompilation(compilation); + return compilation; + }, + 'dispose': (AsyncCompiler self) { + self._disposed = true; + return futureToPromise((() async { + self.compilations.close(); + await self.compilations.future; + })()); + } + }); + + getJSClass(AsyncCompiler()).injectSuperclass(jsClass); + return jsClass; +}(); + +Promise initAsyncCompiler() => futureToPromise((() async => AsyncCompiler())()); diff --git a/lib/src/js/deprecations.dart b/lib/src/js/deprecations.dart new file mode 100644 index 000000000..e26fe9ea3 --- /dev/null +++ b/lib/src/js/deprecations.dart @@ -0,0 +1,99 @@ +// Copyright 2023 Google LLC. 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 'package:pub_semver/pub_semver.dart'; + +import '../deprecation.dart' as dart show Deprecation; +import '../logger/js_to_dart.dart'; +import 'reflection.dart'; + +@JS() +@anonymous +class Deprecation { + external String get id; + external String get status; + external String? get description; + external Version? get deprecatedIn; + external Version? get obsoleteIn; + + external factory Deprecation( + {required String id, + required String status, + String? description, + Version? deprecatedIn, + Version? obsoleteIn}); +} + +final Map deprecations = { + for (var deprecation in dart.Deprecation.values) + // `calc-interp` was never actually used, so we don't want to expose it + // in the JS API. + if (deprecation != dart.Deprecation.calcInterp) + deprecation.id: Deprecation( + id: deprecation.id, + status: (() => switch (deprecation) { + dart.Deprecation(isFuture: true) => 'future', + dart.Deprecation(deprecatedIn: null, obsoleteIn: null) => + 'user', + dart.Deprecation(obsoleteIn: null) => 'active', + _ => 'obsolete' + })(), + description: deprecation.description, + deprecatedIn: deprecation.deprecatedIn, + obsoleteIn: deprecation.deprecatedIn), +}; + +/// Parses a list of [deprecations] from JS into an list of Dart [Deprecation] +/// objects. +/// +/// [deprecations] can contain deprecation IDs, JS Deprecation objects, and +/// (if [supportVersions] is true) [Version]s. +Iterable? parseDeprecations( + JSToDartLogger logger, List? deprecations, + {bool supportVersions = false}) { + if (deprecations == null) return null; + return () sync* { + for (var item in deprecations) { + switch (item) { + case String id: + var deprecation = dart.Deprecation.fromId(id); + if (deprecation == null) { + logger.warn('Invalid deprecation "$id".'); + } else { + yield deprecation; + } + case Deprecation(:var id): + var deprecation = dart.Deprecation.fromId(id); + if (deprecation == null) { + logger.warn('Invalid deprecation "$id".'); + } else { + yield deprecation; + } + case Version version when supportVersions: + yield* dart.Deprecation.forVersion(version); + } + } + }(); +} + +/// The JavaScript `Version` class. +final JSClass versionClass = () { + var jsClass = createJSClass('sass.Version', + (Object self, int major, int minor, int patch) { + return Version(major, minor, patch); + }); + + jsClass.defineStaticMethod('parse', (String version) { + var v = Version.parse(version); + if (v.isPreRelease || v.build.isNotEmpty) { + throw FormatException( + 'Build identifiers and prerelease versions not supported.'); + } + return v; + }); + + getJSClass(Version(0, 0, 0)).injectSuperclass(jsClass); + return jsClass; +}(); diff --git a/lib/src/js/exports.dart b/lib/src/js/exports.dart index 0dff13698..225f5fcf7 100644 --- a/lib/src/js/exports.dart +++ b/lib/src/js/exports.dart @@ -19,9 +19,16 @@ class Exports { external set compileStringAsync(Function function); external set compile(Function function); external set compileAsync(Function function); + external set initCompiler(Function function); + external set initAsyncCompiler(Function function); + external set Compiler(JSClass function); + external set AsyncCompiler(JSClass function); external set info(String info); external set Exception(JSClass function); external set Logger(LoggerNamespace namespace); + external set NodePackageImporter(JSClass function); + external set deprecations(Object? object); + external set Version(JSClass version); // Value APIs external set Value(JSClass function); @@ -32,6 +39,7 @@ class Exports { external set SassBoolean(JSClass function); external set SassColor(JSClass function); external set SassFunction(JSClass function); + external set SassMixin(JSClass mixin); external set SassList(JSClass function); external set SassMap(JSClass function); external set SassNumber(JSClass function); @@ -47,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/importer.dart b/lib/src/js/importer.dart index 0469737ee..09ffcd665 100644 --- a/lib/src/js/importer.dart +++ b/lib/src/js/importer.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../importer/canonicalize_context.dart'; import 'url.dart'; @JS() @@ -15,15 +16,6 @@ class JSImporter { external Object? get nonCanonicalScheme; } -@JS() -@anonymous -class CanonicalizeContext { - external bool get fromImport; - external JSUrl? get containingUrl; - - external factory CanonicalizeContext({bool fromImport, JSUrl? containingUrl}); -} - @JS() @anonymous class JSImporterResult { diff --git a/lib/src/js/importer/canonicalize_context.dart b/lib/src/js/importer/canonicalize_context.dart new file mode 100644 index 000000000..412f21ce8 --- /dev/null +++ b/lib/src/js/importer/canonicalize_context.dart @@ -0,0 +1,16 @@ +// Copyright 2014 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 '../../importer/canonicalize_context.dart'; +import '../../util/nullable.dart'; +import '../reflection.dart'; +import '../utils.dart'; + +/// Adds JS members to Dart's `CanonicalizeContext` class. +void updateCanonicalizeContextPrototype() => + getJSClass(CanonicalizeContext(null, false)).defineGetters({ + 'fromImport': (CanonicalizeContext self) => self.fromImport, + 'containingUrl': (CanonicalizeContext self) => + self.containingUrl.andThen(dartToJSUrl), + }); diff --git a/lib/src/js/legacy.dart b/lib/src/js/legacy.dart index 66875da51..65ae4ca48 100644 --- a/lib/src/js/legacy.dart +++ b/lib/src/js/legacy.dart @@ -10,6 +10,9 @@ import 'dart:typed_data'; import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:path/path.dart' as p; +import '../async_import_cache.dart'; +import '../import_cache.dart'; +import '../importer/node_package.dart'; import '../callable.dart'; import '../compile.dart'; @@ -24,6 +27,7 @@ import '../util/nullable.dart'; import '../utils.dart'; import '../value.dart'; import '../visitor/serialize.dart'; +import 'deprecations.dart'; import 'function.dart'; import 'legacy/render_context.dart'; import 'legacy/render_options.dart'; @@ -39,7 +43,7 @@ import 'utils.dart'; /// [render]: https://github.com/sass/node-sass#options void render( RenderOptions options, void callback(Object? error, RenderResult? result)) { - if (!isNode) { + if (!isNodeJs) { jsThrow(JsError("The render() method is only available in Node.js.")); } if (options.fiber case var fiber?) { @@ -73,9 +77,12 @@ Future _renderAsync(RenderOptions options) async { CompileResult result; var file = options.file.andThen(p.absolute); + var logger = + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)); if (options.data case var data?) { result = await compileStringAsync(data, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImportersAsync(options, start), functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -84,14 +91,20 @@ Future _renderAsync(RenderOptions options) async { lineFeed: _parseLineFeed(options.linefeed), url: file == null ? 'stdin' : p.toUri(file).toString(), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations, + supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: - JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else if (file != null) { result = await compileAsync(file, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImportersAsync(options, start), functions: _parseFunctions(options, start, asynch: true), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -99,11 +112,16 @@ Future _renderAsync(RenderOptions options) async { indentWidth: _parseIndentWidth(options.indentWidth), lineFeed: _parseLineFeed(options.linefeed), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations(logger, options.fatalDeprecations, + supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: - JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else { throw ArgumentError("Either options.data or options.file must be set."); } @@ -118,7 +136,7 @@ Future _renderAsync(RenderOptions options) async { /// /// [render]: https://github.com/sass/node-sass#options RenderResult renderSync(RenderOptions options) { - if (!isNode) { + if (!isNodeJs) { jsThrow(JsError("The renderSync() method is only available in Node.js.")); } try { @@ -126,9 +144,12 @@ RenderResult renderSync(RenderOptions options) { CompileResult result; var file = options.file.andThen(p.absolute); + var logger = + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal)); if (options.data case var data?) { result = compileString(data, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImporters(options, start), functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -137,14 +158,20 @@ RenderResult renderSync(RenderOptions options) { lineFeed: _parseLineFeed(options.linefeed), url: file == null ? 'stdin' : p.toUri(file).toString(), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations( + logger, options.fatalDeprecations, supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: JSToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else if (file != null) { result = compile(file, nodeImporter: _parseImporter(options, start), + importCache: _parsePackageImporters(options, start), functions: _parseFunctions(options, start).cast(), syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, style: _parseOutputStyle(options.outputStyle), @@ -152,11 +179,16 @@ RenderResult renderSync(RenderOptions options) { indentWidth: _parseIndentWidth(options.indentWidth), lineFeed: _parseLineFeed(options.linefeed), quietDeps: options.quietDeps ?? false, + fatalDeprecations: parseDeprecations( + logger, options.fatalDeprecations, supportVersions: true), + futureDeprecations: + parseDeprecations(logger, options.futureDeprecations), + silenceDeprecations: + parseDeprecations(logger, options.silenceDeprecations), verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: JSToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: logger); } else { throw ArgumentError("Either options.data or options.file must be set."); } @@ -289,6 +321,23 @@ NodeImporter _parseImporter(RenderOptions options, DateTime start) { return NodeImporter(contextOptions, includePaths, importers); } +/// Creates an [AsyncImportCache] for Package Importers. +AsyncImportCache? _parsePackageImportersAsync( + RenderOptions options, DateTime start) { + if (options.pkgImporter is NodePackageImporter) { + return AsyncImportCache.only([options.pkgImporter!]); + } + return null; +} + +/// Creates an [ImportCache] for Package Importers. +ImportCache? _parsePackageImporters(RenderOptions options, DateTime start) { + if (options.pkgImporter is NodePackageImporter) { + return ImportCache.only([options.pkgImporter!]); + } + return null; +} + /// Creates the [RenderContextOptions] for the `this` context in which custom /// functions and importers will be evaluated. RenderContextOptions _contextOptions(RenderOptions options, DateTime start) { diff --git a/lib/src/js/legacy/render_options.dart b/lib/src/js/legacy/render_options.dart index 3357166de..e63de45f1 100644 --- a/lib/src/js/legacy/render_options.dart +++ b/lib/src/js/legacy/render_options.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../importer/node_package.dart'; import '../logger.dart'; import 'fiber.dart'; @@ -13,6 +14,7 @@ class RenderOptions { external String? get file; external String? get data; external Object? get importer; + external NodePackageImporter? get pkgImporter; external Object? get functions; external List? get includePaths; external bool? get indentedSyntax; @@ -28,6 +30,9 @@ class RenderOptions { external bool? get sourceMapEmbed; external String? get sourceMapRoot; external bool? get quietDeps; + external List? get fatalDeprecations; + external List? get futureDeprecations; + external List? get silenceDeprecations; external bool? get verbose; external bool? get charset; external JSLogger? get logger; @@ -36,6 +41,7 @@ class RenderOptions { {String? file, String? data, Object? importer, + NodePackageImporter? pkgImporter, Object? functions, List? includePaths, bool? indentedSyntax, @@ -51,6 +57,9 @@ class RenderOptions { bool? sourceMapEmbed, String? sourceMapRoot, bool? quietDeps, + List? fatalDeprecations, + List? futureDeprecations, + List? silenceDeprecations, bool? verbose, bool? charset, JSLogger? logger}); diff --git a/lib/src/js/legacy/value/color.dart b/lib/src/js/legacy/value/color.dart index 6aae64426..0545e761e 100644 --- a/lib/src/js/legacy/value/color.dart +++ b/lib/src/js/legacy/value/color.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../../util/nullable.dart'; import '../../../util/number.dart'; import '../../../value.dart'; import '../../reflection.dart'; @@ -45,8 +46,8 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', red = redOrArgb!; } - thisArg.dartValue = SassColor.rgb( - _clamp(red), _clamp(green), _clamp(blue), alpha?.clamp(0, 1) ?? 1); + thisArg.dartValue = SassColor.rgb(_clamp(red), _clamp(green), _clamp(blue), + alpha.andThen((alpha) => clampLikeCss(alpha.toDouble(), 0, 1)) ?? 1); }) ..defineMethods({ 'getR': (_NodeSassColor thisArg) => thisArg.dartValue.red, @@ -63,10 +64,11 @@ final JSClass legacyColorClass = createJSClass('sass.types.Color', thisArg.dartValue = thisArg.dartValue.changeRgb(blue: _clamp(value)); }, 'setA': (_NodeSassColor thisArg, num value) { - thisArg.dartValue = thisArg.dartValue.changeRgb(alpha: value.clamp(0, 1)); + thisArg.dartValue = thisArg.dartValue + .changeRgb(alpha: clampLikeCss(value.toDouble(), 0, 1)); } }); /// Clamps [channel] within the range 0, 255 and rounds it to the nearest /// integer. -int _clamp(num channel) => fuzzyRound(channel.clamp(0, 255)); +int _clamp(num channel) => fuzzyRound(clampLikeCss(channel.toDouble(), 0, 255)); diff --git a/lib/src/js/logger.dart b/lib/src/js/logger.dart index fb28030d5..2e6fd6fc0 100644 --- a/lib/src/js/logger.dart +++ b/lib/src/js/logger.dart @@ -5,6 +5,8 @@ import 'package:js/js.dart'; import 'package:source_span/source_span.dart'; +import 'deprecations.dart'; + @JS() @anonymous class JSLogger { @@ -20,11 +22,15 @@ class JSLogger { @anonymous class WarnOptions { external bool get deprecation; + external Deprecation? get deprecationType; external SourceSpan? get span; external String? get stack; external factory WarnOptions( - {required bool deprecation, SourceSpan? span, String? stack}); + {required bool deprecation, + Deprecation? deprecationType, + SourceSpan? span, + String? stack}); } @JS() diff --git a/lib/src/js/module.dart b/lib/src/js/module.dart new file mode 100644 index 000000000..0723b994e --- /dev/null +++ b/lib/src/js/module.dart @@ -0,0 +1,26 @@ +// 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'; + +@JS('nodeModule') +external JSModule get module; + +/// A Dart API for the [`node:module`] module. +/// +/// [`node:module`]: https://nodejs.org/api/module.html#modules-nodemodule-api +@JS() +@anonymous +class JSModule { + /// See https://nodejs.org/api/module.html#modulecreaterequirefilename. + external JSModuleRequire createRequire(String filename); +} + +/// A `require` function returned by `module.createRequire()`. +@JS() +@anonymous +class JSModuleRequire { + /// See https://nodejs.org/api/modules.html#requireresolverequest-options. + external String resolve(String filename); +} diff --git a/lib/src/js/parcel_watcher.dart b/lib/src/js/parcel_watcher.dart new file mode 100644 index 000000000..4d1720bdd --- /dev/null +++ b/lib/src/js/parcel_watcher.dart @@ -0,0 +1,49 @@ +// 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 'dart:js_interop'; + +@JS() +extension type ParcelWatcherSubscription(JSObject _) implements JSObject { + external void unsubscribe(); +} + +@JS() +extension type ParcelWatcherEvent(JSObject _) implements JSObject { + external String get type; + external String get path; +} + +/// The @parcel/watcher module. +/// +/// See [the docs on npm](https://www.npmjs.com/package/@parcel/watcher). +@JS() +extension type ParcelWatcher(JSObject _) implements JSObject { + @JS('subscribe') + external JSPromise _subscribe( + String path, JSFunction callback); + Future subscribe(String path, + void Function(Object? error, List) callback) => + _subscribe( + path, + (JSObject? error, JSArray events) { + callback(error, events.toDart); + }.toJS) + .toDart; + + @JS('getEventsSince') + external JSPromise> _getEventsSince( + String path, String snapshotPath); + Future> getEventsSince( + String path, String snapshotPath) async => + (await _getEventsSince(path, snapshotPath).toDart).toDart; + + @JS('writeSnapshot') + external JSPromise _writeSnapshot(String path, String snapshotPath); + Future writeSnapshot(String path, String snapshotPath) => + _writeSnapshot(path, snapshotPath).toDart; +} + +@JS('parcel_watcher') +external ParcelWatcher? get parcelWatcher; diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart new file mode 100644 index 000000000..79d9a5cc9 --- /dev/null +++ b/lib/src/js/parser.dart @@ -0,0 +1,147 @@ +// 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 '../exception.dart'; +import '../parse/parser.dart'; +import '../syntax.dart'; +import '../util/nullable.dart'; +import '../util/span.dart'; +import '../util/string.dart'; +import '../visitor/interface/expression.dart'; +import '../visitor/interface/statement.dart'; +import 'reflection.dart'; +import 'set.dart'; +import 'visitor/expression.dart'; +import 'visitor/statement.dart'; + +@JS() +@anonymous +class ParserExports { + external factory ParserExports( + {required Function parse, + required Function parseIdentifier, + required Function toCssIdentifier, + required Function createExpressionVisitor, + required Function createStatementVisitor, + required Function setToJS}); + + external set parse(Function function); + external set parseIdentifier(Function function); + external set toCssIdentifier(Function function); + external set createStatementVisitor(Function function); + external set createExpressionVisitor(Function function); + external set setToJS(Function function); +} + +/// An empty interpolation, used to initialize empty AST entries to modify their +/// prototypes. +final _interpolation = Interpolation(const [], const [], bogusSpan); + +/// An expression used to initialize empty AST entries to modify their +/// prototypes. +final _expression = NullExpression(bogusSpan); + +/// Loads and returns all the exports needed for the `sass-parser` package. +ParserExports loadParserExports() { + _updateAstPrototypes(); + return ParserExports( + parse: allowInterop(_parse), + parseIdentifier: allowInterop(_parseIdentifier), + toCssIdentifier: allowInterop(_toCssIdentifier), + createExpressionVisitor: allowInterop( + (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), + createStatementVisitor: allowInterop( + (JSStatementVisitorObject inner) => JSStatementVisitor(inner)), + setToJS: allowInterop((Set set) => JSSet([...set]))); +} + +/// Modifies the prototypes of the Sass AST classes to provide access to JS. +/// +/// This API is not intended to be used directly by end users and is subject to +/// breaking changes without notice. Instead, it's wrapped by the `sass-parser` +/// package which exposes a PostCSS-style API. +void _updateAstPrototypes() { + // We don't need explicit getters for field names, because dart2js preserves + // them as-is, so we actually need to expose very little to JS manually. + var file = SourceFile.fromString(''); + getJSClass(file).defineMethod('getText', + (SourceFile self, int start, [int? end]) => self.getText(start, end)); + getJSClass(file) + .defineGetter('codeUnits', (SourceFile self) => self.codeUnits); + 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)); + + _addSupportsConditionToInterpolation(); + + for (var node in [ + string, + BinaryOperationExpression(BinaryOperator.plus, string, string), + SupportsExpression(SupportsAnything(_interpolation, bogusSpan)), + LoudComment(_interpolation) + ]) { + getJSClass(node).defineGetter('span', (SassNode self) => self.span); + } +} + +/// Updates the prototypes of [SupportsCondition] AST types to support +/// converting them to an [Interpolation] for the JS API. +/// +/// Works around sass/sass#3935. +void _addSupportsConditionToInterpolation() { + var anything = SupportsAnything(_interpolation, bogusSpan); + for (var node in [ + anything, + SupportsDeclaration(_expression, _expression, bogusSpan), + SupportsFunction(_interpolation, _interpolation, bogusSpan), + SupportsInterpolation(_expression, bogusSpan), + SupportsNegation(anything, bogusSpan), + SupportsOperation(anything, anything, "and", bogusSpan) + ]) { + getJSClass(node).defineMethod( + 'toInterpolation', (SupportsCondition self) => self.toInterpolation()); + } +} + +/// A JavaScript-friendly method to parse a stylesheet. +Stylesheet _parse(String css, String syntax, String? path) => Stylesheet.parse( + css, + switch (syntax) { + 'scss' => Syntax.scss, + 'sass' => Syntax.sass, + 'css' => Syntax.css, + _ => throw UnsupportedError('Unknown syntax "$syntax"') + }, + url: path.andThen(p.toUri)); + +/// A JavaScript-friendly method to parse an identifier to its semantic value. +/// +/// Returns null if [identifier] isn't a valid identifier. +String? _parseIdentifier(String identifier) { + try { + return Parser.parseIdentifier(identifier); + } on SassFormatException { + return null; + } +} + +/// A JavaScript-friendly method to convert text to a valid CSS identifier with +/// the same contents. +String _toCssIdentifier(String text) => text.toCssIdentifier(); diff --git a/lib/src/js/set.dart b/lib/src/js/set.dart new file mode 100644 index 000000000..69ec119ba --- /dev/null +++ b/lib/src/js/set.dart @@ -0,0 +1,10 @@ +// 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 'package:js/js.dart'; + +@JS('Set') +class JSSet { + external JSSet(List contents); +} diff --git a/lib/src/js/source_span.dart b/lib/src/js/source_span.dart index 998db03ad..ddf8ee776 100644 --- a/lib/src/js/source_span.dart +++ b/lib/src/js/source_span.dart @@ -2,8 +2,10 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; +import '../util/lazy_file_span.dart'; import '../util/multi_span.dart'; import '../util/nullable.dart'; import 'reflection.dart'; @@ -14,12 +16,14 @@ import 'utils.dart'; void updateSourceSpanPrototype() { var span = SourceFile.fromString('').span(0); var multiSpan = MultiSpan(span, '', {}); + var lazySpan = LazyFileSpan(() => span); - for (var item in [span, multiSpan]) { + for (var item in [span, multiSpan, lazySpan]) { getJSClass(item).defineGetters({ 'start': (FileSpan span) => span.start, 'end': (FileSpan span) => span.end, - 'url': (FileSpan span) => span.sourceUrl.andThen(dartToJSUrl), + 'url': (FileSpan span) => span.sourceUrl.andThen((url) => dartToJSUrl( + url.scheme == '' ? p.toUri(p.absolute(p.fromUri(url))) : url)), 'text': (FileSpan span) => span.text, 'context': (FileSpan span) => span.context, }); diff --git a/lib/src/js/utils.dart b/lib/src/js/utils.dart index 687484c9a..e291054b5 100644 --- a/lib/src/js/utils.dart +++ b/lib/src/js/utils.dart @@ -5,7 +5,7 @@ import 'dart:js_util'; import 'dart:typed_data'; -import 'package:node_interop/js.dart'; +import 'package:node_interop/node.dart' hide module; import 'package:js/js.dart'; import 'package:js/js_util.dart'; @@ -14,6 +14,7 @@ import '../utils.dart'; import '../value.dart'; import 'array.dart'; import 'function.dart'; +import 'module.dart'; import 'reflection.dart'; import 'url.dart'; @@ -27,6 +28,11 @@ bool isUndefined(Object? value) => _isUndefined.call(value) as bool; final _isUndefined = JSFunction("value", "return value === undefined;"); +/// Returns whether or not [value] is the JS `null` value. +bool isNull(Object? value) => _isNull.call(value) as bool; + +final _isNull = JSFunction("value", "return value === null;"); + @JS("Error") external JSClass get jsErrorClass; @@ -233,3 +239,23 @@ Syntax parseSyntax(String? syntax) => switch (syntax) { 'css' => Syntax.css, _ => jsThrow(JsError('Unknown syntax "$syntax".')) }; + +/// The path to the Node.js entrypoint, if one can be located. +String? get entrypointFilename { + if (_requireMain?.filename case var filename?) { + return filename; + } else if (process.argv case [_, String path, ...]) { + return module.createRequire(path).resolve(path); + } else { + return null; + } +} + +@JS("require.main") +external _RequireMain? get _requireMain; + +@JS() +@anonymous +class _RequireMain { + external String? get filename; +} diff --git a/lib/src/js/value.dart b/lib/src/js/value.dart index f8697efea..c57621c45 100644 --- a/lib/src/js/value.dart +++ b/lib/src/js/value.dart @@ -15,6 +15,7 @@ export 'value/color.dart'; export 'value/function.dart'; export 'value/list.dart'; export 'value/map.dart'; +export 'value/mixin.dart'; export 'value/number.dart'; export 'value/string.dart'; @@ -42,6 +43,7 @@ final JSClass valueClass = () { 'assertColor': (Value self, [String? name]) => self.assertColor(name), 'assertFunction': (Value self, [String? name]) => self.assertFunction(name), 'assertMap': (Value self, [String? name]) => self.assertMap(name), + 'assertMixin': (Value self, [String? name]) => self.assertMixin(name), 'assertNumber': (Value self, [String? name]) => self.assertNumber(name), 'assertString': (Value self, [String? name]) => self.assertString(name), 'tryMap': (Value self) => self.tryMap(), diff --git a/lib/src/js/value/calculation.dart b/lib/src/js/value/calculation.dart index 51dfadae8..96e13c5b0 100644 --- a/lib/src/js/value/calculation.dart +++ b/lib/src/js/value/calculation.dart @@ -55,15 +55,10 @@ final JSClass calculationClass = () { if ((value == null && !_isValidClampArg(min)) || (max == null && ![min, value].any(_isValidClampArg))) { jsThrow(JsError('Expected at least one SassString or ' - 'CalculationInterpolation in `${[ - min, - value, - max - ].whereNotNull()}`')); + 'CalculationInterpolation in `${[min, value, max].nonNulls}`')); } - [min, value, max].whereNotNull().forEach(_assertCalculationValue); - return SassCalculation.unsimplified( - 'clamp', [min, value, max].whereNotNull()); + [min, value, max].nonNulls.forEach(_assertCalculationValue); + return SassCalculation.unsimplified('clamp', [min, value, max].nonNulls); } }); @@ -93,7 +88,7 @@ final JSClass calculationOperationClass = () { _assertCalculationValue(left); _assertCalculationValue(right); return SassCalculation.operateInternal(operator, left, right, - inLegacySassFunction: false, simplify: false); + inLegacySassFunction: null, simplify: false, warn: null); }); jsClass.defineMethods({ @@ -109,7 +104,7 @@ final JSClass calculationOperationClass = () { getJSClass(SassCalculation.operateInternal( CalculationOperator.plus, SassNumber(1), SassNumber(1), - inLegacySassFunction: false, simplify: false)) + inLegacySassFunction: null, simplify: false, warn: null)) .injectSuperclass(jsClass); return jsClass; }(); diff --git a/lib/src/js/value/color.dart b/lib/src/js/value/color.dart index 51987f47d..0594b0399 100644 --- a/lib/src/js/value/color.dart +++ b/lib/src/js/value/color.dart @@ -2,67 +2,368 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:js_util'; + import 'package:js/js.dart'; +import 'package:node_interop/js.dart'; -import '../../util/nullable.dart'; -import '../../util/number.dart'; +import '../../deprecation.dart'; +import '../../evaluation_context.dart'; import '../../value.dart'; +import '../immutable.dart'; import '../reflection.dart'; import '../utils.dart'; /// The JavaScript `SassColor` class. final JSClass colorClass = () { - var jsClass = createJSClass('sass.SassColor', (Object self, _Channels color) { - if (color.red != null) { - return SassColor.rgb(fuzzyRound(color.red!), fuzzyRound(color.green!), - fuzzyRound(color.blue!), _handleNullAlpha(color.alpha)); - } else if (color.saturation != null) { - return SassColor.hsl(color.hue!, color.saturation!, color.lightness!, - _handleNullAlpha(color.alpha)); - } else { - return SassColor.hwb(color.hue!, color.whiteness!, color.blackness!, - _handleNullAlpha(color.alpha)); + var jsClass = createJSClass('sass.SassColor', + (Object self, _ConstructionOptions options) { + var constructionSpace = _constructionSpace(options); + switch (constructionSpace) { + case ColorSpace.rgb: + _checkNullAlphaDeprecation(options); + return SassColor.rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hsl: + _checkNullAlphaDeprecation(options); + return SassColor.hsl(options.hue, options.saturation, options.lightness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.hwb: + _checkNullAlphaDeprecation(options); + return SassColor.hwb(options.hue, options.whiteness, options.blackness, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lab: + return SassColor.lab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklab: + return SassColor.oklab(options.lightness, options.a, options.b, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.lch: + return SassColor.lch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.oklch: + return SassColor.oklch(options.lightness, options.chroma, options.hue, + _handleUndefinedAlpha(options.alpha)); + + case ColorSpace.srgb: + return SassColor.srgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.srgbLinear: + return SassColor.srgbLinear(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.displayP3: + return SassColor.displayP3(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.a98Rgb: + return SassColor.a98Rgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.prophotoRgb: + return SassColor.prophotoRgb(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.rec2020: + return SassColor.rec2020(options.red, options.green, options.blue, + _handleUndefinedAlpha(options.alpha)); + + // `xyz` name is mapped to `xyzD65` space. + case ColorSpace.xyzD50: + return SassColor.xyzD50(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + case ColorSpace.xyzD65: + return SassColor.xyzD65(options.x, options.y, options.z, + _handleUndefinedAlpha(options.alpha)); + + default: + throw "Unreachable"; } }); - jsClass.defineMethod('change', (SassColor self, _Channels options) { - if (options.whiteness != null || options.blackness != null) { - return self.changeHwb( - hue: options.hue ?? self.hue, - whiteness: options.whiteness ?? self.whiteness, - blackness: options.blackness ?? self.blackness, - alpha: options.alpha ?? self.alpha); - } else if (options.hue != null || - options.saturation != null || - options.lightness != null) { - return self.changeHsl( - hue: options.hue ?? self.hue, - saturation: options.saturation ?? self.saturation, - lightness: options.lightness ?? self.lightness, - alpha: options.alpha ?? self.alpha); - } else if (options.red != null || - options.green != null || - options.blue != null) { - return self.changeRgb( - red: options.red.andThen(fuzzyRound) ?? self.red, - green: options.green.andThen(fuzzyRound) ?? self.green, - blue: options.blue.andThen(fuzzyRound) ?? self.blue, - alpha: options.alpha ?? self.alpha); - } else { - return self.changeAlpha(options.alpha ?? self.alpha); + jsClass.defineMethods({ + 'equals': (SassColor self, Object other) => self == other, + 'hashCode': (SassColor self) => self.hashCode, + 'toSpace': (SassColor self, String space) => _toSpace(self, space), + 'isInGamut': (SassColor self, [String? space]) => + _toSpace(self, space).isInGamut, + 'toGamut': (SassColor self, _ToGamutOptions options) { + var originalSpace = self.space; + return _toSpace(self, options.space) + .toGamut(GamutMapMethod.fromName(options.method)) + .toSpace(originalSpace); + }, + 'channel': (SassColor self, String channel, [_ChannelOptions? options]) => + _toSpace(self, options?.space).channel(channel), + 'isChannelMissing': (SassColor self, String channel) => + self.isChannelMissing(channel), + 'isChannelPowerless': (SassColor self, String channel, + [_ChannelOptions? options]) => + _toSpace(self, options?.space).isChannelPowerless(channel), + 'change': (SassColor self, _ConstructionOptions options) { + var spaceSetExplicitly = options.space != null; + var space = + spaceSetExplicitly ? ColorSpace.fromName(options.space!) : self.space; + + if (self.isLegacy && !spaceSetExplicitly) { + if (hasProperty(options, 'whiteness') || + hasProperty(options, 'blackness')) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') && + self.space == ColorSpace.hwb) { + space = ColorSpace.hwb; + } else if (hasProperty(options, 'hue') || + hasProperty(options, 'saturation') || + hasProperty(options, 'lightness')) { + space = ColorSpace.hsl; + } else if (hasProperty(options, 'red') || + hasProperty(options, 'green') || + hasProperty(options, 'blue')) { + space = ColorSpace.rgb; + } + if (space != self.space) { + warnForDeprecationFromApi( + "Changing a channel not in this color's space without explicitly specifying " + "the `space` option is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); + } + } + + for (final key in objectKeys(options)) { + if (['alpha', 'space'].contains(key)) continue; + if (!space.channels.any((channel) => channel.name == key)) { + jsThrow(JsError("`$key` is not a valid channel in `$space`.")); + } + } + + var color = self.toSpace(space); + + SassColor changedColor; + + double? changedValue(String channel) { + return _changeComponentValue(color, channel, options); + } + + switch (space) { + case ColorSpace.hsl when spaceSetExplicitly: + changedColor = SassColor.hsl( + changedValue('hue'), + changedValue('saturation'), + changedValue('lightness'), + changedValue('alpha')); + break; + + case ColorSpace.hsl: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.saturation)) { + _emitColor4ApiNullDeprecation('saturation'); + } else if (isNull(options.lightness)) { + _emitColor4ApiNullDeprecation('lightness'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.hsl( + options.hue ?? color.channel('hue'), + options.saturation ?? color.channel('saturation'), + options.lightness ?? color.channel('lightness'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.hwb when spaceSetExplicitly: + changedColor = SassColor.hwb( + changedValue('hue'), + changedValue('whiteness'), + changedValue('blackness'), + changedValue('alpha')); + break; + + case ColorSpace.hwb: + if (isNull(options.hue)) { + _emitColor4ApiNullDeprecation('hue'); + } else if (isNull(options.whiteness)) { + _emitColor4ApiNullDeprecation('whiteness'); + } else if (isNull(options.blackness)) { + _emitColor4ApiNullDeprecation('blackness'); + } + if (isNull(options.alpha)) _emitNullAlphaDeprecation(); + changedColor = SassColor.hwb( + options.hue ?? color.channel('hue'), + options.whiteness ?? color.channel('whiteness'), + options.blackness ?? color.channel('blackness'), + options.alpha ?? color.channel('alpha')); + + break; + + case ColorSpace.rgb when spaceSetExplicitly: + changedColor = SassColor.rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.rgb: + if (isNull(options.red)) { + _emitColor4ApiNullDeprecation('red'); + } else if (isNull(options.green)) { + _emitColor4ApiNullDeprecation('green'); + } else if (isNull(options.blue)) { + _emitColor4ApiNullDeprecation('blue'); + } + if (isNull(options.alpha)) { + _emitNullAlphaDeprecation(); + } + changedColor = SassColor.rgb( + options.red ?? color.channel('red'), + options.green ?? color.channel('green'), + options.blue ?? color.channel('blue'), + options.alpha ?? color.channel('alpha')); + break; + + case ColorSpace.lab: + changedColor = SassColor.lab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.oklab: + changedColor = SassColor.oklab(changedValue('lightness'), + changedValue('a'), changedValue('b'), changedValue('alpha')); + break; + + case ColorSpace.lch: + changedColor = SassColor.lch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + case ColorSpace.oklch: + changedColor = SassColor.oklch( + changedValue('lightness'), + changedValue('chroma'), + changedValue('hue'), + changedValue('alpha')); + break; + + case ColorSpace.a98Rgb: + changedColor = SassColor.a98Rgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.displayP3: + changedColor = SassColor.displayP3( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.prophotoRgb: + changedColor = SassColor.prophotoRgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.rec2020: + changedColor = SassColor.rec2020( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgb: + changedColor = SassColor.srgb( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + case ColorSpace.srgbLinear: + changedColor = SassColor.srgbLinear( + changedValue('red'), + changedValue('green'), + changedValue('blue'), + changedValue('alpha')); + break; + + case ColorSpace.xyzD50: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + case ColorSpace.xyzD65: + changedColor = SassColor.forSpaceInternal(space, changedValue('x'), + changedValue('y'), changedValue('z'), changedValue('alpha')); + break; + + default: + throw "No space set"; + } + + return changedColor.toSpace(self.space); + }, + 'interpolate': (SassColor self, SassColor color2, + [_InterpolationOptions? options]) { + InterpolationMethod interpolationMethod; + + if (options?.method case var method?) { + var hue = HueInterpolationMethod.values.byName(method); + interpolationMethod = InterpolationMethod(self.space, hue); + } else if (!self.space.isPolar) { + interpolationMethod = InterpolationMethod(self.space); + } else { + interpolationMethod = + InterpolationMethod(self.space, HueInterpolationMethod.shorter); + } + + return self.interpolate(color2, interpolationMethod, + weight: options?.weight); } }); jsClass.defineGetters({ - 'red': (SassColor self) => self.red, - 'green': (SassColor self) => self.green, - 'blue': (SassColor self) => self.blue, - 'hue': (SassColor self) => self.hue, - 'saturation': (SassColor self) => self.saturation, - 'lightness': (SassColor self) => self.lightness, - 'whiteness': (SassColor self) => self.whiteness, - 'blackness': (SassColor self) => self.blackness, + 'red': (SassColor self) { + _emitColor4ApiChannelDeprecation('red'); + return self.red; + }, + 'green': (SassColor self) { + _emitColor4ApiChannelDeprecation('green'); + return self.green; + }, + 'blue': (SassColor self) { + _emitColor4ApiChannelDeprecation('blue'); + return self.blue; + }, + 'hue': (SassColor self) { + _emitColor4ApiChannelDeprecation('hue'); + return self.hue; + }, + 'saturation': (SassColor self) { + _emitColor4ApiChannelDeprecation('saturation'); + return self.saturation; + }, + 'lightness': (SassColor self) { + _emitColor4ApiChannelDeprecation('lightness'); + return self.lightness; + }, + 'whiteness': (SassColor self) { + _emitColor4ApiChannelDeprecation('whiteness'); + return self.whiteness; + }, + 'blackness': (SassColor self) { + _emitColor4ApiChannelDeprecation('blackness'); + return self.blackness; + }, 'alpha': (SassColor self) => self.alpha, + 'space': (SassColor self) => self.space.name, + 'isLegacy': (SassColor self) => self.isLegacy, + 'channelsOrNull': (SassColor self) => ImmutableList(self.channelsOrNull), + 'channels': (SassColor self) => ImmutableList(self.channels) }); getJSClass(SassColor.rgb(0, 0, 0)).injectSuperclass(jsClass); @@ -71,20 +372,111 @@ final JSClass colorClass = () { /// Converts an undefined [alpha] to 1. /// -/// This ensures that an explicitly null alpha will produce a deprecation -/// warning when passed to the Dart API. -num? _handleNullAlpha(num? alpha) => isUndefined(alpha) ? 1 : alpha; +/// This ensures that an explicitly null alpha will be treated as a missing +/// component. +double? _handleUndefinedAlpha(double? alpha) => isUndefined(alpha) ? 1 : alpha; + +/// This procedure takes a `channel` name, an object `changes` and a SassColor +/// `initial` and returns the result of applying the change for `channel` to +/// `initial`. +double? _changeComponentValue( + SassColor initial, String channel, _ConstructionOptions changes) => + hasProperty(changes, channel) && !isUndefined(getProperty(changes, channel)) + ? getProperty(changes, channel) + : initial.channel(channel); + +/// Determines the construction space based on the provided options. +ColorSpace _constructionSpace(_ConstructionOptions options) { + if (options.space != null) return ColorSpace.fromName(options.space!); + if (options.red != null) return ColorSpace.rgb; + if (options.saturation != null) return ColorSpace.hsl; + if (options.whiteness != null) return ColorSpace.hwb; + throw "No color space found"; +} + +// Return a SassColor in a named space, or in its original space. +SassColor _toSpace(SassColor self, String? space) { + return self.toSpace(ColorSpace.fromName(space ?? self.space.name)); +} + +// If alpha is explicitly null and space is not set, emit deprecation. +void _checkNullAlphaDeprecation(_ConstructionOptions options) { + if (!isUndefined(options.alpha) && + identical(options.alpha, null) && + identical(options.space, null)) { + _emitNullAlphaDeprecation(); + } +} + +// Warn users about null-alpha deprecation. +void _emitNullAlphaDeprecation() { + warnForDeprecationFromApi( + "Passing `alpha: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/null-alpha", + Deprecation.nullAlpha); +} + +// Warn users about `null` channel values without setting `space`. +void _emitColor4ApiNullDeprecation(String name) { + warnForDeprecationFromApi( + "Passing `$name: null` without setting `space` is deprecated." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} + +// Warn users about legacy color channel getters. +void _emitColor4ApiChannelDeprecation(String name) { + warnForDeprecationFromApi( + "$name is deprecated, use `channel` instead." + "\n" + "More info: https://sass-lang.com/d/color-4-api", + Deprecation.color4Api); +} @JS() @anonymous class _Channels { - external num? get red; - external num? get green; - external num? get blue; - external num? get hue; - external num? get saturation; - external num? get lightness; - external num? get whiteness; - external num? get blackness; - external num? get alpha; + external double? get red; + external double? get green; + external double? get blue; + external double? get hue; + external double? get saturation; + external double? get lightness; + external double? get whiteness; + external double? get blackness; + external double? get alpha; + external double? get a; + external double? get b; + external double? get x; + external double? get y; + external double? get z; + external double? get chroma; +} + +@JS() +@anonymous +class _ConstructionOptions extends _Channels { + external String? get space; +} + +@JS() +@anonymous +class _ChannelOptions { + external String? get space; +} + +@JS() +@anonymous +class _ToGamutOptions { + external String? get space; + external String get method; +} + +@JS() +@anonymous +class _InterpolationOptions { + external double? get weight; + external String? get method; } diff --git a/lib/src/js/value/mixin.dart b/lib/src/js/value/mixin.dart new file mode 100644 index 000000000..cc55f3eb4 --- /dev/null +++ b/lib/src/js/value/mixin.dart @@ -0,0 +1,23 @@ +// 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 'package:node_interop/js.dart'; + +import '../../callable.dart'; +import '../../value.dart'; +import '../reflection.dart'; +import '../utils.dart'; + +/// The JavaScript `SassMixin` class. +final JSClass mixinClass = () { + var jsClass = createJSClass('sass.SassMixin', (Object self) { + jsThrow(JsError( + 'It is not possible to construct a SassMixin through the JavaScript ' + 'API')); + }); + + getJSClass(SassMixin(Callable('f', '', (_) => sassNull))) + .injectSuperclass(jsClass); + return jsClass; +}(); diff --git a/lib/src/js/visitor/expression.dart b/lib/src/js/visitor/expression.dart new file mode 100644 index 000000000..88fa684de --- /dev/null +++ b/lib/src/js/visitor/expression.dart @@ -0,0 +1,74 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:js/js.dart'; + +import '../../ast/sass.dart'; +import '../../visitor/interface/expression.dart'; + +/// A wrapper around a JS object that implements the [ExpressionVisitor] methods. +class JSExpressionVisitor implements ExpressionVisitor { + final JSExpressionVisitorObject _inner; + + JSExpressionVisitor(this._inner); + + Object? visitBinaryOperationExpression(BinaryOperationExpression node) => + _inner.visitBinaryOperationExpression(node); + Object? visitBooleanExpression(BooleanExpression node) => + _inner.visitBooleanExpression(node); + Object? visitColorExpression(ColorExpression node) => + _inner.visitColorExpression(node); + Object? visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + _inner.visitInterpolatedFunctionExpression(node); + Object? visitFunctionExpression(FunctionExpression node) => + _inner.visitFunctionExpression(node); + Object? visitIfExpression(IfExpression node) => + _inner.visitIfExpression(node); + Object? visitListExpression(ListExpression node) => + _inner.visitListExpression(node); + Object? visitMapExpression(MapExpression node) => + _inner.visitMapExpression(node); + Object? visitNullExpression(NullExpression node) => + _inner.visitNullExpression(node); + Object? visitNumberExpression(NumberExpression node) => + _inner.visitNumberExpression(node); + Object? visitParenthesizedExpression(ParenthesizedExpression node) => + _inner.visitParenthesizedExpression(node); + Object? visitSelectorExpression(SelectorExpression node) => + _inner.visitSelectorExpression(node); + Object? visitStringExpression(StringExpression node) => + _inner.visitStringExpression(node); + Object? visitSupportsExpression(SupportsExpression node) => + _inner.visitSupportsExpression(node); + Object? visitUnaryOperationExpression(UnaryOperationExpression node) => + _inner.visitUnaryOperationExpression(node); + Object? visitValueExpression(ValueExpression node) => + _inner.visitValueExpression(node); + Object? visitVariableExpression(VariableExpression node) => + _inner.visitVariableExpression(node); +} + +@JS() +class JSExpressionVisitorObject { + external Object? visitBinaryOperationExpression( + BinaryOperationExpression node); + external Object? visitBooleanExpression(BooleanExpression node); + external Object? visitColorExpression(ColorExpression node); + external Object? visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node); + external Object? visitFunctionExpression(FunctionExpression node); + external Object? visitIfExpression(IfExpression node); + external Object? visitListExpression(ListExpression node); + external Object? visitMapExpression(MapExpression node); + external Object? visitNullExpression(NullExpression node); + external Object? visitNumberExpression(NumberExpression node); + external Object? visitParenthesizedExpression(ParenthesizedExpression node); + external Object? visitSelectorExpression(SelectorExpression node); + external Object? visitStringExpression(StringExpression node); + external Object? visitSupportsExpression(SupportsExpression node); + external Object? visitUnaryOperationExpression(UnaryOperationExpression node); + external Object? visitValueExpression(ValueExpression node); + external Object? visitVariableExpression(VariableExpression node); +} diff --git a/lib/src/js/visitor/statement.dart b/lib/src/js/visitor/statement.dart new file mode 100644 index 000000000..71ee96945 --- /dev/null +++ b/lib/src/js/visitor/statement.dart @@ -0,0 +1,79 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:js/js.dart'; + +import '../../ast/sass.dart'; +import '../../visitor/interface/statement.dart'; + +/// A wrapper around a JS object that implements the [StatementVisitor] methods. +class JSStatementVisitor implements StatementVisitor { + final JSStatementVisitorObject _inner; + + JSStatementVisitor(this._inner); + + Object? visitAtRootRule(AtRootRule node) => _inner.visitAtRootRule(node); + Object? visitAtRule(AtRule node) => _inner.visitAtRule(node); + Object? visitContentBlock(ContentBlock node) => + _inner.visitContentBlock(node); + Object? visitContentRule(ContentRule node) => _inner.visitContentRule(node); + Object? visitDebugRule(DebugRule node) => _inner.visitDebugRule(node); + Object? visitDeclaration(Declaration node) => _inner.visitDeclaration(node); + Object? visitEachRule(EachRule node) => _inner.visitEachRule(node); + Object? visitErrorRule(ErrorRule node) => _inner.visitErrorRule(node); + Object? visitExtendRule(ExtendRule node) => _inner.visitExtendRule(node); + Object? visitForRule(ForRule node) => _inner.visitForRule(node); + Object? visitForwardRule(ForwardRule node) => _inner.visitForwardRule(node); + Object? visitFunctionRule(FunctionRule node) => + _inner.visitFunctionRule(node); + Object? visitIfRule(IfRule node) => _inner.visitIfRule(node); + Object? visitImportRule(ImportRule node) => _inner.visitImportRule(node); + Object? visitIncludeRule(IncludeRule node) => _inner.visitIncludeRule(node); + Object? visitLoudComment(LoudComment node) => _inner.visitLoudComment(node); + Object? visitMediaRule(MediaRule node) => _inner.visitMediaRule(node); + Object? visitMixinRule(MixinRule node) => _inner.visitMixinRule(node); + Object? visitReturnRule(ReturnRule node) => _inner.visitReturnRule(node); + Object? visitSilentComment(SilentComment node) => + _inner.visitSilentComment(node); + Object? visitStyleRule(StyleRule node) => _inner.visitStyleRule(node); + Object? visitStylesheet(Stylesheet node) => _inner.visitStylesheet(node); + Object? visitSupportsRule(SupportsRule node) => + _inner.visitSupportsRule(node); + Object? visitUseRule(UseRule node) => _inner.visitUseRule(node); + Object? visitVariableDeclaration(VariableDeclaration node) => + _inner.visitVariableDeclaration(node); + Object? visitWarnRule(WarnRule node) => _inner.visitWarnRule(node); + Object? visitWhileRule(WhileRule node) => _inner.visitWhileRule(node); +} + +@JS() +class JSStatementVisitorObject { + external Object? visitAtRootRule(AtRootRule node); + external Object? visitAtRule(AtRule node); + external Object? visitContentBlock(ContentBlock node); + external Object? visitContentRule(ContentRule node); + external Object? visitDebugRule(DebugRule node); + external Object? visitDeclaration(Declaration node); + external Object? visitEachRule(EachRule node); + external Object? visitErrorRule(ErrorRule node); + external Object? visitExtendRule(ExtendRule node); + external Object? visitForRule(ForRule node); + external Object? visitForwardRule(ForwardRule node); + external Object? visitFunctionRule(FunctionRule node); + external Object? visitIfRule(IfRule node); + external Object? visitImportRule(ImportRule node); + external Object? visitIncludeRule(IncludeRule node); + external Object? visitLoudComment(LoudComment node); + external Object? visitMediaRule(MediaRule node); + external Object? visitMixinRule(MixinRule node); + external Object? visitReturnRule(ReturnRule node); + external Object? visitSilentComment(SilentComment node); + external Object? visitStyleRule(StyleRule node); + external Object? visitStylesheet(Stylesheet node); + external Object? visitSupportsRule(SupportsRule node); + external Object? visitUseRule(UseRule node); + external Object? visitVariableDeclaration(VariableDeclaration node); + external Object? visitWarnRule(WarnRule node); + external Object? visitWhileRule(WhileRule node); +} diff --git a/lib/src/logger.dart b/lib/src/logger.dart index a329b3b79..7569b6e7c 100644 --- a/lib/src/logger.dart +++ b/lib/src/logger.dart @@ -7,7 +7,7 @@ import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; import 'deprecation.dart'; -import 'logger/deprecation_handling.dart'; +import 'logger/deprecation_processing.dart'; import 'logger/stderr.dart'; /// An interface for loggers that print messages produced by Sass stylesheets. @@ -37,6 +37,39 @@ abstract class Logger { void debug(String message, SourceSpan span); } +/// A base class for loggers that support the [Deprecation] object, rather than +/// just a boolean flag for whether a warnings is a deprecation warning or not. +/// +/// In Dart Sass 2.0.0, we will eliminate this interface and change +/// [Logger.warn]'s signature to match that of [internalWarn]. This is used +/// in the meantime to provide access to the [Deprecation] object to internal +/// loggers. +/// +/// Implementers should override the protected [internalWarn] method instead of +/// [warn]. +@internal +abstract class LoggerWithDeprecationType implements Logger { + /// This forwards all calls to [internalWarn]. + /// + /// For non-user deprecation warnings, the [warnForDeprecation] extension + /// method should be called instead. + void warn(String message, + {FileSpan? span, Trace? trace, bool deprecation = false}) { + internalWarn(message, + span: span, + trace: trace, + deprecation: deprecation ? Deprecation.userAuthored : null); + } + + /// Equivalent to [Logger.warn], but for internal loggers that support + /// the [Deprecation] object. + /// + /// Subclasses of this logger should override this method instead of [warn]. + @protected + void internalWarn(String message, + {FileSpan? span, Trace? trace, Deprecation? deprecation}); +} + /// An extension to add a `warnForDeprecation` method to loggers without /// making a breaking API change. @internal @@ -44,9 +77,11 @@ extension WarnForDeprecation on Logger { /// Emits a deprecation warning for [deprecation] with the given [message]. void warnForDeprecation(Deprecation deprecation, String message, {FileSpan? span, Trace? trace}) { - if (this case DeprecationHandlingLogger self) { - self.warnForDeprecation(deprecation, message, span: span, trace: trace); - } else if (!deprecation.isFuture) { + if (deprecation.isFuture && this is! DeprecationProcessingLogger) return; + if (this case LoggerWithDeprecationType self) { + self.internalWarn(message, + span: span, trace: trace, deprecation: deprecation); + } else { warn(message, span: span, trace: trace, deprecation: true); } } diff --git a/lib/src/logger/deprecation_handling.dart b/lib/src/logger/deprecation_processing.dart similarity index 51% rename from lib/src/logger/deprecation_handling.dart rename to lib/src/logger/deprecation_processing.dart index 4b185651b..d83c2ef80 100644 --- a/lib/src/logger/deprecation_handling.dart +++ b/lib/src/logger/deprecation_processing.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:collection/collection.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; @@ -11,17 +12,21 @@ import '../exception.dart'; import '../logger.dart'; /// The maximum number of repetitions of the same warning -/// [DeprecationHandlingLogger] will emit before hiding the rest. +/// [DeprecationProcessingLogger] will emit before hiding the rest. const _maxRepetitions = 5; /// A logger that wraps an inner logger to have special handling for -/// deprecation warnings. -final class DeprecationHandlingLogger implements Logger { +/// deprecation warnings, silencing, making fatal, enabling future, and/or +/// limiting repetition based on its inputs. +final class DeprecationProcessingLogger extends LoggerWithDeprecationType { /// A map of how many times each deprecation has been emitted by this logger. final _warningCounts = {}; final Logger _inner; + /// Deprecation warnings of these types will be ignored. + final Set silenceDeprecations; + /// Deprecation warnings of one of these types will cause an error to be /// thrown. /// @@ -36,27 +41,82 @@ final class DeprecationHandlingLogger implements Logger { /// [_maxRepetitions]. final bool limitRepetition; - DeprecationHandlingLogger(this._inner, - {required this.fatalDeprecations, + DeprecationProcessingLogger(this._inner, + {required this.silenceDeprecations, + required this.fatalDeprecations, required this.futureDeprecations, this.limitRepetition = true}); - void warn(String message, - {FileSpan? span, Trace? trace, bool deprecation = false}) { - _inner.warn(message, span: span, trace: trace, deprecation: deprecation); + /// Warns if any of the deprecations options are incompatible or unnecessary. + void validate() { + for (var deprecation in fatalDeprecations) { + switch (deprecation) { + case Deprecation(isFuture: true) + when !futureDeprecations.contains(deprecation): + warn('Future $deprecation deprecation must be enabled before it can ' + 'be made fatal.'); + case Deprecation(obsoleteIn: Version()): + warn('$deprecation deprecation is obsolete, so does not need to be ' + 'made fatal.'); + case _ when silenceDeprecations.contains(deprecation): + warn('Ignoring setting to silence $deprecation deprecation, since it ' + 'has also been made fatal.'); + default: + // No warning. + } + } + + for (var deprecation in silenceDeprecations) { + switch (deprecation) { + case Deprecation.userAuthored: + warn('User-authored deprecations should not be silenced.'); + case Deprecation(obsoleteIn: Version()): + warn('$deprecation deprecation is obsolete. If you were previously ' + 'silencing it, your code may now behave in unexpected ways.'); + case Deprecation(isFuture: true) + when futureDeprecations.contains(deprecation): + warn('Conflicting options for future $deprecation deprecation cancel ' + 'each other out.'); + case Deprecation(isFuture: true): + warn('Future $deprecation deprecation is not yet active, so ' + 'silencing it is unnecessary.'); + default: + // No warning. + } + } + + for (var deprecation in futureDeprecations) { + if (!deprecation.isFuture) { + warn('$deprecation is not a future deprecation, so it does not need to ' + 'be explicitly enabled.'); + } + } + } + + void internalWarn(String message, + {FileSpan? span, Trace? trace, Deprecation? deprecation}) { + if (deprecation != null) { + _handleDeprecation(deprecation, message, span: span, trace: trace); + } else { + _inner.warn(message, span: span, trace: trace); + } } /// Processes a deprecation warning. /// /// If [deprecation] is in [fatalDeprecations], this shows an error. /// - /// If it's a future deprecation that hasn't been opted into or its a + /// If it's a future deprecation that hasn't been opted into or it's a /// deprecation that's already been warned for [_maxReptitions] times and /// [limitRepetitions] is true, the warning is dropped. /// /// Otherwise, this is passed on to [warn]. - void warnForDeprecation(Deprecation deprecation, String message, + void _handleDeprecation(Deprecation deprecation, String message, {FileSpan? span, Trace? trace}) { + if (deprecation.isFuture && !futureDeprecations.contains(deprecation)) { + return; + } + if (fatalDeprecations.contains(deprecation)) { message += "\n\nThis is only an error because you've set the " '$deprecation deprecation to be fatal.\n' @@ -67,10 +127,7 @@ final class DeprecationHandlingLogger implements Logger { _ => SassScriptException(message) }; } - - if (deprecation.isFuture && !futureDeprecations.contains(deprecation)) { - return; - } + if (silenceDeprecations.contains(deprecation)) return; if (limitRepetition) { var count = @@ -78,7 +135,12 @@ final class DeprecationHandlingLogger implements Logger { if (count > _maxRepetitions) return; } - warn(message, span: span, trace: trace, deprecation: true); + if (_inner case LoggerWithDeprecationType inner) { + inner.internalWarn(message, + span: span, trace: trace, deprecation: deprecation); + } else { + _inner.warn(message, span: span, trace: trace, deprecation: true); + } } void debug(String message, SourceSpan span) => _inner.debug(message, span); diff --git a/lib/src/logger/js_to_dart.dart b/lib/src/logger/js_to_dart.dart index aa58a243d..4a11bf546 100644 --- a/lib/src/logger/js_to_dart.dart +++ b/lib/src/logger/js_to_dart.dart @@ -7,11 +7,13 @@ import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:term_glyph/term_glyph.dart' as glyph; +import '../deprecation.dart'; import '../logger.dart'; +import '../js/deprecations.dart' show deprecations; import '../js/logger.dart'; /// A wrapper around a [JSLogger] that exposes it as a Dart [Logger]. -final class JSToDartLogger implements Logger { +final class JSToDartLogger extends LoggerWithDeprecationType { /// The wrapped logger object. final JSLogger? _node; @@ -27,19 +29,20 @@ final class JSToDartLogger implements Logger { JSToDartLogger(this._node, this._fallback, {bool? ascii}) : _ascii = ascii ?? glyph.ascii; - void warn(String message, - {FileSpan? span, Trace? trace, bool deprecation = false}) { + void internalWarn(String message, + {FileSpan? span, Trace? trace, Deprecation? deprecation}) { if (_node?.warn case var warn?) { warn( message, WarnOptions( span: span ?? (undefined as SourceSpan?), stack: trace.toString(), - deprecation: deprecation)); + deprecation: deprecation != null, + deprecationType: deprecations[deprecation?.id])); } else { _withAscii(() { _fallback.warn(message, - span: span, trace: trace, deprecation: deprecation); + span: span, trace: trace, deprecation: deprecation != null); }); } } diff --git a/lib/src/parse/README.md b/lib/src/parse/README.md new file mode 100644 index 000000000..32f5ed357 --- /dev/null +++ b/lib/src/parse/README.md @@ -0,0 +1,33 @@ +# Sass Parser + +This directory contains various parsers used by Sass. The two most relevant +classes are: + +* [`Parser`]: The base class of all other parsers, which includes basic + infrastructure, utilities, and methods for parsing common CSS constructs that + appear across multiple different specific parsers. + + [`Parser`]: parser.dart + +* [`StylesheetParser`]: The base class specifically for the initial stylesheet + parse. Almost all of the logic for parsing Sass files, both statement- and + expression-level, lives here. Only places where individual syntaxes differ + from one another are left abstract or overridden by subclasses. + + [`StylesheetParser`]: stylesheet.dart + +All Sass parsing is done by hand using the [`string_scanner`] package, which we +use to read the source [code-unit]-by-code-unit while also tracking source span +information which we can then use to report errors and generate source maps. We +don't use any kind of parser generator, partly because Sass's grammar requires +arbitrary backtracking in various places and partly because handwritten code is +often easier to read and debug. + +[`string_scanner`]: https://pub.dev/packages/string_scanner +[code-unit]: https://developer.mozilla.org/en-US/docs/Glossary/Code_unit + +The parser is simple recursive descent. There's usually a method for each +logical production that either consumes text and returns its corresponding AST +node or throws an exception; in some cases, a method (conventionally beginning +with `try`) will instead consume text and return a node if it matches and return +null without consuming anything if it doesn't. diff --git a/lib/src/parse/at_root_query.dart b/lib/src/parse/at_root_query.dart index 11eee11f2..f69501ea6 100644 --- a/lib/src/parse/at_root_query.dart +++ b/lib/src/parse/at_root_query.dart @@ -5,16 +5,11 @@ import 'package:charcode/charcode.dart'; import '../ast/sass.dart'; -import '../interpolation_map.dart'; -import '../logger.dart'; import 'parser.dart'; /// A parser for `@at-root` queries. class AtRootQueryParser extends Parser { - AtRootQueryParser(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) - : super(contents, - url: url, logger: logger, interpolationMap: interpolationMap); + AtRootQueryParser(super.contents, {super.url, super.interpolationMap}); AtRootQuery parse() { return wrapSpanFormatException(() { diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index 6ed9123b7..4fd9ae2df 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -7,7 +7,7 @@ import 'package:string_scanner/string_scanner.dart'; import '../ast/sass.dart'; import '../functions.dart'; -import '../logger.dart'; +import '../interpolation_buffer.dart'; import 'scss.dart'; /// The set of all function names disallowed in plain CSS. @@ -31,10 +31,11 @@ final _disallowedFunctionNames = class CssParser extends ScssParser { bool get plainCss => true; - CssParser(String contents, {Object? url, Logger? logger}) - : super(contents, url: url, logger: logger); + CssParser(super.contents, {super.url}); + + bool silentComment() { + if (inExpression) return false; - void silentComment() { var start = scanner.state; super.silentComment(); error("Silent comments aren't allowed in plain CSS.", @@ -42,7 +43,7 @@ class CssParser extends ScssParser { } Statement atRule(Statement child(), {bool root = false}) { - // NOTE: this logic is largely duplicated in CssParser.atRule. Most changes + // NOTE: this logic is largely duplicated in StylesheetParser.atRule. Most changes // here should be mirrored there. var start = scanner.state; @@ -65,7 +66,7 @@ class CssParser extends ScssParser { "return" || "warn" || "while" => - _forbiddenAtRoot(start), + _forbiddenAtRule(start), "import" => _cssImportRule(start), "media" => mediaRule(start), "-moz-document" => mozDocumentRule(start, name), @@ -75,7 +76,7 @@ class CssParser extends ScssParser { } /// Throws an error for a forbidden at-rule. - Never _forbiddenAtRoot(LineScannerState start) { + Never _forbiddenAtRule(LineScannerState start) { almostAnyValue(); error("This at-rule isn't allowed in plain CSS.", scanner.spanFrom(start)); } @@ -86,18 +87,38 @@ class CssParser extends ScssParser { ImportRule _cssImportRule(LineScannerState start) { var urlStart = scanner.state; var url = switch (scanner.peekChar()) { - $u || $U => dynamicUrl(), + $u || $U => switch (dynamicUrl()) { + StringExpression string => string.text, + InterpolatedFunctionExpression( + :var name, + arguments: ArgumentInvocation( + positional: [StringExpression string], + named: Map(isEmpty: true), + rest: null, + keywordRest: null, + ), + :var span + ) => + (InterpolationBuffer() + ..addInterpolation(name) + ..writeCharCode($lparen) + ..addInterpolation(string.asInterpolation()) + ..writeCharCode($rparen)) + .interpolation(span), + // This shouldn't be reachable. + var expression => + error("Unsupported plain CSS import.", expression.span) + }, _ => StringExpression(interpolatedString().asInterpolation(static: true)) + .text }; - var urlSpan = scanner.spanFrom(urlStart); whitespace(); var modifiers = tryImportModifiers(); expectStatementSeparator("@import rule"); - return ImportRule([ - StaticImport(Interpolation([url], urlSpan), scanner.spanFrom(urlStart), - modifiers: modifiers) - ], scanner.spanFrom(start)); + return ImportRule( + [StaticImport(url, scanner.spanFrom(urlStart), modifiers: modifiers)], + scanner.spanFrom(start)); } ParenthesizedExpression parentheses() { diff --git a/lib/src/parse/keyframe_selector.dart b/lib/src/parse/keyframe_selector.dart index 71908c3e3..fb69ee638 100644 --- a/lib/src/parse/keyframe_selector.dart +++ b/lib/src/parse/keyframe_selector.dart @@ -4,17 +4,12 @@ import 'package:charcode/charcode.dart'; -import '../interpolation_map.dart'; -import '../logger.dart'; import '../util/character.dart'; import 'parser.dart'; /// A parser for `@keyframes` block selectors. class KeyframeSelectorParser extends Parser { - KeyframeSelectorParser(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) - : super(contents, - url: url, logger: logger, interpolationMap: interpolationMap); + KeyframeSelectorParser(super.contents, {super.url, super.interpolationMap}); List parse() { return wrapSpanFormatException(() { diff --git a/lib/src/parse/media_query.dart b/lib/src/parse/media_query.dart index be86a1994..89d854161 100644 --- a/lib/src/parse/media_query.dart +++ b/lib/src/parse/media_query.dart @@ -5,17 +5,12 @@ import 'package:charcode/charcode.dart'; import '../ast/css.dart'; -import '../interpolation_map.dart'; -import '../logger.dart'; import '../utils.dart'; import 'parser.dart'; /// A parser for `@media` queries. class MediaQueryParser extends Parser { - MediaQueryParser(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) - : super(contents, - url: url, logger: logger, interpolationMap: interpolationMap); + MediaQueryParser(super.contents, {super.url, super.interpolationMap}); List parse() { return wrapSpanFormatException(() { diff --git a/lib/src/parse/parser.dart b/lib/src/parse/parser.dart index 6dd17c80d..19ef3971c 100644 --- a/lib/src/parse/parser.dart +++ b/lib/src/parse/parser.dart @@ -10,7 +10,6 @@ import 'package:string_scanner/string_scanner.dart'; import '../exception.dart'; import '../interpolation_map.dart'; import '../io.dart'; -import '../logger.dart'; import '../util/character.dart'; import '../util/lazy_file_span.dart'; import '../util/map.dart'; @@ -25,10 +24,6 @@ class Parser { /// The scanner that scans through the text being parsed. final SpanScanner scanner; - /// The logger to use when emitting warnings. - @protected - final Logger logger; - /// A map used to map source spans in the text being parsed back to their /// original locations in the source file, if this isn't being parsed directly /// from source. @@ -37,13 +32,12 @@ class Parser { /// Parses [text] as a CSS identifier and returns the result. /// /// Throws a [SassFormatException] if parsing fails. - static String parseIdentifier(String text, {Logger? logger}) => - Parser(text, logger: logger)._parseIdentifier(); + static String parseIdentifier(String text) => Parser(text)._parseIdentifier(); /// Returns whether [text] is a valid CSS identifier. - static bool isIdentifier(String text, {Logger? logger}) { + static bool isIdentifier(String text) { try { - parseIdentifier(text, logger: logger); + parseIdentifier(text); return true; } on SassFormatException { return false; @@ -53,14 +47,12 @@ class Parser { /// Returns whether [text] starts like a variable declaration. /// /// Ignores everything after the `:`. - static bool isVariableDeclarationLike(String text, {Logger? logger}) => - Parser(text, logger: logger)._isVariableDeclarationLike(); + static bool isVariableDeclarationLike(String text) => + Parser(text)._isVariableDeclarationLike(); @protected - Parser(String contents, - {Object? url, Logger? logger, InterpolationMap? interpolationMap}) + Parser(String contents, {Object? url, InterpolationMap? interpolationMap}) : scanner = SpanScanner(contents, sourceUrl: url), - logger = logger ?? const Logger.stderr(), _interpolationMap = interpolationMap; String _parseIdentifier() { @@ -114,8 +106,7 @@ class Parser { switch (scanner.peekChar(1)) { case $slash: - silentComment(); - return true; + return silentComment(); case $asterisk: loudComment(); return true; @@ -134,13 +125,17 @@ class Parser { whitespace(); } - /// Consumes and ignores a silent (Sass-style) comment. + /// Consumes and ignores a single silent (Sass-style) comment, not including + /// the trailing newline. + /// + /// Returns whether the comment was consumed. @protected - void silentComment() { + bool silentComment() { scanner.expect("//"); while (!scanner.isDone && !scanner.peekChar().isNewline) { scanner.readChar(); } + return true; } /// Consumes and ignores a loud (CSS-style) comment. @@ -656,13 +651,9 @@ class Parser { var span = scanner.spanFrom(state); return _interpolationMap == null ? span - : LazyFileSpan(() => _interpolationMap!.mapSpan(span)); + : LazyFileSpan(() => _interpolationMap.mapSpan(span)); } - /// Prints a warning to standard error, associated with [span]. - @protected - void warn(String message, FileSpan span) => logger.warn(message, span: span); - /// Throws an error associated with [span]. /// /// If [trace] is passed, attaches it as the error's stack trace. @@ -716,14 +707,6 @@ class Parser { throwWithTrace(map.mapException(error), error, stackTrace); } - } on SourceSpanFormatException catch (error, stackTrace) { - var span = error.span as FileSpan; - if (startsWithIgnoreCase(error.message, "expected")) { - span = _adjustExceptionSpan(span); - } - - throwWithTrace( - SassFormatException(error.message, span), error, stackTrace); } on MultiSourceSpanFormatException catch (error, stackTrace) { var span = error.span as FileSpan; var secondarySpans = error.secondarySpans.cast(); @@ -740,6 +723,14 @@ class Parser { error.message, span, error.primaryLabel, secondarySpans), error, stackTrace); + } on SourceSpanFormatException catch (error, stackTrace) { + var span = error.span as FileSpan; + if (startsWithIgnoreCase(error.message, "expected")) { + span = _adjustExceptionSpan(span); + } + + throwWithTrace( + SassFormatException(error.message, span), error, stackTrace); } } diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index 95fd054c5..55b2086c9 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -6,8 +6,8 @@ import 'package:charcode/charcode.dart'; import 'package:string_scanner/string_scanner.dart'; import '../ast/sass.dart'; +import '../exception.dart'; import '../interpolation_buffer.dart'; -import '../logger.dart'; import '../util/character.dart'; import '../value.dart'; import 'stylesheet.dart'; @@ -38,8 +38,7 @@ class SassParser extends StylesheetParser { bool get indented => true; - SassParser(String contents, {Object? url, Logger? logger}) - : super(contents, url: url, logger: logger); + SassParser(super.contents, {super.url}); Interpolation styleRuleSelector() { var start = scanner.state; @@ -100,7 +99,7 @@ class SassParser extends StylesheetParser { // Serialize [url] as a Sass string because [StaticImport] expects it to // include quotes. return StaticImport( - Interpolation([SassString(url).toString()], span), span); + Interpolation.plain(SassString(url).toString(), span), span); } else { try { return DynamicImport(parseImportUrl(url), span); @@ -249,7 +248,43 @@ class SassParser extends StylesheetParser { case $hash: if (scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); + } else { + buffer.writeCharCode(scanner.readChar()); + } + + case $asterisk: + if (scanner.peekChar(1) == $slash) { + buffer.writeCharCode(scanner.readChar()); + buffer.writeCharCode(scanner.readChar()); + var span = scanner.spanFrom(start); + whitespace(); + + // For backwards compatibility, allow additional comments after + // the initial comment is closed. + while (scanner.peekChar().isNewline && + _peekIndentation() > parentIndentation) { + while (_lookingAtDoubleNewline()) { + _expectNewline(); + } + _readIndentation(); + whitespace(); + } + + if (!scanner.isDone && !scanner.peekChar().isNewline) { + var errorStart = scanner.state; + while (!scanner.isDone && !scanner.peekChar().isNewline) { + scanner.readChar(); + } + throw MultiSpanSassFormatException( + "Unexpected text after end of comment", + scanner.spanFrom(errorStart), + "extra text", + {span: "comment"}); + } else { + return LoudComment(buffer.interpolation(span)); + } } else { buffer.writeCharCode(scanner.readChar()); } @@ -270,7 +305,6 @@ class SassParser extends StylesheetParser { _readIndentation(); } - if (!buffer.trailingString.trimRight().endsWith("*/")) buffer.write(" */"); return LoudComment(buffer.interpolation(scanner.spanFrom(start))); } diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index cc432e9c8..d8b923104 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -7,7 +7,6 @@ import 'package:charcode/charcode.dart'; import '../ast/sass.dart'; import '../deprecation.dart'; import '../interpolation_buffer.dart'; -import '../logger.dart'; import '../util/character.dart'; import 'stylesheet.dart'; @@ -16,8 +15,7 @@ class ScssParser extends StylesheetParser { bool get indented => false; int get currentIndentation => 0; - ScssParser(String contents, {Object? url, Logger? logger}) - : super(contents, url: url, logger: logger); + ScssParser(super.contents, {super.url}); Interpolation styleRuleSelector() => almostAnyValue(); @@ -45,13 +43,15 @@ class ScssParser extends StylesheetParser { if (scanner.scanChar($at)) { if (scanIdentifier('else', caseSensitive: true)) return true; if (scanIdentifier('elseif', caseSensitive: true)) { - logger.warnForDeprecation( - Deprecation.elseif, - '@elseif is deprecated and will not be supported in future Sass ' - 'versions.\n' - '\n' - 'Recommendation: @else if', - span: scanner.spanFrom(beforeAt)); + warnings.add(( + deprecation: Deprecation.elseif, + message: + '@elseif is deprecated and will not be supported in future Sass ' + 'versions.\n' + '\n' + 'Recommendation: @else if', + span: scanner.spanFrom(beforeAt) + )); scanner.position -= 2; return true; } @@ -156,7 +156,8 @@ class ScssParser extends StylesheetParser { switch (scanner.peekChar()) { case $hash: if (scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); } else { buffer.writeCharCode(scanner.readChar()); } diff --git a/lib/src/parse/selector.dart b/lib/src/parse/selector.dart index 75df8b205..595c0bba7 100644 --- a/lib/src/parse/selector.dart +++ b/lib/src/parse/selector.dart @@ -6,8 +6,6 @@ import 'package:charcode/charcode.dart'; import '../ast/css/value.dart'; import '../ast/selector.dart'; -import '../interpolation_map.dart'; -import '../logger.dart'; import '../util/character.dart'; import '../utils.dart'; import 'parser.dart'; @@ -33,19 +31,23 @@ class SelectorParser extends Parser { /// Whether this parser allows the parent selector `&`. final bool _allowParent; - /// Whether this parser allows placeholder selectors beginning with `%`. - final bool _allowPlaceholder; + /// Whether to parse the selector as plain CSS. + final bool _plainCss; - SelectorParser(String contents, - {Object? url, - Logger? logger, - InterpolationMap? interpolationMap, + /// Creates a parser that parses CSS selectors. + /// + /// If [allowParent] is `false`, this will throw a [SassFormatException] if + /// the selector includes the parent selector `&`. + /// + /// If [plainCss] is `true`, this will parse the selector as a plain CSS + /// selector rather than a Sass selector. + SelectorParser(super.contents, + {super.url, + super.interpolationMap, bool allowParent = true, - bool allowPlaceholder = true}) + bool plainCss = false}) : _allowParent = allowParent, - _allowPlaceholder = allowPlaceholder, - super(contents, - url: url, logger: logger, interpolationMap: interpolationMap); + _plainCss = plainCss; SelectorList parse() { return wrapSpanFormatException(() { @@ -169,7 +171,9 @@ class SelectorParser extends Parser { } } - if (lastCompound != null) { + if (combinators.isNotEmpty && _plainCss) { + scanner.error("expected selector."); + } else if (lastCompound != null) { components.add(ComplexSelectorComponent( lastCompound, combinators, spanFrom(componentStart))); } else if (combinators.isNotEmpty) { @@ -188,8 +192,8 @@ class SelectorParser extends Parser { var start = scanner.state; var components = [_simpleSelector()]; - while (isSimpleSelectorStart(scanner.peekChar())) { - components.add(_simpleSelector(allowParent: false)); + while (_isSimpleSelectorStart(scanner.peekChar())) { + components.add(_simpleSelector(allowParent: _plainCss)); } return CompoundSelector(components, spanFrom(start)); @@ -211,8 +215,8 @@ class SelectorParser extends Parser { return _idSelector(); case $percent: var selector = _placeholderSelector(); - if (!_allowPlaceholder) { - error("Placeholder selectors aren't allowed here.", + if (_plainCss) { + error("Placeholder selectors aren't allowed in plain CSS.", scanner.spanFrom(start)); } return selector; @@ -344,6 +348,11 @@ class SelectorParser extends Parser { var start = scanner.state; scanner.expectChar($ampersand); var suffix = lookingAtIdentifierBody() ? identifierBody() : null; + if (_plainCss && suffix != null) { + scanner.error("Parent selectors can't have suffixes in plain CSS.", + position: start.position, length: scanner.position - start.position); + } + return ParentSelector(spanFrom(start), suffix: suffix); } @@ -461,4 +470,12 @@ class SelectorParser extends Parser { spanFrom(start)); } } + + // Returns whether [character] can start a simple selector in the middle of a + // compound selector. + bool _isSimpleSelectorStart(int? character) => switch (character) { + $asterisk || $lbracket || $dot || $hash || $percent || $colon => true, + $ampersand => _plainCss, + _ => false + }; } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index 23c53952e..94f551d2d 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -13,7 +13,6 @@ import '../color_names.dart'; import '../deprecation.dart'; import '../exception.dart'; import '../interpolation_buffer.dart'; -import '../logger.dart'; import '../util/character.dart'; import '../utils.dart'; import '../util/nullable.dart'; @@ -56,21 +55,30 @@ abstract class StylesheetParser extends Parser { /// Whether the parser is currently within a parenthesized expression. var _inParentheses = false; + /// Whether the parser is currently within an expression. + @protected + bool get inExpression => _inExpression; + var _inExpression = false; + /// A map from all variable names that are assigned with `!global` in the - /// current stylesheet to the nodes where they're defined. + /// current stylesheet to the spans where they're defined. /// /// These are collected at parse time because they affect the variables /// exposed by the module generated for this stylesheet, *even if they aren't /// evaluated*. This allows us to ensure that the stylesheet always exposes /// the same set of variable names no matter how it's evaluated. - final _globalVariables = {}; + final _globalVariables = {}; + + /// Warnings discovered while parsing that should be emitted during + /// evaluation once a proper logger is available. + @protected + final warnings = []; /// The silent comment this parser encountered previously. @protected SilentComment? lastSilentComment; - StylesheetParser(String contents, {Object? url, Logger? logger}) - : super(contents, url: url, logger: logger); + StylesheetParser(super.contents, {super.url}); // ## Statements @@ -92,15 +100,8 @@ abstract class StylesheetParser extends Parser { }); scanner.expectDone(); - /// Ensure that all global variable assignments produce a variable in this - /// stylesheet, even if they aren't evaluated. See sass/language#50. - statements.addAll(_globalVariables.values.map((declaration) => - VariableDeclaration(declaration.name, - NullExpression(declaration.expression.span), declaration.span, - guarded: true))); - - return Stylesheet.internal(statements, scanner.spanFrom(start), - plainCss: plainCss); + return Stylesheet.internal(statements, scanner.spanFrom(start), warnings, + plainCss: plainCss, globalVariables: _globalVariables); }); } @@ -115,20 +116,31 @@ abstract class StylesheetParser extends Parser { return arguments; }); - Expression parseExpression() => _parseSingleProduction(_expression); + (Expression, List) parseExpression() => + (_parseSingleProduction(_expression), warnings); + + SassNumber parseNumber() { + var expression = _parseSingleProduction(_number); + return SassNumber(expression.value, expression.unit); + } - VariableDeclaration parseVariableDeclaration() => - _parseSingleProduction(() => lookingAtIdentifier() - ? _variableDeclarationWithNamespace() - : variableDeclarationWithoutNamespace()); + (VariableDeclaration, List) parseVariableDeclaration() => ( + _parseSingleProduction(() => lookingAtIdentifier() + ? _variableDeclarationWithNamespace() + : variableDeclarationWithoutNamespace()), + warnings + ); - UseRule parseUseRule() => _parseSingleProduction(() { - var start = scanner.state; - scanner.expectChar($at, name: "@-rule"); - expectIdentifier("use"); - whitespace(); - return _useRule(start); - }); + (UseRule, List) parseUseRule() => ( + _parseSingleProduction(() { + var start = scanner.state; + scanner.expectChar($at, name: "@-rule"); + expectIdentifier("use"); + whitespace(); + return _useRule(start); + }), + warnings + ); /// Parses and returns [production] as the entire contents of [scanner]. T _parseSingleProduction(T production()) { @@ -230,11 +242,13 @@ abstract class StylesheetParser extends Parser { switch (identifier()) { case 'default': if (guarded) { - logger.warnForDeprecation( - Deprecation.duplicateVariableFlags, - '!default should only be written once for each variable.\n' - 'This will be an error in Dart Sass 2.0.0.', - span: scanner.spanFrom(flagStart)); + warnings.add(( + deprecation: Deprecation.duplicateVarFlags, + message: + '!default should only be written once for each variable.\n' + 'This will be an error in Dart Sass 2.0.0.', + span: scanner.spanFrom(flagStart) + )); } guarded = true; @@ -243,11 +257,13 @@ abstract class StylesheetParser extends Parser { error("!global isn't allowed for variables in other modules.", scanner.spanFrom(flagStart)); } else if (global) { - logger.warnForDeprecation( - Deprecation.duplicateVariableFlags, - '!global should only be written once for each variable.\n' - 'This will be an error in Dart Sass 2.0.0.', - span: scanner.spanFrom(flagStart)); + warnings.add(( + deprecation: Deprecation.duplicateVarFlags, + message: + '!global should only be written once for each variable.\n' + 'This will be an error in Dart Sass 2.0.0.', + span: scanner.spanFrom(flagStart) + )); } global = true; @@ -265,7 +281,7 @@ abstract class StylesheetParser extends Parser { guarded: guarded, global: global, comment: precedingComment); - if (global) _globalVariables.putIfAbsent(name, () => declaration); + if (global) _globalVariables.putIfAbsent(name, () => declaration.span); return declaration; } @@ -320,10 +336,6 @@ abstract class StylesheetParser extends Parser { /// parsed as a selector and never as a property with nested properties /// beneath it. Statement _declarationOrStyleRule() { - if (plainCss && _inStyleRule && !_inUnknownAtRule) { - return _propertyOrVariableDeclaration(); - } - // The indented syntax allows a single backslash to distinguish a style rule // from old-style property syntax. We don't support old property syntax, but // we do support the backslash because it's easy to do. @@ -380,7 +392,8 @@ abstract class StylesheetParser extends Parser { // Parse custom properties as declarations no matter what. var name = nameBuffer.interpolation(scanner.spanFrom(start, beforeColon)); if (name.initialPlain.startsWith('--')) { - var value = StringExpression(_interpolatedDeclarationValue()); + var value = StringExpression( + _interpolatedDeclarationValue(silentComments: false)); expectStatementSeparator("custom property"); return Declaration(name, value, scanner.spanFrom(start)); } @@ -396,10 +409,7 @@ abstract class StylesheetParser extends Parser { } var postColonWhitespace = rawText(whitespace); - if (lookingAtChildren()) { - return _withChildren(_declarationChild, start, - (children, span) => Declaration.nested(name, children, span)); - } + if (_tryDeclarationChildren(name, start) case var nested?) return nested; midBuffer.write(postColonWhitespace); var couldBeSelector = @@ -435,12 +445,8 @@ abstract class StylesheetParser extends Parser { return nameBuffer; } - if (lookingAtChildren()) { - return _withChildren( - _declarationChild, - start, - (children, span) => - Declaration.nested(name, children, span, value: value)); + if (_tryDeclarationChildren(name, start, value: value) case var nested?) { + return nested; } else { expectStatementSeparator(); return Declaration(name, value, scanner.spanFrom(start)); @@ -494,8 +500,12 @@ abstract class StylesheetParser extends Parser { return _withChildren(_statement, start, (children, span) { if (indented && children.isEmpty) { - warn("This selector doesn't have any properties and won't be rendered.", - interpolation.span); + warnings.add(( + deprecation: null, + message: "This selector doesn't have any properties and won't be " + "rendered.", + span: interpolation.span + )); } _inStyleRule = wasInStyleRule; @@ -539,37 +549,43 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($colon); if (parseCustomProperties && name.initialPlain.startsWith('--')) { - var value = StringExpression(_interpolatedDeclarationValue()); + var value = StringExpression( + _interpolatedDeclarationValue(silentComments: false)); expectStatementSeparator("custom property"); return Declaration(name, value, scanner.spanFrom(start)); } whitespace(); - - if (lookingAtChildren()) { - if (plainCss) { - scanner.error("Nested declarations aren't allowed in plain CSS."); - } - return _withChildren(_declarationChild, start, - (children, span) => Declaration.nested(name, children, span)); - } + if (_tryDeclarationChildren(name, start) case var nested?) return nested; var value = _expression(); - if (lookingAtChildren()) { - if (plainCss) { - scanner.error("Nested declarations aren't allowed in plain CSS."); - } - return _withChildren( - _declarationChild, - start, - (children, span) => - Declaration.nested(name, children, span, value: value)); + if (_tryDeclarationChildren(name, start, value: value) case var nested?) { + return nested; } else { expectStatementSeparator(); return Declaration(name, value, scanner.spanFrom(start)); } } + /// Tries parsing nested children of a declaration whose [name] has already + /// been parsed, and returns `null` if it doesn't have any. + /// + /// If [value] is passed, it's used as the value of the property without + /// nesting. + Declaration? _tryDeclarationChildren( + Interpolation name, LineScannerState start, + {Expression? value}) { + if (!lookingAtChildren()) return null; + if (plainCss) { + scanner.error("Nested declarations aren't allowed in plain CSS."); + } + return _withChildren( + _declarationChild, + start, + (children, span) => + Declaration.nested(name, children, span, value: value)); + } + /// Consumes a statement that's allowed within a declaration. Statement _declarationChild() => scanner.peekChar() == $at ? _declarationAtRule() @@ -731,7 +747,7 @@ abstract class StylesheetParser extends Parser { whitespace(); return _withChildren(_statement, start, (children, span) => AtRootRule(children, span, query: query)); - } else if (lookingAtChildren()) { + } else if (lookingAtChildren() || (indented && atEndOfStatement())) { return _withChildren( _statement, start, (children, span) => AtRootRule(children, span)); } else { @@ -748,12 +764,12 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($lparen); whitespace(); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); buffer.writeCharCode($space); - buffer.add(_expression()); + _addOrInject(buffer, _expression()); } scanner.expectChar($rparen); @@ -772,10 +788,15 @@ abstract class StylesheetParser extends Parser { scanner.spanFrom(start)); } + var beforeWhitespace = scanner.location; whitespace(); - var arguments = scanner.peekChar() == $lparen - ? _argumentInvocation(mixin: true) - : ArgumentInvocation.empty(scanner.emptySpan); + ArgumentInvocation arguments; + if (scanner.peekChar() == $lparen) { + arguments = _argumentInvocation(mixin: true); + whitespace(); + } else { + arguments = ArgumentInvocation.empty(beforeWhitespace.pointSpan()); + } expectStatementSeparator("@content rule"); return ContentRule(arguments, scanner.spanFrom(start)); @@ -837,7 +858,10 @@ abstract class StylesheetParser extends Parser { var value = almostAnyValue(); var optional = scanner.scanChar($exclamation); - if (optional) expectIdentifier("optional"); + if (optional) { + expectIdentifier("optional"); + whitespace(); + } expectStatementSeparator("@extend rule"); return ExtendRule(value, scanner.spanFrom(start), optional: optional); } @@ -848,7 +872,21 @@ abstract class StylesheetParser extends Parser { FunctionRule _functionRule(LineScannerState start) { var precedingComment = lastSilentComment; lastSilentComment = null; - var name = identifier(normalize: true); + var beforeName = scanner.state; + var name = identifier(); + + if (name.startsWith('--')) { + warnings.add(( + deprecation: Deprecation.cssFunctionMixin, + message: + 'Sass @function names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS mixins.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + span: scanner.spanFrom(beforeName) + )); + } + whitespace(); var arguments = _argumentDeclaration(); @@ -944,6 +982,7 @@ abstract class StylesheetParser extends Parser { } var configuration = _configuration(allowGuarded: true); + whitespace(); expectStatementSeparator("@forward rule"); var span = scanner.spanFrom(start); @@ -1029,12 +1068,15 @@ abstract class StylesheetParser extends Parser { whitespace(); var argument = importArgument(); if (argument is DynamicImport) { - logger.warnForDeprecation( - Deprecation.import, - 'Sass @import rules will be deprecated in the future.\n' - 'Remove the --future-deprecation=import flag to silence this ' - 'warning for now.', - span: argument.span); + warnings.add(( + deprecation: Deprecation.import, + message: + 'Sass @import rules are deprecated and will be removed in Dart ' + 'Sass 3.0.0.\n\n' + 'More info and automated migrator: ' + 'https://sass-lang.com/d/import', + span: argument.span + )); } if ((_inControlDirective || _inMixin) && argument is DynamicImport) { _disallowedAtRule(start); @@ -1058,7 +1100,10 @@ abstract class StylesheetParser extends Parser { var url = dynamicUrl(); whitespace(); var modifiers = tryImportModifiers(); - return StaticImport(Interpolation([url], scanner.spanFrom(start)), + return StaticImport( + url is StringExpression + ? url.text + : Interpolation([url], [url.span], url.span), scanner.spanFrom(start), modifiers: modifiers); } @@ -1069,7 +1114,7 @@ abstract class StylesheetParser extends Parser { var modifiers = tryImportModifiers(); if (isPlainImportUrl(url) || modifiers != null) { return StaticImport( - Interpolation([urlSpan.text], urlSpan), scanner.spanFrom(start), + Interpolation.plain(urlSpan.text, urlSpan), scanner.spanFrom(start), modifiers: modifiers); } else { try { @@ -1132,7 +1177,7 @@ abstract class StylesheetParser extends Parser { if (name == "supports") { var query = _importSupportsQuery(); if (query is! SupportsDeclaration) buffer.writeCharCode($lparen); - buffer.add(SupportsExpression(query)); + buffer.add(SupportsExpression(query), query.span); if (query is! SupportsDeclaration) buffer.writeCharCode($rparen); } else { buffer.writeCharCode($lparen); @@ -1177,7 +1222,8 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; var name = _expression(); scanner.expectChar($colon); - return _supportsDeclarationValue(name, start); + return SupportsDeclaration( + name, _supportsDeclarationValue(name), scanner.spanFrom(start)); } } @@ -1211,8 +1257,6 @@ abstract class StylesheetParser extends Parser { if (scanner.scanChar($dot)) { namespace = name; name = _publicIdentifier(); - } else { - name = name.replaceAll("_", "-"); } whitespace(); @@ -1263,7 +1307,21 @@ abstract class StylesheetParser extends Parser { MixinRule _mixinRule(LineScannerState start) { var precedingComment = lastSilentComment; lastSilentComment = null; - var name = identifier(normalize: true); + var beforeName = scanner.state; + var name = identifier(); + + if (name.startsWith('--')) { + warnings.add(( + deprecation: Deprecation.cssFunctionMixin, + message: + 'Sass @mixin names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS mixins.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + span: scanner.spanFrom(beforeName) + )); + } + whitespace(); var arguments = scanner.peekChar() == $lparen ? _argumentDeclaration() @@ -1301,7 +1359,8 @@ abstract class StylesheetParser extends Parser { var needsDeprecationWarning = false; while (true) { if (scanner.peekChar() == $hash) { - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); needsDeprecationWarning = true; } else { var identifierStart = scanner.state; @@ -1356,13 +1415,15 @@ abstract class StylesheetParser extends Parser { var value = buffer.interpolation(scanner.spanFrom(valueStart)); return _withChildren(_statement, start, (children, span) { if (needsDeprecationWarning) { - logger.warnForDeprecation( - Deprecation.mozDocument, - "@-moz-document is deprecated and support will be removed in Dart " - "Sass 2.0.0.\n" - "\n" - "For details, see https://sass-lang.com/d/moz-document.", - span: span); + warnings.add(( + deprecation: Deprecation.mozDocument, + message: + "@-moz-document is deprecated and support will be removed in " + "Dart Sass 2.0.0.\n" + "\n" + "For details, see https://sass-lang.com/d/moz-document.", + span: span + )); } return AtRule(name, span, value: value, children: children); @@ -1399,8 +1460,7 @@ abstract class StylesheetParser extends Parser { var namespace = _useNamespace(url, start); whitespace(); var configuration = _configuration(); - - expectStatementSeparator("@use rule"); + whitespace(); var span = scanner.spanFrom(start); if (!_isUseAllowed) { @@ -1426,7 +1486,7 @@ abstract class StylesheetParser extends Parser { var namespace = basename.substring( basename.startsWith("_") ? 1 : 0, dot == -1 ? basename.length : dot); try { - return Parser.parseIdentifier(namespace, logger: logger); + return Parser.parseIdentifier(namespace); } on SassFormatException { error( 'The default namespace "$namespace" is not a valid Sass identifier.\n' @@ -1522,7 +1582,7 @@ abstract class StylesheetParser extends Parser { Interpolation? value; if (scanner.peekChar() != $exclamation && !atEndOfStatement()) { - value = almostAnyValue(); + value = _interpolatedDeclarationValue(allowOpenBrace: false); } AtRule rule; @@ -1547,7 +1607,7 @@ abstract class StylesheetParser extends Parser { /// This declares a return type of [Statement] so that it can be returned /// within case statements. Statement _disallowedAtRule(LineScannerState start) { - almostAnyValue(); + _interpolatedDeclarationValue(allowEmpty: true, allowOpenBrace: false); error("This at-rule is not allowed here.", scanner.spanFrom(start)); } @@ -1687,7 +1747,9 @@ abstract class StylesheetParser extends Parser { } var start = scanner.state; + var wasInExpression = _inExpression; var wasInParentheses = _inParentheses; + _inExpression = true; // We use the convention below of referring to nullable variables that are // shared across anonymous functions in this method with a trailing @@ -1761,25 +1823,26 @@ abstract class StylesheetParser extends Parser { right.span.start.offset - 1, right.span.start.offset) == operator.operator && scanner.string.codeUnitAt(left.span.end.offset).isWhitespace) { - logger.warnForDeprecation( - Deprecation.strictUnary, - "This operation is parsed as:\n" - "\n" - " $left ${operator.operator} $right\n" - "\n" - "but you may have intended it to mean:\n" - "\n" - " $left (${operator.operator}$right)\n" - "\n" - "Add a space after ${operator.operator} to clarify that it's " - "meant to be a binary operation, or wrap\n" - "it in parentheses to make it a unary operation. This will be " - "an error in future\n" - "versions of Sass.\n" - "\n" - "More info and automated migrator: " - "https://sass-lang.com/d/strict-unary", - span: singleExpression_!.span); + warnings.add(( + deprecation: Deprecation.strictUnary, + message: "This operation is parsed as:\n" + "\n" + " $left ${operator.operator} $right\n" + "\n" + "but you may have intended it to mean:\n" + "\n" + " $left (${operator.operator}$right)\n" + "\n" + "Add a space after ${operator.operator} to clarify that it's " + "meant to be a binary operation, or wrap\n" + "it in parentheses to make it a unary operation. This will be " + "an error in future\n" + "versions of Sass.\n" + "\n" + "More info and automated migrator: " + "https://sass-lang.com/d/strict-unary", + span: singleExpression_!.span + )); } } } @@ -2040,11 +2103,13 @@ abstract class StylesheetParser extends Parser { _inParentheses = wasInParentheses; var singleExpression = singleExpression_; if (singleExpression != null) commaExpressions.add(singleExpression); + _inExpression = wasInExpression; return ListExpression(commaExpressions, ListSeparator.comma, scanner.spanFrom(beforeBracket ?? start), brackets: bracketList); } else if (bracketList && spaceExpressions != null) { resolveOperations(); + _inExpression = wasInExpression; return ListExpression(spaceExpressions..add(singleExpression_!), ListSeparator.space, scanner.spanFrom(beforeBracket!), brackets: true); @@ -2055,6 +2120,7 @@ abstract class StylesheetParser extends Parser { ListSeparator.undecided, scanner.spanFrom(beforeBracket!), brackets: true); } + _inExpression = wasInExpression; return singleExpression_!; } } @@ -2461,10 +2527,12 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($ampersand); if (scanner.scanChar($ampersand)) { - warn( - 'In Sass, "&&" means two copies of the parent selector. You ' - 'probably want to use "and" instead.', - scanner.spanFrom(start)); + warnings.add(( + deprecation: null, + message: 'In Sass, "&&" means two copies of the parent selector. You ' + 'probably want to use "and" instead.', + span: scanner.spanFrom(start) + )); scanner.position--; } @@ -2502,7 +2570,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode(escapeCharacter()); } case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: buffer.writeCharCode(scanner.readChar()); } @@ -2665,7 +2734,8 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape()); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case $exclamation || $percent || $ampersand || @@ -2700,7 +2770,7 @@ abstract class StylesheetParser extends Parser { } return InterpolatedFunctionExpression( - Interpolation(["url"], scanner.spanFrom(start)), + Interpolation.plain("url", scanner.spanFrom(start)), _argumentInvocation(), scanner.spanFrom(start)); } @@ -2715,13 +2785,11 @@ abstract class StylesheetParser extends Parser { /// /// Differences from [_interpolatedDeclarationValue] include: /// - /// * This does not balance brackets. + /// * This always stops at curly braces. /// /// * This does not interpret backslashes, since the text is expected to be /// re-parsed. /// - /// * This supports Sass-style single-line comments. - /// /// * This does not compress adjacent whitespace characters. @protected Interpolation almostAnyValue({bool omitComments = false}) { @@ -2740,11 +2808,21 @@ abstract class StylesheetParser extends Parser { buffer.addInterpolation(interpolatedString().asInterpolation()); case $slash: - var commentStart = scanner.position; - if (scanComment()) { - if (!omitComments) buffer.write(scanner.substring(commentStart)); - } else { - buffer.writeCharCode(scanner.readChar()); + switch (scanner.peekChar(1)) { + case $asterisk when !omitComments: + buffer.write(rawText(loudComment)); + + case $asterisk: + loudComment(); + + case $slash when !omitComments: + buffer.write(rawText(silentComment)); + + case $slash: + silentComment(); + + case _: + buffer.writeCharCode(scanner.readChar()); } case $hash when scanner.peekChar(1) == $lbrace: @@ -2761,12 +2839,17 @@ abstract class StylesheetParser extends Parser { case $u || $U: var beforeUrl = scanner.state; - if (!scanIdentifier("url")) { - buffer.writeCharCode(scanner.readChar()); + var identifier = this.identifier(); + if (identifier != "url" && + // This isn't actually a standard CSS feature, but it was + // supported by the old `@document` rule so we continue to support + // it for backwards-compatibility. + identifier != "url-prefix") { + buffer.write(identifier); continue loop; } - if (_tryUrlContents(beforeUrl) case var contents?) { + if (_tryUrlContents(beforeUrl, name: identifier) case var contents?) { buffer.addInterpolation(contents); } else { scanner.state = beforeUrl; @@ -2797,11 +2880,19 @@ abstract class StylesheetParser extends Parser { /// /// If [allowColon] is `false`, this stops at top-level colons. /// + /// If [allowOpenBrace] is `false`, this stops at opening curly braces. + /// + /// If [silentComments] is `true`, this will parse silent comments as + /// comments. Otherwise, it will preserve two adjacent slashes and emit them + /// to CSS. + /// /// Unlike [declarationValue], this allows interpolation. Interpolation _interpolatedDeclarationValue( {bool allowEmpty = false, bool allowSemicolon = false, - bool allowColon = true}) { + bool allowColon = true, + bool allowOpenBrace = true, + bool silentComments = true}) { // NOTE: this logic is largely duplicated in Parser.declarationValue. Most // changes here should be mirrored there. @@ -2821,9 +2912,20 @@ abstract class StylesheetParser extends Parser { buffer.addInterpolation(interpolatedString().asInterpolation()); wroteNewline = false; - case $slash when scanner.peekChar(1) == $asterisk: - buffer.write(rawText(loudComment)); - wroteNewline = false; + case $slash: + switch (scanner.peekChar(1)) { + case $asterisk: + buffer.write(rawText(loudComment)); + wroteNewline = false; + + case $slash when silentComments: + silentComment(); + wroteNewline = false; + + case _: + buffer.writeCharCode(scanner.readChar()); + wroteNewline = false; + } // Add a full interpolated identifier to handle cases like "#{...}--1", // since "--1" isn't a valid identifier on its own. @@ -2849,6 +2951,9 @@ abstract class StylesheetParser extends Parser { scanner.readChar(); wroteNewline = true; + case $lbrace when !allowOpenBrace: + break loop; + case $lparen || $lbrace || $lbracket: var bracket = scanner.readChar(); buffer.writeCharCode(bracket); @@ -2874,13 +2979,18 @@ abstract class StylesheetParser extends Parser { case $u || $U: var beforeUrl = scanner.state; - if (!scanIdentifier("url")) { - buffer.writeCharCode(scanner.readChar()); + var identifier = this.identifier(); + if (identifier != "url" && + // This isn't actually a standard CSS feature, but it was + // supported by the old `@document` rule so we continue to support + // it for backwards-compatibility. + identifier != "url-prefix") { + buffer.write(identifier); wroteNewline = false; continue loop; } - if (_tryUrlContents(beforeUrl) case var contents?) { + if (_tryUrlContents(beforeUrl, name: identifier) case var contents?) { buffer.addInterpolation(contents); } else { scanner.state = beforeUrl; @@ -2930,7 +3040,8 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape(identifierStart: true)); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: scanner.error("Expected identifier."); } @@ -2952,28 +3063,30 @@ abstract class StylesheetParser extends Parser { case $backslash: buffer.write(escape()); case $hash when scanner.peekChar(1) == $lbrace: - buffer.add(singleInterpolation()); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); case _: break loop; } } } - /// Consumes interpolation. + /// Consumes interpolation and returns it along with the span covering the + /// `#{}`. @protected - Expression singleInterpolation() { + (Expression, FileSpan span) singleInterpolation() { var start = scanner.state; scanner.expect('#{'); whitespace(); var contents = _expression(); scanner.expectChar($rbrace); + var span = scanner.spanFrom(start); if (plainCss) { - error( - "Interpolation isn't allowed in plain CSS.", scanner.spanFrom(start)); + error("Interpolation isn't allowed in plain CSS.", span); } - return contents; + return (contents, span); } // ## Media Queries @@ -3085,9 +3198,8 @@ abstract class StylesheetParser extends Parser { /// Consumes a `MediaOrInterp` expression and writes it to [buffer]. void _mediaOrInterp(InterpolationBuffer buffer) { if (scanner.peekChar() == $hash) { - var interpolation = singleInterpolation(); - buffer - .addInterpolation(Interpolation([interpolation], interpolation.span)); + var (expression, span) = singleInterpolation(); + buffer.add(expression, span); } else { _mediaInParens(buffer); } @@ -3116,12 +3228,14 @@ abstract class StylesheetParser extends Parser { expectWhitespace(); _mediaOrInterp(buffer); } else { - buffer.add(_expressionUntilComparison()); + var expressionBefore = _expressionUntilComparison(); + buffer.add(expressionBefore, expressionBefore.span); if (scanner.scanChar($colon)) { whitespace(); buffer.writeCharCode($colon); buffer.writeCharCode($space); - buffer.add(_expression()); + var expressionAfter = _expression(); + buffer.add(expressionAfter, expressionAfter.span); } else { var next = scanner.peekChar(); if (next case $langle || $rangle || $equal) { @@ -3133,7 +3247,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($space); whitespace(); - buffer.add(_expressionUntilComparison()); + var expressionMiddle = _expressionUntilComparison(); + buffer.add(expressionMiddle, expressionMiddle.span); // dart-lang/sdk#45356 if (next case $langle || $rangle when scanner.scanChar(next!)) { @@ -3143,7 +3258,8 @@ abstract class StylesheetParser extends Parser { buffer.writeCharCode($space); whitespace(); - buffer.add(_expressionUntilComparison()); + var expressionAfter = _expressionUntilComparison(); + buffer.add(expressionAfter, expressionAfter.span); } } } @@ -3228,7 +3344,7 @@ abstract class StylesheetParser extends Parser { } else if (scanner.peekChar() == $lparen) { var condition = _supportsCondition(); scanner.expectChar($rparen); - return condition; + return condition.withSpan(scanner.spanFrom(start)); } // Unfortunately, we may have to backtrack here. The grammar is: @@ -3258,7 +3374,7 @@ abstract class StylesheetParser extends Parser { var identifier = interpolatedIdentifier(); if (_trySupportsOperation(identifier, nameStart) case var operation?) { scanner.expectChar($rparen); - return operation; + return operation.withSpan(scanner.spanFrom(start)); } // If parsing an expression fails, try to parse an @@ -3276,24 +3392,21 @@ abstract class StylesheetParser extends Parser { return SupportsAnything(contents, scanner.spanFrom(start)); } - var declaration = _supportsDeclarationValue(name, start); + var value = _supportsDeclarationValue(name); scanner.expectChar($rparen); - return declaration; + return SupportsDeclaration(name, value, scanner.spanFrom(start)); } /// Parses and returns the right-hand side of a declaration in a supports /// query. - SupportsDeclaration _supportsDeclarationValue( - Expression name, LineScannerState start) { - Expression value; + Expression _supportsDeclarationValue(Expression name) { if (name case StringExpression(hasQuotes: false, :var text) when text.initialPlain.startsWith("--")) { - value = StringExpression(_interpolatedDeclarationValue()); + return StringExpression(_interpolatedDeclarationValue()); } else { whitespace(); - value = _expression(); + return _expression(); } - return SupportsDeclaration(name, value, scanner.spanFrom(start)); } /// If [interpolation] is followed by `"and"` or `"or"`, parse it as a supports operation. @@ -3430,7 +3543,7 @@ abstract class StylesheetParser extends Parser { /// Like [identifier], but rejects identifiers that begin with `_` or `-`. String _publicIdentifier() { var start = scanner.state; - var result = identifier(normalize: true); + var result = identifier(); _assertPublic(result, () => scanner.spanFrom(start)); return result; } @@ -3444,6 +3557,16 @@ abstract class StylesheetParser extends Parser { span()); } + /// Adds [expression] to [buffer], or if it's an unquoted string adds the + /// interpolation it contains instead. + void _addOrInject(InterpolationBuffer buffer, Expression expression) { + if (expression is StringExpression && !expression.hasQuotes) { + buffer.addInterpolation(expression.text); + } else { + buffer.add(expression, expression.span); + } + } + // ## Abstract Methods /// Whether this is parsing the indented syntax. diff --git a/lib/src/stylesheet_graph.dart b/lib/src/stylesheet_graph.dart index 3109fc5f0..245d8b146 100644 --- a/lib/src/stylesheet_graph.dart +++ b/lib/src/stylesheet_graph.dart @@ -385,7 +385,7 @@ class StylesheetNode { _upstreamImports = newUpstreamImports; } - /// Removes [this] as an upstream and downstream node from all the nodes that + /// Removes `this` as an upstream and downstream node from all the nodes that /// import it and that it imports. void _remove() { for (var node in {...upstream.values, ...upstreamImports.values}) { diff --git a/lib/src/util/box.dart b/lib/src/util/box.dart index cfd076669..50a9eb750 100644 --- a/lib/src/util/box.dart +++ b/lib/src/util/box.dart @@ -13,7 +13,7 @@ class Box { Box._(this._inner); - bool operator ==(Object? other) => other is Box && other._inner == _inner; + bool operator ==(Object other) => other is Box && other._inner == _inner; int get hashCode => _inner.hashCode; } diff --git a/lib/src/util/character.dart b/lib/src/util/character.dart index ea4085d29..614fec45a 100644 --- a/lib/src/util/character.dart +++ b/lib/src/util/character.dart @@ -10,6 +10,11 @@ import 'package:charcode/charcode.dart'; /// lowercase equivalents. const _asciiCaseBit = 0x20; +/// The highest character allowed in CSS. +/// +/// See https://drafts.csswg.org/css-syntax-3/#maximum-allowed-code-point +const maxAllowedCharacter = 0x10FFFF; + // Define these checks as extension getters so they can be used in pattern // matches. extension CharacterExtension on int { @@ -35,6 +40,12 @@ extension CharacterExtension on int { // 0x36 == 0b110110. this >> 10 == 0x36; + /// Returns whether [character] is the end of a UTF-16 surrogate pair. + bool get isLowSurrogate => + // A character is a high surrogate exactly if it matches 0b110111XXXXXXXXXX. + // 0x36 == 0b110111. + this >> 10 == 0x37; + /// Returns whether [character] is a Unicode private-use code point in the Basic /// Multilingual Plane. /// @@ -92,16 +103,6 @@ int combineSurrogates(int highSurrogate, int lowSurrogate) => // high/low surrogates. 0x10000 + ((highSurrogate & 0x3FF) << 10) + (lowSurrogate & 0x3FF); -// Returns whether [character] can start a simple selector other than a type -// selector. -bool isSimpleSelectorStart(int? character) => - character == $asterisk || - character == $lbracket || - character == $dot || - character == $hash || - character == $percent || - character == $colon; - /// Returns whether [identifier] is module-private. /// /// Assumes [identifier] is a valid Sass identifier. diff --git a/lib/src/util/fuzzy_equality.dart b/lib/src/util/fuzzy_equality.dart new file mode 100644 index 000000000..9d8a78e95 --- /dev/null +++ b/lib/src/util/fuzzy_equality.dart @@ -0,0 +1,17 @@ +// Copyright 2022 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:collection/collection.dart'; + +import 'number.dart'; + +class FuzzyEquality implements Equality { + const FuzzyEquality(); + + bool equals(double e1, double e2) => fuzzyEquals(e1, e2); + + int hash(double e1) => fuzzyHashCode(e1); + + bool isValidKey(Object? o) => o is double; +} diff --git a/lib/src/util/map.dart b/lib/src/util/map.dart index 70037fd64..865b213bc 100644 --- a/lib/src/util/map.dart +++ b/lib/src/util/map.dart @@ -2,8 +2,10 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'option.dart'; + extension MapExtensions on Map { - /// If [this] doesn't contain the given [key], sets that key to [value] and + /// If `this` doesn't contain the given [key], sets that key to [value] and /// returns it. /// /// Otherwise, calls [merge] with the existing value and [value] and sets @@ -16,4 +18,8 @@ extension MapExtensions on Map { // TODO(nweiz): Remove this once dart-lang/collection#289 is released. /// Like [Map.entries], but returns each entry as a record. Iterable<(K, V)> get pairs => entries.map((e) => (e.key, e.value)); + + /// Returns an option that contains the value at [key] if one exists and null + /// otherwise. + Option getOption(K key) => containsKey(key) ? (this[key] as V,) : null; } diff --git a/lib/src/util/multi_span.dart b/lib/src/util/multi_span.dart index 24ca42b48..41121042a 100644 --- a/lib/src/util/multi_span.dart +++ b/lib/src/util/multi_span.dart @@ -70,7 +70,7 @@ class MultiSpan implements FileSpan { primaryColor: primaryColor, secondaryColor: secondaryColor); - /// Returns a copy of [this] with [newPrimary] as its primary span. + /// Returns a copy of `this` with [newPrimary] as its primary span. MultiSpan _withPrimary(FileSpan newPrimary) => MultiSpan._(newPrimary, primaryLabel, secondarySpans); } diff --git a/lib/src/util/nullable.dart b/lib/src/util/nullable.dart index ad4a8ba2f..cf24c880e 100644 --- a/lib/src/util/nullable.dart +++ b/lib/src/util/nullable.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. extension NullableExtension on T? { - /// If [this] is `null`, returns `null`. Otherwise, runs [fn] and returns its + /// If `this` is `null`, returns `null`. Otherwise, runs [fn] and returns its /// result. /// /// Based on Rust's `Option.and_then`. diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index 8df7beb1a..8aad45581 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -30,6 +30,17 @@ bool fuzzyEquals(num number1, num number2) { (number2 * _inverseEpsilon).round(); } +/// Like [fuzzyEquals], but allows null values for [number1] and [number2]. +/// +/// null values are only equal to one another. +bool fuzzyEqualsNullable(num? number1, num? number2) { + if (number1 == number2) return true; + if (number1 == null || number2 == null) return false; + return (number1 - number2).abs() <= _epsilon && + (number1 * _inverseEpsilon).round() == + (number2 * _inverseEpsilon).round(); +} + /// Returns a hash code for [number] that matches [fuzzyEquals]. int fuzzyHashCode(double number) { if (!number.isFinite) return number.hashCode; @@ -83,6 +94,11 @@ int fuzzyRound(num number) { } } +/// Returns whether [number] is within [min] and [max] inclusive, using fuzzy +/// equality. +bool fuzzyInRange(double number, num min, num max) => + fuzzyGreaterThanOrEquals(number, min) && fuzzyLessThanOrEquals(number, max); + /// Returns [number] if it's within [min] and [max], or `null` if it's not. /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the @@ -124,6 +140,12 @@ double moduloLikeSass(double num1, double num2) { return result == 0 ? 0 : result + num2; } +//// Returns [num] clamped between [lowerBound] and [upperBound], with `NaN` +//// preferring the lower bound (unlike Dart for which it prefers the upper +//// bound). +double clampLikeCss(double number, double lowerBound, double upperBound) => + number.isNaN ? lowerBound : number.clamp(lowerBound, upperBound); + /// Returns the square root of [number]. SassNumber sqrt(SassNumber number) { number.assertNoUnits("number"); diff --git a/lib/src/util/option.dart b/lib/src/util/option.dart new file mode 100644 index 000000000..84d296a80 --- /dev/null +++ b/lib/src/util/option.dart @@ -0,0 +1,12 @@ +// 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. + +/// A type that represents either the presence of a value of type `T` or its +/// absence. +/// +/// When the option is present, this will be a single-element tuple that +/// contains the value. If it's absent, it will be null. This allows callers to +/// distinguish between a present null value and a value that's absent +/// altogether. +typedef Option = (T,)?; diff --git a/lib/src/util/span.dart b/lib/src/util/span.dart index bcb9b6165..7d84cbd63 100644 --- a/lib/src/util/span.dart +++ b/lib/src/util/span.dart @@ -84,10 +84,54 @@ extension SpanExtensions on FileSpan { return subspan(scanner.position).trimLeft(); } - /// Whether [this] FileSpan contains the [target] FileSpan. + /// Returns a span covering the text after this span and before [other]. + /// + /// Throws an [ArgumentError] if [other.start] isn't on or after `this.end` in + /// the same file. + FileSpan between(FileSpan other) { + if (sourceUrl != other.sourceUrl) { + throw ArgumentError("$this and $other are in different files."); + } else if (end.offset > other.start.offset) { + throw ArgumentError("$this isn't before $other."); + } + + return file.span(end.offset, other.start.offset); + } + + /// Returns a span covering the text from the beginning of this span to the + /// beginning of [inner]. + /// + /// Throws an [ArgumentError] if [inner] isn't fully within this span. + FileSpan before(FileSpan inner) { + if (sourceUrl != inner.sourceUrl) { + throw ArgumentError("$this and $inner are in different files."); + } else if (inner.start.offset < start.offset || + inner.end.offset > end.offset) { + throw ArgumentError("$inner isn't inside $this."); + } + + return file.span(start.offset, inner.start.offset); + } + + /// Returns a span covering the text from the end of [inner] to the end of + /// this span. + /// + /// Throws an [ArgumentError] if [inner] isn't fully within this span. + FileSpan after(FileSpan inner) { + if (sourceUrl != inner.sourceUrl) { + throw ArgumentError("$this and $inner are in different files."); + } else if (inner.start.offset < start.offset || + inner.end.offset > end.offset) { + throw ArgumentError("$inner isn't inside $this."); + } + + return file.span(inner.end.offset, end.offset); + } + + /// Whether this [FileSpan] contains the [target] FileSpan. /// /// Validates the FileSpans to be in the same file and for the [target] to be - /// within [this] FileSpan inclusive range [start,end]. + /// within this [FileSpan]'s inclusive range `[start,end]`. bool contains(FileSpan target) => file.url == target.file.url && start.offset <= target.start.offset && diff --git a/lib/src/util/string.dart b/lib/src/util/string.dart new file mode 100644 index 000000000..949b9092c --- /dev/null +++ b/lib/src/util/string.dart @@ -0,0 +1,108 @@ +// 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:charcode/charcode.dart'; +import 'package:string_scanner/string_scanner.dart'; + +import 'character.dart'; + +extension StringExtension on String { + /// Returns a minimally-escaped CSS identifiers whose contents evaluates to + /// [text]. + /// + /// Throws a [FormatException] if [text] cannot be represented as a CSS + /// identifier (such as the empty string). + String toCssIdentifier() { + var buffer = StringBuffer(); + var scanner = SpanScanner(this); + + void writeEscape(int character) { + buffer.writeCharCode($backslash); + buffer.write(character.toRadixString(16)); + if (scanner.peekChar() case int(isHex: true)) { + buffer.writeCharCode($space); + } + } + + void consumeSurrogatePair(int character) { + if (scanner.peekChar(1) case null || int(isLowSurrogate: false)) { + scanner.error( + "An individual surrogates can't be represented as a CSS " + "identifier.", + length: 1); + } else if (character.isPrivateUseHighSurrogate) { + writeEscape(combineSurrogates(scanner.readChar(), scanner.readChar())); + } else { + buffer.writeCharCode(scanner.readChar()); + buffer.writeCharCode(scanner.readChar()); + } + } + + var doubleDash = false; + if (scanner.scanChar($dash)) { + if (scanner.isDone) return '\\2d'; + + buffer.writeCharCode($dash); + + if (scanner.scanChar($dash)) { + buffer.writeCharCode($dash); + doubleDash = true; + } + } + + if (!doubleDash) { + switch (scanner.peekChar()) { + case null: + scanner.error( + "The empty string can't be represented as a CSS identifier."); + + case 0: + scanner.error("The U+0000 can't be represented as a CSS identifier."); + + case int character when character.isHighSurrogate: + consumeSurrogatePair(character); + + case int(isLowSurrogate: true): + scanner.error( + "An individual surrogate can't be represented as a CSS " + "identifier.", + length: 1); + + case int(isNameStart: true, isPrivateUseBMP: false): + buffer.writeCharCode(scanner.readChar()); + + case _: + writeEscape(scanner.readChar()); + } + } + + loop: + while (true) { + switch (scanner.peekChar()) { + case null: + break loop; + + case 0: + scanner.error("The U+0000 can't be represented as a CSS identifier."); + + case int character when character.isHighSurrogate: + consumeSurrogatePair(character); + + case int(isLowSurrogate: true): + scanner.error( + "An individual surrogate can't be represented as a CSS " + "identifier.", + length: 1); + + case int(isName: true, isPrivateUseBMP: false): + buffer.writeCharCode(scanner.readChar()); + + case _: + writeEscape(scanner.readChar()); + } + } + + return buffer.toString(); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index abd7834cc..2e04afd16 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -155,6 +155,9 @@ List flattenVertically(Iterable> iterable) { return result; } +/// Returns [value] if it's a [T] or null otherwise. +T? castOrNull(Object? value) => value is T ? value : null; + /// Converts [codepointIndex] to a code unit index, relative to [string]. /// /// A codepoint index is the index in pure Unicode codepoints; a code unit index @@ -406,7 +409,7 @@ int consumeEscapedCharacter(StringScanner scanner) { if (scanner.peekChar().isWhitespace) scanner.readChar(); return switch (value) { - 0 || (>= 0xD800 && <= 0xDFFF) || >= 0x10FFFF => 0xFFFD, + 0 || (>= 0xD800 && <= 0xDFFF) || >= maxAllowedCharacter => 0xFFFD, _ => value }; case _: diff --git a/lib/src/value.dart b/lib/src/value.dart index 4b21e2434..81f4df27e 100644 --- a/lib/src/value.dart +++ b/lib/src/value.dart @@ -15,6 +15,7 @@ import 'value/color.dart'; import 'value/function.dart'; import 'value/list.dart'; import 'value/map.dart'; +import 'value/mixin.dart'; import 'value/number.dart'; import 'value/string.dart'; import 'visitor/interface/value.dart'; @@ -27,6 +28,7 @@ export 'value/color.dart'; export 'value/function.dart'; export 'value/list.dart'; export 'value/map.dart'; +export 'value/mixin.dart'; export 'value/null.dart'; export 'value/number.dart' hide conversionFactor; export 'value/string.dart'; @@ -98,7 +100,7 @@ abstract class Value { @internal bool get isVar => false; - /// Returns Dart's `null` value if this is [sassNull], and returns [this] + /// Returns Dart's `null` value if this is [sassNull], and returns `this` /// otherwise. Value? get realNull => this; @@ -146,7 +148,7 @@ abstract class Value { return index < 0 ? lengthAsList + index : index - 1; } - /// Throws a [SassScriptException] if [this] isn't a boolean. + /// Throws a [SassScriptException] if `this` isn't a boolean. /// /// Note that generally, functions should use [isTruthy] rather than requiring /// a literal boolean. @@ -156,56 +158,90 @@ abstract class Value { SassBoolean assertBoolean([String? name]) => throw SassScriptException("$this is not a boolean.", name); - /// Throws a [SassScriptException] if [this] isn't a calculation. + /// Throws a [SassScriptException] if `this` isn't a calculation. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassCalculation assertCalculation([String? name]) => throw SassScriptException("$this is not a calculation.", name); - /// Throws a [SassScriptException] if [this] isn't a color. + /// Throws a [SassScriptException] if `this` isn't a color. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassColor assertColor([String? name]) => throw SassScriptException("$this is not a color.", name); - /// Throws a [SassScriptException] if [this] isn't a function reference. + /// Throws a [SassScriptException] if `this` isn't a function reference. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassFunction assertFunction([String? name]) => throw SassScriptException("$this is not a function reference.", name); - /// Throws a [SassScriptException] if [this] isn't a map. + /// Throws a [SassScriptException] if `this` isn't a mixin reference. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + SassMixin assertMixin([String? name]) => + throw SassScriptException("$this is not a mixin reference.", name); + + /// Throws a [SassScriptException] if `this` isn't a map. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassMap assertMap([String? name]) => throw SassScriptException("$this is not a map.", name); - /// Returns [this] as a [SassMap] if it is one (including empty lists, which + /// Returns `this` as a [SassMap] if it is one (including empty lists, which /// count as empty maps) or returns `null` if it's not. SassMap? tryMap() => null; - /// Throws a [SassScriptException] if [this] isn't a number. + /// Throws a [SassScriptException] if `this` isn't a number. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassNumber assertNumber([String? name]) => throw SassScriptException("$this is not a number.", name); - /// Throws a [SassScriptException] if [this] isn't a string. + /// Throws a [SassScriptException] if `this` isn't a string. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. SassString assertString([String? name]) => throw SassScriptException("$this is not a string.", name); + /// Throws a [SassScriptException] if `this` isn't a list of the sort commonly + /// used in plain CSS expression syntax: space-separated and unbracketed. + /// + /// If [allowSlash] is `true`, this allows slash-separated lists as well. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + List assertCommonListStyle(String? name, {required bool allowSlash}) { + var invalidSeparator = separator == ListSeparator.comma || + (!allowSlash && separator == ListSeparator.slash); + if (!invalidSeparator && !hasBrackets) return asList; + + var buffer = StringBuffer(r"Expected"); + if (hasBrackets) buffer.write(" an unbracketed"); + if (invalidSeparator) { + buffer.write(hasBrackets ? "," : " a"); + buffer.write(" space-"); + if (allowSlash) buffer.write(" or slash-"); + buffer.write("separated"); + } + buffer.write(" list, was $this"); + throw SassScriptException(buffer.toString(), name); + } + /// Converts a `selector-parse()`-style input into a string that can be /// parsed. /// - /// Throws a [SassScriptException] if [this] isn't a type or a structure that + /// Throws a [SassScriptException] if `this` isn't a type or a structure that /// can be parsed as a selector. String _selectorString([String? name]) { if (_selectorStringOrNull() case var string?) return string; @@ -219,7 +255,7 @@ abstract class Value { /// Converts a `selector-parse()`-style input into a string that can be /// parsed. /// - /// Returns `null` if [this] isn't a type or a structure that can be parsed as + /// Returns `null` if `this` isn't a type or a structure that can be parsed as /// a selector. String? _selectorStringOrNull() { var self = this; @@ -361,7 +397,7 @@ abstract class Value { @internal Value unaryNot() => sassFalse; - /// Returns a copy of [this] without [SassNumber.asSlash] set. + /// Returns a copy of `this` without [SassNumber.asSlash] set. /// /// If this isn't a [SassNumber], returns it as-is. /// @@ -369,9 +405,9 @@ abstract class Value { @internal Value withoutSlash() => this; - /// Returns a valid CSS representation of [this]. + /// Returns a valid CSS representation of `this`. /// - /// Throws a [SassScriptException] if [this] can't be represented in plain + /// Throws a [SassScriptException] if `this` can't be represented in plain /// CSS. Use [toString] instead to get a string representation even if this /// isn't valid CSS. // @@ -380,11 +416,11 @@ abstract class Value { String toCssString({@internal bool quote = true}) => serializeValue(this, quote: quote); - /// Returns a string representation of [this]. + /// Returns a string representation of `this`. /// /// Note that this is equivalent to calling `inspect()` on the value, and thus /// won't reflect the user's output settings. [toCssString] should be used - /// instead to convert [this] to CSS. + /// instead to convert `this` to CSS. String toString() => serializeValue(this, inspect: true); } @@ -395,7 +431,7 @@ abstract class Value { /// /// {@category Value} extension SassApiValue on Value { - /// Parses [this] as a selector list, in the same manner as the + /// Parses `this` as a selector list, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -419,7 +455,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a simple selector, in the same manner as the + /// Parses `this` as a simple selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -444,7 +480,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a compound selector, in the same manner as the + /// Parses `this` as a compound selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a @@ -469,7 +505,7 @@ extension SassApiValue on Value { } } - /// Parses [this] as a complex selector, in the same manner as the + /// Parses `this` as a complex selector, in the same manner as the /// `selector-parse()` function. /// /// Throws a [SassScriptException] if this isn't a type that can be parsed as a diff --git a/lib/src/value/README.md b/lib/src/value/README.md new file mode 100644 index 000000000..e23644722 --- /dev/null +++ b/lib/src/value/README.md @@ -0,0 +1,13 @@ +# Value Types + +This directory contains definitions for all the SassScript value types. These +definitions are used both to represent SassScript values internally and in the +public Dart API. They are usually produced by [the evaluator] as it evaluates +the expression-level [Sass AST]. + +[the evaluator]: ../visitor/async_evaluate.dart +[Sass AST]: ../ast/sass/README.md + +Sass values are always immutable, even internally. Any changes to them must be +done by creating a new value. In some cases, it's easiest to make a mutable +copy, edit it, and then create a new immutable value from the result. diff --git a/lib/src/value/argument_list.dart b/lib/src/value/argument_list.dart index f9b4b5014..23de2db7a 100644 --- a/lib/src/value/argument_list.dart +++ b/lib/src/value/argument_list.dart @@ -42,8 +42,6 @@ class SassArgumentList extends SassList { bool get wereKeywordsAccessed => _wereKeywordsAccessed; var _wereKeywordsAccessed = false; - SassArgumentList(Iterable contents, Map keywords, - ListSeparator separator) - : _keywords = Map.unmodifiable(keywords), - super(contents, separator); + SassArgumentList(super.contents, Map keywords, super.separator) + : _keywords = Map.unmodifiable(keywords); } diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index cbb8b92e6..c59bc893f 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'package:charcode/charcode.dart'; import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../deprecation.dart'; import '../evaluation_context.dart'; @@ -482,13 +483,47 @@ final class SassCalculation extends Value { /// This may be passed fewer than two arguments, but only if one of the /// arguments is an unquoted `var()` string. static Value round(Object strategyOrNumber, - [Object? numberOrStep, Object? step]) { + [Object? numberOrStep, Object? step]) => + roundInternal(strategyOrNumber, numberOrStep, step, + span: null, inLegacySassFunction: null, warn: null); + + /// Like [round], but with the internal-only [inLegacySassFunction] and + /// [warn] parameters. + /// + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()` and `max()` functions. This emits a + /// deprecation warning using the string as the function's name. + /// + /// If [simplify] is `false`, no simplification will be done. + /// + /// The [warn] callback is used to surface deprecation warnings. + /// + /// @nodoc + @internal + static Value roundInternal( + Object strategyOrNumber, Object? numberOrStep, Object? step, + {required FileSpan? span, + required String? inLegacySassFunction, + required void Function(String message, [Deprecation? deprecation])? + warn}) { switch (( _simplify(strategyOrNumber), numberOrStep.andThen(_simplify), step.andThen(_simplify) )) { - case (SassNumber number, null, null): + case (SassNumber(hasUnits: false) && var number, null, null): + return SassNumber(number.value.round()); + + case (SassNumber number, null, null) when inLegacySassFunction != null: + warn!( + "In future versions of Sass, round() will be interpreted as a CSS " + "round() calculation. This requires an explicit modulus when " + "rounding numbers with units. If you want to use the Sass " + "function, call math.round() instead.\n" + "\n" + "See https://sass-lang.com/d/import", + Deprecation.globalBuiltin); return _matchUnits(number.value.round().toDouble(), number); case (SassNumber number, SassNumber step, null) @@ -542,12 +577,8 @@ final class SassCalculation extends Value { throw SassScriptException( "Number to round and step arguments are required."); - case (SassString rest, null, null): - return SassCalculation._("round", [rest]); - case (var number, null, null): - throw SassScriptException( - "Single argument $number expected to be simplifiable."); + return SassCalculation._("round", [number]); case (var number, var step?, null): return SassCalculation._("round", [number, step]); @@ -584,32 +615,54 @@ final class SassCalculation extends Value { static Object operate( CalculationOperator operator, Object left, Object right) => operateInternal(operator, left, right, - inLegacySassFunction: false, simplify: true); + inLegacySassFunction: null, simplify: true, warn: null); - /// Like [operate], but with the internal-only [inLegacySassFunction] parameter. + /// Like [operate], but with the internal-only [inLegacySassFunction] and + /// [warn] parameters. /// - /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and - /// subtracted with numbers with units, for backwards-compatibility with the - /// old global `min()` and `max()` functions. + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()` and `max()` functions. This emits a + /// deprecation warning using the string as the function's name. /// /// If [simplify] is `false`, no simplification will be done. + /// + /// The [warn] callback is used to surface deprecation warnings. + /// + /// @nodoc @internal static Object operateInternal( CalculationOperator operator, Object left, Object right, - {required bool inLegacySassFunction, required bool simplify}) { + {required String? inLegacySassFunction, + required bool simplify, + required void Function(String message, [Deprecation? deprecation])? + warn}) { if (!simplify) return CalculationOperation._(operator, left, right); left = _simplify(left); right = _simplify(right); if (operator case CalculationOperator.plus || CalculationOperator.minus) { - if (left is SassNumber && - right is SassNumber && - (inLegacySassFunction - ? left.isComparableTo(right) - : left.hasCompatibleUnits(right))) { - return operator == CalculationOperator.plus - ? left.plus(right) - : left.minus(right); + if (left is SassNumber && right is SassNumber) { + var compatible = left.hasCompatibleUnits(right); + if (!compatible && + inLegacySassFunction != null && + left.isComparableTo(right)) { + warn!( + "In future versions of Sass, $inLegacySassFunction() will be " + "interpreted as the CSS $inLegacySassFunction() calculation. " + "This doesn't allow unitless numbers to be mixed with numbers " + "with units. If you want to use the Sass function, call " + "math.$inLegacySassFunction() instead.\n" + "\n" + "See https://sass-lang.com/d/import", + Deprecation.globalBuiltin); + compatible = true; + } + if (compatible) { + return operator == CalculationOperator.plus + ? left.plus(right) + : left.minus(right); + } } _verifyCompatibleNumbers([left, right]); @@ -906,13 +959,13 @@ enum CalculationOperator { /// The division operator. dividedBy('divided by', '/', 2); - /// The English name of [this]. + /// The English name of `this`. final String name; - /// The CSS syntax for [this]. + /// The CSS syntax for `this`. final String operator; - /// The precedence of [this]. + /// The precedence of `this`. /// /// An operator with higher precedence binds tighter. /// diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 794f236fd..d68f465a0 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -2,207 +2,539 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:math' as math; - +import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; -import '../deprecation.dart'; -import '../evaluation_context.dart'; import '../exception.dart'; -import '../io.dart'; +import '../util/nullable.dart'; import '../util/number.dart'; import '../value.dart'; import '../visitor/interface/value.dart'; +export 'color/gamut_map_method.dart'; +export 'color/interpolation_method.dart'; +export 'color/channel.dart'; +export 'color/space.dart'; + /// A SassScript color. /// /// {@category Value} @sealed class SassColor extends Value { - /// This color's red channel, between `0` and `255`. - int get red { - if (_red == null) _hslToRgb(); - return _red!; - } + // We don't use public fields because they'd be overridden by the getters of + // the same name in the JS API. - int? _red; + /// This color's space. + ColorSpace get space => _space; + final ColorSpace _space; - /// This color's green channel, between `0` and `255`. - int get green { - if (_green == null) _hslToRgb(); - return _green!; - } + /// The values of this color's channels (excluding the alpha channel). + /// + /// Note that the semantics of each of these channels varies significantly + /// based on the value of [space]. + List get channels => + List.unmodifiable([channel0, channel1, channel2]); - int? _green; + /// The values of this color's channels (excluding the alpha channel), or + /// `null` for [missing] channels. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// Note that the semantics of each of these channels varies significantly + /// based on the value of [space]. + List get channelsOrNull => + List.unmodifiable([channel0OrNull, channel1OrNull, channel2OrNull]); - /// This color's blue channel, between `0` and `255`. - int get blue { - if (_blue == null) _hslToRgb(); - return _blue!; - } + /// This color's first channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel0 => channel0OrNull ?? 0; - int? _blue; + /// Returns whether this color's first channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel0Missing => channel0OrNull == null; - /// This color's hue, between `0` and `360`. - double get hue { - if (_hue == null) _rgbToHsl(); - return _hue!; - } + /// Returns whether this color's first channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + bool get isChannel0Powerless => switch (space) { + ColorSpace.hsl => fuzzyEquals(channel1, 0), + ColorSpace.hwb => fuzzyGreaterThanOrEquals(channel1 + channel2, 100), + _ => false + }; - double? _hue; + /// This color's first channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + final double? channel0OrNull; - /// This color's saturation, a percentage between `0` and `100`. - double get saturation { - if (_saturation == null) _rgbToHsl(); - return _saturation!; - } + /// This color's second channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel1 => channel1OrNull ?? 0; - double? _saturation; + /// Returns whether this color's second channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel1Missing => channel1OrNull == null; - /// This color's lightness, a percentage between `0` and `100`. - double get lightness { - if (_lightness == null) _rgbToHsl(); - return _lightness!; - } + /// Returns whether this color's second channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + final bool isChannel1Powerless = false; - double? _lightness; + /// This color's second channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + final double? channel1OrNull; - /// This color's whiteness, a percentage between `0` and `100`. - double get whiteness { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return math.min(math.min(red, green), blue) / 255 * 100; - } + /// Returns whether this color's third channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + bool get isChannel2Missing => channel2OrNull == null; - /// This color's blackness, a percentage between `0` and `100`. - double get blackness { - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. - return 100 - math.max(math.max(red, green), blue) / 255 * 100; - } + /// Returns whether this color's third channel is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// @nodoc + @internal + bool get isChannel2Powerless => switch (space) { + ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel1, 0), + _ => false + }; - // We don't use public fields because they'd be overridden by the getters of - // the same name in the JS API. + /// This color's third channel. + /// + /// The semantics of this depend on the color space. Returns 0 for a missing + /// channel. + /// + /// @nodoc + @internal + double get channel2 => channel2OrNull ?? 0; - /// This color's alpha channel, between `0` and `1`. - double get alpha => _alpha; - final double _alpha; + /// This color's third channel. + /// + /// The semantics of this depend on the color space. If this is `null`, that + /// indicates a [missing] component. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// @nodoc + @internal + final double? channel2OrNull; /// The format in which this color was originally written and should be /// serialized in expanded mode, or `null` if the color wasn't written in a /// supported format. /// + /// This is only set if `space` is `"rgb"`. + /// /// @nodoc @internal final ColorFormat? format; - /// Creates an RGB color. + /// This color's alpha channel, between `0` and `1`. + double get alpha => alphaOrNull ?? 0; + + /// This color's alpha channel. /// - /// Passing `null` to [alpha] is deprecated, and will change behavior in - /// future versions of Dart Sass to represent a [missing component] instead of - /// being equivalent to `1`. Callers who want to create opaque colors should - /// explicitly pass `1` or not pass [alpha] at all. + /// If this is `null`, that indicates a [missing] component. /// - /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// - /// Throws a [RangeError] if [red], [green], and [blue] aren't between `0` and - /// `255`, or if [alpha] isn't between `0` and `1`. - SassColor.rgb(int red, int green, int blue, [num? alpha = 1]) - : this.rgbInternal(red, green, blue, _handleNullAlpha(alpha)); + /// @nodoc + final double? alphaOrNull; - /// Like [SassColor.rgb], but also takes a [format] parameter. + /// Returns whether this color's alpha channel is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing /// /// @nodoc @internal - SassColor.rgbInternal(this._red, this._green, this._blue, - [num alpha = 1, this.format]) - : _alpha = fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha") { - RangeError.checkValueInInterval(red, 0, 255, "red"); - RangeError.checkValueInInterval(green, 0, 255, "green"); - RangeError.checkValueInInterval(blue, 0, 255, "blue"); + bool get isAlphaMissing => alphaOrNull == null; + + /// Whether this is a legacy color—that is, a color defined using + /// pre-color-spaces syntax that preserves comaptibility with old color + /// behavior and semantics. + bool get isLegacy => space.isLegacy; + + /// Whether this color is in-gamut for its color space. + bool get isInGamut { + if (!space.isBounded) return true; + + // There aren't (currently) any color spaces that are bounded but not + // STRICTLY bounded, and have polar-angle channels. + return _isChannelInGamut(channel0, space.channels[0]) && + _isChannelInGamut(channel1, space.channels[1]) && + _isChannelInGamut(channel2, space.channels[2]); } - /// Creates an HSL color. + /// Returns whether [value] is in-gamut for the given [channel]. + bool _isChannelInGamut(double value, ColorChannel channel) => + switch (channel) { + LinearChannel(:var min, :var max) => + fuzzyLessThanOrEquals(value, max) && + fuzzyGreaterThanOrEquals(value, min), + _ => true + }; + + /// Whether this color has any missing channels. /// - /// Passing `null` to [alpha] is deprecated, and will change behavior in - /// future versions of Dart Sass to represent a [missing component] instead of - /// being equivalent to `1`. Callers who want to create opaque colors should - /// explicitly pass `1` or not pass [alpha] at all. + /// @nodoc + @internal + bool get hasMissingChannel => + isChannel0Missing || + isChannel1Missing || + isChannel2Missing || + isAlphaMissing; + + /// This color's red channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get red => _legacyChannel(ColorSpace.rgb, 'red').round(); + + /// This color's green channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get green => _legacyChannel(ColorSpace.rgb, 'green').round(); + + /// This color's blue channel, between `0` and `255`. + /// + /// **Note:** This is rounded to the nearest integer, which may be lossy. Use + /// [channel] instead to get the true red value. + @Deprecated('Use channel() instead.') + int get blue => _legacyChannel(ColorSpace.rgb, 'blue').round(); + + /// This color's hue, between `0` and `360`. + @Deprecated('Use channel() instead.') + double get hue => _legacyChannel(ColorSpace.hsl, 'hue'); + + /// This color's saturation, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get saturation => _legacyChannel(ColorSpace.hsl, 'saturation'); + + /// This color's lightness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get lightness => _legacyChannel(ColorSpace.hsl, 'lightness'); + + /// This color's whiteness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get whiteness => _legacyChannel(ColorSpace.hwb, 'whiteness'); + + /// This color's blackness, a percentage between `0` and `100`. + @Deprecated('Use channel() instead.') + double get blackness => _legacyChannel(ColorSpace.hwb, 'blackness'); + + /// Creates a color in [ColorSpace.rgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. /// /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components /// - /// Throws a [RangeError] if [saturation] or [lightness] aren't between `0` - /// and `100`, or if [alpha] isn't between `0` and `1`. - SassColor.hsl(num hue, num saturation, num lightness, [num? alpha = 1]) - : this.hslInternal(hue, saturation, lightness, _handleNullAlpha(alpha)); + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rgb(num? red, num? green, num? blue, [num? alpha = 1]) => + SassColor.rgbInternal(red, green, blue, alpha); - /// Like [SassColor.hsl], but also takes a [format] parameter. + /// Like [SassColor.rgb], but also takes a [format] parameter. /// /// @nodoc @internal - SassColor.hslInternal(num hue, num saturation, num lightness, - [num alpha = 1, this.format]) - : _hue = hue % 360, - _saturation = - fuzzyAssertRange(saturation.toDouble(), 0, 100, "saturation"), - _lightness = - fuzzyAssertRange(lightness.toDouble(), 0, 100, "lightness"), - _alpha = fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha"); - - /// Creates an HWB color. - /// - /// Throws a [RangeError] if [whiteness] or [blackness] aren't between `0` and - /// `100`, or if [alpha] isn't between `0` and `1`. - factory SassColor.hwb(num hue, num whiteness, num blackness, - [num? alpha = 1]) { - // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb - var scaledHue = hue % 360 / 360; - var scaledWhiteness = - fuzzyAssertRange(whiteness.toDouble(), 0, 100, "whiteness") / 100; - var scaledBlackness = - fuzzyAssertRange(blackness.toDouble(), 0, 100, "blackness") / 100; - - var sum = scaledWhiteness + scaledBlackness; - if (sum > 1) { - scaledWhiteness /= sum; - scaledBlackness /= sum; - } + factory SassColor.rgbInternal(num? red, num? green, num? blue, + [num? alpha = 1, ColorFormat? format]) => + SassColor._forSpace(ColorSpace.rgb, red?.toDouble(), green?.toDouble(), + blue?.toDouble(), alpha?.toDouble(), format); - var factor = 1 - scaledWhiteness - scaledBlackness; - int toRgb(double hue) { - var channel = _hueToRgb(0, 1, hue) * factor + scaledWhiteness; - return fuzzyRound(channel * 255); - } + /// Creates a color in [ColorSpace.hsl]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hsl(num? hue, num? saturation, num? lightness, + [num? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.hsl, hue?.toDouble(), + saturation?.toDouble(), lightness?.toDouble(), alpha?.toDouble()); - // Because HWB is (currently) used much less frequently than HSL or RGB, we - // don't cache its values because we expect the memory overhead of doing so - // to outweigh the cost of recalculating it on access. Instead, we eagerly - // convert it to RGB and then convert back if necessary. - return SassColor.rgb(toRgb(scaledHue + 1 / 3), toRgb(scaledHue), - toRgb(scaledHue - 1 / 3), alpha); - } + /// Creates a color in [ColorSpace.hwb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.hwb(num? hue, num? whiteness, num? blackness, + [num? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.hwb, hue?.toDouble(), + whiteness?.toDouble(), blackness?.toDouble(), alpha?.toDouble()); + + /// Creates a color in [ColorSpace.srgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.srgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.srgbLinear]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.srgbLinear(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.srgbLinear, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.displayP3]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.displayP3(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.displayP3, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.a98Rgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.a98Rgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.a98Rgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.prophotoRgb]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.prophotoRgb(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.prophotoRgb, red, green, blue, alpha); + + /// Creates a color in [ColorSpace.rec2020]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.rec2020(double? red, double? green, double? blue, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.rec2020, red, green, blue, alpha); - /// Prints a deprecation warning if [alpha] is explicitly `null`. - static num _handleNullAlpha(num? alpha) { - if (alpha != null) return alpha; - - warnForDeprecation( - 'Passing null for alpha in the ${isJS ? 'JS' : 'Dart'} API is ' - 'deprecated.\n' - 'To preserve current behavior, pass 1${isJS ? ' or undefined' : ''} ' - 'instead.' - '\n' - 'More info: https://sass-lang.com/d/null-alpha', - Deprecation.nullAlpha); - return 1; + /// Creates a color in [ColorSpace.xyzD50]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD50(double? x, double? y, double? z, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.xyzD50, x, y, z, alpha); + + /// Creates a color in [ColorSpace.xyzD65]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.xyzD65(double? x, double? y, double? z, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.xyzD65, x, y, z, alpha); + + /// Creates a color in [ColorSpace.lab]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lab(double? lightness, double? a, double? b, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.lab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.lch]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.lch(double? lightness, double? chroma, double? hue, + [double? alpha = 1]) => + SassColor.forSpaceInternal(ColorSpace.lch, lightness, chroma, hue, alpha); + + /// Creates a color in [ColorSpace.oklab]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklab(double? lightness, double? a, double? b, + [double? alpha = 1]) => + SassColor._forSpace(ColorSpace.oklab, lightness, a, b, alpha); + + /// Creates a color in [ColorSpace.oklch]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1`. + factory SassColor.oklch(double? lightness, double? chroma, double? hue, + [double? alpha = 1]) => + SassColor.forSpaceInternal( + ColorSpace.oklch, lightness, chroma, hue, alpha); + + /// Creates a color in the color space named [space]. + /// + /// If `null` is passed for [alpha], that indicates that it's a [missing + /// component]. In most cases, this is equivalent to the color being + /// transparent. + /// + /// [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + /// + /// Throws a [RangeError] if [alpha] isn't between `0` and `1` or if + /// [channels] is the wrong length for [space]. + factory SassColor.forSpace(ColorSpace space, List channels, + [double? alpha = 1]) => + channels.length == space.channels.length + ? SassColor.forSpaceInternal( + space, channels[0], channels[1], channels[2], alpha) + : throw RangeError.value(channels.length, "channels.length", + 'must be exactly ${space.channels.length} for color space "$space"'); + + /// Like [forSpace], but takes three channels explicitly rather than wrapping + /// and unwrapping them in an array. + /// + /// @nodoc + factory SassColor.forSpaceInternal(ColorSpace space, double? channel0, + double? channel1, double? channel2, + [double? alpha = 1]) => + switch (space) { + ColorSpace.hsl => SassColor._forSpace( + space, + _normalizeHue(channel0, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + channel1?.abs(), + channel2, + alpha), + ColorSpace.hwb => SassColor._forSpace(space, + _normalizeHue(channel0, invert: false), channel1, channel2, alpha), + ColorSpace.lch || ColorSpace.oklch => SassColor._forSpace( + space, + channel0, + channel1?.abs(), + _normalizeHue(channel2, + invert: channel1 != null && fuzzyLessThan(channel1, 0)), + alpha), + _ => SassColor._forSpace(space, channel0, channel1, channel2, alpha) + }; + + /// Like [forSpaceInternal], but doesn't do _any_ pre-processing of any + /// channels. + SassColor._forSpace(this._space, this.channel0OrNull, this.channel1OrNull, + this.channel2OrNull, double? alpha, [this.format]) + : alphaOrNull = + alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) { + assert(format == null || _space == ColorSpace.rgb); + assert(space != ColorSpace.lms); } - SassColor._(this._red, this._green, this._blue, this._hue, this._saturation, - this._lightness, this._alpha) - : format = null; + /// If [hue] isn't null, normalizes it to the range `[0, 360)`. + /// + /// If [invert] is true, this returns the hue 180deg offset from the original value. + static double? _normalizeHue(double? hue, {required bool invert}) { + if (hue == null) return hue; + return (hue % 360 + 360 + (invert ? 180 : 0)) % 360; + } /// @nodoc @internal @@ -210,31 +542,392 @@ class SassColor extends Value { SassColor assertColor([String? name]) => this; + /// Throws a [SassScriptException] if this isn't in a legacy color space. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). This is used for error reporting. + /// + /// @nodoc + @internal + void assertLegacy([String? name]) { + if (isLegacy) return; + throw SassScriptException( + 'Expected $this to be in the legacy RGB, HSL, or HWB color space.', + name); + } + + /// Returns the value of the given [channel] in this color, or throws a + /// [SassScriptException] if it doesn't exist. + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + double channel(String channel, {String? colorName, String? channelName}) { + var channels = space.channels; + if (channel == channels[0].name) return channel0; + if (channel == channels[1].name) return channel1; + if (channel == channels[2].name) return channel2; + if (channel == 'alpha') return alpha; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// Returns whether the given [channel] in this color is [missing]. + /// + /// [missing]: https://www.w3.org/TR/css-color-4/#missing + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + bool isChannelMissing(String channel, + {String? colorName, String? channelName}) { + var channels = space.channels; + if (channel == channels[0].name) return isChannel0Missing; + if (channel == channels[1].name) return isChannel1Missing; + if (channel == channels[2].name) return isChannel2Missing; + if (channel == 'alpha') return isAlphaMissing; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// Returns whether the given [channel] in this color is [powerless]. + /// + /// [powerless]: https://www.w3.org/TR/css-color-4/#powerless + /// + /// If this came from a function argument, [colorName] is the argument name + /// for this color and [channelName] is the argument name for [channel] + /// (without the `$`). These are used for error reporting. + bool isChannelPowerless(String channel, + {String? colorName, String? channelName}) { + var channels = space.channels; + if (channel == channels[0].name) return isChannel0Powerless; + if (channel == channels[1].name) return isChannel1Powerless; + if (channel == channels[2].name) return isChannel2Powerless; + if (channel == 'alpha') return false; + + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", channelName); + } + + /// If this is a legacy color, converts it to the given [space] and then + /// returns the given [channel]. + /// + /// Otherwise, throws an exception. + double _legacyChannel(ColorSpace space, String channel) { + if (!isLegacy) { + throw SassScriptException( + "color.$channel() is only supported for legacy colors. Please use " + "color.channel() instead with an explicit \$space argument."); + } + + return toSpace(space).channel(channel); + } + + /// Converts this color to [space]. + /// + /// If [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. Otherwise, they remain + /// missing after the conversion. + SassColor toSpace(ColorSpace space, {bool legacyMissing = true}) { + if (this.space == space) return this; + + var converted = this + .space + .convert(space, channel0OrNull, channel1OrNull, channel2OrNull, alpha); + return !legacyMissing && + converted.isLegacy && + (converted.isChannel0Missing || + converted.isChannel1Missing || + converted.isChannel2Missing || + converted.isAlphaMissing) + ? SassColor.forSpaceInternal(converted.space, converted.channel0, + converted.channel1, converted.channel2, converted.alpha) + : converted; + } + + /// Returns a copy of this color that's in-gamut in the current color space. + SassColor toGamut(GamutMapMethod method) => + isInGamut ? this : method.map(this); + /// Changes one or more of this color's RGB channels and returns the result. - SassColor changeRgb({int? red, int? green, int? blue, num? alpha}) => - SassColor.rgb(red ?? this.red, green ?? this.green, blue ?? this.blue, - alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeRgb({int? red, int? green, int? blue, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeRgb() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.rgb( + red?.toDouble() ?? channel('red'), + green?.toDouble() ?? channel('green'), + blue?.toDouble() ?? channel('blue'), + alpha?.toDouble() ?? this.alpha); + } /// Changes one or more of this color's HSL channels and returns the result. - SassColor changeHsl( - {num? hue, num? saturation, num? lightness, num? alpha}) => - SassColor.hsl(hue ?? this.hue, saturation ?? this.saturation, - lightness ?? this.lightness, alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeHsl({num? hue, num? saturation, num? lightness, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeHsl() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.hsl( + hue?.toDouble() ?? this.hue, + saturation?.toDouble() ?? this.saturation, + lightness?.toDouble() ?? this.lightness, + alpha?.toDouble() ?? this.alpha) + .toSpace(space); + } /// Changes one or more of this color's HWB channels and returns the result. - SassColor changeHwb({num? hue, num? whiteness, num? blackness, num? alpha}) => - SassColor.hwb(hue ?? this.hue, whiteness ?? this.whiteness, - blackness ?? this.blackness, alpha ?? this.alpha); + @Deprecated('Use changeChannels() instead.') + SassColor changeHwb({num? hue, num? whiteness, num? blackness, num? alpha}) { + if (!isLegacy) { + throw SassScriptException( + "color.changeHsl() is only supported for legacy colors. Please use " + "color.changeChannels() instead with an explicit \$space argument."); + } + + return SassColor.hwb( + hue?.toDouble() ?? this.hue, + whiteness?.toDouble() ?? this.whiteness, + blackness?.toDouble() ?? this.blackness, + alpha?.toDouble() ?? this.alpha + 0.0) + .toSpace(space); + } /// Returns a new copy of this color with the alpha channel set to [alpha]. - SassColor changeAlpha(num alpha) => SassColor._( - _red, - _green, - _blue, - _hue, - _saturation, - _lightness, - fuzzyAssertRange(alpha.toDouble(), 0, 1, "alpha")); + SassColor changeAlpha(num alpha) => SassColor.forSpaceInternal( + space, channel0, channel1, channel2, alpha.toDouble()); + + /// Changes one or more of this color's channels and returns the result. + /// + /// The keys of [newValues] are channel names and the values are the new + /// values of those channels. + /// + /// If [space] is passed, this converts this color to [space], sets the + /// channels, then converts the result back to its original color space. + /// + /// Throws a [SassScriptException] if any of the keys aren't valid channel + /// names for this color, or if the same channel is set multiple times. + /// + /// If this color came from a function argument, [colorName] is the argument + /// name (without the `$`). This is used for error reporting. + SassColor changeChannels(Map newValues, + {ColorSpace? space, String? colorName}) { + if (newValues.isEmpty) return this; + + if (space != null && space != this.space) { + return toSpace(space) + .changeChannels(newValues, colorName: colorName) + .toSpace(this.space); + } + + double? new0; + double? new1; + double? new2; + double? alpha; + var channels = this.space.channels; + + void setChannel0(double value) { + if (new0 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[0]}": $new0 and ' + '$value.', + colorName); + } + new0 = value; + } + + void setChannel1(double value) { + if (new1 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[1]}": $new1 and ' + '$value.', + colorName); + } + new1 = value; + } + + void setChannel2(double value) { + if (new2 != null) { + throw SassScriptException( + 'Multiple values supplied for "${channels[2]}": $new2 and ' + '$value.', + colorName); + } + new2 = value; + } + + for (var entry in newValues.entries) { + var channel = entry.key; + if (channel == channels[0].name) { + setChannel0(entry.value); + } else if (channel == channels[1].name) { + setChannel1(entry.value); + } else if (channel == channels[2].name) { + setChannel2(entry.value); + } else if (channel == 'alpha') { + if (alpha != null) { + throw SassScriptException( + 'Multiple values supplied for "alpha": $alpha and ' + '${entry.value}.', + colorName); + } + alpha = entry.value; + } else { + throw SassScriptException( + "Color $this doesn't have a channel named \"$channel\".", + colorName); + } + } + + return SassColor.forSpaceInternal(this.space, new0 ?? channel0OrNull, + new1 ?? channel1OrNull, new2 ?? channel2OrNull, alpha ?? alphaOrNull); + } + + /// Returns a color partway between `this` and [other] according to [method], + /// as defined by the CSS Color 4 [color interpolation] procedure. + /// + /// [color interpolation]: https://www.w3.org/TR/css-color-4/#interpolation + /// + /// The [weight] is a number between 0 and 1 that indicates how much of `this` + /// should be in the resulting color. It defaults to 0.5. + /// + /// If [legacyMissing] is false, this will convert missing channels in legacy + /// color spaces to zero if a conversion occurs. + SassColor interpolate(SassColor other, InterpolationMethod method, + {double? weight, bool legacyMissing = true}) { + weight ??= 0.5; + + if (fuzzyEquals(weight, 0)) return other; + if (fuzzyEquals(weight, 1)) return this; + + var color1 = toSpace(method.space); + var color2 = other.toSpace(method.space); + + if (weight < 0 || weight > 1) { + throw RangeError.range(weight, 0, 1, 'weight'); + } + + // If either color is missing a channel _and_ that channel is analogous with + // one in the output space, then the output channel should take on the other + // color's value. + var missing1_0 = _isAnalogousChannelMissing(this, color1, 0); + var missing1_1 = _isAnalogousChannelMissing(this, color1, 1); + var missing1_2 = _isAnalogousChannelMissing(this, color1, 2); + var missing2_0 = _isAnalogousChannelMissing(other, color2, 0); + var missing2_1 = _isAnalogousChannelMissing(other, color2, 1); + var missing2_2 = _isAnalogousChannelMissing(other, color2, 2); + var channel1_0 = (missing1_0 ? color2 : color1).channel0; + var channel1_1 = (missing1_1 ? color2 : color1).channel1; + var channel1_2 = (missing1_2 ? color2 : color1).channel2; + var channel2_0 = (missing2_0 ? color1 : color2).channel0; + var channel2_1 = (missing2_1 ? color1 : color2).channel1; + var channel2_2 = (missing2_2 ? color1 : color2).channel2; + var alpha1 = alphaOrNull ?? other.alpha; + var alpha2 = other.alphaOrNull ?? alpha; + + var thisMultiplier = (alphaOrNull ?? 1) * weight; + var otherMultiplier = (other.alphaOrNull ?? 1) * (1 - weight); + var mixedAlpha = isAlphaMissing && other.isAlphaMissing + ? null + : alpha1 * weight + alpha2 * (1 - weight); + var mixed0 = missing1_0 && missing2_0 + ? null + : (channel1_0 * thisMultiplier + channel2_0 * otherMultiplier) / + (mixedAlpha ?? 1); + var mixed1 = missing1_1 && missing2_1 + ? null + : (channel1_1 * thisMultiplier + channel2_1 * otherMultiplier) / + (mixedAlpha ?? 1); + var mixed2 = missing1_2 && missing2_2 + ? null + : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / + (mixedAlpha ?? 1); + + return switch (method.space) { + ColorSpace.hsl || ColorSpace.hwb => SassColor.forSpaceInternal( + method.space, + missing1_0 && missing2_0 + ? null + : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), + mixed1, + mixed2, + mixedAlpha), + ColorSpace.lch || ColorSpace.oklch => SassColor.forSpaceInternal( + method.space, + mixed0, + mixed1, + missing1_2 && missing2_2 + ? null + : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), + mixedAlpha), + _ => SassColor.forSpaceInternal( + method.space, mixed0, mixed1, mixed2, mixedAlpha) + } + .toSpace(space, legacyMissing: legacyMissing); + } + + /// Returns whether [output], which was converted to its color space from + /// [original], should be considered to have a missing channel at + /// [outputChannelIndex]. + /// + /// This includes channels that are analogous to missing channels in + /// [original]. + bool _isAnalogousChannelMissing( + SassColor original, SassColor output, int outputChannelIndex) { + if (output.channelsOrNull[outputChannelIndex] == null) return true; + if (identical(original, output)) return false; + + var outputChannel = output.space.channels[outputChannelIndex]; + var originalChannel = + original.space.channels.firstWhereOrNull(outputChannel.isAnalogous); + if (originalChannel == null) return false; + + return original.isChannelMissing(originalChannel.name); + } + + /// Returns a hue partway between [hue1] and [hue2] according to [method]. + /// + /// The [weight] is a number between 0 and 1 that indicates how much of [hue1] + /// should be in the resulting hue. + double _interpolateHues( + double hue1, double hue2, HueInterpolationMethod method, double weight) { + // Algorithms from https://www.w3.org/TR/css-color-4/#hue-interpolation + switch (method) { + case HueInterpolationMethod.shorter: + switch (hue2 - hue1) { + case > 180: + hue1 += 360; + case < -180: + hue2 += 360; + } + + case HueInterpolationMethod.longer: + switch (hue2 - hue1) { + case > 0 && < 180: + hue2 += 360; + case > -180 && <= 0: + hue1 += 360; + } + + case HueInterpolationMethod.increasing when hue2 < hue1: + hue2 += 360; + + case HueInterpolationMethod.decreasing when hue1 < hue2: + hue1 += 360; + + case _: // do nothing + } + + return hue1 * weight + hue2 * (1 - weight); + } /// @nodoc @internal @@ -259,126 +952,45 @@ class SassColor extends Value { throw SassScriptException('Undefined operation "$this / $other".'); } - bool operator ==(Object other) => - other is SassColor && - other.red == red && - other.green == green && - other.blue == blue && - other.alpha == alpha; - - int get hashCode => - red.hashCode ^ green.hashCode ^ blue.hashCode ^ alpha.hashCode; - - /// Computes [_hue], [_saturation], and [_value] based on [red], [green], and - /// [blue]. - void _rgbToHsl() { - // Algorithm from https://en.wikipedia.org/wiki/HSL_and_HSV#RGB_to_HSL_and_HSV - var scaledRed = red / 255; - var scaledGreen = green / 255; - var scaledBlue = blue / 255; - - var max = math.max(math.max(scaledRed, scaledGreen), scaledBlue); - var min = math.min(math.min(scaledRed, scaledGreen), scaledBlue); - var delta = max - min; - - if (max == min) { - _hue = 0; - } else if (max == scaledRed) { - _hue = (60 * (scaledGreen - scaledBlue) / delta) % 360; - } else if (max == scaledGreen) { - _hue = (120 + 60 * (scaledBlue - scaledRed) / delta) % 360; - } else if (max == scaledBlue) { - _hue = (240 + 60 * (scaledRed - scaledGreen) / delta) % 360; - } - - var lightness = _lightness = 50 * (max + min); - - if (max == min) { - _saturation = 0; - } else if (lightness < 50) { - _saturation = 100 * delta / (max + min); - } else { - _saturation = 100 * delta / (2 - max - min); + operator ==(Object other) { + if (other is! SassColor) return false; + + if (isLegacy) { + if (!other.isLegacy) return false; + if (!fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull)) return false; + if (space == other.space) { + return fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull); + } else { + return toSpace(ColorSpace.rgb) == other.toSpace(ColorSpace.rgb); + } } - } - /// Computes [_red], [_green], and [_blue] based on [hue], [saturation], and - /// [value]. - void _hslToRgb() { - // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. - var scaledHue = hue / 360; - var scaledSaturation = saturation / 100; - var scaledLightness = lightness / 100; - - var m2 = scaledLightness <= 0.5 - ? scaledLightness * (scaledSaturation + 1) - : scaledLightness + - scaledSaturation - - scaledLightness * scaledSaturation; - var m1 = scaledLightness * 2 - m2; - _red = fuzzyRound(_hueToRgb(m1, m2, scaledHue + 1 / 3) * 255); - _green = fuzzyRound(_hueToRgb(m1, m2, scaledHue) * 255); - _blue = fuzzyRound(_hueToRgb(m1, m2, scaledHue - 1 / 3) * 255); + return space == other.space && + fuzzyEqualsNullable(channel0OrNull, other.channel0OrNull) && + fuzzyEqualsNullable(channel1OrNull, other.channel1OrNull) && + fuzzyEqualsNullable(channel2OrNull, other.channel2OrNull) && + fuzzyEqualsNullable(alphaOrNull, other.alphaOrNull); } - /// An algorithm from the CSS3 spec: - /// https://www.w3.org/TR/css3-color/#hsl-color. - static double _hueToRgb(double m1, double m2, double hue) { - if (hue < 0) hue += 1; - if (hue > 1) hue -= 1; - - return switch (hue) { - < 1 / 6 => m1 + (m2 - m1) * hue * 6, - < 1 / 2 => m2, - < 2 / 3 => m1 + (m2 - m1) * (2 / 3 - hue) * 6, - _ => m1 - }; - } - - /// Returns an `rgb()` or `rgba()` function call that will evaluate to this - /// color. - /// - /// @nodoc - @internal - String toStringAsRgb() { - var isOpaque = fuzzyEquals(alpha, 1); - var buffer = StringBuffer(isOpaque ? "rgb" : "rgba") - ..write("($red, $green, $blue"); - - if (!isOpaque) { - // Write the alpha as a SassNumber to ensure it's valid CSS. - buffer.write(", ${SassNumber(alpha)}"); + int get hashCode { + if (isLegacy) { + var rgb = toSpace(ColorSpace.rgb); + return fuzzyHashCode(rgb.channel0) ^ + fuzzyHashCode(rgb.channel1) ^ + fuzzyHashCode(rgb.channel2) ^ + fuzzyHashCode(alpha); + } else { + return space.hashCode ^ + fuzzyHashCode(channel0) ^ + fuzzyHashCode(channel1) ^ + fuzzyHashCode(channel2) ^ + fuzzyHashCode(alpha); } - - buffer.write(")"); - return buffer.toString(); } } -/// Extension methods that are only visible through the `sass_api` package. -/// -/// These methods are considered less general-purpose and more liable to change -/// than the main [SassColor] interface. -/// -/// {@category Value} -extension SassApiColor on SassColor { - /// Whether the `red`, `green`, and `blue` fields have already been computed - /// for this value. - /// - /// Note that these fields can always be safely computed after the fact; this - /// just allows users such as the Sass embedded compiler to access whichever - /// representation is readily available. - bool get hasCalculatedRgb => _red != null; - - /// Whether the `hue`, `saturation`, and `lightness` fields have already been - /// computed for this value. - /// - /// Note that these fields can always be safely computed after the fact; this - /// just allows users such as the Sass embedded compiler to access whichever - /// representation is readily available. - bool get hasCalculatedHsl => _saturation != null; -} - /// A union interface of possible formats in which a Sass color could be /// defined. /// @@ -388,9 +1000,6 @@ extension SassApiColor on SassColor { abstract class ColorFormat { /// A color defined using the `rgb()` or `rgba()` functions. static const rgbFunction = _ColorFormatEnum("rgbFunction"); - - /// A color defined using the `hsl()` or `hsla()` functions. - static const hslFunction = _ColorFormatEnum("hslFunction"); } /// The class for enum values of the [ColorFormat] type. diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart new file mode 100644 index 000000000..63e279da7 --- /dev/null +++ b/lib/src/value/color/channel.dart @@ -0,0 +1,106 @@ +// Copyright 2022 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:meta/meta.dart'; + +/// Metadata about a single channel in a known color space. +/// +/// {@category Value} +@sealed +class ColorChannel { + /// The alpha channel that's shared across all colors. + static const alpha = LinearChannel('alpha', 0, 1); + + /// The channel's name. + final String name; + + /// Whether this is a polar angle channel, which represents (in degrees) the + /// angle around a circle. + /// + /// This is true if and only if this is not a [LinearChannel]. + final bool isPolarAngle; + + /// The unit that's associated with this channel. + /// + /// Some channels are typically written without units, while others have a + /// specific unit that is conventionally applied to their values. Although any + /// compatible unit or unitless value will work for input¹, this unit is used + /// when the value is serialized or returned from a Sass function. + /// + /// 1: Unless [LinearChannel.requiresPercent] is set, in which case unitless + /// values are not allowed. + final String? associatedUnit; + + /// @nodoc + @internal + const ColorChannel(this.name, + {required this.isPolarAngle, this.associatedUnit}); + + /// Returns whether this channel is [analogous] to [other]. + /// + /// [analogous]: https://www.w3.org/TR/css-color-4/#interpolation-missing + bool isAnalogous(ColorChannel other) => switch ((name, other.name)) { + ("red" || "x", "red" || "x") || + ("green" || "y", "green" || "y") || + ("blue" || "z", "blue" || "z") || + ("chroma" || "saturation", "chroma" || "saturation") || + ("lightness", "lightness") || + ("hue", "hue") => + true, + _ => false + }; +} + +/// Metadata about a color channel with a linear (as opposed to polar) value. +/// +/// {@category Value} +@sealed +class LinearChannel extends ColorChannel { + /// The channel's minimum value. + /// + /// Unless this color space is strictly bounded, this channel's values may + /// still be below this minimum value. It just represents a limit to reference + /// when specifying channels by percentage, as well as a boundary for what's + /// considered in-gamut if the color space has a bounded gamut. + final double min; + + /// The channel's maximum value. + /// + /// Unless this color space is strictly bounded, this channel's values may + /// still be above this maximum value. It just represents a limit to reference + /// when specifying channels by percentage, as well as a boundary for what's + /// considered in-gamut if the color space has a bounded gamut. + final double max; + + /// Whether this channel requires values to be specified with unit `%` and + /// forbids unitless values. + final bool requiresPercent; + + /// Whether the lower bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool lowerClamped; + + /// Whether the upper bound of this channel is clamped when the color is + /// created using the global function syntax. + final bool upperClamped; + + /// Creates a linear color channel. + /// + /// By default, [ColorChannel.associatedUnit] is set to `%` if and only if + /// [min] is 0 and [max] is 100. However, if [conventionallyPercent] is + /// true, it's set to `%`, and if it's false, it's set to null. + /// + /// @nodoc + @internal + const LinearChannel(super.name, this.min, this.max, + {this.requiresPercent = false, + this.lowerClamped = false, + this.upperClamped = false, + bool? conventionallyPercent}) + : super( + isPolarAngle: false, + associatedUnit: (conventionallyPercent ?? (min == 0 && max == 100)) + ? '%' + : null); +} diff --git a/lib/src/value/color/conversions.dart b/lib/src/value/color/conversions.dart new file mode 100644 index 000000000..14046c53b --- /dev/null +++ b/lib/src/value/color/conversions.dart @@ -0,0 +1,465 @@ +// Copyright 2022 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:typed_data'; + +/// The D50 white point. +/// +/// Definition from https://www.w3.org/TR/css-color-4/#color-conversion-code. +const d50 = [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585]; + +// Matrix values from https://www.w3.org/TR/css-color-4/#color-conversion-code. + +/// The transformation matrix for converting LMS colors to OKLab. +/// +/// Note that this can't be directly multiplied with [xyzD65ToLms]; see Color +/// Level 4 spec for details on how to convert between XYZ and OKLab. +final lmsToOklab = Float64List.fromList([ + 00.21045426830931400, 00.79361777470230540, -0.00407204301161930, // + 01.97799853243116840, -2.42859224204858000, 00.45059370961741100, + 00.02590404246554780, 00.78277171245752960, -0.80867575492307740, +]); + +/// The transformation matrix for converting OKLab colors to LMS. +/// +/// Note that this can't be directly multiplied with [lmsToXyzD65]; see Color +/// Level 4 spec for details on how to convert between XYZ and OKLab. +final oklabToLms = Float64List.fromList([ + 01.00000000000000020, 00.39633777737617490, 00.21580375730991360, // + 00.99999999999999980, -0.10556134581565854, -0.06385417282581334, + 00.99999999999999990, -0.08948417752981180, -1.29148554801940940, +]); + +// The following matrices were precomputed using +// https://gist.github.com/nex3/3d7ecfef467b22e02e7a666db1b8a316. + +/// The transformation matrix for converting linear-light srgb colors to +/// linear-light display-p3. +final linearSrgbToLinearDisplayP3 = Float64List.fromList([ + 00.82246196871436230, 00.17753803128563775, 00.00000000000000000, // + 00.03319419885096161, 00.96680580114903840, 00.00000000000000000, + 00.01708263072112003, 00.07239744066396346, 00.91051992861491650, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// linear-light srgb. +final linearDisplayP3ToLinearSrgb = Float64List.fromList([ + 01.22494017628055980, -0.22494017628055996, 00.00000000000000000, // + -0.04205695470968816, 01.04205695470968800, 00.00000000000000000, + -0.01963755459033443, -0.07863604555063188, 01.09827360014096630, +]); + +/// The transformation matrix for converting linear-light srgb colors to +/// linear-light a98-rgb. +final linearSrgbToLinearA98Rgb = Float64List.fromList([ + 00.71512560685562470, 00.28487439314437535, 00.00000000000000000, // + 00.00000000000000000, 01.00000000000000000, 00.00000000000000000, + 00.00000000000000000, 00.04116194845011846, 00.95883805154988160, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to +/// linear-light srgb. +final linearA98RgbToLinearSrgb = Float64List.fromList([ + 01.39835574396077830, -0.39835574396077830, 00.00000000000000000, // + 00.00000000000000000, 01.00000000000000000, 00.00000000000000000, + 00.00000000000000000, -0.04292898929447326, 01.04292898929447330, +]); + +/// The transformation matrix for converting linear-light srgb colors to +/// linear-light rec2020. +final linearSrgbToLinearRec2020 = Float64List.fromList([ + 00.62740389593469900, 00.32928303837788370, 00.04331306568741722, // + 00.06909728935823208, 00.91954039507545870, 00.01136231556630917, + 00.01639143887515027, 00.08801330787722575, 00.89559525324762400, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to +/// linear-light srgb. +final linearRec2020ToLinearSrgb = Float64List.fromList([ + 01.66049100210843450, -0.58764113878854950, -0.07284986331988487, // + -0.12455047452159074, 01.13289989712596030, -0.00834942260436947, + -0.01815076335490530, -0.10057889800800737, 01.11872966136291270, +]); + +/// The transformation matrix for converting linear-light srgb colors to xyz. +final linearSrgbToXyzD65 = Float64List.fromList([ + 00.41239079926595950, 00.35758433938387796, 00.18048078840183430, // + 00.21263900587151036, 00.71516867876775590, 00.07219231536073371, + 00.01933081871559185, 00.11919477979462598, 00.95053215224966060, +]); + +/// The transformation matrix for converting xyz colors to linear-light srgb. +final xyzD65ToLinearSrgb = Float64List.fromList([ + 03.24096994190452130, -1.53738317757009350, -0.49861076029300330, // + -0.96924363628087980, 01.87596750150772060, 00.04155505740717561, + 00.05563007969699360, -0.20397695888897657, 01.05697151424287860, +]); + +/// The transformation matrix for converting linear-light srgb colors to lms. +final linearSrgbToLms = Float64List.fromList([ + 00.41222146947076300, 00.53633253726173480, 00.05144599326750220, // + 00.21190349581782520, 00.68069955064523420, 00.10739695353694050, + 00.08830245919005641, 00.28171883913612150, 00.62997870167382210, +]); + +/// The transformation matrix for converting lms colors to linear-light srgb. +final lmsToLinearSrgb = Float64List.fromList([ + 04.07674163607595800, -3.30771153925806200, 00.23096990318210417, // + -1.26843797328503200, 02.60975734928768900, -0.34131937600265710, + -0.00419607613867551, -0.70341861793593630, 01.70761469407461200, +]); + +/// The transformation matrix for converting linear-light srgb colors to +/// linear-light prophoto-rgb. +final linearSrgbToLinearProphotoRgb = Float64List.fromList([ + 00.52927697762261160, 00.33015450197849283, 00.14056852039889556, // + 00.09836585954044917, 00.87347071290696180, 00.02816342755258900, + 00.01687534092138684, 00.11765941425612084, 00.86546524482249230, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// linear-light srgb. +final linearProphotoRgbToLinearSrgb = Float64List.fromList([ + 02.03438084951699600, -0.72763578993413420, -0.30674505958286180, // + -0.22882573163305037, 01.23174254119010480, -0.00291680955705449, + -0.00855882878391742, -0.15326670213803720, 01.16182553092195470, +]); + +/// The transformation matrix for converting linear-light srgb colors to +/// xyz-d50. +final linearSrgbToXyzD50 = Float64List.fromList([ + 00.43606574687426936, 00.38515150959015960, 00.14307841996513868, // + 00.22249317711056518, 00.71688701309448240, 00.06061980979495235, + 00.01392392146316939, 00.09708132423141015, 00.71409935681588070, +]); + +/// The transformation matrix for converting xyz-d50 colors to linear-light +/// srgb. +final xyzD50ToLinearSrgb = Float64List.fromList([ + 03.13413585290011780, -1.61738599801804200, -0.49066221791109754, // + -0.97879547655577770, 01.91625437739598840, 00.03344287339036693, + 00.07195539255794733, -0.22897675981518200, 01.40538603511311820, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// linear-light a98-rgb. +final linearDisplayP3ToLinearA98Rgb = Float64List.fromList([ + 00.86400513747404840, 00.13599486252595164, 00.00000000000000000, // + -0.04205695470968816, 01.04205695470968800, 00.00000000000000000, + -0.02056038078232985, -0.03250613804550798, 01.05306651882783790, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to +/// linear-light display-p3. +final linearA98RgbToLinearDisplayP3 = Float64List.fromList([ + 01.15009441814101840, -0.15009441814101834, 00.00000000000000000, // + 00.04641729862941844, 00.95358270137058150, 00.00000000000000000, + 00.02388759479083904, 00.02650477632633013, 00.94960762888283080, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// linear-light rec2020. +final linearDisplayP3ToLinearRec2020 = Float64List.fromList([ + 00.75383303436172180, 00.19859736905261630, 00.04756959658566187, // + 00.04574384896535833, 00.94177721981169350, 00.01247893122294812, + -0.00121034035451832, 00.01760171730108989, 00.98360862305342840, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to +/// linear-light display-p3. +final linearRec2020ToLinearDisplayP3 = Float64List.fromList([ + 01.34357825258433200, -0.28217967052613570, -0.06139858205819628, // + -0.06529745278911953, 01.07578791584857460, -0.01049046305945495, + 00.00282178726170095, -0.01959849452449406, 01.01677670726279310, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// xyz. +final linearDisplayP3ToXyzD65 = Float64List.fromList([ + 00.48657094864821626, 00.26566769316909294, 00.19821728523436250, // + 00.22897456406974884, 00.69173852183650620, 00.07928691409374500, + 00.00000000000000000, 00.04511338185890257, 01.04394436890097570, +]); + +/// The transformation matrix for converting xyz colors to linear-light +/// display-p3. +final xyzD65ToLinearDisplayP3 = Float64List.fromList([ + 02.49349691194142450, -0.93138361791912360, -0.40271078445071684, // + -0.82948896956157490, 01.76266406031834680, 00.02362468584194359, + 00.03584583024378433, -0.07617238926804170, 00.95688452400768730, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// lms. +final linearDisplayP3ToLms = Float64List.fromList([ + 00.48137985274995443, 00.46211837101131803, 00.05650177623872756, // + 00.22883194181124472, 00.65321681938356760, 00.11795123880518774, + 00.08394575232299319, 00.22416527097756642, 00.69188897669944040, +]); + +/// The transformation matrix for converting lms colors to linear-light +/// display-p3. +final lmsToLinearDisplayP3 = Float64List.fromList([ + 03.12776897136187370, -2.25713576259163860, 00.12936679122976494, // + -1.09100901843779790, 02.41333171030692250, -0.32232269186912466, + -0.02601080193857045, -0.50804133170416700, 01.53405213364273730, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// linear-light prophoto-rgb. +final linearDisplayP3ToLinearProphotoRgb = Float64List.fromList([ + 00.63168691934035890, 00.21393038569465722, 00.15438269496498390, // + 00.08320371426648458, 00.88586513676302430, 00.03093114897049121, + -0.00127273456473881, 00.05075510433665735, 00.95051763022808140, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// linear-light display-p3. +final linearProphotoRgbToLinearDisplayP3 = Float64List.fromList([ + 01.63257560870691790, -0.37977161848259840, -0.25280399022431950, // + -0.15370040233755072, 01.16670254724250140, -0.01300214490495082, + 00.01039319529676572, -0.06280731264959440, 01.05241411735282870, +]); + +/// The transformation matrix for converting linear-light display-p3 colors to +/// xyz-d50. +final linearDisplayP3ToXyzD50 = Float64List.fromList([ + 00.51514644296811600, 00.29200998206385770, 00.15713925139759397, // + 00.24120032212525520, 00.69222254113138180, 00.06657713674336294, + -0.00105013914714014, 00.04187827018907460, 00.78427647146852570, +]); + +/// The transformation matrix for converting xyz-d50 colors to linear-light +/// display-p3. +final xyzD50ToLinearDisplayP3 = Float64List.fromList([ + 02.40393412185549730, -0.99003044249559310, -0.39761363181465614, // + -0.84227001614546880, 01.79895801610670820, 00.01604562477090472, + 00.04819381686413303, -0.09738519815446048, 01.27367136933212730, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to +/// linear-light rec2020. +final linearA98RgbToLinearRec2020 = Float64List.fromList([ + 00.87733384166365680, 00.07749370651571998, 00.04517245182062317, // + 00.09662259146620378, 00.89152732024418050, 00.01185008828961569, + 00.02292106270284839, 00.04303668501067932, 00.93404225228647230, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to +/// linear-light a98-rgb. +final linearRec2020ToLinearA98Rgb = Float64List.fromList([ + 01.15197839471591630, -0.09750305530240860, -0.05447533941350766, // + -0.12455047452159074, 01.13289989712596030, -0.00834942260436947, + -0.02253038278105590, -0.04980650742838876, 01.07233689020944460, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to xyz. +final linearA98RgbToXyzD65 = Float64List.fromList([ + 00.57666904291013080, 00.18555823790654627, 00.18822864623499472, // + 00.29734497525053616, 00.62736356625546600, 00.07529145849399789, + 00.02703136138641237, 00.07068885253582714, 00.99133753683763890, +]); + +/// The transformation matrix for converting xyz colors to linear-light a98-rgb. +final xyzD65ToLinearA98Rgb = Float64List.fromList([ + 02.04158790381074600, -0.56500697427885960, -0.34473135077832950, // + -0.96924363628087980, 01.87596750150772060, 00.04155505740717561, + 00.01344428063203102, -0.11836239223101823, 01.01517499439120540, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to lms. +final linearA98RgbToLms = Float64List.fromList([ + 00.57643225961839410, 00.36991322261987963, 00.05365451776172635, // + 00.29631647054222465, 00.59167613325218850, 00.11200739620558686, + 00.12347825101427760, 00.21949869837199862, 00.65702305061372380, +]); + +/// The transformation matrix for converting lms colors to linear-light a98-rgb. +final lmsToLinearA98Rgb = Float64List.fromList([ + 02.55403683861155660, -1.62197618068286990, 00.06793934207131327, // + -1.26843797328503200, 02.60975734928768900, -0.34131937600265710, + -0.05623473593749381, -0.56704183956690610, 01.62327657550439990, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to +/// linear-light prophoto-rgb. +final linearA98RgbToLinearProphotoRgb = Float64List.fromList([ + 00.74011750180477920, 00.11327951328898105, 00.14660298490623970, // + 00.13755046469802620, 00.83307708026948400, 00.02937245503248977, + 00.02359772990871766, 00.07378347703906656, 00.90261879305221580, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// linear-light a98-rgb. +final linearProphotoRgbToLinearA98Rgb = Float64List.fromList([ + 01.38965124815152000, -0.16945907691487766, -0.22019217123664242, // + -0.22882573163305037, 01.23174254119010480, -0.00291680955705449, + -0.01762544368426068, -0.09625702306122665, 01.11388246674548740, +]); + +/// The transformation matrix for converting linear-light a98-rgb colors to +/// xyz-d50. +final linearA98RgbToXyzD50 = Float64List.fromList([ + 00.60977504188618140, 00.20530000261929401, 00.14922063192409227, // + 00.31112461220464155, 00.62565323083468560, 00.06322215696067286, + 00.01947059555648168, 00.06087908649415867, 00.74475492045981980, +]); + +/// The transformation matrix for converting xyz-d50 colors to linear-light +/// a98-rgb. +final xyzD50ToLinearA98Rgb = Float64List.fromList([ + 01.96246703637688060, -0.61074234048150730, -0.34135809808271540, // + -0.97879547655577770, 01.91625437739598840, 00.03344287339036693, + 00.02870443944957101, -0.14067486633170680, 01.34891418141379370, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to xyz. +final linearRec2020ToXyzD65 = Float64List.fromList([ + 00.63695804830129130, 00.14461690358620838, 00.16888097516417205, // + 00.26270021201126703, 00.67799807151887100, 00.05930171646986194, + 00.00000000000000000, 00.02807269304908750, 01.06098505771079090, +]); + +/// The transformation matrix for converting xyz colors to linear-light rec2020. +final xyzD65ToLinearRec2020 = Float64List.fromList([ + 01.71665118797126760, -0.35567078377639240, -0.25336628137365980, // + -0.66668435183248900, 01.61648123663493900, 00.01576854581391113, + 00.01763985744531091, -0.04277061325780865, 00.94210312123547400, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to lms. +final linearRec2020ToLms = Float64List.fromList([ + 00.61675578486544440, 00.36019840122646335, 00.02304581390809228, // + 00.26513305939263670, 00.63583937206784910, 00.09902756853951408, + 00.10010262952034828, 00.20390652261661452, 00.69599084786303720, +]); + +/// The transformation matrix for converting lms colors to linear-light rec2020. +final lmsToLinearRec2020 = Float64List.fromList([ + 02.13990673043465130, -1.24638949376061800, 00.10648276332596668, // + -0.88473583575776740, 02.16323093836120070, -0.27849510260343340, + -0.04857374640044396, -0.45450314971409640, 01.50307689611454040, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to +/// linear-light prophoto-rgb. +final linearRec2020ToLinearProphotoRgb = Float64List.fromList([ + 00.83518733312972350, 00.04886884858605698, 00.11594381828421951, // + 00.05403324519953363, 00.92891840856920440, 00.01704834623126199, + -0.00234203897072539, 00.03633215316169465, 00.96600988580903070, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// linear-light rec2020. +final linearProphotoRgbToLinearRec2020 = Float64List.fromList([ + 01.20065932951740800, -0.05756805370122346, -0.14309127581618444, // + -0.06994154955888504, 01.08061789759721400, -0.01067634803832895, + 00.00554147334294746, -0.04078219298657951, 01.03524071964363200, +]); + +/// The transformation matrix for converting linear-light rec2020 colors to +/// xyz-d50. +final linearRec2020ToXyzD50 = Float64List.fromList([ + 00.67351546318827600, 00.16569726370390453, 00.12508294953738705, // + 00.27905900514112060, 00.67531800574910980, 00.04562298910976962, + -0.00193242713400438, 00.02997782679282923, 00.79705920285163550, +]); + +/// The transformation matrix for converting xyz-d50 colors to linear-light +/// rec2020. +final xyzD50ToLinearRec2020 = Float64List.fromList([ + 01.64718490467176600, -0.39368189813164710, -0.23595963848828266, // + -0.68266410741738180, 01.64771461274440760, 00.01281708338512084, + 00.02966887665275675, -0.06292589642970030, 01.25355782018657710, +]); + +/// The transformation matrix for converting xyz colors to lms. +final xyzD65ToLms = Float64List.fromList([ + 00.81902243799670300, 00.36190626005289034, -0.12887378152098788, // + 00.03298365393238846, 00.92928686158634330, 00.03614466635064235, + 00.04817718935962420, 00.26423953175273080, 00.63354782846943080, +]); + +/// The transformation matrix for converting lms colors to xyz. +final lmsToXyzD65 = Float64List.fromList([ + 01.22687987584592430, -0.55781499446021710, 00.28139104566596460, // + -0.04057574521480084, 01.11228680328031730, -0.07171105806551635, + -0.07637293667466007, -0.42149333240224324, 01.58692401983678180, +]); + +/// The transformation matrix for converting xyz colors to linear-light +/// prophoto-rgb. +final xyzD65ToLinearProphotoRgb = Float64List.fromList([ + 01.40319046337749790, -0.22301514479051668, -0.10160668507413790, // + -0.52623840216330720, 01.48163196292346440, 00.01701879027252688, + -0.01120226528622150, 00.01824640347962099, 00.91124722749150480, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// xyz. +final linearProphotoRgbToXyzD65 = Float64List.fromList([ + 00.75559074229692100, 00.11271984265940525, 00.08214534209534540, // + 00.26832184357857190, 00.71511525666179120, 00.01656289975963685, + 00.00391597276242580, -0.01293344283684181, 01.09807522083429450, +]); + +/// The transformation matrix for converting xyz colors to xyz-d50. +final xyzD65ToXyzD50 = Float64List.fromList([ + 01.04792979254499660, 00.02294687060160952, -0.05019226628920519, // + 00.02962780877005567, 00.99043442675388000, -0.01707379906341879, + -0.00924304064620452, 00.01505519149029816, 00.75187428142813700, +]); + +/// The transformation matrix for converting xyz-d50 colors to xyz. +final xyzD50ToXyzD65 = Float64List.fromList([ + 00.95547342148807520, -0.02309845494876452, 00.06325924320057065, // + -0.02836970933386358, 01.00999539808130410, 00.02104144119191730, + 00.01231401486448199, -0.02050764929889898, 01.33036592624212400, +]); + +/// The transformation matrix for converting lms colors to linear-light +/// prophoto-rgb. +final lmsToLinearProphotoRgb = Float64List.fromList([ + 01.73835514811572070, -0.98795094275144580, 00.24959579463572504, // + -0.70704940153292660, 01.93437004444013820, -0.22732064290721150, + -0.08407882206239634, -0.35754060521141334, 01.44161942727380970, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// lms. +final linearProphotoRgbToLms = Float64List.fromList([ + 00.71544846056555340, 00.35279155007721186, -0.06824001064276530, // + 00.27441164900156710, 00.66779764984123670, 00.05779070115719616, + 00.10978443261622942, 00.18619829115002018, 00.70401727623375040, +]); + +/// The transformation matrix for converting lms colors to xyz-d50. +final lmsToXyzD50 = Float64List.fromList([ + 01.28858621817270600, -0.53787174449737450, 00.21358120275423640, // + -0.00253387643187372, 01.09231679887191650, -0.08978292244004273, + -0.06937382305734124, -0.29500839894431263, 01.18948682451211420, +]); + +/// The transformation matrix for converting xyz-d50 colors to lms. +final xyzD50ToLms = Float64List.fromList([ + 00.77070004204311720, 00.34924840261939616, -0.11202351884164681, // + 00.00559649248368848, 00.93707234011367690, 00.06972568836252771, + 00.04633714262191069, 00.25277531574310524, 00.85145807674679600, +]); + +/// The transformation matrix for converting linear-light prophoto-rgb colors to +/// xyz-d50. +final linearProphotoRgbToXyzD50 = Float64List.fromList([ + 00.79776664490064230, 00.13518129740053308, 00.03134773412839220, // + 00.28807482881940130, 00.71183523424187300, 00.00008993693872564, + 00.00000000000000000, 00.00000000000000000, 00.82510460251046020, +]); + +/// The transformation matrix for converting xyz-d50 colors to linear-light +/// prophoto-rgb. +final xyzD50ToLinearProphotoRgb = Float64List.fromList([ + 01.34578688164715830, -0.25557208737979464, -0.05110186497554526, // + -0.54463070512490190, 01.50824774284514680, 00.02052744743642139, + 00.00000000000000000, 00.00000000000000000, 01.21196754563894520, +]); diff --git a/lib/src/value/color/gamut_map_method.dart b/lib/src/value/color/gamut_map_method.dart new file mode 100644 index 000000000..f934d5940 --- /dev/null +++ b/lib/src/value/color/gamut_map_method.dart @@ -0,0 +1,65 @@ +// 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:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'gamut_map_method/clip.dart'; +import 'gamut_map_method/local_minde.dart'; + +/// Different algorithms that can be used to map an out-of-gamut Sass color into +/// the gamut for its color space. +/// +/// {@category Value} +@sealed +abstract base class GamutMapMethod { + /// Clamp each color channel that's outside the gamut to the minimum or + /// maximum value for that channel. + /// + /// This algorithm will produce poor visual results, but it may be useful to + /// match the behavior of other situations in which a color can be clipped. + static const GamutMapMethod clip = ClipGamutMap(); + + /// The algorithm specified in [the original Color Level 4 candidate + /// recommendation]. + /// + /// This maps in the Oklch color space, using the [deltaEOK] color difference + /// formula and the [local-MINDE] improvement. + /// + /// [the original Color Level 4 candidate recommendation]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#css-gamut-mapping + /// [deltaEOK]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#color-difference-OK + /// [local-MINDE]: https://www.w3.org/TR/2024/CRD-css-color-4-20240213/#GM-chroma-local-MINDE + static const GamutMapMethod localMinde = LocalMindeGamutMap(); + + /// The Sass name of the gamut-mapping algorithm. + final String name; + + /// @nodoc + @internal + const GamutMapMethod(this.name); + + /// Parses a [GamutMapMethod] from its Sass name. + /// + /// Throws a [SassScriptException] if there is no method with the given + /// [name]. If this came from a function argument, [argumentName] is the + /// argument name (without the `$`). This is used for error reporting. + factory GamutMapMethod.fromName(String name, [String? argumentName]) => + switch (name) { + 'clip' => GamutMapMethod.clip, + 'local-minde' => GamutMapMethod.localMinde, + _ => throw SassScriptException( + 'Unknown gamut map method "$name".', argumentName) + }; + + /// Maps [color] to its gamut using this method's algorithm. + /// + /// Callers should use [SassColor.toGamut] instead of this method. + /// + /// @nodoc + @internal + SassColor map(SassColor color); + + String toString() => name; +} diff --git a/lib/src/value/color/gamut_map_method/clip.dart b/lib/src/value/color/gamut_map_method/clip.dart new file mode 100644 index 000000000..14420dea3 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/clip.dart @@ -0,0 +1,31 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping by clipping individual channels. +/// +/// @nodoc +@internal +final class ClipGamutMap extends GamutMapMethod { + const ClipGamutMap() : super("clip"); + + SassColor map(SassColor color) => SassColor.forSpaceInternal( + color.space, + _clampChannel(color.channel0OrNull, color.space.channels[0]), + _clampChannel(color.channel1OrNull, color.space.channels[1]), + _clampChannel(color.channel2OrNull, color.space.channels[2]), + color.alphaOrNull); + + /// Clamps the channel value [value] within the bounds given by [channel]. + double? _clampChannel(double? value, ColorChannel channel) => value == null + ? null + : switch (channel) { + LinearChannel(:var min, :var max) => clampLikeCss(value, min, max), + _ => value + }; +} diff --git a/lib/src/value/color/gamut_map_method/local_minde.dart b/lib/src/value/color/gamut_map_method/local_minde.dart new file mode 100644 index 000000000..a8b896d21 --- /dev/null +++ b/lib/src/value/color/gamut_map_method/local_minde.dart @@ -0,0 +1,93 @@ +// 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 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// Gamut mapping using the deltaEOK difference formula and the local-MINDE +/// improvement. +/// +/// @nodoc +@internal +final class LocalMindeGamutMap extends GamutMapMethod { + /// A constant from the gamut-mapping algorithm. + static const _jnd = 0.02; + + /// A constant from the gamut-mapping algorithm. + static const _epsilon = 0.0001; + + const LocalMindeGamutMap() : super("local-minde"); + + SassColor map(SassColor color) { + // Algorithm from https://www.w3.org/TR/2022/CRD-css-color-4-20221101/#css-gamut-mapping-algorithm + var originOklch = color.toSpace(ColorSpace.oklch); + + // The channel equivalents to `current` in the Color 4 algorithm. + var lightness = originOklch.channel0OrNull; + var hue = originOklch.channel2OrNull; + var alpha = originOklch.alphaOrNull; + + if (fuzzyGreaterThanOrEquals(lightness ?? 0, 1)) { + return color.isLegacy + ? SassColor.rgb(255, 255, 255, color.alphaOrNull).toSpace(color.space) + : SassColor.forSpaceInternal(color.space, 1, 1, 1, color.alphaOrNull); + } else if (fuzzyLessThanOrEquals(lightness ?? 0, 0)) { + return SassColor.rgb(0, 0, 0, color.alphaOrNull).toSpace(color.space); + } + + var clipped = color.toGamut(GamutMapMethod.clip); + if (_deltaEOK(clipped, color) < _jnd) return clipped; + + var min = 0.0; + var max = originOklch.channel1; + var minInGamut = true; + while (max - min > _epsilon) { + var chroma = (min + max) / 2; + + // In the Color 4 algorithm `current` is in Oklch, but all its actual uses + // other than modifying chroma convert it to `color.space` first so we + // just store it in that space to begin with. + var current = + ColorSpace.oklch.convert(color.space, lightness, chroma, hue, alpha); + + // Per [this comment], the intention of the algorithm is to fall through + // this clause if `minInGamut = false` without checking + // `current.isInGamut` at all, even though that's unclear from the + // pseudocode. `minInGamut = false` *should* imply `current.isInGamut = + // false`. + // + // [this comment]: https://github.com/w3c/csswg-drafts/issues/10226#issuecomment-2065534713 + if (minInGamut && current.isInGamut) { + min = chroma; + continue; + } + + clipped = current.toGamut(GamutMapMethod.clip); + var e = _deltaEOK(clipped, current); + if (e < _jnd) { + if (_jnd - e < _epsilon) return clipped; + minInGamut = false; + min = chroma; + } else { + max = chroma; + } + } + return clipped; + } + + /// Returns the ΔEOK measure between [color1] and [color2]. + double _deltaEOK(SassColor color1, SassColor color2) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-difference-OK + var lab1 = color1.toSpace(ColorSpace.oklab); + var lab2 = color2.toSpace(ColorSpace.oklab); + + return math.sqrt(math.pow(lab1.channel0 - lab2.channel0, 2) + + math.pow(lab1.channel1 - lab2.channel1, 2) + + math.pow(lab1.channel2 - lab2.channel2, 2)); + } +} diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart new file mode 100644 index 000000000..6a4a67535 --- /dev/null +++ b/lib/src/value/color/interpolation_method.dart @@ -0,0 +1,117 @@ +// Copyright 2022 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 '../../exception.dart'; +import '../../value.dart'; + +/// The method by which two colors are interpolated to find a color in the +/// middle. +/// +/// Used by [SassColor.interpolate]. +/// +/// {@category Value} +class InterpolationMethod { + /// The color space in which to perform the interpolation. + final ColorSpace space; + + /// How to interpolate the hues between two colors. + /// + /// This is non-null if and only if [space] is a color space. + final HueInterpolationMethod? hue; + + InterpolationMethod(this.space, [HueInterpolationMethod? hue]) + : hue = space.isPolar ? hue ?? HueInterpolationMethod.shorter : null { + if (!space.isPolar && hue != null) { + throw ArgumentError( + "Hue interpolation method may not be set for rectangular color space " + "$space."); + } + } + + /// Parses a SassScript value representing an interpolation method, not + /// beginning with "in". + /// + /// Throws a [SassScriptException] if [value] isn't a valid interpolation + /// method. If [value] came from a function argument, [name] is the argument name + /// (without the `$`). This is used for error reporting. + factory InterpolationMethod.fromValue(Value value, [String? name]) { + var list = value.assertCommonListStyle(name, allowSlash: false); + if (list.isEmpty) { + throw SassScriptException( + 'Expected a color interpolation method, got an empty list.', name); + } + + var space = ColorSpace.fromName( + (list.first.assertString(name)..assertUnquoted(name)).text, name); + if (list.length == 1) return InterpolationMethod(space); + + var hueMethod = HueInterpolationMethod._fromValue(list[1], name); + if (list.length == 2) { + throw SassScriptException( + 'Expected unquoted string "hue" after $value.', name); + } else if ((list[2].assertString(name)..assertUnquoted(name)) + .text + .toLowerCase() != + 'hue') { + throw SassScriptException( + 'Expected unquoted string "hue" at the end of $value, was ${list[2]}.', + name); + } else if (list.length > 3) { + throw SassScriptException( + 'Expected nothing after "hue" in $value.', name); + } else if (!space.isPolar) { + throw SassScriptException( + 'Hue interpolation method "$hueMethod hue" may not be set for ' + 'rectangular color space $space.', + name); + } + + return InterpolationMethod(space, hueMethod); + } + + String toString() => space.toString() + (hue == null ? '' : ' $hue hue'); +} + +/// The method by which two hues are adjusted when interpolating between colors. +/// +/// Used by [InterpolationMethod]. +/// +/// {@category Value} +enum HueInterpolationMethod { + /// Angles are adjusted so that `θ₂ - θ₁ ∈ [-180, 180]`. + /// + /// https://www.w3.org/TR/css-color-4/#shorter + shorter, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ {0, [180, 360)}`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-longer + longer, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ [0, 360)`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-increasing + increasing, + + /// Angles are adjusted so that `θ₂ - θ₁ ∈ (-360, 0]`. + /// + /// https://www.w3.org/TR/css-color-4/#hue-decreasing + decreasing; + + /// Parses a SassScript value representing a hue interpolation method, not + /// ending with "hue". + /// + /// Throws a [SassScriptException] if [value] isn't a valid hue interpolation + /// method. If [value] came from a function argument, [name] is the argument + /// name (without the `$`). This is used for error reporting. + factory HueInterpolationMethod._fromValue(Value value, [String? name]) => + switch ((value.assertString(name)..assertUnquoted()).text.toLowerCase()) { + 'shorter' => HueInterpolationMethod.shorter, + 'longer' => HueInterpolationMethod.longer, + 'increasing' => HueInterpolationMethod.increasing, + 'decreasing' => HueInterpolationMethod.decreasing, + _ => throw SassScriptException( + 'Unknown hue interpolation method $value.', name) + }; +} diff --git a/lib/src/value/color/space.dart b/lib/src/value/color/space.dart new file mode 100644 index 000000000..1d96ae705 --- /dev/null +++ b/lib/src/value/color/space.dart @@ -0,0 +1,323 @@ +// Copyright 2022 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:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../exception.dart'; +import '../color.dart'; +import 'space/a98_rgb.dart'; +import 'space/display_p3.dart'; +import 'space/hsl.dart'; +import 'space/hwb.dart'; +import 'space/lab.dart'; +import 'space/lch.dart'; +import 'space/lms.dart'; +import 'space/oklab.dart'; +import 'space/oklch.dart'; +import 'space/prophoto_rgb.dart'; +import 'space/rec2020.dart'; +import 'space/rgb.dart'; +import 'space/srgb.dart'; +import 'space/srgb_linear.dart'; +import 'space/xyz_d50.dart'; +import 'space/xyz_d65.dart'; + +/// A color space whose channel names and semantics Sass knows. +/// +/// {@category Value} +@sealed +abstract base class ColorSpace { + /// The legacy RGB color space. + static const ColorSpace rgb = RgbColorSpace(); + + /// The legacy HSL color space. + static const ColorSpace hsl = HslColorSpace(); + + /// The legacy HWB color space. + static const ColorSpace hwb = HwbColorSpace(); + + /// The sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB + static const ColorSpace srgb = SrgbColorSpace(); + + /// The linear-light sRGB color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear + static const ColorSpace srgbLinear = SrgbLinearColorSpace(); + + /// The display-p3 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-display-p3 + static const ColorSpace displayP3 = DisplayP3ColorSpace(); + + /// The a98-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-a98-rgb + static const ColorSpace a98Rgb = A98RgbColorSpace(); + + /// The prophoto-rgb color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-prophoto-rgb + static const ColorSpace prophotoRgb = ProphotoRgbColorSpace(); + + /// The rec2020 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-rec2020 + static const ColorSpace rec2020 = Rec2020ColorSpace(); + + /// The xyz-d65 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD65 = XyzD65ColorSpace(); + + /// The xyz-d50 color space. + /// + /// https://www.w3.org/TR/css-color-4/#predefined-xyz + static const ColorSpace xyzD50 = XyzD50ColorSpace(); + + /// The CIE Lab color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lab = LabColorSpace(); + + /// The CIE LCH color space. + /// + /// https://www.w3.org/TR/css-color-4/#cie-lab + static const ColorSpace lch = LchColorSpace(); + + /// The internal LMS color space. + /// + /// This only used as an intermediate space for conversions to and from OKLab + /// and OKLCH. It's never used in a real color value and isn't returned by + /// [fromName]. + /// + /// @nodoc + @internal + static const ColorSpace lms = LmsColorSpace(); + + /// The Oklab color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklab = OklabColorSpace(); + + /// The Oklch color space. + /// + /// https://www.w3.org/TR/css-color-4/#ok-lab + static const ColorSpace oklch = OklchColorSpace(); + + /// The CSS name of the color space. + final String name; + + /// See [SassApiColorSpace.channels]. + final List _channels; + + /// See [SassApiColorSpace.isBounded]. + /// + /// @nodoc + @internal + bool get isBoundedInternal; + + /// See [SassApiColorSpace.isLegacy]. + /// + /// @nodoc + @internal + bool get isLegacyInternal => false; + + /// See [SassApiColorSpace.isPolar]. + /// + /// @nodoc + @internal + bool get isPolarInternal => false; + + /// @nodoc + @internal + const ColorSpace(this.name, this._channels); + + /// Given a color space name, returns the known color space with that name or + /// throws a [SassScriptException] if there is none. + /// + /// If this came from a function argument, [argumentName] is the argument name + /// (without the `$`). This is used for error reporting. + static ColorSpace fromName(String name, [String? argumentName]) => + switch (name.toLowerCase()) { + 'rgb' => rgb, + 'hwb' => hwb, + 'hsl' => hsl, + 'srgb' => srgb, + 'srgb-linear' => srgbLinear, + 'display-p3' => displayP3, + 'a98-rgb' => a98Rgb, + 'prophoto-rgb' => prophotoRgb, + 'rec2020' => rec2020, + 'xyz' || 'xyz-d65' => xyzD65, + 'xyz-d50' => xyzD50, + 'lab' => lab, + 'lch' => lch, + 'oklab' => oklab, + 'oklch' => oklch, + _ => throw SassScriptException( + 'Unknown color space "$name".', argumentName) + }; + + /// Converts a color with the given channels from this color space to [dest]. + /// + /// By default, this uses this color space's [toLinear] and + /// [transformationMatrix] as well as [dest]'s [fromLinear], and relies on + /// individual color space conversions to do more than purely linear + /// conversions. + /// + /// @nodoc + @internal + SassColor convert(ColorSpace dest, double? channel0, double? channel1, + double? channel2, double? alpha) => + convertLinear(dest, channel0, channel1, channel2, alpha); + + /// The default implementation of [convert], which always starts with a linear + /// transformation from RGB or XYZ channels to a linear destination space, and + /// may then further convert to a polar space. + /// + /// @nodoc + @internal + @protected + @nonVirtual + SassColor convertLinear( + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + var linearDest = switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace(), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace(), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace(), + _ => dest + }; + + double? transformedRed; + double? transformedGreen; + double? transformedBlue; + if (linearDest == this) { + transformedRed = red; + transformedGreen = green; + transformedBlue = blue; + } else { + var linearRed = toLinear(red ?? 0); + var linearGreen = toLinear(green ?? 0); + var linearBlue = toLinear(blue ?? 0); + var matrix = transformationMatrix(linearDest); + + // (matrix * [linearRed, linearGreen, linearBlue]).map(linearDest.fromLinear) + transformedRed = linearDest.fromLinear(matrix[0] * linearRed + + matrix[1] * linearGreen + + matrix[2] * linearBlue); + transformedGreen = linearDest.fromLinear(matrix[3] * linearRed + + matrix[4] * linearGreen + + matrix[5] * linearBlue); + transformedBlue = linearDest.fromLinear(matrix[6] * linearRed + + matrix[7] * linearGreen + + matrix[8] * linearBlue); + } + + return switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => const SrgbColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue), + ColorSpace.lab || ColorSpace.lch => const XyzD50ColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), + ColorSpace.oklab || ColorSpace.oklch => const LmsColorSpace().convert( + dest, transformedRed, transformedGreen, transformedBlue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB), + _ => SassColor.forSpaceInternal( + dest, + red == null ? null : transformedRed, + green == null ? null : transformedGreen, + blue == null ? null : transformedBlue, + alpha) + }; + } + + /// Converts a channel in this color space into an element of a vector that + /// can be linearly transformed into other color spaces. + /// + /// The precise semantics of this vector may vary from color space to color + /// space. The only requirement is that, for any space `dest` for which + /// `transformationMatrix(dest)` returns a value, + /// `dest.fromLinear(toLinear(channels) * transformationMatrix(dest))` + /// converts from this space to `dest`. + /// + /// If a color space explicitly supports all conversions in [convert], it need + /// not override this at all. + /// + /// @nodoc + @protected + @internal + double toLinear(double channel) => throw UnimplementedError( + "[BUG] Color space $this doesn't support linear conversions."); + + /// Converts an element of a 3-element vector that can be linearly transformed + /// into other color spaces into a channel in this color space. + /// + /// The precise semantics of this vector may vary from color space to color + /// space. The only requirement is that, for any space `dest` for which + /// `transformationMatrix(dest)` returns a value, + /// `dest.fromLinear(toLinear(channels) * transformationMatrix(dest))` + /// converts from this space to `dest`. + /// + /// If a color space explicitly supports all conversions in [convert], it need + /// not override this at all. + /// + /// @nodoc + @protected + @internal + double fromLinear(double channel) => throw UnimplementedError( + "[BUG] Color space $this doesn't support linear conversions."); + + /// Returns the matrix for performing a linear transformation from this color + /// space to [dest]. + /// + /// Specifically, `dest.fromLinear(toLinear(channels) * + /// transformationMatrix(dest))` must convert from this space to `dest`. + /// + /// This only needs to return values for color spaces that aren't explicitly + /// supported in [convert]. If a color space explicitly supports all + /// conversions in [convert], it need not override this at all. + /// + /// @nodoc + @protected + @internal + Float64List transformationMatrix(ColorSpace dest) => throw UnimplementedError( + '[BUG] Color space conversion from $this to $dest not implemented.'); + + String toString() => name; +} + +/// ColorSpace methods that are only visible through the `sass_api` package. +extension SassApiColorSpace on ColorSpace { + // This color space's channels. + List get channels => _channels; + + /// Whether this color space has a bounded gamut. + bool get isBounded => isBoundedInternal; + + /// Whether this is a legacy color space. + bool get isLegacy => isLegacyInternal; + + /// Whether this color space uses a polar coordinate system. + bool get isPolar => isPolarInternal; +} diff --git a/lib/src/value/color/space/a98_rgb.dart b/lib/src/value/color/space/a98_rgb.dart new file mode 100644 index 000000000..df61a6d50 --- /dev/null +++ b/lib/src/value/color/space/a98_rgb.dart @@ -0,0 +1,49 @@ +// Copyright 2022 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:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The a98-rgb color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-a98-rgb +/// +/// @nodoc +@internal +final class A98RgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const A98RgbColorSpace() : super('a98-rgb', rgbChannels); + + @protected + double toLinear(double channel) => + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + channel.sign * math.pow(channel.abs(), 563 / 256); + + @protected + double fromLinear(double channel) => + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + channel.sign * math.pow(channel.abs(), 256 / 563); + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearA98RgbToLinearSrgb, + ColorSpace.displayP3 => linearA98RgbToLinearDisplayP3, + ColorSpace.prophotoRgb => linearA98RgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearA98RgbToLinearRec2020, + ColorSpace.xyzD65 => linearA98RgbToXyzD65, + ColorSpace.xyzD50 => linearA98RgbToXyzD50, + ColorSpace.lms => linearA98RgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/display_p3.dart b/lib/src/value/color/space/display_p3.dart new file mode 100644 index 000000000..b1c56df5d --- /dev/null +++ b/lib/src/value/color/space/display_p3.dart @@ -0,0 +1,44 @@ +// Copyright 2022 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:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The display-p3 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-display-p3 +/// +/// @nodoc +@internal +final class DisplayP3ColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const DisplayP3ColorSpace() : super('display-p3', rgbChannels); + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel); + + @protected + double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearDisplayP3ToLinearSrgb, + ColorSpace.a98Rgb => linearDisplayP3ToLinearA98Rgb, + ColorSpace.prophotoRgb => linearDisplayP3ToLinearProphotoRgb, + ColorSpace.rec2020 => linearDisplayP3ToLinearRec2020, + ColorSpace.xyzD65 => linearDisplayP3ToXyzD65, + ColorSpace.xyzD50 => linearDisplayP3ToXyzD50, + ColorSpace.lms => linearDisplayP3ToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/hsl.dart b/lib/src/value/color/space/hsl.dart new file mode 100644 index 000000000..bc4a02164 --- /dev/null +++ b/lib/src/value/color/space/hsl.dart @@ -0,0 +1,54 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'srgb.dart'; +import 'utils.dart'; + +/// The legacy HSL color space. +/// +/// @nodoc +@internal +final class HslColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + bool get isPolarInternal => true; + + const HslColorSpace() + : super('hsl', const [ + hueChannel, + LinearChannel('saturation', 0, 100, + requiresPercent: true, lowerClamped: true), + LinearChannel('lightness', 0, 100, requiresPercent: true) + ]); + + SassColor convert(ColorSpace dest, double? hue, double? saturation, + double? lightness, double? alpha) { + // Algorithm from the CSS3 spec: https://www.w3.org/TR/css3-color/#hsl-color. + var scaledHue = ((hue ?? 0) / 360) % 1; + var scaledSaturation = (saturation ?? 0) / 100; + var scaledLightness = (lightness ?? 0) / 100; + + var m2 = scaledLightness <= 0.5 + ? scaledLightness * (scaledSaturation + 1) + : scaledLightness + + scaledSaturation - + scaledLightness * scaledSaturation; + var m1 = scaledLightness * 2 - m2; + + return const SrgbColorSpace().convert( + dest, + hueToRgb(m1, m2, scaledHue + 1 / 3), + hueToRgb(m1, m2, scaledHue), + hueToRgb(m1, m2, scaledHue - 1 / 3), + alpha, + missingLightness: lightness == null, + missingChroma: saturation == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/hwb.dart b/lib/src/value/color/space/hwb.dart new file mode 100644 index 000000000..956768b19 --- /dev/null +++ b/lib/src/value/color/space/hwb.dart @@ -0,0 +1,51 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'srgb.dart'; +import 'utils.dart'; + +/// The legacy HWB color space. +/// +/// @nodoc +@internal +final class HwbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + bool get isPolarInternal => true; + + const HwbColorSpace() + : super('hwb', const [ + hueChannel, + LinearChannel('whiteness', 0, 100, requiresPercent: true), + LinearChannel('blackness', 0, 100, requiresPercent: true) + ]); + + SassColor convert(ColorSpace dest, double? hue, double? whiteness, + double? blackness, double? alpha) { + // From https://www.w3.org/TR/css-color-4/#hwb-to-rgb + var scaledHue = (hue ?? 0) % 360 / 360; + var scaledWhiteness = (whiteness ?? 0) / 100; + var scaledBlackness = (blackness ?? 0) / 100; + + var sum = scaledWhiteness + scaledBlackness; + if (sum > 1) { + scaledWhiteness /= sum; + scaledBlackness /= sum; + } + + var factor = 1 - scaledWhiteness - scaledBlackness; + double toRgb(double hue) => hueToRgb(0, 1, hue) * factor + scaledWhiteness; + + // Non-null because an in-gamut HSL color is guaranteed to be in-gamut for + // HWB as well. + return const SrgbColorSpace().convert(dest, toRgb(scaledHue + 1 / 3), + toRgb(scaledHue), toRgb(scaledHue - 1 / 3), alpha, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/lab.dart b/lib/src/value/color/space/lab.dart new file mode 100644 index 000000000..7766e706d --- /dev/null +++ b/lib/src/value/color/space/lab.dart @@ -0,0 +1,75 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../../util/number.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; +import 'xyz_d50.dart'; + +/// The Lab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +final class LabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const LabColorSpace() + : super('lab', const [ + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), + LinearChannel('a', -125, 125), + LinearChannel('b', -125, 125) + ]); + + SassColor convert( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + switch (dest) { + case ColorSpace.lab: + var powerlessAB = lightness == null || fuzzyEquals(lightness, 0); + return SassColor.lab(lightness, a == null || powerlessAB ? null : a, + b == null || powerlessAB ? null : b, alpha); + + case ColorSpace.lch: + return labToLch(dest, lightness, a, b, alpha); + + default: + var missingLightness = lightness == null; + lightness ??= 0; + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f1 = (lightness + 16) / 116; + + return const XyzD50ColorSpace().convert( + dest, + _convertFToXorZ((a ?? 0) / 500 + f1) * d50[0], + (lightness > labKappa * labEpsilon + ? math.pow((lightness + 16) / 116, 3) * 1.0 + : lightness / labKappa) * + d50[1], + _convertFToXorZ(f1 - (b ?? 0) / 200) * d50[2], + alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: a == null, + missingB: b == null); + } + } + + /// Converts an f-format component to the X or Z channel of an XYZ color. + double _convertFToXorZ(double component) { + var cubed = math.pow(component, 3) + 0.0; + return cubed > labEpsilon ? cubed : (116 * component - 16) / labKappa; + } +} diff --git a/lib/src/value/color/space/lch.dart b/lib/src/value/color/space/lch.dart new file mode 100644 index 000000000..095babeef --- /dev/null +++ b/lib/src/value/color/space/lch.dart @@ -0,0 +1,45 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'lab.dart'; +import 'utils.dart'; + +/// The LCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-lab-lch +/// +/// @nodoc +@internal +final class LchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const LchColorSpace() + : super('lch', const [ + LinearChannel('lightness', 0, 100, + lowerClamped: true, upperClamped: true), + LinearChannel('chroma', 0, 150, lowerClamped: true), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const LabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/lms.dart b/lib/src/value/color/space/lms.dart new file mode 100644 index 000000000..0ea82eb01 --- /dev/null +++ b/lib/src/value/color/space/lms.dart @@ -0,0 +1,124 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The LMS color space. +/// +/// This only used as an intermediate space for conversions to and from OKLab +/// and OKLCH. It's never used in a real color value and isn't returned by +/// [ColorSpace.fromName]. +/// +/// @nodoc +@internal +final class LmsColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const LmsColorSpace() + : super('lms', const [ + LinearChannel('long', 0, 1), + LinearChannel('medium', 0, 1), + LinearChannel('short', 0, 1) + ]); + + SassColor convert(ColorSpace dest, double? long, double? medium, + double? short, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + switch (dest) { + case ColorSpace.oklab: + // Algorithm from https://drafts.csswg.org/css-color-4/#color-conversion-code + var longScaled = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); + var lightness = lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled; + + return SassColor.oklab( + missingLightness ? null : lightness, + missingA + ? null + : lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + missingB + ? null + : lmsToOklab[6] * longScaled + + lmsToOklab[7] * mediumScaled + + lmsToOklab[8] * shortScaled, + alpha); + + case ColorSpace.oklch: + // This is equivalent to converting to OKLab and then to OKLCH, but we + // do it inline to avoid extra list allocations since we expect + // conversions to and from OKLCH to be very common. + var longScaled = _cubeRootPreservingSign(long ?? 0); + var mediumScaled = _cubeRootPreservingSign(medium ?? 0); + var shortScaled = _cubeRootPreservingSign(short ?? 0); + return labToLch( + dest, + missingLightness + ? null + : lmsToOklab[0] * longScaled + + lmsToOklab[1] * mediumScaled + + lmsToOklab[2] * shortScaled, + lmsToOklab[3] * longScaled + + lmsToOklab[4] * mediumScaled + + lmsToOklab[5] * shortScaled, + lmsToOklab[6] * longScaled + + lmsToOklab[7] * mediumScaled + + lmsToOklab[8] * shortScaled, + alpha, + missingChroma: missingChroma, + missingHue: missingHue); + + default: + return super.convertLinear(dest, long, medium, short, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } + } + + /// Returns the cube root of the absolute value of [number] with the same sign + /// as [number]. + double _cubeRootPreservingSign(double number) => + math.pow(number.abs(), 1 / 3) * number.sign; + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + lmsToLinearSrgb, + ColorSpace.a98Rgb => lmsToLinearA98Rgb, + ColorSpace.prophotoRgb => lmsToLinearProphotoRgb, + ColorSpace.displayP3 => lmsToLinearDisplayP3, + ColorSpace.rec2020 => lmsToLinearRec2020, + ColorSpace.xyzD65 => lmsToXyzD65, + ColorSpace.xyzD50 => lmsToXyzD50, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/oklab.dart b/lib/src/value/color/space/oklab.dart new file mode 100644 index 000000000..c24806635 --- /dev/null +++ b/lib/src/value/color/space/oklab.dart @@ -0,0 +1,77 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'lms.dart'; +import 'utils.dart'; + +/// The OKLab color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +final class OklabColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const OklabColorSpace() + : super('oklab', const [ + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), + LinearChannel('a', -0.4, 0.4), + LinearChannel('b', -0.4, 0.4) + ]); + + SassColor convert( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + if (dest == ColorSpace.oklch) { + return labToLch(dest, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); + } + + var missingLightness = lightness == null; + var missingA = a == null; + var missingB = b == null; + lightness ??= 0; + a ??= 0; + b ??= 0; + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + return const LmsColorSpace().convert( + dest, + math.pow( + oklabToLms[0] * lightness + + oklabToLms[1] * a + + oklabToLms[2] * b, + 3) + + 0.0, + math.pow( + oklabToLms[3] * lightness + + oklabToLms[4] * a + + oklabToLms[5] * b, + 3) + + 0.0, + math.pow( + oklabToLms[6] * lightness + + oklabToLms[7] * a + + oklabToLms[8] * b, + 3) + + 0.0, + alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } +} diff --git a/lib/src/value/color/space/oklch.dart b/lib/src/value/color/space/oklch.dart new file mode 100644 index 000000000..bdf7fea65 --- /dev/null +++ b/lib/src/value/color/space/oklch.dart @@ -0,0 +1,47 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'oklab.dart'; +import 'utils.dart'; + +/// The OKLCH color space. +/// +/// https://www.w3.org/TR/css-color-4/#specifying-oklab-oklch +/// +/// @nodoc +@internal +final class OklchColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + bool get isPolarInternal => true; + + const OklchColorSpace() + : super('oklch', const [ + LinearChannel('lightness', 0, 1, + conventionallyPercent: true, + lowerClamped: true, + upperClamped: true), + LinearChannel('chroma', 0, 0.4, lowerClamped: true), + hueChannel + ]); + + SassColor convert(ColorSpace dest, double? lightness, double? chroma, + double? hue, double? alpha) { + var hueRadians = (hue ?? 0) * math.pi / 180; + return const OklabColorSpace().convert( + dest, + lightness, + (chroma ?? 0) * math.cos(hueRadians), + (chroma ?? 0) * math.sin(hueRadians), + alpha, + missingChroma: chroma == null, + missingHue: hue == null); + } +} diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart new file mode 100644 index 000000000..0de23ada9 --- /dev/null +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -0,0 +1,55 @@ +// Copyright 2022 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:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The prophoto-rgb color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-prophoto-rgb +/// +/// @nodoc +@internal +final class ProphotoRgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const ProphotoRgbColorSpace() : super('prophoto-rgb', rgbChannels); + + @protected + double toLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs <= 16 / 512 ? channel / 16 : channel.sign * math.pow(abs, 1.8); + } + + @protected + double fromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs >= 1 / 512 + ? channel.sign * math.pow(abs, 1 / 1.8) + : 16 * channel; + } + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearProphotoRgbToLinearSrgb, + ColorSpace.a98Rgb => linearProphotoRgbToLinearA98Rgb, + ColorSpace.displayP3 => linearProphotoRgbToLinearDisplayP3, + ColorSpace.rec2020 => linearProphotoRgbToLinearRec2020, + ColorSpace.xyzD65 => linearProphotoRgbToXyzD65, + ColorSpace.xyzD50 => linearProphotoRgbToXyzD50, + ColorSpace.lms => linearProphotoRgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/rec2020.dart b/lib/src/value/color/space/rec2020.dart new file mode 100644 index 000000000..ca5dcf0e5 --- /dev/null +++ b/lib/src/value/color/space/rec2020.dart @@ -0,0 +1,63 @@ +// Copyright 2022 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:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// A constant used in the rec2020 gamma encoding/decoding functions. +const _alpha = 1.09929682680944; + +/// A constant used in the rec2020 gamma encoding/decoding functions. +const _beta = 0.018053968510807; + +/// The rec2020 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-rec2020 +/// +/// @nodoc +@internal +final class Rec2020ColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const Rec2020ColorSpace() : super('rec2020', rgbChannels); + + @protected + double toLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs < _beta * 4.5 + ? channel / 4.5 + : channel.sign * (math.pow((abs + _alpha - 1) / _alpha, 1 / 0.45)); + } + + @protected + double fromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs > _beta + ? channel.sign * (_alpha * math.pow(abs, 0.45) - (_alpha - 1)) + : 4.5 * channel; + } + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + linearRec2020ToLinearSrgb, + ColorSpace.a98Rgb => linearRec2020ToLinearA98Rgb, + ColorSpace.displayP3 => linearRec2020ToLinearDisplayP3, + ColorSpace.prophotoRgb => linearRec2020ToLinearProphotoRgb, + ColorSpace.xyzD65 => linearRec2020ToXyzD65, + ColorSpace.xyzD50 => linearRec2020ToXyzD50, + ColorSpace.lms => linearRec2020ToLms, + _ => super.transformationMatrix(dest), + }; +} diff --git a/lib/src/value/color/space/rgb.dart b/lib/src/value/color/space/rgb.dart new file mode 100644 index 000000000..b12fd40bd --- /dev/null +++ b/lib/src/value/color/space/rgb.dart @@ -0,0 +1,43 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import 'utils.dart'; + +/// The legacy RGB color space. +/// +/// @nodoc +@internal +final class RgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + bool get isLegacyInternal => true; + + const RgbColorSpace() + : super('rgb', const [ + LinearChannel('red', 0, 255, lowerClamped: true, upperClamped: true), + LinearChannel('green', 0, 255, + lowerClamped: true, upperClamped: true), + LinearChannel('blue', 0, 255, lowerClamped: true, upperClamped: true) + ]); + + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => + ColorSpace.srgb.convert( + dest, + red == null ? null : red / 255, + green == null ? null : green / 255, + blue == null ? null : blue / 255, + alpha); + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel / 255); + + @protected + double fromLinear(double channel) => + srgbAndDisplayP3FromLinear(channel) * 255; +} diff --git a/lib/src/value/color/space/srgb.dart b/lib/src/value/color/space/srgb.dart new file mode 100644 index 000000000..963cdf733 --- /dev/null +++ b/lib/src/value/color/space/srgb.dart @@ -0,0 +1,124 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../util/nullable.dart'; +import '../../../util/number.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The sRGB color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-sRGB +/// +/// @nodoc +@internal +final class SrgbColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbColorSpace() : super('srgb', rgbChannels); + + SassColor convert( + ColorSpace dest, double? red, double? green, double? blue, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false}) { + switch (dest) { + case ColorSpace.hsl || ColorSpace.hwb: + red ??= 0; + green ??= 0; + blue ??= 0; + + // Algorithm from https://drafts.csswg.org/css-color-4/#rgb-to-hsl + var max = math.max(math.max(red, green), blue); + var min = math.min(math.min(red, green), blue); + var delta = max - min; + + double hue; + if (max == min) { + hue = 0; + } else if (max == red) { + hue = 60 * (green - blue) / delta + 360; + } else if (max == green) { + hue = 60 * (blue - red) / delta + 120; + } else { + // max == blue + hue = 60 * (red - green) / delta + 240; + } + + if (dest == ColorSpace.hsl) { + var lightness = (min + max) / 2; + + var saturation = lightness == 0 || lightness == 1 + ? 0.0 + : 100 * (max - lightness) / math.min(lightness, 1 - lightness); + if (saturation < 0) { + hue += 180; + saturation = saturation.abs(); + } + + return SassColor.forSpaceInternal( + dest, + missingHue || fuzzyEquals(saturation, 0) ? null : hue % 360, + missingChroma ? null : saturation, + missingLightness ? null : lightness * 100, + alpha); + } else { + var whiteness = min * 100; + var blackness = 100 - max * 100; + return SassColor.forSpaceInternal( + dest, + missingHue || fuzzyGreaterThanOrEquals(whiteness + blackness, 100) + ? null + : hue % 360, + whiteness, + blackness, + alpha); + } + + case ColorSpace.rgb: + return SassColor.rgb( + red == null ? null : red * 255, + green == null ? null : green * 255, + blue == null ? null : blue * 255, + alpha); + + case ColorSpace.srgbLinear: + return SassColor.forSpaceInternal(dest, red.andThen(toLinear), + green.andThen(toLinear), blue.andThen(toLinear), alpha); + + default: + return super.convertLinear(dest, red, green, blue, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue); + } + } + + @protected + double toLinear(double channel) => srgbAndDisplayP3ToLinear(channel); + + @protected + double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest), + }; +} diff --git a/lib/src/value/color/space/srgb_linear.dart b/lib/src/value/color/space/srgb_linear.dart new file mode 100644 index 000000000..3e5151da4 --- /dev/null +++ b/lib/src/value/color/space/srgb_linear.dart @@ -0,0 +1,60 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../../util/nullable.dart'; +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The linear-light sRGB color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear +/// +/// @nodoc +@internal +final class SrgbLinearColorSpace extends ColorSpace { + bool get isBoundedInternal => true; + + const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); + + SassColor convert(ColorSpace dest, double? red, double? green, double? blue, + double? alpha) => + switch (dest) { + ColorSpace.rgb || + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.srgb => + ColorSpace.srgb.convert( + dest, + red.andThen(srgbAndDisplayP3FromLinear), + green.andThen(srgbAndDisplayP3FromLinear), + blue.andThen(srgbAndDisplayP3FromLinear), + alpha), + _ => super.convert(dest, red, green, blue, alpha) + }; + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.displayP3 => linearSrgbToLinearDisplayP3, + ColorSpace.a98Rgb => linearSrgbToLinearA98Rgb, + ColorSpace.prophotoRgb => linearSrgbToLinearProphotoRgb, + ColorSpace.rec2020 => linearSrgbToLinearRec2020, + ColorSpace.xyzD65 => linearSrgbToXyzD65, + ColorSpace.xyzD50 => linearSrgbToXyzD50, + ColorSpace.lms => linearSrgbToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/utils.dart b/lib/src/value/color/space/utils.dart new file mode 100644 index 000000000..a5be17e22 --- /dev/null +++ b/lib/src/value/color/space/utils.dart @@ -0,0 +1,89 @@ +// Copyright 2022 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:math' as math; + +import '../../../util/number.dart'; +import '../../color.dart'; + +/// A constant used to convert Lab to/from XYZ. +const labKappa = 24389 / 27; // 29^3/3^3; + +/// A constant used to convert Lab to/from XYZ. +const labEpsilon = 216 / 24389; // 6^3/29^3; + +/// The hue channel shared across all polar color spaces. +const hueChannel = + ColorChannel('hue', isPolarAngle: true, associatedUnit: 'deg'); + +/// The color channels shared across all RGB color spaces (except the legacy RGB space). +const rgbChannels = [ + LinearChannel('red', 0, 1), + LinearChannel('green', 0, 1), + LinearChannel('blue', 0, 1) +]; + +/// The color channels shared across both XYZ color spaces. +const xyzChannels = [ + LinearChannel('x', 0, 1), + LinearChannel('y', 0, 1), + LinearChannel('z', 0, 1) +]; + +/// Converts a legacy HSL/HWB hue to an RGB channel. +/// +/// The algorithm comes from from the CSS3 spec: +/// http://www.w3.org/TR/css3-color/#hsl-color. +double hueToRgb(double m1, double m2, double hue) { + if (hue < 0) hue += 1; + if (hue > 1) hue -= 1; + + return switch (hue) { + < 1 / 6 => m1 + (m2 - m1) * hue * 6, + < 1 / 2 => m2, + < 2 / 3 => m1 + (m2 - m1) * (2 / 3 - hue) * 6, + _ => m1 + }; +} + +/// The algorithm for converting a single `srgb` or `display-p3` channel to +/// linear-light form. +double srgbAndDisplayP3ToLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs <= 0.04045 + ? channel / 12.92 + : channel.sign * math.pow((abs + 0.055) / 1.055, 2.4); +} + +/// The algorithm for converting a single `srgb` or `display-p3` channel to +/// gamma-corrected form. +double srgbAndDisplayP3FromLinear(double channel) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var abs = channel.abs(); + return abs <= 0.0031308 + ? channel * 12.92 + : channel.sign * (1.055 * math.pow(abs, 1 / 2.4) - 0.055); +} + +/// Converts a Lab or OKLab color to LCH or OKLCH, respectively. +/// +/// The [missingChroma] and [missingHue] arguments indicate whether this came +/// from a color that was missing its chroma or hue channels, respectively. +SassColor labToLch( + ColorSpace dest, double? lightness, double? a, double? b, double? alpha, + {bool missingChroma = false, bool missingHue = false}) { + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + var chroma = math.sqrt(math.pow(a ?? 0, 2) + math.pow(b ?? 0, 2)); + var hue = missingHue || fuzzyEquals(chroma, 0) + ? null + : math.atan2(b ?? 0, a ?? 0) * 180 / math.pi; + + return SassColor.forSpaceInternal( + dest, + lightness, + missingChroma ? null : chroma, + hue == null || hue >= 0 ? hue : hue + 360, + alpha); +} diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart new file mode 100644 index 000000000..fab064cb9 --- /dev/null +++ b/lib/src/value/color/space/xyz_d50.dart @@ -0,0 +1,86 @@ +// Copyright 2022 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: avoid_renaming_method_parameters + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import '../../color.dart'; +import '../conversions.dart'; +import 'utils.dart'; + +/// The xyz-d50 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-xyz +/// +/// @nodoc +@internal +final class XyzD50ColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const XyzD50ColorSpace() : super('xyz-d50', xyzChannels); + + SassColor convert( + ColorSpace dest, double? x, double? y, double? z, double? alpha, + {bool missingLightness = false, + bool missingChroma = false, + bool missingHue = false, + bool missingA = false, + bool missingB = false}) { + switch (dest) { + case ColorSpace.lab || ColorSpace.lch: + // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code + // and http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + var f0 = _convertComponentToLabF((x ?? 0) / d50[0]); + var f1 = _convertComponentToLabF((y ?? 0) / d50[1]); + var f2 = _convertComponentToLabF((z ?? 0) / d50[2]); + var lightness = missingLightness ? null : (116 * f1) - 16; + var a = 500 * (f0 - f1); + var b = 200 * (f1 - f2); + + return dest == ColorSpace.lab + ? SassColor.lab( + lightness, missingA ? null : a, missingB ? null : b, alpha) + : labToLch(ColorSpace.lch, lightness, a, b, alpha, + missingChroma: missingChroma, missingHue: missingHue); + + default: + return super.convertLinear(dest, x, y, z, alpha, + missingLightness: missingLightness, + missingChroma: missingChroma, + missingHue: missingHue, + missingA: missingA, + missingB: missingB); + } + } + + /// Does a partial conversion of a single XYZ component to Lab. + double _convertComponentToLabF(double component) => component > labEpsilon + ? math.pow(component, 1 / 3) + 0.0 + : (labKappa * component + 16) / 116; + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD50ToLinearSrgb, + ColorSpace.a98Rgb => xyzD50ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD50ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD50ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD50ToLinearRec2020, + ColorSpace.xyzD65 => xyzD50ToXyzD65, + ColorSpace.lms => xyzD50ToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/color/space/xyz_d65.dart b/lib/src/value/color/space/xyz_d65.dart new file mode 100644 index 000000000..4915a8cbc --- /dev/null +++ b/lib/src/value/color/space/xyz_d65.dart @@ -0,0 +1,44 @@ +// Copyright 2022 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:typed_data'; + +import 'package:meta/meta.dart'; + +import '../conversions.dart'; +import '../space.dart'; +import 'utils.dart'; + +/// The xyz-d65 color space. +/// +/// https://www.w3.org/TR/css-color-4/#predefined-xyz +/// +/// @nodoc +@internal +final class XyzD65ColorSpace extends ColorSpace { + bool get isBoundedInternal => false; + + const XyzD65ColorSpace() : super('xyz', xyzChannels); + + @protected + double toLinear(double channel) => channel; + + @protected + double fromLinear(double channel) => channel; + + @protected + Float64List transformationMatrix(ColorSpace dest) => switch (dest) { + ColorSpace.srgbLinear || + ColorSpace.srgb || + ColorSpace.rgb => + xyzD65ToLinearSrgb, + ColorSpace.a98Rgb => xyzD65ToLinearA98Rgb, + ColorSpace.prophotoRgb => xyzD65ToLinearProphotoRgb, + ColorSpace.displayP3 => xyzD65ToLinearDisplayP3, + ColorSpace.rec2020 => xyzD65ToLinearRec2020, + ColorSpace.xyzD50 => xyzD65ToXyzD50, + ColorSpace.lms => xyzD65ToLms, + _ => super.transformationMatrix(dest) + }; +} diff --git a/lib/src/value/list.dart b/lib/src/value/list.dart index 68ca5c91c..78c993840 100644 --- a/lib/src/value/list.dart +++ b/lib/src/value/list.dart @@ -58,6 +58,18 @@ class SassList extends Value { } } + /// Add parentheses to the debug information for lists to help make the list + /// bounds clear. + String toString() { + if (hasBrackets || + lengthAsList == 0 || + (lengthAsList == 1 && separator == ListSeparator.comma)) { + return super.toString(); + } + + return "(${super.toString()})"; + } + /// @nodoc @internal T accept(ValueVisitor visitor) => visitor.visitList(this); diff --git a/lib/src/value/mixin.dart b/lib/src/value/mixin.dart new file mode 100644 index 000000000..79091579d --- /dev/null +++ b/lib/src/value/mixin.dart @@ -0,0 +1,40 @@ +// Copyright 2023 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:meta/meta.dart'; + +import '../callable.dart'; +import '../visitor/interface/value.dart'; +import '../value.dart'; + +/// A SassScript mixin reference. +/// +/// A mixin reference captures a mixin from the local environment so that +/// it may be passed between modules. +/// +/// {@category Value} +final class SassMixin extends Value { + /// The callable that this mixin invokes. + /// + /// Note that this is typed as an [AsyncCallable] so that it will work with + /// both synchronous and asynchronous evaluate visitors, but in practice the + /// synchronous evaluate visitor will crash if this isn't a [Callable]. + /// + /// @nodoc + @internal + final AsyncCallable callable; + + SassMixin(this.callable); + + /// @nodoc + @internal + T accept(ValueVisitor visitor) => visitor.visitMixin(this); + + SassMixin assertMixin([String? name]) => this; + + bool operator ==(Object other) => + other is SassMixin && callable == other.callable; + + int get hashCode => callable.hashCode; +} diff --git a/lib/src/value/number.dart b/lib/src/value/number.dart index a5c90a501..d4cb2b1b8 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -190,7 +190,7 @@ abstract class SassNumber extends Value { /// The value of this number. /// - /// Note that Sass stores all numbers as [double]s even if if [this] + /// Note that Sass stores all numbers as [double]s even if if `this` /// represents an integer from Sass's perspective. Use [isInt] to determine /// whether this is an integer, [asInt] to get its integer value, or /// [assertInt] to do both at once. @@ -209,14 +209,14 @@ abstract class SassNumber extends Value { /// This number's denominator units. List get denominatorUnits; - /// Whether [this] has any units. + /// Whether `this` has any units. /// /// If a function expects a number to have no units, it should use /// [assertNoUnits]. If it expects the number to have a particular unit, it /// should use [assertUnit]. bool get hasUnits; - /// Whether [this] has more than one numerator unit, or any denominator units. + /// Whether `this` has more than one numerator unit, or any denominator units. /// /// This is `true` for numbers whose units make them unrepresentable as CSS /// lengths. @@ -229,7 +229,7 @@ abstract class SassNumber extends Value { @internal final (SassNumber, SassNumber)? asSlash; - /// Whether [this] is an integer, according to [fuzzyEquals]. + /// Whether `this` is an integer, according to [fuzzyEquals]. /// /// The [int] value can be accessed using [asInt] or [assertInt]. Note that /// this may return `false` for very large doubles even though they may be @@ -237,7 +237,7 @@ abstract class SassNumber extends Value { /// representation for integers that large. bool get isInt => fuzzyIsInt(value); - /// If [this] is an integer according to [isInt], returns [value] as an [int]. + /// If `this` is an integer according to [isInt], returns [value] as an [int]. /// /// Otherwise, returns `null`. int? get asInt => fuzzyAsInt(value); @@ -304,20 +304,20 @@ abstract class SassNumber extends Value { T accept(ValueVisitor visitor) => visitor.visitNumber(this); - /// Returns a number with the same units as [this] but with [value] as its + /// Returns a number with the same units as `this` but with [value] as its /// value. /// /// @nodoc @protected SassNumber withValue(num value); - /// Returns a copy of [this] without [asSlash] set. + /// Returns a copy of `this` without [asSlash] set. /// /// @nodoc @internal SassNumber withoutSlash() => asSlash == null ? this : withValue(value); - /// Returns a copy of [this] with [asSlash] set to a pair containing + /// Returns a copy of `this` with [asSlash] set to a pair containing /// [numerator] and [denominator]. /// /// @nodoc @@ -365,10 +365,10 @@ abstract class SassNumber extends Value { "Expected $this to be within $min$unit and $max$unit.", name); } - /// Returns whether [this] has [unit] as its only unit (and as a numerator). + /// Returns whether `this` has [unit] as its only unit (and as a numerator). bool hasUnit(String unit); - /// Returns whether [this] has units that are compatible with [other]. + /// Returns whether `this` has units that are compatible with [other]. /// /// Unlike [isComparableTo], unitless numbers are only considered compatible /// with other unitless numbers. @@ -378,17 +378,17 @@ abstract class SassNumber extends Value { return isComparableTo(other); } - /// Returns whether [this] has units that are possibly-compatible with + /// Returns whether `this` has units that are possibly-compatible with /// [other], as defined by the Sass spec. @internal bool hasPossiblyCompatibleUnits(SassNumber other); - /// Returns whether [this] can be coerced to the given [unit]. + /// Returns whether `this` can be coerced to the given [unit]. /// /// This always returns `true` for a unitless number. bool compatibleWithUnit(String unit); - /// Throws a [SassScriptException] unless [this] has [unit] as its only unit + /// Throws a [SassScriptException] unless `this` has [unit] as its only unit /// (and as a numerator). /// /// If this came from a function argument, [name] is the argument name @@ -398,7 +398,7 @@ abstract class SassNumber extends Value { throw SassScriptException('Expected $this to have unit "$unit".', name); } - /// Throws a [SassScriptException] unless [this] has no units. + /// Throws a [SassScriptException] unless `this` has no units. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. @@ -428,8 +428,7 @@ abstract class SassNumber extends Value { /// [newDenominators]. /// /// Throws a [SassScriptException] if this number's units aren't compatible - /// with [other]'s units, or if either number is unitless but the other is - /// not. + /// with [newNumerators] and [newDenominators] or if this number is unitless. /// /// If this came from a function argument, [name] is the argument name /// (without the `$`). It's used for error reporting. @@ -438,6 +437,10 @@ abstract class SassNumber extends Value { _coerceOrConvertValue(newNumerators, newDenominators, coerceUnitless: false, name: name); + /// A shorthand for [convertValue] with only one numerator unit. + double convertValueToUnit(String unit, [String? name]) => + convertValue([unit], [], name); + /// Returns a copy of this number, converted to the same units as [other]. /// /// Note that [convertValueToMatch] is generally more efficient if the value @@ -571,7 +574,7 @@ abstract class SassNumber extends Value { /// /// If [other] is passed, it should be the number from which [newNumerators] /// and [newDenominators] are derived. The [name] and [otherName] are the Sass - /// function parameter names of [this] and [other], respectively, used for + /// function parameter names of `this` and [other], respectively, used for /// error reporting. double _coerceOrConvertValue( List newNumerators, List newDenominators, @@ -775,7 +778,7 @@ abstract class SassNumber extends Value { return operation(value, other.coerceValueToMatch(this)); } on SassScriptException { // If the conversion fails, re-run it in the other direction. This will - // generate an error message that prints [this] before [other], which is + // generate an error message that prints `this` before [other], which is // more readable. coerceValueToMatch(other); rethrow; // This should be unreachable. @@ -849,7 +852,8 @@ abstract class SassNumber extends Value { ([], [var denominator]) => "$denominator^-1", ([], _) => "(${denominators.join('*')})^-1", (_, []) => numerators.join("*"), - _ => "${numerators.join("*")}/${denominators.join("*")}" + (_, [var denominator]) => "${numerators.join("*")}/$denominator", + _ => "${numerators.join("*")}/(${denominators.join("*")})", }; bool operator ==(Object other) { diff --git a/lib/src/value/number/single_unit.dart b/lib/src/value/number/single_unit.dart index 9ccea8488..e5fc09814 100644 --- a/lib/src/value/number/single_unit.dart +++ b/lib/src/value/number/single_unit.dart @@ -93,6 +93,11 @@ class SingleUnitSassNumber extends SassNumber { // Call this to generate a consistent error message. super.coerceValueToMatch(other, name, otherName); + double convertValueToUnit(String unit, [String? name]) => + _coerceValueToUnit(unit) ?? + // Call this to generate a consistent error message. + super.convertValueToUnit(unit, name); + SassNumber convertToMatch(SassNumber other, [String? name, String? otherName]) => (other is SingleUnitSassNumber ? _coerceToUnit(other._unit) : null) ?? diff --git a/lib/src/value/number/unitless.dart b/lib/src/value/number/unitless.dart index 7272b7c59..a81d1d9a9 100644 --- a/lib/src/value/number/unitless.dart +++ b/lib/src/value/number/unitless.dart @@ -19,8 +19,7 @@ class UnitlessSassNumber extends SassNumber { bool get hasUnits => false; bool get hasComplexUnits => false; - UnitlessSassNumber(double value, [(SassNumber, SassNumber)? asSlash]) - : super.protected(value, asSlash); + UnitlessSassNumber(super.value, [super.asSlash]) : super.protected(); SassNumber withValue(num value) => UnitlessSassNumber(value.toDouble()); diff --git a/lib/src/value/string.dart b/lib/src/value/string.dart index a6965bbf1..e3455d442 100644 --- a/lib/src/value/string.dart +++ b/lib/src/value/string.dart @@ -121,6 +121,30 @@ class SassString extends Value { /// Creates a string with the given [text]. SassString(this._text, {bool quotes = true}) : _hasQuotes = quotes; + /// Throws a [SassScriptException] if this is an unquoted string. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + void assertQuoted([String? name]) { + if (hasQuotes) return; + throw SassScriptException('Expected $this to be a quoted string.', name); + } + + /// Throws a [SassScriptException] if this is a quoted string. + /// + /// If this came from a function argument, [name] is the argument name + /// (without the `$`). It's used for error reporting. + /// + /// @nodoc + @internal + void assertUnquoted([String? name]) { + if (!hasQuotes) return; + throw SassScriptException('Expected $this to be an unquoted string.', name); + } + /// Converts [sassIndex] into a Dart-style index into [text]. /// /// Sass indexes are one-based, while Dart indexes are zero-based. Sass diff --git a/lib/src/visitor/README.md b/lib/src/visitor/README.md new file mode 100644 index 000000000..da951508c --- /dev/null +++ b/lib/src/visitor/README.md @@ -0,0 +1,15 @@ +# Visitors + +This directory contains various types that implement the [visitor pattern] for +[various ASTs]. A few of these, such as [the evaluator] and [the serializer], +implement critical business logic for the Sass compiler. Most of the rest are +either small utilities or base classes for small utilities that need to run over +an AST to determine some kind of information about it. Some are even entirely +unused within Sass itself, and exist only to support users of the [`sass_api`] +package. + +[visitor pattern]: https://en.wikipedia.org/wiki/Visitor_pattern +[various ASTs]: ../ast +[the evaluator]: async_evaluate.dart +[the serializer]: serialize.dart +[`sass_api`]: https://pub.dev/packages/sass_api diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index 6e16a71bf..c06b45484 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math' as math; +import 'package:cli_pkg/js.dart'; import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; @@ -33,7 +34,6 @@ import '../functions/meta.dart' as meta; import '../importer.dart'; import '../importer/legacy_node.dart'; import '../interpolation_map.dart'; -import '../io.dart'; import '../logger.dart'; import '../module.dart'; import '../module/built_in.dart'; @@ -51,6 +51,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = Future Function( @@ -337,9 +338,8 @@ final class _EvaluateVisitor Logger? logger, bool quietDeps = false, bool sourceMap = false}) - : _importCache = nodeImporter == null - ? importCache ?? AsyncImportCache.none(logger: logger) - : null, + : _importCache = importCache ?? + (nodeImporter == null ? AsyncImportCache.none() : null), _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), _quietDeps = quietDeps, @@ -418,6 +418,19 @@ final class _EvaluateVisitor }); }, url: "sass:meta"), + BuiltInCallable.function("module-mixins", r"$module", (arguments) { + var namespace = arguments[0].assertString("module"); + var module = _environment.modules[namespace.text]; + if (module == null) { + throw 'There is no module with namespace "${namespace.text}".'; + } + + return SassMap({ + for (var (name, value) in module.mixins.pairs) + SassString(name): SassMixin(value) + }); + }, url: "sass:meta"), + BuiltInCallable.function( "get-function", r"$name, $css: false, $module: null", (arguments) { var name = arguments[0].assertString("name"); @@ -444,6 +457,20 @@ final class _EvaluateVisitor return SassFunction(callable); }, url: "sass:meta"), + BuiltInCallable.function("get-mixin", r"$name, $module: null", + (arguments) { + var name = arguments[0].assertString("name"); + var module = arguments[1].realNull?.assertString("module"); + + var callable = _addExceptionSpan( + _callableNode!, + () => _environment.getMixin(name.text.replaceAll("_", "-"), + namespace: module?.text)); + if (callable == null) throw "Mixin not found: $name"; + + return SassMixin(callable); + }, url: "sass:meta"), + AsyncBuiltInCallable.function("call", r"$function, $args...", (arguments) async { var function = arguments[0]; @@ -517,18 +544,51 @@ final class _EvaluateVisitor configuration: configuration, namesInErrors: true); _assertConfigurationIsEmpty(configuration, nameInError: true); - }, url: "sass:meta") + }, url: "sass:meta"), + AsyncBuiltInCallable.mixin("apply", r"$mixin, $args...", + (arguments) async { + var mixin = arguments[0]; + var args = arguments[1] as SassArgumentList; + + var callableNode = _callableNode!; + var invocation = ArgumentInvocation( + const [], + const {}, + callableNode.span, + rest: ValueExpression(args, callableNode.span), + ); + + var callable = mixin.assertMixin("mixin").callable; + var content = _environment.content; + + // ignore: unnecessary_type_check + if (callable is AsyncCallable) { + await _applyMixin( + callable, content, invocation, callableNode, callableNode); + } else { + throw SassScriptException( + "The mixin ${callable.name} is asynchronous.\n" + "This is probably caused by a bug in a Sass plugin."); + } + }, url: "sass:meta", acceptsContent: true), ]; var metaModule = BuiltInModule("meta", - functions: [...meta.global, ...meta.local, ...metaFunctions], + functions: [...meta.moduleFunctions, ...metaFunctions], mixins: metaMixins); for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } - functions = [...?functions, ...globalFunctions, ...metaFunctions]; + functions = [ + ...?functions, + ...globalFunctions, + ...[ + for (var function in metaFunctions) + function.withDeprecationWarning('meta') + ] + ]; for (var function in functions) { _builtInFunctions[function.name.replaceAll("_", "-")] = function; } @@ -939,9 +999,20 @@ final class _EvaluateVisitor // ## Statements Future visitStylesheet(Stylesheet node) async { + for (var warning in node.parseTimeWarnings) { + _warn(warning.message, warning.span, warning.deprecation); + } for (var child in node.children) { await child.accept(this); } + + // Make sure all global variables declared in a module always appear in the + // module's definition, even if their assignments aren't reached. + for (var (name, span) in node.globalVariables.pairs) { + visitVariableDeclaration( + VariableDeclaration(name, NullExpression(span), span, guarded: true)); + } + return null; } @@ -950,8 +1021,7 @@ final class _EvaluateVisitor if (node.query case var unparsedQuery?) { var (resolved, map) = await _performInterpolationWithMap(unparsedQuery, warnForColor: true); - query = - AtRootQuery.parse(resolved, interpolationMap: map, logger: _logger); + query = AtRootQuery.parse(resolved, interpolationMap: map); } var parent = _parent; @@ -1120,7 +1190,8 @@ final class _EvaluateVisitor Future visitDebugRule(DebugRule node) async { var value = await node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1135,6 +1206,45 @@ final class _EvaluateVisitor node.span); } + var siblings = _parent.parent!.children; + var interleavedRules = []; + if (siblings.last != _parent && + // Reproduce this condition from [_warn] so that we don't add anything to + // [interleavedRules] for declarations in dependencies. + !(_quietDeps && + (_inDependency || (_currentCallable?.inDependency ?? false)))) { + loop: + for (var sibling in siblings.skip(siblings.indexOf(_parent) + 1)) { + switch (sibling) { + case CssComment(): + continue loop; + + case CssStyleRule rule: + interleavedRules.add(rule); + + case _: + // Always warn for siblings that aren't style rules, because they + // add no specificity and they're nested in the same parent as this + // declaration. + _warn( + "Sass's behavior for declarations that appear after nested\n" + "rules will be changing to match the behavior specified by CSS " + "in an upcoming\n" + "version. To keep the existing behavior, move the declaration " + "above the nested\n" + "rule. To opt into the new behavior, wrap the declaration in " + "`& {}`.\n" + "\n" + "More info: https://sass-lang.com/d/mixed-decls", + MultiSpan( + node.span, 'declaration', {sibling.span: 'nested rule'}), + Deprecation.mixedDecls); + interleavedRules.clear(); + break; + } + } + } + var name = await _interpolationToValue(node.name, warnForColor: true); if (_declarationName case var declarationName?) { name = CssValue("$declarationName-${name.value}", name.span); @@ -1148,6 +1258,8 @@ final class _EvaluateVisitor _parent.addChild(ModifiableCssDeclaration( name, CssValue(value, expression.span), node.span, parsedAsCustomProperty: node.isCustomProperty, + interleavedRules: interleavedRules, + trace: interleavedRules.isEmpty ? null : _stackTrace(node.span), valueSpanForMap: _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); } else if (name.value.startsWith('--')) { @@ -1236,7 +1348,7 @@ final class _EvaluateVisitor await _performInterpolationWithMap(node.selector, warnForColor: true); var list = SelectorList.parse(trimAscii(targetText, excludeEscape: true), - interpolationMap: targetMap, logger: _logger, allowParent: false); + interpolationMap: targetMap, allowParent: false); for (var complex in list.components) { var compound = complex.singleCompound; @@ -1641,6 +1753,13 @@ final class _EvaluateVisitor if (await importCache.canonicalize(Uri.parse(url), baseImporter: _importer, baseUrl: baseUrl, forImport: forImport) case (var importer, var canonicalUrl, :var originalUrl)) { + if (canonicalUrl.scheme == '') { + _logger.warnForDeprecation( + Deprecation.relativeCanonical, + "Importer $importer canonicalized $url to $canonicalUrl.\n" + "Relative canonical URLs are deprecated and will eventually be " + "disallowed."); + } // Make sure we record the canonical URL as "loaded" even if the // actual load fails, because watchers should watch it to see if it // changes in a way that allows the load to succeed. @@ -1648,12 +1767,14 @@ final class _EvaluateVisitor var isDependency = _inDependency || importer != _importer; if (await importCache.importCanonical(importer, canonicalUrl, - originalUrl: originalUrl, quiet: _quietDeps && isDependency) + originalUrl: originalUrl) case var stylesheet?) { return (stylesheet, importer: importer, isDependency: isDependency); } } - } else { + } + + if (_nodeImporter != null) { if (await _importLikeNode( url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) case var result?) { @@ -1674,13 +1795,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -1697,7 +1812,7 @@ final class _EvaluateVisitor if (result != null) { isDependency = _inDependency; } else { - result = await _nodeImporter!.loadAsync(originalUrl, previous, forImport); + result = await _nodeImporter.loadAsync(originalUrl, previous, forImport); if (result == null) return null; isDependency = true; } @@ -1706,8 +1821,7 @@ final class _EvaluateVisitor return ( Stylesheet.parse( contents, url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, - url: url, - logger: _quietDeps && isDependency ? Logger.quiet : _logger), + url: url), importer: null, isDependency: isDependency ); @@ -1733,41 +1847,57 @@ final class _EvaluateVisitor } } - Future visitIncludeRule(IncludeRule node) async { - var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); - var mixin = _addExceptionSpan(node, - () => _environment.getMixin(node.name, namespace: node.namespace)); + /// Evaluate a given [mixin] with [arguments] and [contentCallable] + Future _applyMixin( + AsyncCallable? mixin, + UserDefinedCallable? contentCallable, + ArgumentInvocation arguments, + AstNode nodeWithSpan, + AstNode nodeWithSpanWithoutContent) async { switch (mixin) { case null: - throw _exception("Undefined mixin.", node.span); - - case AsyncBuiltInCallable() when node.content != null: - throw _exception("Mixin doesn't accept a content block.", node.span); - + throw _exception("Undefined mixin.", nodeWithSpan.span); + + case AsyncBuiltInCallable(acceptsContent: false) + when contentCallable != null: + { + var evaluated = await _evaluateArguments(arguments); + var (overload, _) = mixin.callbackFor( + evaluated.positional.length, MapKeySet(evaluated.named)); + throw MultiSpanSassRuntimeException( + "Mixin doesn't accept a content block.", + nodeWithSpanWithoutContent.span, + "invocation", + {overload.spanWithName: "declaration"}, + _stackTrace(nodeWithSpanWithoutContent.span)); + } case AsyncBuiltInCallable(): - await _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); + await _environment.withContent(contentCallable, () async { + await _environment.asMixin(() async { + await _runBuiltInCallable( + arguments, mixin, nodeWithSpanWithoutContent); + }); + }); case UserDefinedCallable( declaration: MixinRule(hasContent: false) ) - when node.content != null: + when contentCallable != null: throw MultiSpanSassRuntimeException( "Mixin doesn't accept a content block.", - node.spanWithoutContent, + nodeWithSpanWithoutContent.span, "invocation", {mixin.declaration.arguments.spanWithName: "declaration"}, - _stackTrace(node.spanWithoutContent)); + _stackTrace(nodeWithSpanWithoutContent.span)); case UserDefinedCallable(): - var contentCallable = node.content.andThen((content) => - UserDefinedCallable(content, _environment.closure(), - inDependency: _inDependency)); - await _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, - () async { + await _runUserDefinedCallable( + arguments, mixin, nodeWithSpanWithoutContent, () async { await _environment.withContent(contentCallable, () async { await _environment.asMixin(() async { for (var statement in mixin.declaration.children) { - await _addErrorSpan(nodeWithSpan, () => statement.accept(this)); + await _addErrorSpan( + nodeWithSpanWithoutContent, () => statement.accept(this)); } }); }); @@ -1776,6 +1906,32 @@ final class _EvaluateVisitor case _: throw UnsupportedError("Unknown callable type $mixin."); } + } + + Future visitIncludeRule(IncludeRule node) async { + var mixin = _addExceptionSpan(node, + () => _environment.getMixin(node.name, namespace: node.namespace)); + if (node.originalName.startsWith('--') && + mixin is UserDefinedCallable && + !mixin.declaration.originalName.startsWith('--')) { + _warn( + 'Sass @mixin names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS mixins.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + node.nameSpan, + Deprecation.cssFunctionMixin); + } + + var contentCallable = node.content.andThen((content) => UserDefinedCallable( + content, _environment.closure(), + inDependency: _inDependency)); + + var nodeWithSpanWithoutContent = + AstNode.fake(() => node.spanWithoutContent); + + await _applyMixin(mixin, contentCallable, node.arguments, node, + nodeWithSpanWithoutContent); return null; } @@ -1797,8 +1953,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild(ModifiableCssComment( - await _performInterpolation(node.text), node.span)); + var text = await _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } @@ -1858,8 +2016,7 @@ final class _EvaluateVisitor Interpolation interpolation) async { var (resolved, map) = await _performInterpolationWithMap(interpolation, warnForColor: true); - return CssMediaQuery.parseList(resolved, - logger: _logger, interpolationMap: map); + return CssMediaQuery.parseList(resolved, interpolationMap: map); } /// Returns a list of queries that selects for contexts that match both @@ -1899,6 +2056,9 @@ final class _EvaluateVisitor if (_declarationName != null) { throw _exception( "Style rules may not be used within nested declarations.", node.span); + } else if (_inKeyframes && _parent is CssKeyframeBlock) { + throw _exception( + "Style rules may not be used within keyframe blocks.", node.span); } var (selectorText, selectorMap) = @@ -1908,9 +2068,9 @@ final class _EvaluateVisitor // NOTE: this logic is largely duplicated in [visitCssKeyframeBlock]. Most // changes here should be mirrored there. - var parsedSelector = KeyframeSelectorParser(selectorText, - logger: _logger, interpolationMap: selectorMap) - .parse(); + var parsedSelector = + KeyframeSelectorParser(selectorText, interpolationMap: selectorMap) + .parse(); var rule = ModifiableCssKeyframeBlock( CssValue(List.unmodifiable(parsedSelector), node.selector.span), node.span); @@ -1925,16 +2085,30 @@ final class _EvaluateVisitor } var parsedSelector = SelectorList.parse(selectorText, - interpolationMap: selectorMap, - allowParent: !_stylesheet.plainCss, - allowPlaceholder: !_stylesheet.plainCss, - logger: _logger) - .resolveParentSelectors(_styleRuleIgnoringAtRoot?.originalSelector, - implicitParent: !_atRootExcludingStyleRule); + interpolationMap: selectorMap, plainCss: _stylesheet.plainCss); + + var nest = !(_styleRule?.fromPlainCss ?? false); + if (nest) { + if (_stylesheet.plainCss) { + for (var complex in parsedSelector.components) { + if (complex.leadingCombinators case [var first, ...] + when _stylesheet.plainCss) { + throw _exception( + "Top-level leading combinators aren't allowed in plain CSS.", + first.span); + } + } + } + + parsedSelector = parsedSelector.nestWithin( + _styleRuleIgnoringAtRoot?.originalSelector, + implicitParent: !_atRootExcludingStyleRule, + preserveParentSelectors: _stylesheet.plainCss); + } var selector = _extensionStore.addSelector(parsedSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, - originalSelector: parsedSelector); + originalSelector: parsedSelector, fromPlainCss: _stylesheet.plainCss); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; _atRootExcludingStyleRule = false; await _withParent(rule, () async { @@ -1944,12 +2118,24 @@ final class _EvaluateVisitor } }); }, - through: (node) => node is CssStyleRule, + through: nest ? (node) => node is CssStyleRule : null, scopeWhen: node.hasDeclarations); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + _warnForBogusCombinators(rule); + + if (_styleRule == null && _parent.children.isNotEmpty) { + var lastChild = _parent.children.last; + lastChild.isGroupEnd = true; + } + + return null; + } + + /// Emits deprecation warnings for any bogus combinators in [rule]. + void _warnForBogusCombinators(CssStyleRule rule) { if (!rule.isInvisibleOtherThanBogusCombinators) { - for (var complex in parsedSelector.components) { + for (var complex in rule.selector.components) { if (!complex.isBogus) continue; if (complex.isUseless) { @@ -1962,13 +2148,15 @@ final class _EvaluateVisitor complex.span.trimRight(), Deprecation.bogusCombinators); } else if (complex.leadingCombinators.isNotEmpty) { - _warn( - 'The selector "${complex.toString().trim()}" is invalid CSS.\n' - 'This will be an error in Dart Sass 2.0.0.\n' - '\n' - 'More info: https://sass-lang.com/d/bogus-combinators', - complex.span.trimRight(), - Deprecation.bogusCombinators); + if (!_stylesheet.plainCss) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + complex.span.trimRight(), + Deprecation.bogusCombinators); + } } else { _warn( 'The selector "${complex.toString().trim()}" is only valid for ' @@ -1991,13 +2179,6 @@ final class _EvaluateVisitor } } } - - if (_styleRule == null && _parent.children.isNotEmpty) { - var lastChild = _parent.children.last; - lastChild.isGroupEnd = true; - } - - return null; } Future visitSupportsRule(SupportsRule node) async { @@ -2371,13 +2552,15 @@ final class _EvaluateVisitor throw _exception("Undefined function.", node.span); } + // Note that the list of calculation functions is also tracked in + // lib/src/visitor/is_plain_css_safe.dart. switch (node.name.toLowerCase()) { - case "min" || "max" || "round" || "abs" + case ("min" || "max" || "round" || "abs") && var name when node.arguments.named.isEmpty && node.arguments.rest == null && node.arguments.positional .every((argument) => argument.isCalculationSafe): - return await _visitCalculation(node, inLegacySassFunction: true); + return await _visitCalculation(node, inLegacySassFunction: name); case "calc" || "clamp" || @@ -2403,6 +2586,19 @@ final class _EvaluateVisitor PlainCssCallable(node.originalName); } + if (node.originalName.startsWith('--') && + function is UserDefinedCallable && + !function.declaration.originalName.startsWith('--')) { + _warn( + 'Sass @function names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS functions.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + node.nameSpan, + Deprecation.cssFunctionMixin, + ); + } + var oldInFunction = _inFunction; _inFunction = true; var result = await _addErrorSpan( @@ -2411,8 +2607,15 @@ final class _EvaluateVisitor return result; } + /// Evaluates [node] as a calculation. + /// + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()`, `max()`, `round()`, and `abs()` functions. + /// The parameter is the name of the function, which is used for reporting + /// deprecation warnings. Future _visitCalculation(FunctionExpression node, - {bool inLegacySassFunction = false}) async { + {String? inLegacySassFunction}) async { if (node.arguments.named.isNotEmpty) { throw _exception( "Keyword arguments can't be used with calculations.", node.span); @@ -2460,8 +2663,12 @@ final class _EvaluateVisitor SassCalculation.mod(arguments[0], arguments.elementAtOrNull(1)), "rem" => SassCalculation.rem(arguments[0], arguments.elementAtOrNull(1)), - "round" => SassCalculation.round(arguments[0], - arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + "round" => SassCalculation.roundInternal(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2), + span: node.span, + inLegacySassFunction: inLegacySassFunction, + warn: (message, [deprecation]) => + _warn(message, node.span, deprecation)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') @@ -2557,11 +2764,13 @@ final class _EvaluateVisitor /// Evaluates [node] as a component of a calculation. /// - /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and - /// subtracted with numbers with units, for backwards-compatibility with the - /// old global `min()`, `max()`, `round()`, and `abs()` functions. + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()`, `max()`, `round()`, and `abs()` functions. + /// The parameter is the name of the function, which is used for reporting + /// deprecation warnings. Future _visitCalculationExpression(Expression node, - {required bool inLegacySassFunction}) async { + {required String? inLegacySassFunction}) async { switch (node) { case ParenthesizedExpression(expression: var inner): var result = await _visitCalculationExpression(inner, @@ -2592,7 +2801,9 @@ final class _EvaluateVisitor await _visitCalculationExpression(right, inLegacySassFunction: inLegacySassFunction), inLegacySassFunction: inLegacySassFunction, - simplify: !_inSupportsDeclaration)); + simplify: !_inSupportsDeclaration, + warn: (message, [deprecation]) => + _warn(message, node.span, deprecation))); case NumberExpression() || VariableExpression() || @@ -2911,13 +3122,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3297,15 +3503,21 @@ final class _EvaluateVisitor if (_declarationName != null) { throw _exception( "Style rules may not be used within nested declarations.", node.span); + } else if (_inKeyframes && _parent is CssKeyframeBlock) { + throw _exception( + "Style rules may not be used within keyframe blocks.", node.span); } var styleRule = _styleRule; - var originalSelector = node.selector.resolveParentSelectors( - styleRule?.originalSelector, - implicitParent: !_atRootExcludingStyleRule); + var nest = !(_styleRule?.fromPlainCss ?? false); + var originalSelector = nest + ? node.selector.nestWithin(styleRule?.originalSelector, + implicitParent: !_atRootExcludingStyleRule, + preserveParentSelectors: node.fromPlainCss) + : node.selector; var selector = _extensionStore.addSelector(originalSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, - originalSelector: originalSelector); + originalSelector: originalSelector, fromPlainCss: node.fromPlainCss); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; _atRootExcludingStyleRule = false; await _withParent(rule, () async { @@ -3314,7 +3526,7 @@ final class _EvaluateVisitor await child.accept(this); } }); - }, through: (node) => node is CssStyleRule, scopeWhen: false); + }, through: nest ? (node) => node is CssStyleRule : null, scopeWhen: false); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; if (_parent.children case [..., var lastChild] when styleRule == null) { @@ -3403,7 +3615,7 @@ final class _EvaluateVisitor Future _performInterpolation(Interpolation interpolation, {bool warnForColor = false}) async { var (result, _) = await _performInterpolationHelper(interpolation, - sourceMap: true, warnForColor: warnForColor); + sourceMap: false, warnForColor: warnForColor); return result; } @@ -3444,7 +3656,7 @@ final class _EvaluateVisitor if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, - StringExpression(Interpolation([""], interpolation.span), + StringExpression(Interpolation.plain("", interpolation.span), quotes: true), expression); _warn( @@ -3757,6 +3969,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index 87f3ef6c0..792326ecc 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: 7669de19668af665d1a9a60cf67e53e071bf415e +// Checksum: fbffa0dbe5a1af846dc83752457d39fb2984d280 // // ignore_for_file: unused_import @@ -15,6 +15,7 @@ export 'async_evaluate.dart' show EvaluateResult; import 'dart:collection'; import 'dart:math' as math; +import 'package:cli_pkg/js.dart'; import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; @@ -42,7 +43,6 @@ import '../functions/meta.dart' as meta; import '../importer.dart'; import '../importer/legacy_node.dart'; import '../interpolation_map.dart'; -import '../io.dart'; import '../logger.dart'; import '../module.dart'; import '../module/built_in.dart'; @@ -60,6 +60,7 @@ import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; import 'interface/statement.dart'; +import 'serialize.dart'; /// A function that takes a callback with no arguments. typedef _ScopeCallback = void Function(void Function() callback); @@ -345,9 +346,8 @@ final class _EvaluateVisitor Logger? logger, bool quietDeps = false, bool sourceMap = false}) - : _importCache = nodeImporter == null - ? importCache ?? ImportCache.none(logger: logger) - : null, + : _importCache = + importCache ?? (nodeImporter == null ? ImportCache.none() : null), _nodeImporter = nodeImporter, _logger = logger ?? const Logger.stderr(), _quietDeps = quietDeps, @@ -426,6 +426,19 @@ final class _EvaluateVisitor }); }, url: "sass:meta"), + BuiltInCallable.function("module-mixins", r"$module", (arguments) { + var namespace = arguments[0].assertString("module"); + var module = _environment.modules[namespace.text]; + if (module == null) { + throw 'There is no module with namespace "${namespace.text}".'; + } + + return SassMap({ + for (var (name, value) in module.mixins.pairs) + SassString(name): SassMixin(value) + }); + }, url: "sass:meta"), + BuiltInCallable.function( "get-function", r"$name, $css: false, $module: null", (arguments) { var name = arguments[0].assertString("name"); @@ -452,6 +465,20 @@ final class _EvaluateVisitor return SassFunction(callable); }, url: "sass:meta"), + BuiltInCallable.function("get-mixin", r"$name, $module: null", + (arguments) { + var name = arguments[0].assertString("name"); + var module = arguments[1].realNull?.assertString("module"); + + var callable = _addExceptionSpan( + _callableNode!, + () => _environment.getMixin(name.text.replaceAll("_", "-"), + namespace: module?.text)); + if (callable == null) throw "Mixin not found: $name"; + + return SassMixin(callable); + }, url: "sass:meta"), + BuiltInCallable.function("call", r"$function, $args...", (arguments) { var function = arguments[0]; var args = arguments[1] as SassArgumentList; @@ -522,18 +549,50 @@ final class _EvaluateVisitor configuration: configuration, namesInErrors: true); _assertConfigurationIsEmpty(configuration, nameInError: true); - }, url: "sass:meta") + }, url: "sass:meta"), + BuiltInCallable.mixin("apply", r"$mixin, $args...", (arguments) { + var mixin = arguments[0]; + var args = arguments[1] as SassArgumentList; + + var callableNode = _callableNode!; + var invocation = ArgumentInvocation( + const [], + const {}, + callableNode.span, + rest: ValueExpression(args, callableNode.span), + ); + + var callable = mixin.assertMixin("mixin").callable; + var content = _environment.content; + + // ignore: unnecessary_type_check + if (callable is Callable) { + _applyMixin( + callable, content, invocation, callableNode, callableNode); + } else { + throw SassScriptException( + "The mixin ${callable.name} is asynchronous.\n" + "This is probably caused by a bug in a Sass plugin."); + } + }, url: "sass:meta", acceptsContent: true), ]; var metaModule = BuiltInModule("meta", - functions: [...meta.global, ...meta.local, ...metaFunctions], + functions: [...meta.moduleFunctions, ...metaFunctions], mixins: metaMixins); for (var module in [...coreModules, metaModule]) { _builtInModules[module.url] = module; } - functions = [...?functions, ...globalFunctions, ...metaFunctions]; + functions = [ + ...?functions, + ...globalFunctions, + ...[ + for (var function in metaFunctions) + function.withDeprecationWarning('meta') + ] + ]; for (var function in functions) { _builtInFunctions[function.name.replaceAll("_", "-")] = function; } @@ -940,9 +999,20 @@ final class _EvaluateVisitor // ## Statements Value? visitStylesheet(Stylesheet node) { + for (var warning in node.parseTimeWarnings) { + _warn(warning.message, warning.span, warning.deprecation); + } for (var child in node.children) { child.accept(this); } + + // Make sure all global variables declared in a module always appear in the + // module's definition, even if their assignments aren't reached. + for (var (name, span) in node.globalVariables.pairs) { + visitVariableDeclaration( + VariableDeclaration(name, NullExpression(span), span, guarded: true)); + } + return null; } @@ -951,8 +1021,7 @@ final class _EvaluateVisitor if (node.query case var unparsedQuery?) { var (resolved, map) = _performInterpolationWithMap(unparsedQuery, warnForColor: true); - query = - AtRootQuery.parse(resolved, interpolationMap: map, logger: _logger); + query = AtRootQuery.parse(resolved, interpolationMap: map); } var parent = _parent; @@ -1121,7 +1190,8 @@ final class _EvaluateVisitor Value? visitDebugRule(DebugRule node) { var value = node.expression.accept(this); _logger.debug( - value is SassString ? value.text : value.toString(), node.span); + value is SassString ? value.text : serializeValue(value, inspect: true), + node.span); return null; } @@ -1136,6 +1206,45 @@ final class _EvaluateVisitor node.span); } + var siblings = _parent.parent!.children; + var interleavedRules = []; + if (siblings.last != _parent && + // Reproduce this condition from [_warn] so that we don't add anything to + // [interleavedRules] for declarations in dependencies. + !(_quietDeps && + (_inDependency || (_currentCallable?.inDependency ?? false)))) { + loop: + for (var sibling in siblings.skip(siblings.indexOf(_parent) + 1)) { + switch (sibling) { + case CssComment(): + continue loop; + + case CssStyleRule rule: + interleavedRules.add(rule); + + case _: + // Always warn for siblings that aren't style rules, because they + // add no specificity and they're nested in the same parent as this + // declaration. + _warn( + "Sass's behavior for declarations that appear after nested\n" + "rules will be changing to match the behavior specified by CSS " + "in an upcoming\n" + "version. To keep the existing behavior, move the declaration " + "above the nested\n" + "rule. To opt into the new behavior, wrap the declaration in " + "`& {}`.\n" + "\n" + "More info: https://sass-lang.com/d/mixed-decls", + MultiSpan( + node.span, 'declaration', {sibling.span: 'nested rule'}), + Deprecation.mixedDecls); + interleavedRules.clear(); + break; + } + } + } + var name = _interpolationToValue(node.name, warnForColor: true); if (_declarationName case var declarationName?) { name = CssValue("$declarationName-${name.value}", name.span); @@ -1149,6 +1258,8 @@ final class _EvaluateVisitor _parent.addChild(ModifiableCssDeclaration( name, CssValue(value, expression.span), node.span, parsedAsCustomProperty: node.isCustomProperty, + interleavedRules: interleavedRules, + trace: interleavedRules.isEmpty ? null : _stackTrace(node.span), valueSpanForMap: _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); } else if (name.value.startsWith('--')) { @@ -1236,7 +1347,7 @@ final class _EvaluateVisitor _performInterpolationWithMap(node.selector, warnForColor: true); var list = SelectorList.parse(trimAscii(targetText, excludeEscape: true), - interpolationMap: targetMap, logger: _logger, allowParent: false); + interpolationMap: targetMap, allowParent: false); for (var complex in list.components) { var compound = complex.singleCompound; @@ -1638,6 +1749,13 @@ final class _EvaluateVisitor if (importCache.canonicalize(Uri.parse(url), baseImporter: _importer, baseUrl: baseUrl, forImport: forImport) case (var importer, var canonicalUrl, :var originalUrl)) { + if (canonicalUrl.scheme == '') { + _logger.warnForDeprecation( + Deprecation.relativeCanonical, + "Importer $importer canonicalized $url to $canonicalUrl.\n" + "Relative canonical URLs are deprecated and will eventually be " + "disallowed."); + } // Make sure we record the canonical URL as "loaded" even if the // actual load fails, because watchers should watch it to see if it // changes in a way that allows the load to succeed. @@ -1645,12 +1763,14 @@ final class _EvaluateVisitor var isDependency = _inDependency || importer != _importer; if (importCache.importCanonical(importer, canonicalUrl, - originalUrl: originalUrl, quiet: _quietDeps && isDependency) + originalUrl: originalUrl) case var stylesheet?) { return (stylesheet, importer: importer, isDependency: isDependency); } } - } else { + } + + if (_nodeImporter != null) { if (_importLikeNode( url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) case var result?) { @@ -1671,13 +1791,7 @@ final class _EvaluateVisitor } on ArgumentError catch (error, stackTrace) { throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error)), error, stackTrace); } finally { _importSpan = null; } @@ -1694,7 +1808,7 @@ final class _EvaluateVisitor if (result != null) { isDependency = _inDependency; } else { - result = _nodeImporter!.load(originalUrl, previous, forImport); + result = _nodeImporter.load(originalUrl, previous, forImport); if (result == null) return null; isDependency = true; } @@ -1703,8 +1817,7 @@ final class _EvaluateVisitor return ( Stylesheet.parse( contents, url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, - url: url, - logger: _quietDeps && isDependency ? Logger.quiet : _logger), + url: url), importer: null, isDependency: isDependency ); @@ -1730,40 +1843,55 @@ final class _EvaluateVisitor } } - Value? visitIncludeRule(IncludeRule node) { - var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); - var mixin = _addExceptionSpan(node, - () => _environment.getMixin(node.name, namespace: node.namespace)); + /// Evaluate a given [mixin] with [arguments] and [contentCallable] + void _applyMixin( + Callable? mixin, + UserDefinedCallable? contentCallable, + ArgumentInvocation arguments, + AstNode nodeWithSpan, + AstNode nodeWithSpanWithoutContent) { switch (mixin) { case null: - throw _exception("Undefined mixin.", node.span); - - case BuiltInCallable() when node.content != null: - throw _exception("Mixin doesn't accept a content block.", node.span); + throw _exception("Undefined mixin.", nodeWithSpan.span); + case BuiltInCallable(acceptsContent: false) when contentCallable != null: + { + var evaluated = _evaluateArguments(arguments); + var (overload, _) = mixin.callbackFor( + evaluated.positional.length, MapKeySet(evaluated.named)); + throw MultiSpanSassRuntimeException( + "Mixin doesn't accept a content block.", + nodeWithSpanWithoutContent.span, + "invocation", + {overload.spanWithName: "declaration"}, + _stackTrace(nodeWithSpanWithoutContent.span)); + } case BuiltInCallable(): - _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); + _environment.withContent(contentCallable, () { + _environment.asMixin(() { + _runBuiltInCallable(arguments, mixin, nodeWithSpanWithoutContent); + }); + }); case UserDefinedCallable( declaration: MixinRule(hasContent: false) ) - when node.content != null: + when contentCallable != null: throw MultiSpanSassRuntimeException( "Mixin doesn't accept a content block.", - node.spanWithoutContent, + nodeWithSpanWithoutContent.span, "invocation", {mixin.declaration.arguments.spanWithName: "declaration"}, - _stackTrace(node.spanWithoutContent)); + _stackTrace(nodeWithSpanWithoutContent.span)); case UserDefinedCallable(): - var contentCallable = node.content.andThen((content) => - UserDefinedCallable(content, _environment.closure(), - inDependency: _inDependency)); - _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, () { + _runUserDefinedCallable(arguments, mixin, nodeWithSpanWithoutContent, + () { _environment.withContent(contentCallable, () { _environment.asMixin(() { for (var statement in mixin.declaration.children) { - _addErrorSpan(nodeWithSpan, () => statement.accept(this)); + _addErrorSpan( + nodeWithSpanWithoutContent, () => statement.accept(this)); } }); }); @@ -1772,6 +1900,32 @@ final class _EvaluateVisitor case _: throw UnsupportedError("Unknown callable type $mixin."); } + } + + Value? visitIncludeRule(IncludeRule node) { + var mixin = _addExceptionSpan(node, + () => _environment.getMixin(node.name, namespace: node.namespace)); + if (node.originalName.startsWith('--') && + mixin is UserDefinedCallable && + !mixin.declaration.originalName.startsWith('--')) { + _warn( + 'Sass @mixin names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS mixins.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + node.nameSpan, + Deprecation.cssFunctionMixin); + } + + var contentCallable = node.content.andThen((content) => UserDefinedCallable( + content, _environment.closure(), + inDependency: _inDependency)); + + var nodeWithSpanWithoutContent = + AstNode.fake(() => node.spanWithoutContent); + + _applyMixin(mixin, contentCallable, node.arguments, node, + nodeWithSpanWithoutContent); return null; } @@ -1793,8 +1947,10 @@ final class _EvaluateVisitor _endOfImports++; } - _parent.addChild( - ModifiableCssComment(_performInterpolation(node.text), node.span)); + var text = _performInterpolation(node.text); + // Indented syntax doesn't require */ + if (!text.endsWith("*/")) text += " */"; + _parent.addChild(ModifiableCssComment(text, node.span)); return null; } @@ -1852,8 +2008,7 @@ final class _EvaluateVisitor List _visitMediaQueries(Interpolation interpolation) { var (resolved, map) = _performInterpolationWithMap(interpolation, warnForColor: true); - return CssMediaQuery.parseList(resolved, - logger: _logger, interpolationMap: map); + return CssMediaQuery.parseList(resolved, interpolationMap: map); } /// Returns a list of queries that selects for contexts that match both @@ -1893,6 +2048,9 @@ final class _EvaluateVisitor if (_declarationName != null) { throw _exception( "Style rules may not be used within nested declarations.", node.span); + } else if (_inKeyframes && _parent is CssKeyframeBlock) { + throw _exception( + "Style rules may not be used within keyframe blocks.", node.span); } var (selectorText, selectorMap) = @@ -1902,9 +2060,9 @@ final class _EvaluateVisitor // NOTE: this logic is largely duplicated in [visitCssKeyframeBlock]. Most // changes here should be mirrored there. - var parsedSelector = KeyframeSelectorParser(selectorText, - logger: _logger, interpolationMap: selectorMap) - .parse(); + var parsedSelector = + KeyframeSelectorParser(selectorText, interpolationMap: selectorMap) + .parse(); var rule = ModifiableCssKeyframeBlock( CssValue(List.unmodifiable(parsedSelector), node.selector.span), node.span); @@ -1919,16 +2077,30 @@ final class _EvaluateVisitor } var parsedSelector = SelectorList.parse(selectorText, - interpolationMap: selectorMap, - allowParent: !_stylesheet.plainCss, - allowPlaceholder: !_stylesheet.plainCss, - logger: _logger) - .resolveParentSelectors(_styleRuleIgnoringAtRoot?.originalSelector, - implicitParent: !_atRootExcludingStyleRule); + interpolationMap: selectorMap, plainCss: _stylesheet.plainCss); + + var nest = !(_styleRule?.fromPlainCss ?? false); + if (nest) { + if (_stylesheet.plainCss) { + for (var complex in parsedSelector.components) { + if (complex.leadingCombinators case [var first, ...] + when _stylesheet.plainCss) { + throw _exception( + "Top-level leading combinators aren't allowed in plain CSS.", + first.span); + } + } + } + + parsedSelector = parsedSelector.nestWithin( + _styleRuleIgnoringAtRoot?.originalSelector, + implicitParent: !_atRootExcludingStyleRule, + preserveParentSelectors: _stylesheet.plainCss); + } var selector = _extensionStore.addSelector(parsedSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, - originalSelector: parsedSelector); + originalSelector: parsedSelector, fromPlainCss: _stylesheet.plainCss); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; _atRootExcludingStyleRule = false; _withParent(rule, () { @@ -1938,12 +2110,24 @@ final class _EvaluateVisitor } }); }, - through: (node) => node is CssStyleRule, + through: nest ? (node) => node is CssStyleRule : null, scopeWhen: node.hasDeclarations); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; + _warnForBogusCombinators(rule); + + if (_styleRule == null && _parent.children.isNotEmpty) { + var lastChild = _parent.children.last; + lastChild.isGroupEnd = true; + } + + return null; + } + + /// Emits deprecation warnings for any bogus combinators in [rule]. + void _warnForBogusCombinators(CssStyleRule rule) { if (!rule.isInvisibleOtherThanBogusCombinators) { - for (var complex in parsedSelector.components) { + for (var complex in rule.selector.components) { if (!complex.isBogus) continue; if (complex.isUseless) { @@ -1956,13 +2140,15 @@ final class _EvaluateVisitor complex.span.trimRight(), Deprecation.bogusCombinators); } else if (complex.leadingCombinators.isNotEmpty) { - _warn( - 'The selector "${complex.toString().trim()}" is invalid CSS.\n' - 'This will be an error in Dart Sass 2.0.0.\n' - '\n' - 'More info: https://sass-lang.com/d/bogus-combinators', - complex.span.trimRight(), - Deprecation.bogusCombinators); + if (!_stylesheet.plainCss) { + _warn( + 'The selector "${complex.toString().trim()}" is invalid CSS.\n' + 'This will be an error in Dart Sass 2.0.0.\n' + '\n' + 'More info: https://sass-lang.com/d/bogus-combinators', + complex.span.trimRight(), + Deprecation.bogusCombinators); + } } else { _warn( 'The selector "${complex.toString().trim()}" is only valid for ' @@ -1985,13 +2171,6 @@ final class _EvaluateVisitor } } } - - if (_styleRule == null && _parent.children.isNotEmpty) { - var lastChild = _parent.children.last; - lastChild.isGroupEnd = true; - } - - return null; } Value? visitSupportsRule(SupportsRule node) { @@ -2351,13 +2530,15 @@ final class _EvaluateVisitor throw _exception("Undefined function.", node.span); } + // Note that the list of calculation functions is also tracked in + // lib/src/visitor/is_plain_css_safe.dart. switch (node.name.toLowerCase()) { - case "min" || "max" || "round" || "abs" + case ("min" || "max" || "round" || "abs") && var name when node.arguments.named.isEmpty && node.arguments.rest == null && node.arguments.positional .every((argument) => argument.isCalculationSafe): - return _visitCalculation(node, inLegacySassFunction: true); + return _visitCalculation(node, inLegacySassFunction: name); case "calc" || "clamp" || @@ -2383,6 +2564,19 @@ final class _EvaluateVisitor PlainCssCallable(node.originalName); } + if (node.originalName.startsWith('--') && + function is UserDefinedCallable && + !function.declaration.originalName.startsWith('--')) { + _warn( + 'Sass @function names beginning with -- are deprecated for forward-' + 'compatibility with plain CSS functions.\n' + '\n' + 'For details, see https://sass-lang.com/d/css-function-mixin', + node.nameSpan, + Deprecation.cssFunctionMixin, + ); + } + var oldInFunction = _inFunction; _inFunction = true; var result = _addErrorSpan( @@ -2391,8 +2585,15 @@ final class _EvaluateVisitor return result; } + /// Evaluates [node] as a calculation. + /// + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()`, `max()`, `round()`, and `abs()` functions. + /// The parameter is the name of the function, which is used for reporting + /// deprecation warnings. Value _visitCalculation(FunctionExpression node, - {bool inLegacySassFunction = false}) { + {String? inLegacySassFunction}) { if (node.arguments.named.isNotEmpty) { throw _exception( "Keyword arguments can't be used with calculations.", node.span); @@ -2440,8 +2641,12 @@ final class _EvaluateVisitor SassCalculation.mod(arguments[0], arguments.elementAtOrNull(1)), "rem" => SassCalculation.rem(arguments[0], arguments.elementAtOrNull(1)), - "round" => SassCalculation.round(arguments[0], - arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + "round" => SassCalculation.roundInternal(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2), + span: node.span, + inLegacySassFunction: inLegacySassFunction, + warn: (message, [deprecation]) => + _warn(message, node.span, deprecation)), "clamp" => SassCalculation.clamp(arguments[0], arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), _ => throw UnsupportedError('Unknown calculation name "${node.name}".') @@ -2537,11 +2742,13 @@ final class _EvaluateVisitor /// Evaluates [node] as a component of a calculation. /// - /// If [inLegacySassFunction] is `true`, this allows unitless numbers to be added and - /// subtracted with numbers with units, for backwards-compatibility with the - /// old global `min()`, `max()`, `round()`, and `abs()` functions. + /// If [inLegacySassFunction] isn't null, this allows unitless numbers to be + /// added and subtracted with numbers with units, for backwards-compatibility + /// with the old global `min()`, `max()`, `round()`, and `abs()` functions. + /// The parameter is the name of the function, which is used for reporting + /// deprecation warnings. Object _visitCalculationExpression(Expression node, - {required bool inLegacySassFunction}) { + {required String? inLegacySassFunction}) { switch (node) { case ParenthesizedExpression(expression: var inner): var result = _visitCalculationExpression(inner, @@ -2572,7 +2779,9 @@ final class _EvaluateVisitor _visitCalculationExpression(right, inLegacySassFunction: inLegacySassFunction), inLegacySassFunction: inLegacySassFunction, - simplify: !_inSupportsDeclaration)); + simplify: !_inSupportsDeclaration, + warn: (message, [deprecation]) => + _warn(message, node.span, deprecation))); case NumberExpression() || VariableExpression() || @@ -2888,13 +3097,8 @@ final class _EvaluateVisitor } on SassException { rethrow; } catch (error, stackTrace) { - String? message; - try { - message = (error as dynamic).message as String; - } catch (_) { - message = error.toString(); - } - throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); + throwWithTrace(_exception(_getErrorMessage(error), nodeWithSpan.span), + error, stackTrace); } _callableNode = oldCallableNode; @@ -3269,15 +3473,21 @@ final class _EvaluateVisitor if (_declarationName != null) { throw _exception( "Style rules may not be used within nested declarations.", node.span); + } else if (_inKeyframes && _parent is CssKeyframeBlock) { + throw _exception( + "Style rules may not be used within keyframe blocks.", node.span); } var styleRule = _styleRule; - var originalSelector = node.selector.resolveParentSelectors( - styleRule?.originalSelector, - implicitParent: !_atRootExcludingStyleRule); + var nest = !(_styleRule?.fromPlainCss ?? false); + var originalSelector = nest + ? node.selector.nestWithin(styleRule?.originalSelector, + implicitParent: !_atRootExcludingStyleRule, + preserveParentSelectors: node.fromPlainCss) + : node.selector; var selector = _extensionStore.addSelector(originalSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, - originalSelector: originalSelector); + originalSelector: originalSelector, fromPlainCss: node.fromPlainCss); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; _atRootExcludingStyleRule = false; _withParent(rule, () { @@ -3286,7 +3496,7 @@ final class _EvaluateVisitor child.accept(this); } }); - }, through: (node) => node is CssStyleRule, scopeWhen: false); + }, through: nest ? (node) => node is CssStyleRule : null, scopeWhen: false); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; if (_parent.children case [..., var lastChild] when styleRule == null) { @@ -3372,7 +3582,7 @@ final class _EvaluateVisitor String _performInterpolation(Interpolation interpolation, {bool warnForColor = false}) { var (result, _) = _performInterpolationHelper(interpolation, - sourceMap: true, warnForColor: warnForColor); + sourceMap: false, warnForColor: warnForColor); return result; } @@ -3413,7 +3623,7 @@ final class _EvaluateVisitor if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, - StringExpression(Interpolation([""], interpolation.span), + StringExpression(Interpolation.plain("", interpolation.span), quotes: true), expression); _warn( @@ -3706,6 +3916,18 @@ final class _EvaluateVisitor stackTrace); } } + + /// Returns the best human-readable message for [error]. + String _getErrorMessage(Object error) { + // Built-in Dart Error objects often require their full toString()s for + // full context. + if (error is Error) return error.toString(); + try { + return (error as dynamic).message as String; + } catch (_) { + return error.toString(); + } + } } /// A helper class for [_EvaluateVisitor] that adds `@import`ed CSS nodes to the diff --git a/lib/src/visitor/interface/value.dart b/lib/src/visitor/interface/value.dart index db25c86d5..e25f5ba11 100644 --- a/lib/src/visitor/interface/value.dart +++ b/lib/src/visitor/interface/value.dart @@ -12,6 +12,7 @@ abstract interface class ValueVisitor { T visitCalculation(SassCalculation value); T visitColor(SassColor value); T visitFunction(SassFunction value); + T visitMixin(SassMixin value); T visitList(SassList value); T visitMap(SassMap value); T visitNull(); diff --git a/lib/src/visitor/is_calculation_safe.dart b/lib/src/visitor/is_calculation_safe.dart new file mode 100644 index 000000000..9d2e406fc --- /dev/null +++ b/lib/src/visitor/is_calculation_safe.dart @@ -0,0 +1,86 @@ +// 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:charcode/charcode.dart'; + +import '../ast/sass.dart'; +import '../util/nullable.dart'; +import '../value.dart'; +import 'interface/expression.dart'; + +// We could use [AstSearchVisitor] to implement this more tersely, but that +// would default to returning `true` if we added a new expression type and +// forgot to update this class. + +/// A visitor that determines whether an expression is valid in a calculation +/// context. +/// +/// This should be used through [Expression.isCalculationSafe]. +class IsCalculationSafeVisitor implements ExpressionVisitor { + const IsCalculationSafeVisitor(); + + bool visitBinaryOperationExpression(BinaryOperationExpression node) => + (const { + BinaryOperator.times, + BinaryOperator.dividedBy, + BinaryOperator.plus, + BinaryOperator.minus + }).contains(node.operator) && + (node.left.accept(this) || node.right.accept(this)); + + bool visitBooleanExpression(BooleanExpression node) => false; + + bool visitColorExpression(ColorExpression node) => false; + + bool visitFunctionExpression(FunctionExpression node) => true; + + bool visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + true; + + bool visitIfExpression(IfExpression node) => true; + + bool visitListExpression(ListExpression node) => + node.separator == ListSeparator.space && + !node.hasBrackets && + node.contents.length > 1 && + node.contents.every((expression) => expression.accept(this)); + + bool visitMapExpression(MapExpression node) => false; + + bool visitNullExpression(NullExpression node) => false; + + bool visitNumberExpression(NumberExpression node) => true; + + bool visitParenthesizedExpression(ParenthesizedExpression node) => + node.expression.accept(this); + + bool visitSelectorExpression(SelectorExpression node) => false; + + bool visitStringExpression(StringExpression node) { + if (node.hasQuotes) return false; + + // Exclude non-identifier constructs that are parsed as [StringExpression]s. + // We could just check if they parse as valid identifiers, but this is + // cheaper. + var text = node.text.initialPlain; + return + // !important + !text.startsWith("!") && + // ID-style identifiers + !text.startsWith("#") && + // Unicode ranges + text.codeUnitAtOrNull(1) != $plus && + // url() + text.codeUnitAtOrNull(3) != $lparen; + } + + bool visitSupportsExpression(SupportsExpression node) => false; + + bool visitUnaryOperationExpression(UnaryOperationExpression node) => false; + + bool visitValueExpression(ValueExpression node) => false; + + bool visitVariableExpression(VariableExpression node) => true; +} diff --git a/lib/src/visitor/replace_expression.dart b/lib/src/visitor/replace_expression.dart index 43d93eebc..8c06423de 100644 --- a/lib/src/visitor/replace_expression.dart +++ b/lib/src/visitor/replace_expression.dart @@ -128,5 +128,6 @@ mixin ReplaceExpressionVisitor implements ExpressionVisitor { Interpolation( interpolation.contents .map((node) => node is Expression ? node.accept(this) : node), + interpolation.spans, interpolation.span); } diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index f86d6c7fb..a93c2fb8f 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -14,10 +14,13 @@ import '../ast/css.dart'; import '../ast/node.dart'; import '../ast/selector.dart'; import '../color_names.dart'; +import '../deprecation.dart'; import '../exception.dart'; +import '../logger.dart'; import '../parse/parser.dart'; import '../utils.dart'; import '../util/character.dart'; +import '../util/multi_span.dart'; import '../util/no_source_map_buffer.dart'; import '../util/nullable.dart'; import '../util/number.dart'; @@ -49,6 +52,7 @@ SerializeResult serialize(CssNode node, bool useSpaces = true, int? indentWidth, LineFeed? lineFeed, + Logger? logger, bool sourceMap = false, bool charset = true}) { indentWidth ??= 2; @@ -58,6 +62,7 @@ SerializeResult serialize(CssNode node, useSpaces: useSpaces, indentWidth: indentWidth, lineFeed: lineFeed, + logger: logger, sourceMap: sourceMap); node.accept(visitor); var css = visitor._buffer.toString(); @@ -129,6 +134,12 @@ final class _SerializeVisitor /// The characters to use for a line feed. final LineFeed _lineFeed; + /// The logger to use to print warnings. + /// + /// This should only be used for statement-level serialization. It's not + /// guaranteed to be the main user-provided logger for expressions. + final Logger _logger; + /// Whether we're emitting compressed output. bool get _isCompressed => _style == OutputStyle.compressed; @@ -139,6 +150,7 @@ final class _SerializeVisitor bool useSpaces = true, int? indentWidth, LineFeed? lineFeed, + Logger? logger, bool sourceMap = true}) : _buffer = sourceMap ? SourceMapBuffer() : NoSourceMapBuffer(), _style = style ?? OutputStyle.expanded, @@ -146,7 +158,8 @@ final class _SerializeVisitor _quote = quote, _indentCharacter = useSpaces ? $space : $tab, _indentWidth = indentWidth ?? 2, - _lineFeed = lineFeed ?? LineFeed.lf { + _lineFeed = lineFeed ?? LineFeed.lf, + _logger = logger ?? const Logger.stderr() { RangeError.checkValueInInterval(_indentWidth, 0, 10, "indentWidth"); } @@ -330,6 +343,33 @@ final class _SerializeVisitor } void visitCssDeclaration(CssDeclaration node) { + if (node.interleavedRules.isNotEmpty) { + var declSpecificities = _specificities(node.parent!); + for (var rule in node.interleavedRules) { + var ruleSpecificities = _specificities(rule); + + // If the declaration can never match with the same specificity as one + // of its sibling rules, then ordering will never matter and there's no + // need to warn about the declaration being re-ordered. + if (!declSpecificities.any(ruleSpecificities.contains)) continue; + + _logger.warnForDeprecation( + Deprecation.mixedDecls, + "Sass's behavior for declarations that appear after nested\n" + "rules will be changing to match the behavior specified by CSS in an " + "upcoming\n" + "version. To keep the existing behavior, move the declaration above " + "the nested\n" + "rule. To opt into the new behavior, wrap the declaration in `& " + "{}`.\n" + "\n" + "More info: https://sass-lang.com/d/mixed-decls", + span: + MultiSpan(node.span, 'declaration', {rule.span: 'nested rule'}), + trace: node.trace); + } + } + _writeIndentation(); _write(node.name); @@ -364,6 +404,22 @@ final class _SerializeVisitor } } + /// Returns the set of possible specificities which which [node] might match. + Set _specificities(CssParentNode node) { + if (node case CssStyleRule rule) { + // Plain CSS style rule nesting implicitly wraps parent selectors in + // `:is()`, so they all match with the highest specificity among any of + // them. + var parent = node.parent.andThen(_specificities)?.max ?? 0; + return { + for (var selector in rule.selector.components) + parent + selector.specificity + }; + } else { + return node.parent.andThen(_specificities) ?? const {0}; + } + } + /// Emits the value of [node], with all newlines followed by whitespace void _writeFoldedValue(CssDeclaration node) { var scanner = StringScanner((node.value.value as SassString).text); @@ -489,13 +545,8 @@ final class _SerializeVisitor void _writeCalculationValue(Object value) { switch (value) { - case SassNumber(value: double(isFinite: false), hasComplexUnits: true): - if (!_inspect) { - throw SassScriptException("$value isn't a valid CSS value."); - } - - _writeNumber(value.value); - _buffer.write(value.unitString); + case SassNumber(hasComplexUnits: true) when !_inspect: + throw SassScriptException("$value isn't a valid CSS value."); case SassNumber(value: double(isFinite: false)): switch (value.value) { @@ -507,12 +558,15 @@ final class _SerializeVisitor _buffer.write('NaN'); } - if (value.numeratorUnits.firstOrNull case var unit?) { - _writeOptionalSpace(); - _buffer.writeCharCode($asterisk); - _writeOptionalSpace(); - _buffer.writeCharCode($1); - _buffer.write(unit); + _writeCalculationUnits(value.numeratorUnits, value.denominatorUnits); + + case SassNumber(hasComplexUnits: true): + _writeNumber(value.value); + if (value.numeratorUnits case [var first, ...var rest]) { + _buffer.write(first); + _writeCalculationUnits(rest, value.denominatorUnits); + } else { + _writeCalculationUnits([], value.denominatorUnits); } case Value(): @@ -534,14 +588,36 @@ final class _SerializeVisitor _parenthesizeCalculationRhs(operator, right.operator)) || (operator == CalculationOperator.dividedBy && right is SassNumber && - !right.value.isFinite && - right.hasUnits); + (right.value.isFinite + ? right.hasComplexUnits + : right.hasUnits)); if (parenthesizeRight) _buffer.writeCharCode($lparen); _writeCalculationValue(right); if (parenthesizeRight) _buffer.writeCharCode($rparen); } } + /// Writes the complex numerator and denominator units beyond the first + /// numerator unit for a number as they appear in a calculation. + void _writeCalculationUnits( + List numeratorUnits, List denominatorUnits) { + for (var unit in numeratorUnits) { + _writeOptionalSpace(); + _buffer.writeCharCode($asterisk); + _writeOptionalSpace(); + _buffer.writeCharCode($1); + _buffer.write(unit); + } + + for (var unit in denominatorUnits) { + _writeOptionalSpace(); + _buffer.writeCharCode($slash); + _writeOptionalSpace(); + _buffer.writeCharCode($1); + _buffer.write(unit); + } + } + /// Returns whether the right-hand operation of a calculation should be /// parenthesized. /// @@ -556,103 +632,361 @@ final class _SerializeVisitor }; void visitColor(SassColor value) { + switch (value.space) { + case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb + when !value.isChannel0Missing && + !value.isChannel1Missing && + !value.isChannel2Missing && + !value.isAlphaMissing: + _writeLegacyColor(value); + + case ColorSpace.rgb: + _buffer.write('rgb('); + _writeChannel(value.channel0OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case ColorSpace.hsl || ColorSpace.hwb: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + _writeChannel(value.channel0OrNull, _isCompressed ? null : 'deg'); + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull, '%'); + _buffer.writeCharCode($space); + _writeChannel(value.channel2OrNull, '%'); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case ColorSpace.lab || ColorSpace.lch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 100) && + !value.isChannel1Missing && + !value.isChannel2Missing: + case ColorSpace.oklab || ColorSpace.oklch + when !_inspect && + !fuzzyInRange(value.channel0, 0, 1) && + !value.isChannel1Missing && + !value.isChannel2Missing: + case ColorSpace.lch || ColorSpace.oklch + when !_inspect && + fuzzyLessThan(value.channel1, 0) && + !value.isChannel0Missing && + !value.isChannel1Missing: + // color-mix() is currently more widely supported than relative color + // syntax, so we use it to serialize out-of-gamut colors in a way that + // maintains the color space defined in Sass while (per spec) not + // clamping their values. In practice, all browsers clamp out-of-gamut + // values, but there's not much we can do about that at time of writing. + _buffer.write('color-mix(in '); + _buffer.write(value.space); + _buffer.write(_commaSeparator); + // The XYZ space has no gamut restrictions, so we use it to represent + // the out-of-gamut color before converting into the target space. + _writeColorFunction(value.toSpace(ColorSpace.xyzD65)); + _writeOptionalSpace(); + _buffer.write('100%'); + _buffer.write(_commaSeparator); + _buffer.write(_isCompressed ? 'red' : 'black'); + _buffer.writeCharCode($rparen); + + case ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch: + _buffer + ..write(value.space) + ..writeCharCode($lparen); + + // color-mix() can't represent out-of-bounds colors with missing + // channels, so in this case we use the less-supported but + // more-expressive relative color syntax instead. Relative color syntax + // never clamps channels. + var polar = value.space.channels[2].isPolarAngle; + if (!_inspect && + (!fuzzyInRange(value.channel0, 0, 100) || + (polar && fuzzyLessThan(value.channel1, 0)))) { + _buffer + ..write('from ') + ..write(_isCompressed ? 'red' : 'black') + ..writeCharCode($space); + } + + if (!_isCompressed && !value.isChannel0Missing) { + var max = (value.space.channels[0] as LinearChannel).max; + _writeNumber(value.channel0 * 100 / max); + _buffer.writeCharCode($percent); + } else { + _writeChannel(value.channel0OrNull); + } + _buffer.writeCharCode($space); + _writeChannel(value.channel1OrNull); + _buffer.writeCharCode($space); + _writeChannel( + value.channel2OrNull, polar && !_isCompressed ? 'deg' : null); + _maybeWriteSlashAlpha(value); + _buffer.writeCharCode($rparen); + + case _: + _writeColorFunction(value); + } + } + + /// Writes a [channel] which may be missing. + void _writeChannel(double? channel, [String? unit]) { + if (channel == null) { + _buffer.write('none'); + } else if (channel.isFinite) { + _writeNumber(channel); + if (unit != null) _buffer.write(unit); + } else { + visitNumber(SassNumber(channel, unit)); + } + } + + /// Writes a legacy color to the stylesheet. + /// + /// Unlike newer color spaces, the three legacy color spaces are + /// interchangeable with one another. We choose the shortest representation + /// that's still compatible with all the browsers we support. + void _writeLegacyColor(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + + // Out-of-gamut colors can _only_ be represented accurately as HSL, because + // only HSL isn't clamped at parse time (except negative saturation which + // isn't necessary anyway). + if (!color.isInGamut && !_inspect) { + _writeHsl(color); + return; + } + // In compressed mode, emit colors in the shortest representation possible. if (_isCompressed) { - if (!fuzzyEquals(value.alpha, 1)) { - _writeRgb(value); + var rgb = color.toSpace(ColorSpace.rgb); + if (opaque && _tryIntegerRgb(rgb)) return; + + var red = _writeNumberToString(rgb.channel0); + var green = _writeNumberToString(rgb.channel1); + var blue = _writeNumberToString(rgb.channel2); + + var hsl = color.toSpace(ColorSpace.hsl); + var hue = _writeNumberToString(hsl.channel0); + var saturation = _writeNumberToString(hsl.channel1); + var lightness = _writeNumberToString(hsl.channel2); + + // Add two characters for HSL for the %s on saturation and lightness. + if (red.length + green.length + blue.length <= + hue.length + saturation.length + lightness.length + 2) { + _buffer + ..write(opaque ? 'rgb(' : 'rgba(') + ..write(red) + ..writeCharCode($comma) + ..write(green) + ..writeCharCode($comma) + ..write(blue); } else { - var hexLength = _canUseShortHex(value) ? 4 : 7; - if (namesByColor[value] case var name? when name.length <= hexLength) { - _buffer.write(name); - } else if (_canUseShortHex(value)) { - _buffer.writeCharCode($hash); - _buffer.writeCharCode(hexCharFor(value.red & 0xF)); - _buffer.writeCharCode(hexCharFor(value.green & 0xF)); - _buffer.writeCharCode(hexCharFor(value.blue & 0xF)); - } else { - _buffer.writeCharCode($hash); - _writeHexComponent(value.red); - _writeHexComponent(value.green); - _writeHexComponent(value.blue); - } + _buffer + ..write(opaque ? 'hsl(' : 'hsla(') + ..write(hue) + ..writeCharCode($comma) + ..write(saturation) + ..write('%,') + ..write(lightness) + ..writeCharCode($percent); } - } else { - if (value.format case var format?) { - switch (format) { - case ColorFormat.rgbFunction: - _writeRgb(value); - case ColorFormat.hslFunction: - _writeHsl(value); - case SpanColorFormat(): - _buffer.write(format.original); - case _: - assert(false, "unknown format"); - } - } else if (namesByColor[value] case var name? - // Always emit generated transparent colors in rgba format. This works - // around an IE bug. See sass/sass#1782. - when !fuzzyEquals(value.alpha, 0)) { + if (!opaque) { + _buffer.writeCharCode($comma); + _writeNumber(color.alpha); + } + _buffer.writeCharCode($rparen); + return; + } + + if (color.space == ColorSpace.hsl) { + _writeHsl(color); + return; + } else if (_inspect && color.space == ColorSpace.hwb) { + _writeHwb(color); + return; + } + + switch (color.format) { + case ColorFormat.rgbFunction: + _writeRgb(color); + return; + + case SpanColorFormat format: + _buffer.write(format.original); + return; + } + + // Always emit generated transparent colors in rgba format. This works + // around an IE bug. See sass/sass#1782. + if (opaque) { + var rgb = color.toSpace(ColorSpace.rgb); + if (namesByColor[rgb] case var name?) { _buffer.write(name); - } else if (fuzzyEquals(value.alpha, 1)) { + return; + } + + if (_canUseHex(rgb)) { _buffer.writeCharCode($hash); - _writeHexComponent(value.red); - _writeHexComponent(value.green); - _writeHexComponent(value.blue); - } else { - _writeRgb(value); + _writeHexComponent(rgb.channel0.round()); + _writeHexComponent(rgb.channel1.round()); + _writeHexComponent(rgb.channel2.round()); + return; } } + + // If an HWB color can't be represented as a hex color, write is as HSL + // rather than RGB since that more clearly captures the author's intent. + if (color.space == ColorSpace.hwb) { + _writeHsl(color); + } else { + _writeRgb(color); + } } + /// If [value] can be written as a hex code or a color name, writes it in the + /// shortest format possible and returns `true.` + /// + /// Otherwise, writes nothing and returns `false`. Assumes [value] is in the + /// RGB space. + bool _tryIntegerRgb(SassColor rgb) { + assert(rgb.space == ColorSpace.rgb); + if (!_canUseHex(rgb)) return false; + + var redInt = rgb.channel0.round(); + var greenInt = rgb.channel1.round(); + var blueInt = rgb.channel2.round(); + + var shortHex = _canUseShortHex(redInt, greenInt, blueInt); + if (namesByColor[rgb] case var name? + when name.length <= (shortHex ? 4 : 7)) { + _buffer.write(name); + } else if (shortHex) { + _buffer.writeCharCode($hash); + _buffer.writeCharCode(hexCharFor(redInt & 0xF)); + _buffer.writeCharCode(hexCharFor(greenInt & 0xF)); + _buffer.writeCharCode(hexCharFor(blueInt & 0xF)); + } else { + _buffer.writeCharCode($hash); + _writeHexComponent(redInt); + _writeHexComponent(greenInt); + _writeHexComponent(blueInt); + } + return true; + } + + /// Whether [rgb] can be represented as a hexadecimal color. + bool _canUseHex(SassColor rgb) { + assert(rgb.space == ColorSpace.rgb); + return _canUseHexForChannel(rgb.channel0) && + _canUseHexForChannel(rgb.channel1) && + _canUseHexForChannel(rgb.channel2); + } + + /// Whether [channel]'s value can be represented as a two-character + /// hexadecimal value. + bool _canUseHexForChannel(double channel) => + fuzzyIsInt(channel) && + fuzzyGreaterThanOrEquals(channel, 0) && + fuzzyLessThan(channel, 256); + /// Writes [value] as an `rgb()` or `rgba()` function. - void _writeRgb(SassColor value) { - var opaque = fuzzyEquals(value.alpha, 1); - _buffer - ..write(opaque ? "rgb(" : "rgba(") - ..write(value.red) - ..write(_commaSeparator) - ..write(value.green) - ..write(_commaSeparator) - ..write(value.blue); + void _writeRgb(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + var rgb = color.toSpace(ColorSpace.rgb); + _buffer.write(opaque ? "rgb(" : "rgba("); + _writeNumber(rgb.channel('red')); + _buffer.write(_commaSeparator); + _writeNumber(rgb.channel('green')); + _buffer.write(_commaSeparator); + _writeNumber(rgb.channel('blue')); if (!opaque) { _buffer.write(_commaSeparator); - _writeNumber(value.alpha); + _writeNumber(color.alpha); } _buffer.writeCharCode($rparen); } /// Writes [value] as an `hsl()` or `hsla()` function. - void _writeHsl(SassColor value) { - var opaque = fuzzyEquals(value.alpha, 1); + void _writeHsl(SassColor color) { + var opaque = fuzzyEquals(color.alpha, 1); + var hsl = color.toSpace(ColorSpace.hsl); _buffer.write(opaque ? "hsl(" : "hsla("); - _writeNumber(value.hue); + _writeChannel(hsl.channel('hue')); _buffer.write(_commaSeparator); - _writeNumber(value.saturation); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('saturation'), '%'); _buffer.write(_commaSeparator); - _writeNumber(value.lightness); - _buffer.writeCharCode($percent); + _writeChannel(hsl.channel('lightness'), '%'); if (!opaque) { _buffer.write(_commaSeparator); - _writeNumber(value.alpha); + _writeNumber(color.alpha); } _buffer.writeCharCode($rparen); } + /// Writes [value] as an `hwb()` function. + /// + /// This is only used in inspect mode, and so only supports the new color syntax. + void _writeHwb(SassColor color) { + _buffer.write("hwb("); + var hwb = color.toSpace(ColorSpace.hwb); + _writeNumber(hwb.channel('hue')); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('whiteness')); + _buffer.writeCharCode($percent); + _buffer.writeCharCode($space); + _writeNumber(hwb.channel('blackness')); + _buffer.writeCharCode($percent); + + if (!fuzzyEquals(color.alpha, 1)) { + _buffer.write(' / '); + _writeNumber(color.alpha); + } + + _buffer.writeCharCode($rparen); + } + + /// Writes [color] using the `color()` function syntax. + void _writeColorFunction(SassColor color) { + assert(!{ + ColorSpace.rgb, + ColorSpace.hsl, + ColorSpace.hwb, + ColorSpace.lab, + ColorSpace.oklab, + ColorSpace.lch, + ColorSpace.oklch + }.contains(color.space)); + _buffer + ..write('color(') + ..write(color.space) + ..writeCharCode($space); + _writeBetween(color.channelsOrNull, ' ', _writeChannel); + _maybeWriteSlashAlpha(color); + _buffer.writeCharCode($rparen); + } + /// Returns whether [color]'s hex pair representation is symmetrical (e.g. /// `FF`). bool _isSymmetricalHex(int color) => color & 0xF == color >> 4; /// Returns whether [color] can be represented as a short hexadecimal color /// (e.g. `#fff`). - bool _canUseShortHex(SassColor color) => - _isSymmetricalHex(color.red) && - _isSymmetricalHex(color.green) && - _isSymmetricalHex(color.blue); + bool _canUseShortHex(int red, int green, int blue) => + _isSymmetricalHex(red) && + _isSymmetricalHex(green) && + _isSymmetricalHex(blue); /// Emits [color] as a hex character pair. void _writeHexComponent(int color) { @@ -661,6 +995,15 @@ final class _SerializeVisitor _buffer.writeCharCode(hexCharFor(color & 0xF)); } + /// Writes the alpha component of [color] if it isn't 1. + void _maybeWriteSlashAlpha(SassColor color) { + if (fuzzyEquals(color.alpha, 1)) return; + _writeOptionalSpace(); + _buffer.writeCharCode($slash); + _writeOptionalSpace(); + _writeChannel(color.alphaOrNull); + } + void visitFunction(SassFunction function) { if (!_inspect) { throw SassScriptException("$function isn't a valid CSS value."); @@ -671,6 +1014,16 @@ final class _SerializeVisitor _buffer.writeCharCode($rparen); } + void visitMixin(SassMixin mixin) { + if (!_inspect) { + throw SassScriptException("$mixin isn't a valid CSS value."); + } + + _buffer.write("get-mixin("); + _visitQuotedString(mixin.callable.name); + _buffer.writeCharCode($rparen); + } + void visitList(SassList value) { if (value.hasBrackets) { _buffer.writeCharCode($lbracket); @@ -777,28 +1130,39 @@ final class _SerializeVisitor return; } - _writeNumber(value.value); - - if (!_inspect) { - if (value.hasComplexUnits) { + if (value.hasComplexUnits) { + if (!_inspect) { throw SassScriptException("$value isn't a valid CSS value."); } - if (value.numeratorUnits case [var first]) _buffer.write(first); + visitCalculation(SassCalculation.unsimplified('calc', [value])); } else { - _buffer.write(value.unitString); + _writeNumber(value.value); + if (value.numeratorUnits case [var first]) _buffer.write(first); } } + /// Like [_writeNumber], but returns a string rather than writing to + /// [_buffer]. + String _writeNumberToString(double number) { + var buffer = NoSourceMapBuffer(); + _writeNumber(number, buffer); + return buffer.toString(); + } + /// Writes [number] without exponent notation and with at most /// [SassNumber.precision] digits after the decimal point. - void _writeNumber(double number) { + /// + /// The number is written to [buffer], which defaults to [_buffer]. + void _writeNumber(double number, [SourceMapBuffer? buffer]) { + buffer ??= _buffer; + // Dart always converts integers to strings in the obvious way, so all we // have to do is clamp doubles that are close to being integers. if (fuzzyAsInt(number) case var integer?) { // JS still uses exponential notation for integers, so we have to handle // it here. - _buffer.write(_removeExponent(integer.toString())); + buffer.write(_removeExponent(integer.toString())); return; } @@ -811,11 +1175,11 @@ final class _SerializeVisitor if (canWriteDirectly) { if (_isCompressed && text.codeUnitAt(0) == $0) text = text.substring(1); - _buffer.write(text); + buffer.write(text); return; } - _writeRounded(text); + _writeRounded(text, buffer); } /// If [text] is written in exponent notation, returns a string representation @@ -878,7 +1242,7 @@ final class _SerializeVisitor /// Assuming [text] is a number written without exponent notation, rounds it /// to [SassNumber.precision] digits after the decimal and writes the result /// to [_buffer]. - void _writeRounded(String text) { + void _writeRounded(String text, SourceMapBuffer buffer) { assert(RegExp(r"^-?\d+(\.\d+)?$").hasMatch(text), '"$text" should be a number written without exponent notation.'); @@ -886,7 +1250,7 @@ final class _SerializeVisitor // integer values. In that case we definitely don't need to adjust for // precision, so we can just write the number as-is without the `.0`. if (text.endsWith(".0")) { - _buffer.write(text.substring(0, text.length - 2)); + buffer.write(text.substring(0, text.length - 2)); return; } @@ -905,9 +1269,9 @@ final class _SerializeVisitor if (negative) textIndex++; while (true) { if (textIndex == text.length) { - // If we get here, [text] has no decmial point. It definitely doesn't + // If we get here, [text] has no decimal point. It definitely doesn't // need to be rounded; we can write it as-is. - _buffer.write(text); + buffer.write(text); return; } @@ -922,7 +1286,7 @@ final class _SerializeVisitor // truncation is needed. var indexAfterPrecision = textIndex + SassNumber.precision; if (indexAfterPrecision >= text.length) { - _buffer.write(text); + buffer.write(text); return; } @@ -960,11 +1324,11 @@ final class _SerializeVisitor // write "0" explicit to avoid adding a minus sign or omitting the number // entirely in compressed mode. if (digitsIndex == 2 && digits[0] == 0 && digits[1] == 0) { - _buffer.writeCharCode($0); + buffer.writeCharCode($0); return; } - if (negative) _buffer.writeCharCode($minus); + if (negative) buffer.writeCharCode($minus); // Write the digits before the decimal point to [_buffer]. Omit the leading // 0 that's added to [digits] to accommodate rounding, and in compressed @@ -975,13 +1339,13 @@ final class _SerializeVisitor if (_isCompressed && digits[1] == 0) writtenIndex++; } for (; writtenIndex < firstFractionalDigit; writtenIndex++) { - _buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); + buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); } if (digitsIndex > firstFractionalDigit) { - _buffer.writeCharCode($dot); + buffer.writeCharCode($dot); for (; writtenIndex < digitsIndex; writtenIndex++) { - _buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); + buffer.writeCharCode(decimalCharFor(digits[writtenIndex])); } } } @@ -1062,7 +1426,8 @@ final class _SerializeVisitor $fs || $gs || $rs || - $us: + $us || + $del: _writeEscape(buffer, char, string, i); case $backslash: diff --git a/lib/src/visitor/source_interpolation.dart b/lib/src/visitor/source_interpolation.dart new file mode 100644 index 000000000..aadb3e1e5 --- /dev/null +++ b/lib/src/visitor/source_interpolation.dart @@ -0,0 +1,136 @@ +// 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 '../ast/sass.dart'; +import '../interpolation_buffer.dart'; +import '../util/span.dart'; +import 'interface/expression.dart'; + +/// A visitor that builds an [Interpolation] that evaluates to the same text as +/// the given expression. +/// +/// This should be used through [Expression.asInterpolation]. +class SourceInterpolationVisitor implements ExpressionVisitor { + /// The buffer to which content is added each time this visitor visits an + /// expression. + /// + /// This is set to null if the visitor encounters a node that's not valid CSS + /// with interpolations. + InterpolationBuffer? buffer = InterpolationBuffer(); + + void visitBinaryOperationExpression(BinaryOperationExpression node) => + buffer = null; + + void visitBooleanExpression(BooleanExpression node) => buffer = null; + + void visitColorExpression(ColorExpression node) => + buffer?.write(node.span.text); + + void visitFunctionExpression(FunctionExpression node) => buffer = null; + + void visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) { + buffer?.addInterpolation(node.name); + _visitArguments(node.arguments); + } + + /// Visits the positional arguments in [arguments] with [visitor], if it's + /// valid interpolated plain CSS. + void _visitArguments(ArgumentInvocation arguments, + [ExpressionVisitor? visitor]) { + if (arguments.named.isNotEmpty || arguments.rest != null) return; + + if (arguments.positional.isEmpty) { + buffer?.write(arguments.span.text); + return; + } + + buffer?.write(arguments.span.before(arguments.positional.first.span).text); + _writeListAndBetween(arguments.positional, visitor); + buffer?.write(arguments.span.after(arguments.positional.last.span).text); + } + + void visitIfExpression(IfExpression node) => buffer = null; + + void visitListExpression(ListExpression node) { + if (node.contents.length <= 1 && !node.hasBrackets) { + buffer = null; + return; + } + + if (node.hasBrackets && node.contents.isEmpty) { + buffer?.write(node.span.text); + return; + } + + if (node.hasBrackets) { + buffer?.write(node.span.before(node.contents.first.span).text); + } + _writeListAndBetween(node.contents); + + if (node.hasBrackets) { + buffer?.write(node.span.after(node.contents.last.span).text); + } + } + + void visitMapExpression(MapExpression node) => buffer = null; + + void visitNullExpression(NullExpression node) => buffer = null; + + void visitNumberExpression(NumberExpression node) => + buffer?.write(node.span.text); + + void visitParenthesizedExpression(ParenthesizedExpression node) => + buffer = null; + + void visitSelectorExpression(SelectorExpression node) => buffer = null; + + void visitStringExpression(StringExpression node) { + if (node.text.isPlain) { + buffer?.write(node.span.text); + return; + } + + for (var i = 0; i < node.text.contents.length; i++) { + var span = node.text.spanForElement(i); + switch (node.text.contents[i]) { + case Expression expression: + if (i == 0) buffer?.write(node.span.before(span).text); + buffer?.add(expression, span); + if (i == node.text.contents.length - 1) { + buffer?.write(node.span.after(span).text); + } + + case _: + buffer?.write(span); + } + } + } + + void visitSupportsExpression(SupportsExpression node) => buffer = null; + + void visitUnaryOperationExpression(UnaryOperationExpression node) => + buffer = null; + + void visitValueExpression(ValueExpression node) => buffer = null; + + void visitVariableExpression(VariableExpression node) => buffer = null; + + /// Visits each expression in [expression] with [visitor], and writes whatever + /// text is between them to [buffer]. + void _writeListAndBetween(List expressions, + [ExpressionVisitor? visitor]) { + visitor ??= this; + + Expression? lastExpression; + for (var expression in expressions) { + if (lastExpression != null) { + buffer?.write(lastExpression.span.between(expression.span).text); + } + expression.accept(visitor); + if (buffer == null) return; + lastExpression = expression; + } + } +} diff --git a/package.json b/package.json index 531856ade..0eb2cbfa9 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,9 @@ ], "name": "sass", "devDependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "@parcel/watcher": "^2.4.1", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "intercept-stdout": "^0.1.2" - }, - "dependencies": { - "sass": "^1.63.5" } } diff --git a/package/package.json b/package/package.json index 73023b861..5b096250a 100644 --- a/package/package.json +++ b/package/package.json @@ -17,10 +17,13 @@ "node": ">=14.0.0" }, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + }, "keywords": [ "style", "scss", 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..3f6138e4b --- /dev/null +++ b/pkg/sass-parser/CHANGELOG.md @@ -0,0 +1,68 @@ +## 0.4.5 + +* Add support for parsing the `@forward` rule. + +## 0.4.4 + +* No user-visible changes. + +## 0.4.3 + +* Add support for parsing the `@while` rule. + +## 0.4.2 + +* Add support for parsing variable declarations. + +* Add support for parsing the `@warn` rule. + +## 0.4.1 + +* Add `BooleanExpression` and `NumberExpression`. + +* Add support for parsing the `@use` rule. + +## 0.4.0 + +* **Breaking change:** Warnings are no longer emitted during parsing, so the + `logger` option has been removed from `SassParserOptions`. + +## 0.3.2 + +* No user-visible changes. + +## 0.3.1 + +* No user-visible changes. + +## 0.3.0 + +* No user-visible changes. + +## 0.2.6 + +* No user-visible changes. + +## 0.2.5 + +* Add support for parsing the `@supports` rule. + +## 0.2.4 + +* No user-visible changes. + +## 0.2.3 + +* No user-visible changes. + +## 0.2.2 + +* No user-visible changes. + +## 0.2.1 + +* No user-visible changes. + +## 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..bfb2b3520 --- /dev/null +++ b/pkg/sass-parser/README.md @@ -0,0 +1,273 @@ +A [PostCSS]-compatible CSS and [Sass] parser with full expression support. + + + + + + + + +
+ Sass logo + + npm statistics + + GitHub actions build status + + @sass@front-end.social on Fediverse +
+ @SassCSS on Twitter +
+ stackoverflow +
+ Gitter +
+ +[PostCSS]: https://postcss.org/ +[Sass]: https://sass-lang.com/ + +**Warning:** `sass-parser` is still in active development, and is not yet +suitable for production use. At time of writing it only supports a small subset +of CSS and Sass syntax. In addition, it does not yet support parsing raws +(metadata about the original formatting of the document), which makes it +unsuitable for certain source-to-source transformations. + +* [Using `sass-parser`](#using-sass-parser) +* [Why `sass-parser`?](#why-sass-parser) +* [API Documentation](#api-documentation) +* [PostCSS Compatibility](#postcss-compatibility) + * [Statement API](#statement-api) + * [Expression API](#expression-api) + * [Constructing New Nodes](#constructing-new-nodes) + +## Using `sass-parser` + +1. Install the `sass-parser` package from the npm repository: + + ```sh + npm install sass-parser + ``` + +2. Use the `scss`, `sass`, or `css` [`Syntax` objects] exports to parse a file. + + ```js + const sassParser = require('sass-parser'); + + const root = sassParser.scss.parse(` + @use 'colors'; + + body { + color: colors.$midnight-blue; + } + `); + ``` + +3. Use the standard [PostCSS API] to inspect and edit the stylesheet: + + ```js + const styleRule = root.nodes[1]; + styleRule.selector = '.container'; + + console.log(root.toString()); + // @use 'colors'; + // + // .container { + // color: colors.$midnight-blue; + // } + ``` + +4. Use new PostCSS-style APIs to inspect and edit expressions and Sass-specific + rules: + + ```js + root.nodes[0].namespace = 'c'; + const variable = styleRule.nodes[0].valueExpression; + variable.namespace = 'c'; + + console.log(root.toString()); + // @use 'colors' as c; + // + // .container { + // color: c.$midnight-blue; + // } + ``` + +[`Syntax` objects]: https://postcss.org/api/#syntax +[PostCSS API]: https://postcss.org/api/ + +## Why `sass-parser`? + +We decided to expose [Dart Sass]'s parser as a JS API because we saw two needs +that were going unmet. + +[Dart Sass]: https://sass-lang.com/dart-sass + +First, there was no fully-compatible Sass parser. Although a [`postcss-scss`] +plugin did exist, its author [requested we create this package] to fix +compatibility issues, support [the indented syntax], and provide first-class +support for Sass-specific rules without needing them to be manually parsed by +each user. + +[`postcss-scss`]: https://www.npmjs.com/package/postcss-scss +[requested we create this package]: https://github.com/sass/dart-sass/issues/88#issuecomment-270069138 +[the indented syntax]: https://sass-lang.com/documentation/syntax/#the-indented-syntax + +Moreover, there was no robust solution for parsing the expressions that are used +as the values of CSS declarations (as well as Sass variable values). This was +true even for plain CSS, and doubly problematic for Sass's particularly complex +expression syntax. The [`postcss-value-parser`] package hasn't been updated +since 2021, the [`postcss-values-parser`] since January 2022, and even the +excellent [`@csstools/css-parser-algorithms`] had limited PostCSS integration +and no intention of ever supporting Sass. + +[`postcss-value-parser`]: https://www.npmjs.com/package/postcss-value-parser +[`postcss-values-parser`]: https://www.npmjs.com/package/postcss-values-parser +[`@csstools/css-parser-algorithms`]: https://www.npmjs.com/package/@csstools/css-parser-algorithms + +The `sass-parser` package intends to solve these problems by providing a parser +that's battle-tested by millions of Sass users and flexible enough to support +use-cases that don't involve Sass at all. We intend it to be usable as a drop-in +replacement for the standard PostCSS parser, and for the new expression-level +APIs to feel highly familiar to anyone used to PostCSS. + +## API Documentation + +The source code is fully documented using [TypeDoc]. Hosted, formatted +documentation will be coming soon. + +[TypeDoc]: https://typedoc.org + +## PostCSS Compatibility + +[PostCSS] is the most popular and long-lived CSS post-processing framework in +the world, and this package aims to be fully compatible with its API. Where we +add new features, we do so in a way that's as similar to PostCSS as possible, +re-using its types and even implementation wherever possible. + +### Statement API + +All statement-level [AST] nodes produced by `sass-parser`—style rules, at-rules, +declarations, statement-level comments, and the root node—extend the +corresponding PostCSS node types ([`Rule`], [`AtRule`], [`Declaration`], +[`Comment`], and [`Root`]). However, `sass-parser` has multiple subclasses for +many of its PostCSS superclasses. For example, `sassParser.PropertyDeclaration` +extends `postcss.Declaration`, but so does `sassParser.VariableDeclaration`. The +different `sass-parser` node types may be distinguished using the +`sassParser.Node.sassType` field. + +[AST]: https://en.wikipedia.org/wiki/Abstract_syntax_tree +[`Rule`]: https://postcss.org/api/#rule +[`AtRule`]: https://postcss.org/api/#atrule +[`Declaration`]: https://postcss.org/api/#declaration +[`Comment`]: https://postcss.org/api/#comment +[`Root`]: https://postcss.org/api/#root + +In addition to supporting the standard PostCSS properties like +`Declaration.value` and `Rule.selector`, `sass-parser` provides more detailed +parsed values. For example, `sassParser.Declaration.valueExpression` provides +the declaration's value as a fully-parsed syntax tree rather than a string, and +`sassParser.Rule.selectorInterpolation` provides access to any interpolated +expressions as in `.prefix-#{$variable} { /*...*/ }`. These parsed values are +automatically kept up-to-date with the standard PostCSS properties. + +### Expression API + +The expression-level AST nodes inherit from PostCSS's [`Node`] class but not any +of its more specific nodes. Nor do expressions support all the PostCSS `Node` +APIs: unlike statements, expressions that contain other expressions don't always +contain them as a clearly-ordered list, so methods like `Node.before()` and +`Node.next` aren't available. Just like with `sass-parser` statements, you can +distinguish between expressions using the `sassType` field. + +[`Node`]: https://postcss.org/api/#node + +Just like standard PostCSS nodes, expression nodes can be modified in-place and +these modifications will be reflected in the CSS output. Each expression type +has its own specific set of properties which can be read about in the expression +documentation. + +### Constructing New Nodes + +All Sass nodes, whether expressions, statements, or miscellaneous nodes like +`Interpolation`s, can be constructed as standard JavaScript objects: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append(new sassParser.Declaration({ + prop: 'content', + valueExpression: new sassParser.StringExpression({ + quotes: true, + text: new sassParser.Interpolation({ + nodes: ["hello, world!"], + }), + }), +})); +``` + +However, the same shorthands can be used as when adding new nodes in standard +PostCSS, as well as a few new ones. Anything that takes an `Interpolation` can +be passed a string instead to represent plain text with no Sass expressions: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append(new sassParser.Declaration({ + prop: 'content', + valueExpression: new sassParser.StringExpression({ + quotes: true, + text: "hello, world!", + }), +})); +``` + +Because the mandatory properties for all node types are unambiguous, you can +leave out the `new ...()` call and just pass the properties directly: + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append({ + prop: 'content', + valueExpression: {quotes: true, text: "hello, world!"}, +}); +``` + +You can even pass a string in place of a statement and PostCSS will parse it for +you! **Warning:** This currently uses the standard PostCSS parser, not the Sass +parser, and as such it does not support Sass-specific constructs. + +```js +const sassParser = require('sass-parser'); + +const root = new sassParser.Root(); +root.append('content: "hello, world!"'); +``` + +### Known Incompatibilities + +There are a few cases where an operation that's valid in PostCSS won't work with +`sass-parser`: + +* Trying to convert a Sass-specific at-rule like `@if` or `@mixin` into a + different at-rule by changing its name is not supported. + +* Trying to add child nodes to a Sass statement that doesn't support children + like `@use` or `@error` is not supported. + +## Contributing + +Before sending out a pull request, please run the following commands from the +`pkg/sass-parser` directory: + +* `npm run check` - Runs `eslint`, and then tries to compile the package with + `tsc`. + +* `npm run test` - Runs all the tests in the package. + +Note: You should run `dart run grinder before-test` from the `dart-sass` +directory beforehand to ensure you're running `sass-parser` against the latest +version of `dart-sass` JavaScript API. diff --git a/pkg/sass-parser/jest.config.ts b/pkg/sass-parser/jest.config.ts new file mode 100644 index 000000000..d7cc13f80 --- /dev/null +++ b/pkg/sass-parser/jest.config.ts @@ -0,0 +1,9 @@ +const config = { + preset: 'ts-jest', + roots: ['lib'], + testEnvironment: 'node', + setupFilesAfterEnv: ['jest-extended/all', '/test/setup.ts'], + verbose: false, +}; + +export default config; diff --git a/pkg/sass-parser/lib/.npmignore b/pkg/sass-parser/lib/.npmignore new file mode 100644 index 000000000..b896f526a --- /dev/null +++ b/pkg/sass-parser/lib/.npmignore @@ -0,0 +1 @@ +*.test.ts diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts new file mode 100644 index 000000000..99dfa5283 --- /dev/null +++ b/pkg/sass-parser/lib/index.ts @@ -0,0 +1,161 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Root} from './src/statement/root'; +import * as sassInternal from './src/sass-internal'; +import {Stringifier} from './src/stringifier'; + +export { + Configuration, + ConfigurationProps, + ConfigurationRaws, +} from './src/configuration'; +export { + ConfiguredVariable, + ConfiguredVariableObjectProps, + ConfiguredVariableExpressionProps, + ConfiguredVariableProps, + ConfiguredVariableRaws, +} from './src/configured-variable'; +export {AnyNode, Node, NodeProps, NodeType} from './src/node'; +export {RawWithValue} from './src/raw-with-value'; +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 { + BooleanExpression, + BooleanExpressionProps, + BooleanExpressionRaws, +} from './src/expression/boolean'; +export { + NumberExpression, + NumberExpressionProps, + NumberExpressionRaws, +} from './src/expression/number'; +export { + Interpolation, + InterpolationProps, + InterpolationRaws, + NewNodeForInterpolation, +} from './src/interpolation'; +export { + CssComment, + CssCommentProps, + CssCommentRaws, +} from './src/statement/css-comment'; +export { + DebugRule, + DebugRuleProps, + DebugRuleRaws, +} from './src/statement/debug-rule'; +export {EachRule, EachRuleProps, EachRuleRaws} from './src/statement/each-rule'; +export { + ErrorRule, + ErrorRuleProps, + ErrorRuleRaws, +} from './src/statement/error-rule'; +export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule'; +export { + ForwardMemberList, + ForwardMemberProps, + ForwardRule, + ForwardRuleProps, + ForwardRuleRaws, +} from './src/statement/forward-rule'; +export { + GenericAtRule, + GenericAtRuleProps, + GenericAtRuleRaws, +} from './src/statement/generic-at-rule'; +export {Root, RootProps, RootRaws} from './src/statement/root'; +export {Rule, RuleProps, RuleRaws} from './src/statement/rule'; +export { + SassComment, + SassCommentProps, + SassCommentRaws, +} from './src/statement/sass-comment'; +export {UseRule, UseRuleProps, UseRuleRaws} from './src/statement/use-rule'; +export { + AnyStatement, + AtRule, + ChildNode, + ChildProps, + ContainerProps, + NewNode, + Statement, + StatementType, + StatementWithChildren, +} from './src/statement'; +export { + VariableDeclaration, + VariableDeclarationProps, + VariableDeclarationRaws, +} from './src/statement/variable-declaration'; +export {WarnRule, WarnRuleProps, WarnRuleRaws} from './src/statement/warn-rule'; +export { + WhileRule, + WhileRuleProps, + WhileRuleRaws, +} from './src/statement/while-rule'; + +/** Options that can be passed to the Sass parsers to control their behavior. */ +export type SassParserOptions = Pick; + +/** 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), + ); + } + + 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__/configured-variable.test.ts.snap b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap new file mode 100644 index 000000000..2b5609937 --- /dev/null +++ b/pkg/sass-parser/lib/src/__snapshots__/configured-variable.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a configured variable toJSON 1`] = ` +{ + "expression": <"qux">, + "guarded": false, + "inputs": [ + { + "css": "@use "foo" with ($baz: "qux")", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "configured-variable", + "source": <1:18-1:29 in 0>, + "variableName": "baz", +} +`; 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/configuration.test.ts b/pkg/sass-parser/lib/src/configuration.test.ts new file mode 100644 index 000000000..e0f88aa6d --- /dev/null +++ b/pkg/sass-parser/lib/src/configuration.test.ts @@ -0,0 +1,428 @@ +// 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 { + Configuration, + ConfiguredVariable, + StringExpression, + UseRule, + sass, + scss, +} from '..'; + +describe('a configuration map', () => { + let node: Configuration; + beforeEach(() => (node = new Configuration())); + + describe('empty', () => { + function describeNode( + description: string, + create: () => Configuration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configuration')); + + it('has no contents', () => { + expect(node.size).toBe(0); + expect([...node.variables()]).toEqual([]); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => (scss.parse('@use "foo"').nodes[0] as UseRule).configuration, + ); + + describeNode( + 'parsed as Sass', + () => (sass.parse('@use "foo"').nodes[0] as UseRule).configuration, + ); + + describe('constructed manually', () => { + describeNode('no args', () => new Configuration()); + + describeNode('variables array', () => new Configuration({variables: []})); + + describeNode( + 'variables record', + () => new Configuration({variables: {}}), + ); + }); + + describeNode( + 'constructed from props', + () => + new UseRule({useUrl: 'foo', configuration: {variables: []}}) + .configuration, + ); + }); + + describe('with a variable', () => { + function describeNode( + description: string, + create: () => Configuration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configuration')); + + it('contains the variable', () => { + expect(node.size).toBe(1); + const variable = [...node.variables()][0]; + expect(variable.variableName).toEqual('bar'); + expect(variable).toHaveStringExpression('expression', 'baz'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + (scss.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule) + .configuration, + ); + + describeNode( + 'parsed as Sass', + () => + (sass.parse('@use "foo" with ($bar: "baz")').nodes[0] as UseRule) + .configuration, + ); + + describe('constructed manually', () => { + describeNode( + 'variables array', + () => + new Configuration({ + variables: [ + {variableName: 'bar', expression: {text: 'baz', quotes: true}}, + ], + }), + ); + + describeNode( + 'variables record', + () => + new Configuration({variables: {bar: {text: 'baz', quotes: true}}}), + ); + }); + + describeNode( + 'constructed from props', + () => + new UseRule({ + useUrl: 'foo', + configuration: {variables: {bar: {text: 'baz', quotes: true}}}, + }).configuration, + ); + }); + + describe('add()', () => { + test('with a ConfiguredVariable', () => { + const variable = new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }); + expect(node.add(variable)).toBe(node); + expect(node.size).toBe(1); + expect([...node.variables()][0]).toBe(variable); + expect(variable.parent).toBe(node); + }); + + test('with a ConfiguredVariableProps', () => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + expect(node.size).toBe(1); + const variable = node.get('foo'); + expect(variable?.variableName).toBe('foo'); + expect(variable).toHaveStringExpression('expression', 'bar'); + expect(variable?.parent).toBe(node); + }); + + test('overwrites on old variable', () => { + node.add({variableName: 'foo', expression: {text: 'old', quotes: true}}); + const old = node.get('foo'); + expect(old?.parent).toBe(node); + + node.add({variableName: 'foo', expression: {text: 'new', quotes: true}}); + expect(node.size).toBe(1); + expect(old?.parent).toBeUndefined(); + expect(node.get('foo')).toHaveStringExpression('expression', 'new'); + }); + }); + + test('clear() removes all variables', () => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + const foo = node.get('foo'); + const bar = node.get('bar'); + node.clear(); + + expect(node.size).toBe(0); + expect([...node.variables()]).toEqual([]); + expect(foo?.parent).toBeUndefined(); + expect(bar?.parent).toBeUndefined(); + }); + + describe('delete()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + node.add({variableName: 'baz', expression: {text: 'bang', quotes: true}}); + }); + + test('removes a matching variable', () => { + const foo = node.get('foo'); + expect(node.delete('foo')).toBe(true); + expect(foo?.parent).toBeUndefined(); + expect(node.size).toBe(1); + expect(node.get('foo')).toBeUndefined(); + }); + + test("doesn't remove a non-matching variable", () => { + expect(node.delete('bang')).toBe(false); + expect(node.size).toBe(2); + }); + }); + + describe('get()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + test('returns a variable in the configuration', () => { + const variable = node.get('foo'); + expect(variable?.variableName).toBe('foo'); + expect(variable).toHaveStringExpression('expression', 'bar'); + }); + + test('returns undefined for a variable not in the configuration', () => { + expect(node.get('bar')).toBeUndefined(); + }); + }); + + describe('has()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + test('returns true for a variable in the configuration', () => + expect(node.has('foo')).toBe(true)); + + test('returns false for a variable not in the configuration', () => + expect(node.has('bar')).toBe(false)); + }); + + describe('set()', () => { + beforeEach(() => { + node.add({variableName: 'foo', expression: {text: 'bar', quotes: true}}); + }); + + describe('adds a new variable', () => { + function describeVariable( + description: string, + create: () => Configuration, + ): void { + it(description, () => { + expect(create()).toBe(node); + expect(node.size).toBe(2); + const variable = node.get('baz'); + expect(variable?.parent).toBe(node); + expect(variable?.variableName).toBe('baz'); + expect(variable).toHaveStringExpression('expression', 'bang'); + }); + } + + describeVariable('with Expression', () => + node.set('baz', new StringExpression({text: 'bang', quotes: true})), + ); + + describeVariable('with ExpressionProps', () => + node.set('baz', {text: 'bang', quotes: true}), + ); + + describeVariable('with ConfiguredVariableObjectProps', () => + node.set('baz', {expression: {text: 'bang', quotes: true}}), + ); + }); + + test('overwrites an existing variable', () => { + const foo = node.get('foo'); + node.set('foo', {text: 'bang', quotes: true}); + expect(foo?.parent).toBeUndefined(); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('empty', () => expect(new Configuration().toString()).toBe('()')); + + it('with variables', () => + expect( + new Configuration({ + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang")')); + }); + + it('with comma: true', () => + expect( + new Configuration({ + raws: {comma: true}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang",)')); + + it('with comma: true and afterValue', () => + expect( + new Configuration({ + raws: {comma: true}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: '/**/'}, + }, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang"/**/,)')); + + it('with after', () => + expect( + new Configuration({ + raws: {after: '/**/'}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: {text: 'bang', quotes: true}, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang"/**/)')); + + it('with after and afterValue', () => + expect( + new Configuration({ + raws: {after: '/**/'}, + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: ' '}, + }, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang" /**/)')); + + it('with afterValue and a guard', () => + expect( + new Configuration({ + variables: { + foo: {text: 'bar', quotes: true}, + baz: { + expression: {text: 'bang', quotes: true}, + raws: {afterValue: '/**/'}, + guarded: true, + }, + }, + }).toString(), + ).toBe('($foo: "bar", $baz: "bang" !default/**/)')); + }); + }); + + describe('clone', () => { + let original: Configuration; + beforeEach(() => { + original = ( + scss.parse('@use "foo" with ($foo: "bar", $baz: "bang")') + .nodes[0] as UseRule + ).configuration; + // TODO: remove this once raws are properly parsed + original.raws.after = ' '; + }); + + describe('with no overrides', () => { + let clone: Configuration; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('variables', () => { + expect(clone.size).toBe(2); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'bar'); + expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.parent).toBe(clone); + expect(variables[1]).toHaveStringExpression('expression', 'bang'); + }); + + it('raws', () => expect(clone.raws.after).toBe(' ')); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {comma: true}}).raws).toEqual({ + comma: true, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + after: ' ', + })); + }); + + describe('variables', () => { + it('defined', () => { + const clone = original.clone({ + variables: {zip: {text: 'zap', quotes: true}}, + }); + expect(clone.size).toBe(1); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('zip'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('undefined', () => { + const clone = original.clone({variables: undefined}); + expect(clone.size).toBe(2); + const variables = [...clone.variables()]; + expect(variables[0]?.variableName).toBe('foo'); + expect(variables[0]?.parent).toBe(clone); + expect(variables[0]).toHaveStringExpression('expression', 'bar'); + expect(variables[1]?.variableName).toBe('baz'); + expect(variables[1]?.parent).toBe(clone); + expect(variables[1]).toHaveStringExpression('expression', 'bang'); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + (scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule) + .configuration, + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts new file mode 100644 index 000000000..e9c025563 --- /dev/null +++ b/pkg/sass-parser/lib/src/configuration.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 * as postcss from 'postcss'; + +import { + ConfiguredVariable, + ConfiguredVariableExpressionProps, + ConfiguredVariableProps, +} from './configured-variable'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import type * as sassInternal from './sass-internal'; +import * as utils from './utils'; +import {ForwardRule} from './statement/forward-rule'; +import {UseRule} from './statement/use-rule'; + +/** + * The set of raws supported by {@link Configuration}. + * + * @category Statement + */ +export interface ConfigurationRaws { + /** Whether the final variable has a trailing comma. */ + comma?: boolean; + + /** + * The whitespace between the final variable (or its trailing comma if it has + * one) and the closing parenthesis. + */ + after?: string; +} + +/** + * The initializer properties for {@link Configuration}. + * + * @category Statement + */ +export interface ConfigurationProps { + raws?: ConfigurationRaws; + variables: + | Record + | Array; +} + +/** + * A configuration map for a `@use` or `@forward` rule. + * + * @category Statement + */ +export class Configuration extends Node { + readonly sassType = 'configuration' as const; + declare raws: ConfigurationRaws; + declare parent: ForwardRule | UseRule | undefined; + + /** The underlying map from variable names to their values. */ + private _variables: Map = new Map(); + + /** The number of variables in this configuration. */ + get size(): number { + return this._variables.size; + } + + constructor(defaults?: ConfigurationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ConfiguredVariable[]); + constructor( + defaults?: ConfigurationProps, + inner?: sassInternal.ConfiguredVariable[], + ) { + super({}); + this.raws = defaults?.raws ?? {}; + + if (defaults) { + for (const variable of Array.isArray(defaults.variables) + ? defaults.variables + : Object.entries(defaults.variables)) { + this.add(variable); + } + } else if (inner) { + this.source = new LazySource({ + get span(): sassInternal.FileSpan { + // TODO: expand inner[0] and inner.at(-1) out through `(` and `)` + // respectively and then combine them. + throw new Error('currently unsupported'); + }, + }); + for (const variable of inner) { + this.add(new ConfiguredVariable(undefined, variable)); + } + } + } + + /** + * Adds {@link variable} to this configuration. + * + * If there's already a variable with that name, it's removed first. + */ + add(variable: ConfiguredVariable | ConfiguredVariableProps): this { + const realVariable = + 'sassType' in variable ? variable : new ConfiguredVariable(variable); + realVariable.parent = this; + const old = this._variables.get(realVariable.variableName); + if (old) old.parent = undefined; + this._variables.set(realVariable.variableName, realVariable); + return this; + } + + /** Removes all variables from this configuration. */ + clear(): void { + for (const variable of this._variables.values()) { + variable.parent = undefined; + } + this._variables.clear(); + } + + /** + * Removes the variable named {@link name} from this configuration. + * + * Returns whether the variable was removed. + */ + delete(key: string): boolean { + const old = this._variables.get(key); + if (old) old.parent = undefined; + return this._variables.delete(key); + } + + /** + * Returns the variable named {@link name} from this configuration if it + * contains one. + */ + get(key: string): ConfiguredVariable | undefined { + return this._variables.get(key); + } + + /** + * Returns whether this configuration has a variable named {@link name}. + */ + has(key: string): boolean { + return this._variables.has(key); + } + + /** + * Sets the variable named {@link key}. This fully overrides the previous + * value, so all previous raws and guarded state are discarded. + */ + set(key: string, expression: ConfiguredVariableExpressionProps): this { + const variable = new ConfiguredVariable([key, expression]); + variable.parent = this; + const old = this._variables.get(key); + if (old) old.parent = undefined; + this._variables.set(key, variable); + return this; + } + + /** Returns all the variables in this configuration. */ + variables(): IterableIterator { + return this._variables.values(); + } + + clone(overrides?: Partial): Configuration { + // We can't use `utils.cloneNode` here because variables isn't a public + // field. Fortunately this class doesn't have any settable derived fields to + // make cloning more complicated. + return new Configuration({ + raws: overrides?.raws ?? structuredClone(this.raws), + variables: overrides?.variables ?? [...this._variables.values()], + }); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['variables'], inputs); + } + + /** @hidden */ + toString(): string { + let result = '('; + let first = true; + for (const variable of this._variables.values()) { + if (first) { + result += variable.raws.before ?? ''; + first = false; + } else { + result += ','; + result += variable.raws.before ?? ' '; + } + result += variable.toString(); + result += variable.raws.afterValue ?? ''; + } + return result + `${this.raws.comma ? ',' : ''}${this.raws.after ?? ''})`; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [...this.variables()]; + } +} diff --git a/pkg/sass-parser/lib/src/configured-variable.test.ts b/pkg/sass-parser/lib/src/configured-variable.test.ts new file mode 100644 index 000000000..c673670a8 --- /dev/null +++ b/pkg/sass-parser/lib/src/configured-variable.test.ts @@ -0,0 +1,409 @@ +// 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 {ConfiguredVariable, StringExpression, UseRule, sass, scss} from '..'; + +describe('a configured variable', () => { + let node: ConfiguredVariable; + beforeEach( + () => + void (node = new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + })), + ); + + describe('unguarded', () => { + function describeNode( + description: string, + create: () => ConfiguredVariable, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configured-variable')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has a value', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it("isn't guarded", () => expect(node.guarded).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => + ( + scss.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')!, + ); + + describeNode( + 'parsed as Sass', + () => + ( + sass.parse('@use "baz" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')!, + ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an Expression', + () => + new ConfiguredVariable([ + 'foo', + new StringExpression({text: 'bar', quotes: true}), + ]), + ); + + describeNode( + 'with ExpressionProps', + () => new ConfiguredVariable(['foo', {text: 'bar', quotes: true}]), + ); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable([ + 'foo', + {expression: new StringExpression({text: 'bar', quotes: true})}, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable([ + 'foo', + {expression: {text: 'bar', quotes: true}}, + ]), + ); + }); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + }), + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }), + ); + }); + }); + }); + + describe('guarded', () => { + function describeNode( + description: string, + create: () => ConfiguredVariable, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('configured-variable')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has a value', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('is guarded', () => expect(node.guarded).toBe(true)); + }); + } + + // We can re-enable these once ForwardRule exists. + // describeNode( + // 'parsed as SCSS', + // () => + // ( + // scss.parse('@forward "baz" with ($foo: "bar" !default)') + // .nodes[0] as ForwardRule + // ).configuration.get('foo')! + // ); + // + // describeNode( + // 'parsed as Sass', + // () => + // ( + // sass.parse('@forward "baz" with ($foo: "bar" !default)') + // .nodes[0] as ForwardRule + // ).configuration.get('foo')! + // ); + + describe('constructed manually', () => { + describe('with an array', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable([ + 'foo', + { + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }, + ]), + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable([ + 'foo', + {expression: {text: 'bar', quotes: true}, guarded: true}, + ]), + ); + }); + + describe('with an object', () => { + describeNode( + 'with an expression', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }), + ); + + describeNode( + 'with ExpressionProps', + () => + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }), + ); + }); + }); + }); + + it('assigned a new variableName', () => { + node.variableName = 'baz'; + expect(node.variableName).toBe('baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz', quotes: true}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a new guarded', () => { + node.guarded = true; + expect(node.guarded).toBe(true); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('unguarded', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }).toString(), + ).toBe('$foo: "bar"')); + + it('guarded', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }).toString(), + ).toBe('$foo: "bar" !default')); + + it('with a non-identifier name', () => + expect( + new ConfiguredVariable({ + variableName: 'f o', + expression: {text: 'bar', quotes: true}, + }).toString(), + ).toBe('$f\\20o: "bar"')); + }); + + // raws.before is only used as part of a Configuration + it('ignores before', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {before: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"')); + + it('with matching name', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {variableName: {raw: 'f\\6fo', value: 'foo'}}, + }).toString(), + ).toBe('$f\\6fo: "bar"')); + + it('with non-matching name', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {variableName: {raw: 'f\\41o', value: 'fao'}}, + }).toString(), + ).toBe('$foo: "bar"')); + + it('with between', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {between: ' : '}, + }).toString(), + ).toBe('$foo : "bar"')); + + it('with beforeGuard and a guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + raws: {beforeGuard: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"/**/!default')); + + it('with beforeGuard and no guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {beforeGuard: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"')); + + // raws.before is only used as part of a Configuration + describe('ignores afterValue', () => { + it('with no guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('$foo: "bar"')); + + it('with a guard', () => + expect( + new ConfiguredVariable({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('$foo: "bar" !default')); + }); + }); + }); + + describe('clone()', () => { + let original: ConfiguredVariable; + beforeEach(() => { + original = ( + scss.parse('@use "foo" with ($foo: "bar")').nodes[0] as UseRule + ).configuration.get('foo')!; + original.raws.between = ' : '; + }); + + describe('with no overrides', () => { + let clone: ConfiguredVariable; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('variableName', () => expect(clone.variableName).toBe('foo')); + + it('expression', () => + expect(clone).toHaveStringExpression('expression', 'bar')); + + it('guarded', () => expect(clone.guarded).toBe(false)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['expression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {before: ' '}}).raws).toEqual({ + before: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' : ', + })); + }); + + describe('variableName', () => { + it('defined', () => + expect(original.clone({variableName: 'baz'}).variableName).toBe( + 'baz', + )); + + it('undefined', () => + expect(original.clone({variableName: undefined}).variableName).toBe( + 'foo', + )); + }); + + describe('expression', () => { + it('defined', () => + expect( + original.clone({expression: {text: 'baz', quotes: true}}), + ).toHaveStringExpression('expression', 'baz')); + + it('undefined', () => + expect( + original.clone({expression: undefined}), + ).toHaveStringExpression('expression', 'bar')); + }); + + describe('guarded', () => { + it('defined', () => + expect(original.clone({guarded: true}).guarded).toBe(true)); + + it('undefined', () => + expect(original.clone({guarded: undefined}).guarded).toBe(false)); + }); + }); + }); + + it('toJSON', () => + expect( + ( + scss.parse('@use "foo" with ($baz: "qux")').nodes[0] as UseRule + ).configuration.get('baz'), + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/configured-variable.ts b/pkg/sass-parser/lib/src/configured-variable.ts new file mode 100644 index 000000000..17dcea6f7 --- /dev/null +++ b/pkg/sass-parser/lib/src/configured-variable.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 {Configuration} from './configuration'; +import {convertExpression} from './expression/convert'; +import {Expression, ExpressionProps} from './expression'; +import {fromProps} from './expression/from-props'; +import {LazySource} from './lazy-source'; +import {Node} from './node'; +import * as sassInternal from './sass-internal'; +import {RawWithValue} from './raw-with-value'; +import * as utils from './utils'; + +/** + * The set of raws supported by {@link ConfiguredVariable}. + * + * @category Statement + */ +export interface ConfiguredVariableRaws { + /** The whitespace before the variable name. */ + before?: string; + + /** + * The variable's name, not including the `$`. + * + * This may be different than {@link ConfiguredVariable.variable} if the name + * contains escape codes or underscores. + */ + variableName?: RawWithValue; + + /** The whitespace and colon between the variable name and value. */ + between?: string; + + /** + * The whitespace between the variable's value and the `!default` flag. If the + * variable doesn't have a `!default` flag, this is ignored. + */ + beforeGuard?: string; + + /** + * The space symbols between the end of the variable declaration and the comma + * afterwards. Always empty for a variable that doesn't have a trailing comma. + */ + afterValue?: string; +} + +/** + * The initializer properties for {@link ConfiguredVariable} passed as an + * options object. + * + * @category Statement + */ +export interface ConfiguredVariableObjectProps { + raws?: ConfiguredVariableRaws; + variableName: string; + expression: Expression | ExpressionProps; + guarded?: boolean; +} + +/** + * Properties used to initialize a {@link ConfiguredVariable} without an + * explicit name. This is used when the name is given elsewhere, either in the + * array form of {@link ConfiguredVariableProps} or the record form of [@link + * ConfigurationProps}. + * + * Passing in an {@link Expression} or {@link ExpressionProps} directly always + * creates an unguarded {@link ConfiguredVariable}. + */ +export type ConfiguredVariableExpressionProps = + | Expression + | ExpressionProps + | Omit; + +/** + * The initializer properties for {@link ConfiguredVariable}. + * + * @category Statement + */ +export type ConfiguredVariableProps = + | ConfiguredVariableObjectProps + | [string, ConfiguredVariableExpressionProps]; + +/** + * A single variable configured for the `with` clause of a `@use` or `@forward` + * rule. This is always included in a {@link Configuration}. + * + * @category Statement + */ +export class ConfiguredVariable extends Node { + readonly sassType = 'configured-variable' as const; + declare raws: ConfiguredVariableRaws; + declare parent: Configuration | undefined; + + /** + * The variable name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + variableName!: string; + + /** The expresison whose value the variable is assigned. */ + get expression(): Expression { + return this._expression!; + } + set expression(value: Expression | ExpressionProps) { + if (this._expression) this._expression.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._expression = value; + } + private _expression!: Expression; + + /** Whether this has a `!default` guard. */ + guarded!: boolean; + + constructor(defaults: ConfiguredVariableProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ConfiguredVariable); + constructor( + defaults?: ConfiguredVariableProps, + inner?: sassInternal.ConfiguredVariable, + ) { + if (Array.isArray(defaults!)) { + const [variableName, rest] = defaults; + if ('sassType' in rest || !('expression' in rest)) { + defaults = { + variableName, + expression: rest as Expression | ExpressionProps, + }; + } else { + defaults = {variableName, ...rest}; + } + } + super(defaults); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.variableName = inner.name; + this.expression = convertExpression(inner.expression); + this.guarded = inner.isGuarded; + } else { + this.guarded ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variableName', + 'expression', + 'guarded', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['variableName', 'expression', 'guarded'], + inputs, + ); + } + + /** @hidden */ + toString(): string { + return ( + '$' + + (this.raws.variableName?.value === this.variableName + ? this.raws.variableName.raw + : sassInternal.toCssIdentifier(this.variableName)) + + (this.raws.between ?? ': ') + + this.expression + + (this.guarded ? `${this.raws.beforeGuard ?? ' '}!default` : '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.expression]; + } +} 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__/boolean.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/boolean.test.ts.snap new file mode 100644 index 000000000..1b68fedcf --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/boolean.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a boolean expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{true}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "boolean", + "source": <1:4-1:8 in 0>, + "value": true, +} +`; diff --git a/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap new file mode 100644 index 000000000..6af882031 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/__snapshots__/number.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a number expression toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@#{123%}", + "hasBOM": false, + "id": "", + }, + ], + "raws": {}, + "sassType": "number", + "source": <1:4-1:8 in 0>, + "unit": "%", + "value": 123, +} +`; 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..cd9d06d74 --- /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..e6f180521 --- /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/boolean.test.ts b/pkg/sass-parser/lib/src/expression/boolean.test.ts new file mode 100644 index 000000000..9322e257d --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/boolean.test.ts @@ -0,0 +1,122 @@ +// 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 {BooleanExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a boolean expression', () => { + let node: BooleanExpression; + + describe('true', () => { + function describeNode( + description: string, + create: () => BooleanExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType boolean', () => expect(node.sassType).toBe('boolean')); + + it('is true', () => expect(node.value).toBe(true)); + }); + } + + describeNode('parsed', () => utils.parseExpression('true')); + + describeNode( + 'constructed manually', + () => new BooleanExpression({value: true}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: true}), + ); + }); + + describe('false', () => { + function describeNode( + description: string, + create: () => BooleanExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType boolean', () => expect(node.sassType).toBe('boolean')); + + it('is false', () => expect(node.value).toBe(false)); + }); + } + + describeNode('parsed', () => utils.parseExpression('false')); + + describeNode( + 'constructed manually', + () => new BooleanExpression({value: false}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: false}), + ); + }); + + it('assigned new value', () => { + node = utils.parseExpression('true'); + node.value = false; + expect(node.value).toBe(false); + }); + + describe('stringifies', () => { + it('true', () => { + expect(utils.parseExpression('true').toString()).toBe('true'); + }); + + it('false', () => { + expect(utils.parseExpression('false').toString()).toBe('false'); + }); + }); + + describe('clone', () => { + let original: BooleanExpression; + + beforeEach(() => { + original = utils.parseExpression('true'); + }); + + describe('with no overrides', () => { + let clone: BooleanExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toBe(true)); + + it('raws', () => expect(clone.raws).toEqual({})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + it('creates a new self', () => expect(clone).not.toBe(original)); + }); + + describe('overrides', () => { + describe('value', () => { + it('defined', () => + expect(original.clone({value: false}).value).toBe(false)); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBe(true)); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {}}).raws).toEqual({})); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({})); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('true')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/boolean.ts b/pkg/sass-parser/lib/src/expression/boolean.ts new file mode 100644 index 000000000..a75c567ed --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/boolean.ts @@ -0,0 +1,82 @@ +// 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} from '.'; + +/** + * The initializer properties for {@link BooleanExpression}. + * + * @category Expression + */ +export interface BooleanExpressionProps { + value: boolean; + raws?: BooleanExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link BooleanExpression}. + * + * @category Expression + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface -- No raws for a boolean expression yet. +export interface BooleanExpressionRaws {} + +/** + * An expression representing a boolean literal in Sass. + * + * @category Expression + */ +export class BooleanExpression extends Expression { + readonly sassType = 'boolean' as const; + declare raws: BooleanExpressionRaws; + + /** The boolean value of this expression. */ + get value(): boolean { + return this._value; + } + set value(value: boolean) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private _value!: boolean; + + constructor(defaults: BooleanExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.BooleanExpression); + constructor(defaults?: object, inner?: sassInternal.BooleanExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + } else { + this.value ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'value']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['value'], inputs); + } + + /** @hidden */ + toString(): string { + return this.value ? 'true' : 'false'; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} 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..63619898f --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/convert.ts @@ -0,0 +1,27 @@ +// 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 '.'; +import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; + +/** 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), + visitBooleanExpression: inner => new BooleanExpression(undefined, inner), + visitNumberExpression: inner => new NumberExpression(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..d74450813 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/from-props.ts @@ -0,0 +1,21 @@ +// 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'; +import {BooleanExpression} from './boolean'; +import {NumberExpression} from './number'; + +/** 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); + if ('value' in props) { + if (typeof props.value === 'boolean') return new BooleanExpression(props); + if (typeof props.value === 'number') return new NumberExpression(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..ac1d37671 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/index.ts @@ -0,0 +1,59 @@ +// 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 {BooleanExpression, BooleanExpressionProps} from './boolean'; +import {NumberExpression, NumberExpressionProps} from './number'; +import type {StringExpression, StringExpressionProps} from './string'; + +/** + * The union type of all Sass expressions. + * + * @category Expression + */ +export type AnyExpression = + | BinaryOperationExpression + | StringExpression + | BooleanExpression + | NumberExpression; + +/** + * Sass expression types. + * + * @category Expression + */ +export type ExpressionType = + | 'binary-operation' + | 'string' + | 'boolean' + | 'number'; + +/** + * The union type of all properties that can be used to construct Sass + * expressions. + * + * @category Expression + */ +export type ExpressionProps = + | BinaryOperationExpressionProps + | StringExpressionProps + | BooleanExpressionProps + | NumberExpressionProps; + +/** + * 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/number.test.ts b/pkg/sass-parser/lib/src/expression/number.test.ts new file mode 100644 index 000000000..3e95a2061 --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.test.ts @@ -0,0 +1,197 @@ +// 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 {NumberExpression} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a number expression', () => { + let node: NumberExpression; + + describe('unitless', () => { + function describeNode( + description: string, + create: () => NumberExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has no unit', () => expect(node.unit).toBeNull()); + }); + } + + describeNode('parsed', () => utils.parseExpression('123')); + + describeNode( + 'constructed manually', + () => new NumberExpression({value: 123}), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({value: 123}), + ); + }); + + describe('with a unit', () => { + function describeNode( + description: string, + create: () => NumberExpression, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has sassType number', () => expect(node.sassType).toBe('number')); + + it('is a number', () => expect(node.value).toBe(123)); + + it('has a unit', () => expect(node.unit).toBe('px')); + }); + } + + describeNode('parsed', () => utils.parseExpression('123px')); + + describeNode( + 'constructed manually', + () => + new NumberExpression({ + value: 123, + unit: 'px', + }), + ); + + describeNode('constructed from ExpressionProps', () => + utils.fromExpressionProps({ + value: 123, + unit: 'px', + }), + ); + }); + + describe('floating-point number', () => { + describe('unitless', () => { + beforeEach(() => void (node = utils.parseExpression('3.14'))); + + it('value', () => expect(node.value).toBe(3.14)); + + it('unit', () => expect(node.unit).toBeNull()); + }); + + describe('with a unit', () => { + beforeEach(() => void (node = utils.parseExpression('1.618px'))); + + it('value', () => expect(node.value).toBe(1.618)); + + it('unit', () => expect(node.unit).toBe('px')); + }); + }); + + describe('assigned new', () => { + beforeEach(() => void (node = utils.parseExpression('123'))); + + it('value', () => { + node.value = 456; + expect(node.value).toBe(456); + }); + + it('unit', () => { + node.unit = 'px'; + expect(node.unit).toBe('px'); + }); + }); + + describe('stringifies', () => { + it('unitless', () => + expect(utils.parseExpression('123').toString()).toBe('123')); + + it('with a unit', () => + expect(utils.parseExpression('123px').toString()).toBe('123px')); + + it('floating-point number', () => + expect(utils.parseExpression('3.14').toString()).toBe('3.14')); + + describe('raws', () => { + it('with the same raw value as the expression', () => + expect( + new NumberExpression({ + value: 123, + raws: {value: {raw: 'hello', value: 123}}, + }).toString(), + ).toBe('hello')); + + it('with a different raw value than the expression', () => + expect( + new NumberExpression({ + value: 123, + raws: {value: {raw: 'hello', value: 234}}, + }).toString(), + ).toBe('123')); + }); + }); + + describe('clone', () => { + let original: NumberExpression; + + beforeEach(() => { + original = utils.parseExpression('123'); + // TODO: remove this once raws are properly parsed. + original.raws.value = {raw: '0123.0', value: 123}; + }); + + describe('with no overrides', () => { + let clone: NumberExpression; + + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('value', () => expect(clone.value).toBe(123)); + + it('unit', () => expect(clone.unit).toBeNull()); + + it('raws', () => + expect(clone.raws).toEqual({value: {raw: '0123.0', value: 123}})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + it('creates a new self', () => expect(clone).not.toBe(original)); + }); + + describe('overrides', () => { + describe('value', () => { + it('defined', () => + expect(original.clone({value: 123}).value).toBe(123)); + + it('undefined', () => + expect(original.clone({value: undefined}).value).toBe(123)); + }); + + describe('unit', () => { + it('defined', () => + expect(original.clone({unit: 'px'}).unit).toBe('px')); + + it('undefined', () => + expect(original.clone({unit: undefined}).unit).toBeNull()); + }); + + describe('raws', () => { + it('defined', () => + expect( + original.clone({raws: {value: {raw: '1e3', value: 1e3}}}).raws, + ).toEqual({ + value: {raw: '1e3', value: 1e3}, + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + value: {raw: '0123.0', value: 123}, + })); + }); + }); + }); + + it('toJSON', () => expect(utils.parseExpression('123%')).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/expression/number.ts b/pkg/sass-parser/lib/src/expression/number.ts new file mode 100644 index 000000000..0a5efbebb --- /dev/null +++ b/pkg/sass-parser/lib/src/expression/number.ts @@ -0,0 +1,112 @@ +// 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} from '.'; + +/** + * The initializer properties for {@link NumberExpression}. + * + * @category Expression + */ +export interface NumberExpressionProps { + value: number; + unit?: string; + raws?: NumberExpressionRaws; +} + +/** + * Raws indicating how to precisely serialize a {@link NumberExpression}. + * + * @category Expression + */ +export interface NumberExpressionRaws { + /** + * The raw string representation of the number. + * + * Numbers can be represented with or without leading and trailing zeroes, and + * use scientific notation. For example, the following number representations + * have the same value: `1e3`, `1000`, `01000.0`. + */ + // TODO: Replace with RawWithValue when #2389 lands. + value?: {raw: string; value: number}; +} + +/** + * An expression representing a number literal in Sass. + * + * @category Expression + */ +export class NumberExpression extends Expression { + readonly sassType = 'number' as const; + declare raws: NumberExpressionRaws; + + /** The numeric value of this expression. */ + get value(): number { + return this._value; + } + set value(value: number) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._value = value; + } + private _value!: number; + + /** The denominator units of this number. */ + get unit(): string | null { + return this._unit; + } + set unit(unit: string | null) { + // TODO - postcss/postcss#1957: Mark this as dirty + this._unit = unit; + } + private _unit!: string | null; + + /** Whether the number is unitless. */ + isUnitless(): boolean { + return this.unit === null; + } + + constructor(defaults: NumberExpressionProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.NumberExpression); + constructor(defaults?: object, inner?: sassInternal.NumberExpression) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + this.value = inner.value; + this.unit = inner.unit; + } else { + this.value ??= 0; + this.unit ??= null; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'value', 'unit']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['value', 'unit'], inputs); + } + + /** @hidden */ + toString(): string { + if (this.raws?.value?.value === this.value) { + return this.raws.value.raw + (this.unit ?? ''); + } + return this.value + (this.unit ?? ''); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} 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..39dae45d8 --- /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..e1638da62 --- /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..fd0b81e29 --- /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..c051decc8 --- /dev/null +++ b/pkg/sass-parser/lib/src/interpolation.ts @@ -0,0 +1,419 @@ +// 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 {RawWithValue} from './raw-with-value'; +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. + * + * Any indices for which {@link Interpolation.nodes} doesn't contain a string + * are ignored. + */ + text?: Array | undefined>; + + /** + * The whitespace before and after each interpolated expression. + * + * Any indices for which {@link Interpolation.nodes} doesn't contain an + * expression are ignored. + */ + expressions?: Array<{before?: string; after?: string} | undefined>; +} + +// Note: unlike the Dart Sass interpolation class, this does *not* guarantee +// that there will be no adjacent strings. Doing so for user modification would +// cause any active iterators to skip the merged string, and the collapsing +// doesn't provide a tremendous amount of user benefit. + +/** + * Sass text that can contian expressions interpolated within it. + * + * This is not itself an expression. Instead, it's used as a field of + * expressions and statements, and acts as a container for further expressions. + * + * @category Expression + */ +export class Interpolation extends Node { + readonly sassType = 'interpolation' as const; + declare raws: InterpolationRaws; + + /** + * An array containing the contents of the interpolation. + * + * Strings in this array represent the raw text in which interpolation (might) + * appear, and expressions represent the interpolated Sass expressions. + * + * This shouldn't be modified directly; instead, the various methods defined + * in {@link Interpolation} should be used to modify it. + */ + get nodes(): ReadonlyArray { + return this._nodes!; + } + /** @hidden */ + set nodes(nodes: Array) { + // This *should* only ever be called by the superclass constructor. + this._nodes = nodes; + } + private _nodes?: Array; + + /** Returns whether this contains no interpolated expressions. */ + get isPlain(): boolean { + return this.asPlain !== null; + } + + /** + * If this contains no interpolated expressions, returns its text contents. + * Otherwise, returns `null`. + */ + get asPlain(): string | null { + if (this.nodes.length === 0) return ''; + if (this.nodes.some(node => typeof node !== 'string')) return null; + return this.nodes.join(''); + } + + /** + * Iterators that are currently active within this interpolation. Their + * indices refer to the last position that has already been sent to the + * callback, and are updated when {@link _nodes} is modified. + */ + readonly #iterators: Array<{index: number}> = []; + + constructor(defaults?: InterpolationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.Interpolation); + constructor(defaults?: object, inner?: sassInternal.Interpolation) { + super(defaults); + if (inner) { + this.source = new LazySource(inner); + // TODO: set lazy raws here to use when stringifying + this._nodes = []; + for (const child of inner.contents) { + this.append( + typeof child === 'string' ? child : convertExpression(child), + ); + } + } + if (this._nodes === undefined) this._nodes = []; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['nodes', 'raws']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['nodes'], inputs); + } + + /** + * Inserts new nodes at the end of this interpolation. + * + * Note: unlike PostCSS's [`Container.append()`], this treats strings as raw + * text rather than parsing them into new nodes. + * + * [`Container.append()`]: https://postcss.org/api/#container-append + */ + append(...nodes: NewNodeForInterpolation[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + this._nodes!.push(...this._normalizeList(nodes)); + return this; + } + + /** + * Iterates through {@link nodes}, calling `callback` for each child. + * + * Returning `false` in the callback will break iteration. + * + * Unlike a `for` loop or `Array#forEach`, this iterator is safe to use while + * modifying the interpolation's children. + * + * @param callback The iterator callback, which is passed each child + * @return Returns `false` if any call to `callback` returned false + */ + each( + callback: (node: string | Expression, index: number) => false | void, + ): false | undefined { + const iterator = {index: 0}; + this.#iterators.push(iterator); + + try { + while (iterator.index < this.nodes.length) { + const result = callback(this.nodes[iterator.index], iterator.index); + if (result === false) return false; + iterator.index += 1; + } + return undefined; + } finally { + this.#iterators.splice(this.#iterators.indexOf(iterator), 1); + } + } + + /** + * Returns `true` if {@link condition} returns `true` for all of the + * container’s children. + */ + every( + condition: ( + node: string | Expression, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.every(condition); + } + + /** + * Returns the first index of {@link child} in {@link nodes}. + * + * If {@link child} is a number, returns it as-is. + */ + index(child: string | Expression | number): number { + return typeof child === 'number' ? child : this.nodes.indexOf(child); + } + + /** + * Inserts {@link newNode} immediately after the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} immediately after + * that index instead. + */ + insertAfter( + oldNode: string | Expression | number, + newNode: NewNodeForInterpolation, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index + 1, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index > index) iterator.index += normalized.length; + } + + return this; + } + + /** + * Inserts {@link newNode} immediately before the first occurance of + * {@link oldNode} in {@link nodes}. + * + * If {@link oldNode} is a number, inserts {@link newNode} at that index + * instead. + */ + insertBefore( + oldNode: string | Expression | number, + newNode: NewNodeForInterpolation, + ): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(oldNode); + const normalized = this._normalize(newNode); + this._nodes!.splice(index, 0, ...normalized); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index += normalized.length; + } + + return this; + } + + /** Inserts {@link nodes} at the beginning of the interpolation. */ + prepend(...nodes: NewNodeForInterpolation[]): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const normalized = this._normalizeList(nodes); + this._nodes!.unshift(...normalized); + + for (const iterator of this.#iterators) { + iterator.index += normalized.length; + } + + return this; + } + + /** Adds {@link child} to the end of this interpolation. */ + push(child: string | Expression): this { + return this.append(child); + } + + /** + * Removes all {@link nodes} from this interpolation and cleans their {@link + * Node.parent} properties. + */ + removeAll(): this { + // TODO - postcss/postcss#1957: Mark this as dirty + for (const node of this.nodes) { + if (typeof node !== 'string') node.parent = undefined; + } + this._nodes!.length = 0; + return this; + } + + /** + * Removes the first occurance of {@link child} from the container and cleans + * the parent properties from the node and its children. + * + * If {@link child} is a number, removes the child at that index. + */ + removeChild(child: string | Expression | number): this { + // TODO - postcss/postcss#1957: Mark this as dirty + const index = this.index(child); + if (typeof child === 'object') child.parent = undefined; + this._nodes!.splice(index, 1); + + for (const iterator of this.#iterators) { + if (iterator.index >= index) iterator.index--; + } + + return this; + } + + /** + * Returns `true` if {@link condition} returns `true` for (at least) one of + * the container’s children. + */ + some( + condition: ( + node: string | Expression, + index: number, + nodes: ReadonlyArray, + ) => boolean, + ): boolean { + return this.nodes.some(condition); + } + + /** The first node in {@link nodes}. */ + get first(): string | Expression | undefined { + return this.nodes[0]; + } + + /** + * The container’s last child. + * + * ```js + * rule.last === rule.nodes[rule.nodes.length - 1] + * ``` + */ + get last(): string | Expression | undefined { + return this.nodes[this.nodes.length - 1]; + } + + /** @hidden */ + toString(): string { + let result = ''; + + const rawText = this.raws.text; + const rawExpressions = this.raws.expressions; + for (let i = 0; i < this.nodes.length; i++) { + const element = this.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + result += raw?.value === element ? raw.raw : element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + return result; + } + + /** + * Normalizes the many types of node that can be used with Interpolation + * methods. + */ + private _normalize(nodes: NewNodeForInterpolation): (Expression | string)[] { + const result: Array = []; + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (node === undefined) { + continue; + } else if (typeof node === 'string') { + if (node.length === 0) continue; + result.push(node); + } else if ('sassType' in node) { + if (node.sassType === 'interpolation') { + for (const subnode of node.nodes) { + if (typeof subnode === 'string') { + if (node.nodes.length === 0) continue; + result.push(subnode); + } else { + subnode.parent = this; + result.push(subnode); + } + } + node._nodes!.length = 0; + } else { + node.parent = this; + result.push(node); + } + } else { + const constructed = fromProps(node); + constructed.parent = this; + result.push(constructed); + } + } + return result; + } + + /** Like {@link _normalize}, but also flattens a list of nodes. */ + private _normalizeList( + nodes: ReadonlyArray, + ): (Expression | string)[] { + const result: Array = []; + for (const node of nodes) { + result.push(...this._normalize(node)); + } + return result; + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return this.nodes.filter( + (node): node is Expression => typeof node !== 'string', + ); + } +} diff --git a/pkg/sass-parser/lib/src/lazy-source.ts b/pkg/sass-parser/lib/src/lazy-source.ts new file mode 100644 index 000000000..bcf93bfcd --- /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..11f8d9ae2 --- /dev/null +++ b/pkg/sass-parser/lib/src/node.d.ts @@ -0,0 +1,103 @@ +// 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' + | 'configuration' + | 'configured-variable'; + +/** The constructor properties shared by all Sass AST nodes. */ +export type NodeProps = postcss.NodeProps; + +/** + * Any node in a Sass stylesheet. + * + * All nodes that Sass can parse implement this type, including expression-level + * nodes, selector nodes, and nodes from more domain-specific syntaxes. It aims + * to match the PostCSS API as closely as possible while still being generic + * enough to work across multiple more than just statements. + * + * This does _not_ include methods for adding and modifying siblings of this + * Node, because these only make sense for expression-level Node types. + */ +declare abstract class Node + implements + Omit< + postcss.Node, + | 'after' + | 'assign' + | 'before' + | 'clone' + | 'cloneAfter' + | 'cloneBefore' + | 'next' + | 'prev' + | 'remove' + // TODO: supporting replaceWith() would be tricky, but it does have + // well-defined semantics even without a nodes array and it's awfully + // useful. See if we can find a way. + | 'replaceWith' + | 'type' + | 'parent' + | 'toString' + > +{ + abstract readonly sassType: NodeType; + parent: Node | undefined; + source?: postcss.Source; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + raws: any; + + /** + * A list of children of this node, *not* including any {@link Statement}s it + * contains. This is used internally to traverse the full AST. + * + * @hidden + */ + abstract get nonStatementChildren(): ReadonlyArray>; + + constructor(defaults?: object); + + assign(overrides: object): this; + cleanRaws(keepBetween?: boolean): void; + error( + message: string, + options?: postcss.NodeErrorOptions, + ): postcss.CssSyntaxError; + positionBy( + opts?: Pick, + ): postcss.Position; + positionInside(index: number): postcss.Position; + rangeBy(opts?: Pick): { + start: postcss.Position; + end: postcss.Position; + }; + raw(prop: string, defaultType?: string): string; + root(): postcss.Root; + toJSON(): object; + warn( + result: postcss.Result, + message: string, + options?: postcss.WarningOptions, + ): postcss.Warning; +} diff --git a/pkg/sass-parser/lib/src/node.js b/pkg/sass-parser/lib/src/node.js new file mode 100644 index 000000000..5ce9c3493 --- /dev/null +++ b/pkg/sass-parser/lib/src/node.js @@ -0,0 +1,47 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +const postcss = require('postcss'); + +// Define this separately from the declaration so that we can have it inherit +// all the methods of the base class and make a few of them throw without it +// showing up in the TypeScript types. +class Node extends postcss.Node { + constructor(defaults = {}) { + super(defaults); + } + + after() { + throw new Error("after() is only supported for Sass statement nodes."); + } + + before() { + throw new Error("before() is only supported for Sass statement nodes."); + } + + cloneAfter() { + throw new Error("cloneAfter() is only supported for Sass statement nodes."); + } + + cloneBefore() { + throw new Error("cloneBefore() is only supported for Sass statement nodes."); + } + + next() { + throw new Error("next() is only supported for Sass statement nodes."); + } + + prev() { + throw new Error("prev() is only supported for Sass statement nodes."); + } + + remove() { + throw new Error("remove() is only supported for Sass statement nodes."); + } + + replaceWith() { + throw new Error("replaceWith() is only supported for Sass statement nodes."); + } +} +exports.Node = Node; diff --git a/pkg/sass-parser/lib/src/postcss.d.ts b/pkg/sass-parser/lib/src/postcss.d.ts new file mode 100644 index 000000000..9d9783fe2 --- /dev/null +++ b/pkg/sass-parser/lib/src/postcss.d.ts @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +export const isClean: unique symbol; diff --git a/pkg/sass-parser/lib/src/postcss.js b/pkg/sass-parser/lib/src/postcss.js new file mode 100644 index 000000000..022a0e3f2 --- /dev/null +++ b/pkg/sass-parser/lib/src/postcss.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports.isClean = require('postcss/lib/symbols').isClean; diff --git a/pkg/sass-parser/lib/src/raw-with-value.ts b/pkg/sass-parser/lib/src/raw-with-value.ts new file mode 100644 index 000000000..3e58021a0 --- /dev/null +++ b/pkg/sass-parser/lib/src/raw-with-value.ts @@ -0,0 +1,26 @@ +// 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. + +/** + * An object describing how a value is represented in a stylesheet's source. + * + * This is used for values that can have multiple different representations that + * all produce the same value. The {@link raw} field indicates the textual + * representation in the stylesheet, while the {@link value} indicates the value + * it represents. + * + * When serializing, if {@link value} doesn't match the value in the AST node, + * this is ignored. This ensures that if a plugin overwrites the AST value + * and ignores the raws, its change is preserved in the serialized output. + */ +export interface RawWithValue { + /** The textual representation of {@link value} in the stylesheet. */ + raw: string; + + /** + * The parsed value that {@link raw} represents. This is used to verify that + * this raw is still valid for the AST node that contains it. + */ + value: T; +} 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..e0ba70554 --- /dev/null +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -0,0 +1,320 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as sass from 'sass'; +import * as postcss from 'postcss'; + +import type * as binaryOperation from './expression/binary-operation'; + +// Type definitions for internal Sass APIs we're wrapping. We cast the Sass +// module to this type to access them. + +export type Syntax = 'scss' | 'sass' | 'css'; + +export interface FileSpan extends sass.SourceSpan { + readonly file: SourceFile; +} + +export interface SourceFile { + /** Node-only extension that we use to avoid re-creating inputs. */ + _postcssInput?: postcss.Input; + + readonly codeUnits: number[]; + + getText(start: number, end?: number): string; +} + +export interface DartSet { + _type: T; + + // A brand to make this function as a nominal type. + _unique: 'DartSet'; +} + +// 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): Stylesheet; + + function parseIdentifier( + identifier: string, + logger?: sass.Logger, + ): string | null; + + function toCssIdentifier(text: string): string; + + function setToJS(set: DartSet): Set; + + class StatementVisitor { + private _fakePropertyToMakeThisAUniqueType1: T; + } + + function createStatementVisitor( + inner: StatementVisitorObject, + ): StatementVisitor; + + class ExpressionVisitor { + private _fakePropertyToMakeThisAUniqueType2: T; + } + + function createExpressionVisitor( + inner: ExpressionVisitorObject, + ): ExpressionVisitor; + + class SassNode { + readonly span: FileSpan; + } + + class Interpolation extends SassNode { + contents: (string | Expression)[]; + get asPlain(): string | undefined; + } + + class Statement extends SassNode { + accept(visitor: StatementVisitor): T; + } + + class ParentStatement extends Statement { + readonly children: T; + } + + class AtRootRule extends ParentStatement { + readonly name: Interpolation; + readonly query?: Interpolation; + } + + class AtRule extends ParentStatement { + readonly name: Interpolation; + readonly value?: Interpolation; + } + + class DebugRule extends Statement { + readonly expression: Expression; + } + + class EachRule extends ParentStatement { + readonly variables: string[]; + readonly list: Expression; + } + + class ErrorRule extends Statement { + readonly expression: Expression; + } + + class ExtendRule extends Statement { + readonly selector: Interpolation; + readonly isOptional: boolean; + } + + class ForRule extends ParentStatement { + readonly variable: string; + readonly from: Expression; + readonly to: Expression; + readonly isExclusive: boolean; + } + + class ForwardRule extends Statement { + readonly url: Object; + readonly shownMixinsAndFunctions?: DartSet; + readonly shownVariables?: DartSet; + readonly hiddenMixinsAndFunctions?: DartSet; + readonly hiddenVariables?: DartSet; + readonly prefix?: string; + readonly configuration: ConfiguredVariable[]; + } + + class LoudComment extends Statement { + readonly text: Interpolation; + } + + class MediaRule extends ParentStatement { + readonly query: Interpolation; + } + + class SilentComment extends Statement { + readonly text: string; + } + + class Stylesheet extends ParentStatement {} + + class StyleRule extends ParentStatement { + readonly selector: Interpolation; + } + + class SupportsRule extends ParentStatement { + readonly condition: SupportsCondition; + } + + type SupportsCondition = + | SupportsAnything + | SupportsDeclaration + | SupportsInterpolation + | SupportsNegation + | SupportsOperation; + + class SupportsAnything extends SassNode { + readonly contents: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsDeclaration extends SassNode { + readonly name: Interpolation; + readonly value: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsFunction extends SassNode { + readonly name: Interpolation; + readonly arguments: Interpolation; + + toInterpolation(): Interpolation; + } + + class SupportsInterpolation extends SassNode { + readonly expression: Expression; + + toInterpolation(): Interpolation; + } + + class SupportsNegation extends SassNode { + readonly condition: SupportsCondition; + + toInterpolation(): Interpolation; + } + + class SupportsOperation extends SassNode { + readonly left: SupportsCondition; + readonly right: SupportsCondition; + readonly operator: 'and' | 'or'; + + toInterpolation(): Interpolation; + } + + class UseRule extends Statement { + readonly url: Object; + readonly namespace: string | null; + readonly configuration: ConfiguredVariable[]; + } + + class VariableDeclaration extends Statement { + readonly namespace: string | null; + readonly name: string; + readonly expression: Expression; + readonly isGuarded: boolean; + readonly isGlobal: boolean; + } + + class WarnRule extends Statement { + readonly expression: Expression; + } + + class WhileRule extends ParentStatement { + readonly condition: Expression; + } + + class ConfiguredVariable extends SassNode { + readonly name: string; + readonly expression: Expression; + readonly isGuarded: boolean; + } + + 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; + } + + class BooleanExpression extends Expression { + readonly value: boolean; + } + + class NumberExpression extends Expression { + readonly value: number; + readonly unit: string; + } +} + +const sassInternal = ( + sass as unknown as {loadParserExports_(): typeof SassInternal} +).loadParserExports_(); + +export type SassNode = SassInternal.SassNode; +export type Statement = SassInternal.Statement; +export type ParentStatement = + SassInternal.ParentStatement; +export type AtRootRule = SassInternal.AtRootRule; +export type AtRule = SassInternal.AtRule; +export type DebugRule = SassInternal.DebugRule; +export type EachRule = SassInternal.EachRule; +export type ErrorRule = SassInternal.ErrorRule; +export type ExtendRule = SassInternal.ExtendRule; +export type ForRule = SassInternal.ForRule; +export type ForwardRule = SassInternal.ForwardRule; +export type LoudComment = SassInternal.LoudComment; +export type MediaRule = SassInternal.MediaRule; +export type SilentComment = SassInternal.SilentComment; +export type Stylesheet = SassInternal.Stylesheet; +export type StyleRule = SassInternal.StyleRule; +export type SupportsRule = SassInternal.SupportsRule; +export type UseRule = SassInternal.UseRule; +export type VariableDeclaration = SassInternal.VariableDeclaration; +export type WarnRule = SassInternal.WarnRule; +export type WhileRule = SassInternal.WhileRule; +export type ConfiguredVariable = SassInternal.ConfiguredVariable; +export type Interpolation = SassInternal.Interpolation; +export type Expression = SassInternal.Expression; +export type BinaryOperationExpression = SassInternal.BinaryOperationExpression; +export type StringExpression = SassInternal.StringExpression; +export type BooleanExpression = SassInternal.BooleanExpression; +export type NumberExpression = SassInternal.NumberExpression; + +export interface StatementVisitorObject { + visitAtRootRule(node: AtRootRule): T; + visitAtRule(node: AtRule): T; + visitDebugRule(node: DebugRule): T; + visitEachRule(node: EachRule): T; + visitErrorRule(node: ErrorRule): T; + visitExtendRule(node: ExtendRule): T; + visitForRule(node: ForRule): T; + visitForwardRule(node: ForwardRule): T; + visitLoudComment(node: LoudComment): T; + visitMediaRule(node: MediaRule): T; + visitSilentComment(node: SilentComment): T; + visitStyleRule(node: StyleRule): T; + visitSupportsRule(node: SupportsRule): T; + visitUseRule(node: UseRule): T; + visitVariableDeclaration(node: VariableDeclaration): T; + visitWarnRule(node: WarnRule): T; + visitWhileRule(node: WhileRule): T; +} + +export interface ExpressionVisitorObject { + visitBinaryOperationExpression(node: BinaryOperationExpression): T; + visitStringExpression(node: StringExpression): T; + visitBooleanExpression(node: BooleanExpression): T; + visitNumberExpression(node: NumberExpression): T; +} + +export const parse = sassInternal.parse; +export const parseIdentifier = sassInternal.parseIdentifier; +export const toCssIdentifier = sassInternal.toCssIdentifier; +export const createStatementVisitor = sassInternal.createStatementVisitor; +export const createExpressionVisitor = sassInternal.createExpressionVisitor; +export const setToJS = sassInternal.setToJS; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap new file mode 100644 index 000000000..1e19f31d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/css-comment.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a CSS-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "/* foo */", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "closed": true, + "left": " ", + "right": " ", + }, + "sassType": "comment", + "source": <1:1-1:10 in 0>, + "text": "foo", + "textInterpolation": , + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap new file mode 100644 index 000000000..621628a23 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/debug-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @debug rule toJSON 1`] = ` +{ + "debugExpression": , + "inputs": [ + { + "css": "@debug foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "debug", + "params": "foo", + "raws": {}, + "sassType": "debug-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap new file mode 100644 index 000000000..75dd15404 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/each-rule.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @each rule toJSON 1`] = ` +{ + "eachExpression": , + "inputs": [ + { + "css": "@each $foo, $bar in baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "each", + "nodes": [], + "params": "$foo, $bar in baz", + "raws": {}, + "sassType": "each-rule", + "source": <1:1-1:27 in 0>, + "type": "atrule", + "variables": [ + "foo", + "bar", + ], +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap new file mode 100644 index 000000000..9ed3f5667 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/error-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @error rule toJSON 1`] = ` +{ + "errorExpression": , + "inputs": [ + { + "css": "@error foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "error", + "params": "foo", + "raws": {}, + "sassType": "error-rule", + "source": <1:1-1:11 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap new file mode 100644 index 000000000..f96b0007f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/for-rule.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`an @for rule toJSON 1`] = ` +{ + "fromExpression": , + "inputs": [ + { + "css": "@for $foo from bar to baz {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "for", + "nodes": [], + "params": "$foo from bar to baz", + "raws": {}, + "sassType": "for-rule", + "source": <1:1-1:29 in 0>, + "to": "to", + "toExpression": , + "type": "atrule", + "variable": "foo", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap new file mode 100644 index 000000000..2f4c3dd15 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/generic-at-rule.test.ts.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a generic @-rule toJSON with a child 1`] = ` +{ + "inputs": [ + { + "css": "@foo {@bar}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "nodes": [ + <@bar;>, + ], + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:12 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON with empty children 1`] = ` +{ + "inputs": [ + { + "css": "@foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "nodes": [], + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:8 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON with params 1`] = ` +{ + "inputs": [ + { + "css": "@foo bar", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "bar", + "paramsInterpolation": , + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:9 in 0>, + "type": "atrule", +} +`; + +exports[`a generic @-rule toJSON without params 1`] = ` +{ + "inputs": [ + { + "css": "@foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:5 in 0>, + "type": "atrule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap new file mode 100644 index 000000000..ee16e41cf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/root.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a root node toJSON with children 1`] = ` +{ + "inputs": [ + { + "css": "@foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "foo", + "nameInterpolation": , + "params": "", + "raws": {}, + "sassType": "atrule", + "source": <1:1-1:5 in 0>, + "type": "atrule", +} +`; + +exports[`a root node toJSON without children 1`] = ` +{ + "inputs": [ + { + "css": "", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [], + "raws": {}, + "sassType": "root", + "source": <1:1-1:1 in 0>, + "type": "root", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap new file mode 100644 index 000000000..792fc7e23 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/rule.test.ts.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a style rule toJSON with a child 1`] = ` +{ + "inputs": [ + { + "css": ".foo {@bar}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [ + <@bar;>, + ], + "raws": {}, + "sassType": "rule", + "selector": ".foo ", + "selectorInterpolation": <.foo >, + "source": <1:1-1:12 in 0>, + "type": "rule", +} +`; + +exports[`a style rule toJSON with empty children 1`] = ` +{ + "inputs": [ + { + "css": ".foo {}", + "hasBOM": false, + "id": "", + }, + ], + "nodes": [], + "raws": {}, + "sassType": "rule", + "selector": ".foo ", + "selectorInterpolation": <.foo >, + "source": <1:1-1:8 in 0>, + "type": "rule", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap new file mode 100644 index 000000000..dc289b9ae --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a Sass-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "// foo", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "before": "", + "beforeLines": [ + "", + ], + "left": " ", + }, + "sassType": "sass-comment", + "source": <1:1-1:7 in 0>, + "text": "foo", + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap new file mode 100644 index 000000000..b2b5e0501 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/variable-declaration.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a variable declaration toJSON 1`] = ` +{ + "expression": <"bar">, + "global": false, + "guarded": false, + "inputs": [ + { + "css": "baz.$foo: "bar"", + "hasBOM": false, + "id": "", + }, + ], + "namespace": "baz", + "raws": {}, + "sassType": "variable-declaration", + "source": <1:1-1:16 in 0>, + "type": "decl", + "variableName": "foo", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap new file mode 100644 index 000000000..b072acb85 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @warn rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@warn foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "warn", + "params": "foo", + "raws": {}, + "sassType": "warn-rule", + "source": <1:1-1:10 in 0>, + "type": "atrule", + "warnExpression": , +} +`; diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap new file mode 100644 index 000000000..792686aa3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/while-rule.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @while rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@while foo {}", + "hasBOM": false, + "id": "", + }, + ], + "name": "while", + "nodes": [], + "params": "foo", + "raws": {}, + "sassType": "while-rule", + "source": <1:1-1:14 in 0>, + "type": "atrule", + "whileCondition": , +} +`; diff --git a/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts new file mode 100644 index 000000000..5f7440c3b --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-root-rule.test.ts @@ -0,0 +1,140 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, Rule, scss} from '../..'; + +describe('an @at-root rule', () => { + let node: GenericAtRule; + + describe('with no params', () => { + beforeEach( + () => void (node = scss.parse('@at-root {}').nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + }); + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: rule) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '(with: rule)')); + + it('has matching params', () => expect(node.params).toBe('(with: rule)')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@at-root (with: #{rule}) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('(with: '); + expect(params).toHaveStringExpression(1, 'rule'); + expect(params.nodes[2]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(with: #{rule})')); + }); + + describe('with style rule shorthand', () => { + beforeEach( + () => + void (node = scss.parse('@at-root .foo {}').nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('at-root')); + + it('has no paramsInterpolation', () => + expect(node.paramsInterpolation).toBeUndefined()); + + it('has no params', () => expect(node.params).toBe('')); + + it('contains a Rule', () => { + const rule = node.nodes[0] as Rule; + expect(rule).toHaveInterpolation('selectorInterpolation', '.foo '); + expect(rule.parent).toBe(node); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with atRootShorthand: false', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: false}, + }).toString(), + ).toBe('@at-root {\n .foo {}\n}')); + + describe('with atRootShorthand: true', () => { + it('with no params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString(), + ).toBe('@at-root .foo {}')); + + it('with no params and multiple children', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{selector: '.foo'}, {selector: '.bar'}], + raws: {atRootShorthand: true}, + }).toString(), + ).toBe('@at-root {\n .foo {}\n .bar {}\n}')); + + it('with no params and a non-style-rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + nodes: [{name: 'foo'}], + raws: {atRootShorthand: true}, + }).toString(), + ).toBe('@at-root {\n @foo\n}')); + + it('with params and only a style rule child', () => + expect( + new GenericAtRule({ + name: 'at-root', + params: '(with: rule)', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString(), + ).toBe('@at-root (with: rule) {\n .foo {}\n}')); + + it("that's not @at-root", () => + expect( + new GenericAtRule({ + name: 'at-wrong', + nodes: [{selector: '.foo'}], + raws: {atRootShorthand: true}, + }).toString(), + ).toBe('@at-wrong {\n .foo {}\n}')); + }); + }); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts new file mode 100644 index 000000000..e5395cdca --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.d.ts @@ -0,0 +1,77 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Rule} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _AtRule extends postcss.AtRule { + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/at-rule-internal.js b/pkg/sass-parser/lib/src/statement/at-rule-internal.js new file mode 100644 index 000000000..70634ab1e --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/at-rule-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports._AtRule = require('postcss').AtRule; diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.d.ts b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts new file mode 100644 index 000000000..eb49874bf --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.d.ts @@ -0,0 +1,31 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Root} from './root'; +import {ChildNode, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Comment extends postcss.Comment { + // Override the PostCSS types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + next(): ChildNode | undefined; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; +} diff --git a/pkg/sass-parser/lib/src/statement/comment-internal.js b/pkg/sass-parser/lib/src/statement/comment-internal.js new file mode 100644 index 000000000..3304da6b3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/comment-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports._Comment = require('postcss').Comment; diff --git a/pkg/sass-parser/lib/src/statement/container.test.ts b/pkg/sass-parser/lib/src/statement/container.test.ts new file mode 100644 index 000000000..52e736787 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/container.test.ts @@ -0,0 +1,188 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {GenericAtRule, Root, Rule} from '../..'; + +let root: Root; +describe('a container node', () => { + beforeEach(() => { + root = new Root(); + }); + + describe('can add', () => { + it('a single Sass node', () => { + const rule = new Rule({selector: '.foo'}); + root.append(rule); + expect(root.nodes).toEqual([rule]); + expect(rule.parent).toBe(root); + }); + + it('a list of Sass nodes', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes).toEqual([rule1, rule2]); + expect(rule1.parent).toBe(root); + expect(rule2.parent).toBe(root); + }); + + it('a Sass root node', () => { + const rule1 = new Rule({selector: '.foo'}); + const rule2 = new Rule({selector: '.bar'}); + const otherRoot = new Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS rule node', () => { + const node = postcss.parse('.foo {}').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a PostCSS at-rule node', () => { + const node = postcss.parse('@foo bar').nodes[0]; + root.append(node); + expect(root.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(root.nodes[0]).toHaveInterpolation('nameInterpolation', 'foo'); + expect(root.nodes[0]).toHaveInterpolation('paramsInterpolation', 'bar'); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[0].source).toBe(node.source); + expect(node.parent).toBeUndefined(); + }); + + it('a list of PostCSS nodes', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + root.append([rule1, rule2]); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it('a PostCSS root node', () => { + const rule1 = new postcss.Rule({selector: '.foo'}); + const rule2 = new postcss.Rule({selector: '.bar'}); + const otherRoot = new postcss.Root({nodes: [rule1, rule2]}); + root.append(otherRoot); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + expect(rule1.parent).toBeUndefined(); + expect(rule2.parent).toBeUndefined(); + }); + + it("a single Sass node's properties", () => { + root.append({selectorInterpolation: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it("a single PostCSS node's properties", () => { + root.append({selector: '.foo'}); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of properties', () => { + root.append( + {selectorInterpolation: '.foo'}, + {selectorInterpolation: '.bar'}, + ); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('a plain CSS string', () => { + root.append('.foo {}'); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[0].parent).toBe(root); + }); + + it('a list of plain CSS strings', () => { + root.append(['.foo {}', '.bar {}']); + expect(root.nodes[0]).toBeInstanceOf(Rule); + expect(root.nodes[0]).toHaveInterpolation( + 'selectorInterpolation', + '.foo', + ); + expect(root.nodes[1]).toBeInstanceOf(Rule); + expect(root.nodes[1]).toHaveInterpolation( + 'selectorInterpolation', + '.bar', + ); + expect(root.nodes[0].parent).toBe(root); + expect(root.nodes[1].parent).toBe(root); + }); + + it('undefined', () => { + root.append(undefined); + expect(root.nodes).toHaveLength(0); + }); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.test.ts b/pkg/sass-parser/lib/src/statement/css-comment.test.ts new file mode 100644 index 000000000..c8fbaff6d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.test.ts @@ -0,0 +1,325 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {CssComment, Interpolation, Root, css, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a CSS-style comment', () => { + let node: CssComment; + function describeNode(description: string, create: () => CssComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType comment', () => expect(node.sassType).toBe('comment')); + + it('has matching textInterpolation', () => + expect(node).toHaveInterpolation('textInterpolation', 'foo')); + + it('has matching text', () => expect(node.text).toBe('foo')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('/* foo */').nodes[0] as CssComment, + ); + + describeNode( + 'parsed as CSS', + () => css.parse('/* foo */').nodes[0] as CssComment, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('/* foo').nodes[0] as CssComment, + ); + + describe('constructed manually', () => { + describeNode( + 'with an interpolation', + () => + new CssComment({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }), + ); + + describeNode('with a text string', () => new CssComment({text: 'foo'})); + }); + + describe('constructed from ChildProps', () => { + describeNode('with an interpolation', () => + utils.fromChildProps({ + textInterpolation: new Interpolation({nodes: ['foo']}), + }), + ); + + describeNode('with a text string', () => + utils.fromChildProps({text: 'foo'}), + ); + }); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with whitespace before and after text', () => + expect((scss.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + + it('with whitespace before and after interpolation', () => + expect( + (scss.parse('/* #{foo} */').nodes[0] as CssComment).raws, + ).toEqual({left: ' ', right: ' ', closed: true})); + + it('without whitespace before and after text', () => + expect((scss.parse('/*foo*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('without whitespace before and after interpolation', () => + expect((scss.parse('/*#{foo}*/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + + it('with whitespace and no text', () => + expect((scss.parse('/* */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: true, + })); + + it('with no whitespace and no text', () => + expect((scss.parse('/**/').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: true, + })); + }); + + describe('in Sass', () => { + // TODO: Test explicit whitespace after text and interpolation once we + // properly parse raws from somewhere other than the original text. + + it('with whitespace before text', () => + expect((sass.parse('/* foo').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('with whitespace before interpolation', () => + expect((sass.parse('/* #{foo}').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: '', + closed: false, + })); + + it('without whitespace before and after text', () => + expect((sass.parse('/*foo').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('without whitespace before and after interpolation', () => + expect((sass.parse('/*#{foo}').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with no whitespace and no text', () => + expect((sass.parse('/*').nodes[0] as CssComment).raws).toEqual({ + left: '', + right: '', + closed: false, + })); + + it('with a trailing */', () => + expect((sass.parse('/* foo */').nodes[0] as CssComment).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new CssComment({text: 'foo'}).toString()).toBe('/* foo */')); + + it('with left', () => + expect( + new CssComment({ + text: 'foo', + raws: {left: '\n'}, + }).toString(), + ).toBe('/*\nfoo */')); + + it('with right', () => + expect( + new CssComment({ + text: 'foo', + raws: {right: '\n'}, + }).toString(), + ).toBe('/* foo\n*/')); + + it('with before', () => + expect( + new Root({ + nodes: [new CssComment({text: 'foo', raws: {before: '/**/'}})], + }).toString(), + ).toBe('/**//* foo */')); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('/* foo */').nodes[0] as CssComment; + }); + + it("removes the old text's parent", () => { + const oldText = node.textInterpolation!; + node.textInterpolation = 'bar'; + expect(oldText.parent).toBeUndefined(); + }); + + it("assigns the new interpolation's parent", () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(interpolation.parent).toBe(node); + }); + + it('assigns the interpolation explicitly', () => { + const interpolation = new Interpolation({nodes: ['bar']}); + node.textInterpolation = interpolation; + expect(node.textInterpolation).toBe(interpolation); + }); + + it('assigns the interpolation as a string', () => { + node.textInterpolation = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + + it('assigns the interpolation as text', () => { + node.text = 'bar'; + expect(node).toHaveInterpolation('textInterpolation', 'bar'); + }); + }); + + describe('clone', () => { + let original: CssComment; + beforeEach( + () => void (original = scss.parse('/* foo */').nodes[0] as CssComment), + ); + + describe('with no overrides', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + + it('text', () => expect(clone.text).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({left: ' ', right: ' ', closed: true})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['textInterpolation', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'bar')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('textInterpolation', () => { + describe('defined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({ + textInterpolation: new Interpolation({nodes: ['baz']}), + }); + }); + + it('changes text', () => expect(clone.text).toBe('baz')); + + it('changes textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'baz')); + }); + + describe('undefined', () => { + let clone: CssComment; + beforeEach(() => { + clone = original.clone({textInterpolation: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves textInterpolation', () => + expect(clone).toHaveInterpolation('textInterpolation', 'foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {right: ' '}}).raws).toEqual({ + right: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + left: ' ', + right: ' ', + closed: true, + })); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('/* foo */').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/css-comment.ts b/pkg/sass-parser/lib/src/statement/css-comment.ts new file mode 100644 index 000000000..432520ddb --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/css-comment.ts @@ -0,0 +1,164 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {CommentRaws} from 'postcss/lib/comment'; + +import {convertExpression} from '../expression/convert'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link CssComment}. + * + * @category Statement + */ +export interface CssCommentRaws extends CommentRaws { + /** + * In the indented syntax, this indicates whether a comment is explicitly + * closed with a `*\/`. It's ignored in other syntaxes. + * + * It defaults to false. + */ + closed?: boolean; +} + +/** + * The initializer properties for {@link CssComment}. + * + * @category Statement + */ +export type CssCommentProps = ContainerProps & { + raws?: CssCommentRaws; +} & ({text: string} | {textInterpolation: Interpolation | string}); + +/** + * A CSS-style "loud" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class CssComment + extends _Comment> + implements Statement +{ + readonly sassType = 'comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: CssCommentRaws; + + get text(): string { + return this.textInterpolation.toString(); + } + set text(value: string) { + this.textInterpolation = value; + } + + /** The interpolation that represents this selector's contents. */ + get textInterpolation(): Interpolation { + return this._textInterpolation!; + } + set textInterpolation(textInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._textInterpolation) { + this._textInterpolation.parent = undefined; + } + if (typeof textInterpolation === 'string') { + textInterpolation = new Interpolation({ + nodes: [textInterpolation], + }); + } + textInterpolation.parent = this; + this._textInterpolation = textInterpolation; + } + private _textInterpolation?: Interpolation; + + constructor(defaults: CssCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.LoudComment); + constructor(defaults?: CssCommentProps, inner?: sassInternal.LoudComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + const nodes = [...inner.text.contents]; + + // The interpolation's contents are guaranteed to begin with a string, + // because Sass includes the `/*`. + let first = nodes[0] as string; + const firstMatch = first.match(/^\/\*([ \t\n\r\f]*)/)!; + this.raws.left ??= firstMatch[1]; + first = first.substring(firstMatch[0].length); + if (first.length === 0) { + nodes.shift(); + } else { + nodes[0] = first; + } + + // The interpolation will end with `*/` in SCSS, but not necessarily in + // the indented syntax. + let last = nodes.at(-1); + if (typeof last === 'string') { + const lastMatch = last.match(/([ \t\n\r\f]*)\*\/$/); + this.raws.right ??= lastMatch?.[1] ?? ''; + this.raws.closed = !!lastMatch; + if (lastMatch) { + last = last.substring(0, last.length - lastMatch[0].length); + if (last.length === 0) { + nodes.pop(); + } else { + nodes[0] = last; + } + } + } else { + this.raws.right ??= ''; + this.raws.closed = false; + } + + this.textInterpolation = new Interpolation(); + for (const child of nodes) { + this.textInterpolation.append( + typeof child === 'string' ? child : convertExpression(child), + ); + } + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'textInterpolation'], + ['text'], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'textInterpolation'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.textInterpolation]; + } +} + +interceptIsClean(CssComment); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.test.ts b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts new file mode 100644 index 000000000..a06b459aa --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-rule.test.ts @@ -0,0 +1,205 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {DebugRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @debug rule', () => { + let node: DebugRule; + function describeNode(description: string, create: () => DebugRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('debug')); + + it('has an expression', () => + expect(node).toHaveStringExpression('debugExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@debug foo').nodes[0] as DebugRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@debug foo').nodes[0] as DebugRule, + ); + + describeNode( + 'constructed manually', + () => + new DebugRule({ + debugExpression: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + debugExpression: {text: 'foo'}, + }), + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new DebugRule({ + debugExpression: {text: 'foo'}, + }).name = 'bar'), + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@debug foo').nodes[0] as DebugRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('debugExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.debugExpression; + node.debugExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.debugExpression = expression; + expect(node.debugExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.debugExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('debugExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + }).toString(), + ).toBe('@debug foo;')); + + it('with afterName', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@debug/**/foo;')); + + it('with between', () => + expect( + new DebugRule({ + debugExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@debug foo/**/;')); + }); + }); + + describe('clone', () => { + let original: DebugRule; + beforeEach(() => { + original = scss.parse('@debug foo').nodes[0] as DebugRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: DebugRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['debugExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('debugExpression', () => { + describe('defined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: DebugRule; + beforeEach(() => { + clone = original.clone({debugExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves debugExpression', () => + expect(clone).toHaveStringExpression('debugExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@debug foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/debug-rule.ts b/pkg/sass-parser/lib/src/statement/debug-rule.ts new file mode 100644 index 000000000..aa4154b63 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/debug-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link DebugRule}. + * + * @category Statement + */ +export type DebugRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link DebugRule}. + * + * @category Statement + */ +export type DebugRuleProps = postcss.NodeProps & { + raws?: DebugRuleRaws; + debugExpression: Expression | ExpressionProps; +}; + +/** + * A `@debug` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class DebugRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'debug-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: DebugRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'debug'; + } + set name(value: string) { + throw new Error("DebugRule.name can't be overwritten."); + } + + get params(): string { + return this.debugExpression.toString(); + } + set params(value: string | number | undefined) { + this.debugExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the debug rule is executed. */ + get debugExpression(): Expression { + return this._debugExpression!; + } + set debugExpression(debugExpression: Expression | ExpressionProps) { + if (this._debugExpression) this._debugExpression.parent = undefined; + if (!('sassType' in debugExpression)) { + debugExpression = fromProps(debugExpression); + } + if (debugExpression) debugExpression.parent = this; + this._debugExpression = debugExpression; + } + private _debugExpression?: Expression; + + constructor(defaults: DebugRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.DebugRule); + constructor(defaults?: DebugRuleProps, inner?: sassInternal.DebugRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.debugExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'debugExpression'], + [{name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'debugExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.debugExpression]; + } +} + +interceptIsClean(DebugRule); diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts new file mode 100644 index 000000000..03f4e3ec8 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration-internal.d.ts @@ -0,0 +1,77 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Rule} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Declaration extends postcss.Declaration { + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/declaration-internal.js b/pkg/sass-parser/lib/src/statement/declaration-internal.js new file mode 100644 index 000000000..8472c1ec9 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/declaration-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._Declaration = require('postcss').Declaration; diff --git a/pkg/sass-parser/lib/src/statement/each-rule.test.ts b/pkg/sass-parser/lib/src/statement/each-rule.test.ts new file mode 100644 index 000000000..433fa10fd --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.test.ts @@ -0,0 +1,303 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {EachRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @each rule', () => { + let node: EachRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@each $foo, $bar in baz').nodes[0] as EachRule, + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => EachRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('each')); + + it('has variables', () => + expect(node.variables).toEqual(['foo', 'bar'])); + + it('has an expression', () => + expect(node).toHaveStringExpression('eachExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo, $bar in baz')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@each $foo, $bar in baz {@child}').nodes[0] as EachRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@each $foo, $bar in baz\n @child').nodes[0] as EachRule, + ); + + describeNode( + 'constructed manually', + () => + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + })), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip, $zap in qux')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.eachExpression; + node.eachExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.eachExpression = expression; + expect(node.eachExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.eachExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('eachExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + }).toString(), + ).toBe('@each $foo, $bar in baz {}')); + + it('with afterName', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@each/**/$foo, $bar in baz {}')); + + it('with afterVariables', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterVariables: ['/**/,', '/* */']}, + }).toString(), + ).toBe('@each $foo/**/,$bar/* */in baz {}')); + + it('with afterIn', () => + expect( + new EachRule({ + variables: ['foo', 'bar'], + eachExpression: {text: 'baz'}, + raws: {afterIn: '/**/'}, + }).toString(), + ).toBe('@each $foo, $bar in/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: EachRule; + beforeEach(() => { + original = scss.parse('@each $foo, $bar in baz {}').nodes[0] as EachRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: EachRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo, $bar in baz')); + + it('variables', () => expect(clone.variables).toEqual(['foo', 'bar'])); + + it('eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['variables', 'eachExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('variables', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: ['zip', 'zap']}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip, $zap in baz')); + + it('changes variables', () => + expect(clone.variables).toEqual(['zip', 'zap'])); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({variables: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves variables', () => + expect(clone.variables).toEqual(['foo', 'bar'])); + }); + }); + + describe('eachExpression', () => { + describe('defined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo, $bar in qux')); + + it('changes eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: EachRule; + beforeEach(() => { + clone = original.clone({eachExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo, $bar in baz')); + + it('preserves eachExpression', () => + expect(clone).toHaveStringExpression('eachExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@each $foo, $bar in baz {}').nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/each-rule.ts b/pkg/sass-parser/lib/src/statement/each-rule.ts new file mode 100644 index 000000000..ea2c812d6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/each-rule.ts @@ -0,0 +1,165 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link EachRule}. + * + * @category Statement + */ +export interface EachRuleRaws extends Omit { + /** + * The whitespace and commas after each variable in + * {@link EachRule.variables}. + * + * The element at index `i` is included after the variable at index `i`. Any + * elements beyond `variables.length` are ignored. + */ + afterVariables?: string[]; + + /** The whitespace between `in` and {@link EachRule.eachExpression}. */ + afterIn?: string; +} + +/** + * The initializer properties for {@link EachRule}. + * + * @category Statement + */ +export type EachRuleProps = ContainerProps & { + raws?: EachRuleRaws; + variables: string[]; + eachExpression: Expression | ExpressionProps; +}; + +/** + * An `@each` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class EachRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'each-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: EachRuleRaws; + declare nodes: ChildNode[]; + + /** The variable names assigned for each iteration, without `"$"`. */ + declare variables: string[]; + + get name(): string { + return 'each'; + } + set name(value: string) { + throw new Error("EachRule.name can't be overwritten."); + } + + get params(): string { + let result = ''; + for (let i = 0; i < this.variables.length; i++) { + result += + '$' + + this.variables[i] + + (this.raws?.afterVariables?.[i] ?? + (i === this.variables.length - 1 ? ' ' : ', ')); + } + return `${result}in${this.raws.afterIn ?? ' '}${this.eachExpression}`; + } + set params(value: string | number | undefined) { + throw new Error("EachRule.params can't be overwritten."); + } + + /** The expresison whose value is iterated over. */ + get eachExpression(): Expression { + return this._eachExpression!; + } + set eachExpression(eachExpression: Expression | ExpressionProps) { + if (this._eachExpression) this._eachExpression.parent = undefined; + if (!('sassType' in eachExpression)) { + eachExpression = fromProps(eachExpression); + } + if (eachExpression) eachExpression.parent = this; + this._eachExpression = eachExpression; + } + private _eachExpression?: Expression; + + constructor(defaults: EachRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.EachRule); + constructor(defaults?: EachRuleProps, inner?: sassInternal.EachRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variables = [...inner.variables]; + this.eachExpression = convertExpression(inner.list); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variables', + 'eachExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'variables', 'eachExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.eachExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(EachRule); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.test.ts b/pkg/sass-parser/lib/src/statement/error-rule.test.ts new file mode 100644 index 000000000..57b33aac5 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-rule.test.ts @@ -0,0 +1,205 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {ErrorRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @error rule', () => { + let node: ErrorRule; + function describeNode(description: string, create: () => ErrorRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('error')); + + it('has an expression', () => + expect(node).toHaveStringExpression('errorExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@error foo').nodes[0] as ErrorRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@error foo').nodes[0] as ErrorRule, + ); + + describeNode( + 'constructed manually', + () => + new ErrorRule({ + errorExpression: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + errorExpression: {text: 'foo'}, + }), + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new ErrorRule({ + errorExpression: {text: 'foo'}, + }).name = 'bar'), + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@error foo').nodes[0] as ErrorRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('errorExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.errorExpression; + node.errorExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.errorExpression = expression; + expect(node.errorExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.errorExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('errorExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + }).toString(), + ).toBe('@error foo;')); + + it('with afterName', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@error/**/foo;')); + + it('with between', () => + expect( + new ErrorRule({ + errorExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@error foo/**/;')); + }); + }); + + describe('clone', () => { + let original: ErrorRule; + beforeEach(() => { + original = scss.parse('@error foo').nodes[0] as ErrorRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ErrorRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['errorExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('errorExpression', () => { + describe('defined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: ErrorRule; + beforeEach(() => { + clone = original.clone({errorExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves errorExpression', () => + expect(clone).toHaveStringExpression('errorExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@error foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/error-rule.ts b/pkg/sass-parser/lib/src/statement/error-rule.ts new file mode 100644 index 000000000..3d7b369c0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/error-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link ErrorRule}. + * + * @category Statement + */ +export type ErrorRuleProps = postcss.NodeProps & { + raws?: ErrorRuleRaws; + errorExpression: Expression | ExpressionProps; +}; + +/** + * An `@error` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ErrorRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'error-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ErrorRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'error'; + } + set name(value: string) { + throw new Error("ErrorRule.name can't be overwritten."); + } + + get params(): string { + return this.errorExpression.toString(); + } + set params(value: string | number | undefined) { + this.errorExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is thrown when the error rule is executed. */ + get errorExpression(): Expression { + return this._errorExpression!; + } + set errorExpression(errorExpression: Expression | ExpressionProps) { + if (this._errorExpression) this._errorExpression.parent = undefined; + if (!('sassType' in errorExpression)) { + errorExpression = fromProps(errorExpression); + } + if (errorExpression) errorExpression.parent = this; + this._errorExpression = errorExpression; + } + private _errorExpression?: Expression; + + constructor(defaults: ErrorRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ErrorRule); + constructor(defaults?: ErrorRuleProps, inner?: sassInternal.ErrorRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.errorExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'errorExpression'], + [{name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'errorExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.errorExpression]; + } +} + +interceptIsClean(ErrorRule); diff --git a/pkg/sass-parser/lib/src/statement/extend-rule.test.ts b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts new file mode 100644 index 000000000..45210c90c --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/extend-rule.test.ts @@ -0,0 +1,61 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, Rule, scss} from '../..'; + +describe('an @extend rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .bar}').nodes[0] as Rule) + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '.bar')); + + it('has matching params', () => expect(node.params).toBe('.bar')); + }); + + describe('with interpolation', () => { + beforeEach( + () => + void (node = (scss.parse('.foo {@extend .#{bar}}').nodes[0] as Rule) + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('.'); + expect(params).toHaveStringExpression(1, 'bar'); + }); + + it('has matching params', () => expect(node.params).toBe('.#{bar}')); + }); + + describe('with !optional', () => { + beforeEach( + () => + void (node = ( + scss.parse('.foo {@extend .bar !optional}').nodes[0] as Rule + ).nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('extend')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '.bar !optional', + )); + + it('has matching params', () => expect(node.params).toBe('.bar !optional')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.test.ts b/pkg/sass-parser/lib/src/statement/for-rule.test.ts new file mode 100644 index 000000000..66becdcb4 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.test.ts @@ -0,0 +1,437 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {ForRule, GenericAtRule, StringExpression, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('an @for rule', () => { + let node: ForRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@for $foo from bar through baz {}').nodes[0] as ForRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@for $foo from bar through baz').nodes[0] as ForRule, + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => ForRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('for')); + + it('has a variable', () => expect(node.variable).toBe('foo')); + + it('has a to', () => expect(node.to).toBe('through')); + + it('has a from expression', () => + expect(node).toHaveStringExpression('fromExpression', 'bar')); + + it('has a to expression', () => + expect(node).toHaveStringExpression('toExpression', 'baz')); + + it('has matching params', () => + expect(node.params).toBe('$foo from bar through baz')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@for $foo from bar through baz {@child}') + .nodes[0] as ForRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@for $foo from bar through baz\n @child') + .nodes[0] as ForRule, + ); + + describeNode( + 'constructed manually', + () => + new ForRule({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variable: 'foo', + to: 'through', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => + void (node = new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + })), + ); + + it('name', () => expect(() => (node.name = 'qux')).toThrow()); + + it('params', () => + expect(() => (node.params = '$zip from zap to qux')).toThrow()); + }); + + describe('assigned a new from expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.fromExpression; + node.fromExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.fromExpression = expression; + expect(node.fromExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.fromExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('fromExpression', 'qux'); + }); + }); + + describe('assigned a new to expression', () => { + beforeEach(() => { + node = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.toExpression; + node.toExpression = {text: 'qux'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'qux'}); + node.toExpression = expression; + expect(node.toExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.toExpression = {text: 'qux'}; + expect(node).toHaveStringExpression('toExpression', 'qux'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + }).toString(), + ).toBe('@for $foo from bar to baz {}')); + + it('with afterName', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@for/**/$foo from bar to baz {}')); + + it('with afterVariable', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterVariable: '/**/'}, + }).toString(), + ).toBe('@for $foo/**/from bar to baz {}')); + + it('with afterFrom', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFrom: '/**/'}, + }).toString(), + ).toBe('@for $foo from/**/bar to baz {}')); + + it('with afterFromExpression', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterFromExpression: '/**/'}, + }).toString(), + ).toBe('@for $foo from bar/**/to baz {}')); + + it('with afterTo', () => + expect( + new ForRule({ + variable: 'foo', + fromExpression: {text: 'bar'}, + toExpression: {text: 'baz'}, + raws: {afterTo: '/**/'}, + }).toString(), + ).toBe('@for $foo from bar to/**/baz {}')); + }); + }); + + describe('clone', () => { + let original: ForRule; + beforeEach(() => { + original = scss.parse('@for $foo from bar to baz {}').nodes[0] as ForRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: ForRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('$foo from bar to baz')); + + it('variable', () => expect(clone.variable).toBe('foo')); + + it('to', () => expect(clone.to).toBe('to')); + + it('fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + + it('toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of [ + 'fromExpression', + 'toExpression', + 'raws', + ] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('variable', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: 'zip'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$zip from bar to baz')); + + it('changes variable', () => expect(clone.variable).toBe('zip')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({variable: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves variable', () => expect(clone.variable).toBe('foo')); + }); + }); + + describe('to', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: 'through'}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar through baz')); + + it('changes tos', () => expect(clone.to).toBe('through')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({to: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves tos', () => expect(clone.to).toBe('to')); + }); + }); + + describe('fromExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from qux to baz')); + + it('changes fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({fromExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves fromExpression', () => + expect(clone).toHaveStringExpression('fromExpression', 'bar')); + }); + }); + + describe('toExpression', () => { + describe('defined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: {text: 'qux'}}); + }); + + it('changes params', () => + expect(clone.params).toBe('$foo from bar to qux')); + + it('changes toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'qux')); + }); + + describe('undefined', () => { + let clone: ForRule; + beforeEach(() => { + clone = original.clone({toExpression: undefined}); + }); + + it('preserves params', () => + expect(clone.params).toBe('$foo from bar to baz')); + + it('preserves toExpression', () => + expect(clone).toHaveStringExpression('toExpression', 'baz')); + }); + }); + }); + }); + + it('toJSON', () => + expect( + scss.parse('@for $foo from bar to baz {}').nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/for-rule.ts b/pkg/sass-parser/lib/src/statement/for-rule.ts new file mode 100644 index 000000000..8147f83b0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/for-rule.ts @@ -0,0 +1,200 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link ForRule}. + * + * @category Statement + */ +export interface ForRuleRaws extends Omit { + /** The whitespace after {@link ForRule.variable}. */ + afterVariable?: string; + + /** The whitespace after a {@link ForRule}'s `from` keyword. */ + afterFrom?: string; + + /** The whitespace after {@link ForRule.fromExpression}. */ + afterFromExpression?: string; + + /** The whitespace after a {@link ForRule}'s `to` or `through` keyword. */ + afterTo?: string; +} + +/** + * The initializer properties for {@link ForRule}. + * + * @category Statement + */ +export type ForRuleProps = ContainerProps & { + raws?: ForRuleRaws; + variable: string; + fromExpression: Expression | ExpressionProps; + toExpression: Expression | ExpressionProps; + to?: 'to' | 'through'; +}; + +/** + * A `@for` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ForRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'for-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ForRuleRaws; + declare nodes: ChildNode[]; + + /** The variabl names assigned for for iteration, without `"$"`. */ + declare variable: string; + + /** + * The keyword that appears before {@link toExpression}. + * + * If this is `"to"`, the loop is exclusive; if it's `"through"`, the loop is + * inclusive. It defaults to `"to"` when creating a new `ForRule`. + */ + declare to: 'to' | 'through'; + + get name(): string { + return 'for'; + } + set name(value: string) { + throw new Error("ForRule.name can't be overwritten."); + } + + get params(): string { + return ( + `$${this.variable}${this.raws.afterVariable ?? ' '}from` + + `${this.raws.afterFrom ?? ' '}${this.fromExpression}` + + `${this.raws.afterFromExpression ?? ' '}${this.to}` + + `${this.raws.afterTo ?? ' '}${this.toExpression}` + ); + } + set params(value: string | number | undefined) { + throw new Error("ForRule.params can't be overwritten."); + } + + /** The expresison whose value is the starting point of the iteration. */ + get fromExpression(): Expression { + return this._fromExpression!; + } + set fromExpression(fromExpression: Expression | ExpressionProps) { + if (this._fromExpression) this._fromExpression.parent = undefined; + if (!('sassType' in fromExpression)) { + fromExpression = fromProps(fromExpression); + } + if (fromExpression) fromExpression.parent = this; + this._fromExpression = fromExpression; + } + private _fromExpression?: Expression; + + /** The expresison whose value is the ending point of the iteration. */ + get toExpression(): Expression { + return this._toExpression!; + } + set toExpression(toExpression: Expression | ExpressionProps) { + if (this._toExpression) this._toExpression.parent = undefined; + if (!('sassType' in toExpression)) { + toExpression = fromProps(toExpression); + } + if (toExpression) toExpression.parent = this; + this._toExpression = toExpression; + } + private _toExpression?: Expression; + + constructor(defaults: ForRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ForRule); + constructor(defaults?: ForRuleProps, inner?: sassInternal.ForRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.variable = inner.variable; + this.to = inner.isExclusive ? 'to' : 'through'; + this.fromExpression = convertExpression(inner.from); + this.toExpression = convertExpression(inner.to); + appendInternalChildren(this, inner.children); + } + + this.to ??= 'to'; + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + [ + 'name', + 'variable', + 'to', + 'fromExpression', + 'toExpression', + 'params', + 'nodes', + ], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.fromExpression, this.toExpression]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(ForRule); diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts new file mode 100644 index 000000000..56f22600c --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts @@ -0,0 +1,934 @@ +// Copyright 2024 Google Inc. Forward 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 {Configuration, ForwardRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @forward rule', () => { + let node: ForwardRule; + describe('with just a URL', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo"')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo"').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo"').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + }), + ); + }); + + describe('with a prefix', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has a prefix', () => expect(node.prefix).toBe('bar-')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" as bar-*')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo" as bar-*').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo" as bar-*').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + prefix: 'bar-', + }), + ); + }); + + describe('with shown names of both types', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has show', () => + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(['bar', 'qux']), + variables: new Set(['baz']), + })); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" show bar, qux, $baz')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@forward "foo" show bar, $baz, qux') + .nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@forward "foo" show bar, $baz, qux') + .nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'qux'], variables: ['baz']}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'qux'], variables: ['baz']}, + }), + ); + }); + + describe('with hidden names of one type only', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has hide', () => + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + })); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" hide bar, baz')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo" hide bar, baz').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo" hide bar, baz').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + }), + ); + }); + + describe('with explicit configuration', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has a configuration', () => { + expect(node.configuration.size).toBe(1); + expect(node.configuration.parent).toBe(node); + const variables = [...node.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" with ($baz: "qux")')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@forward "foo" with ($baz: "qux")').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@forward "foo" with ($baz: "qux")').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new ForwardRule({forwardUrl: 'foo'}))); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'bar')).toThrow()); + }); + + it('assigned a new url', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.forwardUrl = 'bar'; + expect(node.forwardUrl).toBe('bar'); + expect(node.params).toBe('"bar"'); + }); + + it('assigned a new prefix', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.prefix = 'bar-'; + expect(node.prefix).toBe('bar-'); + expect(node.params).toBe('"foo" as bar-*'); + }); + + describe('assigned a new show', () => { + it('defined unsets hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.show = {mixinsAndFunctions: ['baz']}; + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(), + }); + expect(node.hide).toBeUndefined(); + expect(node.params).toBe('"foo" show baz'); + }); + + it('undefined unsets show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.show = undefined; + expect(node.show).toBeUndefined(); + expect(node.params).toBe('"foo"'); + }); + + it('undefined retains hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.show = undefined; + expect(node.show).toBeUndefined(); + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(), + variables: new Set(['bar']), + }); + expect(node.params).toBe('"foo" hide $bar'); + }); + }); + + describe('assigned a new hide', () => { + it('defined unsets show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.hide = {mixinsAndFunctions: ['baz']}; + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(), + }); + expect(node.show).toBeUndefined(); + expect(node.params).toBe('"foo" hide baz'); + }); + + it('undefined unsets hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.hide = undefined; + expect(node.hide).toBeUndefined(); + expect(node.params).toBe('"foo"'); + }); + + it('undefined retains show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.hide = undefined; + expect(node.hide).toBeUndefined(); + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(), + variables: new Set(['bar']), + }); + expect(node.params).toBe('"foo" show $bar'); + }); + }); + + it('assigned a new configuration', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.configuration = new Configuration({ + variables: {bar: {text: 'baz', quotes: true}}, + }); + expect(node.configuration.size).toBe(1); + expect(node.params).toBe('"foo" with ($bar: "baz")'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with a prefix', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + }).toString(), + ).toBe('@forward "foo" as bar-*;')); + + it('with a non-identifier prefix', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: ' ', + }).toString(), + ).toBe('@forward "foo" as \\20*;')); + + it('with show', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, + }).toString(), + ).toBe('@forward "foo" show bar, $baz, $qux;')); + + it('with a non-identifier show', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: [' ']}, + }).toString(), + ).toBe('@forward "foo" show \\20;')); + + it('with hide', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, + }).toString(), + ).toBe('@forward "foo" hide bar, $baz, $qux;')); + + it('with a non-identifier hide', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: [' ']}, + }).toString(), + ).toBe('@forward "foo" hide \\20;')); + + it('with configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + }).toString(), + ).toBe('@forward "foo" with ($bar: "baz");')); + }); + + describe('with a URL raw', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {url: {raw: "'foo'", value: 'foo'}}, + }).toString(), + ).toBe("@forward 'foo';")); + + it("that doesn't match", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {url: {raw: "'bar'", value: 'bar'}}, + }).toString(), + ).toBe('@forward "foo";')); + }); + + describe('with a prefix raw', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + raws: {prefix: {raw: ' as bar-*', value: 'bar-'}}, + }).toString(), + ).toBe('@forward "foo" as bar-*;')); + + it("that doesn't match", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'baz-', + raws: {url: {raw: ' as bar-*', value: 'bar-'}}, + }).toString(), + ).toBe('@forward "foo" as baz-*;')); + }); + + describe('with show', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar, baz', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + + it('that has an extra member', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar, baz, $qux', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(['qux']), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + + it("that's missing a member", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar', + value: { + mixinsAndFunctions: new Set(['bar']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + }); + + describe('with hide', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar, baz', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + + it('that has an extra member', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar, baz, $qux', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(['qux']), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + + it("that's missing a member", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar', + value: { + mixinsAndFunctions: new Set(['bar']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + }); + + describe('with beforeWith', () => { + it('and a configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo"/**/with ($bar: "baz");')); + + it('and no configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo";')); + }); + + describe('with afterWith', () => { + it('and a configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo" with/**/($bar: "baz");')); + + it('and no configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo";')); + }); + }); + }); + + describe('clone', () => { + let original: ForwardRule; + beforeEach(() => { + original = scss.parse( + '@forward "foo" as bar-* show baz, $qux with ($zip: "zap")', + ).nodes[0] as ForwardRule; + // TODO: remove this once raws are properly parsed + original.raws.beforeWith = ' '; + }); + + describe('with no overrides', () => { + let clone: ForwardRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + + it('forwardUrl', () => expect(clone.forwardUrl).toBe('foo')); + + it('prefix', () => expect(clone.prefix).toBe('bar-')); + + it('show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(['qux']), + })); + + it('hide', () => expect(clone.hide).toBeUndefined()); + + it('configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('zip'); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('raws', () => expect(clone.raws).toEqual({beforeWith: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['show', 'configuration', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + + it('show.mixinsAndFunctions', () => + expect(clone.show!.mixinsAndFunctions).not.toBe( + original.show!.mixinsAndFunctions, + )); + + it('show.variables', () => + expect(clone.show!.variables).not.toBe(original.show!.variables)); + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterWith: ' '}}).raws).toEqual({ + afterWith: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + beforeWith: ' ', + })); + }); + + describe('forwardUrl', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({forwardUrl: 'flip'}); + }); + + it('changes forwardUrl', () => expect(clone.forwardUrl).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe( + '"flip" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({forwardUrl: undefined}); + }); + + it('preserves forwardUrl', () => + expect(clone.forwardUrl).toBe('foo')); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('prefix', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({prefix: 'flip-'}); + }); + + it('changes prefix', () => expect(clone.prefix).toBe('flip-')); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as flip-* show baz, $qux with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({prefix: undefined}); + }); + + it('preserves prefix', () => expect(clone.prefix).toBe('bar-')); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('show', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({show: {variables: ['flip']}}); + }); + + it('changes show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set([]), + variables: new Set(['flip']), + })); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as bar-* show $flip with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({show: undefined}); + }); + + it('changes show', () => expect(clone.show).toBeUndefined()); + + it('changes params', () => + expect(clone.params).toBe('"foo" as bar-* with ($zip: "zap")')); + }); + }); + + describe('hide', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({hide: {variables: ['flip']}}); + }); + + it('changes show', () => expect(clone.show).toBeUndefined()); + + it('changes hide', () => + expect(clone.hide).toEqual({ + mixinsAndFunctions: new Set([]), + variables: new Set(['flip']), + })); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as bar-* hide $flip with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({hide: undefined}); + }); + + it('preserves show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(['qux']), + })); + + it('preserves hide', () => expect(clone.hide).toBeUndefined()); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('configuration', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({configuration: new Configuration()}); + }); + + it('changes configuration', () => + expect(clone.configuration.size).toBe(0)); + + it('changes params', () => + expect(clone.params).toBe('"foo" as bar-* show baz, $qux')); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({configuration: undefined}); + }); + + it('preserves configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('zip'); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + scss.parse('@forward "foo" as bar-* show baz, $qux with ($zip: "zap")') + .nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.ts b/pkg/sass-parser/lib/src/statement/forward-rule.ts new file mode 100644 index 000000000..f1b316049 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/forward-rule.ts @@ -0,0 +1,349 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {Configuration, ConfigurationProps} from '../configuration'; +import {StringExpression} from '../expression/string'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * A list of member names that are shown or hidden by a {@link ForwardRule}. At + * least one of {@link mixinsAndFunctions} or {@link variables} must contain at + * least one element, or this can't be represented as Sass source code. + * + * @category Statement + */ +export interface ForwardMemberList { + /** Mixin and function names to show or hide. */ + mixinsAndFunctions: Set; + + /** Variable names to show or hide, without `$`. */ + variables: Set; +} + +/** + * The set of raws supported by {@link ForwardRule}. + * + * @category Statement + */ +export interface ForwardRuleRaws extends Omit { + /** The representation of {@link ForwardRule.forwardUrl}. */ + url?: RawWithValue; + + /** + * The text of the added prefix, including `as` and any whitespace before it. + * + * Only used if {@link prefix.value} matches {@link ForwardRule.prefix}. + */ + prefix?: RawWithValue; + + /** + * The text of the list of members to forward, including `show` and any + * whitespace before it. + * + * Only used if {@link show.value} matches {@link ForwardRule.show}. + */ + show?: RawWithValue; + + /** + * The text of the list of members not to forward, including `hide` and any + * whitespace before it. + * + * Only used if {@link hide.value} matches {@link ForwardRule.hide}. + */ + hide?: RawWithValue; + + /** + * The whitespace between the URL or prefix and the `with` keyword. + * + * Unused if the rule doesn't have a `with` clause. + */ + beforeWith?: string; + + /** + * The whitespace between the `with` keyword and the configuration map. + * + * Unused unless the rule has a non-empty configuration. + */ + afterWith?: string; +} + +/** The initilaizer properties for {@link ForwardMemberList}. */ +export interface ForwardMemberProps { + mixinsAndFunctions?: Iterable; + variables?: Iterable; +} + +/** + * The initializer properties for {@link ForwardRule}. + * + * @category Statement + */ +export type ForwardRuleProps = ContainerProps & { + raws?: ForwardRuleRaws; + forwardUrl: string; + prefix?: string; + configuration?: Configuration | ConfigurationProps; +} & ( + | {show?: ForwardMemberProps; hide?: never} + | {hide?: ForwardMemberProps; show?: never} + ); + +/** + * A `@forward` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ForwardRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'forward-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ForwardRuleRaws; + declare readonly nodes: undefined; + + /** The URL loaded by the `@forward` rule. */ + declare forwardUrl: string; + + /** + * The prefix added to the beginning of mixin, variable, and function names + * loaded by this rule. Defaults to ''. + */ + declare prefix: string; + + /** + * The allowlist of names of members to forward from the loaded module. + * + * If this is defined, {@link hide} must be undefined. If this and {@link + * hide} are both undefined, all members are forwarded. + * + * Setting this to a non-`undefined` value automatically sets {@link hide} to + * `undefined`. + */ + get show(): ForwardMemberList | undefined { + return this._show; + } + set show(value: ForwardMemberProps | undefined) { + if (value) { + this._hide = undefined; + this._show = { + mixinsAndFunctions: new Set([...(value.mixinsAndFunctions ?? [])]), + variables: new Set([...(value.variables ?? [])]), + }; + } else { + this._show = undefined; + } + } + declare _show?: ForwardMemberList; + + /** + * The blocklist of names of members to forward from the loaded module. + * + * If this is defined, {@link show} must be undefined. If this and {@link + * show} are both undefined, all members are forwarded. + * + * Setting this to a non-`undefined` value automatically sets {@link show} to + * `undefined`. + */ + get hide(): ForwardMemberList | undefined { + return this._hide; + } + set hide(value: ForwardMemberProps | undefined) { + if (value) { + this._show = undefined; + this._hide = { + mixinsAndFunctions: new Set([...(value.mixinsAndFunctions ?? [])]), + variables: new Set([...(value.variables ?? [])]), + }; + } else { + this._hide = undefined; + } + } + declare _hide?: ForwardMemberList; + + get name(): string { + return 'forward'; + } + set name(value: string) { + throw new Error("ForwardRule.name can't be overwritten."); + } + + get params(): string { + let result = + this.raws.url?.value === this.forwardUrl + ? this.raws.url!.raw + : new StringExpression({ + text: this.forwardUrl, + quotes: true, + }).toString(); + + if (this.raws.prefix?.value === this.prefix) { + result += this.raws.prefix?.raw; + } else if (this.prefix) { + result += ` as ${sassInternal.toCssIdentifier(this.prefix)}*`; + } + + if (this.show) { + result += this._serializeMemberList('show', this.show, this.raws.show); + } else if (this.hide) { + result += this._serializeMemberList('hide', this.hide, this.raws.hide); + } + + const hasConfiguration = this.configuration.size > 0; + if (hasConfiguration) { + result += + `${this.raws.beforeWith ?? ' '}with` + + `${this.raws.afterWith ?? ' '}${this.configuration}`; + } + return result; + } + set params(value: string | number | undefined) { + throw new Error("ForwardRule.params can't be overwritten."); + } + + /** The variables whose defaults are set when loading this module. */ + get configuration(): Configuration { + return this._configuration!; + } + set configuration(configuration: Configuration | ConfigurationProps) { + if (this._configuration) { + this._configuration.clear(); + this._configuration.parent = undefined; + } + this._configuration = + 'sassType' in configuration + ? configuration + : new Configuration(configuration); + this._configuration.parent = this; + } + private _configuration!: Configuration; + + constructor(defaults: ForwardRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ForwardRule); + constructor(defaults?: ForwardRuleProps, inner?: sassInternal.ForwardRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.forwardUrl = inner.url.toString(); + this.prefix = inner.prefix ?? ''; + if (inner.shownMixinsAndFunctions) { + this.show = { + mixinsAndFunctions: sassInternal.setToJS( + inner.shownMixinsAndFunctions, + ), + variables: sassInternal.setToJS(inner.shownVariables!), + }; + } else if (inner.hiddenMixinsAndFunctions) { + this.hide = { + mixinsAndFunctions: sassInternal.setToJS( + inner.hiddenMixinsAndFunctions, + ), + variables: sassInternal.setToJS(inner.hiddenVariables!), + }; + } + this.configuration = new Configuration(undefined, inner.configuration); + } else { + this.configuration ??= new Configuration(); + this.prefix ??= ''; + } + } + + /** + * Serializes {@link members} to string, respecting {@link raws} if it's + * defined and matches. + */ + private _serializeMemberList( + keyword: string, + members: ForwardMemberList, + raws: RawWithValue | undefined, + ): string { + if (this._memberListsEqual(members, raws?.value)) return raws!.raw; + const mixinsAndFunctionsEmpty = members.mixinsAndFunctions.size === 0; + const variablesEmpty = members.variables.size === 0; + if (mixinsAndFunctionsEmpty && variablesEmpty) { + throw new Error( + 'Either ForwardMemberList.mixinsAndFunctions or ' + + 'ForwardMemberList.variables must contain a name.', + ); + } + + return ( + ` ${keyword} ` + + [...members.mixinsAndFunctions] + .map(name => sassInternal.toCssIdentifier(name)) + .join(', ') + + (mixinsAndFunctionsEmpty || variablesEmpty ? '' : ', ') + + [...members.variables] + .map(variable => '$' + sassInternal.toCssIdentifier(variable)) + .join(', ') + ); + } + + /** + * Returns whether {@link list1} and {@link list2} contain the same values. + */ + private _memberListsEqual( + list1: ForwardMemberList | undefined, + list2: ForwardMemberList | undefined, + ): boolean { + if (list1 === list2) return true; + if (!list1 || !list2) return false; + return ( + utils.setsEqual(list1.mixinsAndFunctions, list2.mixinsAndFunctions) && + utils.setsEqual(list1.variables, list2.variables) + ); + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'forwardUrl', + 'prefix', + {name: 'show', explicitUndefined: true}, + {name: 'hide', explicitUndefined: true}, + 'configuration', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['forwardUrl', 'prefix', 'configuration', 'show', 'hide', 'params'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.configuration]; + } +} + +interceptIsClean(ForwardRule); 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..e75d06519 --- /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..0ccb6e586 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -0,0 +1,212 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + AtRule, + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link GenericAtRule}. + * + * Sass doesn't support PostCSS's `params` raws, since + * {@link GenericAtRule.paramInterpolation} has its own raws. + * + * @category Statement + */ +export interface GenericAtRuleRaws extends Omit { + /** + * Whether to collapse the nesting for an `@at-root` with no params that + * contains only a single style rule. + * + * This is ignored for rules that don't meet all of those criteria. + */ + atRootShorthand?: boolean; +} + +/** + * The initializer properties for {@link GenericAtRule}. + * + * @category Statement + */ +export type GenericAtRuleProps = ContainerProps & { + raws?: GenericAtRuleRaws; +} & ( + | {nameInterpolation: Interpolation | string; name?: never} + | {name: string; nameInterpolation?: never} + ) & + ( + | {paramsInterpolation?: Interpolation | string; params?: never} + | {params?: string | number; paramsInterpolation?: never} + ); + +/** + * An `@`-rule that isn't parsed as a more specific type. Extends + * [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class GenericAtRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'atrule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: GenericAtRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return this.nameInterpolation.toString(); + } + set name(value: string) { + this.nameInterpolation = value; + } + + /** + * The interpolation that represents this at-rule's name. + */ + get nameInterpolation(): Interpolation { + return this._nameInterpolation!; + } + set nameInterpolation(nameInterpolation: Interpolation | string) { + if (this._nameInterpolation) this._nameInterpolation.parent = undefined; + if (typeof nameInterpolation === 'string') { + nameInterpolation = new Interpolation({nodes: [nameInterpolation]}); + } + nameInterpolation.parent = this; + this._nameInterpolation = nameInterpolation; + } + private _nameInterpolation?: Interpolation; + + get params(): string { + if (this.name !== 'media' || !this.paramsInterpolation) { + return this.paramsInterpolation?.toString() ?? ''; + } + + // @media has special parsing in Sass, and allows raw expressions within + // parens. + let result = ''; + const rawText = this.paramsInterpolation.raws.text; + const rawExpressions = this.paramsInterpolation.raws.expressions; + for (let i = 0; i < this.paramsInterpolation.nodes.length; i++) { + const element = this.paramsInterpolation.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + result += raw?.value === element ? raw.raw : element; + } else { + if (result.match(/(\([ \t\n\f\r]*|(:|[<>]?=)[ \t\n\f\r]*)$/)) { + result += element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + } + return result; + } + set params(value: string | number | undefined) { + this.paramsInterpolation = value === '' ? undefined : value?.toString(); + } + + /** + * The interpolation that represents this at-rule's parameters, or undefined + * if it has no parameters. + */ + get paramsInterpolation(): Interpolation | undefined { + return this._paramsInterpolation; + } + set paramsInterpolation( + paramsInterpolation: Interpolation | string | undefined, + ) { + if (this._paramsInterpolation) this._paramsInterpolation.parent = undefined; + if (typeof paramsInterpolation === 'string') { + paramsInterpolation = new Interpolation({nodes: [paramsInterpolation]}); + } + if (paramsInterpolation) paramsInterpolation.parent = this; + this._paramsInterpolation = paramsInterpolation; + } + private _paramsInterpolation: Interpolation | undefined; + + constructor(defaults: GenericAtRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.AtRule); + constructor(defaults?: GenericAtRuleProps, inner?: sassInternal.AtRule) { + super(defaults as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.nameInterpolation = new Interpolation(undefined, inner.name); + if (inner.value) { + this.paramsInterpolation = new Interpolation(undefined, inner.value); + } + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'nodes', + 'raws', + 'nameInterpolation', + {name: 'paramsInterpolation', explicitUndefined: true}, + ], + ['name', {name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'nameInterpolation', 'params', 'paramsInterpolation', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + const result = [this.nameInterpolation]; + if (this.paramsInterpolation) result.push(this.paramsInterpolation); + return result; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(GenericAtRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts new file mode 100644 index 000000000..416352f0f --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -0,0 +1,351 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import {Node, NodeProps} from '../node'; +import * as sassInternal from '../sass-internal'; +import {CssComment, CssCommentProps} from './css-comment'; +import {SassComment, SassCommentChildProps} from './sass-comment'; +import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; +import {DebugRule, DebugRuleProps} from './debug-rule'; +import {EachRule, EachRuleProps} from './each-rule'; +import {ErrorRule, ErrorRuleProps} from './error-rule'; +import {ForRule, ForRuleProps} from './for-rule'; +import {ForwardRule, ForwardRuleProps} from './forward-rule'; +import {Root} from './root'; +import {Rule, RuleProps} from './rule'; +import {UseRule, UseRuleProps} from './use-rule'; +import { + VariableDeclaration, + VariableDeclarationProps, +} from './variable-declaration'; +import {WarnRule, WarnRuleProps} from './warn-rule'; +import {WhileRule, WhileRuleProps} from './while-rule'; + +// TODO: Replace this with the corresponding Sass types once they're +// implemented. +export {Declaration} from 'postcss'; + +/** + * The union type of all Sass statements. + * + * @category Statement + */ +export type AnyStatement = Comment | Root | Rule | AtRule | VariableDeclaration; + +/** + * Sass statement types. + * + * This is a superset of the node types PostCSS exposes, and is provided + * alongside `Node.type` to disambiguate between the wide range of statements + * that Sass parses as distinct types. + * + * @category Statement + */ +export type StatementType = + | 'root' + | 'rule' + | 'atrule' + | 'comment' + | 'debug-rule' + | 'each-rule' + | 'for-rule' + | 'forward-rule' + | 'error-rule' + | 'use-rule' + | 'sass-comment' + | 'variable-declaration' + | 'warn-rule' + | 'while-rule'; + +/** + * All Sass statements that are also at-rules. + * + * @category Statement + */ +export type AtRule = + | DebugRule + | EachRule + | ErrorRule + | ForRule + | ForwardRule + | GenericAtRule + | UseRule + | WarnRule + | WhileRule; + +/** + * All Sass statements that are comments. + * + * @category Statement + */ +export type Comment = CssComment | SassComment; + +/** + * All Sass statements that are valid children of other statements. + * + * The Sass equivalent of PostCSS's `ChildNode`. + * + * @category Statement + */ +export type ChildNode = Rule | AtRule | Comment | VariableDeclaration; + +/** + * The properties that can be used to construct {@link ChildNode}s. + * + * The Sass equivalent of PostCSS's `ChildProps`. + * + * @category Statement + */ +export type ChildProps = + | postcss.ChildProps + | CssCommentProps + | DebugRuleProps + | EachRuleProps + | ErrorRuleProps + | ForRuleProps + | ForwardRuleProps + | GenericAtRuleProps + | RuleProps + | SassCommentChildProps + | UseRuleProps + | VariableDeclarationProps + | WarnRuleProps + | WhileRuleProps; + +/** + * The Sass eqivalent of PostCSS's `ContainerProps`. + * + * @category Statement + */ +export interface ContainerProps extends NodeProps { + nodes?: ReadonlyArray; +} + +/** + * A {@link Statement} that has actual child nodes. + * + * @category Statement + */ +export type StatementWithChildren = postcss.Container & { + nodes: ChildNode[]; +} & Statement; + +/** + * A statement in a Sass stylesheet. + * + * In addition to implementing the standard PostCSS behavior, this provides + * extra information to help disambiguate different types that Sass parses + * differently. + * + * @category Statement + */ +export interface Statement extends postcss.Node, Node { + /** The type of this statement. */ + readonly sassType: StatementType; + + parent: StatementWithChildren | undefined; +} + +/** The visitor to use to convert internal Sass nodes to JS. */ +const visitor = sassInternal.createStatementVisitor({ + visitAtRootRule: inner => { + const rule = new GenericAtRule({ + name: 'at-root', + paramsInterpolation: inner.query + ? new Interpolation(undefined, inner.query) + : undefined, + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, + visitAtRule: inner => new GenericAtRule(undefined, inner), + visitDebugRule: inner => new DebugRule(undefined, inner), + visitErrorRule: inner => new ErrorRule(undefined, inner), + visitEachRule: inner => new EachRule(undefined, inner), + visitForRule: inner => new ForRule(undefined, inner), + visitForwardRule: inner => new ForwardRule(undefined, inner), + visitExtendRule: inner => { + const paramsInterpolation = new Interpolation(undefined, inner.selector); + if (inner.isOptional) paramsInterpolation.append('!optional'); + return new GenericAtRule({ + name: 'extend', + paramsInterpolation, + source: new LazySource(inner), + }); + }, + visitLoudComment: inner => new CssComment(undefined, inner), + visitMediaRule: inner => { + const rule = new GenericAtRule({ + name: 'media', + paramsInterpolation: new Interpolation(undefined, inner.query), + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, + visitSilentComment: inner => new SassComment(undefined, inner), + visitStyleRule: inner => new Rule(undefined, inner), + visitSupportsRule: inner => { + const rule = new GenericAtRule({ + name: 'supports', + paramsInterpolation: new Interpolation( + undefined, + inner.condition.toInterpolation(), + ), + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, + visitUseRule: inner => new UseRule(undefined, inner), + visitVariableDeclaration: inner => new VariableDeclaration(undefined, inner), + visitWarnRule: inner => new WarnRule(undefined, inner), + visitWhileRule: inner => new WhileRule(undefined, inner), +}); + +/** Appends parsed versions of `internal`'s children to `container`. */ +export function appendInternalChildren( + container: postcss.Container, + children: sassInternal.Statement[] | null, +): void { + // Make sure `container` knows it has a block. + if (children?.length === 0) container.append(undefined); + if (!children) return; + for (const child of children) { + container.append(child.accept(visitor)); + } +} + +/** + * The type of nodes that can be passed as new child nodes to PostCSS methods. + */ +export type NewNode = + | ChildProps + | ReadonlyArray + | postcss.Node + | ReadonlyArray + | string + | ReadonlyArray + | undefined; + +/** PostCSS's built-in normalize function. */ +const postcssNormalize = postcss.Container.prototype['normalize'] as ( + nodes: postcss.NewChild, + sample: postcss.Node | undefined, + type?: 'prepend' | false, +) => postcss.ChildNode[]; + +/** + * A wrapper around {@link postcssNormalize} that converts the results to the + * corresponding Sass type(s) after normalizing. + */ +function postcssNormalizeAndConvertToSass( + self: StatementWithChildren, + node: string | postcss.ChildProps | postcss.Node, + sample: postcss.Node | undefined, +): ChildNode[] { + return postcssNormalize.call(self, node, sample).map(postcssNode => { + // postcssNormalize sets the parent to the Sass node, but we don't want to + // mix Sass AST nodes with plain PostCSS AST nodes so we unset it in favor + // of creating a totally new node. + postcssNode.parent = undefined; + + switch (postcssNode.type) { + case 'atrule': + return new GenericAtRule({ + name: postcssNode.name, + params: postcssNode.params, + raws: postcssNode.raws, + source: postcssNode.source, + }); + case 'rule': + return new Rule({ + selector: postcssNode.selector, + raws: postcssNode.raws, + source: postcssNode.source, + }); + default: + throw new Error(`Unsupported PostCSS node type ${postcssNode.type}`); + } + }); +} + +/** + * An override of {@link postcssNormalize} that supports Sass nodes as arguments + * and converts PostCSS-style arguments to Sass. + */ +export function normalize( + self: StatementWithChildren, + node: NewNode, + sample?: postcss.Node, +): ChildNode[] { + if (node === undefined) return []; + const nodes = Array.isArray(node) ? node : [node]; + + const result: ChildNode[] = []; + for (const node of nodes) { + if (typeof node === 'string') { + // We could in principle parse these as Sass. + result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } else if ('sassType' in node) { + if (node.sassType === 'root') { + result.push(...(node as Root).nodes); + } else { + result.push(node as ChildNode); + } + } else if ('type' in node) { + result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); + } else if ( + 'selectorInterpolation' in node || + 'selector' in node || + 'selectors' in node + ) { + result.push(new Rule(node)); + } else if ('name' in node || 'nameInterpolation' in node) { + result.push(new GenericAtRule(node as GenericAtRuleProps)); + } else if ('debugExpression' in node) { + result.push(new DebugRule(node)); + } else if ('eachExpression' in node) { + result.push(new EachRule(node)); + } else if ('fromExpression' in node) { + result.push(new ForRule(node)); + } else if ('forwardUrl' in node) { + result.push(new ForwardRule(node)); + } else if ('errorExpression' in node) { + result.push(new ErrorRule(node)); + } else if ('text' in node || 'textInterpolation' in node) { + result.push(new CssComment(node as CssCommentProps)); + } else if ('silentText' in node) { + result.push(new SassComment(node)); + } else if ('useUrl' in node) { + result.push(new UseRule(node)); + } else if ('variableName' in node) { + result.push(new VariableDeclaration(node)); + } else if ('warnExpression' in node) { + result.push(new WarnRule(node)); + } else if ('whileCondition' in node) { + result.push(new WhileRule(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..57ebfa87c --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/intercept-is-clean.ts @@ -0,0 +1,33 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {isClean} from '../postcss'; +import type {Node} from '../node'; +import * as utils from '../utils'; +import type {Statement} from '.'; + +/** + * Defines a getter/setter pair for the given {@link klass} that intercepts + * PostCSS's attempt to mark it as clean and marks any non-statement children as + * clean as well. + */ +export function interceptIsClean( + klass: utils.Constructor, +): void { + Object.defineProperty(klass as typeof klass & {_isClean: boolean}, isClean, { + get(): boolean { + return this._isClean; + }, + set(value: boolean): void { + this._isClean = value; + if (value) this.nonStatementChildren.forEach(markClean); + }, + }); +} + +/** Marks {@link node} and all its children as clean. */ +function markClean(node: Node): void { + (node as Node & {[isClean]: boolean})[isClean] = true; + node.nonStatementChildren.forEach(markClean); +} diff --git a/pkg/sass-parser/lib/src/statement/media-rule.test.ts b/pkg/sass-parser/lib/src/statement/media-rule.test.ts new file mode 100644 index 000000000..8b48530f3 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/media-rule.test.ts @@ -0,0 +1,61 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, StringExpression, scss} from '../..'; + +describe('a @media rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media screen {}').nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'screen')); + + it('has matching params', () => expect(node.params).toBe('screen')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media (hover: #{hover}) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('('); + expect(params).toHaveStringExpression(1, 'hover'); + expect(params.nodes[2]).toBe(': '); + expect(params.nodes[3]).toBeInstanceOf(StringExpression); + expect((params.nodes[3] as StringExpression).text).toHaveStringExpression( + 0, + 'hover', + ); + expect(params.nodes[4]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(hover: #{hover})')); + }); + + describe('stringifies', () => { + // TODO: Use raws technology to include the actual original text between + // interpolations. + it('to SCSS', () => + expect( + (node = scss.parse('@media #{screen} and (hover: #{hover}) {@foo}') + .nodes[0] as GenericAtRule).toString(), + ).toBe('@media #{screen} and (hover: #{hover}) {\n @foo\n}')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/root-internal.d.ts b/pkg/sass-parser/lib/src/statement/root-internal.d.ts new file mode 100644 index 000000000..7306eeeb2 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root-internal.d.ts @@ -0,0 +1,79 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Rule} from './rule'; +import {Root, RootProps} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Root extends postcss.Root { + declare nodes: ChildNode[]; + + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/root-internal.js b/pkg/sass-parser/lib/src/statement/root-internal.js new file mode 100644 index 000000000..599781530 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/root-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports._Root = require('postcss').Root; diff --git a/pkg/sass-parser/lib/src/statement/root.test.ts b/pkg/sass-parser/lib/src/statement/root.test.ts new file mode 100644 index 000000000..a236dc874 --- /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..7f2dd4b80 --- /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..89f4e1a00 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule-internal.d.ts @@ -0,0 +1,79 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; + +import {Rule, RuleProps} from './rule'; +import {Root} from './root'; +import {AtRule, ChildNode, Comment, Declaration, NewNode} from '.'; + +/** + * A fake intermediate class to convince TypeScript to use Sass types for + * various upstream methods. + * + * @hidden + */ +export class _Rule extends postcss.Rule { + declare nodes: ChildNode[]; + + // Override the PostCSS container types to constrain them to Sass types only. + // Unfortunately, there's no way to abstract this out, because anything + // mixin-like returns an intersection type which doesn't actually override + // parent methods. See microsoft/TypeScript#59394. + + after(newNode: NewNode): this; + append(...nodes: NewNode[]): this; + assign(overrides: Partial): this; + before(newNode: NewNode): this; + cloneAfter(overrides?: Partial): this; + cloneBefore(overrides?: Partial): this; + each( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + insertAfter(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + insertBefore(oldNode: postcss.ChildNode | number, newNode: NewNode): this; + next(): ChildNode | undefined; + prepend(...nodes: NewNode[]): this; + prev(): ChildNode | undefined; + replaceWith(...nodes: NewNode[]): this; + root(): Root; + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean, + ): boolean; + walk( + callback: (node: ChildNode, index: number) => false | void, + ): false | undefined; + walkAtRules( + nameFilter: RegExp | string, + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkAtRules( + callback: (atRule: AtRule, index: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkComments( + callback: (comment: Comment, indexed: number) => false | void, + ): false | undefined; + walkDecls( + propFilter: RegExp | string, + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkDecls( + callback: (decl: Declaration, index: number) => false | void, + ): false | undefined; + walkRules( + selectorFilter: RegExp | string, + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + walkRules( + callback: (rule: Rule, index: number) => false | void, + ): false | undefined; + get first(): ChildNode | undefined; + get last(): ChildNode | undefined; +} diff --git a/pkg/sass-parser/lib/src/statement/rule-internal.js b/pkg/sass-parser/lib/src/statement/rule-internal.js new file mode 100644 index 000000000..96d32cce0 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule-internal.js @@ -0,0 +1,5 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +exports._Rule = require('postcss').Rule; diff --git a/pkg/sass-parser/lib/src/statement/rule.test.ts b/pkg/sass-parser/lib/src/statement/rule.test.ts new file mode 100644 index 000000000..708de8170 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule.test.ts @@ -0,0 +1,363 @@ +// 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..b53605a09 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/rule.ts @@ -0,0 +1,141 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {RuleRaws as PostcssRuleRaws} from 'postcss/lib/rule'; + +import {Interpolation} from '../interpolation'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {interceptIsClean} from './intercept-is-clean'; +import {_Rule} from './rule-internal'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by a style rule. + * + * Sass doesn't support PostCSS's `params` raws, since the selector is lexed and + * made directly available to the caller. + * + * @category Statement + */ +export type RuleRaws = Omit; + +/** + * The initializer properties for {@link Rule}. + * + * @category Statement + */ +export type RuleProps = ContainerProps & {raws?: RuleRaws} & ( + | {selectorInterpolation: Interpolation | string} + | {selector: string} + | {selectors: string[]} + ); + +/** + * A style rule. Extends [`postcss.Rule`]. + * + * [`postcss.Rule`]: https://postcss.org/api/#rule + * + * @category Statement + */ +export class Rule extends _Rule implements Statement { + readonly sassType = 'rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: RuleRaws; + + get selector(): string { + return this.selectorInterpolation.toString(); + } + set selector(value: string) { + this.selectorInterpolation = value; + } + + /** The interpolation that represents this rule's selector. */ + get selectorInterpolation(): Interpolation { + return this._selectorInterpolation!; + } + set selectorInterpolation(selectorInterpolation: Interpolation | string) { + // TODO - postcss/postcss#1957: Mark this as dirty + if (this._selectorInterpolation) { + this._selectorInterpolation.parent = undefined; + } + if (typeof selectorInterpolation === 'string') { + selectorInterpolation = new Interpolation({ + nodes: [selectorInterpolation], + }); + } + selectorInterpolation.parent = this; + this._selectorInterpolation = selectorInterpolation; + } + private _selectorInterpolation?: Interpolation; + + constructor(defaults: RuleProps); + constructor(_: undefined, inner: sassInternal.StyleRule); + /** @hidden */ + constructor(defaults?: RuleProps, inner?: sassInternal.StyleRule) { + // PostCSS claims that it requires either selector or selectors, but we + // define the former as a getter instead. + super(defaults as postcss.RuleProps); + if (inner) { + this.source = new LazySource(inner); + this.selectorInterpolation = new Interpolation(undefined, inner.selector); + appendInternalChildren(this, inner.children); + } + } + + // TODO: Once we make selector parsing available to JS, use it to override + // selectors() and to provide access to parsed selectors if selector is plain + // text. + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['nodes', 'raws', 'selectorInterpolation'], + ['selector', 'selectors'], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['selector', 'selectorInterpolation', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.selectorInterpolation]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(Rule); diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.test.ts b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts new file mode 100644 index 000000000..3f2655eee --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts @@ -0,0 +1,465 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {Root, Rule, SassComment, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a Sass-style comment', () => { + let node: SassComment; + function describeNode(description: string, create: () => SassComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType sass-comment', () => + expect(node.sassType).toBe('sass-comment')); + + it('has matching text', () => expect(node.text).toBe('foo\nbar')); + + it('has matching silentText', () => expect(node.text).toBe('foo\nbar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('// foo\n// bar').nodes[0] as SassComment, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('// foo\n// bar').nodes[0] as SassComment, + ); + + describeNode( + 'constructed manually', + () => new SassComment({text: 'foo\nbar'}), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({silentText: 'foo\nbar'}), + ); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with consistent whitespace before and after //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with an empty line', () => { + const node = scss.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = scss.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace before //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: [' ', '', ' '], + left: ' ', + }); + }); + + it('with inconsistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n // bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['\t', ' '], + left: ' ', + }); + }); + + it('with consistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n \t// bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' \t', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = scss.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = scss.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = scss.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = scss.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + + describe('in Sass', () => { + it('with an empty line', () => { + const node = sass.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = sass.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = sass.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = sass.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = sass.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = sass.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new SassComment({text: 'foo\nbar'}).toString()).toBe( + '// foo\n// bar', + )); + + it('with left', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {left: '\t'}, + }).toString(), + ).toBe('//\tfoo\n//\tbar')); + + it('with left and an empty line', () => + expect( + new SassComment({ + text: 'foo\n\nbar', + raws: {left: '\t'}, + }).toString(), + ).toBe('//\tfoo\n//\n//\tbar')); + + it('with left and a whitespace-only line', () => + expect( + new SassComment({ + text: 'foo\n \nbar', + raws: {left: '\t'}, + }).toString(), + ).toBe('//\tfoo\n// \n//\tbar')); + + it('with before', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {before: '\t'}, + }).toString(), + ).toBe('\t// foo\n\t// bar')); + + it('with beforeLines', () => + expect( + new Root({ + nodes: [ + new SassComment({ + text: 'foo\nbar', + raws: {beforeLines: [' ', '\t']}, + }), + ], + }).toString(), + ).toBe(' // foo\n\t// bar')); + + describe('with a following sibling', () => { + it('without before', () => + expect( + new Root({ + nodes: [{silentText: 'foo\nbar'}, {name: 'baz'}], + }).toString(), + ).toBe('// foo\n// bar\n@baz')); + + it('with before with newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: '\n '}}, + ], + }).toString(), + ).toBe('// foo\n// bar\n @baz')); + + it('with before without newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: ' '}}, + ], + }).toString(), + ).toBe('// foo\n// bar\n @baz')); + }); + + describe('in a nested rule', () => { + it('without after', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + }).toString(), + ).toBe('.zip {\n // foo\n// bar\n}')); + + it('with after with newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: '\n '}, + }).toString(), + ).toBe('.zip {\n // foo\n// bar\n }')); + + it('with after without newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: ' '}, + }).toString(), + ).toBe('.zip {\n // foo\n// bar\n }')); + }); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.text = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.text = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('assigned new silentText', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.silentText = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.silentText = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('clone', () => { + let original: SassComment; + beforeEach( + () => void (original = scss.parse('// foo').nodes[0] as SassComment), + ); + + describe('with no overrides', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('text', () => expect(clone.text).toBe('foo')); + + it('silentText', () => expect(clone.silentText).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + it('raws.beforeLines', () => + expect(clone.raws.beforeLines).not.toBe(original.raws.beforeLines)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {left: ' '}}).raws).toEqual({ + left: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + }); + }); + }); + + it('toJSON', () => expect(scss.parse('// foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.ts b/pkg/sass-parser/lib/src/statement/sass-comment.ts new file mode 100644 index 000000000..6aa9e4aa6 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.ts @@ -0,0 +1,182 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {CommentRaws} from 'postcss/lib/comment'; + +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link SassComment}. + * + * @category Statement + */ +export interface SassCommentRaws extends Omit { + /** + * Unlike PostCSS's, `CommentRaws.before`, this is added before `//` for + * _every_ line of this comment. If any lines have more indentation than this, + * it appears in {@link beforeLines} instead. + */ + before?: string; + + /** + * For each line in the comment, this is the whitespace that appears before + * the `//` _in addition to_ {@link before}. + */ + beforeLines?: string[]; + + /** + * Unlike PostCSS's `CommentRaws.left`, this is added after `//` for _every_ + * line in the comment that's not only whitespace. If any lines have more + * initial whitespace than this, it appears in {@link SassComment.text} + * instead. + * + * Lines that are only whitespace do not have `left` added to them, and + * instead have all their whitespace directly in {@link SassComment.text}. + */ + left?: string; +} + +/** + * The subset of {@link SassCommentProps} that can be used to construct it + * implicitly without calling `new SassComment()`. + * + * @category Statement + */ +export type SassCommentChildProps = ContainerProps & { + raws?: SassCommentRaws; + silentText: string; +}; + +/** + * The initializer properties for {@link SassComment}. + * + * @category Statement + */ +export type SassCommentProps = ContainerProps & { + raws?: SassCommentRaws; +} & ( + | { + silentText: string; + } + | {text: string} + ); + +/** + * A Sass-style "silent" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class SassComment + extends _Comment> + implements Statement +{ + readonly sassType = 'sass-comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: SassCommentRaws; + + /** + * The text of this comment, potentially spanning multiple lines. + * + * This is always the same as {@link text}, it just has a different name to + * distinguish {@link SassCommentProps} from {@link CssCommentProps}. + */ + declare silentText: string; + + get text(): string { + return this.silentText; + } + set text(value: string) { + this.silentText = value; + } + + constructor(defaults: SassCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.SilentComment); + constructor(defaults?: SassCommentProps, inner?: sassInternal.SilentComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + + const lineInfo = inner.text + .trimRight() + .split('\n') + .map(line => { + const index = line.indexOf('//'); + const before = line.substring(0, index); + const regexp = /[^ \t]/g; + regexp.lastIndex = index + 2; + const firstNonWhitespace = regexp.exec(line)?.index; + if (firstNonWhitespace === undefined) { + return {before, left: null, text: line.substring(index + 2)}; + } + + const left = line.substring(index + 2, firstNonWhitespace); + const text = line.substring(firstNonWhitespace); + return {before, left, text}; + }); + + // Dart Sass doesn't include the whitespace before the first `//` in + // SilentComment.text, so we grab it directly from the SourceFile. + let i = inner.span.start.offset - 1; + for (; i >= 0; i--) { + const char = inner.span.file.codeUnits[i]; + if (char !== 0x20 && char !== 0x09) break; + } + lineInfo[0].before = inner.span.file.getText( + i + 1, + inner.span.start.offset, + ); + + const before = (this.raws.before = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.before), + )); + this.raws.beforeLines = lineInfo.map(info => + info.before.substring(before.length), + ); + const left = (this.raws.left = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.left).filter(left => left !== null), + )); + this.text = lineInfo + .map(info => (info.left?.substring(left.length) ?? '') + info.text) + .join('\n'); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'silentText'], ['text']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'text'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} + +interceptIsClean(SassComment); diff --git a/pkg/sass-parser/lib/src/statement/supports-rule.test.ts b/pkg/sass-parser/lib/src/statement/supports-rule.test.ts new file mode 100644 index 000000000..cde095733 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/supports-rule.test.ts @@ -0,0 +1,209 @@ +// 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, scss} from '../..'; + +describe('a @supports rule', () => { + let node: GenericAtRule; + + describe('SupportsAnything', () => { + beforeEach( + () => + void (node = scss.parse('@supports ( foo $&#{bar} baz) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('( foo $&'); + expect(params).toHaveStringExpression(1, 'bar'); + expect(params.nodes[2]).toBe(' baz)'); + }); + + it('has matching params', () => + expect(node.params).toBe('( foo $&#{bar} baz)')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ( foo $&#{bar} baz) {}')); + }); + + describe('SupportsDeclaration', () => { + describe('with plain CSS on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports ( foo : bar, #abc, []) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '( foo : bar, #abc, [])', + )); + + it('has matching params', () => + expect(node.params).toBe('( foo : bar, #abc, [])')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ( foo : bar, #abc, []) {}')); + }); + + // Can't test this until variable expressions are supported + describe.skip('with raw SassScript on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports ($foo: $bar) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{$foo}: #{$bar})', + )); + + it('has matching params', () => expect(node.params).toBe('($foo: $bar)')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports ($foo: $bar) {}')); + }); + + describe('with explicit interpolation on both sides', () => { + beforeEach( + () => + void (node = scss.parse('@supports (#{"foo"}: #{"bar"}) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{"foo"}: #{"bar"})', + )); + + it('has matching params', () => + expect(node.params).toBe('(#{"foo"}: #{"bar"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports (#{"foo"}: #{"bar"}) {}')); + }); + }); + + describe('SupportsFunction', () => { + beforeEach( + () => + void (node = scss.parse('@supports foo#{"bar"}(baz &*^ #{"bang"}) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'foo#{"bar"}(baz &*^ #{"bang"})', + )); + + it('has matching params', () => + expect(node.params).toBe('foo#{"bar"}(baz &*^ #{"bang"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe( + '@supports foo#{"bar"}(baz &*^ #{"bang"}) {}', + )); + }); + + describe('SupportsInterpolation', () => { + beforeEach( + () => + void (node = scss.parse('@supports #{"bar"} {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', '#{"bar"}')); + + it('has matching params', () => expect(node.params).toBe('#{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports #{"bar"} {}')); + }); + + describe('SupportsNegation', () => { + describe('with one space', () => { + beforeEach( + () => + void (node = scss.parse('@supports not #{"bar"} {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'not #{"bar"}', + )); + + it('has matching params', () => expect(node.params).toBe('not #{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports not #{"bar"} {}')); + }); + + describe('with a comment', () => { + beforeEach( + () => + void (node = scss.parse('@supports not/**/#{"bar"} {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + 'not/**/#{"bar"}', + )); + + it('has matching params', () => + expect(node.params).toBe('not/**/#{"bar"}')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports not/**/#{"bar"} {}')); + }); + }); + + describe('SupportsOperation', () => { + beforeEach( + () => + void (node = scss.parse('@supports (#{"foo"} or #{"bar"}) {}') + .nodes[0] as GenericAtRule), + ); + + it('has a name', () => expect(node.name).toBe('supports')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation( + 'paramsInterpolation', + '(#{"foo"} or #{"bar"})', + )); + + it('has matching params', () => + expect(node.params).toBe('(#{"foo"} or #{"bar"})')); + + it('stringifies to SCSS', () => + expect(node.toString()).toBe('@supports (#{"foo"} or #{"bar"}) {}')); + }); +}); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts new file mode 100644 index 000000000..fa079fb76 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -0,0 +1,555 @@ +// 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 {Configuration, UseRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @use rule', () => { + let node: UseRule; + describe('with just a URL', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has a default namespace', () => expect(node.namespace).toBe('foo')); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo"')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@use "foo"').nodes[0] as UseRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@use "foo"').nodes[0] as UseRule, + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + }), + ); + }); + + describe('with no namespace', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has a null namespace', () => expect(node.namespace).toBeNull()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo" as *')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@use "foo" as *').nodes[0] as UseRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@use "foo" as *').nodes[0] as UseRule, + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + namespace: null, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + namespace: null, + }), + ); + }); + + describe('with explicit namespace and configuration', () => { + function describeNode(description: string, create: () => UseRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('use-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('use')); + + it('has a url', () => expect(node.useUrl).toBe('foo')); + + it('has an explicit namespace', () => + expect(node.namespace).toBe('bar')); + + it('has a configuration', () => { + expect(node.configuration.size).toBe(1); + expect(node.configuration.parent).toBe(node); + const variables = [...node.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" as bar with ($baz: "qux")')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@use "foo" as bar with ($baz: "qux")').nodes[0] as UseRule, + ); + + describeNode( + 'constructed manually', + () => + new UseRule({ + useUrl: 'foo', + namespace: 'bar', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + useUrl: 'foo', + namespace: 'bar', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new UseRule({useUrl: 'foo'}))); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'bar')).toThrow()); + }); + + it('assigned a new url', () => { + node = new UseRule({useUrl: 'foo'}); + node.useUrl = 'bar'; + expect(node.useUrl).toBe('bar'); + expect(node.params).toBe('"bar" as foo'); + expect(node.defaultNamespace).toBe('bar'); + }); + + it('assigned a new namespace', () => { + node = new UseRule({useUrl: 'foo'}); + node.namespace = 'bar'; + expect(node.namespace).toBe('bar'); + expect(node.params).toBe('"foo" as bar'); + expect(node.defaultNamespace).toBe('foo'); + }); + + it('assigned a new configuration', () => { + node = new UseRule({useUrl: 'foo'}); + node.configuration = new Configuration({ + variables: {bar: {text: 'baz', quotes: true}}, + }); + expect(node.configuration.size).toBe(1); + expect(node.params).toBe('"foo" with ($bar: "baz")'); + }); + + describe('defaultNamespace', () => { + describe('is null for', () => { + it('a URL without a pathname', () => + expect( + new UseRule({useUrl: 'https://example.org'}).defaultNamespace, + ).toBeNull()); + + it('a URL with a slash pathname', () => + expect( + new UseRule({useUrl: 'https://example.org/'}).defaultNamespace, + ).toBeNull()); + + it('a basename that starts with .', () => + expect(new UseRule({useUrl: '.foo'}).defaultNamespace).toBeNull()); + + it('a fragment', () => + expect(new UseRule({useUrl: '#foo'}).defaultNamespace).toBeNull()); + + it('a path that ends in /', () => + expect(new UseRule({useUrl: 'foo/'}).defaultNamespace).toBeNull()); + + it('an invalid identifier', () => + expect(new UseRule({useUrl: '123'}).defaultNamespace).toBeNull()); + }); + + it('the basename', () => + expect(new UseRule({useUrl: 'foo/bar/baz'}).defaultNamespace).toBe( + 'baz', + )); + + it('without an extension', () => + expect(new UseRule({useUrl: 'foo.scss'}).defaultNamespace).toBe('foo')); + + it('the basename of an HTTP URL', () => + expect( + new UseRule({useUrl: 'http://example.org/foo/bar/baz'}) + .defaultNamespace, + ).toBe('baz')); + + it('the basename of a file: URL', () => + expect( + new UseRule({useUrl: 'file:///foo/bar/baz'}).defaultNamespace, + ).toBe('baz')); + + it('the basename of an unknown scheme URL', () => + expect(new UseRule({useUrl: 'foo:bar/bar/qux'}).defaultNamespace).toBe( + 'qux', + )); + + it('a sass: URL', () => + expect(new UseRule({useUrl: 'sass:foo'}).defaultNamespace).toBe('foo')); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with a non-default namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: 'bar', + }).toString(), + ).toBe('@use "foo" as bar;')); + + it('with a non-identifier namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: ' ', + }).toString(), + ).toBe('@use "foo" as \\20;')); + + it('with no namespace', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: null, + }).toString(), + ).toBe('@use "foo" as *;')); + + it('with configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + }).toString(), + ).toBe('@use "foo" with ($bar: "baz");')); + }); + + describe('with a URL raw', () => { + it('that matches', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: "'foo'", value: 'foo'}}, + }).toString(), + ).toBe("@use 'foo';")); + + it("that doesn't match", () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: "'bar'", value: 'bar'}}, + }).toString(), + ).toBe('@use "foo";')); + }); + + describe('with a namespace raw', () => { + it('that matches a string', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {namespace: {raw: ' as foo', value: 'foo'}}, + }).toString(), + ).toBe('@use "foo" as foo;')); + + it('that matches null', () => + expect( + new UseRule({ + useUrl: 'foo', + namespace: null, + raws: {namespace: {raw: ' as *', value: null}}, + }).toString(), + ).toBe('@use "foo" as *;')); + + it("that doesn't match", () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {url: {raw: ' as bar', value: 'bar'}}, + }).toString(), + ).toBe('@use "foo";')); + }); + + describe('with beforeWith', () => { + it('and a configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@use "foo"/**/with ($bar: "baz");')); + + it('and no configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@use "foo";')); + }); + + describe('with afterWith', () => { + it('and a configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@use "foo" with/**/($bar: "baz");')); + + it('and no configuration', () => + expect( + new UseRule({ + useUrl: 'foo', + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@use "foo";')); + }); + }); + }); + + describe('clone', () => { + let original: UseRule; + beforeEach(() => { + original = scss.parse('@use "foo" as bar with ($baz: "qux")') + .nodes[0] as UseRule; + // TODO: remove this once raws are properly parsed + original.raws.beforeWith = ' '; + }); + + describe('with no overrides', () => { + let clone: UseRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + + it('useUrl', () => expect(clone.useUrl).toBe('foo')); + + it('namespace', () => expect(clone.namespace).toBe('bar')); + + it('configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('raws', () => expect(clone.raws).toEqual({beforeWith: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['configuration', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterWith: ' '}}).raws).toEqual({ + afterWith: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + beforeWith: ' ', + })); + }); + + describe('useUrl', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({useUrl: 'flip'}); + }); + + it('changes useUrl', () => expect(clone.useUrl).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe('"flip" as bar with ($baz: "qux")')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({useUrl: undefined}); + }); + + it('preserves useUrl', () => expect(clone.useUrl).toBe('foo')); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + + describe('namespace', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: 'flip'}); + }); + + it('changes namespace', () => expect(clone.namespace).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe('"foo" as flip with ($baz: "qux")')); + }); + + describe('null', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: null}); + }); + + it('changes namespace', () => expect(clone.namespace).toBeNull()); + + it('changes params', () => + expect(clone.params).toBe('"foo" as * with ($baz: "qux")')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({namespace: undefined}); + }); + + it('preserves namespace', () => expect(clone.namespace).toBe('bar')); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + + describe('configuration', () => { + describe('defined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({configuration: new Configuration()}); + }); + + it('changes configuration', () => + expect(clone.configuration.size).toBe(0)); + + it('changes params', () => expect(clone.params).toBe('"foo" as bar')); + }); + + describe('undefined', () => { + let clone: UseRule; + beforeEach(() => { + clone = original.clone({configuration: undefined}); + }); + + it('preserves configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('preserves params', () => + expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + scss.parse('@use "foo" as bar with ($baz: "qux")').nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.ts b/pkg/sass-parser/lib/src/statement/use-rule.ts new file mode 100644 index 000000000..d70b3dff7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/use-rule.ts @@ -0,0 +1,208 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {Configuration, ConfigurationProps} from '../configuration'; +import {StringExpression} from '../expression/string'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link UseRule}. + * + * @category Statement + */ +export interface UseRuleRaws extends Omit { + /** The representation of {@link UseRule.useUrl}. */ + url?: RawWithValue; + + /** + * The text of the explicit namespace value, including `as` and any whitespace + * before it. + * + * Only used if {@link namespace.value} matches {@link UseRule.namespace}. + */ + namespace?: RawWithValue; + + /** + * The whitespace between the URL or namespace and the `with` keyword. + * + * Unused if the rule doesn't have a `with` clause. + */ + beforeWith?: string; + + /** + * The whitespace between the `with` keyword and the configuration map. + * + * Unused unless the rule has a non-empty configuration. + */ + afterWith?: string; +} + +/** + * The initializer properties for {@link UseRule}. + * + * @category Statement + */ +export type UseRuleProps = ContainerProps & { + raws?: UseRuleRaws; + useUrl: string; + namespace?: string | null; + configuration?: Configuration | ConfigurationProps; +}; + +/** + * A `@use` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class UseRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'use-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: UseRuleRaws; + declare readonly nodes: undefined; + + /** The URL loaded by the `@use` rule. */ + declare useUrl: string; + + /** + * This rule's namespace, or `null` if the members can be accessed without a + * namespace. + * + * Note that this is the _semantic_ namespace for the rule, so it's set even + * if the namespace is inferred from the URL. When constructing a new + * `UseRule`, this is set to {@link defaultNamespace} by default unless an + * explicit `null` or string value is passed. + */ + declare namespace: string | null; + + /** + * The default namespace for {@link useUrl} if no explicit namespace is + * specified, or null if there's not a valid default. + */ + get defaultNamespace(): string | null { + // Use a bogus base URL so we can parse relative URLs. + const url = new URL(this.useUrl, 'https://example.org/'); + const basename = url.pathname.split('/').at(-1)!; + const dot = basename.indexOf('.'); + return sassInternal.parseIdentifier( + dot === -1 ? basename : basename.substring(0, dot), + ); + } + + get name(): string { + return 'use'; + } + set name(value: string) { + throw new Error("UseRule.name can't be overwritten."); + } + + get params(): string { + let result = + this.raws.url?.value === this.useUrl + ? this.raws.url!.raw + : new StringExpression({text: this.useUrl, quotes: true}).toString(); + const hasConfiguration = this.configuration.size > 0; + if (this.raws.namespace?.value === this.namespace) { + result += this.raws.namespace?.raw; + } else if (!this.namespace) { + result += ' as *'; + } else if (this.defaultNamespace !== this.namespace) { + result += ' as ' + sassInternal.toCssIdentifier(this.namespace); + } + + if (hasConfiguration) { + result += + `${this.raws.beforeWith ?? ' '}with` + + `${this.raws.afterWith ?? ' '}${this.configuration}`; + } + return result; + } + set params(value: string | number | undefined) { + throw new Error("UseRule.params can't be overwritten."); + } + + /** The variables whose defaults are set when loading this module. */ + get configuration(): Configuration { + return this._configuration!; + } + set configuration(configuration: Configuration | ConfigurationProps) { + if (this._configuration) { + this._configuration.clear(); + this._configuration.parent = undefined; + } + this._configuration = + 'sassType' in configuration + ? configuration + : new Configuration(configuration); + this._configuration.parent = this; + } + private _configuration!: Configuration; + + constructor(defaults: UseRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.UseRule); + constructor(defaults?: UseRuleProps, inner?: sassInternal.UseRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.useUrl = inner.url.toString(); + this.namespace = inner.namespace ?? null; + this.configuration = new Configuration(undefined, inner.configuration); + } else { + this.configuration ??= new Configuration(); + if (this.namespace === undefined) this.namespace = this.defaultNamespace; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'useUrl', + 'namespace', + 'configuration', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['useUrl', 'namespace', 'configuration', 'params'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.configuration]; + } +} + +interceptIsClean(UseRule); diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts new file mode 100644 index 000000000..26bb44391 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.test.ts @@ -0,0 +1,619 @@ +// 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 {StringExpression, VariableDeclaration, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a variable declaration', () => { + let node: VariableDeclaration; + beforeEach( + () => + void (node = new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + })), + ); + + describe('with no namespace and no flags', () => { + function describeNode( + description: string, + create: () => VariableDeclaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('bar')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: bar').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: bar').nodes[0] as VariableDeclaration, + ); + + describe('constructed manually', () => { + describeNode( + 'with an Expression', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar'}), + }), + ); + + describeNode( + 'with child props', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + }), + ); + + describeNode( + 'with a value', + () => + new VariableDeclaration({ + variableName: 'foo', + value: 'bar', + }), + ); + }); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({variableName: 'foo', expression: {text: 'bar'}}), + ); + }); + + describe('with a namespace', () => { + function describeNode( + description: string, + create: () => VariableDeclaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has a namespace', () => expect(node.namespace).toBe('baz')); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('baz.$foo: "bar"').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('baz.$foo: "bar"').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + }), + ); + }); + + describe('guarded', () => { + function describeNode( + description: string, + create: () => VariableDeclaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is guarded', () => expect(node.guarded).toBe(true)); + + it('is not global', () => expect(node.global).toBe(false)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: "bar" !default').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: "bar" !default').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + guarded: true, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + guarded: true, + }), + ); + }); + + describe('global', () => { + function describeNode( + description: string, + create: () => VariableDeclaration, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('decl')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('variable-declaration')); + + it('has no namespace', () => expect(node.namespace).toBeUndefined()); + + it('has a name', () => expect(node.variableName).toBe('foo')); + + it('has an expression', () => + expect(node).toHaveStringExpression('expression', 'bar')); + + it('has a value', () => expect(node.value).toBe('"bar"')); + + it('is not guarded', () => expect(node.guarded).toBe(false)); + + it('is global', () => expect(node.global).toBe(true)); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('$foo: "bar" !global').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('$foo: "bar" !global').nodes[0] as VariableDeclaration, + ); + + describeNode( + 'constructed manually', + () => + new VariableDeclaration({ + variableName: 'foo', + expression: new StringExpression({text: 'bar', quotes: true}), + global: true, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + variableName: 'foo', + expression: {text: 'bar', quotes: true}, + global: true, + }), + ); + }); + + it('throws an error when assigned a new prop', () => + expect(() => (node.prop = 'bar')).toThrow()); + + it('assigned a new namespace', () => { + node.namespace = 'baz'; + expect(node.namespace).toBe('baz'); + expect(node.prop).toBe('baz.$foo'); + }); + + it('assigned a new variableName', () => { + node.variableName = 'baz'; + expect(node.variableName).toBe('baz'); + expect(node.prop).toBe('$baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a new expression', () => { + const old = node.expression; + node.expression = {text: 'baz'}; + expect(old.parent).toBeUndefined(); + expect(node).toHaveStringExpression('expression', 'baz'); + }); + + it('assigned a value', () => { + node.value = 'Helvetica, sans-serif'; + expect(node).toHaveStringExpression('expression', 'Helvetica, sans-serif'); + }); + + it('is a variable', () => expect(node.variable).toBe(true)); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with no flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + }).toString(), + ).toBe('$foo: bar')); + + describe('with a namespace', () => { + it("that's an identifier", () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + }).toString(), + ).toBe('baz.$foo: bar')); + + it("that's not an identifier", () => + expect( + new VariableDeclaration({ + namespace: 'b z', + variableName: 'foo', + expression: {text: 'bar'}, + }).toString(), + ).toBe('b\\20z.$foo: bar')); + }); + + it("with a name that's not an identifier", () => + expect( + new VariableDeclaration({ + variableName: 'f o', + expression: {text: 'bar'}, + }).toString(), + ).toBe('$f\\20o: bar')); + + it('global', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + }).toString(), + ).toBe('$foo: bar !global')); + + it('guarded', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + }).toString(), + ).toBe('$foo: bar !default')); + + it('with both flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + guarded: true, + }).toString(), + ).toBe('$foo: bar !default !global')); + }); + + describe('with a namespace raw', () => { + it('that matches', () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'b\\41z', value: 'baz'}}, + }).toString(), + ).toBe('b\\41z.$foo: bar')); + + it("that doesn't match", () => + expect( + new VariableDeclaration({ + namespace: 'baz', + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'z\\41p', value: 'zap'}}, + }).toString(), + ).toBe('baz.$foo: bar')); + }); + + describe('with a variableName raw', () => { + it('that matches', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {variableName: {raw: 'f\\f3o', value: 'foo'}}, + }).toString(), + ).toBe('$f\\f3o: bar')); + + it("that doesn't match", () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {namespace: {raw: 'z\\41p', value: 'zap'}}, + }).toString(), + ).toBe('$foo: bar')); + }); + + it('with between', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {between: '/**/:'}, + }).toString(), + ).toBe('$foo/**/:bar')); + + describe('with a flags raw', () => { + it('that matches both', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!default', + value: {guarded: true, global: false}, + }, + }, + }).toString(), + ).toBe('$foo: bar/**/!default')); + + it('that matches only one', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!default !global', + value: {guarded: true, global: true}, + }, + }, + }).toString(), + ).toBe('$foo: bar !default')); + + it('that matches neither', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + guarded: true, + raws: { + flags: { + raw: '/**/!global', + value: {guarded: false, global: true}, + }, + }, + }).toString(), + ).toBe('$foo: bar !default')); + }); + + describe('with an afterValue raw', () => { + it('without flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('$foo: bar/**/')); + + it('with flags', () => + expect( + new VariableDeclaration({ + variableName: 'foo', + expression: {text: 'bar'}, + global: true, + raws: {afterValue: '/**/'}, + }).toString(), + ).toBe('$foo: bar !global/**/')); + }); + }); + }); + + describe('clone', () => { + let original: VariableDeclaration; + beforeEach(() => { + original = scss.parse('baz.$foo: bar !default') + .nodes[0] as VariableDeclaration; + // TODO: remove this once raws are properly parsed + original.raws.between = ' :'; + }); + + describe('with no overrides', () => { + let clone: VariableDeclaration; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('prop', () => expect(clone.prop).toBe('baz.$foo')); + + it('namespace', () => expect(clone.namespace).toBe('baz')); + + it('variableName', () => expect(clone.variableName).toBe('foo')); + + it('expression', () => + expect(clone).toHaveStringExpression('expression', 'bar')); + + it('global', () => expect(clone.global).toBe(false)); + + it('guarded', () => expect(clone.guarded).toBe(true)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['expression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterValue: ' '}}).raws).toEqual({ + afterValue: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' :', + })); + }); + + describe('namespace', () => { + describe('defined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({namespace: 'zap'}); + }); + + it('changes namespace', () => expect(clone.namespace).toBe('zap')); + + it('changes prop', () => expect(clone.prop).toBe('zap.$foo')); + }); + + describe('undefined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({namespace: undefined}); + }); + + it('removes namespace', () => + expect(clone.namespace).toBeUndefined()); + + it('changes prop', () => expect(clone.prop).toBe('$foo')); + }); + }); + + describe('variableName', () => { + describe('defined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({variableName: 'zap'}); + }); + + it('changes variableName', () => + expect(clone.variableName).toBe('zap')); + + it('changes prop', () => expect(clone.prop).toBe('baz.$zap')); + }); + + describe('undefined', () => { + let clone: VariableDeclaration; + beforeEach(() => { + clone = original.clone({variableName: undefined}); + }); + + it('preserves variableName', () => + expect(clone.variableName).toBe('foo')); + + it('preserves prop', () => expect(clone.prop).toBe('baz.$foo')); + }); + }); + + describe('expression', () => { + it('defined changes expression', () => + expect( + original.clone({expression: {text: 'zap'}}), + ).toHaveStringExpression('expression', 'zap')); + + it('undefined preserves expression', () => + expect( + original.clone({expression: undefined}), + ).toHaveStringExpression('expression', 'bar')); + }); + + describe('guarded', () => { + it('defined changes guarded', () => + expect(original.clone({guarded: false}).guarded).toBe(false)); + + it('undefined preserves guarded', () => + expect(original.clone({guarded: undefined}).guarded).toBe(true)); + }); + + describe('global', () => { + it('defined changes global', () => + expect(original.clone({global: true}).global).toBe(true)); + + it('undefined preserves global', () => + expect(original.clone({global: undefined}).global).toBe(false)); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('baz.$foo: "bar"').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/variable-declaration.ts b/pkg/sass-parser/lib/src/statement/variable-declaration.ts new file mode 100644 index 000000000..50377d802 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/variable-declaration.ts @@ -0,0 +1,228 @@ +// 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 {DeclarationRaws} from 'postcss/lib/declaration'; + +import {Expression, ExpressionProps} from '../expression'; +import {convertExpression} from '../expression/convert'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_Declaration} from './declaration-internal'; + +/** + * The set of raws supported by {@link VariableDeclaration}. + * + * @category Statement + */ +export interface VariableDeclarationRaws + extends Omit { + /** + * The variable's namespace. + * + * This may be different than {@link VariableDeclarationRaws.namespace} if the + * name contains escape codes or underscores. + */ + namespace?: RawWithValue; + + /** + * The variable's name, not including the `$`. + * + * This may be different than {@link VariableDeclarationRaws.variableName} if + * the name contains escape codes or underscores. + */ + variableName?: RawWithValue; + + /** The whitespace and colon between the variable name and value. */ + between?: string; + + /** The `!default` and/or `!global` flags, including preceding whitespace. */ + flags?: RawWithValue<{guarded: boolean; global: boolean}>; + + /** + * The space symbols between the end of the variable declaration and the + * semicolon afterwards. Always empty for a variable that isn't followed by a + * semicolon. + */ + afterValue?: string; +} + +/** + * The initializer properties for {@link VariableDeclaration}. + * + * @category Statement + */ +export type VariableDeclarationProps = { + raws?: VariableDeclarationRaws; + namespace?: string; + variableName: string; + guarded?: boolean; + global?: boolean; +} & ({expression: Expression | ExpressionProps} | {value: string}); + +/** + * A Sass variable declaration. Extends [`postcss.Declaration`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#declaration + * + * @category Statement + */ +export class VariableDeclaration + extends _Declaration> + implements Statement +{ + readonly sassType = 'variable-declaration' as const; + declare parent: StatementWithChildren | undefined; + declare raws: VariableDeclarationRaws; + + /** + * The variable name, not including `$`. + * + * This is the parsed value, with escapes resolved to the characters they + * represent. + */ + declare namespace: string | undefined; + + /** + * The variable name, not including `$`. + * + * This is the parsed and normalized value, with underscores converted to + * hyphens and escapes resolved to the characters they represent. + */ + declare variableName: string; + + /** The variable's value. */ + get expression(): Expression { + return this._expression; + } + set expression(value: Expression | ExpressionProps) { + if (this._expression) this._expression.parent = undefined; + if (!('sassType' in value)) value = fromProps(value); + if (value) value.parent = this; + this._expression = value; + } + private _expression!: Expression; + + /** Whether the variable has a `!default` flag. */ + declare guarded: boolean; + + /** Whether the variable has a `!global` flag. */ + declare global: boolean; + + get prop(): string { + return ( + (this.namespace + ? (this.raws.namespace?.value === this.namespace + ? this.raws.namespace.raw + : sassInternal.toCssIdentifier(this.namespace)) + '.' + : '') + + '$' + + (this.raws.variableName?.value === this.variableName + ? this.raws.variableName.raw + : sassInternal.toCssIdentifier(this.variableName)) + ); + } + set prop(value: string) { + throw new Error("VariableDeclaration.prop can't be overwritten."); + } + + get value(): string { + return this.expression.toString(); + } + set value(value: string) { + this.expression = {text: value}; + } + + get important(): boolean { + // TODO: Return whether `this.expression` is a nested series of unbracketed + // list expressions that ends in the unquoted string `!important` (or an + // unquoted string ending in " !important", which can occur if `value` is + // set // manually). + throw new Error('Not yet implemented'); + } + set important(value: boolean) { + // TODO: If value !== this.important, either set this to a space-separated + // list whose second value is `!important` or remove the existing + // `!important` from wherever it's defined. Or if that's too complex, just + // bake this to a string expression and edit that. + throw new Error('Not yet implemented'); + } + + get variable(): boolean { + return true; + } + + constructor(defaults: VariableDeclarationProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.VariableDeclaration); + constructor( + defaults?: VariableDeclarationProps, + inner?: sassInternal.VariableDeclaration, + ) { + super(defaults as unknown as postcss.DeclarationProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.namespace = inner.namespace ? inner.namespace : undefined; + this.variableName = inner.name; + this.expression = convertExpression(inner.expression); + this.guarded = inner.isGuarded; + this.global = inner.isGlobal; + } else { + this.guarded ??= false; + this.global ??= false; + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + [ + 'raws', + {name: 'namespace', explicitUndefined: true}, + 'variableName', + 'expression', + 'guarded', + 'global', + ], + ['value'], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['namespace', 'variableName', 'expression', 'guarded', 'global'], + inputs, + ); + } + + /** @hidden */ + toString(): string { + return ( + this.prop + + (this.raws.between ?? ': ') + + this.expression + + (this.raws.flags?.value?.guarded === this.guarded && + this.raws.flags?.value?.global === this.global + ? this.raws.flags.raw + : (this.guarded ? ' !default' : '') + (this.global ? ' !global' : '')) + + (this.raws.afterValue ?? '') + ); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.expression]; + } +} diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.test.ts b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts new file mode 100644 index 000000000..a314ada09 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts @@ -0,0 +1,205 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {StringExpression, WarnRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @warn rule', () => { + let node: WarnRule; + function describeNode(description: string, create: () => WarnRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('warn')); + + it('has an expression', () => + expect(node).toHaveStringExpression('warnExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@warn foo').nodes[0] as WarnRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@warn foo').nodes[0] as WarnRule, + ); + + describeNode( + 'constructed manually', + () => + new WarnRule({ + warnExpression: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + warnExpression: {text: 'foo'}, + }), + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new WarnRule({ + warnExpression: {text: 'foo'}, + }).name = 'bar'), + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@warn foo').nodes[0] as WarnRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.warnExpression; + node.warnExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(node.warnExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.warnExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + }).toString(), + ).toBe('@warn foo;')); + + it('with afterName', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@warn/**/foo;')); + + it('with between', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@warn foo/**/;')); + }); + }); + + describe('clone', () => { + let original: WarnRule; + beforeEach(() => { + original = scss.parse('@warn foo').nodes[0] as WarnRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: WarnRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['warnExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('warnExpression', () => { + describe('defined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@warn foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.ts b/pkg/sass-parser/lib/src/statement/warn-rule.ts new file mode 100644 index 000000000..cc2529ece --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.ts @@ -0,0 +1,129 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleProps = postcss.NodeProps & { + raws?: WarnRuleRaws; + warnExpression: Expression | ExpressionProps; +}; + +/** + * A `@warn` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class WarnRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'warn-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: WarnRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'warn'; + } + set name(value: string) { + throw new Error("WarnRule.name can't be overwritten."); + } + + get params(): string { + return this.warnExpression.toString(); + } + set params(value: string | number | undefined) { + this.warnExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the warn rule is executed. */ + get warnExpression(): Expression { + return this._warnExpression!; + } + set warnExpression(warnExpression: Expression | ExpressionProps) { + if (this._warnExpression) this._warnExpression.parent = undefined; + if (!('sassType' in warnExpression)) { + warnExpression = fromProps(warnExpression); + } + if (warnExpression) warnExpression.parent = this; + this._warnExpression = warnExpression; + } + private _warnExpression?: Expression; + + constructor(defaults: WarnRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.WarnRule); + constructor(defaults?: WarnRuleProps, inner?: sassInternal.WarnRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.warnExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'warnExpression'], + [{name: 'params', explicitUndefined: true}], + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'warnExpression', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.warnExpression]; + } +} + +interceptIsClean(WarnRule); diff --git a/pkg/sass-parser/lib/src/statement/while-rule.test.ts b/pkg/sass-parser/lib/src/statement/while-rule.test.ts new file mode 100644 index 000000000..473534dee --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/while-rule.test.ts @@ -0,0 +1,239 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, StringExpression, WhileRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @while rule', () => { + let node: WhileRule; + describe('with empty children', () => { + function describeNode(description: string, create: () => WhileRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('while')); + + it('has an expression', () => + expect(node).toHaveStringExpression('whileCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has empty nodes', () => expect(node.nodes).toEqual([])); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@while foo {}').nodes[0] as WhileRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@while foo').nodes[0] as WhileRule, + ); + + describeNode( + 'constructed manually', + () => + new WhileRule({ + whileCondition: {text: 'foo'}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + whileCondition: {text: 'foo'}, + }), + ); + }); + + describe('with a child', () => { + function describeNode(description: string, create: () => WhileRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('while')); + + it('has an expression', () => + expect(node).toHaveStringExpression('whileCondition', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has a child node', () => { + expect(node.nodes).toHaveLength(1); + expect(node.nodes[0]).toBeInstanceOf(GenericAtRule); + expect(node.nodes[0]).toHaveProperty('name', 'child'); + }); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@while foo {@child}').nodes[0] as WhileRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@while foo\n @child').nodes[0] as WhileRule, + ); + + describeNode( + 'constructed manually', + () => + new WhileRule({ + whileCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + whileCondition: {text: 'foo'}, + nodes: [{name: 'child'}], + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach( + () => void (node = new WhileRule({whileCondition: {text: 'foo'}})), + ); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'true')).toThrow()); + }); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@while foo {}').nodes[0] as WhileRule; + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.whileCondition; + node.whileCondition = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.whileCondition = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.whileCondition = expression; + expect(node.whileCondition).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.whileCondition = {text: 'bar'}; + expect(node).toHaveStringExpression('whileCondition', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + }).toString(), + ).toBe('@while foo {}')); + + it('with afterName', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString(), + ).toBe('@while/**/foo {}')); + + it('with between', () => + expect( + new WhileRule({ + whileCondition: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString(), + ).toBe('@while foo/**/{}')); + }); + }); + + describe('clone', () => { + let original: WhileRule; + beforeEach(() => { + original = scss.parse('@while foo {}').nodes[0] as WhileRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: WhileRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['whileCondition', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('whileCondition', () => { + describe('defined', () => { + let clone: WhileRule; + beforeEach(() => { + clone = original.clone({whileCondition: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'bar')); + }); + + describe('undefined', () => { + let clone: WhileRule; + beforeEach(() => { + clone = original.clone({whileCondition: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves whileCondition', () => + expect(clone).toHaveStringExpression('whileCondition', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@while foo {}').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/while-rule.ts b/pkg/sass-parser/lib/src/statement/while-rule.ts new file mode 100644 index 000000000..9284454d4 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/while-rule.ts @@ -0,0 +1,136 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {AtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import { + ChildNode, + ContainerProps, + NewNode, + Statement, + StatementWithChildren, + appendInternalChildren, + normalize, +} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link WhileRule}. + * + * @category Statement + */ +export type WhileRuleRaws = Omit; + +/** + * The initializer properties for {@link WhileRule}. + * + * @category Statement + */ +export type WhileRuleProps = ContainerProps & { + raws?: WhileRuleRaws; + whileCondition: Expression | ExpressionProps; +}; + +/** + * A `@while` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class WhileRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'while-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: WhileRuleRaws; + declare nodes: ChildNode[]; + + get name(): string { + return 'while'; + } + set name(value: string) { + throw new Error("WhileRule.name can't be overwritten."); + } + + get params(): string { + return this.whileCondition.toString(); + } + set params(value: string | number | undefined) { + throw new Error("WhileRule.params can't be overwritten."); + } + + /** The expresison whose value is emitted when the while rule is executed. */ + get whileCondition(): Expression { + return this._whileCondition!; + } + set whileCondition(whileCondition: Expression | ExpressionProps) { + if (this._whileCondition) this._whileCondition.parent = undefined; + if (!('sassType' in whileCondition)) { + whileCondition = fromProps(whileCondition); + } + if (whileCondition) whileCondition.parent = this; + this._whileCondition = whileCondition; + } + private _whileCondition?: Expression; + + constructor(defaults: WhileRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.WhileRule); + constructor(defaults?: WhileRuleProps, inner?: sassInternal.WhileRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.nodes ??= []; + + if (inner) { + this.source = new LazySource(inner); + this.whileCondition = convertExpression(inner.condition); + appendInternalChildren(this, inner.children); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'whileCondition']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'whileCondition', 'params', 'nodes'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.whileCondition]; + } + + /** @hidden */ + normalize(node: NewNode, sample?: postcss.Node): ChildNode[] { + return normalize(this, node, sample); + } +} + +interceptIsClean(WhileRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts new file mode 100644 index 000000000..8ad173e58 --- /dev/null +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -0,0 +1,191 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +// Portions of this source file are adapted from the PostCSS codebase under the +// terms of the following license: +// +// The MIT License (MIT) +// +// Copyright 2013 Andrey Sitnik +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import * as postcss from 'postcss'; + +import {AnyStatement} from './statement'; +import {DebugRule} from './statement/debug-rule'; +import {EachRule} from './statement/each-rule'; +import {ErrorRule} from './statement/error-rule'; +import {ForRule} from './statement/for-rule'; +import {ForwardRule} from './statement/forward-rule'; +import {GenericAtRule} from './statement/generic-at-rule'; +import {Rule} from './statement/rule'; +import {SassComment} from './statement/sass-comment'; +import {UseRule} from './statement/use-rule'; +import {WarnRule} from './statement/warn-rule'; +import {WhileRule} from './statement/while-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 ['debug-rule'](node: DebugRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['each-rule'](node: EachRule): void { + this.sassAtRule(node); + } + + private ['error-rule'](node: ErrorRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['for-rule'](node: ForRule): void { + this.sassAtRule(node); + } + + private ['forward-rule'](node: ForwardRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private atrule(node: GenericAtRule, semicolon: boolean): void { + // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as + // `@at-root .foo {...}`. + if ( + node.raws.atRootShorthand && + node.name === 'at-root' && + node.paramsInterpolation === undefined && + node.nodes.length === 1 && + node.nodes[0].sassType === 'rule' + ) { + this.block( + node.nodes[0], + '@at-root' + + (node.raws.afterName ?? ' ') + + node.nodes[0].selectorInterpolation, + ); + return; + } + + const start = + `@${node.nameInterpolation}` + + (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) + + node.params; + if (node.nodes) { + this.block(node, start); + } else { + this.builder( + start + (node.raws.between ?? '') + (semicolon ? ';' : ''), + node, + ); + } + } + + private rule(node: Rule): void { + this.block(node, node.selectorInterpolation.toString()); + } + + private ['sass-comment'](node: SassComment): void { + const before = node.raws.before ?? ''; + const left = node.raws.left ?? ' '; + let text = node.text + .split('\n') + .map( + (line, i) => + before + + (node.raws.beforeLines?.[i] ?? '') + + '//' + + (/[^ \t]/.test(line) ? left : '') + + line, + ) + .join('\n'); + + // Ensure that a Sass-style comment always has a newline after it unless + // it's the last node in the document. + const next = node.next(); + if (next && !this.raw(next, 'before').startsWith('\n')) { + text += '\n'; + } else if ( + !next && + node.parent && + !this.raw(node.parent, 'after').startsWith('\n') + ) { + text += '\n'; + } + + this.builder(text, node); + } + + private ['use-rule'](node: UseRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['warn-rule'](node: WarnRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + + private ['while-rule'](node: WhileRule): void { + this.sassAtRule(node); + } + + /** Helper method for non-generic Sass at-rules. */ + private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void { + const start = '@' + node.name + (node.raws.afterName ?? ' ') + node.params; + if (node.nodes) { + this.block(node, start); + } else { + this.builder( + start + (node.raws.between ?? '') + (semicolon ? ';' : ''), + node, + ); + } + } +} diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts new file mode 100644 index 000000000..f73eab798 --- /dev/null +++ b/pkg/sass-parser/lib/src/utils.ts @@ -0,0 +1,232 @@ +// 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 (Symbol.iterator in value) { + return ( + Array.isArray(value) ? value : [...(value as IterableIterator)] + ).map((element, i) => toJsonField(i.toString(), element, inputs)); + } else if ('toJSON' in value) { + if ('sassType' in value) { + return ( + value as { + toJSON: (field: string, inputs: Map) => object; + } + ).toJSON('', inputs); + } else { + return (value as {toJSON: (field: string) => object}).toJSON(field); + } + } else { + return value; + } +} + +/** + * Returns the longest string (of code units) that's an initial substring of + * every string in + * {@link strings}. + */ +export function longestCommonInitialSubstring(strings: string[]): string { + let candidate: string | undefined; + for (const string of strings) { + if (candidate === undefined) { + candidate = string; + } else { + for (let i = 0; i < candidate.length && i < string.length; i++) { + if (candidate.charCodeAt(i) !== string.charCodeAt(i)) { + candidate = candidate.substring(0, i); + break; + } + } + candidate = candidate.substring( + 0, + Math.min(candidate.length, string.length), + ); + } + } + return candidate ?? ''; +} + +/** + * Returns whether {@link set1} and {@link set2} contain the same elements, + * regardless of order. + */ +export function setsEqual(set1: Set, set2: Set): boolean { + if (set1 === set2) return true; + if (set1.size !== set2.size) return false; + for (const element of set1) { + if (!set2.has(element)) return false; + } + return true; +} diff --git a/pkg/sass-parser/package.json b/pkg/sass-parser/package.json new file mode 100644 index 000000000..d3232dd2c --- /dev/null +++ b/pkg/sass-parser/package.json @@ -0,0 +1,51 @@ +{ + "name": "sass-parser", + "version": "0.4.5", + "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": "file:../../build/npm" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "copyfiles": "^2.4.1", + "expect": "^29.7.0", + "gts": "^6.0.2", + "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..cbafd0682 --- /dev/null +++ b/pkg/sass-parser/test/setup.ts @@ -0,0 +1,295 @@ +// 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'; + +/** + * Like {@link MatcherContext.printReceived}, but with special handling for AST + * nodes. + */ +function printValue(self: MatcherContext, value: unknown): string { + return value instanceof postcss.Node + ? value.toString() + : self.utils.printReceived(value); +} + +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 ${printValue( + this, + actual, + )} to have a property ${this.utils.printExpected(property)}`, + pass: false, + }; + } + + const actualValue = (actual as Record)[property]; + const message = (): string => + `expected (${printValue(this, actual)}).${property} ${printValue( + this, + actualValue, + )} to be an Interpolation with value ${this.utils.printExpected(value)}`; + + if ( + !(actualValue instanceof Interpolation) || + actualValue.toString() !== value + ) { + return { + message, + pass: false, + }; + } + + if (actualValue.parent !== actual) { + return { + message: () => + `expected (${printValue(this, actual)}).${property} ${printValue( + this, + 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 ${printValue( + this, + 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 (${printValue(this, actual)}).${property}`; + if (index !== null) message += `[${index}]`; + + return ( + message + + ` ${printValue( + this, + 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 (${printValue(this, actual)}).${property} ${printValue( + this, + 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..741be6a71 --- /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..50261e574 --- /dev/null +++ b/pkg/sass-parser/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "lib": ["es2022"], + "allowJs": true, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "useUnknownInCatchVariables": false, + "useDefineForClassFields": 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 9b9e2c55c..b65d12b7d 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,274 @@ +## 14.2.0 + +* No user-visible changes. + +## 14.1.3 + +* No user-visible changes. + +## 14.1.2 + +* No user-visible changes. + +## 14.1.1 + +* No user-visible changes. + +## 14.1.0 + +* Add `Expression.isCalculationSafe`, which returns true when this expression + can safely be used in a calcuation. + +## 14.0.0 + +* **Breaking change:** Warnings are no longer emitted during parsing, so the + `logger` parameter has been removed from the following: + + * `ArgumentDeclaration.parse()`, `ComplexSelector.parse()`, + `CompoundSelector.parse()`, `Expression.parse()`, `SelectorList.parse()`, + and `SimpleSelector.parse()`. + + * `Stylesheet.parse()`, `Stylesheet.parseCss()`, `Stylesheet.parseSass()`, + and `Stylesheet.parseScss()`. + + * The `AsyncImportCache` and `ImportCache` constructors. + + Additionally, the `quiet` parameter has been removed from + `AsyncImportCache.importCanonical()` and `ImportCache.importCanonical()`, and + `AsyncImportCache.wrapLogger()` and `ImportCache.wrapLogger()` have been + removed entirely. + +## 13.1.2 + +* No user-visible changes. + +## 13.1.1 + +* Make `AsyncImportCache.wrapLogger()` and `ImportCache.wrapLogger()` always + limit the repetition of deprecations. this is unlikely to be the long-term + behavior, but it's necessary to avoid flooding users with deprecations in the + short term. + +## 13.1.0 + +* Add `AsyncImportCache.wrapLogger()` and `ImportCache.wrapLogger()` methods, + which wrap a given logger to apply deprecation options to it. + +## 13.0.1 + +* Fix a bug where `LoudComment`s parsed from the indented syntax would include + whitespace after the closing `*/`. + +## 13.0.0 + +* The `Interpolation()` constructor now takes an additional `List` + spans argument which cover the `#{}` for expression elements. + +* Added a new `Interpolation.plain()` constructor for interpolations that only + contain a single plain-text string. + +* Added `Interpolation.spanForElement()` which returns the span that covers a + single element of `contents`. + +* `InterpolationBuffer.add()` now takes a `FileSpan` that covers the `#{}` + around the expression. + +## 12.0.5 + +* No user-visible changes. + +## 12.0.4 + +* No user-visible changes. + +## 12.0.3 + +* No user-visible changes. + +## 12.0.2 + +* No user-visible changes. + +## 12.0.1 + +* No user-visible changes. + +## 12.0.0 + +* **Breaking change:** Remove the `SassApiColor.hasCalculatedRgb` and + `.hasCalculatedHsl` extension methods. These can now be determined by checking + if `SassColor.space` is `KnownColorSpace.rgb` or `KnownColorSpace.hsl`, + respectively. + +* Added a `ColorSpace` class which represents the various color spaces defined + in the CSS spec. + +* Added `SassColor.space` which returns a color's color space. + +* Added `SassColor.channels` and `.channelsOrNull` which returns a list + of channel values, with missing channels converted to 0 or exposed as null, + respectively. + +* Added `SassColor.isLegacy`, `.isInGamut`, `.channel()`, `.isChannelMissing()`, + `.isChannelPowerless()`, `.toSpace()`, `.toGamut()`, `.changeChannels()`, and + `.interpolate()` which do the same thing as the Sass functions of the + corresponding names. + +* `SassColor.rgb()` now allows out-of-bounds and non-integer arguments. + +* `SassColor.hsl()` and `.hwb()` now allow out-of-bounds arguments. + +* Added `SassColor.hwb()`, `.srgb()`, `.srgbLinear()`, `.displayP3()`, + `.a98Rgb()`, `.prophotoRgb()`, `.rec2020()`, `.xyzD50()`, `.xyzD65()`, + `.lab()`, `.lch()`, `.oklab()`, `.oklch()`, and `.forSpace()` constructors. + +* Deprecated `SassColor.red`, `.green`, `.blue`, `.hue`, `.saturation`, + `.lightness`, `.whiteness`, and `.blackness` in favor of + `SassColor.channel()`. + +* Deprecated `SassColor.changeRgb()`, `.changeHsl()`, and `.changeHwb()` in + favor of `SassColor.changeChannels()`. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + +* Added `InterpolationMethod` and `HueInterpolationMethod` which collectively + represent the method to use to interpolate two colors. + +* Added the `SassApiColorSpace` extension to expose additional members of + `ColorSpace`. + +* Added the `ColorChannel` class to represent information about a single channel + of a color space. + +* Added `SassNumber.convertValueToUnit()` as a shorthand for + `SassNumber.convertValue()` with a single numerator. + +## 11.1.0 + +* Loud comments in the Sass syntax no longer automatically inject ` */` to the + end when parsed. + +## 11.0.0 + +* Remove the `CallableDeclaration()` constructor. + +## 10.4.8 + +* No user-visible changes. + +## 10.4.7 + +* No user-visible changes. + +## 10.4.6 + +* No user-visible changes. + +## 10.4.5 + +* No user-visible changes. + +## 10.4.4 + +* No user-visible changes. + +## 10.4.3 + +* No user-visible changes. + +## 10.4.2 + +* No user-visible changes. + +## 10.4.1 + +* No user-visible changes. + +## 10.4.0 + +* No user-visible changes. + +## 10.3.0 + +* No user-visible changes. + +## 10.2.1 + +* No user-visible changes. + +## 10.2.0 + +* No user-visible changes. + +## 10.1.1 + +* No user-visible changes. + +## 10.1.0 + +* No user-visible changes. + +## 10.0.0 + +* Remove the `allowPlaceholders` argument from `SelectorList.parse()`. Instead, + it now has a more generic `plainCss` argument which tells it to parse the + selector in plain CSS mode. + +* Rename `SelectorList.resolveParentSelectors` to `SelectorList.nestWithin`. + +## 9.5.0 + +* No user-visible changes. + +## 9.4.2 + +* No user-visible changes. + +## 9.4.1 + +* No user-visible changes. + +## 9.4.0 + +* No user-visible changes. + +## 9.3.0 + +* No user-visible changes. + +## 9.2.7 + +* No user-visible changes. + +## 9.2.6 + +* No user-visible changes. + +## 9.2.5 + +* No user-visible changes. + +## 9.2.4 + +* No user-visible changes. + +## 9.2.3 + +* No user-visible changes. + +## 9.2.2 + +* No user-visible changes. + +## 9.2.1 + +* No user-visible changes. + +## 9.2.0 + +* No user-visible changes. + ## 9.1.0 * No user-visible changes. @@ -35,7 +306,7 @@ * All uses of classes from the `tuple` package have been replaced by record types. - + ## 7.2.2 * No user-visible changes. @@ -173,8 +444,6 @@ ## 4.0.0 -### Dart API - * **Breaking change:** The first argument to `NumberExpression()` is now a `double` rather than a `num`. diff --git a/pkg/sass_api/lib/sass_api.dart b/pkg/sass_api/lib/sass_api.dart index b0369f908..7190a49fd 100644 --- a/pkg/sass_api/lib/sass_api.dart +++ b/pkg/sass_api/lib/sass_api.dart @@ -7,7 +7,6 @@ library sass; // ignore_for_file: implementation_imports -import 'package:sass/sass.dart'; import 'package:sass/src/parse/parser.dart'; export 'package:sass/sass.dart'; @@ -36,11 +35,9 @@ export 'package:sass/src/visitor/statement_search.dart'; /// Throws a [SassFormatException] if parsing fails. /// /// {@category Parsing} -String parseIdentifier(String text) => - Parser.parseIdentifier(text, logger: Logger.quiet); +String parseIdentifier(String text) => Parser.parseIdentifier(text); /// Returns whether [text] is a valid CSS identifier. /// /// {@category Parsing} -bool isIdentifier(String text) => - Parser.isIdentifier(text, logger: Logger.quiet); +bool isIdentifier(String text) => Parser.isIdentifier(text); diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 3fb53a144..fd8ce6638 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,18 +2,18 @@ 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: 9.1.0 +version: 14.2.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.3.0 <4.0.0" dependencies: - sass: 1.68.0 + sass: 1.81.0 dev_dependencies: - dartdoc: ^6.0.0 + dartdoc: ^8.0.14 dependency_overrides: sass: { path: ../.. } diff --git a/pubspec.yaml b/pubspec.yaml index 021a583c8..9acd22205 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.68.0 +version: 1.81.0 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -8,19 +8,19 @@ executables: sass: sass environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.3.0 <4.0.0" dependencies: args: ^2.0.0 async: ^2.5.0 charcode: ^1.2.0 - cli_pkg: ^2.5.0 + cli_pkg: ^2.11.0 cli_repl: ^0.2.1 collection: ^1.16.0 - http: "^1.1.0" + http: ^1.1.0 js: ^0.6.3 meta: ^1.3.0 - native_synchronization: ^0.2.0 + native_synchronization: ^0.3.0 node_interop: ^2.1.0 package_config: ^2.0.0 path: ^1.8.0 @@ -32,23 +32,23 @@ dependencies: stack_trace: ^1.10.0 stream_channel: ^2.1.0 stream_transform: ^2.0.0 - string_scanner: ^1.1.0 + string_scanner: ^1.3.0 term_glyph: ^1.2.0 typed_data: ^1.1.0 watcher: ^1.0.0 dev_dependencies: - analyzer: ">=5.13.0 <7.0.0" + analyzer: ^6.8.0 archive: ^3.1.2 crypto: ^3.0.0 dart_style: ^2.0.0 - dartdoc: ^6.0.0 + dartdoc: ^8.0.14 grinder: ^0.9.0 node_preamble: ^2.0.2 - lints: ^2.0.0 - protoc_plugin: ">=20.0.0 <22.0.0" + lints: ^4.0.0 + protoc_plugin: ^21.1.2 pub_api_client: ^2.1.1 - pubspec_parse: ^1.0.0 + pubspec_parse: ^1.3.0 test: ^1.16.7 test_descriptor: ^2.0.0 test_process: ^2.0.0 diff --git a/test/browser_test.dart b/test/browser_test.dart index 4e4aa4af8..a6efa9785 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -1,4 +1,5 @@ @TestOn('browser') +library; import 'package:js/js.dart'; import 'package:node_interop/js.dart'; diff --git a/test/cli/dart/colon_args_test.dart b/test/cli/dart/colon_args_test.dart index 6a8da9f78..e91700110 100644 --- a/test/cli/dart/colon_args_test.dart +++ b/test/cli/dart/colon_args_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/cli/dart/deprecations_test.dart b/test/cli/dart/deprecations_test.dart new file mode 100644 index 000000000..babccae82 --- /dev/null +++ b/test/cli/dart/deprecations_test.dart @@ -0,0 +1,16 @@ +// 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. + +@TestOn('vm') +library; + +import 'package:test/test.dart'; + +import '../dart_test.dart'; +import '../shared/deprecations.dart'; + +void main() { + setUpAll(ensureSnapshotUpToDate); + sharedTests(runSass); +} diff --git a/test/cli/dart/errors_test.dart b/test/cli/dart/errors_test.dart index 478d2129f..85c269865 100644 --- a/test/cli/dart/errors_test.dart +++ b/test/cli/dart/errors_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -15,7 +16,7 @@ void main() { sharedTests(runSass); test("for package urls", () async { - await d.file("test.scss", "@import 'package:nope/test';").create(); + await d.file("test.scss", "@use 'package:nope/test';").create(); var sass = await runSass(["--no-unicode", "test.scss"]); expect( @@ -23,10 +24,10 @@ void main() { emitsInOrder([ "Error: Can't find stylesheet to import.", " ,", - "1 | @import 'package:nope/test';", - " | ^^^^^^^^^^^^^^^^^^^", + "1 | @use 'package:nope/test';", + " | ^^^^^^^^^^^^^^^^^^^^^^^^", " '", - " test.scss 1:9 root stylesheet" + " test.scss 1:1 root stylesheet" ])); await sass.shouldExit(65); }); diff --git a/test/cli/dart/repl_test.dart b/test/cli/dart/repl_test.dart index d19641e14..36828952d 100644 --- a/test/cli/dart/repl_test.dart +++ b/test/cli/dart/repl_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/cli/dart/source_maps_test.dart b/test/cli/dart/source_maps_test.dart index 133bf719d..7a97e5bd0 100644 --- a/test/cli/dart/source_maps_test.dart +++ b/test/cli/dart/source_maps_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/cli/dart/update_test.dart b/test/cli/dart/update_test.dart index ef401f916..12351e91b 100644 --- a/test/cli/dart/update_test.dart +++ b/test/cli/dart/update_test.dart @@ -4,6 +4,7 @@ // OS X's modification time reporting is flaky, so we skip these tests on it. @TestOn('vm && !mac-os') +library; import 'package:test/test.dart'; diff --git a/test/cli/dart/watch_test.dart b/test/cli/dart/watch_test.dart index b50568d4f..7288dc468 100644 --- a/test/cli/dart/watch_test.dart +++ b/test/cli/dart/watch_test.dart @@ -8,6 +8,7 @@ // File watching is inherently flaky at the OS level. To mitigate this, we do a // few retries when the tests fail. @Retry(3) +library; import 'package:test/test.dart'; diff --git a/test/cli/dart_test.dart b/test/cli/dart_test.dart index 27a14c626..4c8ee35c6 100644 --- a/test/cli/dart_test.dart +++ b/test/cli/dart_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:convert'; diff --git a/test/cli/node/colon_args_test.dart b/test/cli/node/colon_args_test.dart index 0fb26c139..3852ee2db 100644 --- a/test/cli/node/colon_args_test.dart +++ b/test/cli/node/colon_args_test.dart @@ -4,6 +4,7 @@ @TestOn('vm') @Tags(['node']) +library; import 'package:test/test.dart'; diff --git a/test/cli/node/deprecations_test.dart b/test/cli/node/deprecations_test.dart new file mode 100644 index 000000000..47c9c2fb8 --- /dev/null +++ b/test/cli/node/deprecations_test.dart @@ -0,0 +1,18 @@ +// 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. + +@TestOn('vm') +@Tags(['node']) +library; + +import 'package:test/test.dart'; + +import '../../ensure_npm_package.dart'; +import '../node_test.dart'; +import '../shared/deprecations.dart'; + +void main() { + setUpAll(ensureNpmPackage); + sharedTests(runSass); +} diff --git a/test/cli/node/errors_test.dart b/test/cli/node/errors_test.dart index 87220b350..a20e4905c 100644 --- a/test/cli/node/errors_test.dart +++ b/test/cli/node/errors_test.dart @@ -4,6 +4,7 @@ @TestOn('vm') @Tags(['node']) +library; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -17,7 +18,7 @@ void main() { sharedTests(runSass); test("for package urls", () async { - await d.file("test.scss", "@import 'package:nope/test';").create(); + await d.file("test.scss", "@use 'package:nope/test';").create(); var sass = await runSass(["--no-unicode", "test.scss"]); expect( @@ -25,10 +26,10 @@ void main() { emitsInOrder([ "Error: \"package:\" URLs aren't supported on this platform.", " ,", - "1 | @import 'package:nope/test';", - " | ^^^^^^^^^^^^^^^^^^^", + "1 | @use 'package:nope/test';", + " | ^^^^^^^^^^^^^^^^^^^^^^^^", " '", - " test.scss 1:9 root stylesheet" + " test.scss 1:1 root stylesheet" ])); await sass.shouldExit(65); }); diff --git a/test/cli/node/repl_test.dart b/test/cli/node/repl_test.dart index 3fe23d2f4..18a5bdd43 100644 --- a/test/cli/node/repl_test.dart +++ b/test/cli/node/repl_test.dart @@ -4,6 +4,7 @@ @TestOn('vm') @Tags(['node']) +library; import 'package:test/test.dart'; diff --git a/test/cli/node/source_maps_test.dart b/test/cli/node/source_maps_test.dart index cf9939c3b..a33b370db 100644 --- a/test/cli/node/source_maps_test.dart +++ b/test/cli/node/source_maps_test.dart @@ -4,6 +4,7 @@ @TestOn('vm') @Tags(['node']) +library; import 'package:test/test.dart'; diff --git a/test/cli/node/update_test.dart b/test/cli/node/update_test.dart index 614f25da6..b423b428a 100644 --- a/test/cli/node/update_test.dart +++ b/test/cli/node/update_test.dart @@ -5,6 +5,7 @@ // OS X's modification time reporting is flaky, so we skip these tests on it. @TestOn('vm && !mac-os') @Tags(['node']) +library; import 'package:test/test.dart'; diff --git a/test/cli/node/watch_test.dart b/test/cli/node/watch_test.dart index 780b649d7..2de6bf452 100644 --- a/test/cli/node/watch_test.dart +++ b/test/cli/node/watch_test.dart @@ -9,6 +9,7 @@ // File watching is inherently flaky at the OS level. To mitigate this, we do a // few retries when the tests fail. @Retry(3) +library; import 'package:test/test.dart'; diff --git a/test/cli/node_test.dart b/test/cli/node_test.dart index 9169cc6aa..b3224d0fd 100644 --- a/test/cli/node_test.dart +++ b/test/cli/node_test.dart @@ -4,6 +4,7 @@ @TestOn('vm') @Tags(['node']) +library; import 'dart:convert'; diff --git a/test/cli/shared.dart b/test/cli/shared.dart index 63c990685..d9264bde2 100644 --- a/test/cli/shared.dart +++ b/test/cli/shared.dart @@ -110,7 +110,7 @@ void sharedTests( group("can import files", () { test("relative to the entrypoint", () async { - await d.file("test.scss", "@import 'dir/test'").create(); + await d.file("test.scss", "@use 'dir/test'").create(); await d.dir("dir", [d.file("test.scss", "a {b: 1 + 2}")]).create(); @@ -119,7 +119,7 @@ void sharedTests( }); test("from the load path", () async { - await d.file("test.scss", "@import 'test2'").create(); + await d.file("test.scss", "@use 'test2'").create(); await d.dir("dir", [d.file("test2.scss", "a {b: c}")]).create(); @@ -129,8 +129,8 @@ void sharedTests( test("from SASS_PATH", () async { await d.file("test.scss", """ - @import 'test2'; - @import 'test3'; + @use 'test2'; + @use 'test3'; """).create(); await d.dir("dir2", [d.file("test2.scss", "a {b: c}")]).create(); @@ -145,12 +145,12 @@ void sharedTests( // Regression test for #369 test("from within a directory, relative to a file on the load path", () async { - await d.dir( - "dir1", [d.file("test.scss", "@import 'subdir/test2'")]).create(); + await d + .dir("dir1", [d.file("test.scss", "@use 'subdir/test2'")]).create(); await d.dir("dir2", [ d.dir("subdir", [ - d.file("test2.scss", "@import 'test3'"), + d.file("test2.scss", "@use 'test3'"), d.file("test3.scss", "a {b: c}") ]) ]).create(); @@ -160,7 +160,7 @@ void sharedTests( }); test("relative in preference to from the load path", () async { - await d.file("test.scss", "@import 'test2'").create(); + await d.file("test.scss", "@use 'test2'").create(); await d.file("test2.scss", "x {y: z}").create(); await d.dir("dir", [d.file("test2.scss", "a {b: c}")]).create(); @@ -170,7 +170,7 @@ void sharedTests( }); test("in load path order", () async { - await d.file("test.scss", "@import 'test2'").create(); + await d.file("test.scss", "@use 'test2'").create(); await d.dir("dir1", [d.file("test2.scss", "a {b: c}")]).create(); await d.dir("dir2", [d.file("test2.scss", "x {y: z}")]).create(); @@ -181,7 +181,7 @@ void sharedTests( }); test("from the load path in preference to from SASS_PATH", () async { - await d.file("test.scss", "@import 'test2'").create(); + await d.file("test.scss", "@use 'test2'").create(); await d.dir("dir1", [d.file("test2.scss", "a {b: c}")]).create(); await d.dir("dir2", [d.file("test2.scss", "x {y: z}")]).create(); @@ -192,7 +192,7 @@ void sharedTests( }); test("in SASS_PATH order", () async { - await d.file("test.scss", "@import 'test2'").create(); + await d.file("test.scss", "@use 'test2'").create(); await d.dir("dir1", [d.file("test2.scss", "a {b: c}")]).create(); await d.dir("dir2", [d.file("test2.scss", "x {y: z}")]).create(); @@ -224,6 +224,8 @@ void sharedTests( "grandparent", "--load-path", "grandparent/parent", + "--silence-deprecation", + "import", "test.scss" ], equalsIgnoringWhitespace("a { b: c; } a { b: c; }")); }); @@ -240,8 +242,13 @@ void sharedTests( d.file("_library.import.scss", "a { b: import-only }") ]).create(); - await expectCompiles(["--load-path", "load-path", "test.scss"], - equalsIgnoringWhitespace("a { b: regular; } a { b: import-only; }")); + await expectCompiles([ + "--load-path", + "load-path", + "--silence-deprecation", + "import", + "test.scss" + ], equalsIgnoringWhitespace("a { b: regular; } a { b: import-only; }")); }); }); @@ -487,7 +494,14 @@ void sharedTests( await d.file("test.scss", "@import 'other'").create(); await d.dir("dir", [d.file("_other.scss", "#{blue} {x: y}")]).create(); - var sass = await runSass(["--quiet-deps", "-I", "dir", "test.scss"]); + var sass = await runSass([ + "--quiet-deps", + "-I", + "dir", + "--silence-deprecation", + "import", + "test.scss" + ]); expect(sass.stderr, emitsDone); await sass.shouldExit(0); }); @@ -501,7 +515,14 @@ void sharedTests( """) ]).create(); - var sass = await runSass(["--quiet-deps", "-I", "dir", "test.scss"]); + var sass = await runSass([ + "--quiet-deps", + "-I", + "dir", + "--silence-deprecation", + "import", + "test.scss" + ]); expect(sass.stderr, emitsDone); await sass.shouldExit(0); }); @@ -637,12 +658,15 @@ void sharedTests( group("with a bunch of deprecation warnings", () { setUp(() async { await d.file("test.scss", r""" - $_: call("inspect", null); - $_: call("rgb", 0, 0, 0); - $_: call("nth", null, 1); - $_: call("join", null, null); - $_: call("if", true, 1, 2); - $_: call("hsl", 0, 100%, 100%); + @use "sass:list"; + @use "sass:meta"; + + $_: meta.call("inspect", null); + $_: meta.call("rgb", 0, 0, 0); + $_: meta.call("nth", null, 1); + $_: meta.call("join", null, null); + $_: meta.call("if", true, 1, 2); + $_: meta.call("hsl", 0, 100%, 100%); $_: 1/2; $_: 1/3; @@ -877,7 +901,8 @@ void sharedTests( expect(sass.stdout, emitsDone); await sass.shouldExit(65); }); - }); + // Skipping while no future deprecations exist + }, skip: true); test("doesn't unassign variables", () async { // This is a regression test for one of the strangest errors I've ever @@ -896,7 +921,8 @@ void sharedTests( await d.file("_midstream.scss", "@forward 'upstream'").create(); await d.file("_upstream.scss", r"$c: g").create(); - var sass = await runSass(["input.scss", "output.css"]); + var sass = await runSass( + ["--silence-deprecation", "import", "input.scss", "output.css"]); await sass.shouldExit(0); await d.file("output.css", equalsIgnoringWhitespace(""" diff --git a/test/cli/shared/deprecations.dart b/test/cli/shared/deprecations.dart new file mode 100644 index 000000000..a956a9572 --- /dev/null +++ b/test/cli/shared/deprecations.dart @@ -0,0 +1,498 @@ +// 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:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; +import 'package:test_process/test_process.dart'; + +/// Defines test that are shared between the Dart and Node.js CLI test suites. +void sharedTests(Future runSass(Iterable arguments)) { + // Test complaining about invalid deprecations, combinations, etc + + group("--silence-deprecation", () { + group("prints a warning", () { + setUp(() => d.file("test.scss", "").create()); + + test("for user-authored", () async { + var sass = + await runSass(["--silence-deprecation=user-authored", "test.scss"]); + expect(sass.stderr, emits(contains("User-authored deprecations"))); + await sass.shouldExit(0); + }); + + test("for an obsolete deprecation", () async { + // TODO: test this when a deprecation is obsoleted + }); + + test("for an inactive future deprecation", () async { + var sass = await runSass(["--silence-deprecation=import", "test.scss"]); + expect(sass.stderr, emits(contains("Future import deprecation"))); + await sass.shouldExit(0); + }, skip: true); + + test("for an active future deprecation", () async { + var sass = await runSass([ + "--future-deprecation=import", + "--silence-deprecation=import", + "test.scss" + ]); + expect(sass.stderr, emits(contains("Conflicting options for future"))); + await sass.shouldExit(0); + }, skip: true); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--silence-deprecation=user-authored", + "test.scss:out.css" + ]); + expect(sass.stderr, emits(contains("User-authored deprecations"))); + + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass( + ["--interactive", "--silence-deprecation=user-authored"]); + await expectLater( + sass.stderr, emits(contains("User-authored deprecations"))); + await sass.kill(); + }); + }); + + group("throws an error for an unknown deprecation", () { + setUp(() => d.file("test.scss", "").create()); + + test("in immediate mode", () async { + var sass = + await runSass(["--silence-deprecation=unknown", "test.scss"]); + expect(sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--silence-deprecation=unknown", + "test.scss:out.css" + ]); + expect(sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in repl mode", () async { + var sass = + await runSass(["--interactive", "--silence-deprecation=unknown"]); + expect(sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + }); + + group("silences", () { + group("a parse-time deprecation", () { + setUp( + () => d.file("test.scss", "@if true {} @elseif false {}").create()); + + test("in immediate mode", () async { + var sass = + await runSass(["--silence-deprecation=elseif", "test.scss"]); + expect(sass.stderr, emitsDone); + await sass.shouldExit(0); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--silence-deprecation=elseif", + "test.scss:out.css" + ]); + expect(sass.stderr, emitsDone); + + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass( + ["--interactive", "--silence-deprecation=strict-unary"]); + expect(sass.stderr, emitsDone); + sass.stdin.writeln("4 -(5)"); + await expectLater(sass.stdout, emitsInOrder([">> 4 -(5)", "-1"])); + await sass.kill(); + }); + }); + + group("an evaluation-time deprecation", () { + setUp(() => d.file("test.scss", """ + @use 'sass:math'; + a {b: math.random(1px)} + """).create()); + + test("in immediate mode", () async { + var sass = await runSass( + ["--silence-deprecation=function-units", "test.scss"]); + expect(sass.stderr, emitsDone); + await sass.shouldExit(0); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--silence-deprecation=function-units", + "test.scss:out.css" + ]); + expect(sass.stderr, emitsDone); + + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass( + ["--interactive", "--silence-deprecation=function-units"]); + expect(sass.stderr, emitsDone); + sass.stdin.writeln("@use 'sass:math'"); + await expectLater(sass.stdout, emits(">> @use 'sass:math'")); + sass.stdin.writeln("math.random(1px)"); + await expectLater( + sass.stdout, emitsInOrder([">> math.random(1px)", "1"])); + await sass.kill(); + }); + }); + }); + }); + + group("--fatal-deprecation", () { + group("prints a warning", () { + setUp(() => d.file("test.scss", "").create()); + + test("for an obsolete deprecation", () async { + // TODO: test this when a deprecation is obsoleted + }); + + test("for an inactive future deprecation", () async { + var sass = await runSass(["--fatal-deprecation=import", "test.scss"]); + expect(sass.stderr, emits(contains("Future import deprecation"))); + await sass.shouldExit(0); + }, skip: true); + + test("for a silent deprecation", () async { + var sass = await runSass([ + "--fatal-deprecation=elseif", + "--silence-deprecation=elseif", + "test.scss" + ]); + expect(sass.stderr, emits(contains("Ignoring setting to silence"))); + await sass.shouldExit(0); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--fatal-deprecation=elseif", + "--silence-deprecation=elseif", + "test.scss:out.css" + ]); + expect(sass.stderr, emits(contains("Ignoring setting to silence"))); + + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass([ + "--interactive", + "--fatal-deprecation=elseif", + "--silence-deprecation=elseif" + ]); + await expectLater( + sass.stderr, emits(contains("Ignoring setting to silence"))); + await sass.kill(); + }); + }); + + group("throws an error for", () { + group("an unknown deprecation", () { + setUp(() => d.file("test.scss", "").create()); + + test("in immediate mode", () async { + var sass = + await runSass(["--fatal-deprecation=unknown", "test.scss"]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--fatal-deprecation=unknown", + "test.scss:out.css" + ]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in repl mode", () async { + var sass = + await runSass(["--interactive", "--fatal-deprecation=unknown"]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + }); + + group("a parse-time deprecation", () { + setUp( + () => d.file("test.scss", "@if true {} @elseif false {}").create()); + + test("in immediate mode", () async { + var sass = await runSass(["--fatal-deprecation=elseif", "test.scss"]); + expect(sass.stderr, emits(startsWith("Error: "))); + await sass.shouldExit(65); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--fatal-deprecation=elseif", + "test.scss:out.css" + ]); + await expectLater(sass.stderr, emits(startsWith("Error: "))); + await expectLater( + sass.stdout, + emitsInOrder( + ["Sass is watching for changes. Press Ctrl-C to stop.", ""])); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass( + ["--interactive", "--fatal-deprecation=strict-unary"]); + sass.stdin.writeln("4 -(5)"); + await expectLater( + sass.stdout, + emitsInOrder([ + ">> 4 -(5)", + emitsThrough(startsWith("Error: ")), + emitsThrough(contains("Remove this setting")) + ])); + + // Verify that there's no output written for the previous line. + sass.stdin.writeln("1"); + await expectLater(sass.stdout, emitsInOrder([">> 1", "1"])); + await sass.kill(); + }); + }); + + group("an evaluation-time deprecation", () { + setUp(() => d.file("test.scss", """ + @use 'sass:math'; + a {b: math.random(1px)} + """).create()); + + test("in immediate mode", () async { + var sass = await runSass( + ["--fatal-deprecation=function-units", "test.scss"]); + expect(sass.stderr, emits(startsWith("Error: "))); + await sass.shouldExit(65); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--fatal-deprecation=function-units", + "test.scss:out.css" + ]); + await expectLater(sass.stderr, emits(startsWith("Error: "))); + await expectLater( + sass.stdout, + emitsInOrder( + ["Sass is watching for changes. Press Ctrl-C to stop.", ""])); + await sass.kill(); + }); + + test("in repl mode", () async { + var sass = await runSass( + ["--interactive", "--fatal-deprecation=function-units"]); + sass.stdin.writeln("@use 'sass:math'"); + await expectLater(sass.stdout, emits(">> @use 'sass:math'")); + sass.stdin.writeln("math.random(1px)"); + await expectLater( + sass.stdout, + emitsInOrder([ + ">> math.random(1px)", + emitsThrough(startsWith("Error: ")), + emitsThrough(contains("Remove this setting")) + ])); + + // Verify that there's no output written for the previous line. + sass.stdin.writeln("1"); + await expectLater(sass.stdout, emitsInOrder([">> 1", "1"])); + await sass.kill(); + }); + }); + }); + }); + + group("--future-deprecation", () { + group("prints a warning for", () { + group("an active deprecation", () { + setUp(() => d.file("test.scss", "").create()); + + test("in immediate mode", () async { + var sass = await runSass( + ["--future-deprecation=function-units", "test.scss"]); + expect(sass.stderr, + emits(contains("function-units is not a future deprecation"))); + await sass.shouldExit(0); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--future-deprecation=function-units", + "test.scss:out.css" + ]); + expect(sass.stderr, + emits(contains("function-units is not a future deprecation"))); + + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + }); + + test("in repl mode", () async { + // TODO: test this when there's an expression-level future deprecation + }); + }); + + group("an obsolete deprecation", () { + // TODO: test this when there are obsolete deprecations + }); + + group("a parse-time deprecation", () { + setUp(() async { + await d.file("test.scss", "@import 'other';").create(); + await d.file("_other.scss", "").create(); + }); + + test("in immediate mode", () async { + var sass = + await runSass(["--future-deprecation=import", "test.scss"]); + expect(sass.stderr, emits(startsWith("DEPRECATION WARNING"))); + await sass.shouldExit(0); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--future-deprecation=import", + "test.scss:out.css" + ]); + + await expectLater( + sass.stderr, emits(startsWith("DEPRECATION WARNING"))); + await sass.kill(); + }); + + test("in repl mode", () async { + // TODO: test this when there's an expression-level future deprecation + }); + }); + + group("an evaluation-time deprecation", () { + // TODO: test this when there's an evaluation-time future deprecation + }); + }); + + group("throws an error for", () { + group("an unknown deprecation", () { + setUp(() => d.file("test.scss", "").create()); + + test("in immediate mode", () async { + var sass = + await runSass(["--future-deprecation=unknown", "test.scss"]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--future-deprecation=unknown", + "test.scss:out.css" + ]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + + test("in repl mode", () async { + var sass = + await runSass(["--interactive", "--future-deprecation=unknown"]); + expect( + sass.stdout, emits(contains('Invalid deprecation "unknown".'))); + await sass.shouldExit(64); + }); + }); + + group("a fatal deprecation", () { + setUp(() async { + await d.file("test.scss", "@import 'other';").create(); + await d.file("_other.scss", "").create(); + }); + + test("in immediate mode", () async { + var sass = await runSass([ + "--fatal-deprecation=import", + "--future-deprecation=import", + "test.scss" + ]); + expect(sass.stderr, emits(startsWith("Error: "))); + await sass.shouldExit(65); + }); + + test("in watch mode", () async { + var sass = await runSass([ + "--watch", + "--poll", + "--fatal-deprecation=import", + "--future-deprecation=import", + "test.scss:out.css" + ]); + await expectLater(sass.stderr, emits(startsWith("Error: "))); + await expectLater( + sass.stdout, + emitsInOrder( + ["Sass is watching for changes. Press Ctrl-C to stop.", ""])); + await sass.kill(); + }); + + test("in repl mode", () async { + // TODO: test this when there's an expression-level future deprecation + }); + }); + }); + // Skipping while no future deprecations exist + }, skip: true); +} diff --git a/test/cli/shared/source_maps.dart b/test/cli/shared/source_maps.dart index 75035b3b4..a2d0ad2e4 100644 --- a/test/cli/shared/source_maps.dart +++ b/test/cli/shared/source_maps.dart @@ -43,7 +43,7 @@ void sharedTests(Future runSass(Iterable arguments)) { group("with multiple sources", () { setUp(() async { await d.file("test.scss", """ - @import 'dir/other'; + @use 'dir/other'; x {y: z} """).create(); await d.dir("dir", [d.file("other.scss", "a {b: 1 + 2}")]).create(); @@ -96,7 +96,7 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("when imported with the same case", () async { - await d.file("importer.scss", "@import 'TeSt.scss'").create(); + await d.file("importer.scss", "@use 'TeSt.scss'").create(); await (await runSass(["importer.scss", "out.css"])).shouldExit(0); expect(_readJson("out.css.map"), containsPair("sources", ["TeSt.scss"])); }); @@ -109,7 +109,7 @@ void sharedTests(Future runSass(Iterable arguments)) { }, testOn: "windows"); test("when imported with a different case", () async { - await d.file("importer.scss", "@import 'test.scss'").create(); + await d.file("importer.scss", "@use 'test.scss'").create(); await (await runSass(["importer.scss", "out.css"])).shouldExit(0); expect(_readJson("out.css.map"), containsPair("sources", ["TeSt.scss"])); }, testOn: "windows"); diff --git a/test/cli/shared/update.dart b/test/cli/shared/update.dart index 4fc7bfbc1..260c1d132 100644 --- a/test/cli/shared/update.dart +++ b/test/cli/shared/update.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; import 'package:test_process/test_process.dart'; @@ -42,7 +43,7 @@ void sharedTests(Future runSass(Iterable arguments)) { test("whose source was transitively modified", () async { await d.file("other.scss", "a {b: c}").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await update(["test.scss:out.css"]); expect(sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); @@ -148,6 +149,18 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("out.css", "x {y: z}").validate(); }); + // Regression test for #2203 + test("whose sources weren't modified with an absolute path", () async { + await d.file("test.scss", "a {b: c}").create(); + await d.file("out.css", "x {y: z}").create(); + + var sass = await update(["${p.absolute(d.path('test.scss'))}:out.css"]); + expect(sass.stdout, emitsDone); + await sass.shouldExit(0); + + await d.file("out.css", "x {y: z}").validate(); + }); + test("whose sibling was modified", () async { await d.file("test1.scss", "a {b: c}").create(); await d.file("out1.css", "x {y: z}").create(); @@ -164,26 +177,26 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("with a missing import", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var message = "Error: Can't find stylesheet to import."; var sass = await update(["test.scss:out.css"]); expect(sass.stderr, emits(message)); - expect(sass.stderr, emitsThrough(contains("test.scss 1:9"))); + expect(sass.stderr, emitsThrough(contains("test.scss 1:1"))); await sass.shouldExit(65); await d.file("out.css", contains(message)).validate(); }); test("with a conflicting import", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.file("other.scss", "a {b: c}").create(); await d.file("_other.scss", "x {y: z}").create(); var message = "Error: It's not clear which file to import. Found:"; var sass = await update(["test.scss:out.css"]); expect(sass.stderr, emits(message)); - expect(sass.stderr, emitsThrough(contains("test.scss 1:9"))); + expect(sass.stderr, emitsThrough(contains("test.scss 1:1"))); await sass.shouldExit(65); await d.file("out.css", contains(message)).validate(); @@ -244,7 +257,7 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("when an import is removed", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.file("_other.scss", "a {b: c}").create(); await (await update(["test.scss:out.css"])).shouldExit(0); await d.file("out.css", anything).validate(); @@ -253,7 +266,7 @@ void sharedTests(Future runSass(Iterable arguments)) { d.file("_other.scss").io.deleteSync(); var sass = await update(["test.scss:out.css"]); expect(sass.stderr, emits(message)); - expect(sass.stderr, emitsThrough(contains("test.scss 1:9"))); + expect(sass.stderr, emitsThrough(contains("test.scss 1:1"))); await sass.shouldExit(65); await d.file("out.css", contains(message)).validate(); diff --git a/test/cli/shared/watch.dart b/test/cli/shared/watch.dart index b80b33e64..f2bfc6d12 100644 --- a/test/cli/shared/watch.dart +++ b/test/cli/shared/watch.dart @@ -208,7 +208,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("_other.scss", "a {b: c}").create(); await d.file("test.scss", "@import 'other'").create(); - var sass = await watch(["test.scss:out.css"]); + var sass = await watch( + ["--silence-deprecation", "import", "test.scss:out.css"]); await expectLater( sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); @@ -361,7 +362,7 @@ void sharedTests(Future runSass(Iterable arguments)) { // Regression test for #550 test("with an error that's later fixed", () async { await d.file("_other.scss", "a {b: c}").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await watch(["test.scss:out.css"]); await expectLater( @@ -373,7 +374,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await expectLater( sass.stderr, emits('Error: Expected expression.')); await expectLater( - sass.stderr, emitsThrough(contains('test.scss 1:9'))); + sass.stderr, emitsThrough(contains('test.scss 1:1'))); await tickIfPoll(); await d.file("_other.scss", "q {r: s}").create(); @@ -466,7 +467,7 @@ void sharedTests(Future runSass(Iterable arguments)) { group("when its dependency is deleted", () { test("and updates the output", () async { await d.file("_other.scss", "a {b: c}").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await watch(["test.scss:out.css"]); await expectLater( @@ -478,7 +479,7 @@ void sharedTests(Future runSass(Iterable arguments)) { d.file("_other.scss").io.deleteSync(); await expectLater(sass.stderr, emits(message)); await expectLater( - sass.stderr, emitsThrough(contains('test.scss 1:9'))); + sass.stderr, emitsThrough(contains('test.scss 1:1'))); await sass.kill(); await d.file("out.css", contains(message)).validate(); @@ -486,7 +487,7 @@ void sharedTests(Future runSass(Iterable arguments)) { test("but another is available", () async { await d.file("_other.scss", "a {b: c}").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.dir("dir", [d.file("_other.scss", "x {y: z}")]).create(); var sass = await watch(["-I", "dir", "test.scss:out.css"]); @@ -508,7 +509,7 @@ void sharedTests(Future runSass(Iterable arguments)) { test("which resolves a conflict", () async { await d.file("_other.scss", "a {b: c}").create(); await d.file("_other.sass", "x\n y: z").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await watch(["test.scss:out.css"]); await expectLater(sass.stderr, @@ -530,13 +531,13 @@ void sharedTests(Future runSass(Iterable arguments)) { group("when a dependency is added", () { group("that was missing", () { test("relative to the file", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await watch(["test.scss:out.css"]); await expectLater(sass.stderr, emits("Error: Can't find stylesheet to import.")); await expectLater( - sass.stderr, emitsThrough(contains("test.scss 1:9"))); + sass.stderr, emitsThrough(contains("test.scss 1:1"))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -551,14 +552,14 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("on a load path", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.dir("dir").create(); var sass = await watch(["-I", "dir", "test.scss:out.css"]); await expectLater(sass.stderr, emits("Error: Can't find stylesheet to import.")); await expectLater( - sass.stderr, emitsThrough(contains("test.scss 1:9"))); + sass.stderr, emitsThrough(contains("test.scss 1:1"))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -573,14 +574,14 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("on a load path that was created", () async { - await d.dir( - "dir1", [d.file("test.scss", "@import 'other'")]).create(); + await d + .dir("dir1", [d.file("test.scss", "@use 'other'")]).create(); var sass = await watch(["-I", "dir2", "dir1:out"]); await expectLater(sass.stderr, emits("Error: Can't find stylesheet to import.")); await expectLater(sass.stderr, - emitsThrough(contains("${p.join('dir1', 'test.scss')} 1:9"))); + emitsThrough(contains("${p.join('dir1', 'test.scss')} 1:1"))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -597,7 +598,7 @@ void sharedTests(Future runSass(Iterable arguments)) { test("that conflicts with the previous dependency", () async { await d.file("_other.scss", "a {b: c}").create(); - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); var sass = await watch(["test.scss:out.css"]); await expectLater( @@ -615,7 +616,7 @@ void sharedTests(Future runSass(Iterable arguments)) { group("that overrides the previous dependency", () { test("on an import path", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.dir("dir2", [d.file("_other.scss", "a {b: c}")]).create(); await d.dir("dir1").create(); @@ -637,7 +638,7 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("because it's relative", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); var sass = await watch(["-I", "dir", "test.scss:out.css"]); @@ -657,7 +658,7 @@ void sharedTests(Future runSass(Iterable arguments)) { }); test("because it's not an index", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d .dir("other", [d.file("_index.scss", "a {b: c}")]).create(); @@ -755,7 +756,7 @@ void sharedTests(Future runSass(Iterable arguments)) { test( "when a potential dependency that's not actually imported is added", () async { - await d.file("test.scss", "@import 'other'").create(); + await d.file("test.scss", "@use 'other'").create(); await d.file("_other.scss", "a {b: c}").create(); await d.dir("dir").create(); diff --git a/test/compressed_test.dart b/test/compressed_test.dart index 3e383b3f1..e3368dcaa 100644 --- a/test/compressed_test.dart +++ b/test/compressed_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/function_test.dart b/test/dart_api/function_test.dart index bebefde0d..68403d424 100644 --- a/test/dart_api/function_test.dart +++ b/test/dart_api/function_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/importer_test.dart b/test/dart_api/importer_test.dart index 28ec53bee..0b47eccde 100644 --- a/test/dart_api/importer_test.dart +++ b/test/dart_api/importer_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:convert'; @@ -15,8 +16,8 @@ import 'test_importer.dart'; import '../utils.dart'; void main() { - test("uses an importer to resolve an @import", () { - var css = compileString('@import "orange";', importers: [ + test("uses an importer to resolve a @use", () { + var css = compileString('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { var color = url.path; return ImporterResult('.$color {color: $color}', indented: false); @@ -27,7 +28,7 @@ void main() { }); test("passes the canonicalized URL to the importer", () { - var css = compileString('@import "orange";', importers: [ + var css = compileString('@use "orange";', importers: [ TestImporter((url) => Uri.parse('u:blue'), (url) { var color = url.path; return ImporterResult('.$color {color: $color}', indented: false); @@ -62,7 +63,7 @@ void main() { test("resolves URLs relative to the pre-canonicalized URL", () { var times = 0; - var css = compileString('@import "foo:bar/baz";', + var css = compileString('@use "foo:bar/baz";', importers: [ TestImporter( expectAsync1((url) { @@ -94,7 +95,7 @@ void main() { group("the imported URL", () { // Regression test for #1137. test("isn't changed if it's root-relative", () { - compileString('@import "/orange";', importers: [ + compileString('@use "/orange";', importers: [ TestImporter(expectAsync1((url) { expect(url, equals(Uri.parse("/orange"))); return Uri.parse("u:$url"); @@ -115,7 +116,7 @@ void main() { group("the containing URL", () { test("is null for a potentially canonical scheme", () { late TestImporter importer; - compileString('@import "u:orange";', + compileString('@use "u:orange";', importers: [ importer = TestImporter(expectAsync1((url) { expect(importer.publicContainingUrl, isNull); @@ -127,7 +128,7 @@ void main() { test("throws an error outside canonicalize", () { late TestImporter importer; - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ importer = TestImporter((url) => Uri.parse("u:$url"), expectAsync1((url) { expect(() => importer.publicContainingUrl, throwsStateError); @@ -139,7 +140,7 @@ void main() { group("for a non-canonical scheme", () { test("is set to the original URL", () { late TestImporter importer; - compileString('@import "u:orange";', + compileString('@use "u:orange";', importers: [ importer = TestImporter(expectAsync1((url) { expect(importer.publicContainingUrl, @@ -153,7 +154,7 @@ void main() { test("is null if the original URL is null", () { late TestImporter importer; - compileString('@import "u:orange";', importers: [ + compileString('@use "u:orange";', importers: [ importer = TestImporter(expectAsync1((url) { expect(importer.publicContainingUrl, isNull); return url.replace(scheme: 'x'); @@ -166,7 +167,7 @@ void main() { group("for a schemeless load", () { test("is set to the original URL", () { late TestImporter importer; - compileString('@import "orange";', + compileString('@use "orange";', importers: [ importer = TestImporter(expectAsync1((url) { expect(importer.publicContainingUrl, @@ -179,7 +180,7 @@ void main() { test("is null if the original URL is null", () { late TestImporter importer; - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ importer = TestImporter(expectAsync1((url) { expect(importer.publicContainingUrl, isNull); return Uri.parse("u:$url"); @@ -193,7 +194,7 @@ void main() { "throws an error if the importer returns a canonical URL with a " "non-canonical scheme", () { expect( - () => compileString('@import "orange";', importers: [ + () => compileString('@use "orange";', importers: [ TestImporter(expectAsync1((url) => Uri.parse("u:$url")), (_) => ImporterResult('', indented: false), nonCanonicalSchemes: {'u'}) @@ -206,7 +207,7 @@ void main() { }); test("uses an importer's source map URL", () { - var result = compileStringToResult('@import "orange";', + var result = compileStringToResult('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { var color = url.path; @@ -220,7 +221,7 @@ void main() { }); test("uses a data: source map URL if the importer doesn't provide one", () { - var result = compileStringToResult('@import "orange";', + var result = compileStringToResult('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { var color = url.path; @@ -237,7 +238,7 @@ void main() { test("wraps an error in canonicalize()", () { expect(() { - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ TestImporter((url) { throw "this import is bad actually"; }, expectNever1) @@ -252,7 +253,7 @@ void main() { test("wraps an error in load()", () { expect(() { - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { throw "this import is bad actually"; }) @@ -267,7 +268,7 @@ void main() { test("prefers .message to .toString() for an importer error", () { expect(() { - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { throw FormatException("bad format somehow"); }) @@ -283,7 +284,7 @@ void main() { test("avoids importer when only load() returns null", () { expect(() { - compileString('@import "orange";', importers: [ + compileString('@use "orange";', importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) => null) ]); }, throwsA(predicate((error) { @@ -296,7 +297,7 @@ void main() { group("compileString()'s importer option", () { test("loads relative imports from the entrypoint", () { - var css = compileString('@import "orange";', + var css = compileString('@use "orange";', importer: TestImporter((url) => Uri.parse("u:$url"), (url) { var color = url.path; return ImporterResult('.$color {color: $color}', indented: false); @@ -306,7 +307,7 @@ void main() { }); test("loads imports relative to the entrypoint's URL", () { - var css = compileString('@import "baz/qux";', + var css = compileString('@use "baz/qux";', importer: TestImporter((url) => url.resolve("bang"), (url) { return ImporterResult('a {result: "${url.path}"}', indented: false); }), @@ -316,7 +317,7 @@ void main() { }); test("doesn't load absolute imports", () { - var css = compileString('@import "u:orange";', + var css = compileString('@use "u:orange";', importer: TestImporter((_) => throw "Should not be called", (_) => throw "Should not be called"), importers: [ @@ -330,13 +331,13 @@ void main() { }); test("doesn't load from other importers", () { - var css = compileString('@import "u:midstream";', + var css = compileString('@use "u:midstream";', importer: TestImporter((_) => throw "Should not be called", (_) => throw "Should not be called"), importers: [ TestImporter((url) => url, (url) { if (url.path == "midstream") { - return ImporterResult("@import 'orange';", indented: false); + return ImporterResult("@use 'orange';", indented: false); } else { var color = url.path; return ImporterResult('.$color {color: $color}', diff --git a/test/dart_api/logger_test.dart b/test/dart_api/logger_test.dart index a1c0ec93f..dbd3f205e 100644 --- a/test/dart_api/logger_test.dart +++ b/test/dart_api/logger_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; import 'package:source_span/source_span.dart'; @@ -75,7 +76,7 @@ void main() { }); }); - test("with a parser warning passes the message and span", () { + test("with a parser warning passes the message, span, and trace", () { var mustBeCalled = expectAsync0(() {}); compileString('a {b: c && d}', logger: _TestLogger.withWarn((message, {span, trace, deprecation = false}) { @@ -86,7 +87,7 @@ void main() { expect(span.end.line, equals(0)); expect(span.end.column, equals(10)); - expect(trace, isNull); + expect(trace!.frames.first.member, equals('root stylesheet')); expect(deprecation, isFalse); mustBeCalled(); })); @@ -193,7 +194,7 @@ void main() { test("from an importer", () { var mustBeCalled = expectAsync0(() {}); - compileString("@import 'foo';", importers: [ + compileString("@use 'foo';", importers: [ TestImporter((url) => Uri.parse("u:$url"), (url) { warn("heck"); return ImporterResult("", indented: false); @@ -203,11 +204,11 @@ void main() { expect(message, equals("heck")); expect(span!.start.line, equals(0)); - expect(span.start.column, equals(8)); + expect(span.start.column, equals(0)); expect(span.end.line, equals(0)); - expect(span.end.column, equals(13)); + expect(span.end.column, equals(10)); - expect(trace!.frames.first.member, equals('@import')); + expect(trace!.frames.first.member, equals('@use')); expect(deprecation, isFalse); mustBeCalled(); })); @@ -227,10 +228,6 @@ void main() { mustBeCalled(); })); }); - - test("throws an error outside a callback", () { - expect(() => warn("heck"), throwsStateError); - }); }); } diff --git a/test/dart_api/value/boolean_test.dart b/test/dart_api/value/boolean_test.dart index 8a0579707..d46da602d 100644 --- a/test/dart_api/value/boolean_test.dart +++ b/test/dart_api/value/boolean_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/calculation_test.dart b/test/dart_api/value/calculation_test.dart index ed284db04..4ebe47d62 100644 --- a/test/dart_api/value/calculation_test.dart +++ b/test/dart_api/value/calculation_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index 6535c8a0c..e0a6f3028 100644 --- a/test/dart_api/value/color_test.dart +++ b/test/dart_api/value/color_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; @@ -37,14 +38,116 @@ void main() { expect(value.alpha, equals(1)); }); + test("has a named alpha channel", () { + expect(value.channel("alpha"), equals(1)); + }); + + group("channel()", () { + test("returns RGB channels", () { + expect(value.channel("red"), equals(0x12)); + expect(value.channel("green"), equals(0x34)); + expect(value.channel("blue"), equals(0x56)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + }); + + test("isChannelMissing() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("isChannelPowerless() throws for a channel not in this space", () { + expect(() => value.channel("hue"), throwsSassScriptException); + }); + + test("has a space", () { + expect(value.space, equals(ColorSpace.rgb)); + }); + + test("is a legacy color", () { + expect(value.isLegacy, isTrue); + }); + test("equals the same color", () { expect(value, equalsWithHash(SassColor.rgb(0x12, 0x34, 0x56))); + }); + + test("equals an equivalent legacy color", () { expect( value, equalsWithHash( SassColor.hsl(210, 65.3846153846154, 20.392156862745097))); }); + test("does not equal an equivalent non-legacy color", () { + expect(value, isNot(equals(SassColor.srgb(0x12, 0x34, 0x56)))); + }); + + group("isInGamut", () { + test("returns true if the color is in the RGB gamut", () { + expect(value.isInGamut, isTrue); + }); + + test("returns false if the color is outside the RGB gamut", () { + expect(value.changeChannels({"red": 0x100}).isInGamut, isFalse); + }); + }); + + group("toSpace", () { + test("converts the color to a given space", () { + expect( + value.toSpace(ColorSpace.lab), + equals(SassColor.lab( + 20.675469453386192, -2.276792630515417, -24.59314874484676))); + }); + + test("with legacyMissing: true, makes a powerless channel missing", () { + expect( + SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl) + .isChannelMissing("hue"), + isTrue); + }); + + test("with legacyMissing: false, makes a powerless channel zero", () { + var result = SassColor.rgb(0, 0, 0) + .toSpace(ColorSpace.hsl, legacyMissing: false); + expect(result.isChannelMissing("hue"), isFalse); + expect(result.channel("hue"), equals(0)); + }); + + test( + "even with legacyMissing: false, preserves missing channels for same " + "space", () { + expect( + SassColor.rgb(0, null, 0) + .toSpace(ColorSpace.rgb, legacyMissing: false) + .isChannelMissing("green"), + isTrue); + }); + }); + + group("toGamut() brings the color into its gamut", () { + setUp(() => value = parseValue("rgb(300 200 100)") as SassColor); + + test("with clip", () { + expect(value.toGamut(GamutMapMethod.clip), + equals(SassColor.rgb(255, 200, 100))); + }); + + test("with localMinde", () { + // TODO: update + expect(value.toGamut(GamutMapMethod.localMinde), + equals(SassColor.rgb(255, 200, 100))); + }); + }); + group("changeRgb()", () { test("changes RGB values", () { expect(value.changeRgb(red: 0xAA), @@ -59,102 +162,85 @@ void main() { equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeRgb(red: 0).red, equals(0)); - expect(value.changeRgb(red: 0xFF).red, equals(0xFF)); - expect(value.changeRgb(green: 0).green, equals(0)); - expect(value.changeRgb(green: 0xFF).green, equals(0xFF)); - expect(value.changeRgb(blue: 0).blue, equals(0)); - expect(value.changeRgb(blue: 0xFF).blue, equals(0xFF)); - expect(value.changeRgb(alpha: 0).alpha, equals(0)); + test("allows in-gamut alpha", () { expect(value.changeRgb(alpha: 1).alpha, equals(1)); + expect(value.changeRgb(alpha: 0).alpha, equals(0)); + }); + + test("allows out-of-gamut values", () { + expect(value.changeRgb(red: -1).red, equals(-1)); + expect(value.changeRgb(red: 0x100).red, equals(0x100)); }); - test("disallows invalid values", () { - expect(() => value.changeRgb(red: -1), throwsRangeError); - expect(() => value.changeRgb(red: 0x100), throwsRangeError); - expect(() => value.changeRgb(green: -1), throwsRangeError); - expect(() => value.changeRgb(green: 0x100), throwsRangeError); - expect(() => value.changeRgb(blue: -1), throwsRangeError); - expect(() => value.changeRgb(blue: 0x100), throwsRangeError); + test("disallows out-of-gamut alpha", () { expect(() => value.changeRgb(alpha: -0.1), throwsRangeError); expect(() => value.changeRgb(alpha: 1.1), throwsRangeError); }); }); - group("changeHsl()", () { - test("changes HSL values", () { - expect(value.changeHsl(hue: 120), - equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); - expect(value.changeHsl(saturation: 42), - equals(SassColor.hsl(210, 42, 20.392156862745097))); - expect(value.changeHsl(lightness: 42), - equals(SassColor.hsl(210, 65.3846153846154, 42))); - expect( - value.changeHsl(alpha: 0.5), - equals( - SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); - expect( - value.changeHsl( - hue: 120, saturation: 42, lightness: 42, alpha: 0.5), - equals(SassColor.hsl(120, 42, 42, 0.5))); - }); - - test("allows valid values", () { - expect(value.changeHsl(saturation: 0).saturation, equals(0)); - expect(value.changeHsl(saturation: 100).saturation, equals(100)); - expect(value.changeHsl(lightness: 0).lightness, equals(0)); - expect(value.changeHsl(lightness: 100).lightness, equals(100)); - expect(value.changeHsl(alpha: 0).alpha, equals(0)); - expect(value.changeHsl(alpha: 1).alpha, equals(1)); - }); + test("changeHsl() changes HSL values", () { + expect(value.changeHsl(hue: 120), + equals(SassColor.hsl(120, 65.3846153846154, 20.392156862745097))); + expect(value.changeHsl(saturation: 42), + equals(SassColor.hsl(210, 42, 20.392156862745097))); + expect(value.changeHsl(lightness: 42), + equals(SassColor.hsl(210, 65.3846153846154, 42))); + expect( + value.changeHsl(alpha: 0.5), + equals( + SassColor.hsl(210, 65.3846153846154, 20.392156862745097, 0.5))); + expect( + value.changeHsl(hue: 120, saturation: 42, lightness: 42, alpha: 0.5), + equals(SassColor.hsl(120, 42, 42, 0.5))); + }); - test("disallows invalid values", () { - expect(() => value.changeHsl(saturation: -0.1), throwsRangeError); - expect(() => value.changeHsl(saturation: 100.1), throwsRangeError); - expect(() => value.changeHsl(lightness: -0.1), throwsRangeError); - expect(() => value.changeHsl(lightness: 100.1), throwsRangeError); - expect(() => value.changeHsl(alpha: -0.1), throwsRangeError); - expect(() => value.changeHsl(alpha: 1.1), throwsRangeError); - }); + test("changeHwb() changes HWB values", () { + expect(value.changeHwb(hue: 120), + equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); + expect(value.changeHwb(whiteness: 20), + equals(SassColor.hwb(210, 20, 66.27450980392157))); + expect(value.changeHwb(blackness: 42), + equals(SassColor.hwb(210, 7.0588235294117645, 42))); + expect( + value.changeHwb(alpha: 0.5), + equals( + SassColor.hwb(210, 7.0588235294117645, 66.27450980392157, 0.5))); + expect( + value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), + equals(SassColor.hwb(120, 42, 42, 0.5))); + expect(value.changeHwb(whiteness: 50), + equals(SassColor.hwb(210, 43.0016863406408, 56.9983136593592))); }); - group("changeHwb()", () { - test("changes HWB values", () { - expect(value.changeHwb(hue: 120), - equals(SassColor.hwb(120, 7.0588235294117645, 66.27450980392157))); - expect(value.changeHwb(whiteness: 20), - equals(SassColor.hwb(210, 20, 66.27450980392157))); - expect(value.changeHwb(blackness: 42), - equals(SassColor.hwb(210, 7.0588235294117645, 42))); - expect( - value.changeHwb(alpha: 0.5), - equals(SassColor.hwb( - 210, 7.0588235294117645, 66.27450980392157, 0.5))); - expect( - value.changeHwb(hue: 120, whiteness: 42, blackness: 42, alpha: 0.5), - equals(SassColor.hwb(120, 42, 42, 0.5))); + group("changeChannels()", () { + test("changes RGB values", () { + expect(value.changeChannels({"red": 0xAA}), + equals(SassColor.rgb(0xAA, 0x34, 0x56))); + expect(value.changeChannels({"green": 0xAA}), + equals(SassColor.rgb(0x12, 0xAA, 0x56))); + expect(value.changeChannels({"blue": 0xAA}), + equals(SassColor.rgb(0x12, 0x34, 0xAA))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.rgb(0x12, 0x34, 0x56, 0.5))); expect( - value.changeHwb(whiteness: 50), equals(SassColor.hwb(210, 43, 57))); + value.changeChannels( + {"red": 0xAA, "green": 0xAA, "blue": 0xAA, "alpha": 0.5}), + equals(SassColor.rgb(0xAA, 0xAA, 0xAA, 0.5))); }); - test("allows valid values", () { - expect(value.changeHwb(whiteness: 0).whiteness, equals(0)); - expect(value.changeHwb(whiteness: 100).whiteness, equals(60.0)); - expect(value.changeHwb(blackness: 0).blackness, equals(0)); - expect(value.changeHwb(blackness: 100).blackness, - equals(93.33333333333333)); - expect(value.changeHwb(alpha: 0).alpha, equals(0)); - expect(value.changeHwb(alpha: 1).alpha, equals(1)); + test("allows in-gamut alpha", () { + expect(value.changeChannels({"alpha": 1}).alpha, equals(1)); + expect(value.changeChannels({"alpha": 0}).alpha, equals(0)); }); - test("disallows invalid values", () { - expect(() => value.changeHwb(whiteness: -0.1), throwsRangeError); - expect(() => value.changeHwb(whiteness: 100.1), throwsRangeError); - expect(() => value.changeHwb(blackness: -0.1), throwsRangeError); - expect(() => value.changeHwb(blackness: 100.1), throwsRangeError); - expect(() => value.changeHwb(alpha: -0.1), throwsRangeError); - expect(() => value.changeHwb(alpha: 1.1), throwsRangeError); + test("allows out-of-gamut values", () { + expect(value.changeChannels({"red": -1}).red, equals(-1)); + expect(value.changeChannels({"red": 0x100}).red, equals(0x100)); + }); + + test("disallows out-of-gamut alpha", () { + expect(() => value.changeChannels({"alpha": -0.1}), throwsRangeError); + expect(() => value.changeChannels({"alpha": 1.1}), throwsRangeError); }); }); @@ -190,126 +276,133 @@ void main() { }); }); - group("an HSL color", () { + group("a color with a missing channel", () { late SassColor value; - setUp(() => value = parseValue("hsl(120, 42%, 42%)") as SassColor); + setUp(() => + value = parseValue("color(display-p3 0.3 0.4 none)") as SassColor); - test("has RGB channels", () { - expect(value.red, equals(0x3E)); - expect(value.green, equals(0x98)); - expect(value.blue, equals(0x3E)); + test("reports present channels as present", () { + expect(value.isChannelMissing("red"), isFalse); + expect(value.isChannelMissing("green"), isFalse); + expect(value.isChannelMissing("alpha"), isFalse); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(42)); - expect(value.lightness, equals(42)); + test("reports the missing channel as missing", () { + expect(value.isChannelMissing("blue"), isTrue); }); - test("has HWB channels", () { - expect(value.whiteness, equals(24.313725490196077)); - expect(value.blackness, equals(40.3921568627451)); - }); - - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("reports the missing channel's value as 0", () { + expect(value.channel("blue"), equals(0)); }); - test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x3E, 0x98, 0x3E))); - expect(value, equalsWithHash(SassColor.hsl(120, 42, 42))); - expect( - value, - equalsWithHash( - SassColor.hwb(120, 24.313725490196077, 40.3921568627451))); + test("does not report the missing channel as powerless", () { + expect(value.isChannelPowerless("blue"), isFalse); }); }); - test("an RGBA color has an alpha channel", () { - var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; - expect(color.alpha, closeTo(0.7, 1e-11)); - }); + group("a color with a powerless channel", () { + late SassColor value; + setUp(() => value = parseValue("hsl(120 0% 50%)") as SassColor); - group("new SassColor.rgb()", () { - test("allows valid values", () { - expect(SassColor.rgb(0, 0, 0, 0), equals(parseValue("rgba(0, 0, 0, 0)"))); - expect(SassColor.rgb(0xFF, 0xFF, 0xFF, 1), equals(parseValue("#fff"))); + test("reports powerful channels as powerful", () { + expect(value.isChannelPowerless("saturation"), isFalse); + expect(value.isChannelPowerless("lightness"), isFalse); + expect(value.isChannelPowerless("alpha"), isFalse); }); - test("disallows invalid values", () { - expect(() => SassColor.rgb(-1, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, -1, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, -1, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.rgb(0x100, 0, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0x100, 0, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0x100, 0), throwsRangeError); - expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); + test("reports the powerless channel as powerless", () { + expect(value.isChannelPowerless("hue"), isTrue); }); - }); - group("new SassColor.hsl()", () { - test("allows valid values", () { - expect( - SassColor.hsl(0, 0, 0, 0), equals(parseValue("hsla(0, 0%, 0%, 0)"))); - expect(SassColor.hsl(4320, 100, 100, 1), - equals(parseValue("hsl(4320, 100%, 100%)"))); + test("reports the powerless channel's value", () { + expect(value.channel("hue"), 120); }); - test("disallows invalid values", () { - expect(() => SassColor.hsl(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hsl(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hsl(0, 0, 0, 1.1), throwsRangeError); + test("does not report the powerless channel as missing", () { + expect(value.isChannelMissing("hue"), isFalse); }); }); - group("new SassColor.hwb()", () { + group("an LCH color", () { late SassColor value; - setUp(() => value = SassColor.hwb(120, 42, 42)); - - test("has RGB channels", () { - expect(value.red, equals(0x6B)); - expect(value.green, equals(0x94)); - expect(value.blue, equals(0x6B)); + setUp(() => value = parseValue("lch(42% 42% 120)") as SassColor); + + test("throws for legacy channels", () { + expect(() => value.red, throwsSassScriptException); + expect(() => value.green, throwsSassScriptException); + expect(() => value.blue, throwsSassScriptException); + expect(() => value.hue, throwsSassScriptException); + expect(() => value.saturation, throwsSassScriptException); + expect(() => value.lightness, throwsSassScriptException); + expect(() => value.whiteness, throwsSassScriptException); + expect(() => value.blackness, throwsSassScriptException); }); - test("has HSL channels", () { - expect(value.hue, equals(120)); - expect(value.saturation, equals(16.078431372549026)); - expect(value.lightness, equals(50)); + test("has an alpha channel", () { + expect(value.alpha, equals(1)); }); - test("has HWB channels", () { - expect(value.whiteness, equals(41.96078431372549)); - expect(value.blackness, equals(41.96078431372548)); + group("channel()", () { + test("returns LCH channels", () { + expect(value.channel("lightness"), equals(42)); + expect(value.channel("chroma"), equals(63)); + expect(value.channel("hue"), equals(120)); + }); + + test("returns alpha", () { + expect(value.channel("alpha"), equals(1)); + }); + + test("throws for a channel not in this space", () { + expect(() => value.channel("red"), throwsSassScriptException); + }); }); - test("has an alpha channel", () { - expect(value.alpha, equals(1)); + test("is not a legacy color", () { + expect(value.isLegacy, isFalse); }); test("equals the same color", () { - expect(value, equalsWithHash(SassColor.rgb(0x6B, 0x94, 0x6B))); - expect(value, equalsWithHash(SassColor.hsl(120, 16, 50))); - expect(value, equalsWithHash(SassColor.hwb(120, 42, 42))); + expect(value, equalsWithHash(SassColor.lch(42, 63, 120))); }); - test("allows valid values", () { + test("doesn't equal an equivalent color", () { expect( - SassColor.hwb(0, 0, 0, 0), equals(parseValue("rgba(255, 0, 0, 0)"))); - expect(SassColor.hwb(4320, 100, 100, 1), equals(parseValue("grey"))); + value, + isNot(equals(SassColor.xyzD65(0.07461544022446227, + 0.12417002656711021, 0.011301590030256693)))); }); - test("disallows invalid values", () { - expect(() => SassColor.hwb(0, -0.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, -0.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, -0.1), throwsRangeError); - expect(() => SassColor.hwb(0, 100.1, 0, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 100.1, 0), throwsRangeError); - expect(() => SassColor.hwb(0, 0, 0, 1.1), throwsRangeError); + test("changeChannels() changes LCH values", () { + expect(value.changeChannels({"lightness": 30}), + equals(SassColor.lch(30, 63, 120))); + expect(value.changeChannels({"chroma": 30}), + equals(SassColor.lch(42, 30, 120))); + expect( + value.changeChannels({"hue": 80}), equals(SassColor.lch(42, 63, 80))); + expect(value.changeChannels({"alpha": 0.5}), + equals(SassColor.lch(42, 63, 120, 0.5))); + expect( + value.changeChannels( + {"lightness": 30, "chroma": 30, "hue": 30, "alpha": 0.5}), + equals(SassColor.lch(30, 30, 30, 0.5))); + }); + }); + + test("an RGBA color has an alpha channel", () { + var color = parseValue("rgba(10, 20, 30, 0.7)") as SassColor; + expect(color.alpha, closeTo(0.7, 1e-11)); + }); + + group("new SassColor.rgb()", () { + test("allows out-of-gamut values", () { + expect(SassColor.rgb(-1, 0, 0, 0).channel("red"), equals(-1)); + expect(SassColor.rgb(0, 100, 0, 0).channel("green"), equals(100)); + }); + + test("disallows out-of-gamut alpha values", () { + expect(() => SassColor.rgb(0, 0, 0, -0.1), throwsRangeError); + expect(() => SassColor.rgb(0, 0, 0, 1.1), throwsRangeError); }); }); } diff --git a/test/dart_api/value/function_test.dart b/test/dart_api/value/function_test.dart index 03776d07c..dee752533 100644 --- a/test/dart_api/value/function_test.dart +++ b/test/dart_api/value/function_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/list_test.dart b/test/dart_api/value/list_test.dart index e49605ce5..bd283680a 100644 --- a/test/dart_api/value/list_test.dart +++ b/test/dart_api/value/list_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/map_test.dart b/test/dart_api/value/map_test.dart index e82ef7f17..a6c82d442 100644 --- a/test/dart_api/value/map_test.dart +++ b/test/dart_api/value/map_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/null_test.dart b/test/dart_api/value/null_test.dart index 4badc075a..e9770a7e4 100644 --- a/test/dart_api/value/null_test.dart +++ b/test/dart_api/value/null_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api/value/number_test.dart b/test/dart_api/value/number_test.dart index 42741fdf9..0a9c7d203 100644 --- a/test/dart_api/value/number_test.dart +++ b/test/dart_api/value/number_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:math' as math; diff --git a/test/dart_api/value/string_test.dart b/test/dart_api/value/string_test.dart index 61d8023b2..702015e58 100644 --- a/test/dart_api/value/string_test.dart +++ b/test/dart_api/value/string_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; diff --git a/test/dart_api_test.dart b/test/dart_api_test.dart index 2fd8d6737..5337e4903 100644 --- a/test/dart_api_test.dart +++ b/test/dart_api_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:package_config/package_config.dart'; import 'package:path/path.dart' as p; @@ -20,7 +21,7 @@ void main() { group("importers", () { test("is used to resolve imports", () async { await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); - await d.file("test.scss", '@import "subtest.scss";').create(); + await d.file("test.scss", '@use "subtest.scss";').create(); var css = compile(d.path("test.scss"), importers: [FilesystemImporter(d.path('subdir'))]); @@ -32,7 +33,7 @@ void main() { .dir("first", [d.file("other.scss", "a {b: from-first}")]).create(); await d .dir("second", [d.file("other.scss", "a {b: from-second}")]).create(); - await d.file("test.scss", '@import "other";').create(); + await d.file("test.scss", '@use "other";').create(); var css = compile(d.path("test.scss"), importers: [ FilesystemImporter(d.path('first')), @@ -45,7 +46,7 @@ void main() { group("loadPaths", () { test("is used to import file: URLs", () async { await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); - await d.file("test.scss", '@import "subtest.scss";').create(); + await d.file("test.scss", '@use "subtest.scss";').create(); var css = compile(d.path("test.scss"), loadPaths: [d.path('subdir')]); expect(css, equals("a {\n b: c;\n}")); @@ -53,7 +54,7 @@ void main() { test("can import partials", () async { await d.dir("subdir", [d.file("_subtest.scss", "a {b: c}")]).create(); - await d.file("test.scss", '@import "subtest.scss";').create(); + await d.file("test.scss", '@use "subtest.scss";').create(); var css = compile(d.path("test.scss"), loadPaths: [d.path('subdir')]); expect(css, equals("a {\n b: c;\n}")); @@ -61,7 +62,7 @@ void main() { test("adds a .scss extension", () async { await d.dir("subdir", [d.file("subtest.scss", "a {b: c}")]).create(); - await d.file("test.scss", '@import "subtest";').create(); + await d.file("test.scss", '@use "subtest";').create(); var css = compile(d.path("test.scss"), loadPaths: [d.path('subdir')]); expect(css, equals("a {\n b: c;\n}")); @@ -69,7 +70,7 @@ void main() { test("adds a .sass extension", () async { await d.dir("subdir", [d.file("subtest.sass", "a\n b: c")]).create(); - await d.file("test.scss", '@import "subtest";').create(); + await d.file("test.scss", '@use "subtest";').create(); var css = compile(d.path("test.scss"), loadPaths: [d.path('subdir')]); expect(css, equals("a {\n b: c;\n}")); @@ -80,7 +81,7 @@ void main() { .dir("first", [d.file("other.scss", "a {b: from-first}")]).create(); await d .dir("second", [d.file("other.scss", "a {b: from-second}")]).create(); - await d.file("test.scss", '@import "other";').create(); + await d.file("test.scss", '@use "other";').create(); var css = compile(d.path("test.scss"), loadPaths: [d.path('first'), d.path('second')]); @@ -92,9 +93,7 @@ void main() { test("is used to import package: URLs", () async { await d.dir("subdir", [d.file("test.scss", "a {b: 1 + 2}")]).create(); - await d - .file("test.scss", '@import "package:fake_package/test";') - .create(); + await d.file("test.scss", '@use "package:fake_package/test";').create(); var config = PackageConfig([Package('fake_package', p.toUri(d.path('subdir/')))]); @@ -104,13 +103,11 @@ void main() { test("can resolve relative paths in a package", () async { await d.dir("subdir", [ - d.file("test.scss", "@import 'other'"), + d.file("test.scss", "@use 'other'"), d.file("_other.scss", "a {b: 1 + 2}"), ]).create(); - await d - .file("test.scss", '@import "package:fake_package/test";') - .create(); + await d.file("test.scss", '@use "package:fake_package/test";').create(); var config = PackageConfig([Package('fake_package', p.toUri(d.path('subdir/')))]); @@ -120,7 +117,7 @@ void main() { test("doesn't import a package URL from a missing package", () async { await d - .file("test.scss", '@import "package:fake_package/test_aux";') + .file("test.scss", '@use "package:fake_package/test_aux";') .create(); expect( @@ -134,7 +131,7 @@ void main() { await d.dir( "subdir", [d.file("other.scss", "a {b: from-load-path}")]).create(); await d.file("other.scss", "a {b: from-relative}").create(); - await d.file("test.scss", '@import "other";').create(); + await d.file("test.scss", '@use "other";').create(); var css = compile(d.path("test.scss"), importers: [FilesystemImporter(d.path('subdir'))]); @@ -149,7 +146,7 @@ void main() { await d .dir("other", [d.file("other.scss", "a {b: from-other}")]).create(); - var css = compileString('@import "other";', + var css = compileString('@use "other";', importer: FilesystemImporter(d.path('original')), url: p.toUri(d.path('original/test.scss')), importers: [FilesystemImporter(d.path('other'))]); @@ -157,7 +154,7 @@ void main() { }); test("importer order is preserved for absolute imports", () { - var css = compileString('@import "second:other";', importers: [ + var css = compileString('@use "second:other";', importers: [ TestImporter((url) => url.scheme == 'first' ? url : null, (url) => ImporterResult('a {from: first}', indented: false)), // This importer should only be invoked once, because when the @@ -166,7 +163,7 @@ void main() { TestImporter( expectAsync1((url) => url.scheme == 'second' ? url : null, count: 1), - (url) => ImporterResult('@import "first:other";', indented: false)), + (url) => ImporterResult('@use "first:other";', indented: false)), ]); expect(css, equals("a {\n from: first;\n}")); }); @@ -176,7 +173,7 @@ void main() { [d.file("other.scss", "a {b: from-load-path}")]).create(); await d.dir( "importer", [d.file("other.scss", "a {b: from-importer}")]).create(); - await d.file("test.scss", '@import "other";').create(); + await d.file("test.scss", '@use "other";').create(); var css = compile(d.path("test.scss"), importers: [FilesystemImporter(d.path('importer'))], @@ -189,9 +186,7 @@ void main() { [d.file("other.scss", "a {b: from-package-config}")]).create(); await d.dir( "importer", [d.file("other.scss", "a {b: from-importer}")]).create(); - await d - .file("test.scss", '@import "package:fake_package/other";') - .create(); + await d.file("test.scss", '@use "package:fake_package/other";').create(); var css = compile(d.path("test.scss"), importers: [ @@ -268,7 +263,8 @@ a { test("contains a URL loaded via @import", () async { await d.file("_other.scss", "a {b: c}").create(); await d.file("input.scss", "@import 'other';").create(); - var result = compileToResult(d.path('input.scss')); + var result = compileToResult(d.path('input.scss'), + silenceDeprecations: [Deprecation.import]); expect(result.loadedUrls, contains(p.toUri(d.path('_other.scss')))); }); @@ -305,7 +301,8 @@ a { @use 'sass:meta'; @include meta.load-css('venus'); """).create(); - var result = compileToResult(d.path('mercury.scss')); + var result = compileToResult(d.path('mercury.scss'), + silenceDeprecations: [Deprecation.import]); expect( result.loadedUrls, unorderedEquals([ diff --git a/test/deprecations_test.dart b/test/deprecations_test.dart index 28cd1e2fd..5841ea323 100644 --- a/test/deprecations_test.dart +++ b/test/deprecations_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; @@ -16,7 +17,7 @@ void main() { // Deprecated in 1.3.2 test("elseIf is violated by using @elseif instead of @else if", () { - _expectDeprecation("@if false {} @elseif {}", Deprecation.elseif); + _expectDeprecation("@if false {} @elseif false {}", Deprecation.elseif); }); // Deprecated in 1.7.2 @@ -98,12 +99,6 @@ void main() { _expectDeprecation("a {b: hsl(10deg, 0%, 0)}", Deprecation.functionUnits); }); - test("an alpha value with a percent unit", () { - _expectDeprecation( - r"@use 'sass:color'; a {b: color.change(red, $alpha: 1%)}", - Deprecation.functionUnits); - }); - test("an alpha value with a non-percent unit", () { _expectDeprecation( r"@use 'sass:color'; a {b: color.change(red, $alpha: 1px)}", diff --git a/test/doc_comments_test.dart b/test/doc_comments_test.dart index 82e2f5252..699ff15a2 100644 --- a/test/doc_comments_test.dart +++ b/test/doc_comments_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:sass/src/ast/sass.dart'; import 'package:test/test.dart'; diff --git a/test/double_check_test.dart b/test/double_check_test.dart index 5cc2a2411..ab1332345 100644 --- a/test/double_check_test.dart +++ b/test/double_check_test.dart @@ -3,121 +3,182 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:io'; import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:test/test.dart'; +import '../tool/grind/generate_deprecations.dart' as deprecations; import '../tool/grind/synchronize.dart' as synchronize; /// Tests that double-check that everything in the repo looks sensible. void main() { - group("synchronized file is up-to-date:", () { - synchronize.sources.forEach((sourcePath, targetPath) { - test(targetPath, () { - if (File(targetPath).readAsStringSync() != - synchronize.synchronizeFile(sourcePath)) { - fail("$targetPath is out-of-date.\n" - "Run `dart pub run grinder` to update it."); - } + group("up-to-date generated", () { + group("synchronized file:", () { + synchronize.sources.forEach((sourcePath, targetPath) { + test(targetPath, () { + if (File(targetPath).readAsStringSync() != + synchronize.synchronizeFile(sourcePath)) { + fail("$targetPath is out-of-date.\n" + "Run `dart run grinder` to update it."); + } + }); }); }); + + test("deprecations", () { + var inputText = File(deprecations.yamlPath).readAsStringSync(); + var outputText = File(deprecations.dartPath).readAsStringSync(); + var checksum = sha1.convert(utf8.encode(inputText)); + if (!outputText.contains('// Checksum: $checksum')) { + fail('${deprecations.dartPath} is out-of-date.\n' + 'Run `dart run grinder` to update it.'); + } + }); }, // Windows sees different bytes than other OSes, possibly because of // 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"])); + }); + }); + + 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( + "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; + + var pkgMajor = pkgVersion.major; + var pkgMinor = pkgVersion.minor; + var pkgPatch = pkgVersion.patch; + if (pkgMajor == 0) { + // Before 1.0.0, the semantics of each version number are moved up by one + // place. + pkgMajor = pkgMinor; + pkgMinor = pkgPatch; + pkgPatch = 0; + } + + if (sassVersion.minor != 0) { + expect(pkgPatch, equals(0), + reason: "sass minor version was incremented, $pkgName must increment " + "at least its minor version"); + } else { + expect(pkgMinor, equals(0), + reason: "sass major version was incremented, $pkgName must increment " + "at its major version as well"); + } +} diff --git a/test/embedded/embedded_process.dart b/test/embedded/embedded_process.dart index 30279b0ba..89740b25c 100644 --- a/test/embedded/embedded_process.dart +++ b/test/embedded/embedded_process.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:async'; import 'dart:convert'; diff --git a/test/embedded/file_importer_test.dart b/test/embedded/file_importer_test.dart index 8d5bf4ec2..61c8e1a42 100644 --- a/test/embedded/file_importer_test.dart +++ b/test/embedded/file_importer_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:path/path.dart' as p; import 'package:test/test.dart'; @@ -24,7 +25,7 @@ void main() { late OutboundMessage_FileImportRequest request; setUp(() async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); @@ -63,7 +64,7 @@ void main() { late OutboundMessage_FileImportRequest request; setUp(() async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); @@ -77,7 +78,7 @@ void main() { ..id = request.id ..fileUrl = "")); - await _expectImportError( + await _expectUseError( process, 'The file importer must return an absolute URL, was ""'); await process.close(); }); @@ -88,7 +89,7 @@ void main() { ..id = request.id ..fileUrl = "foo")); - await _expectImportError(process, + await _expectUseError(process, 'The file importer must return an absolute URL, was "foo"'); await process.close(); }); @@ -99,7 +100,7 @@ void main() { ..id = request.id ..fileUrl = "other:foo")); - await _expectImportError(process, + await _expectUseError(process, 'The file importer must return a file: URL, was "other:foo"'); await process.close(); }); @@ -111,8 +112,7 @@ void main() { var importerId = 5679; late OutboundMessage_FileImportRequest request; setUp(() async { - process.send( - compileString("@import 'other'", id: compilationId, importers: [ + process.send(compileString("@use 'other'", id: compilationId, importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = importerId ])); request = await getFileImportRequest(process); @@ -129,13 +129,13 @@ void main() { }); test("whether the import came from an @import", () async { - expect(request.fromImport, isTrue); + expect(request.fromImport, isFalse); await process.kill(); }); }); test("errors cause compilation to fail", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); @@ -147,13 +147,13 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); - expect(failure.span.text, equals("'other'")); - expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + expect(failure.span.text, equals("@use 'other'")); + expect(failure.stackTrace, equals('- 1:1 root stylesheet\n')); await process.close(); }); test("null results count as not found", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..fileImporterId = 1 ])); @@ -164,13 +164,13 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); - expect(failure.span.text, equals("'other'")); + expect(failure.span.text, equals("@use 'other'")); await process.close(); }); group("attempts importers in order", () { test("with multiple file importers", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..fileImporterId = i ])); @@ -187,7 +187,7 @@ void main() { }); test("with a mixture of file and normal importers", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ for (var i = 0; i < 10; i++) if (i % 2 == 0) InboundMessage_CompileRequest_Importer()..fileImporterId = i @@ -217,9 +217,9 @@ void main() { test("tries resolved URL as a relative path first", () async { await d.file("upstream.scss", "a {b: c}").create(); - await d.file("midstream.scss", "@import 'upstream';").create(); + await d.file("midstream.scss", "@use 'upstream';").create(); - process.send(compileString("@import 'midstream'", importers: [ + process.send(compileString("@use 'midstream'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..fileImporterId = i ])); @@ -250,7 +250,7 @@ void main() { }); test("without a base URL", () async { - process.send(compileString("@import 'other'", + process.send(compileString("@use 'other'", importer: InboundMessage_CompileRequest_Importer() ..fileImporterId = 1)); @@ -267,7 +267,7 @@ void main() { }); test("with a base URL", () async { - process.send(compileString("@import 'other'", + process.send(compileString("@use 'other'", url: p.toUri(d.path("input")).toString(), importer: InboundMessage_CompileRequest_Importer() ..fileImporterId = 1)); @@ -280,8 +280,8 @@ void main() { /// Asserts that [process] emits a [CompileFailure] result with the given /// [message] on its protobuf stream and causes the compilation to fail. -Future _expectImportError(EmbeddedProcess process, Object message) async { +Future _expectUseError(EmbeddedProcess process, Object message) async { var failure = await getCompileFailure(process); expect(failure.message, equals(message)); - expect(failure.span.text, equals("'other'")); + expect(failure.span.text, equals("@use 'other'")); } diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart index d927a780f..694341405 100644 --- a/test/embedded/function_test.dart +++ b/test/embedded/function_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:test/test.dart'; @@ -209,7 +210,7 @@ void main() { @use "sass:math"; @use "sass:meta"; - a {b: call(foo(meta.get-function("abs", $module: "math")), -1)} + a {b: meta.call(foo(meta.get-function("abs", $module: "math")), -1)} """, functions: [r"foo($arg)"])); var request = await getFunctionCallRequest(_process); @@ -225,8 +226,11 @@ void main() { }); test("defined in the host", () async { - _process.send( - compileString("a {b: call(foo(), true)}", functions: [r"foo()"])); + _process.send(compileString(""" + @use "sass:meta"; + + a {b: meta.call(foo(), true)} + """, functions: [r"foo()"])); var hostFunctionId = 5678; var request = await getFunctionCallRequest(_process); @@ -253,9 +257,11 @@ void main() { test("defined in the host and passed to and from the host", () async { _process.send(compileString(r""" + @use "sass:meta"; + $function: get-host-function(); $function: round-trip($function); - a {b: call($function, true)} + a {b: meta.call($function, true)} """, functions: [r"get-host-function()", r"round-trip($function)"])); var hostFunctionId = 5678; @@ -310,7 +316,7 @@ void main() { group("unquoted", () { test("and empty", () async { - var value = (await _protofy('unquote("")')).string; + var value = (await _protofy('string.unquote("")')).string; expect(value.text, isEmpty); expect(value.quoted, isFalse); }); @@ -912,7 +918,7 @@ void main() { ..value = 1 ..numerators.addAll(["em", "px", "foo"])), inspect: true), - "1em*px*foo"); + "calc(1em * 1px * 1foo)"); }); test("with one denominator", () async { @@ -923,7 +929,7 @@ void main() { ..value = 1 ..denominators.add("em")), inspect: true), - "1em^-1"); + "calc(1 / 1em)"); }); test("with multiple denominators", () async { @@ -934,7 +940,7 @@ void main() { ..value = 1 ..denominators.addAll(["em", "px", "foo"])), inspect: true), - "1(em*px*foo)^-1"); + "calc(1 / 1em / 1px / 1foo)"); }); test("with numerators and denominators", () async { @@ -946,7 +952,7 @@ void main() { ..numerators.addAll(["em", "px"]) ..denominators.addAll(["s", "foo"])), inspect: true), - "1em*px/s*foo"); + "calc(1em * 1px / 1s / 1foo)"); }); }); @@ -965,6 +971,21 @@ void main() { expect(await _deprotofy(_rgb(0xaa, 0xbb, 0xcc, 1.0)), equals('#aabbcc')); }); + + test("with red above 255", () async { + expect(await _deprotofy(_rgb(256, 0, 0, 1.0)), + equals('hsl(0, 100.7874015748%, 50.1960784314%)')); + }); + + test("with green above 255", () async { + expect(await _deprotofy(_rgb(0, 256, 0, 1.0)), + equals('hsl(120, 100.7874015748%, 50.1960784314%)')); + }); + + test("with blue above 255", () async { + expect(await _deprotofy(_rgb(0, 0, 256, 1.0)), + equals('hsl(240, 100.7874015748%, 50.1960784314%)')); + }); }); group("with alpha", () { @@ -984,64 +1005,73 @@ void main() { group("without alpha:", () { group("hue", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(0, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("360", () async { - expect(await _deprotofy(_hsl(360, 50, 50, 1.0)), "#bf4040"); + expect( + await _deprotofy(_hsl(360, 50, 50, 1.0)), "hsl(0, 50%, 50%)"); }); test("below 0", () async { - expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), "#6a40bf"); + expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), + "hsl(260, 50%, 50%)"); }); test("between 0 and 360", () async { - expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), "#6abf40"); + expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), + "hsl(100, 50%, 50%)"); }); test("above 360", () async { - expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), "#4095bf"); + expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), + "hsl(200, 50%, 50%)"); }); }); group("saturation", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "gray"); + expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "hsl(0, 0%, 50%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 100, 50, 1.0)), "red"); + expect( + await _deprotofy(_hsl(0, 100, 50, 1.0)), "hsl(0, 100%, 50%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 42, 50, 1.0)), "#b54a4a"); + expect( + await _deprotofy(_hsl(0, 42, 50, 1.0)), "hsl(0, 42%, 50%)"); }); }); group("lightness", () { test("0", () async { - expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "black"); + expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "hsl(0, 50%, 0%)"); }); test("100", () async { - expect(await _deprotofy(_hsl(0, 50, 100, 1.0)), "white"); + expect( + await _deprotofy(_hsl(0, 50, 100, 1.0)), "hsl(0, 50%, 100%)"); }); test("in the middle", () async { - expect(await _deprotofy(_hsl(0, 50, 42, 1.0)), "#a13636"); + expect( + await _deprotofy(_hsl(0, 50, 42, 1.0)), "hsl(0, 50%, 42%)"); }); }); }); group("with alpha", () { test("0", () async { - expect( - await _deprotofy(_hsl(10, 20, 30, 0.0)), "rgba(92, 66, 61, 0)"); + expect(await _deprotofy(_hsl(10, 20, 30, 0.0)), + "hsla(10, 20%, 30%, 0)"); }); test("between 0 and 1", () async { expect(await _deprotofy(_hsl(10, 20, 30, 0.123)), - "rgba(92, 66, 61, 0.123)"); + "hsla(10, 20%, 30%, 0.123)"); }); }); }); @@ -1594,63 +1624,24 @@ void main() { group("and rejects", () { group("a color", () { - test("with red above 255", () async { - await _expectDeprotofyError(_rgb(256, 0, 0, 1.0), - "RgbColor.red must be between 0 and 255, was 256"); - }); - - test("with green above 255", () async { - await _expectDeprotofyError(_rgb(0, 256, 0, 1.0), - "RgbColor.green must be between 0 and 255, was 256"); - }); - - test("with blue above 255", () async { - await _expectDeprotofyError(_rgb(0, 0, 256, 1.0), - "RgbColor.blue must be between 0 and 255, was 256"); - }); - test("with RGB alpha below 0", () async { await _expectDeprotofyError(_rgb(0, 0, 0, -0.1), - "RgbColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with RGB alpha above 1", () async { await _expectDeprotofyError(_rgb(0, 0, 0, 1.1), - "RgbColor.alpha must be between 0 and 1, was 1.1"); - }); - - test("with saturation below 0", () async { - await _expectDeprotofyError(_hsl(0, -0.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was -0.1"); - }); - - test("with saturation above 100", () async { - await _expectDeprotofyError( - _hsl(0, 100.1, 0, 1.0), - "HslColor.saturation must be between 0 and 100, was " - "100.1"); - }); - - test("with lightness below 0", () async { - await _expectDeprotofyError(_hsl(0, 0, -0.1, 1.0), - "HslColor.lightness must be between 0 and 100, was -0.1"); - }); - - test("with lightness above 100", () async { - await _expectDeprotofyError( - _hsl(0, 0, 100.1, 1.0), - "HslColor.lightness must be between 0 and 100, was " - "100.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); test("with HSL alpha below 0", () async { await _expectDeprotofyError(_hsl(0, 0, 0, -0.1), - "HslColor.alpha must be between 0 and 1, was -0.1"); + "Color.alpha must be between 0 and 1, was -0.1"); }); test("with HSL alpha above 1", () async { await _expectDeprotofyError(_hsl(0, 0, 0, 1.1), - "HslColor.alpha must be between 0 and 1, was 1.1"); + "Color.alpha must be between 0 and 1, was 1.1"); }); }); @@ -1764,8 +1755,9 @@ void main() { group("reports a compilation error for a function with a signature", () { Future expectSignatureError( String signature, Object message) async { - _process.send( - compileString("a {b: inspect(foo())}", functions: [r"foo()"])); + _process.send(compileString( + "@use 'sass:meta';\na {b: meta.inspect(foo())}", + functions: [r"foo()"])); var request = await getFunctionCallRequest(_process); expect(request.arguments, isEmpty); @@ -1819,6 +1811,7 @@ Future _protofy(String sassScript) async { @use 'sass:map'; @use 'sass:math'; @use 'sass:meta'; +@use 'sass:string'; @function capture-args(\$args...) { \$_: meta.keywords(\$args); @@ -1852,7 +1845,9 @@ void _testSerializationAndRoundTrip(Value value, String expected, /// `meta.inspect()` function. Future _deprotofy(Value value, {bool inspect = false}) async { _process.send(compileString( - inspect ? "a {b: inspect(foo())}" : "a {b: foo()}", + inspect + ? "@use 'sass:meta';\na {b: meta.inspect(foo())}" + : "a {b: foo()}", functions: [r"foo()"])); var request = await getFunctionCallRequest(_process); @@ -1914,18 +1909,20 @@ Future _roundTrip(Value value) async { /// Returns a [Value] that's an RGB color with the given fields. Value _rgb(int red, int green, int blue, double alpha) => Value() - ..rgbColor = (Value_RgbColor() - ..red = red - ..green = green - ..blue = blue + ..color = (Value_Color() + ..space = 'rgb' + ..channel1 = red * 1.0 + ..channel2 = green * 1.0 + ..channel3 = blue * 1.0 ..alpha = alpha); /// Returns a [Value] that's an HSL color with the given fields. Value _hsl(num hue, num saturation, num lightness, double alpha) => Value() - ..hslColor = (Value_HslColor() - ..hue = hue * 1.0 - ..saturation = saturation * 1.0 - ..lightness = lightness * 1.0 + ..color = (Value_Color() + ..space = 'hsl' + ..channel1 = hue * 1.0 + ..channel2 = saturation * 1.0 + ..channel3 = lightness * 1.0 ..alpha = alpha); /// Asserts that [process] emits a [CompileFailure] result with the given diff --git a/test/embedded/importer_test.dart b/test/embedded/importer_test.dart index fdfc904d2..ee4d90c0f 100644 --- a/test/embedded/importer_test.dart +++ b/test/embedded/importer_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:source_maps/source_maps.dart' as source_maps; import 'package:test/test.dart'; @@ -22,7 +23,7 @@ void main() { group("emits a protocol error", () { test("for a response without a corresponding request ID", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -40,7 +41,7 @@ void main() { }); test("for a response that doesn't match the request type", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -109,7 +110,7 @@ void main() { group("canonicalization", () { group("emits a compile failure", () { test("for a canonicalize response with an empty URL", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -125,7 +126,7 @@ void main() { }); test("for a canonicalize response with a relative URL", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -145,7 +146,7 @@ void main() { var importerId = 5679; late OutboundMessage_CanonicalizeRequest request; setUp(() async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = importerId ])); request = await getCanonicalizeRequest(process); @@ -163,7 +164,7 @@ void main() { }); test("errors cause compilation to fail", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -175,13 +176,13 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); - expect(failure.span.text, equals("'other'")); - expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + expect(failure.span.text, equals("@use 'other'")); + expect(failure.stackTrace, equals('- 1:1 root stylesheet\n')); await process.close(); }); test("null results count as not found", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -192,13 +193,13 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); - expect(failure.span.text, equals("'other'")); + expect(failure.span.text, equals("@use 'other'")); await process.close(); }); group("the containing URL", () { test("is unset for a potentially canonical scheme", () async { - process.send(compileString('@import "u:orange"', importers: [ + process.send(compileString('@use "u:orange"', importers: [ InboundMessage_CompileRequest_Importer(importerId: 1) ])); @@ -209,7 +210,7 @@ void main() { group("for a non-canonical scheme", () { test("is set to the original URL", () async { - process.send(compileString('@import "u:orange"', + process.send(compileString('@use "u:orange"', importers: [ InboundMessage_CompileRequest_Importer( importerId: 1, nonCanonicalScheme: ["u"]) @@ -222,7 +223,7 @@ void main() { }); test("is unset to the original URL is unset", () async { - process.send(compileString('@import "u:orange"', importers: [ + process.send(compileString('@use "u:orange"', importers: [ InboundMessage_CompileRequest_Importer( importerId: 1, nonCanonicalScheme: ["u"]) ])); @@ -235,7 +236,7 @@ void main() { group("for a schemeless load", () { test("is set to the original URL", () async { - process.send(compileString('@import "orange"', + process.send(compileString('@use "orange"', importers: [ InboundMessage_CompileRequest_Importer(importerId: 1) ], @@ -247,7 +248,7 @@ void main() { }); test("is unset to the original URL is unset", () async { - process.send(compileString('@import "u:orange"', importers: [ + process.send(compileString('@use "u:orange"', importers: [ InboundMessage_CompileRequest_Importer(importerId: 1) ])); @@ -261,7 +262,7 @@ void main() { test( "fails if the importer returns a canonical URL with a non-canonical " "scheme", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer( importerId: 1, nonCanonicalScheme: ["u"]) ])); @@ -277,7 +278,7 @@ void main() { }); test("attempts importers in order", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..importerId = i ])); @@ -294,7 +295,7 @@ void main() { }); test("tries resolved URL using the original importer first", () async { - process.send(compileString("@import 'midstream'", importers: [ + process.send(compileString("@use 'midstream'", importers: [ for (var i = 0; i < 10; i++) InboundMessage_CompileRequest_Importer()..importerId = i ])); @@ -320,7 +321,7 @@ void main() { ..importResponse = (InboundMessage_ImportResponse() ..id = import.id ..success = (InboundMessage_ImportResponse_ImportSuccess() - ..contents = "@import 'upstream'"))); + ..contents = "@use 'upstream'"))); canonicalize = await getCanonicalizeRequest(process); expect(canonicalize.importerId, equals(5)); @@ -333,7 +334,7 @@ void main() { group("importing", () { group("emits a compile failure", () { test("for an import result with a relative sourceMapUrl", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); @@ -355,7 +356,7 @@ void main() { var importerId = 5678; late OutboundMessage_ImportRequest request; setUp(() async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = importerId ])); @@ -380,7 +381,7 @@ void main() { }); test("null results count as not found", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -397,12 +398,12 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals("Can't find stylesheet to import.")); - expect(failure.span.text, equals("'other'")); + expect(failure.span.text, equals("@use 'other'")); await process.close(); }); test("errors cause compilation to fail", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); @@ -415,13 +416,13 @@ void main() { var failure = await getCompileFailure(process); expect(failure.message, equals('oh no')); - expect(failure.span.text, equals("'other'")); - expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + expect(failure.span.text, equals("@use 'other'")); + expect(failure.stackTrace, equals('- 1:1 root stylesheet\n')); await process.close(); }); test("can return an SCSS file", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); @@ -438,7 +439,7 @@ void main() { }); test("can return an indented syntax file", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); @@ -456,7 +457,7 @@ void main() { }); test("can return a plain CSS file", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await _canonicalize(process); @@ -474,11 +475,9 @@ void main() { }); test("uses a data: URL rather than an empty source map URL", () async { - process.send(compileString("@import 'other'", - sourceMap: true, - importers: [ - InboundMessage_CompileRequest_Importer()..importerId = 1 - ])); + process.send(compileString("@use 'other'", sourceMap: true, importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); await _canonicalize(process); var request = await getImportRequest(process); @@ -497,11 +496,9 @@ void main() { }); test("uses a non-empty source map URL", () async { - process.send(compileString("@import 'other'", - sourceMap: true, - importers: [ - InboundMessage_CompileRequest_Importer()..importerId = 1 - ])); + process.send(compileString("@use 'other'", sourceMap: true, importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); await _canonicalize(process); var request = await getImportRequest(process); @@ -521,7 +518,7 @@ void main() { }); test("handles an importer for a string compile request", () async { - process.send(compileString("@import 'other'", + process.send(compileString("@use 'other'", importer: InboundMessage_CompileRequest_Importer()..importerId = 1)); await _canonicalize(process); @@ -540,7 +537,7 @@ void main() { test("are used to load imports", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..path = d.path("dir") ])); @@ -553,7 +550,7 @@ void main() { await d.dir("dir$i", [d.file("other$i.scss", "a {b: $i}")]).create(); } - process.send(compileString("@import 'other2'", importers: [ + process.send(compileString("@use 'other2'", importers: [ for (var i = 0; i < 3; i++) InboundMessage_CompileRequest_Importer()..path = d.path("dir$i") ])); @@ -565,7 +562,7 @@ void main() { test("take precedence over later importers", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..path = d.path("dir"), InboundMessage_CompileRequest_Importer()..importerId = 1 ])); @@ -577,7 +574,7 @@ void main() { test("yield precedence to earlier importers", () async { await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1, InboundMessage_CompileRequest_Importer()..path = d.path("dir") ])); @@ -653,5 +650,5 @@ Future _canonicalize(EmbeddedProcess process) async { Future _expectImportError(EmbeddedProcess process, Object message) async { var failure = await getCompileFailure(process); expect(failure.message, equals(message)); - expect(failure.span.text, equals("'other'")); + expect(failure.span.text, equals("@use 'other'")); } diff --git a/test/embedded/length_delimited_test.dart b/test/embedded/length_delimited_test.dart index e329d1fdc..1d4c6fb62 100644 --- a/test/embedded/length_delimited_test.dart +++ b/test/embedded/length_delimited_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:async'; import 'dart:typed_data'; diff --git a/test/embedded/protocol_test.dart b/test/embedded/protocol_test.dart index 9e293365e..c7473ac69 100644 --- a/test/embedded/protocol_test.dart +++ b/test/embedded/protocol_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -67,7 +68,7 @@ void main() { }); test("caused by duplicate compilation IDs", () async { - process.send(compileString("@import 'other'", importers: [ + process.send(compileString("@use 'other'", importers: [ InboundMessage_CompileRequest_Importer()..importerId = 1 ])); await getCanonicalizeRequest(process); @@ -96,7 +97,7 @@ void main() { Version.parse(response.protocolVersion); // shouldn't throw Version.parse(response.compilerVersion); // shouldn't throw Version.parse(response.implementationVersion); // shouldn't throw - expect(response.implementationName, equals("Dart Sass")); + expect(response.implementationName, equals("dart-sass")); await process.close(); }); @@ -367,12 +368,13 @@ void main() { var logEvent = await getLogEvent(process); expect( logEvent.formatted, - equals('WARNING on line 1, column 13: \n' - 'In Sass, "&&" means two copies of the parent selector. You probably want to use "and" instead.\n' + equals('WARNING: In Sass, "&&" means two copies of the parent ' + 'selector. You probably want to use "and" instead.\n\n' ' ,\n' '1 | a {@debug a && b}\n' ' | ^^\n' - ' \'\n')); + ' \'\n' + ' - 1:13 root stylesheet\n')); await process.kill(); }); }); @@ -393,7 +395,7 @@ void main() { expect(logEvent.span.start, equals(location(12, 0, 12))); expect(logEvent.span.end, equals(location(19, 0, 19))); expect(logEvent.span.context, equals("@if true {} @elseif true {}")); - expect(logEvent.stackTrace, isEmpty); + expect(logEvent.stackTrace, "- 1:13 root stylesheet\n"); await process.kill(); }); diff --git a/test/embedded/utils.dart b/test/embedded/utils.dart index 68c1e2f83..7741706d6 100644 --- a/test/embedded/utils.dart +++ b/test/embedded/utils.dart @@ -7,6 +7,7 @@ import 'package:test/test.dart'; import 'package:sass/src/embedded/embedded_sass.pb.dart'; import 'package:sass/src/embedded/utils.dart'; +import 'package:sass/src/util/nullable.dart'; import 'embedded_process.dart'; @@ -27,7 +28,10 @@ InboundMessage compileString(String css, bool? sourceMapIncludeSources, Iterable? importers, InboundMessage_CompileRequest_Importer? importer, - Iterable? functions}) { + Iterable? functions, + Iterable? fatalDeprecations, + Iterable? futureDeprecations, + Iterable? silenceDeprecations}) { var input = InboundMessage_CompileRequest_StringInput()..source = css; if (syntax != null) input.syntax = syntax; if (url != null) input.url = url; @@ -43,7 +47,9 @@ InboundMessage compileString(String css, if (functions != null) request.globalFunctions.addAll(functions); if (alertColor != null) request.alertColor = alertColor; if (alertAscii != null) request.alertAscii = alertAscii; - + fatalDeprecations.andThen(request.fatalDeprecation.addAll); + futureDeprecations.andThen(request.futureDeprecation.addAll); + silenceDeprecations.andThen(request.silenceDeprecation.addAll); return InboundMessage()..compileRequest = request; } diff --git a/test/ensure_npm_package.dart b/test/ensure_npm_package.dart index 5e73c3c54..d31041e1a 100644 --- a/test/ensure_npm_package.dart +++ b/test/ensure_npm_package.dart @@ -4,6 +4,7 @@ import 'package:test/test.dart'; +import 'package:cli_pkg/js.dart'; import 'package:sass/src/io.dart'; /// Ensures that the NPM package is compiled and up-to-date. @@ -12,7 +13,7 @@ import 'package:sass/src/io.dart'; Future ensureNpmPackage() async { // spawnHybridUri() doesn't currently work on Windows and Node due to busted // path handling in the SDK. - if (isNode && isWindows) return; + if (isNodeJs && isWindows) return; var channel = spawnHybridCode(""" import 'package:cli_pkg/testing.dart' as pkg; diff --git a/test/output_test.dart b/test/output_test.dart index 2023ae65b..4a5f40cff 100644 --- a/test/output_test.dart +++ b/test/output_test.dart @@ -7,6 +7,7 @@ // implementation-specific to verify in sass-spec. @TestOn('vm') +library; import 'package:test/test.dart'; @@ -113,10 +114,55 @@ void main() { }); }); - // Tests for sass/dart-sass#417. + // Tests for sass/dart-sass#2070. // - // Note there's no need for "in Sass" cases as it's not possible to have - // trailing loud comments in the Sass syntax. + // These aren't covered by sass-spec because the inspect format for + // non-literal values isn't covered by the spec. + group("uses a nice format to inspect numbers with complex units", () { + group("finite", () { + test("top-level", () { + expect(compileString(""" + @use 'sass:meta'; + a {b: meta.inspect(1px * 1em)}; + """), equalsIgnoringWhitespace('a { b: calc(1px * 1em); }')); + }); + + test("in calc", () { + expect(compileString(""" + @use 'sass:meta'; + a {b: meta.inspect(calc(1px * 1em))}; + """), equalsIgnoringWhitespace('a { b: calc(1px * 1em); }')); + }); + + test("nested in calc", () { + expect(compileString(""" + @use 'sass:meta'; + a {b: meta.inspect(calc(c / (1px * 1em)))}; + """), equalsIgnoringWhitespace('a { b: calc(c / (1px * 1em)); }')); + }); + + test("numerator and denominator", () { + expect(compileString(""" + @use 'sass:math'; + @use 'sass:meta'; + a {b: meta.inspect(1px * math.div(math.div(1em, 1s), 1x))}; + """), equalsIgnoringWhitespace('a { b: calc(1px * 1em / 1s / 1x); }')); + }); + + test("denominator only", () { + expect(compileString(""" + @use 'sass:math'; + @use 'sass:meta'; + a {b: meta.inspect(math.div(math.div(1, 1s), 1x))}; + """), equalsIgnoringWhitespace('a { b: calc(1 / 1s / 1x); }')); + }); + }); + }); + + // Tests for sass/dart-sass#417. + // + // Note there's no need for "in Sass" cases as it's not possible to have + // trailing loud comments in the Sass syntax. group("preserve trailing loud comments in SCSS", () { test("after open block", () { expect(compileString(""" diff --git a/test/repo_test.dart b/test/repo_test.dart index 54d89fdbc..637899e7f 100644 --- a/test/repo_test.dart +++ b/test/repo_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'dart:io'; diff --git a/test/source_map_test.dart b/test/source_map_test.dart index 61b91120f..df4d0e4af 100644 --- a/test/source_map_test.dart +++ b/test/source_map_test.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. @TestOn('vm') +library; import 'package:charcode/charcode.dart'; import 'package:source_maps/source_maps.dart'; diff --git a/test/util/string_test.dart b/test/util/string_test.dart new file mode 100644 index 000000000..9aa6563de --- /dev/null +++ b/test/util/string_test.dart @@ -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 'package:test/test.dart'; + +import 'package:sass/src/util/string.dart'; +import 'package:sass/src/util/map.dart'; + +void main() { + group("toCssIdentifier()", () { + group("doesn't escape", () { + test('a double hyphen', + () => expect('--'.toCssIdentifier(), equals('--'))); + + group("a starting character", () { + const chars = { + 'lower-case alphabetic': 'q', + 'upper-case alphabetic': 'E', + 'an underscore': '_', + 'non-ASCII': 'ä', + 'double-width': '👭' + }; + + group("at the very beginning that's", () { + for (var (name, char) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier(), equals(char))); + } + }); + + group("after a single hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier(), equals('-$char'))); + } + }); + }); + + group("a middle character", () { + const chars = { + 'lower-case alphabetic': 'q', + 'upper-case alphabetic': 'E', + 'numeric': '4', + 'an underscore': '_', + 'a hyphen': '-', + 'non-ASCII': 'ä', + 'double-width': '👭' + }; + + group("after a name start that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier(), equals('a$char'))); + } + }); + + group("after a double hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier(), equals('--$char'))); + } + }); + }); + }); + + group('escapes', () { + test('a single hyphen', + () => expect('-'.toCssIdentifier(), equals('\\2d'))); + + group('a starting character', () { + const chars = { + 'numeric': ('4', '\\34'), + 'non-alphanumeric ASCII': ('%', '\\25'), + 'a BMP private-use character': ('\ueabc', '\\eabc'), + 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'), + }; + + group("at the very beginning that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier(), equals(escape))); + } + }); + + group("after a single hyphen that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier(), equals('-$escape'))); + } + }); + }); + + group('a middle character', () { + const chars = { + 'non-alphanumeric ASCII': ('%', '\\25'), + 'a BMP private-use character': ('\ueabc', '\\eabc'), + 'a supplementary private-use character': ('\u{fabcd}', '\\fabcd'), + }; + + group("after a name start that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier(), equals('a$escape'))); + } + }); + + group("after a double hyphen that's", () { + for (var (name, (char, escape)) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier(), equals('--$escape'))); + } + }); + }); + }); + + group('throws an error for', () { + test('the empty string', + () => expect(''.toCssIdentifier, throwsFormatException)); + + const chars = { + 'zero': '\u0000', + 'single high surrogate': '\udabc', + 'single low surrogate': '\udcde', + }; + + group("a starting character that's", () { + for (var (name, char) in chars.pairs) { + test(name, () => expect(char.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('-$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a name start that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("after a double hyphen that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('--$char'.toCssIdentifier, throwsFormatException)); + } + }); + + group("before a body char that's", () { + for (var (name, char) in chars.pairs) { + test(name, + () => expect('a${char}b'.toCssIdentifier, throwsFormatException)); + } + }); + }); + + group('adds a space between an escape and', () { + test('a digit', () => expect(' 1'.toCssIdentifier(), '\\20 1')); + + test('a lowercase hex letter', + () => expect(' b'.toCssIdentifier(), '\\20 b')); + + test('an uppercase hex letter', + () => expect(' B'.toCssIdentifier(), '\\20 B')); + }); + + group('doesn\'t add a space between an escape and', () { + test( + 'the end of the string', () => expect(' '.toCssIdentifier(), '\\20')); + + test('a lowercase non-hex letter', + () => expect(' g'.toCssIdentifier(), '\\20g')); + + test('an uppercase non-hex letter', + () => expect(' G'.toCssIdentifier(), '\\20G')); + + test('a hyphen', () => expect(' -'.toCssIdentifier(), '\\20-')); + + test('a non-ascii character', + () => expect(' ä'.toCssIdentifier(), '\\20ä')); + + test('another escape', () => expect(' '.toCssIdentifier(), '\\20\\20')); + }); + }); +} diff --git a/tool/grind.dart b/tool/grind.dart index ce65138df..6f2acc9db 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -11,6 +11,8 @@ import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; +import 'grind/bump_version.dart'; +import 'grind/generate_deprecations.dart'; import 'grind/synchronize.dart'; import 'grind/utils.dart'; @@ -18,8 +20,10 @@ export 'grind/bazel.dart'; export 'grind/benchmark.dart'; export 'grind/double_check.dart'; export 'grind/frameworks.dart'; -export 'grind/subpackages.dart'; +export 'grind/generate_deprecations.dart'; +export 'grind/sass_api.dart'; export 'grind/synchronize.dart'; +export 'grind/utils.dart'; void main(List args) { pkg.humanName.value = "Dart Sass"; @@ -29,11 +33,16 @@ void main(List args) { pkg.chocolateyNuspec.value = _nuspec; pkg.homebrewRepo.value = "sass/homebrew-sass"; pkg.homebrewFormula.value = "Formula/sass.rb"; + pkg.homebrewEditFormula.value = _updateHomebrewLanguageRevision; pkg.jsRequires.value = [ + pkg.JSRequire("@parcel/watcher", + target: pkg.JSRequireTarget.cli, lazy: true, optional: true), pkg.JSRequire("immutable", target: pkg.JSRequireTarget.all), pkg.JSRequire("chokidar", target: pkg.JSRequireTarget.cli), pkg.JSRequire("readline", target: pkg.JSRequireTarget.cli), pkg.JSRequire("fs", target: pkg.JSRequireTarget.node), + pkg.JSRequire("module", + target: pkg.JSRequireTarget.node, identifier: 'nodeModule'), pkg.JSRequire("stream", target: pkg.JSRequireTarget.node), pkg.JSRequire("util", target: pkg.JSRequireTarget.node), ]; @@ -51,6 +60,10 @@ void main(List args) { 'compileAsync', 'compileString', 'compileStringAsync', + 'initCompiler', + 'initAsyncCompiler', + 'Compiler', + 'AsyncCompiler', 'Logger', 'SassArgumentList', 'SassBoolean', @@ -61,6 +74,7 @@ void main(List args) { 'SassFunction', 'SassList', 'SassMap', + 'SassMixin', 'SassNumber', 'SassString', 'Value', @@ -78,6 +92,10 @@ void main(List args) { 'FALSE', 'NULL', 'types', + 'NodePackageImporter', + 'deprecations', + 'Version', + 'parser_', }; pkg.githubReleaseNotes.fn = () => @@ -109,6 +127,8 @@ void main(List args) { pkg.addAllTasks(); + addBumpVersionTasks(); + afterTask("pkg-npm-dev", _addDefaultExport); afterTask("pkg-npm-release", _addDefaultExport); @@ -116,7 +136,7 @@ void main(List args) { } @DefaultTask('Compile async code and reformat.') -@Depends(format, synchronize) +@Depends(format, synchronize, deprecations, protobuf) void all() {} @Task('Run the Dart formatter.') @@ -129,7 +149,7 @@ void npmInstall() => run(Platform.isWindows ? "npm.cmd" : "npm", arguments: ["install"]); @Task('Runs the tasks that are required for running tests.') -@Depends(format, synchronize, protobuf, "pkg-npm-dev", npmInstall, +@Depends(format, synchronize, protobuf, deprecations, "pkg-npm-dev", npmInstall, "pkg-standalone-dev") void beforeTest() {} @@ -200,12 +220,11 @@ String _readAndResolveMarkdown(String path) => File(path) return included.substring(headerMatch.end, sectionEnd).trim(); }); -/// Returns a map from JS type declaration file names to their contnets. +/// Returns a map from JS type declaration file names to their contents. Map _fetchJSTypes() { - var languageRepo = - cloneOrCheckout("https://github.com/sass/sass", "main", name: 'language'); + updateLanguageRepo(); - var typeRoot = p.join(languageRepo, 'js-api-doc'); + var typeRoot = p.join('build/language', 'js-api-doc'); return { for (var entry in Directory(typeRoot).listSync(recursive: true)) if (entry is File && entry.path.endsWith('.d.ts')) @@ -221,6 +240,7 @@ void _matchError(Match match, String message, {Object? url}) { } @Task('Compile the protocol buffer definition to a Dart library.') +@Depends(updateLanguageRepo) Future protobuf() async { Directory('build').createSync(recursive: true); @@ -240,11 +260,6 @@ dart run protoc_plugin "\$@" run('chmod', arguments: ['a+x', 'build/protoc-gen-dart']); } - if (Platform.environment['UPDATE_SASS_PROTOCOL'] != 'false') { - cloneOrCheckout("https://github.com/sass/sass.git", "main", - name: 'language'); - } - await runAsync("buf", arguments: ["generate"], runOptions: RunOptions(environment: { @@ -287,3 +302,30 @@ function defaultExportDeprecation() { File("build/npm/sass.node.mjs").writeAsStringSync(buffer.toString()); } + +/// A regular expression to locate the language repo revision in the Dart Sass +/// Homebrew formula. +final _homebrewLanguageRegExp = RegExp( + r'resource "language" do$' + r'(?:(?! end$).)+' + r'revision: "([a-f0-9]{40})"', + dotAll: true, + multiLine: true); + +/// Updates the Homebrew [formula] to change the revision of the language repo +/// to the latest revision. +String _updateHomebrewLanguageRevision(String formula) { + var languageRepoRevision = run("git", + arguments: ["ls-remote", "https://github.com/sass/sass"], quiet: true) + .split("\t") + .first; + + var match = _homebrewLanguageRegExp.firstMatch(formula); + if (match == null) { + fail("Couldn't find a language repo revision in the Homebrew formula."); + } + + return formula.substring(0, match.start) + + match.group(0)!.replaceFirst(match.group(1)!, languageRepoRevision) + + formula.substring(match.end); +} diff --git a/tool/grind/bump_version.dart b/tool/grind/bump_version.dart new file mode 100644 index 000000000..9aac0b9c6 --- /dev/null +++ b/tool/grind/bump_version.dart @@ -0,0 +1,113 @@ +// 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 'dart:io'; +import 'dart:convert'; + +import 'package:grinder/grinder.dart'; +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +/// A regular expression that matches a version in a pubspec. +final _pubspecVersionRegExp = RegExp(r'^version: (.*)$', multiLine: true); + +/// A regular expression that matches a Sass dependency version in a pubspec. +final _sassVersionRegExp = RegExp(r'^( +)sass: (\d.*)$', multiLine: true); + +/// Adds grinder tasks for bumping package versions. +void addBumpVersionTasks() { + for (var patch in [false, true]) { + for (var dev in [true, false]) { + addTask(GrinderTask( + 'bump-version-${patch ? 'patch' : 'minor'}' + (dev ? '-dev' : ''), + taskFunction: () => _bumpVersion(patch, dev), + description: 'Bump the version of all packages to the next ' + '${patch ? 'patch' : 'minor'}${dev ? ' dev' : ''} version')); + } + } +} + +/// Bumps the current package versions to the next [patch] version, with `-dev` +/// if [dev] is true. +void _bumpVersion(bool patch, bool dev) { + // Returns the version to which to bump [version]. + Version chooseNextVersion(Version version, SourceSpan span) { + if (dev) { + if (version.preRelease.isNotEmpty && (patch || version.patch == 0)) { + fail(span.message("Version is already pre-release", color: true)); + } + } else if (version.preRelease.length == 1 && + version.preRelease.first == "dev" && + (patch || version.patch == 0)) { + // If it's already a dev version, just mark it stable instead of + // increasing it. + return Version(version.major, version.minor, version.patch); + } + + var nextVersion = + patch || version.major == 0 ? version.nextPatch : version.nextMinor; + return Version(nextVersion.major, nextVersion.minor, nextVersion.patch, + pre: dev ? "dev" : null); + } + + /// Adds a "No user-visible changes" entry for [version] to the changelog in + /// [dir]. + void addChangelogEntry(String dir, Version version) { + var path = p.join(dir, "CHANGELOG.md"); + var text = File(path).readAsStringSync(); + if (!dev && text.startsWith("## $version-dev\n")) { + File(path).writeAsStringSync( + text.replaceFirst("## $version-dev\n", "## $version\n")); + } else if (text.startsWith("## $version\n")) { + return; + } else { + File(path).writeAsStringSync( + "## $version\n\n* No user-visible changes.\n\n$text"); + } + } + + // Bumps the current version of [pubspec] to the next [patch] version, with + // `-dev` if [dev] is true. + // + // If [sassVersion] is passed, this bumps the `sass` dependency to that version. + // + // Returns the new version of this package. + Version bumpDartVersion(String path, [Version? sassVersion]) { + var text = File(path).readAsStringSync(); + var pubspec = loadYaml(text, sourceUrl: p.toUri(path)) as YamlMap; + var version = chooseNextVersion(Version.parse(pubspec["version"] as String), + pubspec.nodes["version"]!.span); + + text = text.replaceFirst(_pubspecVersionRegExp, 'version: $version'); + if (sassVersion != null) { + // Don't depend on a prerelease version, depend on its released + // equivalent. + var sassDependencyVersion = + Version(sassVersion.major, sassVersion.minor, sassVersion.patch); + text = text.replaceFirstMapped(_sassVersionRegExp, + (match) => '${match[1]}sass: $sassDependencyVersion'); + } + + File(path).writeAsStringSync(text); + addChangelogEntry(p.dirname(path), version); + return version; + } + + var sassVersion = bumpDartVersion('pubspec.yaml'); + bumpDartVersion('pkg/sass_api/pubspec.yaml', sassVersion); + + var packageJsonPath = 'pkg/sass-parser/package.json'; + var packageJsonText = File(packageJsonPath).readAsStringSync(); + var packageJson = + loadYaml(packageJsonText, sourceUrl: p.toUri(packageJsonPath)) as YamlMap; + var version = chooseNextVersion( + Version.parse(packageJson["version"] as String), + packageJson.nodes["version"]!.span); + File(packageJsonPath).writeAsStringSync(JsonEncoder.withIndent(" ") + .convert({...packageJson, "version": version.toString()}) + + "\n"); + addChangelogEntry("pkg/sass-parser", version); +} diff --git a/tool/grind/double_check.dart b/tool/grind/double_check.dart index 4ec0cd8e7..2578b5bf5 100644 --- a/tool/grind/double_check.dart +++ b/tool/grind/double_check.dart @@ -5,13 +5,11 @@ import 'dart:io'; import 'package:cli_pkg/cli_pkg.dart' as pkg; +import 'package:collection/collection.dart'; import 'package:grinder/grinder.dart'; -import 'package:path/path.dart' as p; import 'package:pub_api_client/pub_api_client.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; -import 'package:sass/src/utils.dart'; - import 'utils.dart'; @Task('Verify that the package is in a good state to release.') @@ -21,7 +19,7 @@ Future doubleCheckBeforeRelease() async { fail("GITHUB_REF $ref is different than pubspec version ${pkg.version}."); } - if (listEquals(pkg.version.preRelease, ["dev"])) { + if (const ListEquality().equals(pkg.version.preRelease, ["dev"])) { fail("${pkg.version} is a dev release."); } @@ -37,8 +35,10 @@ Future doubleCheckBeforeRelease() async { ".", ...Directory("pkg").listSync().map((entry) => entry.path) ]) { - var pubspec = Pubspec.parse(File("$dir/pubspec.yaml").readAsStringSync(), - sourceUrl: p.toUri("$dir/pubspec.yaml")); + var pubspecFile = File("$dir/pubspec.yaml"); + if (!pubspecFile.existsSync()) continue; + var pubspec = Pubspec.parse(pubspecFile.readAsStringSync(), + sourceUrl: pubspecFile.uri); var package = await client.packageInfo(pubspec.name); if (pubspec.version == package.latestPubspec.version) { diff --git a/tool/grind/generate_deprecations.dart b/tool/grind/generate_deprecations.dart new file mode 100644 index 000000000..21ce6f58e --- /dev/null +++ b/tool/grind/generate_deprecations.dart @@ -0,0 +1,82 @@ +// Copyright 2024 Google LLC. 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:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:grinder/grinder.dart'; +import 'package:yaml/yaml.dart'; + +import 'utils.dart'; + +const yamlPath = 'build/language/spec/deprecations.yaml'; +const dartPath = 'lib/src/deprecation.dart'; + +final _blockRegex = + RegExp(r'// START AUTOGENERATED CODE[\s\S]*?// END AUTOGENERATED CODE'); + +@Task('Generate deprecation.g.dart from the list in the language repo.') +@Depends(updateLanguageRepo) +void deprecations() { + var yamlFile = File(yamlPath); + var dartFile = File(dartPath); + var yamlText = yamlFile.readAsStringSync(); + var data = loadYaml(yamlText, sourceUrl: yamlFile.uri) as Map; + var dartText = dartFile.readAsStringSync(); + var buffer = StringBuffer('''// START AUTOGENERATED CODE + // + // DO NOT EDIT. This section was generated from the language repo. + // See tool/grind/generate_deprecations.dart for details. + // + // Checksum: ${sha1.convert(utf8.encode(yamlText))} + +'''); + for (var MapEntry(:String key, :value) in data.entries) { + var camelCase = key.replaceAllMapped( + RegExp(r'-(.)'), (match) => match.group(1)!.toUpperCase()); + var (description, deprecatedIn, obsoleteIn) = switch (value) { + { + 'description': String description, + 'dart-sass': {'status': 'future'}, + } => + (description, null, null), + { + 'description': String description, + 'dart-sass': {'status': 'active', 'deprecated': String deprecatedIn}, + } => + (description, deprecatedIn, null), + { + 'description': String description, + 'dart-sass': { + 'status': 'obsolete', + 'deprecated': String deprecatedIn, + 'obsolete': String obsoleteIn + }, + } => + (description, deprecatedIn, obsoleteIn), + _ => throw Exception('Invalid deprecation $key: $value') + }; + description = + description.replaceAll(r'$PLATFORM', r"${isJS ? 'JS': 'Dart'}"); + var constructorName = deprecatedIn == null ? '.future' : ''; + var deprecatedClause = + deprecatedIn == null ? '' : "deprecatedIn: '$deprecatedIn', "; + var obsoleteClause = + obsoleteIn == null ? '' : "obsoleteIn: '$obsoleteIn', "; + var comment = 'Deprecation for ${description.substring(0, 1).toLowerCase()}' + '${description.substring(1)}'; + buffer.writeln('/// $comment'); + buffer.writeln( + "$camelCase$constructorName('$key', $deprecatedClause$obsoleteClause" + "description: '$description'),"); + } + buffer.write('\n // END AUTOGENERATED CODE'); + if (!dartText.contains(_blockRegex)) { + fail("Couldn't find block for generated code in lib/src/deprecation.dart"); + } + var newCode = dartText.replaceFirst(_blockRegex, buffer.toString()); + dartFile.writeAsStringSync(DartFormatter().format(newCode)); +} 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}."); - } - } -} diff --git a/tool/grind/synchronize.dart b/tool/grind/synchronize.dart index 87ab7af5d..de18066d2 100644 --- a/tool/grind/synchronize.dart +++ b/tool/grind/synchronize.dart @@ -247,6 +247,9 @@ class _Visitor extends RecursiveAstVisitor { } else if (node.name2.lexeme == "Module") { _skipNode(node); _buffer.write("Module"); + } else if (node.typeArguments == null) { + _skip(node.name2); + _buffer.write(_synchronizeName(node.name2.lexeme)); } else { super.visitNamedType(node); } diff --git a/tool/grind/utils.dart b/tool/grind/utils.dart index 21d9f66c8..86ea7ed96 100644 --- a/tool/grind/utils.dart +++ b/tool/grind/utils.dart @@ -100,3 +100,20 @@ void afterTask(String taskName, FutureOr callback()) { await callback(); }); } + +/// Clones the main branch of `github.com/sass/sass`. +/// +/// If the `UPDATE_SASS_SASS_REPO` environment variable is `false`, this instead +/// assumes the repo that already exists at `build/language/sass`. +/// `UPDATE_SASS_PROTOCOL` is also checked as a deprecated alias for +/// `UPDATE_SASS_SASS_REPO`. +@Task('Clones the main branch of `github.com/sass/sass` if necessary.') +void updateLanguageRepo() { + // UPDATE_SASS_PROTOCOL is considered deprecated, because it doesn't apply as + // generically to other tasks. + if (Platform.environment['UPDATE_SASS_SASS_REPO'] != 'false' && + Platform.environment['UPDATE_SASS_PROTOCOL'] != 'false') { + cloneOrCheckout("https://github.com/sass/sass.git", "main", + name: 'language'); + } +}