diff --git a/.github/util/initialize/action.yml b/.github/util/initialize/action.yml new file mode 100644 index 000000000..ad2abda5e --- /dev/null +++ b/.github/util/initialize/action.yml @@ -0,0 +1,36 @@ +name: Initialize +description: Check out Dart Sass and build the embedded protocol buffer. +inputs: + github-token: {required: true} + node-version: {required: false, default: 18} + dart-sdk: {required: false, default: stable} + architecture: {required: false} +runs: + using: composite + steps: + - uses: dart-lang/setup-dart@v1 + with: + sdk: "${{ inputs.dart-sdk }}" + architecture: "${{ inputs.architecture }}" + + - uses: actions/setup-node@v3 + with: + node-version: "${{ inputs.node-version }}" + + - run: dart pub get + shell: bash + + - run: npm install + shell: bash + + - uses: bufbuild/buf-setup-action@v1.13.1 + with: {github_token: "${{ inputs.github-token }}"} + + - name: Check out the language repo + uses: sass/clone-linked-repo@v1 + with: {repo: sass/sass, path: build/language} + + - name: Generate Dart from protobuf + run: dart run grinder protobuf + env: {UPDATE_SASS_PROTOCOL: false} + shell: bash diff --git a/.github/util/sass-spec/action.yml b/.github/util/sass-spec/action.yml new file mode 100644 index 000000000..d44b25db8 --- /dev/null +++ b/.github/util/sass-spec/action.yml @@ -0,0 +1,12 @@ +name: sass-spec +description: Check out sass-spec and install its dependencies. +runs: + using: composite + steps: + - name: Check out sass-spec + uses: sass/clone-linked-repo@v1 + with: {repo: sass/sass-spec} + + - run: npm install + working-directory: sass-spec + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec0a3702b..2f8fdc4f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,11 @@ name: CI defaults: run: {shell: bash} -env: - # Note: when changing this, also change - # jobs.node_tests.strategy.matrix.node_version and the Node version for Dart - # dev tests. - DEFAULT_NODE_VERSION: 16 +# 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: @@ -16,6 +16,46 @@ 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 @@ -33,16 +73,12 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - with: {sdk: "${{ matrix.dart_channel }}"} - - run: dart pub get - - name: Check out sass-spec - uses: sass/clone-linked-repo@v1 - with: {repo: sass/sass-spec} - - uses: actions/setup-node@v3 - with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} - - run: npm install - working-directory: sass-spec + - 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 @@ -52,7 +88,7 @@ jobs: # They next need to be rotated April 2021. See # https://github.com/nodejs/Release. sass_spec_js: - name: "JS API Tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node_version }} | ${{ matrix.os }}" + name: "JS API Tests | Pure JS | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" runs-on: "${{ matrix.os }}" strategy: @@ -60,39 +96,122 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] dart_channel: [stable] - node_version: [16] + node-version: [18] include: # Include LTS versions on Ubuntu - os: ubuntu-latest dart_channel: stable - node_version: 14 + node-version: 16 - os: ubuntu-latest dart_channel: stable - node_version: 12 + node-version: 14 - os: ubuntu-latest dart_channel: dev - node_version: 16 + node-version: 18 steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - with: {sdk: "${{ matrix.dart_channel }}"} - - run: dart pub get - - uses: actions/setup-node@v3 - with: {node-version: "${{ matrix.node_version }}"} - - run: npm install + - 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: Check out 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-spec} + with: + repo: sass/sass + path: language - - name: Install sass-spec dependencies - run: npm install + - 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 + + 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: @@ -100,8 +219,10 @@ jobs: path: language - name: Run tests - run: npm run js-api-spec -- --sassSassRepo ../language --sassPackage ../build/npm + 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 }}" @@ -116,12 +237,14 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - with: {sdk: "${{ matrix.dart_channel }}"} - - run: dart pub get + - 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 -p vm -x node -r expanded + run: dart run test -x node # Unit tests that use Node.js, defined in test/. # @@ -129,7 +252,7 @@ jobs: # 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 }}" + name: "Node tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}" runs-on: "${{ matrix.os }}" strategy: @@ -138,58 +261,52 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] dart_channel: [stable] - node_version: [16] + node-version: [18] include: # Include LTS versions on Ubuntu - os: ubuntu-latest dart_channel: stable - node_version: 14 + node-version: 16 - os: ubuntu-latest dart_channel: stable - node_version: 12 + node-version: 14 - os: ubuntu-latest dart_channel: dev - node_version: 16 - + node-version: 18 steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - with: {sdk: "${{ matrix.dart_channel }}"} - - run: dart pub get - - uses: actions/setup-node@v3 - with: {node-version: "${{ matrix.node_version }}"} - - run: npm install - - run: dart run grinder before-test + - 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 -j 2 -t node -r expanded + run: dart run test -t node -j 2 - static_analysis: - name: Static analysis - runs-on: ubuntu-latest + browser_tests: + name: "Browser Tests | Dart ${{ matrix.dart_channel }}" - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - name: Analyze Dart - run: dart analyze --fatal-warnings --fatal-infos . + strategy: + matrix: + dart_channel: [stable] + fail-fast: false - dartdoc: - name: Dartdoc runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - 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 + - 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 double_check: name: Double-check @@ -197,16 +314,21 @@ jobs: 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'" steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Run checks run: dart run grinder double-check-before-release @@ -222,8 +344,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - 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 @@ -236,8 +359,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - run: dart run grinder fetch-bourbon env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - name: Test @@ -252,8 +376,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - 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 @@ -269,8 +394,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - run: dart run grinder fetch-bulma env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - name: Build @@ -284,8 +410,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - 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: @@ -307,6 +434,9 @@ jobs: steps: - uses: actions/checkout@v3 + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - uses: docker/setup-qemu-action@v2 - name: Deploy run: | @@ -342,11 +472,12 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 + - uses: ./.github/util/initialize # Workaround for dart-lang/setup-dart#59 with: + github-token: ${{ github.token }} architecture: ${{ matrix.architecture }} - - run: dart pub get + - name: Deploy run: dart run grinder pkg-github-${{ matrix.platform }} env: @@ -361,10 +492,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - uses: actions/setup-node@v3 - with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Deploy run: dart run grinder pkg-npm-deploy env: @@ -378,10 +508,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - uses: actions/setup-node@v3 - with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Deploy run: dart run grinder update-bazel env: @@ -396,12 +525,11 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get - - uses: actions/setup-node@v3 - with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Deploy - run: dart run grinder pkg-pub-deploy + run: dart run grinder protobuf pkg-pub-deploy env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} deploy_sub_packages: @@ -412,8 +540,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Deploy run: dart run grinder deploy-sub-packages env: @@ -431,6 +560,7 @@ jobs: - uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1 - run: dart pub get + - name: Deploy run: dart run grinder pkg-homebrew-update env: @@ -445,8 +575,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1 - - run: dart pub get + - uses: ./.github/util/initialize + with: {github-token: "${{ github.token }}"} + - name: Deploy run: dart run grinder pkg-chocolatey-deploy env: {CHOCOLATEY_TOKEN: "${{ secrets.CHOCOLATEY_TOKEN }}"} @@ -456,66 +587,57 @@ jobs: 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.GH_TOKEN }} + token: ${{ secrets.SASS_SITE_TOKEN }} - - uses: EndBug/add-and-commit@v8 + - 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_compiler: - name: "Release Embedded Compiler" + release_embedded_host: + name: "Release Embedded Host" runs-on: ubuntu-latest - needs: [deploy_pub, deploy_sub_packages] + 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/dart-sass-embedded + repository: sass/embedded-host-node token: ${{ secrets.GH_TOKEN }} - - uses: dart-lang/setup-dart@v1 - - uses: frenck/action-setup-yq@v1 - with: {version: v4.30.5} # frenck/action-setup-yq#35 - name: Get version id: version - run: echo "::set-output name=version::${GITHUB_REF##*/}" + run: echo "version=${GITHUB_REF##*/}" | tee --append "$GITHUB_OUTPUT" - name: Update version run: | - sed -i 's/version: .*/version: ${{ steps.version.outputs.version }}/' pubspec.yaml - dart pub remove sass - dart pub add sass:${{ steps.version.outputs.version }} - - # Delete a dependency override on Sass if it exists, and delete the - # dependency_overrides field if it's now empty. The embedded compiler - # often uses dev dependencies to run against the latest Dart Sass, but - # once we release the latest version that's no longer necessary. - # - # TODO(dart-lang/pub#3700): Use pub for this instead to avoid removing - # blank lines. See also mikefarah/yq#515. - yq -i ' - del(.dependency_overrides.sass) | - to_entries | - map(select(.key != "dependency_overrides" or (.value | length >0))) | - from_entries - ' pubspec.yaml - - # The embedded compiler has a checked-in pubspec.yaml, so upgrade to - # make sure we're releasing against the latest version of all deps. - dart pub upgrade - + # 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@v8 + - uses: EndBug/add-and-commit@v9 with: author_name: Sass Bot author_email: sass.bot.beep.boop@gmail.com diff --git a/.gitignore b/.gitignore index 89b6acfd6..2c61888e5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ package-lock.json node_modules/ /doc/api /pkg/*/doc/api + +# Generated protocol buffer files. +*.pb*.dart diff --git a/.pubignore b/.pubignore new file mode 100644 index 000000000..2fbab300a --- /dev/null +++ b/.pubignore @@ -0,0 +1,19 @@ +# This should be identical to .gitignore except that it doesn't exclude +# generated protobuf files. + +.buildlog +.DS_Store +.idea +.pub/ +.dart_tool/ +.settings/ +.sass-cache/ +build/ +packages +.packages +pubspec.lock +package-lock.json +/benchmark/source +node_modules/ +/doc/api +/pkg/*/doc/api diff --git a/CHANGELOG.md b/CHANGELOG.md index f0721fa70..55986797b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ -## 1.58.0 +## 1.67.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)`. * 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. @@ -147,7 +152,306 @@ * Added `InterpolationMethod` and `HueInterpolationMethod` which collectively represent the method to use to interpolate two colors. -## 1.57.2 +## 1.66.1 + +### JS API + +* Fix a bug where Sass compilation could crash in strict mode if passed a + callback that threw a string, boolean, number, symbol, or bignum. + +## 1.66.0 + +* **Breaking change:** Drop support for the additional CSS calculations defined + in CSS Values and Units 4. Custom Sass functions whose names overlapped with + these new CSS functions were being parsed as CSS calculations instead, causing + an unintentional breaking change outside our normal [compatibility policy] for + CSS compatibility changes. + + Support will be added again in a future version, but only after Sass has + emitted a deprecation warning for all functions that will break for at least + three months prior to the breakage. + +## 1.65.1 + +* Update abs-percent deprecatedIn version to `1.65.0`. + +## 1.65.0 + +* All functions defined in CSS Values and Units 4 are now parsed as calculation + objects: `round()`, `mod()`, `rem()`, `sin()`, `cos()`, `tan()`, `asin()`, + `acos()`, `atan()`, `atan2()`, `pow()`, `sqrt()`, `hypot()`, `log()`, `exp()`, + `abs()`, and `sign()`. + +* Deprecate explicitly passing the `%` unit to the global `abs()` function. In + future releases, this will emit a CSS abs() function to be resolved by the + browser. This deprecation is named `abs-percent`. + +## 1.64.3 + +### Dart API + +* Deprecate explicitly passing `null` as the alpha channel for + `SassColor.rgb()`, `SassColor.hsl()`, and `SassColor.hwb()`. Omitting the + `alpha` channel is still allowed. In future releases, `null` will be used to + indicate a [missing component]. This deprecation is named `null-alpha`. + + [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + +* Include protocol buffer definitions when uploading the `sass` package to pub. + +### JS API + +* Deprecate explicitly passing `null` as the alpha channel for `new + SassColor()`. Omitting the `alpha` channel or passing `undefined` for it is + still allowed. In future releases, `null` will be used to indicate a [missing + component]. This deprecation is named `null-alpha`. + + [missing component]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#missing_color_components + + (Note that this was already prohibited by the TypeScript types, but in + practice prior to this `null` was treated as `1`.) + +## 1.64.2 + +* No user-visible changes. + +## 1.64.1 + +### Embedded Sass + +* Fix a bug where a valid `SassCalculation.clamp()` with less than 3 arguments + would throw an error. + +## 1.64.0 + +* Comments that appear before or between `@use` and `@forward` rules are now + emitted in source order as much as possible, instead of always being emitted + after the CSS of all module dependencies. + +* Fix a bug where an interpolation in a custom property name crashed if the file + was loaded by a `@use` nested in an `@import`. + +### JavaScript API + +* Add a new `SassCalculation` type that represents the calculation objects added + in Dart Sass 1.40.0. + +* Add `Value.assertCalculation()`, which returns the value if it's a + `SassCalculation` and throws an error otherwise. + +* Produce a better error message when an environment that supports some Node.js + APIs loads the browser entrypoint but attempts to access the filesystem. + +### Embedded Sass + +* Fix a bug where nested relative `@imports` failed to load when using the + deprecated functions `render` or `renderSync` and those relative imports were + loaded multiple times across different files. + +## 1.63.6 + +### JavaScript API + +* Fix `import sass from 'sass'` again after it was broken in the last release. + +### Embedded Sass + +* Fix the `exports` declaration in `package.json`. + +## 1.63.5 + +### JavaScript API + +* Fix a bug where loading the package through both CJS `require()` and ESM + `import` could crash on Node.js. + +### Embedded Sass + +* Fix a deadlock when running at high concurrency on 32-bit systems. + +* Fix a race condition where the embedded compiler could deadlock or crash if a + compilation ID was reused immediately after the compilation completed. + +## 1.63.4 + +### JavaScript API + +* Re-enable support for `import sass from 'sass'` when loading the package from + an ESM module in Node.js. However, this syntax is now deprecated; ESM users + should use `import * as sass from 'sass'` instead. + + On the browser and other ESM-only platforms, only `import * as sass from + 'sass'` is supported. + +* Properly export the legacy API values `TRUE`, `FALSE`, `NULL`, and `types` from + the ECMAScript module API. + +### Embedded Sass + +* Fix a race condition where closing standard input while requests are in-flight + could sometimes cause the process to hang rather than shutting down + gracefully. + +* Properly include the root stylesheet's URL in the set of loaded URLs when it + fails to parse. + +## 1.63.3 + +### JavaScript API + +* Fix loading Sass as an ECMAScript module on Node.js. + +## 1.63.2 + +* No user-visible changes. + +## 1.63.1 + +* No user-visible changes. + +## 1.63.0 + +### JavaScript API + +* Dart Sass's JS API now supports running in the browser. Further details and + instructions for use are in [the README](README.md#dart-sass-in-the-browser). + +### Embedded Sass + +* The Dart Sass embedded compiler is now included as part of the primary Dart + Sass distribution, rather than a separate executable. To use the embedded + compiler, just run `sass --embedded` from any Sass executable (other than the + pure JS executable). + + The Node.js embedded host will still be distributed as the `sass-embedded` + package on npm. The only change is that it will now provide direct access to a + `sass` executable with the same CLI as the `sass` package. + +* The Dart Sass embedded compiler now uses version 2.0.0 of the Sass embedded + protocol. See [the spec][embedded-protocol-spec] for a full description of the + protocol, and [the changelog][embedded-protocol-changelog] for a summary of + changes since version 1.2.0. + + [embedded-protocol-spec]: https://github.com/sass/sass/blob/main/spec/embedded-protocol.md + [embedded-protocol-changelog]: https://github.com/sass/sass/blob/main/EMBEDDED_PROTOCOL_CHANGELOG.md + +* The Dart Sass embedded compiler now runs multiple simultaneous compilations in + parallel, rather than serially. + +## 1.62.1 + +* Fix a bug where `:has(+ &)` and related constructs would drop the leading + combinator. + +## 1.62.0 + +* Deprecate the use of multiple `!global` or `!default` flags on the same + variable. This deprecation is named `duplicate-var-flags`. + +* Allow special numbers like `var()` or `calc()` in the global functions: + `grayscale()`, `invert()`, `saturate()`, and `opacity()`. These are also + native CSS `filter` functions. This is in addition to number values which were + already allowed. + +* Fix a cosmetic bug where an outer rule could be duplicated after nesting was + resolved, instead of re-using a shared rule. + +## 1.61.0 + +* **Potentially breaking change:** Drop support for End-of-Life Node.js 12. + +* Fix remaining cases for the performance regression introduced in 1.59.0. + +### Embedded Sass + +* The JS embedded host now loads files from the working directory when using the + legacy API. + +## 1.60.0 + +* Add support for the `pi`, `e`, `infinity`, `-infinity`, and `NaN` constants in + calculations. These will be interpreted as the corresponding numbers. + +* Add support for unknown constants in calculations. These will be interpreted + as unquoted strings. + +* Serialize numbers with value `infinity`, `-infinity`, and `NaN` to `calc()` + expressions rather than CSS-invalid identifiers. Numbers with complex units + still can't be serialized. + +## 1.59.3 + +* Fix a performance regression introduced in 1.59.0. + +* The NPM release of 1.59.0 dropped support for Node 12 without actually + indicating so in its pubspec. This release temporarily adds back support so + that the latest Sass version that declares it supports Node 12 actually does + so. However, Node 12 is now end-of-life, so we will drop support for it + properly in an upcoming release. + +## 1.59.2 + +* No user-visible changes. + +## 1.59.1 + +* No user-visible changes. + +## 1.59.0 + +### Command Line Interface + +* Added a new `--fatal-deprecation` flag that lets you treat a deprecation + warning as an error. You can pass an individual deprecation ID + (e.g. `slash-div`) or you can pass a Dart Sass version to treat all + deprecations initially emitted in that version or earlier as errors. + +* New `--future-deprecation` flag that lets you opt into warning for use of + certain features that will be deprecated in the future. At the moment, the + only option is `--future-deprecation=import`, which will emit warnings for + Sass `@import` rules, which are not yet deprecated, but will be in the future. + +### Dart API + +* New `Deprecation` enum, which contains the different current and future + deprecations used by the new CLI flags. + +* The `compile` methods now take in `fatalDeprecations` and `futureDeprecations` + parameters, which work similarly to the CLI flags. + +## 1.58.4 + +* Pull `@font-face` to the root rather than bubbling the style rule selector + inwards. + +* Improve error messages for invalid CSS values passed to plain CSS functions. + +* Improve error messages involving selectors. + +### Embedded Sass + +* Improve the performance of starting up a compilation. + +## 1.58.3 + +* No user-visible changes. + +## 1.58.2 + +### Command Line Interface + +* Add a timestamp to messages printed in `--watch` mode. + +* Print better `calc()`-based suggestions for `/`-as-division expression that + contain calculation-incompatible constructs like unary minus. + +## 1.58.1 + +* Emit a unitless hue when serializing `hsl()` colors. The `deg` unit is + incompatible with IE, and while that officially falls outside our + compatibility policy, it's better to lean towards greater compatibility. + +## 1.58.0 * Remove sourcemap comments from Sass sources. The generated sourcemap comment for the compiled CSS output remains unaffected. @@ -160,6 +464,19 @@ * Produce a better error message for a number with a leading `+` or `-`, a decimal point, but no digits. +* Produce a better error message for a nested property whose name starts with + `--`. + +* Fix a crash when a selector ends in an escaped backslash. + +* Add the relative length units from CSS Values 4 and CSS Contain 3 as known + units to validate bad computation in `calc`. + +### Command Line Interface + +* The `--watch` flag will now track loads through calls to `meta.load-css()` as + long as their URLs are literal strings without any interpolation. + ## 1.57.1 * No user-visible changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24e71bc86..934a0576a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ one above, the dependencies. 3. [Install Node.js][]. This is only necessary if you're making changes to the - language or to Dart Sass's Node API. + language or to Dart Sass's Node API. [Install the Dart SDK]: https://www.dartlang.org/install [Install Node.js]: https://nodejs.org/en/download/ @@ -86,7 +86,7 @@ repository contains language tests that are shared among the main Sass implementations. Any new feature should be thoroughly tested there, and any bug should have a regression test added. -[sass-spec]: http://github.com/sass/sass-spec +[sass-spec]: https://github.com/sass/sass-spec To create a new spec: diff --git a/README.md b/README.md index c185dc71b..e22309bd4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun again**. +A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun**. @@ -28,10 +28,12 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun again**. * [Using Dart Sass](#using-dart-sass) * [From Chocolatey or Scoop (Windows)](#from-chocolatey-or-scoop-windows) - * [From Homebrew (macOS)](#from-homebrew-macos) + * [From Homebrew (macOS)](#from-homebrew-macos-or-linux) * [Standalone](#standalone) * [From npm](#from-npm) + * [Dart Sass in the Browser](#dart-sass-in-the-browser) * [Legacy JavaScript API](#legacy-javascript-api) + * [Using Sass with Jest](#using-sass-with-jest) * [From Pub](#from-pub) * [`sass_api` Package](#sass_api-package) * [From Source](#from-source) @@ -40,6 +42,8 @@ A [Dart][dart] implementation of [Sass][sass]. **Sass makes CSS fun again**. * [Compatibility Policy](#compatibility-policy) * [Browser Compatibility](#browser-compatibility) * [Node.js Compatibility](#nodejs-compatibility) +* [Embedded Dart Sass](#embedded-dart-sass) + * [Usage](#usage) * [Behavioral Differences from Ruby Sass](#behavioral-differences-from-ruby-sass) ## Using Dart Sass @@ -68,9 +72,9 @@ Sass. See [the CLI docs][cli] for details. [cli]: https://sass-lang.com/documentation/cli/dart-sass -### From Homebrew (macOS) +### From Homebrew (macOS or Linux) -If you use [the Homebrew package manager](https://brew.sh/) for macOS, you +If you use [the Homebrew package manager](https://brew.sh/), you can install Dart Sass by running ```sh @@ -115,6 +119,92 @@ See [the Sass website][js api] for full API documentation. [js api]: https://sass-lang.com/documentation/js-api +#### Dart Sass in the Browser + +The `sass` npm package can also be run directly in the browser. It's compatible +with all major web bundlers as long as you disable renaming (such as +[`--keep-names`] in esbuild). You can also import it directly from a browser as +an ECMAScript Module without any bundling (assuming `node_modules` is served as +well): + +[`--keep-names`]: https://esbuild.github.io/api/#keep-names + +```html + + + + + + +``` + +Or from a CDN: + +```html + + + + + + +``` + +Or even bundled with all its dependencies: + +```html + +``` + +Since the browser doesn't have access to the filesystem, the [`compile()`] and +`compileAsync()` functions aren't available for it. If you want to load other +files, you'll need to pass a [custom importer] to [`compileString()`] or +[`compileStringAsync()`]. The [legacy API] is also not supported in the browser. + +[`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 +[`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 + #### Legacy JavaScript API Dart Sass also supports an older JavaScript API that's fully compatible with @@ -123,8 +213,8 @@ Dart Sass also supports an older JavaScript API that's fully compatible with and will be removed in Dart Sass 2.0.0, so it should be avoided in new projects. [Node Sass]: https://github.com/sass/node-sass -[`render()`]: https://sass-lang.com/documentation/js-api/modules#render -[`renderSync()`]: https://sass-lang.com/documentation/js-api/modules#renderSync +[`render()`]: https://sass-lang.com/documentation/js-api/functions/render +[`renderSync()`]: https://sass-lang.com/documentation/js-api/functions/renderSync Sass's support for the legacy JavaScript API has the following limitations: @@ -192,10 +282,19 @@ Assuming you've already checked out this repository: manually rather than using an installer, make sure the SDK's `bin` directory is on your `PATH`. -2. In this repository, run `pub get`. This will install Dart Sass's +2. [Install Buf]. This is used to build the protocol buffers for the [embedded + compiler]. + +3. In this repository, run `dart pub get`. This will install Dart Sass's dependencies. -3. Run `dart bin/sass.dart path/to/file.scss`. +4. Run `dart run grinder protobuf`. This will download and build the embedded + protocol definition. + +5. Run `dart bin/sass.dart path/to/file.scss`. + +[Install Buf]: https://docs.buf.build/installation +[embedded compiler]: #embedded-dart-sass That's it! @@ -207,13 +306,20 @@ commands: ```Dockerfile # Dart stage FROM dart:stable AS dart +FROM bufbuild/buf AS buf +# Add your scss files COPY --from=another_stage /app /app +# Include Protocol Buffer binary +COPY --from=buf /usr/local/bin/buf /usr/local/bin/ + WORKDIR /dart-sass RUN git clone https://github.com/sass/dart-sass.git . && \ dart pub get && \ - dart ./bin/sass.dart /app/sass/example.scss /app/public/css/example.css + dart run grinder protobuf +# This is where you run sass.dart on your scss file(s) +RUN dart ./bin/sass.dart /app/sass/example.scss /app/public/css/example.css ``` ## Why Dart? @@ -299,6 +405,26 @@ considers itself free to break support if necessary. [the Node.js release page]: https://nodejs.org/en/about/releases/ +## Embedded Dart Sass + +Dart Sass includes an implementation of the compiler side of the [Embedded Sass +protocol]. It's designed to be embedded in a host language, which then exposes +an API for users to invoke Sass and define custom functions and importers. + +[Embedded Sass protocol]: https://github.com/sass/sass/blob/main/spec/embedded-protocol.md + +### Usage + +* `sass --embedded` starts the embedded compiler and listens on stdin. +* `sass --embedded --version` prints `versionResponse` with `id = 0` in JSON and + exits. + +The `--embedded` command-line flag is not available when you install Dart Sass +as an [npm package]. No other command-line flags are supported with +`--embedded`. + +[npm package]: #from-npm + ## Behavioral Differences from Ruby Sass There are a few intentional behavioral differences between Dart Sass and Ruby diff --git a/analysis/lib/analysis_options.yaml b/analysis/lib/analysis_options.yaml index 4ab629352..2a2921abc 100644 --- a/analysis/lib/analysis_options.yaml +++ b/analysis/lib/analysis_options.yaml @@ -1,8 +1,7 @@ analyzer: exclude: [build/**] - strong-mode: - implicit-casts: false language: + strict-casts: true strict-inference: true strict-raw-types: true errors: diff --git a/analysis_options.yaml b/analysis_options.yaml index fdd023b43..36aac758f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,3 +4,5 @@ # out-of-date (because they cause "pub run" to modify the lockfile before it # runs the executable). include: analysis/lib/analysis_options.yaml +analyzer: + exclude: ['**/*.pb*.dart'] diff --git a/bin/sass.dart b/bin/sass.dart index a95ad65ba..986ff9bcb 100644 --- a/bin/sass.dart +++ b/bin/sass.dart @@ -15,8 +15,15 @@ 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/io.dart'; +import 'package:sass/src/io.dart' as io; +import 'package:sass/src/logger/deprecation_handling.dart'; import 'package:sass/src/stylesheet_graph.dart'; +import 'package:sass/src/util/map.dart'; import 'package:sass/src/utils.dart'; +import 'package:sass/src/embedded/executable.dart' + // Never load the embedded protocol when compiling to JS. + if (dart.library.js) 'package:sass/src/embedded/unavailable.dart' + as embedded; Future main(List args) async { var printedError = false; @@ -26,14 +33,23 @@ Future main(List args) async { // // If [trace] is passed, its terse representation is printed after the error. void printError(String error, StackTrace? stackTrace) { - if (printedError) stderr.writeln(); + var buffer = StringBuffer(); + if (printedError) buffer.writeln(); printedError = true; - stderr.writeln(error); + buffer.write(error); if (stackTrace != null) { - stderr.writeln(); - stderr.writeln(Trace.from(stackTrace).terse.toString().trimRight()); + buffer.writeln(); + buffer.writeln(); + buffer.write(Trace.from(stackTrace).terse.toString().trimRight()); } + + io.printError(buffer); + } + + if (args case ['--embedded', ...var rest]) { + embedded.main(rest); + return; } ExecutableOptions? options; @@ -52,38 +68,36 @@ Future main(List args) async { return; } - var graph = StylesheetGraph( - ImportCache(loadPaths: options.loadPaths, logger: options.logger)); + 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))); if (options.watch) { await watch(options, graph); return; } - for (var source in options.sourcesToDestinations.keys) { - var destination = options.sourcesToDestinations[source]; + for (var (source, destination) in options.sourcesToDestinations.pairs) { try { await compileStylesheet(options, graph, source, destination, ifModified: options.update); } on SassException catch (error, stackTrace) { - // This is an immediately-invoked function expression to work around - // dart-lang/sdk#33400. - () { - try { - if (destination != null && - // dart-lang/sdk#45348 - !options!.emitErrorCss) { - deleteFile(destination); - } - } on FileSystemException { - // If the file doesn't exist, that's fine. - } - }(); + if (destination != null && !options.emitErrorCss) { + _tryDelete(destination); + } printError(error.toString(color: options.color), options.trace ? getTrace(error) ?? stackTrace : null); // Exit code 65 indicates invalid data per - // http://www.freebsd.org/cgi/man.cgi?query=sysexits. + // https://www.freebsd.org/cgi/man.cgi?query=sysexits. // // We let exitCode 66 take precedence for deterministic behavior. if (exitCode != 66) exitCode = 65; @@ -109,9 +123,9 @@ Future main(List args) async { exitCode = 64; } catch (error, stackTrace) { var buffer = StringBuffer(); - if (options != null && options.color) buffer.write('\u001b[31m\u001b[1m'); + if (options?.color ?? false) buffer.write('\u001b[31m\u001b[1m'); buffer.write('Unexpected exception:'); - if (options != null && options.color) buffer.write('\u001b[0m'); + if (options?.color ?? false) buffer.write('\u001b[0m'); buffer.writeln(); buffer.writeln(error); @@ -140,3 +154,14 @@ Future _loadVersion() async { .split(" ") .last; } + +/// Delete [path] if it exists and do nothing otherwise. +/// +/// This is a separate function to work around dart-lang/sdk#53082. +void _tryDelete(String path) { + try { + deleteFile(path); + } on FileSystemException { + // If the file doesn't exist, that's fine. + } +} diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 000000000..2fd379361 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,4 @@ +version: v1 +plugins: +- plugin: dart + out: lib/src/embedded diff --git a/buf.work.yaml b/buf.work.yaml new file mode 100644 index 000000000..3ffcae58c --- /dev/null +++ b/buf.work.yaml @@ -0,0 +1,2 @@ +version: v1 +directories: [build/language/spec] diff --git a/lib/sass.dart b/lib/sass.dart index 157383dee..bc7487da1 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -13,6 +13,7 @@ import 'src/async_import_cache.dart'; import 'src/callable.dart'; import 'src/compile.dart' as c; import 'src/compile_result.dart'; +import 'src/deprecation.dart'; import 'src/exception.dart'; import 'src/import_cache.dart'; import 'src/importer.dart'; @@ -24,9 +25,10 @@ import 'src/visitor/serialize.dart'; export 'src/callable.dart' show Callable, AsyncCallable; export 'src/compile_result.dart'; +export 'src/deprecation.dart'; export 'src/exception.dart' show SassException; export 'src/importer.dart'; -export 'src/logger.dart'; +export 'src/logger.dart' show Logger; export 'src/syntax.dart'; export 'src/value.dart' hide @@ -110,7 +112,9 @@ CompileResult compileToResult(String path, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) => + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) => c.compile(path, logger: logger, importCache: ImportCache( @@ -123,7 +127,9 @@ CompileResult compileToResult(String path, quietDeps: quietDeps, verbose: verbose, sourceMap: sourceMap, - charset: charset); + charset: charset, + fatalDeprecations: fatalDeprecations, + futureDeprecations: futureDeprecations); /// Compiles [source] to CSS and returns a [CompileResult] containing the CSS /// and additional metadata about the compilation.. @@ -205,7 +211,9 @@ CompileResult compileStringToResult(String source, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) => + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) => c.compileString(source, syntax: syntax, logger: logger, @@ -221,7 +229,9 @@ CompileResult compileStringToResult(String source, quietDeps: quietDeps, verbose: verbose, sourceMap: sourceMap, - charset: charset); + charset: charset, + fatalDeprecations: fatalDeprecations, + futureDeprecations: futureDeprecations); /// Like [compileToResult], except it runs asynchronously. /// @@ -239,7 +249,9 @@ Future compileToResultAsync(String path, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) => + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) => c.compileAsync(path, logger: logger, importCache: AsyncImportCache( @@ -252,7 +264,9 @@ Future compileToResultAsync(String path, quietDeps: quietDeps, verbose: verbose, sourceMap: sourceMap, - charset: charset); + charset: charset, + fatalDeprecations: fatalDeprecations, + futureDeprecations: futureDeprecations); /// Like [compileStringToResult], except it runs asynchronously. /// @@ -275,7 +289,9 @@ Future compileStringToResultAsync(String source, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) => + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) => c.compileStringAsync(source, syntax: syntax, logger: logger, @@ -315,8 +331,7 @@ Future compileStringToResultAsync(String source, /// /// {@category Compile} @Deprecated("Use compileToResult() instead.") -String compile( - String path, +String compile(String path, {bool color = false, Logger? logger, Iterable? importers, @@ -327,7 +342,7 @@ String compile( bool quietDeps = false, bool verbose = false, @Deprecated("Use CompileResult.sourceMap from compileToResult() instead.") - void sourceMap(SingleMapping map)?, + void sourceMap(SingleMapping map)?, bool charset = true}) { var result = compileToResult(path, logger: logger, @@ -366,8 +381,7 @@ String compile( /// /// {@category Compile} @Deprecated("Use compileStringToResult() instead.") -String compileString( - String source, +String compileString(String source, {Syntax? syntax, bool color = false, Logger? logger, @@ -380,11 +394,11 @@ String compileString( Object? url, bool quietDeps = false, bool verbose = false, - @Deprecated("Use CompileResult.sourceMap from compileStringToResult() instead.") - void sourceMap(SingleMapping map)?, + @Deprecated( + "Use CompileResult.sourceMap from compileStringToResult() instead.") + void sourceMap(SingleMapping map)?, bool charset = true, - @Deprecated("Use syntax instead.") - bool indented = false}) { + @Deprecated("Use syntax instead.") bool indented = false}) { var result = compileStringToResult(source, syntax: syntax ?? (indented ? Syntax.sass : Syntax.scss), logger: logger, @@ -411,8 +425,7 @@ String compileString( /// /// {@category Compile} @Deprecated("Use compileToResultAsync() instead.") -Future compileAsync( - String path, +Future compileAsync(String path, {bool color = false, Logger? logger, Iterable? importers, @@ -422,8 +435,9 @@ Future compileAsync( OutputStyle? style, bool quietDeps = false, bool verbose = false, - @Deprecated("Use CompileResult.sourceMap from compileToResultAsync() instead.") - void sourceMap(SingleMapping map)?}) async { + @Deprecated( + "Use CompileResult.sourceMap from compileToResultAsync() instead.") + void sourceMap(SingleMapping map)?}) async { var result = await compileToResultAsync(path, logger: logger, importers: importers, @@ -446,8 +460,7 @@ Future compileAsync( /// /// {@category Compile} @Deprecated("Use compileStringToResultAsync() instead.") -Future compileStringAsync( - String source, +Future compileStringAsync(String source, {Syntax? syntax, bool color = false, Logger? logger, @@ -460,11 +473,11 @@ Future compileStringAsync( Object? url, bool quietDeps = false, bool verbose = false, - @Deprecated("Use CompileResult.sourceMap from compileStringToResultAsync() instead.") - void sourceMap(SingleMapping map)?, + @Deprecated( + "Use CompileResult.sourceMap from compileStringToResultAsync() instead.") + void sourceMap(SingleMapping map)?, bool charset = true, - @Deprecated("Use syntax instead.") - bool indented = false}) async { + @Deprecated("Use syntax instead.") bool indented = false}) async { var result = await compileStringToResultAsync(source, syntax: syntax ?? (indented ? Syntax.sass : Syntax.scss), logger: logger, diff --git a/lib/src/ast/css/at_rule.dart b/lib/src/ast/css/at_rule.dart index 8aff6529e..f11aa253f 100644 --- a/lib/src/ast/css/at_rule.dart +++ b/lib/src/ast/css/at_rule.dart @@ -2,12 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'node.dart'; import 'value.dart'; /// An unknown plain CSS at-rule. -abstract class CssAtRule extends CssParentNode { +abstract interface class CssAtRule implements CssParentNode { /// The name of this rule. CssValue get name; @@ -19,6 +18,4 @@ abstract class CssAtRule extends CssParentNode { /// This implies `children.isEmpty`, but the reverse is not true—for a rule /// like `@foo {}`, [children] is empty but [isChildless] is `false`. bool get isChildless; - - T accept(CssVisitor visitor) => visitor.visitCssAtRule(this); } diff --git a/lib/src/ast/css/comment.dart b/lib/src/ast/css/comment.dart index 4724a83b2..39d76423c 100644 --- a/lib/src/ast/css/comment.dart +++ b/lib/src/ast/css/comment.dart @@ -2,19 +2,16 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'node.dart'; /// A plain CSS comment. /// /// This is always a multi-line comment. -abstract class CssComment extends CssNode { +abstract interface class CssComment implements CssNode { /// The contents of this comment, including `/*` and `*/`. String get text; /// Whether this comment starts with `/*!` and so should be preserved even in /// compressed mode. bool get isPreserved; - - T accept(CssVisitor visitor) => visitor.visitCssComment(this); } diff --git a/lib/src/ast/css/declaration.dart b/lib/src/ast/css/declaration.dart index 1bd7279fc..4d5e906cd 100644 --- a/lib/src/ast/css/declaration.dart +++ b/lib/src/ast/css/declaration.dart @@ -5,12 +5,11 @@ import 'package:source_span/source_span.dart'; import '../../value.dart'; -import '../../visitor/interface/css.dart'; import 'node.dart'; import 'value.dart'; /// A plain CSS declaration (that is, a `name: value` pair). -abstract class CssDeclaration extends CssNode { +abstract interface class CssDeclaration implements CssNode { /// The name of this declaration. CssValue get name; @@ -34,6 +33,4 @@ abstract class CssDeclaration extends CssNode { /// If this is `true`, [isCustomProperty] will also be `true` and [value] will /// contain a [SassString]. bool get parsedAsCustomProperty; - - T accept(CssVisitor visitor); } diff --git a/lib/src/ast/css/import.dart b/lib/src/ast/css/import.dart index b8ad9e9e0..527006b7e 100644 --- a/lib/src/ast/css/import.dart +++ b/lib/src/ast/css/import.dart @@ -2,12 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'node.dart'; import 'value.dart'; /// A plain CSS `@import`. -abstract class CssImport extends CssNode { +abstract interface class CssImport implements CssNode { /// The URL being imported. /// /// This includes quotes. @@ -15,6 +14,4 @@ abstract class CssImport extends CssNode { /// The modifiers (such as media or supports queries) attached to this import. CssValue? get modifiers; - - T accept(CssVisitor visitor) => visitor.visitCssImport(this); } diff --git a/lib/src/ast/css/keyframe_block.dart b/lib/src/ast/css/keyframe_block.dart index 0b52cd82d..c6255fe45 100644 --- a/lib/src/ast/css/keyframe_block.dart +++ b/lib/src/ast/css/keyframe_block.dart @@ -2,16 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'node.dart'; import 'value.dart'; /// A block within a `@keyframes` rule. /// /// For example, `10% {opacity: 0.5}`. -abstract class CssKeyframeBlock extends CssParentNode { +abstract interface class CssKeyframeBlock implements CssParentNode { /// The selector for this block. CssValue> get selector; - - T accept(CssVisitor visitor) => visitor.visitCssKeyframeBlock(this); } diff --git a/lib/src/ast/css/media_query.dart b/lib/src/ast/css/media_query.dart index 8a095622d..dc2d5d532 100644 --- a/lib/src/ast/css/media_query.dart +++ b/lib/src/ast/css/media_query.dart @@ -2,12 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import '../../interpolation_map.dart'; import '../../logger.dart'; import '../../parse/media_query.dart'; import '../../utils.dart'; /// A plain CSS media query, as used in `@media` and `@import`. -class CssMediaQuery { +final class CssMediaQuery { /// The modifier, probably either "not" or "only". /// /// This may be `null` if no modifier is in use. @@ -43,8 +44,10 @@ class CssMediaQuery { /// /// Throws a [SassFormatException] if parsing fails. static List parseList(String contents, - {Object? url, Logger? logger}) => - MediaQueryParser(contents, url: url, logger: logger).parse(); + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) => + MediaQueryParser(contents, + url: url, logger: logger, interpolationMap: interpolationMap) + .parse(); /// Creates a media query specifies a type and, optionally, conditions. /// @@ -194,25 +197,21 @@ class CssMediaQuery { /// /// This is either the singleton values [empty] or [unrepresentable], or an /// instance of [MediaQuerySuccessfulMergeResult]. -abstract class MediaQueryMergeResult { +sealed class MediaQueryMergeResult { /// A singleton value indicating that there are no contexts that match both /// input queries. - static const empty = _SingletonCssMediaQueryMergeResult("empty"); + static const empty = _SingletonCssMediaQueryMergeResult.empty; /// A singleton value indicating that the contexts that match both input /// queries can't be represented by a Level 3 media query. static const unrepresentable = - _SingletonCssMediaQueryMergeResult("unrepresentable"); + _SingletonCssMediaQueryMergeResult.unrepresentable; } /// The subclass [MediaQueryMergeResult] that represents singleton enum values. -class _SingletonCssMediaQueryMergeResult implements MediaQueryMergeResult { - /// The name of the result type. - final String _name; - - const _SingletonCssMediaQueryMergeResult(this._name); - - String toString() => _name; +enum _SingletonCssMediaQueryMergeResult implements MediaQueryMergeResult { + empty, + unrepresentable; } /// A successful result of [CssMediaQuery.merge]. diff --git a/lib/src/ast/css/media_rule.dart b/lib/src/ast/css/media_rule.dart index d44859fb1..8eeba7e01 100644 --- a/lib/src/ast/css/media_rule.dart +++ b/lib/src/ast/css/media_rule.dart @@ -2,16 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'media_query.dart'; import 'node.dart'; /// A plain CSS `@media` rule. -abstract class CssMediaRule extends CssParentNode { +abstract interface class CssMediaRule implements CssParentNode { /// The queries for this rule. /// /// This is never empty. List get queries; - - T accept(CssVisitor visitor) => visitor.visitCssMediaRule(this); } diff --git a/lib/src/ast/css/modifiable.dart b/lib/src/ast/css/modifiable.dart index 3d9ec990d..c2518811b 100644 --- a/lib/src/ast/css/modifiable.dart +++ b/lib/src/ast/css/modifiable.dart @@ -12,4 +12,3 @@ export 'modifiable/node.dart'; export 'modifiable/style_rule.dart'; export 'modifiable/stylesheet.dart'; export 'modifiable/supports_rule.dart'; -export 'modifiable/value.dart'; diff --git a/lib/src/ast/css/modifiable/at_rule.dart b/lib/src/ast/css/modifiable/at_rule.dart index e616de5c2..a63579688 100644 --- a/lib/src/ast/css/modifiable/at_rule.dart +++ b/lib/src/ast/css/modifiable/at_rule.dart @@ -10,7 +10,8 @@ import '../value.dart'; import 'node.dart'; /// A modifiable version of [CssAtRule] for use in the evaluation step. -class ModifiableCssAtRule extends ModifiableCssParentNode implements CssAtRule { +final class ModifiableCssAtRule extends ModifiableCssParentNode + implements CssAtRule { final CssValue name; final CssValue? value; final bool isChildless; @@ -22,6 +23,12 @@ class ModifiableCssAtRule extends ModifiableCssParentNode implements CssAtRule { T accept(ModifiableCssVisitor visitor) => visitor.visitCssAtRule(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssAtRule && + name == other.name && + value == other.value && + isChildless == other.isChildless; + ModifiableCssAtRule copyWithoutChildren() => ModifiableCssAtRule(name, span, childless: isChildless, value: value); diff --git a/lib/src/ast/css/modifiable/comment.dart b/lib/src/ast/css/modifiable/comment.dart index 40e7838c7..a795b1d00 100644 --- a/lib/src/ast/css/modifiable/comment.dart +++ b/lib/src/ast/css/modifiable/comment.dart @@ -10,7 +10,8 @@ import '../comment.dart'; import 'node.dart'; /// A modifiable version of [CssComment] for use in the evaluation step. -class ModifiableCssComment extends ModifiableCssNode implements CssComment { +final class ModifiableCssComment extends ModifiableCssNode + implements CssComment { final String text; final FileSpan span; diff --git a/lib/src/ast/css/modifiable/declaration.dart b/lib/src/ast/css/modifiable/declaration.dart index 1082e1a48..1acb292a7 100644 --- a/lib/src/ast/css/modifiable/declaration.dart +++ b/lib/src/ast/css/modifiable/declaration.dart @@ -11,7 +11,7 @@ import '../value.dart'; import 'node.dart'; /// A modifiable version of [CssDeclaration] for use in the evaluation step. -class ModifiableCssDeclaration extends ModifiableCssNode +final class ModifiableCssDeclaration extends ModifiableCssNode implements CssDeclaration { final CssValue name; final CssValue value; diff --git a/lib/src/ast/css/modifiable/import.dart b/lib/src/ast/css/modifiable/import.dart index 033424e78..24de1fb17 100644 --- a/lib/src/ast/css/modifiable/import.dart +++ b/lib/src/ast/css/modifiable/import.dart @@ -10,7 +10,7 @@ import '../value.dart'; import 'node.dart'; /// A modifiable version of [CssImport] for use in the evaluation step. -class ModifiableCssImport extends ModifiableCssNode implements CssImport { +final class ModifiableCssImport extends ModifiableCssNode implements CssImport { /// The URL being imported. /// /// This includes quotes. diff --git a/lib/src/ast/css/modifiable/keyframe_block.dart b/lib/src/ast/css/modifiable/keyframe_block.dart index 69233f355..6b7beb9e2 100644 --- a/lib/src/ast/css/modifiable/keyframe_block.dart +++ b/lib/src/ast/css/modifiable/keyframe_block.dart @@ -4,13 +4,14 @@ import 'package:source_span/source_span.dart'; +import '../../../utils.dart'; import '../../../visitor/interface/modifiable_css.dart'; import '../keyframe_block.dart'; import '../value.dart'; import 'node.dart'; /// A modifiable version of [CssKeyframeBlock] for use in the evaluation step. -class ModifiableCssKeyframeBlock extends ModifiableCssParentNode +final class ModifiableCssKeyframeBlock extends ModifiableCssParentNode implements CssKeyframeBlock { final CssValue> selector; final FileSpan span; @@ -20,6 +21,10 @@ class ModifiableCssKeyframeBlock extends ModifiableCssParentNode T accept(ModifiableCssVisitor visitor) => visitor.visitCssKeyframeBlock(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssKeyframeBlock && + listEquals(selector.value, other.selector.value); + ModifiableCssKeyframeBlock copyWithoutChildren() => ModifiableCssKeyframeBlock(selector, span); } diff --git a/lib/src/ast/css/modifiable/media_rule.dart b/lib/src/ast/css/modifiable/media_rule.dart index f1dcf25e4..e30f44b29 100644 --- a/lib/src/ast/css/modifiable/media_rule.dart +++ b/lib/src/ast/css/modifiable/media_rule.dart @@ -4,13 +4,14 @@ import 'package:source_span/source_span.dart'; +import '../../../utils.dart'; import '../../../visitor/interface/modifiable_css.dart'; import '../media_query.dart'; import '../media_rule.dart'; import 'node.dart'; /// A modifiable version of [CssMediaRule] for use in the evaluation step. -class ModifiableCssMediaRule extends ModifiableCssParentNode +final class ModifiableCssMediaRule extends ModifiableCssParentNode implements CssMediaRule { final List queries; final FileSpan span; @@ -25,6 +26,9 @@ class ModifiableCssMediaRule extends ModifiableCssParentNode T accept(ModifiableCssVisitor visitor) => visitor.visitCssMediaRule(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssMediaRule && listEquals(queries, other.queries); + ModifiableCssMediaRule copyWithoutChildren() => ModifiableCssMediaRule(queries, span); } diff --git a/lib/src/ast/css/modifiable/node.dart b/lib/src/ast/css/modifiable/node.dart index 0f4495f32..bef0be821 100644 --- a/lib/src/ast/css/modifiable/node.dart +++ b/lib/src/ast/css/modifiable/node.dart @@ -12,7 +12,7 @@ import '../node.dart'; /// Almost all CSS nodes are the modifiable classes under the covers. However, /// modification should only be done within the evaluation step, so the /// unmodifiable types are used elsewhere to enforce that constraint. -abstract class ModifiableCssNode extends CssNode { +abstract base class ModifiableCssNode extends CssNode { /// The node that contains this, or `null` for the root [CssStylesheet] node. ModifiableCssParentNode? get parent => _parent; ModifiableCssParentNode? _parent; @@ -43,8 +43,7 @@ abstract class ModifiableCssNode extends CssNode { } parent._children.removeAt(_indexInParent!); - for (var i = _indexInParent!; i < parent._children.length; i++) { - var child = parent._children[i]; + for (var child in parent._children.skip(_indexInParent!)) { child._indexInParent = child._indexInParent! - 1; } _parent = null; @@ -52,7 +51,7 @@ abstract class ModifiableCssNode extends CssNode { } /// A modifiable version of [CssParentNode] for use in the evaluation step. -abstract class ModifiableCssParentNode extends ModifiableCssNode +abstract base class ModifiableCssParentNode extends ModifiableCssNode implements CssParentNode { final List children; final List _children; @@ -66,6 +65,9 @@ abstract class ModifiableCssParentNode extends ModifiableCssNode : _children = children, children = UnmodifiableListView(children); + /// 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. /// /// This is *not* a deep copy. If other parts of this node are modifiable, @@ -78,4 +80,13 @@ abstract class ModifiableCssParentNode extends ModifiableCssNode child._indexInParent = _children.length; _children.add(child); } + + /// Destructively removes all elements from [children]. + void clearChildren() { + for (var child in _children) { + child._parent = null; + child._indexInParent = null; + } + _children.clear(); + } } diff --git a/lib/src/ast/css/modifiable/style_rule.dart b/lib/src/ast/css/modifiable/style_rule.dart index 41400be70..a5d2b1f0c 100644 --- a/lib/src/ast/css/modifiable/style_rule.dart +++ b/lib/src/ast/css/modifiable/style_rule.dart @@ -4,30 +4,38 @@ import 'package:source_span/source_span.dart'; +import '../../../util/box.dart'; import '../../../visitor/interface/modifiable_css.dart'; import '../../selector.dart'; import '../style_rule.dart'; import 'node.dart'; -import 'value.dart'; /// A modifiable version of [CssStyleRule] for use in the evaluation step. -class ModifiableCssStyleRule extends ModifiableCssParentNode +final class ModifiableCssStyleRule extends ModifiableCssParentNode implements CssStyleRule { - final ModifiableCssValue selector; + SelectorList get selector => _selector.value; + + /// A reference to the modifiable selector list provided by the extension + /// store, which may update it over time as new extensions are applied. + final Box _selector; + final SelectorList originalSelector; final FileSpan span; /// Creates a new [ModifiableCssStyleRule]. /// - /// If [originalSelector] isn't passed, it defaults to [selector.value]. - ModifiableCssStyleRule(this.selector, this.span, + /// If [originalSelector] isn't passed, it defaults to [_selector.value]. + ModifiableCssStyleRule(this._selector, this.span, {SelectorList? originalSelector}) - : originalSelector = originalSelector ?? selector.value; + : originalSelector = originalSelector ?? _selector.value; T accept(ModifiableCssVisitor visitor) => visitor.visitCssStyleRule(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssStyleRule && other.selector == selector; + ModifiableCssStyleRule copyWithoutChildren() => - ModifiableCssStyleRule(selector, span, + ModifiableCssStyleRule(_selector, span, originalSelector: originalSelector); } diff --git a/lib/src/ast/css/modifiable/stylesheet.dart b/lib/src/ast/css/modifiable/stylesheet.dart index 46610a948..08f598b35 100644 --- a/lib/src/ast/css/modifiable/stylesheet.dart +++ b/lib/src/ast/css/modifiable/stylesheet.dart @@ -9,7 +9,7 @@ import '../stylesheet.dart'; import 'node.dart'; /// A modifiable version of [CssStylesheet] for use in the evaluation step. -class ModifiableCssStylesheet extends ModifiableCssParentNode +final class ModifiableCssStylesheet extends ModifiableCssParentNode implements CssStylesheet { final FileSpan span; @@ -18,6 +18,9 @@ class ModifiableCssStylesheet extends ModifiableCssParentNode T accept(ModifiableCssVisitor visitor) => visitor.visitCssStylesheet(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssStylesheet; + ModifiableCssStylesheet copyWithoutChildren() => ModifiableCssStylesheet(span); } diff --git a/lib/src/ast/css/modifiable/supports_rule.dart b/lib/src/ast/css/modifiable/supports_rule.dart index ef921b511..6f3419dff 100644 --- a/lib/src/ast/css/modifiable/supports_rule.dart +++ b/lib/src/ast/css/modifiable/supports_rule.dart @@ -10,7 +10,7 @@ import '../value.dart'; import 'node.dart'; /// A modifiable version of [CssSupportsRule] for use in the evaluation step. -class ModifiableCssSupportsRule extends ModifiableCssParentNode +final class ModifiableCssSupportsRule extends ModifiableCssParentNode implements CssSupportsRule { final CssValue condition; final FileSpan span; @@ -20,6 +20,9 @@ class ModifiableCssSupportsRule extends ModifiableCssParentNode T accept(ModifiableCssVisitor visitor) => visitor.visitCssSupportsRule(this); + bool equalsIgnoringChildren(ModifiableCssNode other) => + other is ModifiableCssSupportsRule && condition == other.condition; + ModifiableCssSupportsRule copyWithoutChildren() => ModifiableCssSupportsRule(condition, span); } diff --git a/lib/src/ast/css/modifiable/value.dart b/lib/src/ast/css/modifiable/value.dart deleted file mode 100644 index 2f29676be..000000000 --- a/lib/src/ast/css/modifiable/value.dart +++ /dev/null @@ -1,17 +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 'package:source_span/source_span.dart'; - -import '../value.dart'; - -/// A modifiable version of [CssValue] for use in the evaluation step. -class ModifiableCssValue implements CssValue { - T value; - final FileSpan span; - - ModifiableCssValue(this.value, this.span); - - String toString() => value.toString(); -} diff --git a/lib/src/ast/css/node.dart b/lib/src/ast/css/node.dart index 0ca31890c..29daba28d 100644 --- a/lib/src/ast/css/node.dart +++ b/lib/src/ast/css/node.dart @@ -13,7 +13,8 @@ import 'comment.dart'; import 'style_rule.dart'; /// A statement in a plain CSS syntax tree. -abstract class CssNode extends AstNode { +@sealed +abstract class CssNode implements AstNode { /// Whether this was generated from the last node in a nested Sass tree that /// got flattened during evaluation. bool get isGroupEnd; @@ -43,12 +44,13 @@ abstract class CssNode extends AstNode { bool get isInvisibleHidingComments => accept( const _IsInvisibleVisitor(includeBogus: true, includeComments: true)); - String toString() => serialize(this, inspect: true).css; + String toString() => serialize(this, inspect: true).$1; } // NOTE: New at-rule implementations should add themselves to [AtRootRule]'s // exclude logic. /// A [CssNode] that can have child statements. +@sealed abstract class CssParentNode extends CssNode { /// The child statements of this node. List get children; @@ -82,7 +84,7 @@ class _IsInvisibleVisitor with EveryCssVisitor { bool visitCssStyleRule(CssStyleRule rule) => (includeBogus - ? rule.selector.value.isInvisible - : rule.selector.value.isInvisibleOtherThanBogusCombinators) || + ? rule.selector.isInvisible + : rule.selector.isInvisibleOtherThanBogusCombinators) || super.visitCssStyleRule(rule); } diff --git a/lib/src/ast/css/style_rule.dart b/lib/src/ast/css/style_rule.dart index 2a902efc3..ccce74fdb 100644 --- a/lib/src/ast/css/style_rule.dart +++ b/lib/src/ast/css/style_rule.dart @@ -2,22 +2,18 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import '../selector.dart'; import 'node.dart'; -import 'value.dart'; /// A plain CSS style rule. /// /// This applies style declarations to elements that match a given selector. /// Note that this isn't *strictly* plain CSS, since [selector] may still /// contain placeholder selectors. -abstract class CssStyleRule extends CssParentNode { +abstract interface class CssStyleRule implements CssParentNode { /// The selector for this rule. - CssValue get selector; + SelectorList get selector; /// The selector for this rule, before any extensions were applied. SelectorList get originalSelector; - - T accept(CssVisitor visitor) => visitor.visitCssStyleRule(this); } diff --git a/lib/src/ast/css/supports_rule.dart b/lib/src/ast/css/supports_rule.dart index 091ba1dad..76457bc67 100644 --- a/lib/src/ast/css/supports_rule.dart +++ b/lib/src/ast/css/supports_rule.dart @@ -2,14 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import '../../visitor/interface/css.dart'; import 'node.dart'; import 'value.dart'; /// A plain CSS `@supports` rule. -abstract class CssSupportsRule extends CssParentNode { +abstract interface class CssSupportsRule implements CssParentNode { /// The supports condition. CssValue get condition; - - T accept(CssVisitor visitor) => visitor.visitCssSupportsRule(this); } diff --git a/lib/src/ast/css/value.dart b/lib/src/ast/css/value.dart index ce8ee2689..0cda62a78 100644 --- a/lib/src/ast/css/value.dart +++ b/lib/src/ast/css/value.dart @@ -9,8 +9,8 @@ import '../node.dart'; /// A value in a plain CSS tree. /// /// This is used to associate a span with a value that doesn't otherwise track -/// its span. -class CssValue implements AstNode { +/// its span. It has value equality semantics. +final class CssValue implements AstNode { /// The value. final T value; @@ -19,5 +19,10 @@ class CssValue implements AstNode { CssValue(this.value, this.span); + bool operator ==(Object other) => + other is CssValue && other.value == value; + + int get hashCode => value.hashCode; + String toString() => value.toString(); } diff --git a/lib/src/ast/node.dart b/lib/src/ast/node.dart index d061a4787..e63fa9df7 100644 --- a/lib/src/ast/node.dart +++ b/lib/src/ast/node.dart @@ -14,7 +14,7 @@ import 'package:source_span/source_span.dart'; /// /// {@category AST} @sealed -abstract class AstNode { +abstract interface class AstNode { /// The source span associated with the node. /// /// This indicates where in the source Sass or SCSS stylesheet the node was diff --git a/lib/src/ast/sass/argument.dart b/lib/src/ast/sass/argument.dart index 310154337..afd9e337c 100644 --- a/lib/src/ast/sass/argument.dart +++ b/lib/src/ast/sass/argument.dart @@ -2,7 +2,6 @@ // 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 '../../utils.dart'; @@ -14,8 +13,7 @@ import 'node.dart'; /// An argument declared as part of an [ArgumentDeclaration]. /// /// {@category AST} -@sealed -class Argument implements SassNode, SassDeclaration { +final class Argument implements SassNode, SassDeclaration { /// The argument name. final String name; diff --git a/lib/src/ast/sass/argument_declaration.dart b/lib/src/ast/sass/argument_declaration.dart index 5e1ac5932..7ab73c3a5 100644 --- a/lib/src/ast/sass/argument_declaration.dart +++ b/lib/src/ast/sass/argument_declaration.dart @@ -2,15 +2,14 @@ // 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 '../../exception.dart'; import '../../logger.dart'; import '../../parse/scss.dart'; -import '../../utils.dart'; import '../../util/character.dart'; import '../../util/span.dart'; +import '../../utils.dart'; import 'argument.dart'; import 'node.dart'; @@ -18,8 +17,7 @@ import 'node.dart'; /// /// {@category AST} /// {@category Parsing} -@sealed -class ArgumentDeclaration implements SassNode { +final class ArgumentDeclaration implements SassNode { /// The arguments that are taken. final List arguments; @@ -36,19 +34,19 @@ class ArgumentDeclaration implements SassNode { // Move backwards through any whitespace between the name and the arguments. var i = span.start.offset - 1; - while (i > 0 && isWhitespace(text.codeUnitAt(i))) { + while (i > 0 && text.codeUnitAt(i).isWhitespace) { i--; } // Then move backwards through the name itself. - if (!isName(text.codeUnitAt(i))) return span; + if (!text.codeUnitAt(i).isName) return span; i--; - while (i >= 0 && isName(text.codeUnitAt(i))) { + while (i >= 0 && text.codeUnitAt(i).isName) { i--; } // If the name didn't start with [isNameStart], it's not a valid identifier. - if (!isNameStart(text.codeUnitAt(i + 1))) return span; + if (!text.codeUnitAt(i + 1).isNameStart) return span; // Trim because it's possible that this span is empty (for example, a mixin // may be declared without an argument list). diff --git a/lib/src/ast/sass/argument_invocation.dart b/lib/src/ast/sass/argument_invocation.dart index 25af571cd..92e7645cf 100644 --- a/lib/src/ast/sass/argument_invocation.dart +++ b/lib/src/ast/sass/argument_invocation.dart @@ -2,17 +2,18 @@ // 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 '../../value/list.dart'; +import '../../util/map.dart'; import 'expression.dart'; +import 'expression/list.dart'; import 'node.dart'; /// A set of arguments passed in to a function or mixin. /// /// {@category AST} -@sealed -class ArgumentInvocation implements SassNode { +final class ArgumentInvocation implements SassNode { /// The arguments passed by position. final List positional; @@ -47,11 +48,24 @@ class ArgumentInvocation implements SassNode { String toString() { var components = [ - ...positional, - for (var name in named.keys) "\$$name: ${named[name]}", - if (rest != null) "$rest...", - if (keywordRest != null) "$keywordRest..." + for (var argument in positional) _parenthesizeArgument(argument), + for (var (name, value) in named.pairs) + "\$$name: ${_parenthesizeArgument(value)}", + if (rest case var rest?) "${_parenthesizeArgument(rest)}...", + if (keywordRest case var keywordRest?) + "${_parenthesizeArgument(keywordRest)}..." ]; return "(${components.join(', ')})"; } + + /// Wraps [argument] in parentheses if necessary. + String _parenthesizeArgument(Expression argument) => switch (argument) { + ListExpression( + separator: ListSeparator.comma, + hasBrackets: false, + contents: [_, _, ...] + ) => + "($argument)", + _ => argument.toString() + }; } diff --git a/lib/src/ast/sass/at_root_query.dart b/lib/src/ast/sass/at_root_query.dart index c00665e4b..3bad9cf20 100644 --- a/lib/src/ast/sass/at_root_query.dart +++ b/lib/src/ast/sass/at_root_query.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import 'package:collection/collection.dart'; import '../../exception.dart'; +import '../../interpolation_map.dart'; import '../../logger.dart'; import '../../parse/at_root_query.dart'; import '../css.dart'; @@ -13,8 +14,7 @@ import '../css.dart'; /// A query for the `@at-root` rule. /// /// @nodoc -@internal -class AtRootQuery { +final class AtRootQuery { /// The default at-root query, which excludes only style rules. static const defaultQuery = AtRootQuery._default(); @@ -53,8 +53,12 @@ class AtRootQuery { /// /// If passed, [url] is the name of the file from which [contents] comes. /// + /// If passed, [interpolationMap] maps the text of [contents] back to the + /// original location of the selector in the source file. + /// /// Throws a [SassFormatException] if parsing fails. - factory AtRootQuery.parse(String contents, {Object? url, Logger? logger}) => + factory AtRootQuery.parse(String contents, + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) => AtRootQueryParser(contents, url: url, logger: logger).parse(); /// Returns whether [this] excludes [node]. @@ -63,11 +67,13 @@ class AtRootQuery { @internal bool excludes(CssParentNode node) { if (_all) return !include; - if (node is CssStyleRule) return excludesStyleRules; - if (node is CssMediaRule) return excludesName("media"); - if (node is CssSupportsRule) return excludesName("supports"); - if (node is CssAtRule) return excludesName(node.name.value.toLowerCase()); - return false; + return switch (node) { + CssStyleRule() => excludesStyleRules, + CssMediaRule() => excludesName("media"), + CssSupportsRule() => excludesName("supports"), + CssAtRule() => excludesName(node.name.value.toLowerCase()), + _ => false + }; } /// Returns whether [this] excludes an at-rule with the given [name]. diff --git a/lib/src/ast/sass/configured_variable.dart b/lib/src/ast/sass/configured_variable.dart index 59bbc0287..c78066734 100644 --- a/lib/src/ast/sass/configured_variable.dart +++ b/lib/src/ast/sass/configured_variable.dart @@ -2,7 +2,6 @@ // 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 '../../util/span.dart'; @@ -13,8 +12,7 @@ import 'node.dart'; /// A variable configured by a `with` clause in a `@use` or `@forward` rule. /// /// {@category AST} -@sealed -class ConfiguredVariable implements SassNode, SassDeclaration { +final class ConfiguredVariable implements SassNode, SassDeclaration { /// The name of the variable being configured. final String name; diff --git a/lib/src/ast/sass/declaration.dart b/lib/src/ast/sass/declaration.dart index 0307e4994..0f2c78a41 100644 --- a/lib/src/ast/sass/declaration.dart +++ b/lib/src/ast/sass/declaration.dart @@ -11,7 +11,7 @@ import 'node.dart'; /// /// {@category AST} @sealed -abstract class SassDeclaration extends SassNode { +abstract interface class SassDeclaration implements SassNode { /// The name of the declaration, with underscores converted to hyphens. /// /// This does not include the `$` for variables. diff --git a/lib/src/ast/sass/dependency.dart b/lib/src/ast/sass/dependency.dart index d48e27623..54172be7c 100644 --- a/lib/src/ast/sass/dependency.dart +++ b/lib/src/ast/sass/dependency.dart @@ -11,7 +11,7 @@ import 'node.dart'; /// /// {@category AST} @sealed -abstract class SassDependency extends SassNode { +abstract interface class SassDependency implements SassNode { /// The URL of the dependency this rule loads. Uri get url; diff --git a/lib/src/ast/sass/expression.dart b/lib/src/ast/sass/expression.dart index 5a707bf7f..a5682411e 100644 --- a/lib/src/ast/sass/expression.dart +++ b/lib/src/ast/sass/expression.dart @@ -15,7 +15,7 @@ import 'node.dart'; /// {@category AST} /// {@category Parsing} @sealed -abstract class Expression implements SassNode { +abstract interface class Expression implements SassNode { /// Calls the appropriate visit method on [visitor]. T accept(ExpressionVisitor visitor); diff --git a/lib/src/ast/sass/expression/binary_operation.dart b/lib/src/ast/sass/expression/binary_operation.dart index d3b45d920..dfaf87d16 100644 --- a/lib/src/ast/sass/expression/binary_operation.dart +++ b/lib/src/ast/sass/expression/binary_operation.dart @@ -8,12 +8,12 @@ import 'package:source_span/source_span.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; +import 'list.dart'; /// A binary operator, as in `1 + 2` or `$this and $other`. /// /// {@category AST} -@sealed -class BinaryOperationExpression implements Expression { +final class BinaryOperationExpression implements Expression { /// The operator being invoked. final BinaryOperator operator; @@ -63,9 +63,14 @@ class BinaryOperationExpression implements Expression { String toString() { var buffer = StringBuffer(); - var left = this.left; // Hack to make analysis work. - var leftNeedsParens = left is BinaryOperationExpression && - left.operator.precedence < operator.precedence; + // dart-lang/language#3064 and #3062 track potential ways of making this + // cleaner. + var leftNeedsParens = switch (left) { + BinaryOperationExpression(operator: BinaryOperator(:var precedence)) => + precedence < operator.precedence, + ListExpression(hasBrackets: false, contents: [_, _, ...]) => true, + _ => false + }; if (leftNeedsParens) buffer.writeCharCode($lparen); buffer.write(left); if (leftNeedsParens) buffer.writeCharCode($rparen); @@ -75,8 +80,16 @@ class BinaryOperationExpression implements Expression { buffer.writeCharCode($space); var right = this.right; // Hack to make analysis work. - var rightNeedsParens = right is BinaryOperationExpression && - right.operator.precedence <= operator.precedence; + var rightNeedsParens = switch (right) { + BinaryOperationExpression(:var operator) => + // dart-lang/linter#4381 + // ignore: unnecessary_this + operator.precedence <= this.operator.precedence && + // ignore: unnecessary_this + !(operator == this.operator && operator.isAssociative), + ListExpression(hasBrackets: false, contents: [_, _, ...]) => true, + _ => false + }; if (rightNeedsParens) buffer.writeCharCode($lparen); buffer.write(right); if (rightNeedsParens) buffer.writeCharCode($rparen); @@ -93,10 +106,10 @@ enum BinaryOperator { singleEquals('single equals', '=', 0), /// The disjunction operator, `or`. - or('or', 'or', 1), + or('or', 'or', 1, associative: true), /// The conjunction operator, `and`. - and('and', 'and', 2), + and('and', 'and', 2, associative: true), /// The equality operator, `==`. equals('equals', '==', 3), @@ -117,13 +130,13 @@ enum BinaryOperator { lessThanOrEquals('less than or equals', '<=', 4), /// The addition operator, `+`. - plus('plus', '+', 5), + plus('plus', '+', 5, associative: true), /// The subtraction operator, `-`. minus('minus', '-', 5), /// The multiplication operator, `*`. - times('times', '*', 6), + times('times', '*', 6, associative: true), /// The division operator, `/`. dividedBy('divided by', '/', 6), @@ -142,7 +155,14 @@ enum BinaryOperator { /// An operator with higher precedence binds tighter. final int precedence; - const BinaryOperator(this.name, this.operator, this.precedence); + /// Whether this operation has the [associative property]. + /// + /// [associative property]: https://en.wikipedia.org/wiki/Associative_property + final bool isAssociative; + + const BinaryOperator(this.name, this.operator, this.precedence, + {bool associative = false}) + : isAssociative = associative; String toString() => name; } diff --git a/lib/src/ast/sass/expression/boolean.dart b/lib/src/ast/sass/expression/boolean.dart index 0686d0bd0..23474a3f6 100644 --- a/lib/src/ast/sass/expression/boolean.dart +++ b/lib/src/ast/sass/expression/boolean.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -11,8 +10,7 @@ import '../expression.dart'; /// A boolean literal, `true` or `false`. /// /// {@category AST} -@sealed -class BooleanExpression implements Expression { +final class BooleanExpression implements Expression { /// The value of this expression. final bool value; diff --git a/lib/src/ast/sass/expression/calculation.dart b/lib/src/ast/sass/expression/calculation.dart index ac17dc0aa..38c25ed14 100644 --- a/lib/src/ast/sass/expression/calculation.dart +++ b/lib/src/ast/sass/expression/calculation.dart @@ -18,8 +18,7 @@ import 'variable.dart'; /// A calculation literal. /// /// {@category AST} -@sealed -class CalculationExpression implements Expression { +final class CalculationExpression implements Expression { /// This calculation's name. final String name; @@ -74,29 +73,31 @@ class CalculationExpression implements Expression { /// Throws an [ArgumentError] if [expression] isn't a valid calculation /// argument. static void _verify(Expression expression) { - if (expression is NumberExpression) return; - if (expression is CalculationExpression) return; - if (expression is VariableExpression) return; - if (expression is FunctionExpression) return; - if (expression is IfExpression) return; - - if (expression is StringExpression) { - if (expression.hasQuotes) { + switch (expression) { + case NumberExpression() || + CalculationExpression() || + VariableExpression() || + FunctionExpression() || + IfExpression() || + StringExpression(hasQuotes: false): + break; + + case ParenthesizedExpression(:var expression): + _verify(expression); + + case BinaryOperationExpression( + :var left, + :var right, + operator: BinaryOperator.plus || + BinaryOperator.minus || + BinaryOperator.times || + BinaryOperator.dividedBy + ): + _verify(left); + _verify(right); + + case _: throw ArgumentError("Invalid calculation argument $expression."); - } - } else if (expression is ParenthesizedExpression) { - _verify(expression.expression); - } else if (expression is BinaryOperationExpression) { - _verify(expression.left); - _verify(expression.right); - if (expression.operator == BinaryOperator.plus) return; - if (expression.operator == BinaryOperator.minus) return; - if (expression.operator == BinaryOperator.times) return; - if (expression.operator == BinaryOperator.dividedBy) return; - - throw ArgumentError("Invalid calculation argument $expression."); - } else { - throw ArgumentError("Invalid calculation argument $expression."); } } diff --git a/lib/src/ast/sass/expression/color.dart b/lib/src/ast/sass/expression/color.dart index eef0e97f4..e81a7f8b8 100644 --- a/lib/src/ast/sass/expression/color.dart +++ b/lib/src/ast/sass/expression/color.dart @@ -2,7 +2,6 @@ // 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 '../../../value.dart'; @@ -12,8 +11,7 @@ import '../expression.dart'; /// A color literal. /// /// {@category AST} -@sealed -class ColorExpression implements Expression { +final class ColorExpression implements 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 4d823ca2e..398a2ff03 100644 --- a/lib/src/ast/sass/expression/function.dart +++ b/lib/src/ast/sass/expression/function.dart @@ -2,7 +2,6 @@ // 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 '../../../util/span.dart'; @@ -18,8 +17,7 @@ import '../reference.dart'; /// interpolation. /// /// {@category AST} -@sealed -class FunctionExpression +final class FunctionExpression implements Expression, CallableInvocation, SassReference { /// The namespace of the function being invoked, or `null` if it's invoked /// without a namespace. diff --git a/lib/src/ast/sass/expression/if.dart b/lib/src/ast/sass/expression/if.dart index f41c4f8e9..8805d4bff 100644 --- a/lib/src/ast/sass/expression/if.dart +++ b/lib/src/ast/sass/expression/if.dart @@ -2,7 +2,6 @@ // 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 '../../../ast/sass.dart'; @@ -15,8 +14,7 @@ import '../../../visitor/interface/expression.dart'; /// evaluated. /// /// {@category AST} -@sealed -class IfExpression implements Expression, CallableInvocation { +final class IfExpression implements Expression, 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 ec1941f64..3c97b0c9f 100644 --- a/lib/src/ast/sass/expression/interpolated_function.dart +++ b/lib/src/ast/sass/expression/interpolated_function.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -16,8 +15,8 @@ import '../interpolation.dart'; /// This is always a plain CSS function. /// /// {@category AST} -@sealed -class InterpolatedFunctionExpression implements Expression, CallableInvocation { +final class InterpolatedFunctionExpression + implements Expression, 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 53a10f3fc..01416afa4 100644 --- a/lib/src/ast/sass/expression/list.dart +++ b/lib/src/ast/sass/expression/list.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:charcode/charcode.dart'; -import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../value.dart'; @@ -14,8 +13,7 @@ import 'unary_operation.dart'; /// A list literal. /// /// {@category AST} -@sealed -class ListExpression implements Expression { +final class ListExpression implements Expression { /// The elements of this list. final List contents; @@ -37,33 +35,44 @@ class ListExpression implements Expression { String toString() { var buffer = StringBuffer(); - if (hasBrackets) buffer.writeCharCode($lbracket); + if (hasBrackets) { + buffer.writeCharCode($lbracket); + } else if (contents.isEmpty || + (contents.length == 1 && separator == ListSeparator.comma)) { + buffer.writeCharCode($lparen); + } + buffer.write(contents .map((element) => _elementNeedsParens(element) ? "($element)" : element.toString()) .join(separator == ListSeparator.comma ? ", " : " ")); - if (hasBrackets) buffer.writeCharCode($rbracket); + + if (hasBrackets) { + buffer.writeCharCode($rbracket); + } else if (contents.isEmpty) { + buffer.writeCharCode($rparen); + } else if (contents.length == 1 && separator == ListSeparator.comma) { + buffer.write(",)"); + } + return buffer.toString(); } /// Returns whether [expression], contained in [this], needs parentheses when /// printed as Sass source. - bool _elementNeedsParens(Expression expression) { - if (expression is ListExpression) { - if (expression.contents.length < 2) return false; - if (expression.hasBrackets) return false; - return separator == ListSeparator.comma - ? expression.separator == ListSeparator.comma - : expression.separator != ListSeparator.undecided; - } - - if (separator != ListSeparator.space) return false; - - if (expression is UnaryOperationExpression) { - return expression.operator == UnaryOperator.plus || - expression.operator == UnaryOperator.minus; - } - - return false; - } + bool _elementNeedsParens(Expression expression) => switch (expression) { + ListExpression( + contents: [_, _, ...], + hasBrackets: false, + separator: var childSeparator + ) => + separator == ListSeparator.comma + ? childSeparator == ListSeparator.comma + : childSeparator != ListSeparator.undecided, + UnaryOperationExpression( + operator: UnaryOperator.plus || UnaryOperator.minus + ) => + separator == ListSeparator.space, + _ => false + }; } diff --git a/lib/src/ast/sass/expression/map.dart b/lib/src/ast/sass/expression/map.dart index cabc4994f..9bc234780 100644 --- a/lib/src/ast/sass/expression/map.dart +++ b/lib/src/ast/sass/expression/map.dart @@ -2,9 +2,7 @@ // 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:tuple/tuple.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; @@ -12,21 +10,20 @@ import '../expression.dart'; /// A map literal. /// /// {@category AST} -@sealed -class MapExpression implements Expression { +final class MapExpression implements Expression { /// The pairs in this map. /// /// This is a list of pairs rather than a map because a map may have two keys /// with the same expression (e.g. `(unique-id(): 1, unique-id(): 2)`). - final List> pairs; + final List<(Expression, Expression)> pairs; final FileSpan span; - MapExpression(Iterable> pairs, this.span) + MapExpression(Iterable<(Expression, Expression)> pairs, this.span) : pairs = List.unmodifiable(pairs); T accept(ExpressionVisitor visitor) => visitor.visitMapExpression(this); String toString() => - '(${pairs.map((pair) => '${pair.item1}: ${pair.item2}').join(', ')})'; + '(${[for (var (key, value) in pairs) '$key: $value'].join(', ')})'; } diff --git a/lib/src/ast/sass/expression/null.dart b/lib/src/ast/sass/expression/null.dart index 0e236753e..4155c00b0 100644 --- a/lib/src/ast/sass/expression/null.dart +++ b/lib/src/ast/sass/expression/null.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -11,8 +10,7 @@ import '../expression.dart'; /// A null literal. /// /// {@category AST} -@sealed -class NullExpression implements Expression { +final class NullExpression implements 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 ad1f1ed1e..7eb2b6fd9 100644 --- a/lib/src/ast/sass/expression/number.dart +++ b/lib/src/ast/sass/expression/number.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -12,8 +11,7 @@ import '../expression.dart'; /// A number literal. /// /// {@category AST} -@sealed -class NumberExpression implements Expression { +final class NumberExpression implements 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 9b89731d3..3788645e3 100644 --- a/lib/src/ast/sass/expression/parenthesized.dart +++ b/lib/src/ast/sass/expression/parenthesized.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -11,8 +10,7 @@ import '../expression.dart'; /// An expression wrapped in parentheses. /// /// {@category AST} -@sealed -class ParenthesizedExpression implements Expression { +final class ParenthesizedExpression implements 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 c5209a88f..81356690b 100644 --- a/lib/src/ast/sass/expression/selector.dart +++ b/lib/src/ast/sass/expression/selector.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -11,8 +10,7 @@ import '../expression.dart'; /// A parent selector reference, `&`. /// /// {@category AST} -@sealed -class SelectorExpression implements Expression { +final class SelectorExpression implements 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 010907ff7..2e7824345 100644 --- a/lib/src/ast/sass/expression/string.dart +++ b/lib/src/ast/sass/expression/string.dart @@ -3,10 +3,11 @@ // https://opensource.org/licenses/MIT. import 'package:charcode/charcode.dart'; -import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../interpolation_buffer.dart'; +// dart-lang/sdk#52535 +// ignore: unused_import import '../../../util/character.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; @@ -15,8 +16,7 @@ import '../interpolation.dart'; /// A string literal. /// /// {@category AST} -@sealed -class StringExpression implements Expression { +final class StringExpression implements Expression { /// Interpolation that, when evaluated, produces the contents of this string. /// /// Unlike [asInterpolation], escapes are resolved and quotes are not @@ -65,10 +65,11 @@ class StringExpression implements Expression { buffer.writeCharCode(quote); for (var value in text.contents) { assert(value is Expression || value is String); - if (value is Expression) { - buffer.add(value); - } else if (value is String) { - _quoteInnerText(value, quote, buffer, static: static); + switch (value) { + case Expression(): + buffer.add(value); + case String(): + _quoteInnerText(value, quote, buffer, static: static); } } buffer.writeCharCode(quote); @@ -84,27 +85,28 @@ class StringExpression implements Expression { static void _quoteInnerText(String text, int quote, StringSink buffer, {bool static = false}) { for (var i = 0; i < text.length; i++) { - var codeUnit = text.codeUnitAt(i); - - if (isNewline(codeUnit)) { - buffer.writeCharCode($backslash); - buffer.writeCharCode($a); - if (i != text.length - 1) { - var next = text.codeUnitAt(i + 1); - if (isWhitespace(next) || isHex(next)) { - buffer.writeCharCode($space); + switch (text.codeUnitAt(i)) { + case int(isNewline: true): + buffer.writeCharCode($backslash); + buffer.writeCharCode($a); + if (i != text.length - 1) { + if (text.codeUnitAt(i + 1) + case int(isWhitespace: true) || int(isHex: true)) { + buffer.writeCharCode($space); + } } - } - } else { - if (codeUnit == quote || - codeUnit == $backslash || - (static && - codeUnit == $hash && + + case $backslash && var codeUnit: + case var codeUnit when codeUnit == quote: + case $hash && var codeUnit + when static && i < text.length - 1 && - text.codeUnitAt(i + 1) == $lbrace)) { + text.codeUnitAt(i + 1) == $lbrace: buffer.writeCharCode($backslash); - } - buffer.writeCharCode(codeUnit); + buffer.writeCharCode(codeUnit); + + case var codeUnit: + buffer.writeCharCode(codeUnit); } } } @@ -114,8 +116,7 @@ class StringExpression implements Expression { static int _bestQuote(Iterable strings) { var containsDoubleQuote = false; for (var value in strings) { - for (var i = 0; i < value.length; i++) { - var codeUnit = value.codeUnitAt(i); + for (var codeUnit in value.codeUnits) { if (codeUnit == $single_quote) return $double_quote; if (codeUnit == $double_quote) containsDoubleQuote = true; } diff --git a/lib/src/ast/sass/expression/supports.dart b/lib/src/ast/sass/expression/supports.dart index 3aa28222c..d5de09a75 100644 --- a/lib/src/ast/sass/expression/supports.dart +++ b/lib/src/ast/sass/expression/supports.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -15,8 +14,7 @@ import '../supports_condition.dart'; /// doesn't include the function name wrapping the condition. /// /// {@category AST} -@sealed -class SupportsExpression implements Expression { +final class SupportsExpression implements 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 201e1a821..d437fafc2 100644 --- a/lib/src/ast/sass/expression/unary_operation.dart +++ b/lib/src/ast/sass/expression/unary_operation.dart @@ -3,17 +3,17 @@ // https://opensource.org/licenses/MIT. import 'package:charcode/charcode.dart'; -import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../visitor/interface/expression.dart'; import '../expression.dart'; +import 'binary_operation.dart'; +import 'list.dart'; /// A unary operator, as in `+$var` or `not fn()`. /// /// {@category AST} -@sealed -class UnaryOperationExpression implements Expression { +final class UnaryOperationExpression implements Expression { /// The operator being invoked. final UnaryOperator operator; @@ -30,7 +30,17 @@ class UnaryOperationExpression implements Expression { String toString() { var buffer = StringBuffer(operator.operator); if (operator == UnaryOperator.not) buffer.writeCharCode($space); + var operand = this.operand; + var needsParens = switch (operand) { + BinaryOperationExpression() || + UnaryOperationExpression() || + ListExpression(hasBrackets: false, contents: [_, _, ...]) => + true, + _ => false + }; + if (needsParens) buffer.write($lparen); buffer.write(operand); + if (needsParens) buffer.write($rparen); return buffer.toString(); } } diff --git a/lib/src/ast/sass/expression/value.dart b/lib/src/ast/sass/expression/value.dart index 55fd52390..75b01212e 100644 --- a/lib/src/ast/sass/expression/value.dart +++ b/lib/src/ast/sass/expression/value.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/expression.dart'; @@ -15,8 +14,7 @@ import '../expression.dart'; /// constructed dynamically, as for the `call()` function. /// /// {@category AST} -@sealed -class ValueExpression implements Expression { +final class ValueExpression implements 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 2c5eace35..c07ffbc5a 100644 --- a/lib/src/ast/sass/expression/variable.dart +++ b/lib/src/ast/sass/expression/variable.dart @@ -2,7 +2,6 @@ // 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 '../../../util/span.dart'; @@ -13,8 +12,7 @@ import '../reference.dart'; /// A Sass variable. /// /// {@category AST} -@sealed -class VariableExpression implements Expression, SassReference { +final class VariableExpression implements Expression, SassReference { /// The namespace of the variable being referenced, or `null` if it's /// referenced without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/import.dart b/lib/src/ast/sass/import.dart index 3747a1a3b..7022f3b73 100644 --- a/lib/src/ast/sass/import.dart +++ b/lib/src/ast/sass/import.dart @@ -2,12 +2,9 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:meta/meta.dart'; - import 'node.dart'; /// An abstract superclass for different types of import. /// /// {@category AST} -@sealed -abstract class Import implements SassNode {} +abstract interface class Import implements SassNode {} diff --git a/lib/src/ast/sass/import/dynamic.dart b/lib/src/ast/sass/import/dynamic.dart index bb45cc6b0..38bb6c7f0 100644 --- a/lib/src/ast/sass/import/dynamic.dart +++ b/lib/src/ast/sass/import/dynamic.dart @@ -12,8 +12,7 @@ import '../import.dart'; /// An import that will load a Sass file at runtime. /// /// {@category AST} -@sealed -class DynamicImport implements Import, SassDependency { +final class DynamicImport implements Import, SassDependency { /// The URL of the file to import. /// /// If this is relative, it's relative to the containing file. diff --git a/lib/src/ast/sass/import/static.dart b/lib/src/ast/sass/import/static.dart index 69b1e3bb3..e20ee0d3f 100644 --- a/lib/src/ast/sass/import/static.dart +++ b/lib/src/ast/sass/import/static.dart @@ -2,7 +2,6 @@ // 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 '../import.dart'; @@ -11,8 +10,7 @@ import '../interpolation.dart'; /// An import that produces a plain CSS `@import` rule. /// /// {@category AST} -@sealed -class StaticImport implements Import { +final class StaticImport implements Import { /// The URL for this import. /// /// This already contains quotes. diff --git a/lib/src/ast/sass/interpolation.dart b/lib/src/ast/sass/interpolation.dart index d3a55971a..578394e83 100644 --- a/lib/src/ast/sass/interpolation.dart +++ b/lib/src/ast/sass/interpolation.dart @@ -12,8 +12,7 @@ import 'node.dart'; /// Plain text interpolated with Sass expressions. /// /// {@category AST} -@sealed -class Interpolation implements SassNode { +final class Interpolation implements SassNode { /// The contents of this interpolation. /// /// This contains [String]s and [Expression]s. It never contains two adjacent @@ -25,21 +24,15 @@ class Interpolation implements SassNode { /// If this contains no interpolated expressions, returns its text contents. /// /// Otherwise, returns `null`. - String? get asPlain { - if (contents.isEmpty) return ''; - if (contents.length > 1) return null; - var first = contents.first; - return first is String ? first : null; - } + String? get asPlain => + switch (contents) { [] => '', [String first] => first, _ => null }; /// Returns the plain text before the interpolation, or the empty string. /// /// @nodoc @internal - String get initialPlain { - var first = contents.first; - return first is String ? first : ''; - } + 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. @@ -48,15 +41,16 @@ class Interpolation implements SassNode { FileSpan span) { var buffer = InterpolationBuffer(); for (var element in contents) { - if (element is String) { - buffer.write(element); - } else if (element is Expression) { - buffer.add(element); - } else if (element is Interpolation) { - buffer.addInterpolation(element); - } else { - throw ArgumentError.value(contents, "contents", - "May only contains Strings, Expressions, or Interpolations."); + 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."); } } diff --git a/lib/src/ast/sass/node.dart b/lib/src/ast/sass/node.dart index 4b8d28b08..ee5211a31 100644 --- a/lib/src/ast/sass/node.dart +++ b/lib/src/ast/sass/node.dart @@ -10,4 +10,4 @@ import '../node.dart'; /// /// {@category AST} @sealed -abstract class SassNode extends AstNode {} +abstract interface class SassNode implements AstNode {} diff --git a/lib/src/ast/sass/reference.dart b/lib/src/ast/sass/reference.dart index ffa838a00..06ed05b73 100644 --- a/lib/src/ast/sass/reference.dart +++ b/lib/src/ast/sass/reference.dart @@ -2,7 +2,6 @@ // 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 'node.dart'; @@ -10,8 +9,7 @@ import 'node.dart'; /// A common interface for any node that references a Sass member. /// /// {@category AST} -@sealed -abstract class SassReference extends SassNode { +abstract interface class SassReference implements SassNode { /// The namespace of the member being referenced, or `null` if it's referenced /// without a namespace. String? get namespace; diff --git a/lib/src/ast/sass/statement.dart b/lib/src/ast/sass/statement.dart index 57a2861b5..123cf3362 100644 --- a/lib/src/ast/sass/statement.dart +++ b/lib/src/ast/sass/statement.dart @@ -2,16 +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 '../../visitor/interface/statement.dart'; import 'node.dart'; /// A statement in a Sass syntax tree. /// /// {@category AST} -@sealed -abstract class Statement implements SassNode { +abstract interface class Statement implements SassNode { /// Calls the appropriate visit method on [visitor]. T accept(StatementVisitor visitor); } diff --git a/lib/src/ast/sass/statement/at_root_rule.dart b/lib/src/ast/sass/statement/at_root_rule.dart index 51b4f7c88..a354d4794 100644 --- a/lib/src/ast/sass/statement/at_root_rule.dart +++ b/lib/src/ast/sass/statement/at_root_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -15,8 +14,7 @@ import 'parent.dart'; /// This moves it contents "up" the tree through parent nodes. /// /// {@category AST} -@sealed -class AtRootRule extends ParentStatement> { +final class AtRootRule extends ParentStatement> { /// The query specifying which statements this should move its contents /// through. final Interpolation? query; diff --git a/lib/src/ast/sass/statement/at_rule.dart b/lib/src/ast/sass/statement/at_rule.dart index e896c82b6..48c1629f9 100644 --- a/lib/src/ast/sass/statement/at_rule.dart +++ b/lib/src/ast/sass/statement/at_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -13,8 +12,7 @@ import 'parent.dart'; /// An unknown at-rule. /// /// {@category AST} -@sealed -class AtRule extends ParentStatement { +final class AtRule extends ParentStatement { /// The name of this rule. final Interpolation name; diff --git a/lib/src/ast/sass/statement/callable_declaration.dart b/lib/src/ast/sass/statement/callable_declaration.dart index 7f7fec48a..e39b9c035 100644 --- a/lib/src/ast/sass/statement/callable_declaration.dart +++ b/lib/src/ast/sass/statement/callable_declaration.dart @@ -2,7 +2,6 @@ // 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 '../argument_declaration.dart'; @@ -14,8 +13,8 @@ import 'silent_comment.dart'; /// user code. /// /// {@category AST} -@sealed -abstract class CallableDeclaration extends ParentStatement> { +abstract base class CallableDeclaration + extends ParentStatement> { /// The name of this callable, with underscores converted to hyphens. final String name; diff --git a/lib/src/ast/sass/statement/content_block.dart b/lib/src/ast/sass/statement/content_block.dart index 99af7746f..618a49ea5 100644 --- a/lib/src/ast/sass/statement/content_block.dart +++ b/lib/src/ast/sass/statement/content_block.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -13,8 +12,7 @@ import 'callable_declaration.dart'; /// An anonymous block of code that's invoked for a [ContentRule]. /// /// {@category AST} -@sealed -class ContentBlock extends CallableDeclaration { +final class ContentBlock extends CallableDeclaration { ContentBlock(ArgumentDeclaration arguments, Iterable children, FileSpan span) : super("@content", arguments, children, span); diff --git a/lib/src/ast/sass/statement/content_rule.dart b/lib/src/ast/sass/statement/content_rule.dart index d2dcb6914..a05066ef0 100644 --- a/lib/src/ast/sass/statement/content_rule.dart +++ b/lib/src/ast/sass/statement/content_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -15,8 +14,7 @@ import '../statement.dart'; /// caller. /// /// {@category AST} -@sealed -class ContentRule implements Statement { +final class ContentRule implements 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 63601cf44..47c2d452d 100644 --- a/lib/src/ast/sass/statement/debug_rule.dart +++ b/lib/src/ast/sass/statement/debug_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -14,8 +13,7 @@ import '../statement.dart'; /// This prints a Sass value for debugging purposes. /// /// {@category AST} -@sealed -class DebugRule implements Statement { +final class DebugRule implements Statement { /// The expression to print. final Expression expression; diff --git a/lib/src/ast/sass/statement/declaration.dart b/lib/src/ast/sass/statement/declaration.dart index 1f55e22a2..852bc3145 100644 --- a/lib/src/ast/sass/statement/declaration.dart +++ b/lib/src/ast/sass/statement/declaration.dart @@ -16,8 +16,7 @@ import 'parent.dart'; /// A declaration (that is, a `name: value` pair). /// /// {@category AST} -@sealed -class Declaration extends ParentStatement { +final class Declaration extends ParentStatement { /// The name of this declaration. final Interpolation name; @@ -42,25 +41,14 @@ class Declaration extends ParentStatement { bool get isCustomProperty => name.initialPlain.startsWith('--'); /// Creates a declaration with no children. - Declaration(this.name, this.value, this.span) : super(null) { - if (isCustomProperty && value is! StringExpression) { - throw ArgumentError( - 'Declarations whose names begin with "--" must have StringExpression ' - 'values (was `$value` of type ${value.runtimeType}).'); - } - } + Declaration(this.name, this.value, this.span) : super(null); /// Creates a declaration with children. /// /// For these declarations, a value is optional. Declaration.nested(this.name, Iterable children, this.span, {this.value}) - : super(List.unmodifiable(children)) { - if (isCustomProperty && value is! StringExpression) { - throw ArgumentError( - 'Declarations whose names begin with "--" may not be nested.'); - } - } + : super(List.unmodifiable(children)); T accept(StatementVisitor visitor) => visitor.visitDeclaration(this); @@ -70,13 +58,14 @@ class Declaration extends ParentStatement { buffer.writeCharCode($colon); if (value != null) { - if (!isCustomProperty) { - buffer.writeCharCode($space); - } + if (!isCustomProperty) buffer.writeCharCode($space); buffer.write("$value"); } - var children = this.children; - return children == null ? "$buffer;" : "$buffer {${children.join(" ")}}"; + if (children case var children?) { + return "$buffer {${children.join(" ")}}"; + } else { + return "$buffer;"; + } } } diff --git a/lib/src/ast/sass/statement/each_rule.dart b/lib/src/ast/sass/statement/each_rule.dart index bcdd37c07..68500ef56 100644 --- a/lib/src/ast/sass/statement/each_rule.dart +++ b/lib/src/ast/sass/statement/each_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -15,8 +14,7 @@ import 'parent.dart'; /// This iterates over values in a list or map. /// /// {@category AST} -@sealed -class EachRule extends ParentStatement> { +final class EachRule extends ParentStatement> { /// The variables assigned for each iteration. final List variables; diff --git a/lib/src/ast/sass/statement/error_rule.dart b/lib/src/ast/sass/statement/error_rule.dart index a82e404f4..977567cbd 100644 --- a/lib/src/ast/sass/statement/error_rule.dart +++ b/lib/src/ast/sass/statement/error_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -14,8 +13,7 @@ import '../statement.dart'; /// This emits an error and stops execution. /// /// {@category AST} -@sealed -class ErrorRule implements Statement { +final class ErrorRule implements 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 7ba9f0c8c..8aa4e4e33 100644 --- a/lib/src/ast/sass/statement/extend_rule.dart +++ b/lib/src/ast/sass/statement/extend_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -14,8 +13,7 @@ import '../statement.dart'; /// This gives one selector all the styling of another. /// /// {@category AST} -@sealed -class ExtendRule implements Statement { +final class ExtendRule implements Statement { /// The interpolation for the selector that will be extended. final Interpolation selector; diff --git a/lib/src/ast/sass/statement/for_rule.dart b/lib/src/ast/sass/statement/for_rule.dart index 8aef52e51..008f4d1f2 100644 --- a/lib/src/ast/sass/statement/for_rule.dart +++ b/lib/src/ast/sass/statement/for_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -15,8 +14,7 @@ import 'parent.dart'; /// This iterates a set number of times. /// /// {@category AST} -@sealed -class ForRule extends ParentStatement> { +final class ForRule extends ParentStatement> { /// The name of the variable that will contain the index value. final String variable; diff --git a/lib/src/ast/sass/statement/forward_rule.dart b/lib/src/ast/sass/statement/forward_rule.dart index 499f502b7..eea2a226d 100644 --- a/lib/src/ast/sass/statement/forward_rule.dart +++ b/lib/src/ast/sass/statement/forward_rule.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; import 'package:source_span/source_span.dart'; import '../../../util/span.dart'; @@ -16,8 +15,7 @@ import '../statement.dart'; /// A `@forward` rule. /// /// {@category AST} -@sealed -class ForwardRule implements Statement, SassDependency { +final class ForwardRule implements Statement, 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 0b2327e52..9242bf858 100644 --- a/lib/src/ast/sass/statement/function_rule.dart +++ b/lib/src/ast/sass/statement/function_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../util/span.dart'; @@ -18,8 +17,8 @@ import 'silent_comment.dart'; /// This declares a function that's invoked using normal CSS function syntax. /// /// {@category AST} -@sealed -class FunctionRule extends CallableDeclaration implements SassDeclaration { +final class FunctionRule extends CallableDeclaration + implements SassDeclaration { FileSpan get nameSpan => span.withoutInitialAtRule().initialIdentifier(); FunctionRule(String name, ArgumentDeclaration arguments, diff --git a/lib/src/ast/sass/statement/if_rule.dart b/lib/src/ast/sass/statement/if_rule.dart index 056cbd9f8..2a92ac28c 100644 --- a/lib/src/ast/sass/statement/if_rule.dart +++ b/lib/src/ast/sass/statement/if_rule.dart @@ -20,8 +20,7 @@ import 'variable_declaration.dart'; /// This conditionally executes a block of code. /// /// {@category AST} -@sealed -class IfRule implements Statement { +final class IfRule implements Statement { /// The `@if` and `@else if` clauses. /// /// The first clause whose expression evaluates to `true` will have its @@ -44,7 +43,8 @@ class IfRule implements Statement { String toString() { var result = clauses .mapIndexed((index, clause) => - "@${index == 0 ? 'if' : 'else if'} ${clause.expression} {${clause.children.join(' ')}}") + "@${index == 0 ? 'if' : 'else if'} ${clause.expression} " + "{${clause.children.join(' ')}}") .join(' '); var lastClause = this.lastClause; @@ -56,8 +56,7 @@ class IfRule implements Statement { /// The superclass of `@if` and `@else` clauses. /// /// {@category AST} -@sealed -abstract class IfRuleClause { +sealed class IfRuleClause { /// The statements to evaluate if this clause matches. final List children; @@ -71,19 +70,18 @@ abstract class IfRuleClause { : this._(List.unmodifiable(children)); IfRuleClause._(this.children) - : hasDeclarations = children.any((child) => - child is VariableDeclaration || - child is FunctionRule || - child is MixinRule || - (child is ImportRule && - child.imports.any((import) => import is DynamicImport))); + : hasDeclarations = children.any((child) => switch (child) { + VariableDeclaration() || FunctionRule() || MixinRule() => true, + ImportRule(:var imports) => + imports.any((import) => import is DynamicImport), + _ => false + }); } /// An `@if` or `@else if` clause in an `@if` rule. /// /// {@category AST} -@sealed -class IfClause extends IfRuleClause { +final class IfClause extends IfRuleClause { /// The expression to evaluate to determine whether to run this rule. final Expression expression; @@ -95,8 +93,7 @@ class IfClause extends IfRuleClause { /// An `@else` clause in an `@if` rule. /// /// {@category AST} -@sealed -class ElseClause extends IfRuleClause { +final class ElseClause extends IfRuleClause { ElseClause(Iterable children) : 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 c18f8e12e..425c3ac42 100644 --- a/lib/src/ast/sass/statement/import_rule.dart +++ b/lib/src/ast/sass/statement/import_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -12,8 +11,7 @@ import '../statement.dart'; /// An `@import` rule. /// /// {@category AST} -@sealed -class ImportRule implements Statement { +final class ImportRule implements 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 263675284..d3c9ceba6 100644 --- a/lib/src/ast/sass/statement/include_rule.dart +++ b/lib/src/ast/sass/statement/include_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../util/span.dart'; @@ -16,8 +15,8 @@ import 'content_block.dart'; /// A mixin invocation. /// /// {@category AST} -@sealed -class IncludeRule implements Statement, CallableInvocation, SassReference { +final class IncludeRule + implements Statement, CallableInvocation, SassReference { /// The namespace of the mixin being invoked, or `null` if it's invoked /// without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/statement/loud_comment.dart b/lib/src/ast/sass/statement/loud_comment.dart index 2876f1ad1..0c48e09fc 100644 --- a/lib/src/ast/sass/statement/loud_comment.dart +++ b/lib/src/ast/sass/statement/loud_comment.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -12,8 +11,7 @@ import '../statement.dart'; /// A loud CSS-style comment. /// /// {@category AST} -@sealed -class LoudComment implements Statement { +final class LoudComment implements Statement { /// The interpolated text of this comment, including comment characters. final Interpolation text; diff --git a/lib/src/ast/sass/statement/media_rule.dart b/lib/src/ast/sass/statement/media_rule.dart index ec63111c7..d219ca007 100644 --- a/lib/src/ast/sass/statement/media_rule.dart +++ b/lib/src/ast/sass/statement/media_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -13,8 +12,7 @@ import 'parent.dart'; /// A `@media` rule. /// /// {@category AST} -@sealed -class MediaRule extends ParentStatement> { +final class MediaRule extends ParentStatement> { /// The query that determines on which platforms the styles will be in effect. /// /// This is only parsed after the interpolation has been resolved. diff --git a/lib/src/ast/sass/statement/mixin_rule.dart b/lib/src/ast/sass/statement/mixin_rule.dart index 20e2ac254..624eff53e 100644 --- a/lib/src/ast/sass/statement/mixin_rule.dart +++ b/lib/src/ast/sass/statement/mixin_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../util/span.dart'; @@ -20,8 +19,7 @@ import 'silent_comment.dart'; /// This declares a mixin that's invoked using `@include`. /// /// {@category AST} -@sealed -class MixinRule extends CallableDeclaration implements SassDeclaration { +final class MixinRule extends CallableDeclaration implements SassDeclaration { /// Whether the mixin contains a `@content` rule. late final bool hasContent = const _HasContentVisitor().visitMixinRule(this) == true; diff --git a/lib/src/ast/sass/statement/parent.dart b/lib/src/ast/sass/statement/parent.dart index 329e45ba7..21293019d 100644 --- a/lib/src/ast/sass/statement/parent.dart +++ b/lib/src/ast/sass/statement/parent.dart @@ -17,8 +17,7 @@ import 'variable_declaration.dart'; /// not their children lists are nullable. /// /// {@category AST} -@sealed -abstract class ParentStatement?> +abstract base class ParentStatement?> implements Statement { /// The child statements of this statement. final T children; @@ -31,11 +30,14 @@ abstract class ParentStatement?> final bool hasDeclarations; ParentStatement(this.children) - : hasDeclarations = children?.any((child) => - child is VariableDeclaration || - child is FunctionRule || - child is MixinRule || - (child is ImportRule && - child.imports.any((import) => import is DynamicImport))) ?? + : hasDeclarations = children?.any((child) => switch (child) { + VariableDeclaration() || + FunctionRule() || + MixinRule() => + true, + ImportRule(:var imports) => + imports.any((import) => import is DynamicImport), + _ => false, + }) ?? false; } diff --git a/lib/src/ast/sass/statement/return_rule.dart b/lib/src/ast/sass/statement/return_rule.dart index bbf7fd370..dc1efc65c 100644 --- a/lib/src/ast/sass/statement/return_rule.dart +++ b/lib/src/ast/sass/statement/return_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -14,8 +13,7 @@ import '../statement.dart'; /// This exits from the current function body with a return value. /// /// {@category AST} -@sealed -class ReturnRule implements Statement { +final class ReturnRule implements 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 189c5d79f..384cd09fb 100644 --- a/lib/src/ast/sass/statement/silent_comment.dart +++ b/lib/src/ast/sass/statement/silent_comment.dart @@ -2,7 +2,6 @@ // 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:string_scanner/string_scanner.dart'; @@ -12,8 +11,7 @@ import '../statement.dart'; /// A silent Sass-style comment. /// /// {@category AST} -@sealed -class SilentComment implements Statement { +final class SilentComment implements Statement { /// The text of this comment, including comment characters. final String text; diff --git a/lib/src/ast/sass/statement/style_rule.dart b/lib/src/ast/sass/statement/style_rule.dart index 02b6c8ea9..32031c762 100644 --- a/lib/src/ast/sass/statement/style_rule.dart +++ b/lib/src/ast/sass/statement/style_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -15,8 +14,7 @@ import 'parent.dart'; /// This applies style declarations to elements that match a given selector. /// /// {@category AST} -@sealed -class StyleRule extends ParentStatement> { +final class StyleRule extends ParentStatement> { /// The selector to which the declaration will be applied. /// /// This is only parsed after the interpolation has been resolved. diff --git a/lib/src/ast/sass/statement/stylesheet.dart b/lib/src/ast/sass/statement/stylesheet.dart index b0acd1218..b90cddc52 100644 --- a/lib/src/ast/sass/statement/stylesheet.dart +++ b/lib/src/ast/sass/statement/stylesheet.dart @@ -13,6 +13,7 @@ import '../../../parse/css.dart'; import '../../../parse/sass.dart'; import '../../../parse/scss.dart'; import '../../../syntax.dart'; +import '../../../utils.dart'; import '../../../visitor/interface/statement.dart'; import '../statement.dart'; import 'forward_rule.dart'; @@ -28,8 +29,7 @@ import 'variable_declaration.dart'; /// /// {@category AST} /// {@category Parsing} -@sealed -class Stylesheet extends ParentStatement> { +final class Stylesheet extends ParentStatement> { final FileSpan span; /// Whether this was parsed from a plain CSS stylesheet. @@ -56,15 +56,22 @@ class Stylesheet extends ParentStatement> { Stylesheet.internal(Iterable children, this.span, {this.plainCss = false}) : super(List.unmodifiable(children)) { + loop: for (var child in this.children) { - if (child is UseRule) { - _uses.add(child); - } else if (child is ForwardRule) { - _forwards.add(child); - } else if (child is! SilentComment && - child is! LoudComment && - child is! VariableDeclaration) { - break; + switch (child) { + case UseRule(): + _uses.add(child); + + case ForwardRule(): + _forwards.add(child); + + case SilentComment() || LoudComment() || VariableDeclaration(): + // These are allowed between `@use` and `@forward` rules. + break; + + case _: + break loop; + // Once we reach anything else, we know we're done with loads. } } } @@ -76,15 +83,23 @@ class Stylesheet extends ParentStatement> { /// Throws a [SassFormatException] if parsing fails. factory Stylesheet.parse(String contents, Syntax syntax, {Object? url, Logger? logger}) { - switch (syntax) { - case Syntax.sass: - return Stylesheet.parseSass(contents, url: url, logger: logger); - case Syntax.scss: - return Stylesheet.parseScss(contents, url: url, logger: logger); - case Syntax.css: - return Stylesheet.parseCss(contents, url: url, logger: logger); - default: - throw ArgumentError("Unknown syntax $syntax."); + try { + switch (syntax) { + case Syntax.sass: + return Stylesheet.parseSass(contents, url: url, logger: logger); + case Syntax.scss: + return Stylesheet.parseScss(contents, url: url, logger: logger); + case Syntax.css: + return Stylesheet.parseCss(contents, url: url, logger: logger); + default: + throw ArgumentError("Unknown syntax $syntax."); + } + } on SassException catch (error, stackTrace) { + var url = error.span.sourceUrl; + if (url == null || url.toString() == 'stdin') rethrow; + + throw throwWithTrace( + error.withLoadedUrls(Set.unmodifiable({url})), error, stackTrace); } } diff --git a/lib/src/ast/sass/statement/supports_rule.dart b/lib/src/ast/sass/statement/supports_rule.dart index fe23d97ad..13bba084f 100644 --- a/lib/src/ast/sass/statement/supports_rule.dart +++ b/lib/src/ast/sass/statement/supports_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -13,8 +12,7 @@ import 'parent.dart'; /// A `@supports` rule. /// /// {@category AST} -@sealed -class SupportsRule extends ParentStatement> { +final class SupportsRule extends ParentStatement> { /// The condition that selects what browsers this rule targets. final SupportsCondition condition; diff --git a/lib/src/ast/sass/statement/use_rule.dart b/lib/src/ast/sass/statement/use_rule.dart index 0e84759ad..244613abc 100644 --- a/lib/src/ast/sass/statement/use_rule.dart +++ b/lib/src/ast/sass/statement/use_rule.dart @@ -18,8 +18,7 @@ import '../statement.dart'; /// A `@use` rule. /// /// {@category AST} -@sealed -class UseRule implements Statement, SassDependency { +final class UseRule implements Statement, SassDependency { /// The URI of the module to use. /// /// If this is relative, it's relative to the containing file. diff --git a/lib/src/ast/sass/statement/variable_declaration.dart b/lib/src/ast/sass/statement/variable_declaration.dart index a82401b0a..235a41648 100644 --- a/lib/src/ast/sass/statement/variable_declaration.dart +++ b/lib/src/ast/sass/statement/variable_declaration.dart @@ -21,8 +21,7 @@ import 'silent_comment.dart'; /// This defines or sets a variable. /// /// {@category AST} -@sealed -class VariableDeclaration implements Statement, SassDeclaration { +final class VariableDeclaration implements Statement, SassDeclaration { /// The namespace of the variable being set, or `null` if it's defined or set /// without a namespace. final String? namespace; diff --git a/lib/src/ast/sass/statement/warn_rule.dart b/lib/src/ast/sass/statement/warn_rule.dart index ae3ccbef7..026f4ca34 100644 --- a/lib/src/ast/sass/statement/warn_rule.dart +++ b/lib/src/ast/sass/statement/warn_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -14,8 +13,7 @@ import '../statement.dart'; /// This prints a Sass value—usually a string—to warn the user of something. /// /// {@category AST} -@sealed -class WarnRule implements Statement { +final class WarnRule implements Statement { /// The expression to print. final Expression expression; diff --git a/lib/src/ast/sass/statement/while_rule.dart b/lib/src/ast/sass/statement/while_rule.dart index 18e8f6f94..34b39d52a 100644 --- a/lib/src/ast/sass/statement/while_rule.dart +++ b/lib/src/ast/sass/statement/while_rule.dart @@ -2,7 +2,6 @@ // 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 '../../../visitor/interface/statement.dart'; @@ -16,8 +15,7 @@ import 'parent.dart'; /// `true`. /// /// {@category AST} -@sealed -class WhileRule extends ParentStatement> { +final class WhileRule extends ParentStatement> { /// The condition that determines whether the block will be executed. final Expression condition; diff --git a/lib/src/ast/sass/supports_condition.dart b/lib/src/ast/sass/supports_condition.dart index 53f96bb38..4b38d304e 100644 --- a/lib/src/ast/sass/supports_condition.dart +++ b/lib/src/ast/sass/supports_condition.dart @@ -2,12 +2,9 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:meta/meta.dart'; - import 'node.dart'; /// An abstract class for defining the condition a `@supports` rule selects. /// /// {@category AST} -@sealed -abstract class SupportsCondition extends SassNode {} +abstract interface class SupportsCondition implements SassNode {} diff --git a/lib/src/ast/sass/supports_condition/anything.dart b/lib/src/ast/sass/supports_condition/anything.dart index 4dbece4b2..91d90024a 100644 --- a/lib/src/ast/sass/supports_condition/anything.dart +++ b/lib/src/ast/sass/supports_condition/anything.dart @@ -2,7 +2,6 @@ // 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'; @@ -12,8 +11,7 @@ import '../supports_condition.dart'; /// `` production. /// /// {@category AST} -@sealed -class SupportsAnything implements SupportsCondition { +final class SupportsAnything implements SupportsCondition { /// The contents of the condition. final Interpolation contents; diff --git a/lib/src/ast/sass/supports_condition/declaration.dart b/lib/src/ast/sass/supports_condition/declaration.dart index d29d717c9..322731018 100644 --- a/lib/src/ast/sass/supports_condition/declaration.dart +++ b/lib/src/ast/sass/supports_condition/declaration.dart @@ -13,8 +13,7 @@ import '../supports_condition.dart'; /// supported. /// /// {@category AST} -@sealed -class SupportsDeclaration implements SupportsCondition { +final class SupportsDeclaration implements SupportsCondition { /// The name of the declaration being tested. final Expression name; @@ -33,12 +32,11 @@ class SupportsDeclaration implements SupportsCondition { /// /// @nodoc @internal - bool get isCustomProperty { - var name = this.name; - return name is StringExpression && - !name.hasQuotes && - name.text.initialPlain.startsWith('--'); - } + bool get isCustomProperty => switch (name) { + StringExpression(hasQuotes: false, :var text) => + text.initialPlain.startsWith('--'), + _ => false + }; SupportsDeclaration(this.name, this.value, this.span); diff --git a/lib/src/ast/sass/supports_condition/function.dart b/lib/src/ast/sass/supports_condition/function.dart index 73bdb9bda..dd9ac5b29 100644 --- a/lib/src/ast/sass/supports_condition/function.dart +++ b/lib/src/ast/sass/supports_condition/function.dart @@ -2,7 +2,6 @@ // 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'; @@ -11,8 +10,7 @@ import '../supports_condition.dart'; /// A function-syntax condition. /// /// {@category AST} -@sealed -class SupportsFunction implements SupportsCondition { +final class SupportsFunction implements SupportsCondition { /// The name of the function. final Interpolation name; diff --git a/lib/src/ast/sass/supports_condition/interpolation.dart b/lib/src/ast/sass/supports_condition/interpolation.dart index 9fbd85829..839fccf9f 100644 --- a/lib/src/ast/sass/supports_condition/interpolation.dart +++ b/lib/src/ast/sass/supports_condition/interpolation.dart @@ -2,7 +2,6 @@ // 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'; @@ -11,8 +10,7 @@ import '../supports_condition.dart'; /// An interpolated condition. /// /// {@category AST} -@sealed -class SupportsInterpolation implements SupportsCondition { +final class SupportsInterpolation implements SupportsCondition { /// The expression in the interpolation. final Expression expression; diff --git a/lib/src/ast/sass/supports_condition/negation.dart b/lib/src/ast/sass/supports_condition/negation.dart index 4187f3793..23cd7193e 100644 --- a/lib/src/ast/sass/supports_condition/negation.dart +++ b/lib/src/ast/sass/supports_condition/negation.dart @@ -2,7 +2,6 @@ // 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 '../supports_condition.dart'; @@ -11,8 +10,7 @@ import 'operation.dart'; /// A negated condition. /// /// {@category AST} -@sealed -class SupportsNegation implements SupportsCondition { +final class SupportsNegation implements SupportsCondition { /// The condition that's been negated. final SupportsCondition condition; diff --git a/lib/src/ast/sass/supports_condition/operation.dart b/lib/src/ast/sass/supports_condition/operation.dart index 3e4fc5113..f072fc2e3 100644 --- a/lib/src/ast/sass/supports_condition/operation.dart +++ b/lib/src/ast/sass/supports_condition/operation.dart @@ -2,7 +2,6 @@ // 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 '../supports_condition.dart'; @@ -11,8 +10,7 @@ import 'negation.dart'; /// An operation defining the relationship between two conditions. /// /// {@category AST} -@sealed -class SupportsOperation implements SupportsCondition { +final class SupportsOperation implements SupportsCondition { /// The left-hand operand. final SupportsCondition left; diff --git a/lib/src/ast/selector.dart b/lib/src/ast/selector.dart index 8b694e430..953ccf7aa 100644 --- a/lib/src/ast/selector.dart +++ b/lib/src/ast/selector.dart @@ -3,12 +3,15 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; +import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../visitor/any_selector.dart'; import '../visitor/interface/selector.dart'; import '../visitor/serialize.dart'; +import 'node.dart'; import 'selector/complex.dart'; import 'selector/list.dart'; import 'selector/placeholder.dart'; @@ -38,7 +41,7 @@ export 'selector/universal.dart'; /// Selectors have structural equality semantics. /// /// {@category AST} -abstract class Selector { +abstract base class Selector implements AstNode { /// Whether this selector, and complex selectors containing it, should not be /// emitted. /// @@ -76,19 +79,23 @@ abstract class Selector { @internal bool get isUseless => accept(const _IsUselessVisitor()); + final FileSpan span; + + Selector(this.span); + /// Prints a warning if [this] is a bogus selector. /// /// This may only be called from within a custom Sass function. This will - /// throw a [SassScriptException] in Dart Sass 2.0.0. + /// throw a [SassException] in Dart Sass 2.0.0. void assertNotBogus({String? name}) { if (!isBogus) return; - warn( + warnForDeprecation( (name == null ? '' : '\$$name: ') + '$this is not valid CSS.\n' 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - deprecation: true); + Deprecation.bogusCombinators); } /// Calls the appropriate visit method on [visitor]. @@ -114,16 +121,17 @@ class _IsInvisibleVisitor with AnySelectorVisitor { bool visitPlaceholderSelector(PlaceholderSelector placeholder) => true; bool visitPseudoSelector(PseudoSelector pseudo) { - var selector = pseudo.selector; - if (selector == null) return false; - - // We don't consider `:not(%foo)` to be invisible because, semantically, it - // means "doesn't match this selector that matches nothing", so it's - // equivalent to *. If the entire compound selector is composed of `:not`s - // with invisible lists, the serializer emits it as `*`. - return pseudo.name == 'not' - ? (includeBogus && selector.isBogus) - : selector.accept(this); + if (pseudo.selector case var selector?) { + // We don't consider `:not(%foo)` to be invisible because, semantically, + // it means "doesn't match this selector that matches nothing", so it's + // equivalent to *. If the entire compound selector is composed of `:not`s + // with invisible lists, the serializer emits it as `*`. + return pseudo.name == 'not' + ? (includeBogus && selector.isBogus) + : selector.accept(this); + } else { + return false; + } } } diff --git a/lib/src/ast/selector/attribute.dart b/lib/src/ast/selector/attribute.dart index 0fbae6a29..dcdedf54a 100644 --- a/lib/src/ast/selector/attribute.dart +++ b/lib/src/ast/selector/attribute.dart @@ -2,7 +2,7 @@ // 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 '../../visitor/interface/selector.dart'; import '../selector.dart'; @@ -13,8 +13,7 @@ import '../selector.dart'; /// value matching certain conditions as well. /// /// {@category AST} -@sealed -class AttributeSelector extends SimpleSelector { +final class AttributeSelector extends SimpleSelector { /// The name of the attribute being selected for. final QualifiedName name; @@ -44,15 +43,17 @@ class AttributeSelector extends SimpleSelector { /// Creates an attribute selector that matches any element with a property of /// the given name. - AttributeSelector(this.name) + AttributeSelector(this.name, FileSpan span) : op = null, value = null, - modifier = null; + modifier = null, + super(span); /// Creates an attribute selector that matches an element with a property /// named [name], whose value matches [value] based on the semantics of [op]. - AttributeSelector.withOperator(this.name, this.op, this.value, - {this.modifier}); + AttributeSelector.withOperator(this.name, this.op, this.value, FileSpan span, + {this.modifier}) + : super(span); T accept(SelectorVisitor visitor) => visitor.visitAttributeSelector(this); diff --git a/lib/src/ast/selector/class.dart b/lib/src/ast/selector/class.dart index 513d46d4e..b01ce9da5 100644 --- a/lib/src/ast/selector/class.dart +++ b/lib/src/ast/selector/class.dart @@ -3,6 +3,7 @@ // 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'; @@ -13,12 +14,11 @@ import '../selector.dart'; /// the given name. /// /// {@category AST} -@sealed -class ClassSelector extends SimpleSelector { +final class ClassSelector extends SimpleSelector { /// The class name this selects for. final String name; - ClassSelector(this.name); + ClassSelector(this.name, FileSpan span) : super(span); bool operator ==(Object other) => other is ClassSelector && other.name == name; @@ -27,7 +27,7 @@ class ClassSelector extends SimpleSelector { /// @nodoc @internal - ClassSelector addSuffix(String suffix) => ClassSelector(name + suffix); + ClassSelector addSuffix(String suffix) => ClassSelector(name + suffix, span); int get hashCode => name.hashCode; } diff --git a/lib/src/ast/selector/complex.dart b/lib/src/ast/selector/complex.dart index e5eb6cd25..3d97729ca 100644 --- a/lib/src/ast/selector/complex.dart +++ b/lib/src/ast/selector/complex.dart @@ -3,12 +3,14 @@ // 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'; +import '../css/value.dart'; import '../selector.dart'; /// A complex selector. @@ -18,14 +20,13 @@ import '../selector.dart'; /// /// {@category AST} /// {@category Parsing} -@sealed -class ComplexSelector extends Selector { +final class ComplexSelector extends Selector { /// This selector's leading combinators. /// /// If this is empty, that indicates that it has no leading combinator. If /// it's more than one element, that means it's invalid CSS; however, we still /// support this for backwards-compatibility purposes. - final List leadingCombinators; + final List> leadingCombinators; /// The components of this selector. /// @@ -60,17 +61,20 @@ class ComplexSelector extends Selector { /// /// @nodoc @internal - CompoundSelector? get singleCompound => leadingCombinators.isEmpty && - components.length == 1 && - components.first.combinators.isEmpty - ? components.first.selector - : null; - - ComplexSelector(Iterable leadingCombinators, - Iterable components, + CompoundSelector? get singleCompound { + if (leadingCombinators.isNotEmpty) return null; + return switch (components) { + [ComplexSelectorComponent(:var selector, combinators: [])] => selector, + _ => null + }; + } + + ComplexSelector(Iterable> leadingCombinators, + Iterable components, FileSpan span, {this.lineBreak = false}) : leadingCombinators = List.unmodifiable(leadingCombinators), - components = List.unmodifiable(components) { + components = List.unmodifiable(components), + super(span) { if (this.leadingCombinators.isEmpty && this.components.isEmpty) { throw ArgumentError( "leadingCombinators and components may not both be empty."); @@ -109,22 +113,18 @@ class ComplexSelector extends Selector { /// /// @nodoc @internal - ComplexSelector withAdditionalCombinators(List combinators, + ComplexSelector withAdditionalCombinators( + List> combinators, {bool forceLineBreak = false}) { - if (combinators.isEmpty) { - return this; - } else if (components.isEmpty) { - return ComplexSelector([...leadingCombinators, ...combinators], const [], - lineBreak: lineBreak || forceLineBreak); - } else { - return ComplexSelector( - leadingCombinators, - [ - ...components.exceptLast, - components.last.withAdditionalCombinators(combinators) - ], - lineBreak: lineBreak || forceLineBreak); - } + if (combinators.isEmpty) return this; + return switch (components) { + [...var initial, var last] => ComplexSelector(leadingCombinators, + [...initial, last.withAdditionalCombinators(combinators)], span, + lineBreak: lineBreak || forceLineBreak), + [] => ComplexSelector( + [...leadingCombinators, ...combinators], const [], span, + lineBreak: lineBreak || forceLineBreak) + }; } /// Returns a copy of `this` with an additional [component] added to the end. @@ -132,11 +132,14 @@ class ComplexSelector extends Selector { /// If [forceLineBreak] is `true`, this will mark the new complex selector as /// having a line break. /// + /// The [span] is used for the new selector. + /// /// @nodoc @internal - ComplexSelector withAdditionalComponent(ComplexSelectorComponent component, + ComplexSelector withAdditionalComponent( + ComplexSelectorComponent component, FileSpan span, {bool forceLineBreak = false}) => - ComplexSelector(leadingCombinators, [...components, component], + ComplexSelector(leadingCombinators, [...components, component], span, lineBreak: lineBreak || forceLineBreak); /// Returns a copy of `this` with [child]'s combinators added to the end. @@ -144,30 +147,34 @@ class ComplexSelector extends Selector { /// If [child] has [leadingCombinators], they're appended to `this`'s last /// combinator. This does _not_ resolve parent selectors. /// + /// The [span] is used for the new selector. + /// /// If [forceLineBreak] is `true`, this will mark the new complex selector as /// having a line break. /// /// @nodoc @internal - ComplexSelector concatenate(ComplexSelector child, + ComplexSelector concatenate(ComplexSelector child, FileSpan span, {bool forceLineBreak = false}) { if (child.leadingCombinators.isEmpty) { return ComplexSelector( - leadingCombinators, [...components, ...child.components], - lineBreak: lineBreak || child.lineBreak || forceLineBreak); - } else if (components.isEmpty) { - return ComplexSelector( - [...leadingCombinators, ...child.leadingCombinators], - child.components, + leadingCombinators, [...components, ...child.components], span, lineBreak: lineBreak || child.lineBreak || forceLineBreak); - } else { + } else if (components case [...var initial, var last]) { return ComplexSelector( leadingCombinators, [ - ...components.exceptLast, - components.last.withAdditionalCombinators(child.leadingCombinators), + ...initial, + last.withAdditionalCombinators(child.leadingCombinators), ...child.components ], + span, + lineBreak: lineBreak || child.lineBreak || forceLineBreak); + } else { + return ComplexSelector( + [...leadingCombinators, ...child.leadingCombinators], + child.components, + span, lineBreak: lineBreak || child.lineBreak || forceLineBreak); } } diff --git a/lib/src/ast/selector/complex_component.dart b/lib/src/ast/selector/complex_component.dart index 61bdd9330..70f6d8e42 100644 --- a/lib/src/ast/selector/complex_component.dart +++ b/lib/src/ast/selector/complex_component.dart @@ -3,8 +3,10 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../utils.dart'; +import '../css/value.dart'; import '../selector.dart'; /// A component of a [ComplexSelector]. @@ -12,8 +14,7 @@ import '../selector.dart'; /// This a [CompoundSelector] with one or more trailing [Combinator]s. /// /// {@category AST} -@sealed -class ComplexSelectorComponent { +final class ComplexSelectorComponent { /// This component's compound selector. final CompoundSelector selector; @@ -22,9 +23,12 @@ class ComplexSelectorComponent { /// If this is empty, that indicates that it has an implicit descendent /// combinator. If it's more than one element, that means it's invalid CSS; /// however, we still support this for backwards-compatibility purposes. - final List combinators; + final List> combinators; - ComplexSelectorComponent(this.selector, Iterable combinators) + final FileSpan span; + + ComplexSelectorComponent( + this.selector, Iterable> combinators, this.span) : combinators = List.unmodifiable(combinators); /// Returns a copy of `this` with [combinators] added to the end of @@ -33,11 +37,11 @@ class ComplexSelectorComponent { /// @nodoc @internal ComplexSelectorComponent withAdditionalCombinators( - List combinators) => + List> combinators) => combinators.isEmpty ? this : ComplexSelectorComponent( - selector, [...this.combinators, ...combinators]); + selector, [...this.combinators, ...combinators], span); int get hashCode => selector.hashCode ^ listHash(combinators); diff --git a/lib/src/ast/selector/compound.dart b/lib/src/ast/selector/compound.dart index 1c3905154..c36662cb0 100644 --- a/lib/src/ast/selector/compound.dart +++ b/lib/src/ast/selector/compound.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../extend/functions.dart'; import '../../logger.dart'; @@ -18,8 +19,7 @@ import '../selector.dart'; /// /// {@category AST} /// {@category Parsing} -@sealed -class CompoundSelector extends Selector { +final class CompoundSelector extends Selector { /// The components of this selector. /// /// This is never empty. @@ -43,8 +43,9 @@ class CompoundSelector extends Selector { SimpleSelector? get singleSimple => components.length == 1 ? components.first : null; - CompoundSelector(Iterable components) - : components = List.unmodifiable(components) { + CompoundSelector(Iterable components, FileSpan span) + : components = List.unmodifiable(components), + super(span) { if (this.components.isEmpty) { throw ArgumentError("components may not be empty."); } diff --git a/lib/src/ast/selector/id.dart b/lib/src/ast/selector/id.dart index 010bd2161..dc820fba3 100644 --- a/lib/src/ast/selector/id.dart +++ b/lib/src/ast/selector/id.dart @@ -5,6 +5,7 @@ import 'dart:math' as math; import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../visitor/interface/selector.dart'; import '../selector.dart'; @@ -14,20 +15,19 @@ import '../selector.dart'; /// This selects elements whose `id` attribute exactly matches the given name. /// /// {@category AST} -@sealed -class IDSelector extends SimpleSelector { +final class IDSelector extends SimpleSelector { /// The ID name this selects for. final String name; int get specificity => math.pow(super.specificity, 2) as int; - IDSelector(this.name); + IDSelector(this.name, FileSpan span) : super(span); T accept(SelectorVisitor visitor) => visitor.visitIDSelector(this); /// @nodoc @internal - IDSelector addSuffix(String suffix) => IDSelector(name + suffix); + IDSelector addSuffix(String suffix) => IDSelector(name + suffix, span); /// @nodoc @internal diff --git a/lib/src/ast/selector/list.dart b/lib/src/ast/selector/list.dart index f87a52daa..d432bbfaa 100644 --- a/lib/src/ast/selector/list.dart +++ b/lib/src/ast/selector/list.dart @@ -3,14 +3,20 @@ // 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 '../../exception.dart'; +import '../../util/iterable.dart'; +import '../../util/span.dart'; import '../../value.dart'; import '../../visitor/interface/selector.dart'; +import '../../visitor/selector_search.dart'; +import '../css/value.dart'; import '../selector.dart'; /// A selector list. @@ -20,17 +26,12 @@ import '../selector.dart'; /// /// {@category AST} /// {@category Parsing} -@sealed -class SelectorList extends Selector { +final class SelectorList extends Selector { /// The components of this selector. /// /// This is never empty. final List components; - /// Whether this contains a [ParentSelector]. - bool get _containsParentSelector => - components.any(_complexContainsParentSelector); - /// Returns a SassScript list that represents this selector. /// /// This has the same format as a list returned by `selector-parse()`. @@ -48,8 +49,9 @@ class SelectorList extends Selector { }), ListSeparator.comma); } - SelectorList(Iterable components) - : components = List.unmodifiable(components) { + SelectorList(Iterable components, FileSpan span) + : components = List.unmodifiable(components), + super(span) { if (this.components.isEmpty) { throw ArgumentError("components may not be empty."); } @@ -61,15 +63,20 @@ class SelectorList extends Selector { /// [allowParent] and [allowPlaceholder] control whether [ParentSelector]s or /// [PlaceholderSelector]s are allowed in this selector, respectively. /// + /// If passed, [interpolationMap] maps the text of [contents] back to the + /// original location of the selector in the source file. + /// /// Throws a [SassFormatException] if parsing fails. factory SelectorList.parse(String contents, {Object? url, Logger? logger, + InterpolationMap? interpolationMap, bool allowParent = true, bool allowPlaceholder = true}) => SelectorParser(contents, url: url, logger: logger, + interpolationMap: interpolationMap, allowParent: allowParent, allowPlaceholder: allowPlaceholder) .parse(); @@ -84,10 +91,10 @@ class SelectorList extends Selector { var contents = [ for (var complex1 in components) for (var complex2 in other.components) - ...?unifyComplex([complex1, complex2]) + ...?unifyComplex([complex1, complex2], complex1.span) ]; - return contents.isEmpty ? null : SelectorList(contents); + return contents.isEmpty ? null : SelectorList(contents, span); } /// Returns a new list with all [ParentSelector]s replaced with [parent]. @@ -101,16 +108,18 @@ class SelectorList extends Selector { SelectorList resolveParentSelectors(SelectorList? parent, {bool implicitParent = true}) { if (parent == null) { - if (!_containsParentSelector) return this; - throw SassScriptException( - 'Top-level selectors may not contain the parent selector "&".'); + var parentSelector = accept(const _ParentSelectorVisitor()); + if (parentSelector == null) return this; + throw SassException( + 'Top-level selectors may not contain the parent selector "&".', + parentSelector.span); } return SelectorList(flattenVertically(components.map((complex) { - if (!_complexContainsParentSelector(complex)) { + if (!_containsParentSelector(complex)) { if (!implicitParent) return [complex]; - return parent.components - .map((parentComplex) => parentComplex.concatenate(complex)); + return parent.components.map((parentComplex) => + parentComplex.concatenate(complex, complex.span)); } var newComplexes = []; @@ -119,40 +128,41 @@ class SelectorList extends Selector { if (resolved == null) { if (newComplexes.isEmpty) { newComplexes.add(ComplexSelector( - complex.leadingCombinators, [component], + complex.leadingCombinators, [component], complex.span, lineBreak: false)); } else { for (var i = 0; i < newComplexes.length; i++) { - newComplexes[i] = - newComplexes[i].withAdditionalComponent(component); + newComplexes[i] = newComplexes[i] + .withAdditionalComponent(component, complex.span); } } } else if (newComplexes.isEmpty) { - newComplexes.addAll(resolved); + newComplexes.addAll(complex.leadingCombinators.isEmpty + ? resolved + : resolved.map((resolvedComplex) => ComplexSelector( + resolvedComplex.leadingCombinators.isEmpty + ? complex.leadingCombinators + : [ + ...complex.leadingCombinators, + ...resolvedComplex.leadingCombinators + ], + resolvedComplex.components, + complex.span, + lineBreak: resolvedComplex.lineBreak))); } else { var previousComplexes = newComplexes; newComplexes = [ for (var newComplex in previousComplexes) for (var resolvedComplex in resolved) - newComplex.concatenate(resolvedComplex) + newComplex.concatenate(resolvedComplex, newComplex.span) ]; } } return newComplexes; - }))); + })), span); } - /// Returns whether [complex] contains a [ParentSelector]. - bool _complexContainsParentSelector(ComplexSelector complex) => - complex.components - .any((component) => component.selector.components.any((simple) { - if (simple is ParentSelector) return true; - if (simple is! PseudoSelector) return false; - var selector = simple.selector; - return selector != null && selector._containsParentSelector; - })); - /// Returns a new selector list based on [component] with all /// [ParentSelector]s replaced with [parent]. /// @@ -163,59 +173,84 @@ class SelectorList extends Selector { var containsSelectorPseudo = simples.any((simple) { if (simple is! PseudoSelector) return false; var selector = simple.selector; - return selector != null && selector._containsParentSelector; + return selector != null && _containsParentSelector(selector); }); if (!containsSelectorPseudo && simples.first is! ParentSelector) { return null; } var resolvedSimples = containsSelectorPseudo - ? simples.map((simple) { - if (simple is! PseudoSelector) return simple; - var selector = simple.selector; - if (selector == null) return simple; - if (!selector._containsParentSelector) return simple; - return simple.withSelector( - selector.resolveParentSelectors(parent, implicitParent: false)); - }) + ? simples.map((simple) => switch (simple) { + PseudoSelector(:var selector?) + when _containsParentSelector(selector) => + simple.withSelector(selector.resolveParentSelectors(parent, + implicitParent: false)), + _ => simple + }) : simples; var parentSelector = simples.first; - if (parentSelector is! ParentSelector) { - return [ - ComplexSelector(const [], [ - ComplexSelectorComponent( - CompoundSelector(resolvedSimples), component.combinators) - ]) - ]; - } else if (simples.length == 1 && parentSelector.suffix == null) { - return parent.withAdditionalCombinators(component.combinators).components; + try { + if (parentSelector is! ParentSelector) { + return [ + ComplexSelector(const [], [ + ComplexSelectorComponent( + CompoundSelector(resolvedSimples, component.selector.span), + component.combinators, + component.span) + ], component.span) + ]; + } else if (simples.length == 1 && parentSelector.suffix == null) { + return parent + .withAdditionalCombinators(component.combinators) + .components; + } + } on SassException catch (error, stackTrace) { + throwWithTrace( + error.withAdditionalSpan(parentSelector.span, "parent selector"), + error, + stackTrace); } return parent.components.map((complex) { - var lastComponent = complex.components.last; - if (lastComponent.combinators.isNotEmpty) { - throw SassScriptException( - 'Parent "$complex" is incompatible with this selector.'); - } + try { + var lastComponent = complex.components.last; + if (lastComponent.combinators.isNotEmpty) { + throw MultiSpanSassException( + 'Selector "$complex" can\'t be used as a parent in a compound ' + 'selector.', + lastComponent.span.trimRight(), + "outer selector", + {parentSelector.span: "parent selector"}); + } + + var suffix = parentSelector.suffix; + var lastSimples = lastComponent.selector.components; + var last = CompoundSelector( + suffix == null + ? [...lastSimples, ...resolvedSimples.skip(1)] + : [ + ...lastSimples.exceptLast, + lastSimples.last.addSuffix(suffix), + ...resolvedSimples.skip(1) + ], + component.selector.span); - var suffix = parentSelector.suffix; - var lastSimples = lastComponent.selector.components; - var last = CompoundSelector(suffix == null - ? [...lastSimples, ...resolvedSimples.skip(1)] - : [ - ...lastSimples.exceptLast, - lastSimples.last.addSuffix(suffix), - ...resolvedSimples.skip(1) - ]); - - return ComplexSelector( - complex.leadingCombinators, - [ - ...complex.components.exceptLast, - ComplexSelectorComponent(last, component.combinators) - ], - lineBreak: complex.lineBreak); + return ComplexSelector( + complex.leadingCombinators, + [ + ...complex.components.exceptLast, + ComplexSelectorComponent( + last, component.combinators, component.span) + ], + component.span, + lineBreak: complex.lineBreak); + } on SassException catch (error, stackTrace) { + throwWithTrace( + error.withAdditionalSpan(parentSelector.span, "parent selector"), + error, + stackTrace); + } }); } @@ -229,14 +264,28 @@ class SelectorList extends Selector { /// Returns a copy of `this` with [combinators] added to the end of each /// complex selector in [components]. @internal - SelectorList withAdditionalCombinators(List combinators) => + SelectorList withAdditionalCombinators( + List> combinators) => combinators.isEmpty ? this - : SelectorList(components.map( - (complex) => complex.withAdditionalCombinators(combinators))); + : SelectorList( + components.map( + (complex) => complex.withAdditionalCombinators(combinators)), + span); int get hashCode => listHash(components); bool operator ==(Object other) => other is SelectorList && listEquals(components, other.components); } + +/// Returns whether [selector] recursively contains a parent selector. +bool _containsParentSelector(Selector selector) => + selector.accept(const _ParentSelectorVisitor()) != null; + +/// A visitor for finding the first [ParentSelector] in a given selector. +class _ParentSelectorVisitor with SelectorSearchVisitor { + const _ParentSelectorVisitor(); + + ParentSelector visitParentSelector(ParentSelector selector) => selector; +} diff --git a/lib/src/ast/selector/parent.dart b/lib/src/ast/selector/parent.dart index 461d5e480..18e898652 100644 --- a/lib/src/ast/selector/parent.dart +++ b/lib/src/ast/selector/parent.dart @@ -3,6 +3,7 @@ // 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'; @@ -13,8 +14,7 @@ import '../selector.dart'; /// document. /// /// {@category AST} -@sealed -class ParentSelector extends SimpleSelector { +final class ParentSelector extends SimpleSelector { /// The suffix that will be added to the parent selector after it's been /// resolved. /// @@ -22,7 +22,7 @@ class ParentSelector extends SimpleSelector { /// indicating that the parent selector will not be modified. final String? suffix; - ParentSelector({this.suffix}); + ParentSelector(FileSpan span, {this.suffix}) : super(span); T accept(SelectorVisitor visitor) => visitor.visitParentSelector(this); diff --git a/lib/src/ast/selector/placeholder.dart b/lib/src/ast/selector/placeholder.dart index a7b935322..a99005d21 100644 --- a/lib/src/ast/selector/placeholder.dart +++ b/lib/src/ast/selector/placeholder.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../util/character.dart' as character; import '../../visitor/interface/selector.dart'; @@ -15,8 +16,7 @@ import '../selector.dart'; /// emitting a CSS document. /// /// {@category AST} -@sealed -class PlaceholderSelector extends SimpleSelector { +final class PlaceholderSelector extends SimpleSelector { /// The name of the placeholder. final String name; @@ -24,7 +24,7 @@ class PlaceholderSelector extends SimpleSelector { /// with `-` or `_`). bool get isPrivate => character.isPrivate(name); - PlaceholderSelector(this.name); + PlaceholderSelector(this.name, FileSpan span) : super(span); T accept(SelectorVisitor visitor) => visitor.visitPlaceholderSelector(this); @@ -32,7 +32,7 @@ class PlaceholderSelector extends SimpleSelector { /// @nodoc @internal PlaceholderSelector addSuffix(String suffix) => - PlaceholderSelector(name + suffix); + PlaceholderSelector(name + suffix, span); bool operator ==(Object other) => other is PlaceholderSelector && other.name == name; diff --git a/lib/src/ast/selector/pseudo.dart b/lib/src/ast/selector/pseudo.dart index 7840eccab..44a263d15 100644 --- a/lib/src/ast/selector/pseudo.dart +++ b/lib/src/ast/selector/pseudo.dart @@ -5,6 +5,7 @@ import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../utils.dart'; import '../../util/nullable.dart'; @@ -19,8 +20,7 @@ import '../selector.dart'; /// ensure that extension and other selector operations work properly. /// /// {@category AST} -@sealed -class PseudoSelector extends SimpleSelector { +final class PseudoSelector extends SimpleSelector { /// The name of this selector. final String name; @@ -104,11 +104,12 @@ class PseudoSelector extends SimpleSelector { } }(); - PseudoSelector(this.name, + PseudoSelector(this.name, FileSpan span, {bool element = false, this.argument, this.selector}) : isClass = !element && !_isFakePseudoElement(name), isSyntacticClass = !element, - normalizedName = unvendor(name); + normalizedName = unvendor(name), + super(span); /// Returns whether [name] is the name of a pseudo-element that can be written /// with pseudo-class syntax (`:before`, `:after`, `:first-line`, or @@ -135,14 +136,15 @@ class PseudoSelector extends SimpleSelector { /// Returns a new [PseudoSelector] based on this, but with the selector /// replaced with [selector]. - PseudoSelector withSelector(SelectorList selector) => PseudoSelector(name, - element: isElement, argument: argument, selector: selector); + PseudoSelector withSelector(SelectorList selector) => + PseudoSelector(name, span, + element: isElement, argument: argument, selector: selector); /// @nodoc @internal PseudoSelector addSuffix(String suffix) { if (argument != null || selector != null) super.addSuffix(suffix); - return PseudoSelector(name + suffix, element: isElement); + return PseudoSelector(name + suffix, span, element: isElement); } /// @nodoc @@ -154,12 +156,11 @@ class PseudoSelector extends SimpleSelector { (simple.isHost || simple.selector != null))) { return null; } - } else if (compound.length == 1) { - var other = compound.first; - if (other is UniversalSelector || - (other is PseudoSelector && (other.isHost || other.isHostContext))) { - return other.unify([this]); - } + } else if (compound case [var other] + when other is UniversalSelector || + (other is PseudoSelector && + (other.isHost || other.isHostContext))) { + return other.unify([this]); } if (compound.contains(this)) return compound; @@ -167,7 +168,7 @@ class PseudoSelector extends SimpleSelector { var result = []; var addedThis = false; for (var simple in compound) { - if (simple is PseudoSelector && simple.isElement) { + 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. if (isElement) return null; @@ -200,7 +201,8 @@ class PseudoSelector extends SimpleSelector { // Fall back to the logic defined in functions.dart, which knows how to // compare selector pseudoclasses against raw selectors. - return CompoundSelector([this]).isSuperselector(CompoundSelector([other])); + return CompoundSelector([this], span) + .isSuperselector(CompoundSelector([other], span)); } T accept(SelectorVisitor visitor) => visitor.visitPseudoSelector(this); diff --git a/lib/src/ast/selector/qualified_name.dart b/lib/src/ast/selector/qualified_name.dart index 05bb4a084..6a594a2c7 100644 --- a/lib/src/ast/selector/qualified_name.dart +++ b/lib/src/ast/selector/qualified_name.dart @@ -2,15 +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'; - /// A [qualified name]. /// /// [qualified name]: https://www.w3.org/TR/css3-namespace/#css-qnames /// /// {@category AST} -@sealed -class QualifiedName { +final class QualifiedName { /// The identifier name. final String name; diff --git a/lib/src/ast/selector/simple.dart b/lib/src/ast/selector/simple.dart index 599b43c8e..0526eed72 100644 --- a/lib/src/ast/selector/simple.dart +++ b/lib/src/ast/selector/simple.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; +import 'package:source_span/source_span.dart'; import '../../exception.dart'; import '../../logger.dart'; @@ -26,7 +27,7 @@ final _subselectorPseudos = { /// /// {@category AST} /// {@category Parsing} -abstract class SimpleSelector extends Selector { +abstract base class SimpleSelector extends Selector { /// This selector's specificity. /// /// Specificity is represented in base 1000. The spec says this should be @@ -34,7 +35,7 @@ abstract class SimpleSelector extends Selector { /// sequence will contain 1000 simple selectors. int get specificity => 1000; - SimpleSelector(); + SimpleSelector(FileSpan span) : super(span); /// Parses a simple selector from [contents]. /// @@ -57,8 +58,8 @@ abstract class SimpleSelector extends Selector { /// /// @nodoc @internal - SimpleSelector addSuffix(String suffix) => - throw SassScriptException('Invalid parent selector "$this"'); + SimpleSelector addSuffix(String suffix) => throw MultiSpanSassException( + 'Selector "$this" can\'t have a suffix', span, "outer selector", {}); /// Returns the components of a [CompoundSelector] that matches only elements /// matched by both this and [compound]. @@ -73,12 +74,11 @@ abstract class SimpleSelector extends Selector { /// @nodoc @internal List? unify(List compound) { - if (compound.length == 1) { - var other = compound.first; - if (other is UniversalSelector || - (other is PseudoSelector && (other.isHost || other.isHostContext))) { - return other.unify([this]); - } + if (compound case [var other] + when other is UniversalSelector || + (other is PseudoSelector && + (other.isHost || other.isHostContext))) { + return other.unify([this]); } if (compound.contains(this)) return compound; diff --git a/lib/src/ast/selector/type.dart b/lib/src/ast/selector/type.dart index 0430de768..d65f94a0c 100644 --- a/lib/src/ast/selector/type.dart +++ b/lib/src/ast/selector/type.dart @@ -3,6 +3,7 @@ // 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'; @@ -13,26 +14,25 @@ import '../selector.dart'; /// This selects elements whose name equals the given name. /// /// {@category AST} -@sealed -class TypeSelector extends SimpleSelector { +final class TypeSelector extends SimpleSelector { /// The element name being selected. final QualifiedName name; int get specificity => 1; - TypeSelector(this.name); + TypeSelector(this.name, FileSpan span) : super(span); T accept(SelectorVisitor visitor) => visitor.visitTypeSelector(this); /// @nodoc @internal TypeSelector addSuffix(String suffix) => TypeSelector( - QualifiedName(name.name + suffix, namespace: name.namespace)); + QualifiedName(name.name + suffix, namespace: name.namespace), span); /// @nodoc @internal List? unify(List compound) { - if (compound.first is UniversalSelector || compound.first is TypeSelector) { + if (compound.first 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 2937a78f6..d714dcb6a 100644 --- a/lib/src/ast/selector/universal.dart +++ b/lib/src/ast/selector/universal.dart @@ -3,6 +3,7 @@ // 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'; @@ -11,8 +12,7 @@ import '../selector.dart'; /// Matches any element in the given namespace. /// /// {@category AST} -@sealed -class UniversalSelector extends SimpleSelector { +final class UniversalSelector extends SimpleSelector { /// The selector namespace. /// /// If this is `null`, this matches all elements in the default namespace. If @@ -23,7 +23,7 @@ class UniversalSelector extends SimpleSelector { int get specificity => 0; - UniversalSelector({this.namespace}); + UniversalSelector(FileSpan span, {this.namespace}) : super(span); T accept(SelectorVisitor visitor) => visitor.visitUniversalSelector(this); @@ -31,20 +31,23 @@ class UniversalSelector extends SimpleSelector { /// @nodoc @internal List? unify(List compound) { - var first = compound.first; - if (first is UniversalSelector || first is TypeSelector) { - var unified = unifyUniversalAndElement(this, first); - if (unified == null) return null; - return [unified, ...compound.skip(1)]; - } else if (compound.length == 1 && - first is PseudoSelector && - (first.isHost || first.isHostContext)) { - return null; - } + switch (compound) { + case [UniversalSelector() || TypeSelector(), ...var rest]: + var unified = unifyUniversalAndElement(this, compound.first); + if (unified == null) return null; + return [unified, ...rest]; + + case [PseudoSelector first] when first.isHost || first.isHostContext: + return null; - if (namespace != null && namespace != "*") return [this, ...compound]; - if (compound.isNotEmpty) return compound; - return [this]; + case []: + return [this]; + + case _: + return namespace == null || namespace == "*" + ? compound + : [this, ...compound]; + } } bool isSuperselector(SimpleSelector other) { diff --git a/lib/src/async_compile.dart b/lib/src/async_compile.dart index d01dfcc40..0d95a5dd7 100644 --- a/lib/src/async_compile.dart +++ b/lib/src/async_compile.dart @@ -10,11 +10,13 @@ import 'ast/sass.dart'; import 'async_import_cache.dart'; import 'callable.dart'; import 'compile_result.dart'; +import 'deprecation.dart'; import 'importer.dart'; import 'importer/legacy_node.dart'; +import 'importer/no_op.dart'; import 'io.dart'; import 'logger.dart'; -import 'logger/terse.dart'; +import 'logger/deprecation_handling.dart'; import 'syntax.dart'; import 'utils.dart'; import 'visitor/async_evaluate.dart'; @@ -37,9 +39,14 @@ Future compileAsync(String path, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) async { - TerseLogger? terseLogger; - if (!verbose) logger = terseLogger = TerseLogger(logger ?? Logger.stderr()); + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) async { + DeprecationHandlingLogger deprecationLogger = logger = + DeprecationHandlingLogger(logger ?? Logger.stderr(), + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: !verbose); // 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. @@ -71,7 +78,7 @@ Future compileAsync(String path, sourceMap, charset); - terseLogger?.summarize(node: nodeImporter != null); + deprecationLogger.summarize(js: nodeImporter != null); return result; } @@ -96,9 +103,14 @@ Future compileStringAsync(String source, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) async { - TerseLogger? terseLogger; - if (!verbose) logger = terseLogger = TerseLogger(logger ?? Logger.stderr()); + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) async { + DeprecationHandlingLogger deprecationLogger = logger = + DeprecationHandlingLogger(logger ?? Logger.stderr(), + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: !verbose); var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); @@ -108,7 +120,7 @@ Future compileStringAsync(String source, logger, importCache, nodeImporter, - importer ?? FilesystemImporter('.'), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), functions, style, useSpaces, @@ -118,7 +130,7 @@ Future compileStringAsync(String source, sourceMap, charset); - terseLogger?.summarize(node: nodeImporter != null); + deprecationLogger.summarize(js: nodeImporter != null); return result; } @@ -158,9 +170,7 @@ Future _compileStylesheet( var resultSourceMap = serializeResult.sourceMap; if (resultSourceMap != null && importCache != null) { - // TODO(nweiz): Don't explicitly use a type parameter when dart-lang/sdk#25490 - // is fixed. - mapInPlace( + mapInPlace( resultSourceMap.urls, (url) => url == '' ? Uri.dataFromString(stylesheet.span.file.getText(0), diff --git a/lib/src/async_environment.dart b/lib/src/async_environment.dart index e6865b0ad..96cbecc18 100644 --- a/lib/src/async_environment.dart +++ b/lib/src/async_environment.dart @@ -17,6 +17,7 @@ import 'extend/extension_store.dart'; import 'module.dart'; import 'module/forwarded_view.dart'; import 'module/shadowed_view.dart'; +import 'util/map.dart'; import 'util/merged_map_view.dart'; import 'util/nullable.dart'; import 'util/public_member_map_view.dart'; @@ -33,7 +34,7 @@ import 'visitor/clone_css.dart'; /// /// This tracks lexically-scoped information, such as variables, functions, and /// mixins. -class AsyncEnvironment { +final class AsyncEnvironment { /// The modules used in the current scope, indexed by their namespaces. Map get modules => UnmodifiableMapView(_modules); final Map _modules; @@ -235,12 +236,11 @@ class AsyncEnvironment { _globalModules[module] = nodeWithSpan; _allModules.add(module); - for (var name in _variables.first.keys) { - if (module.variables.containsKey(name)) { - throw SassScriptException( - 'This module and the new module both define a variable named ' - '"\$$name".'); - } + if (_variables.first.keys.firstWhereOrNull(module.variables.containsKey) + case var name?) { + throw SassScriptException( + 'This module and the new module both define a variable named ' + '"\$$name".'); } } else { if (_modules.containsKey(namespace)) { @@ -299,11 +299,12 @@ class AsyncEnvironment { larger = newMembers; } - for (var name in smaller.keys) { - if (!larger.containsKey(name)) continue; + for (var (name, small) in smaller.pairs) { + var large = larger[name]; + if (large == null) continue; if (type == "variable" ? newModule.variableIdentity(name) == oldModule.variableIdentity(name) - : larger[name] == smaller[name]) { + : large == small) { continue; } @@ -321,82 +322,82 @@ class AsyncEnvironment { /// /// This is called when [module] is `@import`ed. void importForwards(Module module) { - if (module is _EnvironmentModule) { - var forwarded = module._environment._forwardedModules; - if (forwarded == null) return; - - // Omit modules from [forwarded] that are already globally available and - // forwarded in this module. - var forwardedModules = _forwardedModules; - if (forwardedModules != null) { - forwarded = { - for (var entry in forwarded.entries) - if (!forwardedModules.containsKey(entry.key) || - !_globalModules.containsKey(entry.key)) - entry.key: entry.value, - }; - } else { - forwardedModules = _forwardedModules ??= {}; - } + if (module is! _EnvironmentModule) return; + var forwarded = module._environment._forwardedModules; + if (forwarded == null) return; + + // Omit modules from [forwarded] that are already globally available and + // forwarded in this module. + var forwardedModules = _forwardedModules; + if (forwardedModules != null) { + forwarded = { + for (var (module, node) in forwarded.pairs) + if (!forwardedModules.containsKey(module) || + !_globalModules.containsKey(module)) + module: node, + }; + } else { + forwardedModules = _forwardedModules ??= {}; + } - var forwardedVariableNames = - forwarded.keys.expand((module) => module.variables.keys).toSet(); - var forwardedFunctionNames = - forwarded.keys.expand((module) => module.functions.keys).toSet(); - var forwardedMixinNames = - forwarded.keys.expand((module) => module.mixins.keys).toSet(); - - if (atRoot) { - // Hide members from modules that have already been imported or - // forwarded that would otherwise conflict with the @imported members. - for (var entry in _importedModules.entries.toList()) { - var module = entry.key; - var shadowed = ShadowedModuleView.ifNecessary(module, - variables: forwardedVariableNames, - mixins: forwardedMixinNames, - functions: forwardedFunctionNames); - if (shadowed != null) { - _importedModules.remove(module); - if (!shadowed.isEmpty) _importedModules[shadowed] = entry.value; - } + var forwardedVariableNames = { + for (var module in forwarded.keys) ...module.variables.keys + }; + var forwardedFunctionNames = { + for (var module in forwarded.keys) ...module.functions.keys + }; + var forwardedMixinNames = { + for (var module in forwarded.keys) ...module.mixins.keys + }; + + if (atRoot) { + // Hide members from modules that have already been imported or + // forwarded that would otherwise conflict with the @imported members. + for (var (module, node) in _importedModules.pairs.toList()) { + var shadowed = ShadowedModuleView.ifNecessary(module, + variables: forwardedVariableNames, + mixins: forwardedMixinNames, + functions: forwardedFunctionNames); + if (shadowed != null) { + _importedModules.remove(module); + if (!shadowed.isEmpty) _importedModules[shadowed] = node; } + } - for (var entry in forwardedModules.entries.toList()) { - var module = entry.key; - var shadowed = ShadowedModuleView.ifNecessary(module, - variables: forwardedVariableNames, - mixins: forwardedMixinNames, - functions: forwardedFunctionNames); - if (shadowed != null) { - forwardedModules.remove(module); - if (!shadowed.isEmpty) forwardedModules[shadowed] = entry.value; - } + for (var (module, node) in forwardedModules.pairs.toList()) { + var shadowed = ShadowedModuleView.ifNecessary(module, + variables: forwardedVariableNames, + mixins: forwardedMixinNames, + functions: forwardedFunctionNames); + if (shadowed != null) { + forwardedModules.remove(module); + if (!shadowed.isEmpty) forwardedModules[shadowed] = node; } - - _importedModules.addAll(forwarded); - forwardedModules.addAll(forwarded); - } else { - (_nestedForwardedModules ??= - List.generate(_variables.length - 1, (_) => [])) - .last - .addAll(forwarded.keys); } - // Remove existing member definitions that are now shadowed by the - // forwarded modules. - for (var variable in forwardedVariableNames) { - _variableIndices.remove(variable); - _variables.last.remove(variable); - _variableNodes.last.remove(variable); - } - for (var function in forwardedFunctionNames) { - _functionIndices.remove(function); - _functions.last.remove(function); - } - for (var mixin in forwardedMixinNames) { - _mixinIndices.remove(mixin); - _mixins.last.remove(mixin); - } + _importedModules.addAll(forwarded); + forwardedModules.addAll(forwarded); + } else { + (_nestedForwardedModules ??= + List.generate(_variables.length - 1, (_) => [])) + .last + .addAll(forwarded.keys); + } + + // Remove existing member definitions that are now shadowed by the + // forwarded modules. + for (var variable in forwardedVariableNames) { + _variableIndices.remove(variable); + _variables.last.remove(variable); + _variableNodes.last.remove(variable); + } + for (var function in forwardedFunctionNames) { + _functionIndices.remove(function); + _functions.last.remove(function); + } + for (var mixin in forwardedMixinNames) { + _mixinIndices.remove(mixin); + _mixins.last.remove(mixin); } } @@ -413,25 +414,21 @@ class AsyncEnvironment { _getVariableFromGlobalModule(name); } - var index = _variableIndices[name]; - if (index != null) { + if (_variableIndices[name] case var index?) { _lastVariableName = name; _lastVariableIndex = index; return _variables[index][name] ?? _getVariableFromGlobalModule(name); - } - - index = _variableIndex(name); - if (index == null) { + } else if (_variableIndex(name) case var index?) { + _lastVariableName = name; + _lastVariableIndex = index; + _variableIndices[name] = index; + return _variables[index][name] ?? _getVariableFromGlobalModule(name); + } else { // There isn't a real variable defined as this index, but it will cause // [getVariable] to short-circuit and get to this function faster next // time the variable is accessed. return _getVariableFromGlobalModule(name); } - - _lastVariableName = name; - _lastVariableIndex = index; - _variableIndices[name] = index; - return _variables[index][name] ?? _getVariableFromGlobalModule(name); } /// Returns the value of the variable named [name] from a namespaceless @@ -456,22 +453,20 @@ class AsyncEnvironment { _getVariableNodeFromGlobalModule(name); } - var index = _variableIndices[name]; - if (index != null) { + if (_variableIndices[name] case var index?) { _lastVariableName = name; _lastVariableIndex = index; return _variableNodes[index][name] ?? _getVariableNodeFromGlobalModule(name); + } else if (_variableIndex(name) case var index?) { + _lastVariableName = name; + _lastVariableIndex = index; + _variableIndices[name] = index; + return _variableNodes[index][name] ?? + _getVariableNodeFromGlobalModule(name); + } else { + return _getVariableNodeFromGlobalModule(name); } - - index = _variableIndex(name); - if (index == null) return _getVariableNodeFromGlobalModule(name); - - _lastVariableName = name; - _lastVariableIndex = index; - _variableIndices[name] = index; - return _variableNodes[index][name] ?? - _getVariableNodeFromGlobalModule(name); } /// Returns the node for the variable named [name] from a namespaceless @@ -486,8 +481,7 @@ class AsyncEnvironment { // We don't need to worry about multiple modules defining the same variable, // because that's already been checked by [getVariable]. for (var module in _importedModules.keys.followedBy(_globalModules.keys)) { - var value = module.variableNodes[name]; - if (value != null) return value; + if (module.variableNodes[name] case var value?) return value; } return null; } @@ -621,16 +615,14 @@ class AsyncEnvironment { AsyncCallable? getFunction(String name, {String? namespace}) { if (namespace != null) return _getModule(namespace).functions[name]; - var index = _functionIndices[name]; - if (index != null) { + if (_functionIndices[name] case var index?) { + return _functions[index][name] ?? _getFunctionFromGlobalModule(name); + } else if (_functionIndex(name) case var index?) { + _functionIndices[name] = index; return _functions[index][name] ?? _getFunctionFromGlobalModule(name); + } else { + return _getFunctionFromGlobalModule(name); } - - index = _functionIndex(name); - if (index == null) return _getFunctionFromGlobalModule(name); - - _functionIndices[name] = index; - return _functions[index][name] ?? _getFunctionFromGlobalModule(name); } /// Returns the value of the function named [name] from a namespaceless @@ -670,16 +662,14 @@ class AsyncEnvironment { AsyncCallable? getMixin(String name, {String? namespace}) { if (namespace != null) return _getModule(namespace).mixins[name]; - var index = _mixinIndices[name]; - if (index != null) { + if (_mixinIndices[name] case var index?) { + return _mixins[index][name] ?? _getMixinFromGlobalModule(name); + } else if (_mixinIndex(name) case var index?) { + _mixinIndices[name] = index; return _mixins[index][name] ?? _getMixinFromGlobalModule(name); + } else { + return _getMixinFromGlobalModule(name); } - - index = _mixinIndex(name); - if (index == null) return _getMixinFromGlobalModule(name); - - _mixinIndices[name] = index; - return _mixins[index][name] ?? _getMixinFromGlobalModule(name); } /// Returns the value of the mixin named [name] from a namespaceless @@ -791,22 +781,24 @@ class AsyncEnvironment { for (var i = 0; i < _variables.length; i++) { var values = _variables[i]; var nodes = _variableNodes[i]; - for (var entry in values.entries) { + for (var (name, value) in values.pairs) { // Implicit configurations are never invalid, making [configurationSpan] // unnecessary, so we pass null here to avoid having to compute it. - configuration[entry.key] = - ConfiguredValue.implicit(entry.value, nodes[entry.key]!); + configuration[name] = ConfiguredValue.implicit(value, nodes[name]!); } } return Configuration.implicit(configuration); } /// Returns a module that represents the top-level members defined in [this], - /// that contains [css] as its CSS tree, which can be extended using - /// [extensionStore]. - Module toModule(CssStylesheet css, ExtensionStore extensionStore) { + /// that contains [css] and [preModuleComments] as its CSS, which can be + /// extended using [extensionStore]. + Module toModule( + CssStylesheet css, + Map> preModuleComments, + ExtensionStore extensionStore) { assert(atRoot); - return _EnvironmentModule(this, css, extensionStore, + return _EnvironmentModule(this, css, preModuleComments, extensionStore, forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } @@ -816,21 +808,18 @@ class AsyncEnvironment { /// This is used when resolving imports, since they need to inject forwarded /// members into the current scope. It's the only situation in which a nested /// environment can become a module. - Module toDummyModule() { - return _EnvironmentModule( - this, - CssStylesheet(const [], - SourceFile.decoded(const [], url: "").span(0)), - ExtensionStore.empty, - forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); - } + Module toDummyModule() => _EnvironmentModule( + this, + CssStylesheet(const [], + SourceFile.decoded(const [], url: "").span(0)), + const {}, + ExtensionStore.empty, + forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); /// Returns the module with the given [namespace], or throws a /// [SassScriptException] if none exists. Module _getModule(String namespace) { - var module = _modules[namespace]; - if (module != null) return module; - + if (_modules[namespace] case var module?) return module; throw SassScriptException( 'There is no module with the namespace "$namespace".'); } @@ -847,18 +836,15 @@ class AsyncEnvironment { /// The [type] should be the singular name of the value type being returned. /// It's used to format an appropriate error message. T? _fromOneModule(String name, String type, T? callback(Module module)) { - var nestedForwardedModules = _nestedForwardedModules; - if (nestedForwardedModules != null) { + if (_nestedForwardedModules case var nestedForwardedModules?) { for (var modules in nestedForwardedModules.reversed) { for (var module in modules.reversed) { - var value = callback(module); - if (value != null) return value; + if (callback(module) case var value?) return value; } } } for (var module in _importedModules.keys) { - var value = callback(module); - if (value != null) return value; + if (callback(module) case var value?) return value; } T? value; @@ -873,14 +859,11 @@ class AsyncEnvironment { if (identityFromModule == identity) continue; if (value != null) { - var spans = _globalModules.entries.map( - (entry) => callback(entry.key).andThen((_) => entry.value.span)); - throw MultiSpanSassScriptException( 'This $type is available from multiple global modules.', '$type use', { - for (var span in spans) - if (span != null) span: 'includes $type' + for (var (module, node) in _globalModules.pairs) + if (callback(module) != null) node.span: 'includes $type' }); } @@ -892,7 +875,7 @@ class AsyncEnvironment { } /// A module that represents the top-level members defined in an [Environment]. -class _EnvironmentModule implements Module { +final class _EnvironmentModule implements Module { Uri? get url => css.span.sourceUrl; final List upstream; @@ -902,6 +885,7 @@ class _EnvironmentModule implements Module { final Map mixins; final ExtensionStore extensionStore; final CssStylesheet css; + final Map> preModuleComments; final bool transitivelyContainsCss; final bool transitivelyContainsExtensions; @@ -916,13 +900,20 @@ class _EnvironmentModule implements Module { /// defined at all. final Map _modulesByVariable; - factory _EnvironmentModule(AsyncEnvironment environment, CssStylesheet css, + factory _EnvironmentModule( + AsyncEnvironment environment, + CssStylesheet css, + Map> preModuleComments, ExtensionStore extensionStore, {Set? forwarded}) { forwarded ??= const {}; return _EnvironmentModule._( environment, css, + Map.unmodifiable({ + for (var (module, comments) in preModuleComments.pairs) + module: List.unmodifiable(comments) + }), extensionStore, _makeModulesByVariable(forwarded), _memberMap(environment._variables.first, @@ -934,6 +925,7 @@ class _EnvironmentModule implements Module { _memberMap(environment._mixins.first, forwarded.map((module) => module.mixins)), transitivelyContainsCss: css.children.isNotEmpty || + preModuleComments.isNotEmpty || environment._allModules .any((module) => module.transitivelyContainsCss), transitivelyContainsExtensions: !extensionStore.isEmpty || @@ -981,6 +973,7 @@ class _EnvironmentModule implements Module { _EnvironmentModule._( this._environment, this.css, + this.preModuleComments, this.extensionStore, this._modulesByVariable, this.variables, @@ -992,8 +985,7 @@ class _EnvironmentModule implements Module { : upstream = _environment._allModules; void setVariable(String name, Value value, AstNode nodeWithSpan) { - var module = _modulesByVariable[name]; - if (module != null) { + if (_modulesByVariable[name] case var module?) { module.setVariable(name, value, nodeWithSpan); return; } @@ -1016,11 +1008,13 @@ class _EnvironmentModule implements Module { Module cloneCss() { if (!transitivelyContainsCss) return this; - var newCssAndExtensionStore = cloneCssStylesheet(css, extensionStore); + var (newStylesheet, newExtensionStore) = + cloneCssStylesheet(css, extensionStore); return _EnvironmentModule._( _environment, - newCssAndExtensionStore.item1, - newCssAndExtensionStore.item2, + newStylesheet, + preModuleComments, + newExtensionStore, _modulesByVariable, variables, variableNodes, diff --git a/lib/src/async_import_cache.dart b/lib/src/async_import_cache.dart index 7139b3b75..c67f77081 100644 --- a/lib/src/async_import_cache.dart +++ b/lib/src/async_import_cache.dart @@ -6,20 +6,31 @@ 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 'package:tuple/tuple.dart'; import 'ast/sass.dart'; +import 'deprecation.dart'; import 'importer.dart'; +import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; import 'logger.dart'; +import 'util/nullable.dart'; import 'utils.dart'; +/// A canonicalized URL and the importer that canonicalized it. +/// +/// This also includes the URL that was originally passed to the importer, which +/// may be resolved relative to a base URL. +typedef AsyncCanonicalizeResult = ( + AsyncImporter, + Uri canonicalUrl, { + Uri originalUrl +}); + /// An in-memory cache of parsed stylesheets that have been imported by Sass. /// /// {@category Dependencies} -@sealed -class AsyncImportCache { +final class AsyncImportCache { /// The importers to use when loading new Sass files. final List _importers; @@ -28,16 +39,14 @@ class AsyncImportCache { /// The canonicalized URLs for each non-canonical URL. /// - /// The second item in each key's tuple is true when this canonicalization is - /// for an `@import` rule. Otherwise, it's for a `@use` or `@forward` rule. - /// - /// This map's values are the same as the return value of [canonicalize]. + /// 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]. final _canonicalizeCache = - , Tuple3?>{}; + <(Uri, {bool forImport}), AsyncCanonicalizeResult?>{}; /// The canonicalized URLs for each non-canonical URL that's resolved using a /// relative importer. @@ -50,8 +59,13 @@ class AsyncImportCache { /// 4. The `baseUrl` passed to [canonicalize]. /// /// The map's values are the same as the return value of [canonicalize]. - final _relativeCanonicalizeCache = , - Tuple3?>{}; + final _relativeCanonicalizeCache = <( + Uri, { + bool forImport, + AsyncImporter baseImporter, + Uri? baseUrl + }), + AsyncCanonicalizeResult?>{}; /// The parsed stylesheets for each canonicalized import URL. final _importCache = {}; @@ -94,6 +108,7 @@ class AsyncImportCache { static List _toImporters(Iterable? importers, Iterable? loadPaths, PackageConfig? packageConfig) { var sassPath = getEnvironmentVariable('SASS_PATH'); + if (isBrowser) return [...?importers]; return [ ...?importers, if (loadPaths != null) @@ -117,28 +132,40 @@ class AsyncImportCache { /// If any importers understand [url], returns that importer as well as the /// canonicalized URL and the original URL (resolved relative to [baseUrl] if /// applicable). Otherwise, returns `null`. - Future?> canonicalize(Uri url, + Future canonicalize(Uri url, {AsyncImporter? baseImporter, Uri? baseUrl, bool forImport = false}) async { + if (isBrowser && + (baseImporter == null || baseImporter is NoOpImporter) && + _importers.isEmpty) { + throw "Custom importers are required to load stylesheets when compiling in the browser."; + } + if (baseImporter != null) { - var relativeResult = await putIfAbsentAsync(_relativeCanonicalizeCache, - Tuple4(url, forImport, baseImporter, baseUrl), () async { + var relativeResult = await putIfAbsentAsync(_relativeCanonicalizeCache, ( + url, + forImport: forImport, + baseImporter: baseImporter, + baseUrl: baseUrl + ), () async { var resolvedUrl = baseUrl?.resolveUri(url) ?? url; - var canonicalUrl = - await _canonicalize(baseImporter, resolvedUrl, forImport); - if (canonicalUrl == null) return null; - return Tuple3(baseImporter, canonicalUrl, resolvedUrl); + if (await _canonicalize(baseImporter, resolvedUrl, forImport) + case var canonicalUrl?) { + return (baseImporter, canonicalUrl, originalUrl: resolvedUrl); + } else { + return null; + } }); if (relativeResult != null) return relativeResult; } - return await putIfAbsentAsync(_canonicalizeCache, Tuple2(url, forImport), - () async { + return await putIfAbsentAsync( + _canonicalizeCache, (url, forImport: forImport), () async { for (var importer in _importers) { - var canonicalUrl = await _canonicalize(importer, url, forImport); - if (canonicalUrl != null) { - return Tuple3(importer, canonicalUrl, url); + if (await _canonicalize(importer, url, forImport) + case var canonicalUrl?) { + return (importer, canonicalUrl, originalUrl: url); } } @@ -154,10 +181,10 @@ class AsyncImportCache { ? inImportRule(() => importer.canonicalize(url)) : importer.canonicalize(url)); if (result?.scheme == '') { - _logger.warn(""" + _logger.warnForDeprecation(Deprecation.relativeCanonical, """ Importer $importer canonicalized $url to $result. Relative canonical URLs are deprecated and will eventually be disallowed. -""", deprecation: true); +"""); } return result; } @@ -171,17 +198,19 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// parsed stylesheet. Otherwise, returns `null`. /// /// Caches the result of the import and uses cached results if possible. - Future?> import(Uri url, + Future<(AsyncImporter, Stylesheet)?> import(Uri url, {AsyncImporter? baseImporter, Uri? baseUrl, bool forImport = false}) async { - var tuple = await canonicalize(url, - baseImporter: baseImporter, baseUrl: baseUrl, forImport: forImport); - if (tuple == null) return null; - var stylesheet = await importCanonical(tuple.item1, tuple.item2, - originalUrl: tuple.item3); - if (stylesheet == null) return null; - return Tuple2(tuple.item1, stylesheet); + if (await canonicalize(url, + baseImporter: baseImporter, baseUrl: baseUrl, forImport: forImport) + case (var importer, var canonicalUrl, :var originalUrl)) { + return (await importCanonical(importer, canonicalUrl, + originalUrl: originalUrl)) + .andThen((stylesheet) => (importer, stylesheet)); + } else { + return null; + } } /// Tries to load the canonicalized [canonicalUrl] using [importer]. @@ -217,21 +246,22 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// Return a human-friendly URL for [canonicalUrl] to use in a stack trace. /// /// Returns [canonicalUrl] as-is if it hasn't been loaded by this cache. - Uri humanize(Uri canonicalUrl) { - // Display the URL with the shortest path length. - var url = minBy( - _canonicalizeCache.values - .whereNotNull() - .where((tuple) => tuple.item2 == canonicalUrl) - .map((tuple) => tuple.item3), - (url) => url.path.length); - if (url == null) return canonicalUrl; - - // Use the canonicalized basename so that we display e.g. - // package:example/_example.scss rather than package:example/example in - // stack traces. - return url.resolve(p.url.basename(canonicalUrl.path)); - } + Uri humanize(Uri canonicalUrl) => + // If multiple original URLs canonicalize to the same thing, choose the + // shortest one. + minBy( + _canonicalizeCache.values + .whereNotNull() + .where((result) => result.$2 == canonicalUrl) + .map((result) => result.originalUrl), + (url) => url.path.length) + // Use the canonicalized basename so that we display e.g. + // package:example/_example.scss rather than package:example/example + // in stack traces. + .andThen((url) => url.resolve(p.url.basename(canonicalUrl.path))) ?? + // If we don't have an original URL cached, display the canonical URL + // as-is. + canonicalUrl; /// Returns the URL to use in the source map to refer to [canonicalUrl]. /// @@ -246,16 +276,9 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// @nodoc @internal void clearCanonicalize(Uri url) { - _canonicalizeCache.remove(Tuple2(url, false)); - _canonicalizeCache.remove(Tuple2(url, true)); - - var relativeKeysToClear = [ - for (var key in _relativeCanonicalizeCache.keys) - if (key.item1 == url) key - ]; - for (var key in relativeKeysToClear) { - _relativeCanonicalizeCache.remove(key); - } + _canonicalizeCache.remove((url, forImport: false)); + _canonicalizeCache.remove((url, forImport: true)); + _relativeCanonicalizeCache.removeWhere((key, _) => key.$1 == 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 28f65c2b1..2d2ed1e26 100644 --- a/lib/src/callable.dart +++ b/lib/src/callable.dart @@ -3,9 +3,7 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; -import 'ast/sass.dart'; import 'callable/async.dart'; import 'callable/built_in.dart'; import 'exception.dart'; @@ -69,7 +67,7 @@ export 'callable/user_defined.dart'; /// /// {@category Compile} @sealed -abstract class Callable extends AsyncCallable { +abstract interface class Callable implements AsyncCallable { @Deprecated('Use `Callable.function` instead.') factory Callable(String name, String arguments, Value callback(List arguments)) => @@ -127,8 +125,8 @@ abstract class Callable extends AsyncCallable { factory Callable.fromSignature( String signature, Value callback(List arguments), {bool requireParens = true}) { - Tuple2 tuple = + var (name, declaration) = parseSignature(signature, requireParens: requireParens); - return BuiltInCallable.parsed(tuple.item1, tuple.item2, callback); + return BuiltInCallable.parsed(name, declaration, callback); } } diff --git a/lib/src/callable/async.dart b/lib/src/callable/async.dart index 7a77d243a..433ca98b6 100644 --- a/lib/src/callable/async.dart +++ b/lib/src/callable/async.dart @@ -5,9 +5,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; -import '../ast/sass.dart'; import '../exception.dart'; import '../utils.dart'; import '../value.dart'; @@ -24,7 +22,7 @@ import 'async_built_in.dart'; /// /// {@category Compile} @sealed -abstract class AsyncCallable { +abstract interface class AsyncCallable { /// The callable's name. String get name; @@ -50,8 +48,8 @@ abstract class AsyncCallable { factory AsyncCallable.fromSignature( String signature, FutureOr callback(List arguments), {bool requireParens = true}) { - Tuple2 tuple = + var (name, declaration) = parseSignature(signature, requireParens: requireParens); - return AsyncBuiltInCallable.parsed(tuple.item1, tuple.item2, callback); + return AsyncBuiltInCallable.parsed(name, declaration, callback); } } diff --git a/lib/src/callable/async_built_in.dart b/lib/src/callable/async_built_in.dart index ff4513f25..0132b787f 100644 --- a/lib/src/callable/async_built_in.dart +++ b/lib/src/callable/async_built_in.dart @@ -4,8 +4,6 @@ import 'dart:async'; -import 'package:tuple/tuple.dart'; - import '../ast/sass.dart'; import '../value.dart'; import 'async.dart'; @@ -76,7 +74,7 @@ class AsyncBuiltInCallable implements AsyncCallable { /// If no exact match is found, finds the closest approximation. Note that this /// doesn't guarantee that [positional] and [names] are valid for the returned /// [ArgumentDeclaration]. - Tuple2 callbackFor( + (ArgumentDeclaration, Callback) callbackFor( int positional, Set names) => - Tuple2(_arguments, _callback); + (_arguments, _callback); } diff --git a/lib/src/callable/built_in.dart b/lib/src/callable/built_in.dart index a6bad3414..905d11e56 100644 --- a/lib/src/callable/built_in.dart +++ b/lib/src/callable/built_in.dart @@ -2,10 +2,9 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:tuple/tuple.dart'; - import '../ast/sass.dart'; import '../callable.dart'; +import '../util/map.dart'; import '../value.dart'; typedef Callback = Value Function(List arguments); @@ -16,11 +15,11 @@ typedef Callback = Value Function(List arguments); /// may declare multiple different callbacks with multiple different sets of /// arguments. When the callable is invoked, the first callback with matching /// arguments is invoked. -class BuiltInCallable implements Callable, AsyncBuiltInCallable { +final class BuiltInCallable implements Callable, AsyncBuiltInCallable { final String name; /// The overloads declared for this callable. - final List> _overloads; + final List<(ArgumentDeclaration, Callback)> _overloads; /// Creates a function with a single [arguments] declaration and a single /// [callback]. @@ -61,7 +60,7 @@ class BuiltInCallable implements Callable, AsyncBuiltInCallable { /// [callback]. BuiltInCallable.parsed(this.name, ArgumentDeclaration arguments, Value callback(List arguments)) - : _overloads = [Tuple2(arguments, callback)]; + : _overloads = [(arguments, callback)]; /// Creates a function with multiple implementations. /// @@ -75,11 +74,11 @@ class BuiltInCallable implements Callable, AsyncBuiltInCallable { BuiltInCallable.overloadedFunction(this.name, Map overloads, {Object? url}) : _overloads = [ - for (var entry in overloads.entries) - Tuple2( - ArgumentDeclaration.parse('@function $name(${entry.key}) {', - url: url), - entry.value) + for (var (args, callback) in overloads.pairs) + ( + ArgumentDeclaration.parse('@function $name($args) {', url: url), + callback + ) ]; BuiltInCallable._(this.name, this._overloads); @@ -90,16 +89,16 @@ class BuiltInCallable implements Callable, AsyncBuiltInCallable { /// If no exact match is found, finds the closest approximation. Note that this /// doesn't guarantee that [positional] and [names] are valid for the returned /// [ArgumentDeclaration]. - Tuple2 callbackFor( + (ArgumentDeclaration, Callback) callbackFor( int positional, Set names) { - Tuple2? fuzzyMatch; + (ArgumentDeclaration, Callback)? fuzzyMatch; int? minMismatchDistance; for (var overload in _overloads) { // Ideally, find an exact match. - if (overload.item1.matches(positional, names)) return overload; + if (overload.$1.matches(positional, names)) return overload; - var mismatchDistance = overload.item1.arguments.length - positional; + var mismatchDistance = overload.$1.arguments.length - positional; if (minMismatchDistance != null) { if (mismatchDistance.abs() > minMismatchDistance.abs()) continue; diff --git a/lib/src/callable/plain_css.dart b/lib/src/callable/plain_css.dart index 9a74ed604..cd46e1f5c 100644 --- a/lib/src/callable/plain_css.dart +++ b/lib/src/callable/plain_css.dart @@ -7,7 +7,7 @@ import '../callable.dart'; /// A callable that emits a plain CSS function. /// /// This can't be used for mixins. -class PlainCssCallable implements Callable { +final class PlainCssCallable implements Callable { final String name; PlainCssCallable(this.name); diff --git a/lib/src/callable/user_defined.dart b/lib/src/callable/user_defined.dart index a0a2af72c..6e0ecfacc 100644 --- a/lib/src/callable/user_defined.dart +++ b/lib/src/callable/user_defined.dart @@ -8,7 +8,7 @@ import '../callable.dart'; /// A callback defined in the user's Sass stylesheet. /// /// The type parameter [E] should either be `Environment` or `AsyncEnvironment`. -class UserDefinedCallable implements Callable { +final class UserDefinedCallable implements Callable { /// The declaration. final CallableDeclaration declaration; diff --git a/lib/src/color_names.dart b/lib/src/color_names.dart index 6bc4575b3..ae315663d 100644 --- a/lib/src/color_names.dart +++ b/lib/src/color_names.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'value.dart'; +import 'util/map.dart'; /// A map from (lowercase) color names to their color values. final colorsByName = { @@ -161,5 +162,5 @@ final colorsByName = { /// A map from Sass colors to (lowercase) color names. final namesByColor = { - for (var entry in colorsByName.entries) entry.value: entry.key + for (var (name, color) in colorsByName.pairs) color: name }; diff --git a/lib/src/compile.dart b/lib/src/compile.dart index 8e4f650cb..2b24def1b 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: f8b5bf7eafbe3523ca4df1a6832e131c5c03986b +// Checksum: c2982db43bcd56f81cab3f51b5669e0edd3cfafb // // ignore_for_file: unused_import @@ -19,11 +19,13 @@ import 'ast/sass.dart'; import 'import_cache.dart'; import 'callable.dart'; import 'compile_result.dart'; +import 'deprecation.dart'; import 'importer.dart'; import 'importer/legacy_node.dart'; +import 'importer/no_op.dart'; import 'io.dart'; import 'logger.dart'; -import 'logger/terse.dart'; +import 'logger/deprecation_handling.dart'; import 'syntax.dart'; import 'utils.dart'; import 'visitor/evaluate.dart'; @@ -46,9 +48,14 @@ CompileResult compile(String path, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) { - TerseLogger? terseLogger; - if (!verbose) logger = terseLogger = TerseLogger(logger ?? Logger.stderr()); + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) { + DeprecationHandlingLogger deprecationLogger = logger = + DeprecationHandlingLogger(logger ?? Logger.stderr(), + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: !verbose); // 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. @@ -80,7 +87,7 @@ CompileResult compile(String path, sourceMap, charset); - terseLogger?.summarize(node: nodeImporter != null); + deprecationLogger.summarize(js: nodeImporter != null); return result; } @@ -105,9 +112,14 @@ CompileResult compileString(String source, bool quietDeps = false, bool verbose = false, bool sourceMap = false, - bool charset = true}) { - TerseLogger? terseLogger; - if (!verbose) logger = terseLogger = TerseLogger(logger ?? Logger.stderr()); + bool charset = true, + Iterable? fatalDeprecations, + Iterable? futureDeprecations}) { + DeprecationHandlingLogger deprecationLogger = logger = + DeprecationHandlingLogger(logger ?? Logger.stderr(), + fatalDeprecations: {...?fatalDeprecations}, + futureDeprecations: {...?futureDeprecations}, + limitRepetition: !verbose); var stylesheet = Stylesheet.parse(source, syntax ?? Syntax.scss, url: url, logger: logger); @@ -117,7 +129,7 @@ CompileResult compileString(String source, logger, importCache, nodeImporter, - importer ?? FilesystemImporter('.'), + importer ?? (isBrowser ? NoOpImporter() : FilesystemImporter('.')), functions, style, useSpaces, @@ -127,7 +139,7 @@ CompileResult compileString(String source, sourceMap, charset); - terseLogger?.summarize(node: nodeImporter != null); + deprecationLogger.summarize(js: nodeImporter != null); return result; } @@ -167,9 +179,7 @@ CompileResult _compileStylesheet( var resultSourceMap = serializeResult.sourceMap; if (resultSourceMap != null && importCache != null) { - // TODO(nweiz): Don't explicitly use a type parameter when dart-lang/sdk#25490 - // is fixed. - mapInPlace( + mapInPlace( resultSourceMap.urls, (url) => url == '' ? Uri.dataFromString(stylesheet.span.file.getText(0), diff --git a/lib/src/compile_result.dart b/lib/src/compile_result.dart index 459c899dc..ad3e60c0d 100644 --- a/lib/src/compile_result.dart +++ b/lib/src/compile_result.dart @@ -21,7 +21,7 @@ class CompileResult { final SerializeResult _serialize; /// The compiled CSS. - String get css => _serialize.css; + String get css => _serialize.$1; /// The source map indicating how the source files map to [css]. /// diff --git a/lib/src/configuration.dart b/lib/src/configuration.dart index b8f10a5bb..1a65d236a 100644 --- a/lib/src/configuration.dart +++ b/lib/src/configuration.dart @@ -7,6 +7,7 @@ import 'ast/node.dart'; import 'ast/sass.dart'; import 'configured_value.dart'; import 'util/limited_map_view.dart'; +import 'util/map.dart'; import 'util/unprefixed_map_view.dart'; /// A set of variables meant to configure a module by overriding its @@ -17,7 +18,7 @@ import 'util/unprefixed_map_view.dart'; /// meaning that it's created by passing a `with` clause to a `@use` rule. /// Explicit configurations have spans associated with them and are represented /// by the [ExplicitConfiguration] subclass. -class Configuration { +final class Configuration { /// A map from variable names (without `$`) to values. /// /// This map may not be modified directly. To remove a value from this @@ -76,14 +77,14 @@ class Configuration { // configured. These views support [Map.remove] so we can mark when a // configuration variable is used by removing it even when the underlying // map is wrapped. - var prefix = forward.prefix; - if (prefix != null) newValues = UnprefixedMapView(newValues, prefix); + if (forward.prefix case var prefix?) { + newValues = UnprefixedMapView(newValues, prefix); + } - var shownVariables = forward.shownVariables; - var hiddenVariables = forward.hiddenVariables; - if (shownVariables != null) { + if (forward.shownVariables case var shownVariables?) { newValues = LimitedMapView.safelist(newValues, shownVariables); - } else if (hiddenVariables != null && hiddenVariables.isNotEmpty) { + } else if (forward.hiddenVariables case var hiddenVariables? + when hiddenVariables.isNotEmpty) { newValues = LimitedMapView.blocklist(newValues, hiddenVariables); } return _withValues(newValues); @@ -101,9 +102,7 @@ class Configuration { String toString() => "(" + - values.entries - .map((entry) => "\$${entry.key}: ${entry.value}") - .join(", ") + + [for (var (name, value) in values.pairs) "\$$name: $value"].join(",") + ")"; } @@ -114,7 +113,7 @@ class Configuration { /// configurations will cause an error if attempting to use them on a module /// that has already been loaded, while implicit configurations will be /// silently ignored in this case. -class ExplicitConfiguration extends Configuration { +final class ExplicitConfiguration extends Configuration { /// The node whose span indicates where the configuration was declared. final AstNode nodeWithSpan; diff --git a/lib/src/configured_value.dart b/lib/src/configured_value.dart index c373b1f8e..faf969cad 100644 --- a/lib/src/configured_value.dart +++ b/lib/src/configured_value.dart @@ -8,7 +8,7 @@ import 'ast/node.dart'; import 'value.dart'; /// A variable value that's been configured for a [Configuration]. -class ConfiguredValue { +final class ConfiguredValue { /// The value of the variable. final Value value; diff --git a/lib/src/deprecation.dart b/lib/src/deprecation.dart new file mode 100644 index 000000000..0266e30e0 --- /dev/null +++ b/lib/src/deprecation.dart @@ -0,0 +1,136 @@ +// Copyright 2022 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: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`. + callString('call-string', + deprecatedIn: '0.0.0', + description: 'Passing a string directly to meta.call().'), + + /// Deprecation for `@elseif`. + elseif('elseif', deprecatedIn: '1.3.2', description: '@elseif.'), + + /// Deprecation for parsing `@-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 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. + colorModuleCompat('color-module-compat', + deprecatedIn: '1.23.0', + description: + 'Using color module functions in place of plain CSS functions.'), + + /// Deprecation for treating `/` as division. + slashDiv('slash-div', + deprecatedIn: '1.33.0', description: '/ operator for division.'), + + /// Deprecation for leading, trailing, and repeated combinators. + bogusCombinators('bogus-combinators', + deprecatedIn: '1.54.0', + description: 'Leading, trailing, and repeated combinators.'), + + /// 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. + 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', + deprecatedIn: '1.62.0', + description: + 'Using !default or !global multiple times for one variable.'), + + nullAlpha('null-alpha', + deprecatedIn: '1.62.3', + description: 'Passing null as alpha in the ${isJS ? 'JS' : 'Dart'} API.'), + + colorFunctions('color-functions', + deprecatedIn: '1.67.0', + description: 'Using global Sass color functions.'), + + /// Deprecation for `@import` rules. + import.future('import', description: '@import rules.'), + + /// Used for deprecations coming from user-authored code. + userAuthored('user-authored', deprecatedIn: null); + + /// A unique ID for this deprecation in kebab case. + /// + /// This is used to refer to the deprecation on the command line. + final String id; + + /// Underlying version string used by [deprecatedIn]. + /// + /// This is necessary because [Version] doesn't have a constant constructor, + /// so we can't use it directly as an enum property. + final String? _deprecatedIn; + + /// The Dart Sass version this feature was first deprecated in. + /// + /// For deprecations that have existed in all versions of Dart Sass, this + /// should be 0.0.0. For deprecations not related to a specific Sass version, + /// this should be null. + Version? get deprecatedIn => _deprecatedIn.andThen(Version.parse); + + /// A description of this deprecation that will be displayed in the CLI usage. + /// + /// If this is null, the given deprecation will not be listed. + final String? description; + + /// Whether this deprecation will occur in the future. + /// + /// If this is true, `deprecatedIn` will be null, since we do not yet know + /// what version of Dart Sass this deprecation will be live in. + final bool isFuture; + + /// Constructs a regular deprecation. + const Deprecation(this.id, {required String? deprecatedIn, this.description}) + : _deprecatedIn = deprecatedIn, + isFuture = false; + + /// Constructs a future deprecation. + const Deprecation.future(this.id, {this.description}) + : _deprecatedIn = null, + isFuture = true; + + @override + String toString() => id; + + /// Returns the deprecation with a given ID, or null if none exists. + static Deprecation? fromId(String id) => Deprecation.values + .firstWhereOrNull((deprecation) => deprecation.id == id); + + /// Returns the set of all deprecations done in or before [version]. + static Set forVersion(Version version) { + var range = VersionRange(max: version, includeMax: true); + return { + for (var deprecation in Deprecation.values) + if (deprecation.deprecatedIn.andThen(range.allows) ?? false) deprecation + }; + } +} diff --git a/lib/src/embedded/dispatcher.dart b/lib/src/embedded/dispatcher.dart new file mode 100644 index 000000000..fae22b458 --- /dev/null +++ b/lib/src/embedded/dispatcher.dart @@ -0,0 +1,321 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:protobuf/protobuf.dart'; +import 'package:sass/sass.dart' as sass; +import 'package:stream_channel/stream_channel.dart'; + +import 'embedded_sass.pb.dart'; +import 'function_registry.dart'; +import 'host_callable.dart'; +import 'importer/file.dart'; +import 'importer/host.dart'; +import 'logger.dart'; +import 'util/proto_extensions.dart'; +import 'utils.dart'; + +/// The request ID used for all outbound requests. +/// +/// Since the dispatcher runs a single-threaded compilation, it will only ever +/// have one active request at a time, so there's no need to vary the ID. +final _outboundRequestId = 0; + +/// A class that dispatches messages to and from the host for a single +/// compilation. +final class Dispatcher { + /// The channel of encoded protocol buffers, connected to the host. + final StreamChannel _channel; + + /// The compilation ID for which this dispatcher is running. + /// + /// This is added to outgoing messages but is _not_ parsed from incoming + /// messages, since that's already handled by the [IsolateDispatcher]. + final int _compilationId; + + /// [_compilationId], serialized as a varint. + final Uint8List _compilationIdVarint; + + /// Whether this dispatcher has received its compile request. + var _compiling = false; + + /// A completer awaiting a response to an outbound request. + /// + /// Since each [Dispatcher] is only running a single-threaded compilation, it + /// can only ever have one request outstanding. + Completer? _outstandingRequest; + + /// Creates a [Dispatcher] that sends and receives encoded protocol buffers + /// over [channel]. + Dispatcher(this._channel, this._compilationId) + : _compilationIdVarint = serializeVarint(_compilationId); + + /// Listens for incoming `CompileRequests` and runs their compilations. + /// + /// This may only be called once. Returns whether or not the compilation + /// succeeded. + Future listen() async { + var success = false; + await _channel.stream.listen((binaryMessage) async { + // Wait a single microtask tick so that we're running in a separate + // microtask from the initial request dispatch. Otherwise, [waitFor] will + // deadlock the event loop fiber that would otherwise be checking stdin + // for new input. + await Future.value(); + + try { + InboundMessage? message; + try { + message = InboundMessage.fromBuffer(binaryMessage); + } on InvalidProtocolBufferException catch (error) { + throw parseError(error.message); + } + + switch (message.whichMessage()) { + case InboundMessage_Message.versionRequest: + throw paramsError("VersionRequest must have compilation ID 0."); + + case InboundMessage_Message.compileRequest: + if (_compiling) { + throw paramsError( + "A CompileRequest with compilation ID $_compilationId is " + "already active."); + } + _compiling = true; + + var request = message.compileRequest; + var response = await _compile(request); + _send(OutboundMessage()..compileResponse = response); + success = true; + // Each Dispatcher runs a single compilation and then closes. + _channel.sink.close(); + + case InboundMessage_Message.canonicalizeResponse: + _dispatchResponse(message.id, message.canonicalizeResponse); + + case InboundMessage_Message.importResponse: + _dispatchResponse(message.id, message.importResponse); + + case InboundMessage_Message.fileImportResponse: + _dispatchResponse(message.id, message.fileImportResponse); + + case InboundMessage_Message.functionCallResponse: + _dispatchResponse(message.id, message.functionCallResponse); + + case InboundMessage_Message.notSet: + throw parseError("InboundMessage.message is not set."); + + default: + throw parseError( + "Unknown message type: ${message.toDebugString()}"); + } + } on ProtocolError catch (error, stackTrace) { + sendError(handleError(error, stackTrace)); + _channel.sink.close(); + } + }).asFuture(); + return success; + } + + Future _compile( + InboundMessage_CompileRequest request) async { + var functions = FunctionRegistry(); + + var style = request.style == OutputStyle.COMPRESSED + ? sass.OutputStyle.compressed + : sass.OutputStyle.expanded; + var logger = EmbeddedLogger(this, + color: request.alertColor, ascii: request.alertAscii); + + try { + var importers = request.importers.map((importer) => + _decodeImporter(request, importer) ?? + (throw mandatoryError("Importer.importer"))); + + var globalFunctions = request.globalFunctions + .map((signature) => hostCallable(this, functions, signature)); + + late sass.CompileResult result; + switch (request.whichInput()) { + case InboundMessage_CompileRequest_Input.string: + var input = request.string; + result = sass.compileStringToResult(input.source, + color: request.alertColor, + logger: logger, + importers: importers, + importer: _decodeImporter(request, input.importer) ?? + (input.url.startsWith("file:") ? null : sass.Importer.noOp), + functions: globalFunctions, + syntax: syntaxToSyntax(input.syntax), + style: style, + url: input.url.isEmpty ? null : input.url, + quietDeps: request.quietDeps, + verbose: request.verbose, + sourceMap: request.sourceMap, + charset: request.charset); + break; + + case InboundMessage_CompileRequest_Input.path: + if (request.path.isEmpty) { + throw mandatoryError("CompileRequest.Input.path"); + } + + try { + result = sass.compileToResult(request.path, + color: request.alertColor, + logger: logger, + importers: importers, + functions: globalFunctions, + style: style, + quietDeps: request.quietDeps, + verbose: request.verbose, + sourceMap: request.sourceMap, + charset: request.charset); + } on FileSystemException catch (error) { + return OutboundMessage_CompileResponse() + ..failure = (OutboundMessage_CompileResponse_CompileFailure() + ..message = error.path == null + ? error.message + : "${error.message}: ${error.path}" + ..span = (SourceSpan() + ..start = SourceSpan_SourceLocation() + ..end = SourceSpan_SourceLocation() + ..url = p.toUri(request.path).toString())); + } + break; + + case InboundMessage_CompileRequest_Input.notSet: + throw mandatoryError("CompileRequest.input"); + } + + var success = OutboundMessage_CompileResponse_CompileSuccess() + ..css = result.css; + + var sourceMap = result.sourceMap; + if (sourceMap != null) { + success.sourceMap = json.encode(sourceMap.toJson( + includeSourceContents: request.sourceMapIncludeSources)); + } + return OutboundMessage_CompileResponse() + ..success = success + ..loadedUrls.addAll(result.loadedUrls.map((url) => url.toString())); + } on sass.SassException catch (error) { + var formatted = withGlyphs( + () => error.toString(color: request.alertColor), + ascii: request.alertAscii); + return OutboundMessage_CompileResponse() + ..failure = (OutboundMessage_CompileResponse_CompileFailure() + ..message = error.message + ..span = protofySpan(error.span) + ..stackTrace = error.trace.toString() + ..formatted = formatted) + ..loadedUrls.addAll(error.loadedUrls.map((url) => url.toString())); + } + } + + /// Converts [importer] into a [sass.Importer]. + sass.Importer? _decodeImporter(InboundMessage_CompileRequest request, + InboundMessage_CompileRequest_Importer importer) { + switch (importer.whichImporter()) { + case InboundMessage_CompileRequest_Importer_Importer.path: + return sass.FilesystemImporter(importer.path); + + case InboundMessage_CompileRequest_Importer_Importer.importerId: + return HostImporter(this, importer.importerId); + + case InboundMessage_CompileRequest_Importer_Importer.fileImporterId: + return FileImporter(this, importer.fileImporterId); + + case InboundMessage_CompileRequest_Importer_Importer.notSet: + return null; + } + } + + /// Sends [event] to the host. + void sendLog(OutboundMessage_LogEvent event) => + _send(OutboundMessage()..logEvent = event); + + /// Sends [error] to the host. + void sendError(ProtocolError error) => + _send(OutboundMessage()..error = error); + + Future sendCanonicalizeRequest( + OutboundMessage_CanonicalizeRequest request) => + _sendRequest( + OutboundMessage()..canonicalizeRequest = request); + + Future sendImportRequest( + OutboundMessage_ImportRequest request) => + _sendRequest( + OutboundMessage()..importRequest = request); + + Future sendFileImportRequest( + OutboundMessage_FileImportRequest request) => + _sendRequest( + OutboundMessage()..fileImportRequest = request); + + Future sendFunctionCallRequest( + OutboundMessage_FunctionCallRequest request) => + _sendRequest( + OutboundMessage()..functionCallRequest = request); + + /// Sends [request] to the host and returns the message sent in response. + Future _sendRequest( + OutboundMessage request) async { + request.id = _outboundRequestId; + _send(request); + + if (_outstandingRequest != null) { + throw StateError( + "Dispatcher.sendRequest() can't be called when another request is " + "active."); + } + + return (_outstandingRequest = Completer()).future; + } + + /// Dispatches [response] to the appropriate outstanding request. + /// + /// Throws an error if there's no outstanding request with the given [id] or + /// if that request is expecting a different type of response. + void _dispatchResponse(int? id, T response) { + var completer = _outstandingRequest; + _outstandingRequest = null; + if (completer == null || id != _outboundRequestId) { + throw paramsError( + "Response ID $id doesn't match any outstanding requests in " + "compilation $_compilationId."); + } else if (completer is! Completer) { + throw paramsError( + "Request ID $id doesn't match response type ${response.runtimeType} " + "in compilation $_compilationId."); + } + + completer.complete(response); + } + + /// Sends [message] to the host with the given [wireId]. + void _send(OutboundMessage message) { + var protobufWriter = CodedBufferWriter(); + message.writeToCodedBufferWriter(protobufWriter); + + // Add one additional byte to the beginning to indicate whether or not the + // compilation is finished, so the [IsolateDispatcher] knows whether to + // treat this isolate as inactive. + var packet = Uint8List( + 1 + _compilationIdVarint.length + protobufWriter.lengthInBytes); + packet[0] = + message.whichMessage() == OutboundMessage_Message.compileResponse + ? 1 + : 0; + packet.setAll(1, _compilationIdVarint); + protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length); + _channel.sink.add(packet); + } +} diff --git a/lib/src/embedded/executable.dart b/lib/src/embedded/executable.dart new file mode 100644 index 000000000..9248713ae --- /dev/null +++ b/lib/src/embedded/executable.dart @@ -0,0 +1,37 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:io'; +import 'dart:convert'; + +import 'package:stream_channel/stream_channel.dart'; + +import 'isolate_dispatcher.dart'; +import 'util/length_delimited_transformer.dart'; + +void main(List args) { + switch (args) { + case ["--version", ...]: + var response = IsolateDispatcher.versionResponse(); + response.id = 0; + stdout.writeln( + JsonEncoder.withIndent(" ").convert(response.toProto3Json())); + return; + + case [_, ...]: + stderr.writeln( + "sass --embedded is not intended to be executed with additional " + "arguments.\n" + "See https://github.com/sass/dart-sass#embedded-dart-sass for " + "details."); + // USAGE error from https://bit.ly/2poTt90 + exitCode = 64; + return; + } + + IsolateDispatcher( + StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false) + .transform(lengthDelimited)) + .listen(); +} diff --git a/lib/src/embedded/function_registry.dart b/lib/src/embedded/function_registry.dart new file mode 100644 index 000000000..b288fbd8d --- /dev/null +++ b/lib/src/embedded/function_registry.dart @@ -0,0 +1,33 @@ +// 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 new file mode 100644 index 000000000..bb1770ea4 --- /dev/null +++ b/lib/src/embedded/host_callable.dart @@ -0,0 +1,59 @@ +// 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. + +// ignore: deprecated_member_use +import 'dart:cli'; + +import '../callable.dart'; +import '../exception.dart'; +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart'; +import 'function_registry.dart'; +import 'protofier.dart'; +import 'utils.dart'; + +/// Returns a Sass callable that invokes a function defined on the host with the +/// given [signature]. +/// +/// If [id] is passed, the function will be called by ID (which is necessary for +/// anonymous functions defined on the host). Otherwise, it will be called using +/// the name defined in the [signature]. +/// +/// Throws a [SassException] if [signature] is invalid. +Callable hostCallable( + Dispatcher dispatcher, FunctionRegistry functions, String signature, + {int? id}) { + late Callable callable; + callable = Callable.fromSignature(signature, (arguments) { + var protofier = Protofier(dispatcher, functions); + var request = OutboundMessage_FunctionCallRequest() + ..arguments.addAll( + [for (var argument in arguments) protofier.protofy(argument)]); + + if (id != null) { + request.functionId = id; + } else { + request.name = callable.name; + } + + // ignore: deprecated_member_use + var response = waitFor(dispatcher.sendFunctionCallRequest(request)); + try { + switch (response.whichResult()) { + case InboundMessage_FunctionCallResponse_Result.success: + return protofier.deprotofyResponse(response); + + case InboundMessage_FunctionCallResponse_Result.error: + throw response.error; + + case InboundMessage_FunctionCallResponse_Result.notSet: + throw mandatoryError('FunctionCallResponse.result'); + } + } 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 new file mode 100644 index 000000000..1de597da5 --- /dev/null +++ b/lib/src/embedded/importer/base.dart @@ -0,0 +1,35 @@ +// 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:meta/meta.dart'; + +import '../../importer.dart'; +import '../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. + @protected + final Dispatcher dispatcher; + + ImporterBase(this.dispatcher); + + /// Parses [url] as a [Uri] and throws an error if it's invalid or relative + /// (including root-relative). + /// + /// The [source] name is used in the error message if one is thrown. + @protected + Uri parseAbsoluteUrl(String source, String url) { + Uri parsedUrl; + try { + parsedUrl = Uri.parse(url); + } on FormatException { + throw '$source must return a URL, was "$url"'; + } + + if (parsedUrl.scheme.isNotEmpty) return parsedUrl; + throw '$source must return an absolute URL, was "$parsedUrl"'; + } +} diff --git a/lib/src/embedded/importer/file.dart b/lib/src/embedded/importer/file.dart new file mode 100644 index 000000000..b945cba2e --- /dev/null +++ b/lib/src/embedded/importer/file.dart @@ -0,0 +1,59 @@ +// 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. + +// ignore: deprecated_member_use +import 'dart:cli'; + +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); + + Uri? canonicalize(Uri url) { + if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); + + // ignore: deprecated_member_use + return waitFor(() async { + var response = await dispatcher + .sendFileImportRequest(OutboundMessage_FileImportRequest() + ..importerId = _importerId + ..url = url.toString() + ..fromImport = fromImport); + + switch (response.whichResult()) { + case InboundMessage_FileImportResponse_Result.fileUrl: + var url = parseAbsoluteUrl("The file importer", response.fileUrl); + if (url.scheme != 'file') { + throw 'The file importer must return a file: URL, was "$url"'; + } + + return _filesystemImporter.canonicalize(url); + + case InboundMessage_FileImportResponse_Result.error: + throw response.error; + + case InboundMessage_FileImportResponse_Result.notSet: + return null; + } + }()); + } + + ImporterResult? load(Uri url) => _filesystemImporter.load(url); + + String toString() => "FileImporter"; +} diff --git a/lib/src/embedded/importer/host.dart b/lib/src/embedded/importer/host.dart new file mode 100644 index 000000000..e4a952100 --- /dev/null +++ b/lib/src/embedded/importer/host.dart @@ -0,0 +1,63 @@ +// 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. + +// ignore: deprecated_member_use +import 'dart:cli'; + +import '../../importer.dart'; +import '../dispatcher.dart'; +import '../embedded_sass.pb.dart' hide SourceSpan; +import '../utils.dart'; +import 'base.dart'; + +/// An importer that asks the host to resolve imports. +final class HostImporter extends ImporterBase { + /// The host-provided ID of the importer to invoke. + final int _importerId; + + HostImporter(Dispatcher dispatcher, this._importerId) : super(dispatcher); + + Uri? canonicalize(Uri url) { + // ignore: deprecated_member_use + return waitFor(() async { + var response = await dispatcher + .sendCanonicalizeRequest(OutboundMessage_CanonicalizeRequest() + ..importerId = _importerId + ..url = url.toString() + ..fromImport = fromImport); + + return switch (response.whichResult()) { + InboundMessage_CanonicalizeResponse_Result.url => + parseAbsoluteUrl("The importer", response.url), + InboundMessage_CanonicalizeResponse_Result.error => + throw response.error, + InboundMessage_CanonicalizeResponse_Result.notSet => null + }; + }()); + } + + ImporterResult? load(Uri url) { + // ignore: deprecated_member_use + return waitFor(() async { + var response = + await dispatcher.sendImportRequest(OutboundMessage_ImportRequest() + ..importerId = _importerId + ..url = url.toString()); + + return switch (response.whichResult()) { + InboundMessage_ImportResponse_Result.success => ImporterResult( + response.success.contents, + sourceMapUrl: response.success.sourceMapUrl.isEmpty + ? null + : parseAbsoluteUrl( + "The importer", response.success.sourceMapUrl), + syntax: syntaxToSyntax(response.success.syntax)), + InboundMessage_ImportResponse_Result.error => throw response.error, + InboundMessage_ImportResponse_Result.notSet => null + }; + }()); + } + + String toString() => "HostImporter"; +} diff --git a/lib/src/embedded/isolate_dispatcher.dart b/lib/src/embedded/isolate_dispatcher.dart new file mode 100644 index 000000000..78d340997 --- /dev/null +++ b/lib/src/embedded/isolate_dispatcher.dart @@ -0,0 +1,224 @@ +// 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 'dart:async'; +import 'dart:ffi'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:pool/pool.dart'; +import 'package:protobuf/protobuf.dart'; +import 'package:stream_channel/isolate_channel.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart'; +import 'util/explicit_close_transformer.dart'; +import 'util/proto_extensions.dart'; +import 'utils.dart'; + +/// The message sent to a previously-inactive isolate to initiate a new +/// compilation session. +/// +/// The [SendPort] is used to establish a dedicated [IsolateChannel] for this +/// compilation and the [int] is the compilation ID to use on the wire. +/// +/// We apply the compilation ID in the isolate for efficiency reasons: it allows +/// us to write the protobuf directly to the same buffer as the wire ID, which +/// saves a copy for each message. +typedef _InitialMessage = (SendPort, int); + +/// A class that dispatches messages between the host and various isolates that +/// are each running an individual compilation. +class IsolateDispatcher { + /// The channel of encoded protocol buffers, connected to the host. + final StreamChannel _channel; + + /// A map from compilation IDs to the sinks for isolates running those + /// compilations. + final _activeIsolates = >{}; + + /// A set of isolates that are _not_ actively running compilations. + final _inactiveIsolates = >{}; + + /// The actual isolate objects that have been spawned. + /// + /// Only used for cleaning up the process when the underlying channel closes. + final _allIsolates = >[]; + + /// A pool controlling how many isolates (and thus concurrent compilations) + /// may be live at once. + /// + /// More than MaxMutatorThreadCount isolates in the same isolate group + /// can deadlock the Dart VM. + /// See https://github.com/sass/dart-sass/pull/2019 + final _isolatePool = Pool(sizeOf() <= 4 ? 7 : 15); + + /// Whether the underlying channel has closed and the dispatcher is shutting + /// down. + var _closed = false; + + IsolateDispatcher(this._channel); + + void listen() { + _channel.stream.listen((packet) async { + int? compilationId; + InboundMessage? message; + try { + Uint8List messageBuffer; + (compilationId, messageBuffer) = parsePacket(packet); + + if (compilationId != 0) { + // TODO(nweiz): Consider using the techniques described in + // https://github.com/dart-lang/language/issues/124#issuecomment-988718728 + // or https://github.com/dart-lang/language/issues/3118 for low-cost + // inter-isolate transfers. + (_activeIsolates[compilationId] ?? await _getIsolate(compilationId)) + .add(messageBuffer); + return; + } + + try { + message = InboundMessage.fromBuffer(messageBuffer); + } on InvalidProtocolBufferException catch (error) { + throw parseError(error.message); + } + + if (message.whichMessage() case var type + when type != InboundMessage_Message.versionRequest) { + throw paramsError( + "Only VersionRequest may have wire ID 0, was $type."); + } + + var request = message.versionRequest; + var response = versionResponse(); + response.id = request.id; + _send(0, OutboundMessage()..versionResponse = response); + } catch (error, stackTrace) { + _handleError(error, stackTrace, + compilationId: compilationId, messageId: message?.id); + } + }, onError: (Object error, StackTrace stackTrace) { + _handleError(error, stackTrace); + }, onDone: () async { + _closed = true; + for (var isolate in _allIsolates) { + (await isolate).kill(); + } + + // Killing isolates isn't sufficient to make sure the process closes; we + // also have to close all the [ReceivePort]s we've constructed (by closing + // the [IsolateChannel]s). + for (var sink in _activeIsolates.values) { + sink.close(); + } + for (var channel in _inactiveIsolates) { + channel.sink.close(); + } + }); + } + + /// Returns an isolate that's ready to run a new compilation. + /// + /// This re-uses an existing isolate if possible, and spawns a new one + /// otherwise. + Future> _getIsolate(int compilationId) async { + var resource = await _isolatePool.request(); + if (_inactiveIsolates.isNotEmpty) { + return _activate(_inactiveIsolates.first, compilationId, resource); + } + + var receivePort = ReceivePort(); + var future = Isolate.spawn(_isolateMain, receivePort.sendPort); + _allIsolates.add(future); + await future; + + var channel = IsolateChannel<_InitialMessage?>.connectReceive(receivePort) + .transform(const ExplicitCloseTransformer()); + channel.stream.listen(null, + onError: (Object error, StackTrace stackTrace) => + _handleError(error, stackTrace), + onDone: _channel.sink.close); + return _activate(channel, compilationId, resource); + } + + /// Activates [isolate] for a new individual compilation. + /// + /// This pipes all the outputs from the given isolate through to [_channel]. + /// The [resource] is released once the isolate is no longer active. + StreamSink _activate(StreamChannel<_InitialMessage> isolate, + int compilationId, PoolResource resource) { + _inactiveIsolates.remove(isolate); + + // Each individual compilation has its own dedicated [IsolateChannel], which + // closes once the compilation is finished. + var receivePort = ReceivePort(); + isolate.sink.add((receivePort.sendPort, compilationId)); + + var channel = IsolateChannel.connectReceive(receivePort); + channel.stream.listen( + (message) { + // The first byte of messages from isolates indicates whether the + // entire compilation is finished. 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. + if (message[0] == 1) { + channel.sink.close(); + _activeIsolates.remove(compilationId); + _inactiveIsolates.add(isolate); + resource.release(); + } + _channel.sink.add(Uint8List.sublistView(message, 1)); + }, + onError: (Object error, StackTrace stackTrace) => + _handleError(error, stackTrace), + onDone: () { + if (_closed) isolate.sink.close(); + }); + _activeIsolates[compilationId] = channel.sink; + return channel.sink; + } + + /// Creates a [OutboundMessage_VersionResponse] + static OutboundMessage_VersionResponse versionResponse() { + return OutboundMessage_VersionResponse() + ..protocolVersion = const String.fromEnvironment("protocol-version") + ..compilerVersion = const String.fromEnvironment("compiler-version") + ..implementationVersion = const String.fromEnvironment("compiler-version") + ..implementationName = "Dart Sass"; + } + + /// Handles an error thrown by the dispatcher or code it dispatches to. + /// + /// The [compilationId] and [messageId] indicate the IDs of the message being + /// responded to, if available. + void _handleError(Object error, StackTrace stackTrace, + {int? compilationId, int? messageId}) { + sendError(compilationId ?? errorId, + handleError(error, stackTrace, messageId: messageId)); + _channel.sink.close(); + } + + /// Sends [message] to the host. + void _send(int compilationId, OutboundMessage message) => + _channel.sink.add(serializePacket(compilationId, message)); + + /// Sends [error] to the host. + void sendError(int compilationId, ProtocolError error) => + _send(compilationId, OutboundMessage()..error = error); +} + +void _isolateMain(SendPort sendPort) { + var channel = IsolateChannel<_InitialMessage?>.connectSend(sendPort) + .transform(const ExplicitCloseTransformer()); + channel.stream.listen((initialMessage) async { + var (compilationSendPort, compilationId) = initialMessage; + var compilationChannel = + IsolateChannel.connectSend(compilationSendPort); + var success = await Dispatcher(compilationChannel, compilationId).listen(); + if (!success) channel.sink.close(); + }); +} diff --git a/lib/src/embedded/logger.dart b/lib/src/embedded/logger.dart new file mode 100644 index 000000000..8da614cc5 --- /dev/null +++ b/lib/src/embedded/logger.dart @@ -0,0 +1,76 @@ +// 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 'package:path/path.dart' as p; +import 'package:source_span/source_span.dart'; +import 'package:stack_trace/stack_trace.dart'; + +import '../logger.dart'; +import '../util/nullable.dart'; +import '../utils.dart'; +import '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; + + /// Whether the formatted message should contain terminal colors. + final bool _color; + + /// Whether the formatted message should use ASCII encoding. + final bool _ascii; + + EmbeddedLogger(this._dispatcher, {bool color = false, bool ascii = false}) + : _color = color, + _ascii = ascii; + + void debug(String message, SourceSpan span) { + _dispatcher.sendLog(OutboundMessage_LogEvent() + ..type = LogEventType.DEBUG + ..message = message + ..span = protofySpan(span) + ..formatted = (span.start.sourceUrl.andThen(p.prettyUri) ?? '-') + + ':${span.start.line + 1} ' + + (_color ? '\u001b[1mDebug\u001b[0m' : 'DEBUG') + + ': $message\n'); + } + + void warn(String message, + {FileSpan? span, Trace? trace, bool deprecation = false}) { + var formatted = withGlyphs(() { + var buffer = StringBuffer(); + if (_color) { + buffer.write('\u001b[33m\u001b[1m'); + if (deprecation) buffer.write('Deprecation '); + buffer.write('Warning\u001b[0m'); + } else { + if (deprecation) buffer.write('DEPRECATION '); + buffer.write('WARNING'); + } + if (span == null) { + buffer.writeln(': $message'); + } else if (trace != null) { + buffer.writeln(': $message\n\n${span.highlight(color: _color)}'); + } else { + buffer.writeln(' on ${span.message("\n" + message, color: _color)}'); + } + if (trace != null) { + buffer.writeln(indent(trace.toString().trimRight(), 4)); + } + return buffer.toString(); + }, ascii: _ascii); + + var event = OutboundMessage_LogEvent() + ..type = + deprecation ? LogEventType.DEPRECATION_WARNING : LogEventType.WARNING + ..message = message + ..formatted = formatted; + if (span != null) event.span = protofySpan(span); + if (trace != null) event.stackTrace = trace.toString(); + _dispatcher.sendLog(event); + } +} diff --git a/lib/src/embedded/protofier.dart b/lib/src/embedded/protofier.dart new file mode 100644 index 000000000..f528ea6c7 --- /dev/null +++ b/lib/src/embedded/protofier.dart @@ -0,0 +1,370 @@ +// 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 '../util/map.dart'; +import '../util/nullable.dart'; +import '../value.dart'; +import '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 'utils.dart'; + +/// A class that converts Sass [Value] objects into [Value] protobufs. +/// +/// A given [Protofier] instance is valid only within the scope of a single +/// custom function call. +final class Protofier { + /// The dispatcher, for invoking deprotofied [Value_HostFunction]s. + final Dispatcher _dispatcher; + + /// The IDs of first-class functions. + final FunctionRegistry _functions; + + /// Any argument lists transitively contained in [value]. + /// + /// The IDs of the [Value_ArgumentList] protobufs are always one greater than + /// the index of the corresponding list in this array (since 0 is reserved for + /// argument lists created by the host). + final _argumentLists = []; + + /// Creates a [Protofier] that's valid within the scope of a single custom + /// function call. + /// + /// 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); + + /// Converts [value] to its protocol buffer representation. + proto.Value protofy(Value value) { + var result = proto.Value(); + switch (value) { + case SassString(): + result.string = Value_String() + ..text = value.text + ..quoted = value.hasQuotes; + case SassNumber(): + result.number = _protofyNumber(value); + case SassColor(space: ColorSpace.hsl): + result.hslColor = Value_HslColor() + ..hue = value.channel('hue') * 1.0 + ..saturation = value.channel('saturation') + ..lightness = value.channel('lightness') + ..alpha = value.alpha * 1.0; + case SassColor(): + result.rgbColor = Value_RgbColor() + ..red = value.channel('red').clamp(0, 255).round() + ..green = value.channel('green').clamp(0, 255).round() + ..blue = value.channel('blue').clamp(0, 255).round() + ..alpha = value.alpha * 1.0; + case SassArgumentList(): + _argumentLists.add(value); + result.argumentList = Value_ArgumentList() + ..id = _argumentLists.length + ..separator = _protofySeparator(value.separator) + ..keywords.addAll({ + for (var (key, value) in value.keywordsWithoutMarking.pairs) + key: protofy(value) + }) + ..contents.addAll(value.asList.map(protofy)); + case SassList(): + result.list = Value_List() + ..separator = _protofySeparator(value.separator) + ..hasBrackets = value.hasBrackets + ..contents.addAll(value.asList.map(protofy)); + case SassMap(): + result.map = Value_Map(); + for (var (key, value) in value.contents.pairs) { + result.map.entries.add(Value_Map_Entry() + ..key = protofy(key) + ..value = protofy(value)); + } + case SassCalculation(): + result.calculation = _protofyCalculation(value); + case SassFunction(): + result.compilerFunction = _functions.protofy(value); + case sassTrue: + result.singleton = SingletonValue.TRUE; + case sassFalse: + result.singleton = SingletonValue.FALSE; + case sassNull: + result.singleton = SingletonValue.NULL; + case _: + throw "Unknown Value $value"; + } + return result; + } + + /// Converts [number] to its protocol buffer representation. + Value_Number _protofyNumber(SassNumber number) => Value_Number() + ..value = number.value * 1.0 + ..numerators.addAll(number.numeratorUnits) + ..denominators.addAll(number.denominatorUnits); + + /// Converts [separator] to its protocol buffer representation. + proto.ListSeparator _protofySeparator(ListSeparator separator) => + switch (separator) { + ListSeparator.comma => proto.ListSeparator.COMMA, + ListSeparator.space => proto.ListSeparator.SPACE, + ListSeparator.slash => proto.ListSeparator.SLASH, + ListSeparator.undecided => proto.ListSeparator.UNDECIDED + }; + + /// Converts [calculation] to its protocol buffer representation. + Value_Calculation _protofyCalculation(SassCalculation calculation) => + Value_Calculation() + ..name = calculation.name + ..arguments.addAll(calculation.arguments.map(_protofyCalculationValue)); + + /// Converts a calculation value that appears within a `SassCalculation` to + /// its protocol buffer representation. + Value_Calculation_CalculationValue _protofyCalculationValue(Object value) { + var result = Value_Calculation_CalculationValue(); + switch (value) { + case SassNumber(): + result.number = _protofyNumber(value); + case SassCalculation(): + result.calculation = _protofyCalculation(value); + case SassString(): + result.string = value.text; + case CalculationOperation(): + result.operation = Value_Calculation_CalculationOperation() + ..operator = _protofyCalculationOperator(value.operator) + ..left = _protofyCalculationValue(value.left) + ..right = _protofyCalculationValue(value.right); + case CalculationInterpolation(): + result.interpolation = value.value; + case _: + throw "Unknown calculation value $value"; + } + return result; + } + + /// Converts [operator] to its protocol buffer representation. + proto.CalculationOperator _protofyCalculationOperator( + CalculationOperator operator) => + switch (operator) { + CalculationOperator.plus => proto.CalculationOperator.PLUS, + CalculationOperator.minus => proto.CalculationOperator.MINUS, + CalculationOperator.times => proto.CalculationOperator.TIMES, + CalculationOperator.dividedBy => proto.CalculationOperator.DIVIDE + }; + + /// Converts [response]'s return value to its Sass representation. + Value deprotofyResponse(InboundMessage_FunctionCallResponse response) { + for (var id in response.accessedArgumentLists) { + // Mark the `keywords` field as accessed. + _argumentListForId(id).keywords; + } + + return _deprotofy(response.success); + } + + /// Converts [value] to its Sass representation. + Value _deprotofy(proto.Value value) { + try { + switch (value.whichValue()) { + case Value_Value.string: + return value.string.text.isEmpty + ? SassString.empty(quotes: value.string.quoted) + : SassString(value.string.text, quotes: value.string.quoted); + + 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.argumentList: + if (value.argumentList.id != 0) { + return _argumentListForId(value.argumentList.id); + } + + var separator = _deprotofySeparator(value.argumentList.separator); + var length = value.argumentList.contents.length; + if (separator == ListSeparator.undecided && length > 1) { + throw paramsError( + "List $value can't have an undecided separator because it has " + "$length elements"); + } + + return SassArgumentList( + value.argumentList.contents.map(_deprotofy), + { + for (var (name, value) in value.argumentList.keywords.pairs) + name: _deprotofy(value) + }, + separator); + + case Value_Value.list: + var separator = _deprotofySeparator(value.list.separator); + if (value.list.contents.isEmpty) { + return SassList.empty( + separator: separator, brackets: value.list.hasBrackets); + } + + var length = value.list.contents.length; + if (separator == ListSeparator.undecided && length > 1) { + throw paramsError( + "List $value can't have an undecided separator because it has " + "$length elements"); + } + + return SassList(value.list.contents.map(_deprotofy), separator, + brackets: value.list.hasBrackets); + + case Value_Value.map: + return value.map.entries.isEmpty + ? const SassMap.empty() + : SassMap({ + for (var Value_Map_Entry(:key, :value) in value.map.entries) + _deprotofy(key): _deprotofy(value) + }); + + case Value_Value.compilerFunction: + var id = value.compilerFunction.id; + if (_functions[id] case var function?) return function; + throw paramsError( + "CompilerFunction.id $id doesn't match any known functions"); + + case Value_Value.hostFunction: + return SassFunction(hostCallable( + _dispatcher, _functions, value.hostFunction.signature, + id: value.hostFunction.id)); + + case Value_Value.calculation: + return _deprotofyCalculation(value.calculation); + + case Value_Value.singleton: + return switch (value.singleton) { + SingletonValue.TRUE => sassTrue, + SingletonValue.FALSE => sassFalse, + SingletonValue.NULL => sassNull, + _ => throw "Unknown Value.singleton ${value.singleton}" + }; + + case Value_Value.notSet: + throw mandatoryError("Value.value"); + } + } on RangeError catch (error) { + var name = error.name; + if (name == null || error.start == null || error.end == null) { + throw paramsError(error.toString()); + } + + if (value.whichValue() == Value_Value.rgbColor) { + name = 'RgbColor.$name'; + } else if (value.whichValue() == Value_Value.hslColor) { + name = 'HslColor.$name'; + } + + throw paramsError( + '$name must be between ${error.start} and ${error.end}, was ' + '${error.invalidValue}'); + } + } + + /// Converts [number] to its Sass representation. + SassNumber _deprotofyNumber(Value_Number number) => + SassNumber.withUnits(number.value, + numeratorUnits: number.numerators, + denominatorUnits: number.denominators); + + /// Returns the argument list in [_argumentLists] that corresponds to [id]. + SassArgumentList _argumentListForId(int id) { + if (id < 1) { + throw paramsError( + "Value.ArgumentList.id $id can't be marked as accessed"); + } else if (id > _argumentLists.length) { + throw paramsError( + "Value.ArgumentList.id $id doesn't match any known argument " + "lists"); + } else { + return _argumentLists[id - 1]; + } + } + + /// Converts [separator] to its Sass representation. + ListSeparator _deprotofySeparator(proto.ListSeparator separator) => + switch (separator) { + proto.ListSeparator.COMMA => ListSeparator.comma, + proto.ListSeparator.SPACE => ListSeparator.space, + proto.ListSeparator.SLASH => ListSeparator.slash, + proto.ListSeparator.UNDECIDED => ListSeparator.undecided, + _ => throw "Unknown ListSeparator $separator", + }; + + /// Converts [calculation] to its Sass representation. + Value _deprotofyCalculation(Value_Calculation calculation) => + switch (calculation) { + Value_Calculation(name: "calc", arguments: [var arg]) => + SassCalculation.calc(_deprotofyCalculationValue(arg)), + Value_Calculation(name: "calc") => throw paramsError( + "Value.Calculation.arguments must have exactly one argument for " + "calc()."), + Value_Calculation( + name: "clamp", + arguments: [var arg1, ...var rest] && List(length: < 4) + ) => + SassCalculation.clamp( + _deprotofyCalculationValue(arg1), + rest.elementAtOrNull(0).andThen(_deprotofyCalculationValue), + rest.elementAtOrNull(1).andThen(_deprotofyCalculationValue)), + Value_Calculation(name: "clamp") => throw paramsError( + "Value.Calculation.arguments must have 1 to 3 arguments for " + "clamp()."), + Value_Calculation(name: "min" || "max", arguments: []) => + throw paramsError( + "Value.Calculation.arguments must have at least 1 argument for " + "${calculation.name}()."), + Value_Calculation(name: "min", :var arguments) => + SassCalculation.min(arguments.map(_deprotofyCalculationValue)), + Value_Calculation(name: "max", :var arguments) => + SassCalculation.max(arguments.map(_deprotofyCalculationValue)), + _ => throw paramsError( + 'Value.Calculation.name "${calculation.name}" is not a recognized ' + 'calculation type.') + }; + + /// Converts [value] to its Sass representation. + Object _deprotofyCalculationValue(Value_Calculation_CalculationValue value) => + switch (value.whichValue()) { + Value_Calculation_CalculationValue_Value.number => + _deprotofyNumber(value.number), + Value_Calculation_CalculationValue_Value.calculation => + _deprotofyCalculation(value.calculation), + Value_Calculation_CalculationValue_Value.string => + SassString(value.string, quotes: false), + Value_Calculation_CalculationValue_Value.operation => + SassCalculation.operate( + _deprotofyCalculationOperator(value.operation.operator), + _deprotofyCalculationValue(value.operation.left), + _deprotofyCalculationValue(value.operation.right)), + Value_Calculation_CalculationValue_Value.interpolation => + CalculationInterpolation(value.interpolation), + Value_Calculation_CalculationValue_Value.notSet => + throw mandatoryError("Value.Calculation.value") + }; + + /// Converts [operator] to its Sass representation. + CalculationOperator _deprotofyCalculationOperator( + proto.CalculationOperator operator) => + switch (operator) { + proto.CalculationOperator.PLUS => CalculationOperator.plus, + proto.CalculationOperator.MINUS => CalculationOperator.minus, + proto.CalculationOperator.TIMES => CalculationOperator.times, + proto.CalculationOperator.DIVIDE => CalculationOperator.dividedBy, + _ => throw "Unknown CalculationOperator $operator" + }; +} diff --git a/lib/src/embedded/unavailable.dart b/lib/src/embedded/unavailable.dart new file mode 100644 index 000000000..bf03a52bf --- /dev/null +++ b/lib/src/embedded/unavailable.dart @@ -0,0 +1,10 @@ +// 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 '../io.dart'; + +void main(List args) async { + printError('sass --embedded is unavailable in pure JS mode.'); + exitCode = 1; +} diff --git a/lib/src/embedded/util/explicit_close_transformer.dart b/lib/src/embedded/util/explicit_close_transformer.dart new file mode 100644 index 000000000..43a5334dd --- /dev/null +++ b/lib/src/embedded/util/explicit_close_transformer.dart @@ -0,0 +1,38 @@ +// 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 'dart:async'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// A [StreamChannelTransformer] that explicitly ensures that when one endpoint +/// closes its sink, the other endpoint will close as well. +/// +/// This must be applied to both ends of the channel, and the underlying channel +/// must reserve `null` for a close event. +class ExplicitCloseTransformer + implements StreamChannelTransformer { + const ExplicitCloseTransformer(); + + StreamChannel bind(StreamChannel channel) { + var closedUnderlyingSink = false; + return StreamChannel.withCloseGuarantee(channel.stream + .transform(StreamTransformer.fromHandlers(handleData: (data, sink) { + if (data == null) { + channel.sink.close(); + closedUnderlyingSink = true; + } else { + sink.add(data); + } + })), channel.sink + .transform(StreamSinkTransformer.fromHandlers(handleDone: (sink) { + if (!closedUnderlyingSink) { + closedUnderlyingSink = true; + sink.add(null); + sink.close(); + } + }))); + } +} diff --git a/lib/src/embedded/util/length_delimited_transformer.dart b/lib/src/embedded/util/length_delimited_transformer.dart new file mode 100644 index 000000000..8e8b782e9 --- /dev/null +++ b/lib/src/embedded/util/length_delimited_transformer.dart @@ -0,0 +1,108 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import '../utils.dart'; +import 'varint_builder.dart'; + +/// A [StreamChannelTransformer] that converts a channel that sends and receives +/// arbitrarily-chunked binary data to one that sends and receives packets of +/// set length using [lengthDelimitedEncoder] and [lengthDelimitedDecoder]. +final StreamChannelTransformer> lengthDelimited = + StreamChannelTransformer>(lengthDelimitedDecoder, + StreamSinkTransformer.fromStreamTransformer(lengthDelimitedEncoder)); + +/// A transformer that converts an arbitrarily-chunked byte stream where each +/// packet is prefixed with a 32-bit little-endian number indicating its length +/// into a stream of packet contents. +final lengthDelimitedDecoder = + StreamTransformer, Uint8List>.fromBind((stream) { + // The builder for the varint indicating the length of the next message. + // + // Once this is fully built up, [buffer] is initialized and this is reset. + final nextMessageLengthBuilder = VarintBuilder(53, 'packet length'); + + // The buffer into which the packet data itself is written. Initialized once + // [nextMessageLength] is known. + Uint8List? buffer; + + // The index of the next byte to write to [buffer]. Once this is equal to + // [buffer.length] (or equivalently [nextMessageLength]), the full packet is + // available. + var bufferIndex = 0; + + // It seems a little silly to use a nested [StreamTransformer] here, but we + // need the outer one to establish a closure context so we can share state + // across different input chunks, and the inner one takes care of all the + // boilerplate of creating a new stream based on [stream]. + return stream + .transform(StreamTransformer.fromHandlers(handleData: (chunk, sink) { + // The index of the next byte to read from [chunk]. We have to track this + // because the chunk may contain the length *and* the message, or even + // multiple messages. + var i = 0; + + while (i < chunk.length) { + var buffer_ = buffer; // dart-lang/language#1536 + + // We can be in one of two states here: + // + // * [buffer] is `null`, in which case we're adding data to + // [nextMessageLength] until we reach a byte with its most significant + // bit set to 0. + // + // * [buffer] is not `null`, in which case we're waiting for [buffer] to + // have [nextMessageLength] bytes in it before we send it to + // [queue.local.sink] and start waiting for the next message. + if (buffer_ == null) { + var length = nextMessageLengthBuilder.add(chunk[i]); + i++; + if (length == null) continue; + + // Otherwise, [nextMessageLength] is now finalized and we can allocate + // the data buffer. + buffer_ = buffer = Uint8List(length); + bufferIndex = 0; + } + + // Copy as many bytes as we can from [chunk] to [buffer], making sure not + // to try to copy more than the buffer can hold (if the chunk has another + // message after the current one) or more than the chunk has available (if + // the current message is split across multiple chunks). + var bytesToWrite = + math.min(buffer_.length - bufferIndex, chunk.length - i); + buffer_.setRange(bufferIndex, bufferIndex + bytesToWrite, chunk, i); + i += bytesToWrite; + bufferIndex += bytesToWrite; + if (bufferIndex < buffer_.length) return; + + // Once we've filled the buffer, emit it and reset our state. + sink.add(buffer_); + nextMessageLengthBuilder.reset(); + buffer = null; + } + })); +}); + +/// A transformer that adds 32-bit little-endian numbers indicating the length +/// of each packet, so that they can safely be sent over a medium that doesn't +/// preserve packet boundaries. +final lengthDelimitedEncoder = + StreamTransformer>.fromHandlers( + handleData: (message, sink) { + var length = message.length; + if (length == 0) { + sink.add([0]); + return; + } + + sink.add(serializeVarint(length)); + sink.add(message); +}); diff --git a/lib/src/embedded/util/proto_extensions.dart b/lib/src/embedded/util/proto_extensions.dart new file mode 100644 index 000000000..43eea2dc9 --- /dev/null +++ b/lib/src/embedded/util/proto_extensions.dart @@ -0,0 +1,53 @@ +// 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 '../embedded_sass.pb.dart'; + +extension InboundMessageExtensions on InboundMessage { + /// Returns the ID of this message, regardless of its type. + /// + /// Returns null if [message] doesn't have an id field. + int? get id => switch (whichMessage()) { + InboundMessage_Message.versionRequest => versionRequest.id, + InboundMessage_Message.canonicalizeResponse => canonicalizeResponse.id, + InboundMessage_Message.importResponse => importResponse.id, + InboundMessage_Message.fileImportResponse => fileImportResponse.id, + InboundMessage_Message.functionCallResponse => functionCallResponse.id, + _ => null + }; +} + +extension OutboundMessageExtensions on OutboundMessage { + /// Returns the outbound ID of this message, regardless of its type. + /// + /// Throws an [ArgumentError] if [message] doesn't have an id field. + int get id => switch (whichMessage()) { + OutboundMessage_Message.canonicalizeRequest => canonicalizeRequest.id, + OutboundMessage_Message.importRequest => importRequest.id, + OutboundMessage_Message.fileImportRequest => fileImportRequest.id, + OutboundMessage_Message.functionCallRequest => functionCallRequest.id, + OutboundMessage_Message.versionResponse => versionResponse.id, + _ => throw ArgumentError("Unknown message type: ${toDebugString()}") + }; + + /// Sets the outbound ID of this message, regardless of its type. + /// + /// Throws an [ArgumentError] if [message] doesn't have an id field. + set id(int id) { + switch (whichMessage()) { + case OutboundMessage_Message.canonicalizeRequest: + canonicalizeRequest.id = id; + case OutboundMessage_Message.importRequest: + importRequest.id = id; + case OutboundMessage_Message.fileImportRequest: + fileImportRequest.id = id; + case OutboundMessage_Message.functionCallRequest: + functionCallRequest.id = id; + case OutboundMessage_Message.versionResponse: + versionResponse.id = id; + default: + throw ArgumentError("Unknown message type: ${toDebugString()}"); + } + } +} diff --git a/lib/src/embedded/util/varint_builder.dart b/lib/src/embedded/util/varint_builder.dart new file mode 100644 index 000000000..78610bc9d --- /dev/null +++ b/lib/src/embedded/util/varint_builder.dart @@ -0,0 +1,82 @@ +// 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 '../embedded_sass.pb.dart'; +import '../utils.dart'; + +/// A class that builds up unsigned varints byte-by-byte. +class VarintBuilder { + /// The maximum length in bits of the varint being parsed. + final int _maxLength; + + /// The name of the value being parsed, used for error reporting. + final String? _name; + + /// The value of the varint so far. + int _value = 0; + + /// The total number of bits parsed so far. + int _bits = 0; + + /// Whether we've finished parsing the varint. + var _done = false; + + /// Creates a builder with [maxLength] as the maximum number of bits allowed + /// for the integer. + /// + /// If [name] is passed, it's used in error reporting. + VarintBuilder(this._maxLength, [this._name]); + + /// Parses [byte] as a continuation of the varint. + /// + /// If this byte completes the varint, returns the parsed int. Otherwise, + /// returns null. + /// + /// Throws a [ProtocolError] if [byte] causes the length of the varint to + /// exceed [_maxLength]. Throws a [StateError] if called after [add] has + /// already returned a value. + int? add(int byte) { + if (_done) { + throw StateError("VarintBuilder.add() has already returned a value."); + } + + // Varints encode data in the 7 lower bits of each byte, which we access by + // masking with 0x7f = 0b01111111. + _value += (byte & 0x7f) << _bits; + _bits += 7; + + // If the byte is higher than 0x7f = 0b01111111, that means its high bit is + // set which and so there are more bytes to consume before we know the full + // value. + if (byte > 0x7f) { + if (_bits >= _maxLength) { + _done = true; + throw _tooLong(); + } else { + return null; + } + } else { + _done = true; + if (_bits > _maxLength && _value >= 1 << _maxLength) { + // [_maxLength] is probably not divisible by 7, so we need to check that + // the highest bits of the final byte aren't set. + throw _tooLong(); + } else { + return _value; + } + } + } + + /// Resets this to its initial state so it can build another varint. + void reset() { + _value = 0; + _bits = 0; + _done = false; + } + + /// Returns a [ProtocolError] indicating that the varint exceeded [_maxLength]. + ProtocolError _tooLong() => + parseError("Varint ${_name == null ? '' : '$_name '}was longer than " + "$_maxLength bits."); +} diff --git a/lib/src/embedded/utils.dart b/lib/src/embedded/utils.dart new file mode 100644 index 000000000..ff987cf2f --- /dev/null +++ b/lib/src/embedded/utils.dart @@ -0,0 +1,154 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; +import 'package:source_span/source_span.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:term_glyph/term_glyph.dart' as term_glyph; + +import '../syntax.dart'; +import 'embedded_sass.pb.dart' as proto; +import 'embedded_sass.pb.dart' hide SourceSpan, Syntax; +import 'util/varint_builder.dart'; + +/// The special ID that indicates an error that's not associated with a specific +/// inbound request ID. +const errorId = 0xffffffff; + +/// Returns a [ProtocolError] indicating that a mandatory field with the given +/// [fieldName] was missing. +ProtocolError mandatoryError(String fieldName) => + paramsError("Missing mandatory field $fieldName"); + +/// Returns a [ProtocolError] indicating that the parameters for an inbound +/// message were invalid. +ProtocolError paramsError(String message) => ProtocolError() + // Set the ID to [errorId] by default. This will be overwritten by the + // dispatcher if a request ID is available. + ..id = errorId + ..type = ProtocolErrorType.PARAMS + ..message = message; + +/// Returns a [ProtocolError] with type `PARSE` and the given [message]. +ProtocolError parseError(String message) => ProtocolError() + ..type = ProtocolErrorType.PARSE + ..message = message; + +/// Converts a Dart source span to a protocol buffer source span. +proto.SourceSpan protofySpan(SourceSpan span) { + var protoSpan = proto.SourceSpan() + ..text = span.text + ..start = _protofyLocation(span.start) + ..end = _protofyLocation(span.end) + ..url = span.sourceUrl?.toString() ?? ""; + if (span is SourceSpanWithContext) protoSpan.context = span.context; + return protoSpan; +} + +/// Converts a Dart source location to a protocol buffer source location. +SourceSpan_SourceLocation _protofyLocation(SourceLocation location) => + SourceSpan_SourceLocation() + ..offset = location.offset + ..line = location.line + ..column = location.column; + +/// Converts a protocol buffer syntax enum into a Sass API syntax enum. +Syntax syntaxToSyntax(proto.Syntax syntax) => switch (syntax) { + proto.Syntax.SCSS => Syntax.scss, + proto.Syntax.INDENTED => Syntax.sass, + proto.Syntax.CSS => Syntax.css, + _ => throw "Unknown syntax $syntax." + }; + +/// Returns the result of running [callback] with the global ASCII config set +/// to [ascii]. +T withGlyphs(T callback(), {required bool ascii}) { + var currentConfig = term_glyph.ascii; + term_glyph.ascii = ascii; + var result = callback(); + term_glyph.ascii = currentConfig; + return result; +} + +/// Serializes [value] to an unsigned varint. +Uint8List serializeVarint(int value) { + if (value == 0) return Uint8List.fromList([0]); + RangeError.checkNotNegative(value); + + // Essentially `(value.bitLength / 7).ceil()`, but without getting floats + // involved. + var lengthInBytes = (value.bitLength + 6) ~/ 7; + var list = Uint8List(lengthInBytes); + for (var i = 0; i < lengthInBytes; i++) { + // The highest-order bit indicates whether more bytes are necessary to fully + // express the number. The lower 7 bits indicate the number's value. + list[i] = (value > 0x7f ? 0x80 : 0) | (value & 0x7f); + value >>= 7; + } + return list; +} + +/// Serializes a compilation ID and protobuf message into a packet buffer as +/// specified in the embedded protocol. +Uint8List serializePacket(int compilationId, GeneratedMessage message) { + var varint = serializeVarint(compilationId); + var protobufWriter = CodedBufferWriter(); + message.writeToCodedBufferWriter(protobufWriter); + + var packet = Uint8List(varint.length + protobufWriter.lengthInBytes); + packet.setAll(0, varint); + protobufWriter.writeTo(packet, varint.length); + return packet; +} + +/// A [VarintBuilder] that's shared across invocations of [parsePacket] to avoid +/// unnecessary allocations. +final _compilationIdBuilder = VarintBuilder(32, 'compilation ID'); + +/// Parses a compilation ID and encoded protobuf message from a packet buffer as +/// specified in the embedded protocol. +(int, Uint8List) parsePacket(Uint8List packet) { + try { + var i = 0; + while (true) { + if (i == packet.length) { + throw parseError( + "Invalid compilation ID: continuation bit always set."); + } + + var compilationId = _compilationIdBuilder.add(packet[i]); + i++; + if (compilationId != null) { + return (compilationId, Uint8List.sublistView(packet, i)); + } + } + } finally { + _compilationIdBuilder.reset(); + } +} + +/// Wraps error object into ProtocolError, writes error to stderr, and returns the ProtocolError. +ProtocolError handleError(Object error, StackTrace stackTrace, + {int? messageId}) { + if (error is ProtocolError) { + error.id = messageId ?? errorId; + stderr.write("Host caused ${error.type.name.toLowerCase()} error"); + if (error.id != errorId) stderr.write(" with request ${error.id}"); + stderr.writeln(": ${error.message}"); + // PROTOCOL error from https://bit.ly/2poTt90 + exitCode = 76; // EX_PROTOCOL + return error; + } else { + var errorMessage = "$error\n${Chain.forTrace(stackTrace)}"; + stderr.write("Internal compiler error: $errorMessage"); + exitCode = 70; // EX_SOFTWARE + return ProtocolError() + ..type = ProtocolErrorType.INTERNAL + ..id = messageId ?? errorId + ..message = errorMessage; + } +} diff --git a/lib/src/environment.dart b/lib/src/environment.dart index 128bd3285..623f67828 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: 38c688423116df1e489aa6eafc16de1bf9bc2bf5 +// Checksum: f7172be68e0a19c4dc2d2ad04fc32a843a98a6bd // // ignore_for_file: unused_import @@ -24,6 +24,7 @@ import 'extend/extension_store.dart'; import 'module.dart'; import 'module/forwarded_view.dart'; import 'module/shadowed_view.dart'; +import 'util/map.dart'; import 'util/merged_map_view.dart'; import 'util/nullable.dart'; import 'util/public_member_map_view.dart'; @@ -40,7 +41,7 @@ import 'visitor/clone_css.dart'; /// /// This tracks lexically-scoped information, such as variables, functions, and /// mixins. -class Environment { +final class Environment { /// The modules used in the current scope, indexed by their namespaces. Map> get modules => UnmodifiableMapView(_modules); final Map> _modules; @@ -243,12 +244,11 @@ class Environment { _globalModules[module] = nodeWithSpan; _allModules.add(module); - for (var name in _variables.first.keys) { - if (module.variables.containsKey(name)) { - throw SassScriptException( - 'This module and the new module both define a variable named ' - '"\$$name".'); - } + if (_variables.first.keys.firstWhereOrNull(module.variables.containsKey) + case var name?) { + throw SassScriptException( + 'This module and the new module both define a variable named ' + '"\$$name".'); } } else { if (_modules.containsKey(namespace)) { @@ -307,11 +307,12 @@ class Environment { larger = newMembers; } - for (var name in smaller.keys) { - if (!larger.containsKey(name)) continue; + for (var (name, small) in smaller.pairs) { + var large = larger[name]; + if (large == null) continue; if (type == "variable" ? newModule.variableIdentity(name) == oldModule.variableIdentity(name) - : larger[name] == smaller[name]) { + : large == small) { continue; } @@ -329,82 +330,82 @@ class Environment { /// /// This is called when [module] is `@import`ed. void importForwards(Module module) { - if (module is _EnvironmentModule) { - var forwarded = module._environment._forwardedModules; - if (forwarded == null) return; - - // Omit modules from [forwarded] that are already globally available and - // forwarded in this module. - var forwardedModules = _forwardedModules; - if (forwardedModules != null) { - forwarded = { - for (var entry in forwarded.entries) - if (!forwardedModules.containsKey(entry.key) || - !_globalModules.containsKey(entry.key)) - entry.key: entry.value, - }; - } else { - forwardedModules = _forwardedModules ??= {}; - } + if (module is! _EnvironmentModule) return; + var forwarded = module._environment._forwardedModules; + if (forwarded == null) return; + + // Omit modules from [forwarded] that are already globally available and + // forwarded in this module. + var forwardedModules = _forwardedModules; + if (forwardedModules != null) { + forwarded = { + for (var (module, node) in forwarded.pairs) + if (!forwardedModules.containsKey(module) || + !_globalModules.containsKey(module)) + module: node, + }; + } else { + forwardedModules = _forwardedModules ??= {}; + } - var forwardedVariableNames = - forwarded.keys.expand((module) => module.variables.keys).toSet(); - var forwardedFunctionNames = - forwarded.keys.expand((module) => module.functions.keys).toSet(); - var forwardedMixinNames = - forwarded.keys.expand((module) => module.mixins.keys).toSet(); - - if (atRoot) { - // Hide members from modules that have already been imported or - // forwarded that would otherwise conflict with the @imported members. - for (var entry in _importedModules.entries.toList()) { - var module = entry.key; - var shadowed = ShadowedModuleView.ifNecessary(module, - variables: forwardedVariableNames, - mixins: forwardedMixinNames, - functions: forwardedFunctionNames); - if (shadowed != null) { - _importedModules.remove(module); - if (!shadowed.isEmpty) _importedModules[shadowed] = entry.value; - } + var forwardedVariableNames = { + for (var module in forwarded.keys) ...module.variables.keys + }; + var forwardedFunctionNames = { + for (var module in forwarded.keys) ...module.functions.keys + }; + var forwardedMixinNames = { + for (var module in forwarded.keys) ...module.mixins.keys + }; + + if (atRoot) { + // Hide members from modules that have already been imported or + // forwarded that would otherwise conflict with the @imported members. + for (var (module, node) in _importedModules.pairs.toList()) { + var shadowed = ShadowedModuleView.ifNecessary(module, + variables: forwardedVariableNames, + mixins: forwardedMixinNames, + functions: forwardedFunctionNames); + if (shadowed != null) { + _importedModules.remove(module); + if (!shadowed.isEmpty) _importedModules[shadowed] = node; } + } - for (var entry in forwardedModules.entries.toList()) { - var module = entry.key; - var shadowed = ShadowedModuleView.ifNecessary(module, - variables: forwardedVariableNames, - mixins: forwardedMixinNames, - functions: forwardedFunctionNames); - if (shadowed != null) { - forwardedModules.remove(module); - if (!shadowed.isEmpty) forwardedModules[shadowed] = entry.value; - } + for (var (module, node) in forwardedModules.pairs.toList()) { + var shadowed = ShadowedModuleView.ifNecessary(module, + variables: forwardedVariableNames, + mixins: forwardedMixinNames, + functions: forwardedFunctionNames); + if (shadowed != null) { + forwardedModules.remove(module); + if (!shadowed.isEmpty) forwardedModules[shadowed] = node; } - - _importedModules.addAll(forwarded); - forwardedModules.addAll(forwarded); - } else { - (_nestedForwardedModules ??= - List.generate(_variables.length - 1, (_) => [])) - .last - .addAll(forwarded.keys); } - // Remove existing member definitions that are now shadowed by the - // forwarded modules. - for (var variable in forwardedVariableNames) { - _variableIndices.remove(variable); - _variables.last.remove(variable); - _variableNodes.last.remove(variable); - } - for (var function in forwardedFunctionNames) { - _functionIndices.remove(function); - _functions.last.remove(function); - } - for (var mixin in forwardedMixinNames) { - _mixinIndices.remove(mixin); - _mixins.last.remove(mixin); - } + _importedModules.addAll(forwarded); + forwardedModules.addAll(forwarded); + } else { + (_nestedForwardedModules ??= + List.generate(_variables.length - 1, (_) => [])) + .last + .addAll(forwarded.keys); + } + + // Remove existing member definitions that are now shadowed by the + // forwarded modules. + for (var variable in forwardedVariableNames) { + _variableIndices.remove(variable); + _variables.last.remove(variable); + _variableNodes.last.remove(variable); + } + for (var function in forwardedFunctionNames) { + _functionIndices.remove(function); + _functions.last.remove(function); + } + for (var mixin in forwardedMixinNames) { + _mixinIndices.remove(mixin); + _mixins.last.remove(mixin); } } @@ -421,25 +422,21 @@ class Environment { _getVariableFromGlobalModule(name); } - var index = _variableIndices[name]; - if (index != null) { + if (_variableIndices[name] case var index?) { _lastVariableName = name; _lastVariableIndex = index; return _variables[index][name] ?? _getVariableFromGlobalModule(name); - } - - index = _variableIndex(name); - if (index == null) { + } else if (_variableIndex(name) case var index?) { + _lastVariableName = name; + _lastVariableIndex = index; + _variableIndices[name] = index; + return _variables[index][name] ?? _getVariableFromGlobalModule(name); + } else { // There isn't a real variable defined as this index, but it will cause // [getVariable] to short-circuit and get to this function faster next // time the variable is accessed. return _getVariableFromGlobalModule(name); } - - _lastVariableName = name; - _lastVariableIndex = index; - _variableIndices[name] = index; - return _variables[index][name] ?? _getVariableFromGlobalModule(name); } /// Returns the value of the variable named [name] from a namespaceless @@ -464,22 +461,20 @@ class Environment { _getVariableNodeFromGlobalModule(name); } - var index = _variableIndices[name]; - if (index != null) { + if (_variableIndices[name] case var index?) { _lastVariableName = name; _lastVariableIndex = index; return _variableNodes[index][name] ?? _getVariableNodeFromGlobalModule(name); + } else if (_variableIndex(name) case var index?) { + _lastVariableName = name; + _lastVariableIndex = index; + _variableIndices[name] = index; + return _variableNodes[index][name] ?? + _getVariableNodeFromGlobalModule(name); + } else { + return _getVariableNodeFromGlobalModule(name); } - - index = _variableIndex(name); - if (index == null) return _getVariableNodeFromGlobalModule(name); - - _lastVariableName = name; - _lastVariableIndex = index; - _variableIndices[name] = index; - return _variableNodes[index][name] ?? - _getVariableNodeFromGlobalModule(name); } /// Returns the node for the variable named [name] from a namespaceless @@ -494,8 +489,7 @@ class Environment { // We don't need to worry about multiple modules defining the same variable, // because that's already been checked by [getVariable]. for (var module in _importedModules.keys.followedBy(_globalModules.keys)) { - var value = module.variableNodes[name]; - if (value != null) return value; + if (module.variableNodes[name] case var value?) return value; } return null; } @@ -629,16 +623,14 @@ class Environment { Callable? getFunction(String name, {String? namespace}) { if (namespace != null) return _getModule(namespace).functions[name]; - var index = _functionIndices[name]; - if (index != null) { + if (_functionIndices[name] case var index?) { + return _functions[index][name] ?? _getFunctionFromGlobalModule(name); + } else if (_functionIndex(name) case var index?) { + _functionIndices[name] = index; return _functions[index][name] ?? _getFunctionFromGlobalModule(name); + } else { + return _getFunctionFromGlobalModule(name); } - - index = _functionIndex(name); - if (index == null) return _getFunctionFromGlobalModule(name); - - _functionIndices[name] = index; - return _functions[index][name] ?? _getFunctionFromGlobalModule(name); } /// Returns the value of the function named [name] from a namespaceless @@ -678,16 +670,14 @@ class Environment { Callable? getMixin(String name, {String? namespace}) { if (namespace != null) return _getModule(namespace).mixins[name]; - var index = _mixinIndices[name]; - if (index != null) { + if (_mixinIndices[name] case var index?) { + return _mixins[index][name] ?? _getMixinFromGlobalModule(name); + } else if (_mixinIndex(name) case var index?) { + _mixinIndices[name] = index; return _mixins[index][name] ?? _getMixinFromGlobalModule(name); + } else { + return _getMixinFromGlobalModule(name); } - - index = _mixinIndex(name); - if (index == null) return _getMixinFromGlobalModule(name); - - _mixinIndices[name] = index; - return _mixins[index][name] ?? _getMixinFromGlobalModule(name); } /// Returns the value of the mixin named [name] from a namespaceless @@ -797,22 +787,24 @@ class Environment { for (var i = 0; i < _variables.length; i++) { var values = _variables[i]; var nodes = _variableNodes[i]; - for (var entry in values.entries) { + for (var (name, value) in values.pairs) { // Implicit configurations are never invalid, making [configurationSpan] // unnecessary, so we pass null here to avoid having to compute it. - configuration[entry.key] = - ConfiguredValue.implicit(entry.value, nodes[entry.key]!); + configuration[name] = ConfiguredValue.implicit(value, nodes[name]!); } } return Configuration.implicit(configuration); } /// Returns a module that represents the top-level members defined in [this], - /// that contains [css] as its CSS tree, which can be extended using - /// [extensionStore]. - Module toModule(CssStylesheet css, ExtensionStore extensionStore) { + /// that contains [css] and [preModuleComments] as its CSS, which can be + /// extended using [extensionStore]. + Module toModule( + CssStylesheet css, + Map, List> preModuleComments, + ExtensionStore extensionStore) { assert(atRoot); - return _EnvironmentModule(this, css, extensionStore, + return _EnvironmentModule(this, css, preModuleComments, extensionStore, forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); } @@ -822,21 +814,18 @@ class Environment { /// This is used when resolving imports, since they need to inject forwarded /// members into the current scope. It's the only situation in which a nested /// environment can become a module. - Module toDummyModule() { - return _EnvironmentModule( - this, - CssStylesheet(const [], - SourceFile.decoded(const [], url: "").span(0)), - ExtensionStore.empty, - forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); - } + Module toDummyModule() => _EnvironmentModule( + this, + CssStylesheet(const [], + SourceFile.decoded(const [], url: "").span(0)), + const {}, + ExtensionStore.empty, + forwarded: _forwardedModules.andThen((modules) => MapKeySet(modules))); /// Returns the module with the given [namespace], or throws a /// [SassScriptException] if none exists. Module _getModule(String namespace) { - var module = _modules[namespace]; - if (module != null) return module; - + if (_modules[namespace] case var module?) return module; throw SassScriptException( 'There is no module with the namespace "$namespace".'); } @@ -854,18 +843,15 @@ class Environment { /// It's used to format an appropriate error message. T? _fromOneModule( String name, String type, T? callback(Module module)) { - var nestedForwardedModules = _nestedForwardedModules; - if (nestedForwardedModules != null) { + if (_nestedForwardedModules case var nestedForwardedModules?) { for (var modules in nestedForwardedModules.reversed) { for (var module in modules.reversed) { - var value = callback(module); - if (value != null) return value; + if (callback(module) case var value?) return value; } } } for (var module in _importedModules.keys) { - var value = callback(module); - if (value != null) return value; + if (callback(module) case var value?) return value; } T? value; @@ -880,14 +866,11 @@ class Environment { if (identityFromModule == identity) continue; if (value != null) { - var spans = _globalModules.entries.map( - (entry) => callback(entry.key).andThen((_) => entry.value.span)); - throw MultiSpanSassScriptException( 'This $type is available from multiple global modules.', '$type use', { - for (var span in spans) - if (span != null) span: 'includes $type' + for (var (module, node) in _globalModules.pairs) + if (callback(module) != null) node.span: 'includes $type' }); } @@ -899,7 +882,7 @@ class Environment { } /// A module that represents the top-level members defined in an [Environment]. -class _EnvironmentModule implements Module { +final class _EnvironmentModule implements Module { Uri? get url => css.span.sourceUrl; final List> upstream; @@ -909,6 +892,7 @@ class _EnvironmentModule implements Module { final Map mixins; final ExtensionStore extensionStore; final CssStylesheet css; + final Map, List> preModuleComments; final bool transitivelyContainsCss; final bool transitivelyContainsExtensions; @@ -924,12 +908,19 @@ class _EnvironmentModule implements Module { final Map> _modulesByVariable; factory _EnvironmentModule( - Environment environment, CssStylesheet css, ExtensionStore extensionStore, + Environment environment, + CssStylesheet css, + Map, List> preModuleComments, + ExtensionStore extensionStore, {Set>? forwarded}) { forwarded ??= const {}; return _EnvironmentModule._( environment, css, + Map.unmodifiable({ + for (var (module, comments) in preModuleComments.pairs) + module: List.unmodifiable(comments) + }), extensionStore, _makeModulesByVariable(forwarded), _memberMap(environment._variables.first, @@ -941,6 +932,7 @@ class _EnvironmentModule implements Module { _memberMap(environment._mixins.first, forwarded.map((module) => module.mixins)), transitivelyContainsCss: css.children.isNotEmpty || + preModuleComments.isNotEmpty || environment._allModules .any((module) => module.transitivelyContainsCss), transitivelyContainsExtensions: !extensionStore.isEmpty || @@ -989,6 +981,7 @@ class _EnvironmentModule implements Module { _EnvironmentModule._( this._environment, this.css, + this.preModuleComments, this.extensionStore, this._modulesByVariable, this.variables, @@ -1000,8 +993,7 @@ class _EnvironmentModule implements Module { : upstream = _environment._allModules; void setVariable(String name, Value value, AstNode nodeWithSpan) { - var module = _modulesByVariable[name]; - if (module != null) { + if (_modulesByVariable[name] case var module?) { module.setVariable(name, value, nodeWithSpan); return; } @@ -1024,11 +1016,13 @@ class _EnvironmentModule implements Module { Module cloneCss() { if (!transitivelyContainsCss) return this; - var newCssAndExtensionStore = cloneCssStylesheet(css, extensionStore); + var (newStylesheet, newExtensionStore) = + cloneCssStylesheet(css, extensionStore); return _EnvironmentModule._( _environment, - newCssAndExtensionStore.item1, - newCssAndExtensionStore.item2, + newStylesheet, + preModuleComments, + newExtensionStore, _modulesByVariable, variables, variableNodes, diff --git a/lib/src/evaluation_context.dart b/lib/src/evaluation_context.dart index 5a347f7f4..5c1b074a9 100644 --- a/lib/src/evaluation_context.dart +++ b/lib/src/evaluation_context.dart @@ -6,19 +6,23 @@ import 'dart:async'; import 'package:source_span/source_span.dart'; +import 'deprecation.dart'; + /// An interface that exposes information about the current Sass evaluation. /// /// This allows us to expose zone-scoped information without having to create a /// new zone variable for each piece of information. -abstract class EvaluationContext { +abstract interface class EvaluationContext { /// The current evaluation context. /// /// Throws [StateError] if there isn't a Sass stylesheet currently being /// evaluated. static EvaluationContext get current { - var context = Zone.current[#_evaluationContext]; - if (context is EvaluationContext) return context; - throw StateError("No Sass stylesheet is currently being evaluated."); + if (Zone.current[#_evaluationContext] case EvaluationContext context) { + return context; + } else { + throw StateError("No Sass stylesheet is currently being evaluated."); + } } /// Returns the span for the currently executing callable. @@ -33,9 +37,9 @@ abstract class EvaluationContext { /// Prints a warning message associated with the current `@import` or function /// call. /// - /// If [deprecation] is `true`, the warning is emitted as a deprecation - /// warning. - void warn(String message, {bool deprecation = false}); + /// If [deprecation] is non-null, the warning is emitted as a deprecation + /// warning of that type. + void warn(String message, [Deprecation? deprecation]); } /// Prints a warning message associated with the current `@import` or function @@ -44,10 +48,15 @@ abstract class EvaluationContext { /// If [deprecation] is `true`, the warning is emitted as a deprecation warning. /// /// 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); + EvaluationContext.current + .warn(message, deprecation ? Deprecation.userAuthored : null); + +/// Prints a deprecation warning with [message] of type [deprecation]. +void warnForDeprecation(String message, Deprecation deprecation) { + EvaluationContext.current.warn(message, deprecation); +} /// Runs [callback] with [context] as [EvaluationContext.current]. /// diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 0c4c20701..38a1a057e 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -24,7 +24,35 @@ class SassException extends SourceSpanException { FileSpan get span => super.span as FileSpan; - SassException(String message, FileSpan span) : super(message, span); + /// The set of canonical stylesheet URLs that were loaded in the course of the + /// compilation, before it failed. + final Set loadedUrls; + + SassException(String message, FileSpan span, [Iterable? loadedUrls]) + : loadedUrls = + loadedUrls == null ? const {} : Set.unmodifiable(loadedUrls), + super(message, span); + + /// Converts this to a [MultiSpanSassException] with the additional [span] and + /// [label]. + /// + /// @nodoc + @internal + MultiSpanSassException withAdditionalSpan(FileSpan span, String label) => + MultiSpanSassException(message, this.span, "", {span: label}, loadedUrls); + + /// Returns a copy of this as a [SassRuntimeException] with [trace] as its + /// Sass stack trace. + /// + /// @nodoc + @internal + SassRuntimeException withTrace(Trace trace) => + SassRuntimeException(message, span, trace, loadedUrls); + + /// Returns a copy of this with [loadedUrls] set to the given value. + @internal + SassException withLoadedUrls(Iterable loadedUrls) => + SassException(message, span, loadedUrls); String toString({Object? color}) { var buffer = StringBuffer() @@ -93,9 +121,22 @@ class MultiSpanSassException extends SassException final Map secondarySpans; MultiSpanSassException(String message, FileSpan span, this.primaryLabel, - Map secondarySpans) + Map secondarySpans, + [Iterable? loadedUrls]) : secondarySpans = Map.unmodifiable(secondarySpans), - super(message, span); + super(message, span, loadedUrls); + + MultiSpanSassException withAdditionalSpan(FileSpan span, String label) => + MultiSpanSassException(message, this.span, primaryLabel, + {...secondarySpans, span: label}, loadedUrls); + + MultiSpanSassRuntimeException withTrace(Trace trace) => + MultiSpanSassRuntimeException( + message, span, primaryLabel, secondarySpans, trace, loadedUrls); + + MultiSpanSassException withLoadedUrls(Iterable loadedUrls) => + MultiSpanSassException( + message, span, primaryLabel, secondarySpans, loadedUrls); String toString({Object? color, String? secondaryColor}) { var useColor = false; @@ -129,8 +170,17 @@ class MultiSpanSassException extends SassException class SassRuntimeException extends SassException { final Trace trace; - SassRuntimeException(String message, FileSpan span, this.trace) - : super(message, span); + MultiSpanSassRuntimeException withAdditionalSpan( + FileSpan span, String label) => + MultiSpanSassRuntimeException( + message, this.span, "", {span: label}, trace, loadedUrls); + + SassRuntimeException withLoadedUrls(Iterable loadedUrls) => + SassRuntimeException(message, span, trace, loadedUrls); + + SassRuntimeException(String message, FileSpan span, this.trace, + [Iterable? loadedUrls]) + : super(message, span, loadedUrls); } /// A [SassRuntimeException] that's also a [MultiSpanSassException]. @@ -139,8 +189,18 @@ class MultiSpanSassRuntimeException extends MultiSpanSassException final Trace trace; MultiSpanSassRuntimeException(String message, FileSpan span, - String primaryLabel, Map secondarySpans, this.trace) - : super(message, span, primaryLabel, secondarySpans); + String primaryLabel, Map secondarySpans, this.trace, + [Iterable? loadedUrls]) + : super(message, span, primaryLabel, secondarySpans, loadedUrls); + + MultiSpanSassRuntimeException withAdditionalSpan( + FileSpan span, String label) => + MultiSpanSassRuntimeException(message, this.span, primaryLabel, + {...secondarySpans, span: label}, trace, loadedUrls); + + MultiSpanSassRuntimeException withLoadedUrls(Iterable loadedUrls) => + MultiSpanSassRuntimeException( + message, span, primaryLabel, secondarySpans, trace, loadedUrls); } /// An exception thrown when Sass parsing has failed. @@ -153,7 +213,45 @@ class SassFormatException extends SassException int get offset => span.start.offset; - SassFormatException(String message, FileSpan span) : super(message, span); + /// @nodoc + @internal + MultiSpanSassFormatException withAdditionalSpan( + FileSpan span, String label) => + MultiSpanSassFormatException( + message, this.span, "", {span: label}, loadedUrls); + + /// @nodoc + SassFormatException withLoadedUrls(Iterable loadedUrls) => + SassFormatException(message, span, loadedUrls); + + SassFormatException(String message, FileSpan span, + [Iterable? loadedUrls]) + : super(message, span, loadedUrls); +} + +/// A [SassFormatException] that's also a [MultiSpanFormatException]. +/// +/// {@category Parsing} +@sealed +class MultiSpanSassFormatException extends MultiSpanSassException + implements MultiSourceSpanFormatException, SassFormatException { + String get source => span.file.getText(0); + + int get offset => span.start.offset; + + MultiSpanSassFormatException withAdditionalSpan( + FileSpan span, String label) => + MultiSpanSassFormatException(message, this.span, primaryLabel, + {...secondarySpans, span: label}, loadedUrls); + + MultiSpanSassFormatException withLoadedUrls(Iterable loadedUrls) => + MultiSpanSassFormatException( + message, span, primaryLabel, secondarySpans, loadedUrls); + + MultiSpanSassFormatException(String message, FileSpan span, + String primaryLabel, Map secondarySpans, + [Iterable? loadedUrls]) + : super(message, span, primaryLabel, secondarySpans, loadedUrls); } /// An exception thrown by SassScript. @@ -173,6 +271,9 @@ class SassScriptException { SassScriptException(String message, [String? argumentName]) : message = argumentName == null ? message : "\$$argumentName: $message"; + /// Converts this to a [SassException] with the given [span]. + SassException withSpan(FileSpan span) => SassException(message, span); + String toString() => "$message\n\nBUG: This should include a source span!"; } @@ -189,4 +290,8 @@ class MultiSpanSassScriptException extends SassScriptException { String message, this.primaryLabel, Map secondarySpans) : secondarySpans = Map.unmodifiable(secondarySpans), super(message); + + /// Converts this to a [SassException] with the given primary [span]. + MultiSpanSassException withSpan(FileSpan span) => + MultiSpanSassException(message, span, primaryLabel, secondarySpans); } diff --git a/lib/src/executable/compile_stylesheet.dart b/lib/src/executable/compile_stylesheet.dart index 05c079698..8c24ee494 100644 --- a/lib/src/executable/compile_stylesheet.dart +++ b/lib/src/executable/compile_stylesheet.dart @@ -72,7 +72,9 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, - charset: options.charset) + charset: options.charset, + fatalDeprecations: options.fatalDeprecations, + futureDeprecations: options.futureDeprecations) : await compileAsync(source, syntax: syntax, logger: options.logger, @@ -81,7 +83,9 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, - charset: options.charset); + charset: options.charset, + fatalDeprecations: options.fatalDeprecations, + futureDeprecations: options.futureDeprecations); } else { result = source == null ? compileString(await readStdin(), @@ -93,7 +97,9 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, - charset: options.charset) + charset: options.charset, + fatalDeprecations: options.fatalDeprecations, + futureDeprecations: options.futureDeprecations) : compile(source, syntax: syntax, logger: options.logger, @@ -102,7 +108,9 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, quietDeps: options.quietDeps, verbose: options.verbose, sourceMap: options.emitSourceMap, - charset: options.charset); + charset: options.charset, + fatalDeprecations: options.fatalDeprecations, + futureDeprecations: options.futureDeprecations); } } on SassException catch (error) { if (options.emitErrorCss) { @@ -127,13 +135,21 @@ Future compileStylesheet(ExecutableOptions options, StylesheetGraph graph, if (options.quiet || (!options.update && !options.watch)) return; var buffer = StringBuffer(); - if (options.color) buffer.write('\u001b[32m'); var sourceName = source == null ? 'stdin' : p.prettyUri(p.toUri(source)); // `destination` is guaranteed to be non-null in update and watch mode. var destinationName = p.prettyUri(p.toUri(destination!)); + + var nowStr = DateTime.now().toString(); + // Remove fractional seconds from printed timestamp + var timestamp = nowStr.substring(0, nowStr.length - 7); + + if (options.color) buffer.write('\u001b[90m'); + buffer.write('[$timestamp] '); + if (options.color) buffer.write('\u001b[32m'); buffer.write('Compiled $sourceName to $destinationName.'); if (options.color) buffer.write('\u001b[0m'); + print(buffer); } diff --git a/lib/src/executable/options.dart b/lib/src/executable/options.dart index 8687c0d7e..504999c49 100644 --- a/lib/src/executable/options.dart +++ b/lib/src/executable/options.dart @@ -2,14 +2,12 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'dart:collection'; - import 'package:args/args.dart'; import 'package:charcode/charcode.dart'; import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; import 'package:term_glyph/term_glyph.dart' as term_glyph; -import 'package:tuple/tuple.dart'; import '../../sass.dart'; import '../io.dart'; @@ -19,7 +17,7 @@ import '../util/character.dart'; /// /// The constructor and any members may throw [UsageException]s indicating that /// invalid arguments were passed. -class ExecutableOptions { +final class ExecutableOptions { /// The bar character to use in help separators. static final _separatorBar = isWindows ? '=' : '━'; @@ -78,6 +76,42 @@ class ExecutableOptions { ..addFlag('embed-source-map', help: 'Embed source map contents in CSS.', defaultsTo: false); + parser + ..addSeparator(_separator('Warnings')) + ..addFlag('quiet', abbr: 'q', help: "Don't print warnings.") + ..addFlag('quiet-deps', + help: "Don't print compiler warnings from dependencies.\n" + "Stylesheets imported through load paths count as dependencies.") + ..addFlag('verbose', + help: "Print all deprecation warnings even when they're repetitive.") + ..addMultiOption('fatal-deprecation', + 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 + }) + ..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 + }); + parser ..addSeparator(_separator('Other')) ..addFlag('watch', @@ -98,12 +132,6 @@ class ExecutableOptions { abbr: 'c', help: 'Whether to use terminal colors for messages.') ..addFlag('unicode', help: 'Whether to use Unicode characters for messages.') - ..addFlag('quiet', abbr: 'q', help: "Don't print warnings.") - ..addFlag('quiet-deps', - help: "Don't print compiler warnings from dependencies.\n" - "Stylesheets imported through load paths count as dependencies.") - ..addFlag('verbose', - help: "Print all deprecation warnings even when they're repetitive.") ..addFlag('trace', help: 'Print full Dart stack traces for exceptions.') ..addFlag('help', abbr: 'h', help: 'Print this usage information.', negatable: false) @@ -144,10 +172,8 @@ class ExecutableOptions { 'stdin', 'indented', 'style', 'source-map', 'source-map-urls', // 'embed-sources', 'embed-source-map', 'update', 'watch' ]; - for (var option in invalidOptions) { - if (_options.wasParsed(option)) { - throw UsageException("--$option isn't allowed with --interactive."); - } + if (invalidOptions.firstWhereOrNull(_options.wasParsed) case var option?) { + throw UsageException("--$option isn't allowed with --interactive."); } return true; }(); @@ -336,10 +362,7 @@ class ExecutableOptions { continue; } - var sourceAndDestination = _splitSourceAndDestination(argument); - var source = sourceAndDestination.item1; - var destination = sourceAndDestination.item2; - + var (source, destination) = _splitSourceAndDestination(argument); if (!seen.add(source)) _fail('Duplicate source "$source".'); if (source == '-') { @@ -358,7 +381,7 @@ class ExecutableOptions { /// Splits an argument that contains a colon and returns its source and its /// destination component. - Tuple2 _splitSourceAndDestination(String argument) { + (String, String) _splitSourceAndDestination(String argument) { for (var i = 0; i < argument.length; i++) { // A colon at position 1 may be a Windows drive letter and not a // separator. @@ -373,7 +396,7 @@ class ExecutableOptions { } if (nextColon != -1) _fail('"$argument" may only contain one ":".'); - return Tuple2(argument.substring(0, i), argument.substring(i + 1)); + return (argument.substring(0, i), argument.substring(i + 1)); } } @@ -383,7 +406,7 @@ class ExecutableOptions { /// Returns whether [string] contains an absolute Windows path at [index]. bool _isWindowsPath(String string, int index) => string.length > index + 2 && - isAlphabetic(string.codeUnitAt(index)) && + string.codeUnitAt(index).isAlphabetic && string.codeUnitAt(index + 1) == $colon; /// Returns the sub-map of [sourcesToDestinations] for the given [source] and @@ -485,6 +508,43 @@ class ExecutableOptions { : p.absolute(path)); } + /// The set of deprecations that cause errors. + Set get fatalDeprecations => _fatalDeprecations ??= () { + var deprecations = {}; + for (var id in _options['fatal-deprecation'] as List) { + if (Deprecation.fromId(id) case var deprecation?) { + deprecations.add(deprecation); + continue; + } + + try { + var argVersion = Version.parse(id); + // We can't get the version synchronously when running from + // source, so we just ignore this check by using a version higher + // than any that will ever be used. + var sassVersion = Version.parse(const bool.hasEnvironment('version') + ? const String.fromEnvironment('version') + : '1000.0.0'); + if (argVersion > sassVersion) { + _fail('Invalid version $argVersion. --fatal-deprecation ' + 'requires a version less than or equal to the current ' + 'Dart Sass version.'); + } + deprecations.addAll(Deprecation.forVersion(argVersion)); + } on FormatException { + _fail('Invalid deprecation "$id".'); + } + } + return deprecations; + }(); + Set? _fatalDeprecations; + + /// The set of future deprecations that should emit warnings anyway. + Set get futureDeprecations => { + for (var id in _options['future-deprecation'] as List) + Deprecation.fromId(id) ?? _fail('Invalid deprecation "$id".') + }; + /// Returns the value of [name] in [options] if it was explicitly provided by /// the user, and `null` otherwise. Object? _ifParsed(String name) => diff --git a/lib/src/executable/watch.dart b/lib/src/executable/watch.dart index 8989ab50e..1fc8d9f37 100644 --- a/lib/src/executable/watch.dart +++ b/lib/src/executable/watch.dart @@ -13,6 +13,7 @@ import '../exception.dart'; import '../importer/filesystem.dart'; import '../io.dart'; import '../stylesheet_graph.dart'; +import '../util/map.dart'; import '../util/multi_dir_watcher.dart'; import '../utils.dart'; import 'compile_stylesheet.dart'; @@ -40,12 +41,11 @@ Future watch(ExecutableOptions options, StylesheetGraph graph) async { // they currently exist. This ensures that changes that come in update a // known-good state. var watcher = _Watcher(options, graph); - for (var entry in _sourcesToDestinations(options).entries) { - graph.addCanonical(FilesystemImporter('.'), - p.toUri(canonicalize(entry.key)), p.toUri(entry.key), + for (var (source, destination) in _sourcesToDestinations(options).pairs) { + graph.addCanonical( + FilesystemImporter('.'), p.toUri(canonicalize(source)), p.toUri(source), recanonicalize: false); - var success = - await watcher.compile(entry.key, entry.value, ifModified: true); + var success = await watcher.compile(source, destination, ifModified: true); if (!success && options.stopOnError) { dirWatcher.events.listen(null).cancel(); return; @@ -58,7 +58,7 @@ Future watch(ExecutableOptions options, StylesheetGraph graph) async { /// Holds state that's shared across functions that react to changes on the /// filesystem. -class _Watcher { +final class _Watcher { /// The options for the Sass executable. final ExecutableOptions _options; @@ -112,14 +112,16 @@ class _Watcher { /// Prints [message] to standard error, with [stackTrace] if [_options.trace] /// is set. void _printError(String message, StackTrace stackTrace) { - stderr.writeln(message); + var buffer = StringBuffer(message); if (_options.trace) { - stderr.writeln(); - stderr.writeln(Trace.from(stackTrace).terse.toString().trimRight()); + buffer.writeln(); + buffer.writeln(); + buffer.write(Trace.from(stackTrace).terse.toString().trimRight()); } - if (!_options.stopOnError) stderr.writeln(); + if (!_options.stopOnError) buffer.writeln(); + printError(buffer); } /// Listens to `watcher.events` and updates the filesystem accordingly. @@ -136,17 +138,14 @@ class _Watcher { case ChangeType.MODIFY: var success = await _handleModify(event.path); if (!success && _options.stopOnError) return; - break; case ChangeType.ADD: var success = await _handleAdd(event.path); if (!success && _options.stopOnError) return; - break; case ChangeType.REMOVE: var success = await _handleRemove(event.path); if (!success && _options.stopOnError) return; - break; } } } @@ -160,11 +159,12 @@ class _Watcher { // It's important to access the node ahead-of-time because it's possible // that `_graph.reload()` notices the file has been deleted and removes it // from the graph. - var node = _graph.nodes[url]; - if (node == null) return _handleAdd(path); - - _graph.reload(url); - return await _recompileDownstream([node]); + if (_graph.nodes[url] case var node?) { + _graph.reload(url); + return await _recompileDownstream([node]); + } else { + return _handleAdd(path); + } } /// Handles an add event for the stylesheet at [url]. @@ -186,8 +186,7 @@ class _Watcher { var url = _canonicalize(path); if (_graph.nodes.containsKey(url)) { - var destination = _destinationFor(path); - if (destination != null) _delete(destination); + if (_destinationFor(path) case var destination?) _delete(destination); } var downstream = _graph.remove(FilesystemImporter('.'), url); @@ -206,19 +205,17 @@ class _Watcher { var typeForPath = p.PathMap(); for (var event in buffer) { var oldType = typeForPath[event.path]; - if (oldType == null) { - typeForPath[event.path] = event.type; - } else if (event.type == ChangeType.REMOVE) { - typeForPath[event.path] = ChangeType.REMOVE; - } else if (oldType != ChangeType.ADD) { - typeForPath[event.path] = ChangeType.MODIFY; - } + typeForPath[event.path] = switch ((oldType, event.type)) { + (null, var newType) => newType, + (_, ChangeType.REMOVE) => ChangeType.REMOVE, + (ChangeType.ADD, _) => ChangeType.ADD, + (_, _) => ChangeType.MODIFY + }; } return [ - for (var entry in typeForPath.entries) - // PathMap always has nullable keys - WatchEvent(entry.value, entry.key!) + // PathMap always has nullable keys + for (var (path!, type) in typeForPath.pairs) WatchEvent(type, path) ]; }); } @@ -253,10 +250,10 @@ class _Watcher { if (url.scheme != 'file') return true; var source = p.fromUri(url); - var destination = _destinationFor(source); - if (destination == null) return true; - - return await compile(source, destination); + return switch (_destinationFor(source)) { + var destination? => await compile(source, destination), + _ => true + }; } /// If a Sass file at [source] should be compiled to CSS, returns the path to @@ -264,15 +261,17 @@ class _Watcher { /// /// Otherwise, returns `null`. String? _destinationFor(String source) { - var destination = _sourcesToDestinations(_options)[source]; - if (destination != null) return destination; + if (_sourcesToDestinations(_options)[source] case var destination?) { + return destination; + } if (p.basename(source).startsWith('_')) return null; - for (var entry in _sourceDirectoriesToDestinations(_options).entries) { - if (!p.isWithin(entry.key, source)) continue; + for (var (sourceDir, destinationDir) + in _sourceDirectoriesToDestinations(_options).pairs) { + if (!p.isWithin(sourceDir, source)) continue; - var destination = p.join(entry.value, - p.setExtension(p.relative(source, from: entry.key), '.css')); + var destination = p.join(destinationDir, + p.setExtension(p.relative(source, from: sourceDir), '.css')); // Don't compile ".css" files to their own locations. if (!p.equals(destination, source)) return destination; diff --git a/lib/src/extend/empty_extension_store.dart b/lib/src/extend/empty_extension_store.dart index 4bb9e7a29..a00c85d86 100644 --- a/lib/src/extend/empty_extension_store.dart +++ b/lib/src/extend/empty_extension_store.dart @@ -3,17 +3,17 @@ // https://opensource.org/licenses/MIT. import 'package:collection/collection.dart'; -import 'package:source_span/source_span.dart'; -import 'package:tuple/tuple.dart'; import '../ast/css.dart'; -import '../ast/css/modifiable.dart'; import '../ast/selector.dart'; import '../ast/sass.dart'; +import '../util/box.dart'; import 'extension_store.dart'; import 'extension.dart'; -class EmptyExtensionStore implements ExtensionStore { +/// An [ExtensionStore] that contains no extensions and can have no extensions +/// added. +final class EmptyExtensionStore implements ExtensionStore { bool get isEmpty => true; Set get simpleSelectors => const UnmodifiableSetView.empty(); @@ -24,15 +24,14 @@ class EmptyExtensionStore implements ExtensionStore { bool callback(SimpleSelector target)) => const []; - ModifiableCssValue addSelector( - SelectorList selector, FileSpan span, + Box addSelector(SelectorList selector, [List? mediaContext]) { throw UnsupportedError( "addSelector() can't be called for a const ExtensionStore."); } void addExtension( - CssValue extender, SimpleSelector target, ExtendRule extend, + SelectorList extender, SimpleSelector target, ExtendRule extend, [List? mediaContext]) { throw UnsupportedError( "addExtension() can't be called for a const ExtensionStore."); @@ -43,7 +42,6 @@ class EmptyExtensionStore implements ExtensionStore { "addExtensions() can't be called for a const ExtensionStore."); } - Tuple2, ModifiableCssValue>> - clone() => const Tuple2(EmptyExtensionStore(), {}); + (ExtensionStore, Map>) clone() => + const (EmptyExtensionStore(), {}); } diff --git a/lib/src/extend/extender.dart b/lib/src/extend/extender.dart deleted file mode 100644 index 441ca5fd7..000000000 --- a/lib/src/extend/extender.dart +++ /dev/null @@ -1,60 +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 'package:source_span/source_span.dart'; - -import '../ast/css.dart'; -import '../ast/selector.dart'; -import '../exception.dart'; -import '../utils.dart'; - -/// A selector that's extending another selector, such as `A` in `A {@extend -/// B}`. -class Extender { - /// The selector in which the `@extend` appeared. - final ComplexSelector selector; - - /// The minimum specificity required for any selector generated from this - /// extender. - final int specificity; - - /// Whether this extender represents a selector that was originally in the - /// document, rather than one defined with `@extend`. - final bool isOriginal; - - /// The media query context to which this extension is restricted, or `null` - /// if it can apply within any context. - final List? mediaContext; - - /// The span in which this selector was defined. - final FileSpan span; - - /// Creates a new extender. - /// - /// If [specificity] isn't passed, it defaults to `extender.specificity`. - Extender(this.selector, this.span, - {this.mediaContext, int? specificity, bool original = false}) - : specificity = specificity ?? selector.specificity, - isOriginal = original; - - /// Asserts that the [mediaContext] for a selector is compatible with the - /// query context for this extender. - void assertCompatibleMediaContext(List? mediaContext) { - if (this.mediaContext == null) return; - if (mediaContext != null && listEquals(this.mediaContext, mediaContext)) { - return; - } - - throw SassException( - "You may not @extend selectors across media queries.", span); - } - - Extender withSelector(ComplexSelector newSelector) => - Extender(newSelector, span, - mediaContext: mediaContext, - specificity: specificity, - original: isOriginal); - - String toString() => selector.toString(); -} diff --git a/lib/src/extend/extension.dart b/lib/src/extend/extension.dart index 96a901e1f..87f0995d7 100644 --- a/lib/src/extend/extension.dart +++ b/lib/src/extend/extension.dart @@ -34,16 +34,15 @@ class Extension { final FileSpan span; /// Creates a new extension. - Extension( - ComplexSelector extender, FileSpan extenderSpan, this.target, this.span, + Extension(ComplexSelector extender, this.target, this.span, {this.mediaContext, bool optional = false}) - : extender = Extender(extender, extenderSpan), + : extender = Extender(extender), isOptional = optional { this.extender._extension = this; } Extension withExtender(ComplexSelector newExtender) => - Extension(newExtender, extender.span, target, span, + Extension(newExtender, target, span, mediaContext: mediaContext, optional: isOptional); String toString() => @@ -52,7 +51,7 @@ class Extension { /// A selector that's extending another selector, such as `A` in `A {@extend /// B}`. -class Extender { +final class Extender { /// The selector in which the `@extend` appeared. final ComplexSelector selector; @@ -70,13 +69,10 @@ class Extender { /// original selectors that exist in the document. Extension? _extension; - /// The span in which this selector was defined. - final FileSpan span; - /// Creates a new extender. /// /// If [specificity] isn't passed, it defaults to `extender.specificity`. - Extender(this.selector, this.span, {int? specificity, bool original = false}) + Extender(this.selector, {int? specificity, bool original = false}) : specificity = specificity ?? selector.specificity, isOriginal = original; diff --git a/lib/src/extend/extension_store.dart b/lib/src/extend/extension_store.dart index cfeae78a4..a3f276ee9 100644 --- a/lib/src/extend/extension_store.dart +++ b/lib/src/extend/extension_store.dart @@ -6,15 +6,15 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:source_span/source_span.dart'; -import 'package:tuple/tuple.dart'; import '../ast/css.dart'; -import '../ast/css/modifiable.dart'; import '../ast/selector.dart'; import '../ast/sass.dart'; import '../exception.dart'; -import '../utils.dart'; +import '../util/box.dart'; +import '../util/map.dart'; import '../util/nullable.dart'; +import '../utils.dart'; import 'empty_extension_store.dart'; import 'extension.dart'; import 'merged_extension.dart'; @@ -23,7 +23,8 @@ import 'mode.dart'; /// Tracks selectors and extensions, and applies the latter to the former. class ExtensionStore { - /// An [ExtensionStore] that contains no extensions and can have no extensions added. + /// An [ExtensionStore] that contains no extensions and can have no extensions + /// added. static const empty = EmptyExtensionStore(); /// A map from all simple selectors in the stylesheet to the selector lists @@ -31,7 +32,7 @@ class ExtensionStore { /// /// This is used to find which selectors an `@extend` applies to and adjust /// them. - final Map>> _selectors; + final Map>> _selectors; /// A map from all extended simple selectors to the sources of those /// extensions. @@ -45,8 +46,7 @@ class ExtensionStore { /// /// This tracks the contexts in which each selector's style rule is defined. /// If a rule is defined at the top level, it doesn't have an entry. - final Map, List> - _mediaContexts; + final Map, List> _mediaContexts; /// A map from [SimpleSelector]s to the specificity of their source /// selectors. @@ -96,9 +96,7 @@ class ExtensionStore { ExtendMode mode, FileSpan span) { var extender = ExtensionStore._mode(mode); - if (!selector.isInvisible) { - extender._originals.addAll(selector.components); - } + if (!selector.isInvisible) extender._originals.addAll(selector.components); for (var complex in targets.components) { var compound = complex.singleCompound; @@ -106,11 +104,11 @@ class ExtensionStore { throw SassScriptException("Can't extend complex selector $complex."); } - selector = extender._extendList(selector, span, { + selector = extender._extendList(selector, { for (var simple in compound.components) simple: { for (var complex in source.components) - complex: Extension(complex, span, simple, span, optional: true) + complex: Extension(complex, simple, span, optional: true) } }); } @@ -150,9 +148,9 @@ class ExtensionStore { /// returned. Iterable extensionsWhereTarget( bool callback(SimpleSelector target)) sync* { - for (var entry in _extensions.entries) { - if (!callback(entry.key)) continue; - for (var extension in entry.value.values) { + for (var (simple, sources) in _extensions.pairs) { + if (!callback(simple)) continue; + for (var extension in sources.values) { if (extension is MergedExtension) { yield* extension .unmerge() @@ -166,56 +164,49 @@ class ExtensionStore { /// Adds [selector] to this extender. /// - /// Extends [selector] using any registered extensions, then returns an empty - /// [ModifiableCssValue] containing the resulting selector. If any more - /// relevant extensions are added, the returned selector is automatically - /// updated. + /// Extends [selector] using any registered extensions, then returns a [Box] + /// containing the resulting selector. If any more relevant extensions are + /// added, the returned selector is automatically updated. /// /// The [mediaContext] is the media query context in which the selector was /// defined, or `null` if it was defined at the top level of the document. - ModifiableCssValue addSelector( - SelectorList selector, FileSpan selectorSpan, + Box addSelector(SelectorList selector, [List? mediaContext]) { var originalSelector = selector; if (!originalSelector.isInvisible) { - for (var complex in originalSelector.components) { - _originals.add(complex); - } + _originals.addAll(originalSelector.components); } if (_extensions.isNotEmpty) { try { - selector = _extendList( - originalSelector, selectorSpan, _extensions, mediaContext); + selector = _extendList(originalSelector, _extensions, mediaContext); } on SassException catch (error, stackTrace) { throwWithTrace( SassException( "From ${error.span.message('')}\n" "${error.message}", error.span), + error, stackTrace); } } - var modifiableSelector = ModifiableCssValue(selector, selectorSpan); + var modifiableSelector = ModifiableBox(selector); if (mediaContext != null) _mediaContexts[modifiableSelector] = mediaContext; _registerSelector(selector, modifiableSelector); - return modifiableSelector; + return modifiableSelector.seal(); } /// Registers the [SimpleSelector]s in [list] to point to [selector] in /// [_selectors]. void _registerSelector( - SelectorList list, ModifiableCssValue selector) { + SelectorList list, ModifiableBox selector) { for (var complex in list.components) { for (var component in complex.components) { for (var simple in component.selector.components) { _selectors.putIfAbsent(simple, () => {}).add(selector); - if (simple is! PseudoSelector) continue; - - var selectorInPseudo = simple.selector; - if (selectorInPseudo != null) { + if (simple case PseudoSelector(selector: var selectorInPseudo?)) { _registerSelector(selectorInPseudo, selector); } } @@ -233,28 +224,26 @@ class ExtensionStore { /// is defined. It can only extend selectors within the same context. A `null` /// context indicates no media queries. void addExtension( - CssValue extender, SimpleSelector target, ExtendRule extend, + SelectorList extender, SimpleSelector target, ExtendRule extend, [List? mediaContext]) { var selectors = _selectors[target]; var existingExtensions = _extensionsByExtender[target]; Map? newExtensions; var sources = _extensions.putIfAbsent(target, () => {}); - for (var complex in extender.value.components) { + for (var complex in extender.components) { if (complex.isUseless) continue; - var extension = Extension(complex, extender.span, target, extend.span, + var extension = Extension(complex, target, extend.span, mediaContext: mediaContext, optional: extend.isOptional); - var existingExtension = sources[complex]; - if (existingExtension != null) { + if (sources[complex] case var existingExtension?) { // If there's already an extend from [extender] to [target], we don't need // to re-run the extension. We may need to mark the extension as // mandatory, though. sources[complex] = MergedExtension.merge(existingExtension, extension); continue; } - sources[complex] = extension; for (var simple in _simpleSelectors(complex)) { @@ -292,11 +281,10 @@ class ExtensionStore { for (var simple in component.selector.components) { yield simple; - if (simple is! PseudoSelector) continue; - var selector = simple.selector; - if (selector == null) continue; - for (var complex in selector.components) { - yield* _simpleSelectors(complex); + if (simple case PseudoSelector(:var selector?)) { + for (var complex in selector.components) { + yield* _simpleSelectors(complex); + } } } } @@ -325,33 +313,27 @@ class ExtensionStore { for (var extension in extensions.toList()) { var sources = _extensions[extension.target]!; - List? selectors; + Iterable? selectors; try { - selectors = _extendComplex(extension.extender.selector, - extension.extender.span, newExtensions, extension.mediaContext); + selectors = _extendComplex( + extension.extender.selector, newExtensions, extension.mediaContext); if (selectors == null) continue; } on SassException catch (error, stackTrace) { throwWithTrace( - SassException( - "From ${extension.extender.span.message('')}\n" - "${error.message}", - error.span), + error.withAdditionalSpan( + extension.extender.selector.span, "target selector"), + error, stackTrace); } + // If the output contains the original complex selector, there's no need + // to recreate it. var containsExtension = selectors.first == extension.extender.selector; - var first = true; - for (var complex in selectors) { - // If the output contains the original complex selector, there's no - // need to recreate it. - if (containsExtension && first) { - first = false; - continue; - } + if (containsExtension) selectors = selectors.skip(1); + for (var complex in selectors) { var withExtender = extension.withExtender(complex); - var existingExtension = sources[complex]; - if (existingExtension != null) { + if (sources[complex] case var existingExtension?) { sources[complex] = MergedExtension.merge(existingExtension, withExtender); } else { @@ -384,20 +366,21 @@ class ExtensionStore { } /// Extend [extensions] using [newExtensions]. - void _extendExistingSelectors(Set> selectors, + void _extendExistingSelectors(Set> selectors, Map> newExtensions) { for (var selector in selectors) { var oldValue = selector.value; try { - selector.value = _extendList(selector.value, selector.span, - newExtensions, _mediaContexts[selector]); + selector.value = _extendList( + selector.value, newExtensions, _mediaContexts[selector]); } on SassException catch (error, stackTrace) { // TODO(nweiz): Make this a MultiSpanSassException. throwWithTrace( SassException( - "From ${selector.span.message('')}\n" + "From ${selector.value.span.message('')}\n" "${error.message}", error.span), + error, stackTrace); } @@ -419,7 +402,7 @@ class ExtensionStore { // Selectors that contain simple selectors that are extended by // [extensions], and thus which need to be extended themselves. - Set>? selectorsToExtend; + Set>? selectorsToExtend; // An extension map with the same structure as [_extensions] that only // includes extensions from [extensionStores]. @@ -428,9 +411,9 @@ class ExtensionStore { for (var extensionStore in extensionStores) { if (extensionStore.isEmpty) continue; _sourceSpecificity.addAll(extensionStore._sourceSpecificity); - extensionStore._extensions.forEach((target, newSources) { + for (var (target, newSources) in extensionStore._extensions.pairs) { // Private selectors can't be extended across module boundaries. - if (target is PlaceholderSelector && target.isPrivate) return; + if (target case PlaceholderSelector(isPrivate: true)) continue; // Find existing extensions to extend. var extensionsForTarget = _extensionsByExtender[target]; @@ -445,14 +428,8 @@ class ExtensionStore { } // Add [newSources] to [_extensions]. - var existingSources = _extensions[target]; - if (existingSources == null) { - _extensions[target] = Map.of(newSources); - if (extensionsForTarget != null || selectorsForTarget != null) { - (newExtensions ??= {})[target] = Map.of(newSources); - } - } else { - newSources.forEach((extender, extension) { + if (_extensions[target] case var existingSources?) { + for (var (extender, extension) in newSources.pairs) { extension = existingSources.putOrMerge( extender, extension, MergedExtension.merge); @@ -460,25 +437,31 @@ class ExtensionStore { (newExtensions ??= {}).putIfAbsent(target, () => {})[extender] = extension; } - }); + } + } else { + _extensions[target] = Map.of(newSources); + if (extensionsForTarget != null || selectorsForTarget != null) { + (newExtensions ??= {})[target] = Map.of(newSources); + } } - }); + } } - // We can't just naively check for `null` here due to dart-lang/sdk#45348. - newExtensions.andThen((newExtensions) { + if (newExtensions != null) { // We can ignore the return value here because it's only useful for extend // loops, which can't exist across module boundaries. - extensionsToExtend.andThen((extensionsToExtend) => - _extendExistingExtensions(extensionsToExtend, newExtensions)); + if (extensionsToExtend != null) { + _extendExistingExtensions(extensionsToExtend, newExtensions); + } - selectorsToExtend.andThen((selectorsToExtend) => - _extendExistingSelectors(selectorsToExtend, newExtensions)); - }); + if (selectorsToExtend != null) { + _extendExistingSelectors(selectorsToExtend, newExtensions); + } + } } /// Extends [list] using [extensions]. - SelectorList _extendList(SelectorList list, FileSpan listSpan, + SelectorList _extendList(SelectorList list, Map> extensions, [List? mediaQueryContext]) { // This could be written more simply using [List.map], but we want to avoid @@ -486,14 +469,13 @@ class ExtensionStore { List? extended; for (var i = 0; i < list.components.length; i++) { var complex = list.components[i]; - var result = - _extendComplex(complex, listSpan, extensions, mediaQueryContext); + var result = _extendComplex(complex, extensions, mediaQueryContext); assert( result?.isNotEmpty ?? true, '_extendComplex($complex) should return null rather than [] if ' 'extension fails'); if (result == null) { - if (extended != null) extended.add(complex); + extended?.add(complex); } else { extended ??= i == 0 ? [] : list.components.sublist(0, i).toList(); extended.addAll(result); @@ -501,14 +483,13 @@ class ExtensionStore { } if (extended == null) return list; - return SelectorList(_trim(extended, _originals.contains)); + return SelectorList(_trim(extended, _originals.contains), list.span); } /// Extends [complex] using [extensions], and returns the contents of a /// [SelectorList]. List? _extendComplex( ComplexSelector complex, - FileSpan complexSpan, Map> extensions, List? mediaQueryContext) { if (complex.leadingCombinators.length > 1) return null; @@ -534,8 +515,7 @@ class ExtensionStore { var isOriginal = _originals.contains(complex); for (var i = 0; i < complex.components.length; i++) { var component = complex.components[i]; - var extended = _extendCompound( - component, complexSpan, extensions, mediaQueryContext, + var extended = _extendCompound(component, extensions, mediaQueryContext, inOriginal: isOriginal); assert( extended?.isNotEmpty ?? true, @@ -543,15 +523,16 @@ class ExtensionStore { 'extension fails'); if (extended == null) { extendedNotExpanded?.add([ - ComplexSelector(const [], [component], lineBreak: complex.lineBreak) + ComplexSelector(const [], [component], complex.span, + lineBreak: complex.lineBreak) ]); } else if (extendedNotExpanded != null) { extendedNotExpanded.add(extended); } else if (i != 0) { extendedNotExpanded = [ [ - ComplexSelector( - complex.leadingCombinators, complex.components.take(i), + ComplexSelector(complex.leadingCombinators, + complex.components.take(i), complex.span, lineBreak: complex.lineBreak) ], extended @@ -565,8 +546,8 @@ class ExtensionStore { if (newComplex.leadingCombinators.isEmpty || listEquals(complex.leadingCombinators, newComplex.leadingCombinators)) - ComplexSelector( - complex.leadingCombinators, newComplex.components, + ComplexSelector(complex.leadingCombinators, + newComplex.components, complex.span, lineBreak: complex.lineBreak || newComplex.lineBreak) ] ]; @@ -576,7 +557,7 @@ class ExtensionStore { var first = true; return paths(extendedNotExpanded).expand((path) { - return weave(path, forceLineBreak: complex.lineBreak) + return weave(path, complex.span, forceLineBreak: complex.lineBreak) .map((outputComplex) { // Make sure that copies of [complex] retain their status as "original" // selectors. This includes selectors that are modified because a :not() @@ -601,7 +582,6 @@ class ExtensionStore { /// complex selector with a line break. List? _extendCompound( ComplexSelectorComponent component, - FileSpan componentSpan, Map> extensions, List? mediaQueryContext, {required bool inOriginal}) { @@ -617,19 +597,20 @@ class ExtensionStore { List>? options; for (var i = 0; i < simples.length; i++) { var simple = simples[i]; - var extended = _extendSimple( - simple, componentSpan, extensions, mediaQueryContext, targetsUsed); + var extended = + _extendSimple(simple, extensions, mediaQueryContext, targetsUsed); assert( extended?.isNotEmpty ?? true, '_extendSimple($simple) should return null rather than [] if ' 'extension fails'); if (extended == null) { - options?.add([_extenderForSimple(simple, componentSpan)]); + options?.add([_extenderForSimple(simple)]); } else { if (options == null) { options = []; if (i != 0) { - options.add([_extenderForCompound(simples.take(i), componentSpan)]); + options + .add([_extenderForCompound(simples.take(i), component.span)]); } } @@ -646,9 +627,9 @@ class ExtensionStore { // Optimize for the simple case of a single simple selector that doesn't // need any unification. - if (options.length == 1) { + if (options case [var extenders]) { List? result; - for (var extender in options.first) { + for (var extender in extenders) { extender.assertCompatibleMediaContext(mediaQueryContext); var complex = extender.selector.withAdditionalCombinators(component.combinators); @@ -702,14 +683,16 @@ class ExtensionStore { ComplexSelector(const [], [ ComplexSelectorComponent( CompoundSelector(extenderPaths.first.expand((extender) { - assert(extender.selector.components.length == 1); - return extender.selector.components.last.selector.components; - })), component.combinators) - ]) + assert(extender.selector.components.length == 1); + return extender.selector.components.last.selector.components; + }), component.selector.span), + component.combinators, + component.span) + ], component.span) ]; for (var path in extenderPaths.skip(_mode == ExtendMode.replace ? 0 : 1)) { - var extended = _unifyExtenders(path, mediaQueryContext); + var extended = _unifyExtenders(path, mediaQueryContext, component.span); if (extended == null) continue; for (var complex in extended) { @@ -732,8 +715,10 @@ class ExtensionStore { /// Returns a list of [ComplexSelector]s that match the intersection of /// elements matched by all of [extenders]' selectors. - List? _unifyExtenders( - List extenders, List? mediaQueryContext) { + /// + /// The [span] will be used for the new selectors. + List? _unifyExtenders(List extenders, + List? mediaQueryContext, FileSpan span) { var toUnify = QueueList(); List? originals; var originalsLineBreak = false; @@ -753,11 +738,12 @@ class ExtensionStore { if (originals != null) { toUnify.addFirst(ComplexSelector(const [], [ - ComplexSelectorComponent(CompoundSelector(originals), const []) - ], lineBreak: originalsLineBreak)); + ComplexSelectorComponent( + CompoundSelector(originals, span), const [], span) + ], span, lineBreak: originalsLineBreak)); } - var complexes = unifyComplex(toUnify); + var complexes = unifyComplex(toUnify, span); if (complexes == null) return null; for (var extender in extenders) { @@ -774,7 +760,6 @@ class ExtensionStore { /// combined using [paths]. Iterable>? _extendSimple( SimpleSelector simple, - FileSpan simpleSpan, Map> extensions, List? mediaQueryContext, Set? targetsUsed) { @@ -786,17 +771,16 @@ class ExtensionStore { targetsUsed?.add(simple); return [ - if (_mode != ExtendMode.replace) _extenderForSimple(simple, simpleSpan), + if (_mode != ExtendMode.replace) _extenderForSimple(simple), for (var extension in extensionsForSimple.values) extension.extender ]; } - if (simple is PseudoSelector && simple.selector != null) { - var extended = - _extendPseudo(simple, simpleSpan, extensions, mediaQueryContext); - if (extended != null) { - return extended.map((pseudo) => - withoutPseudo(pseudo) ?? [_extenderForSimple(pseudo, simpleSpan)]); + if (simple case PseudoSelector(selector: _?)) { + if (_extendPseudo(simple, extensions, mediaQueryContext) + case var extended?) { + return extended.map( + (pseudo) => withoutPseudo(pseudo) ?? [_extenderForSimple(pseudo)]); } } @@ -807,21 +791,20 @@ class ExtensionStore { /// [simples]. Extender _extenderForCompound( Iterable simples, FileSpan span) { - var compound = CompoundSelector(simples); + var compound = CompoundSelector(simples, span); return Extender( - ComplexSelector( - const [], [ComplexSelectorComponent(compound, const [])]), - span, + ComplexSelector(const [], + [ComplexSelectorComponent(compound, const [], span)], span), specificity: _sourceSpecificityFor(compound), original: true); } /// Returns an [Extender] composed solely of [simple]. - Extender _extenderForSimple(SimpleSelector simple, FileSpan span) => Extender( + Extender _extenderForSimple(SimpleSelector simple) => Extender( ComplexSelector(const [], [ - ComplexSelectorComponent(CompoundSelector([simple]), const []) - ]), - span, + ComplexSelectorComponent( + CompoundSelector([simple], simple.span), const [], simple.span) + ], simple.span), specificity: _sourceSpecificity[simple] ?? 0, original: true); @@ -831,7 +814,6 @@ class ExtensionStore { /// This requires that [pseudo] have a selector argument. List? _extendPseudo( PseudoSelector pseudo, - FileSpan pseudoSpan, Map> extensions, List? mediaQueryContext) { var selector = pseudo.selector; @@ -839,8 +821,7 @@ class ExtensionStore { throw ArgumentError("Selector $pseudo must have a selector argument."); } - var extended = - _extendList(selector, pseudoSpan, extensions, mediaQueryContext); + var extended = _extendList(selector, extensions, mediaQueryContext); if (identical(extended, selector)) return null; // For `:not()`, we usually want to get rid of any complex selectors because @@ -909,11 +890,12 @@ class ExtensionStore { // unless it originally contained a selector list. if (pseudo.normalizedName == 'not' && selector.components.length == 1) { var result = complexes - .map((complex) => pseudo.withSelector(SelectorList([complex]))) + .map((complex) => + pseudo.withSelector(SelectorList([complex], selector.span))) .toList(); return result.isEmpty ? null : result; } else { - return [pseudo.withSelector(SelectorList(complexes))]; + return [pseudo.withSelector(SelectorList(complexes, selector.span))]; } } @@ -996,40 +978,38 @@ class ExtensionStore { return specificity; } - /// Returns a copy of [this] that extends new selectors, as well as a map from - /// the selectors extended by [this] to the selectors extended by the new - /// [ExtensionStore]. - Tuple2, ModifiableCssValue>> clone() { - var newSelectors = - >>{}; - var newMediaContexts = - , List>{}; - var oldToNewSelectors = - , ModifiableCssValue>{}; + /// 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 = >>{}; + var newMediaContexts = , List>{}; + var oldToNewSelectors = Map>.identity(); _selectors.forEach((simple, selectors) { - var newSelectorSet = >{}; + var newSelectorSet = >{}; newSelectors[simple] = newSelectorSet; for (var selector in selectors) { - var newSelector = ModifiableCssValue(selector.value, selector.span); + var newSelector = ModifiableBox(selector.value); newSelectorSet.add(newSelector); - oldToNewSelectors[selector] = newSelector; + oldToNewSelectors[selector.value] = newSelector.seal(); - var mediaContext = _mediaContexts[selector]; - if (mediaContext != null) newMediaContexts[newSelector] = mediaContext; + if (_mediaContexts[selector] case var mediaContext?) { + newMediaContexts[newSelector] = mediaContext; + } } }); - return Tuple2( - ExtensionStore._( - newSelectors, - copyMapOfMap(_extensions), - copyMapOfList(_extensionsByExtender), - newMediaContexts, - Map.identity()..addAll(_sourceSpecificity), - Set.identity()..addAll(_originals)), - oldToNewSelectors); + return ( + ExtensionStore._( + newSelectors, + copyMapOfMap(_extensions), + copyMapOfList(_extensionsByExtender), + newMediaContexts, + Map.identity()..addAll(_sourceSpecificity), + Set.identity()..addAll(_originals)), + oldToNewSelectors + ); } } diff --git a/lib/src/extend/functions.dart b/lib/src/extend/functions.dart index dc11b399a..6299c5fcf 100644 --- a/lib/src/extend/functions.dart +++ b/lib/src/extend/functions.dart @@ -13,9 +13,12 @@ import 'dart:collection'; import 'package:collection/collection.dart'; -import 'package:tuple/tuple.dart'; +import 'package:source_span/source_span.dart'; +import '../ast/css/value.dart'; import '../ast/selector.dart'; +import '../util/iterable.dart'; +import '../util/span.dart'; import '../utils.dart'; /// Pseudo-selectors that can only meaningfully appear in the first component of @@ -25,29 +28,36 @@ final _rootishPseudoClasses = {'root', 'scope', 'host', 'host-context'}; /// Returns the contents of a [SelectorList] that matches only elements that are /// matched by every complex selector in [complexes]. /// +/// The [span] is used for the unified complex selectors. +/// /// If no such list can be produced, returns `null`. -List? unifyComplex(List complexes) { +List? unifyComplex( + List complexes, FileSpan span) { if (complexes.length == 1) return complexes; List? unifiedBase; - Combinator? leadingCombinator; - Combinator? trailingCombinator; + CssValue? leadingCombinator; + CssValue? trailingCombinator; for (var complex in complexes) { if (complex.isUseless) return null; - if (complex.components.length == 1 && - complex.leadingCombinators.isNotEmpty) { - var newLeadingCombinator = complex.leadingCombinators.single; - if (leadingCombinator != null && - leadingCombinator != newLeadingCombinator) { + if (complex + case ComplexSelector( + components: [_], + leadingCombinators: [var newLeadingCombinator] + )) { + if (leadingCombinator == null) { + leadingCombinator = newLeadingCombinator; + } else if (leadingCombinator != newLeadingCombinator) { return null; } - leadingCombinator = newLeadingCombinator; } var base = complex.components.last; - if (base.combinators.isNotEmpty) { - var newTrailingCombinator = base.combinators.single; + if (base + case ComplexSelectorComponent( + combinators: [var newTrailingCombinator] + )) { if (trailingCombinator != null && trailingCombinator != newTrailingCombinator) { return null; @@ -68,70 +78,59 @@ List? unifyComplex(List complexes) { var withoutBases = [ for (var complex in complexes) if (complex.components.length > 1) - ComplexSelector( - complex.leadingCombinators, complex.components.exceptLast, + ComplexSelector(complex.leadingCombinators, + complex.components.exceptLast, complex.span, lineBreak: complex.lineBreak), ]; var base = ComplexSelector( leadingCombinator == null ? const [] : [leadingCombinator], [ - ComplexSelectorComponent(CompoundSelector(unifiedBase!), - trailingCombinator == null ? const [] : [trailingCombinator]) + ComplexSelectorComponent(CompoundSelector(unifiedBase!, span), + trailingCombinator == null ? const [] : [trailingCombinator], span) ], + span, lineBreak: complexes.any((complex) => complex.lineBreak)); - return weave(withoutBases.isEmpty - ? [base] - : [...withoutBases.exceptLast, withoutBases.last.concatenate(base)]); + return weave( + withoutBases.isEmpty + ? [base] + : [ + ...withoutBases.exceptLast, + withoutBases.last.concatenate(base, span) + ], + span); } /// 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. +/// /// If no such selector can be produced, returns `null`. CompoundSelector? unifyCompound( - List compound1, List compound2) { - var result = compound2; - for (var simple in compound1) { + 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; } - return CompoundSelector(result); + return CompoundSelector(result, compound1.span); } /// Returns a [SimpleSelector] that matches only elements that are matched by /// both [selector1] and [selector2], which must both be either /// [UniversalSelector]s or [TypeSelector]s. /// +/// The [span] will be used for the new unified selector. +/// /// If no such selector can be produced, returns `null`. SimpleSelector? unifyUniversalAndElement( SimpleSelector selector1, SimpleSelector selector2) { - String? namespace1; - String? name1; - if (selector1 is UniversalSelector) { - namespace1 = selector1.namespace; - } else if (selector1 is TypeSelector) { - namespace1 = selector1.name.namespace; - name1 = selector1.name.name; - } else { - throw ArgumentError.value(selector1, 'selector1', - 'must be a UniversalSelector or a TypeSelector'); - } - - String? namespace2; - String? name2; - if (selector2 is UniversalSelector) { - namespace2 = selector2.namespace; - } else if (selector2 is TypeSelector) { - namespace2 = selector2.name.namespace; - name2 = selector2.name.name; - } else { - throw ArgumentError.value(selector2, 'selector2', - 'must be a UniversalSelector or a TypeSelector'); - } + var (namespace1, name1) = _namespaceAndName(selector1, 'selector1'); + var (namespace2, name2) = _namespaceAndName(selector2, 'selector2'); String? namespace; if (namespace1 == namespace2 || namespace2 == '*') { @@ -152,10 +151,26 @@ SimpleSelector? unifyUniversalAndElement( } return name == null - ? UniversalSelector(namespace: namespace) - : TypeSelector(QualifiedName(name, namespace: namespace)); + ? UniversalSelector(selector1.span, namespace: namespace) + : TypeSelector(QualifiedName(name, namespace: namespace), selector1.span); } +/// Returns the namespace and name for [selector], which must be a +/// [UniversalSelector] or a [TypeSelector]. +/// +/// The [name] parameter is used for error reporting. +(String? namespace, String? name) _namespaceAndName( + SimpleSelector selector, String name) => + switch (selector) { + UniversalSelector(:var namespace) => (namespace, null), + TypeSelector(name: QualifiedName(:var name, :var namespace)) => ( + namespace, + name + ), + _ => throw ArgumentError.value( + selector, name, 'must be a UniversalSelector or a TypeSelector') + }; + /// Expands "parenthesized selectors" in [complexes]. /// /// That is, if we have `.A .B {@extend .C}` and `.D .C {...}`, this @@ -166,36 +181,36 @@ SimpleSelector? unifyUniversalAndElement( /// /// The selector `.D (.A .B)` is represented as the list `[.D, .A .B]`. /// +/// The [span] will be used for any new combined selectors. +/// /// If [forceLineBreak] is `true`, this will mark all returned complex selectors /// as having line breaks. -List weave(List complexes, +List weave(List complexes, FileSpan span, {bool forceLineBreak = false}) { - if (complexes.length == 1) { - var complex = complexes.first; + if (complexes case [var complex]) { if (!forceLineBreak || complex.lineBreak) return complexes; return [ - ComplexSelector(complex.leadingCombinators, complex.components, + ComplexSelector( + complex.leadingCombinators, complex.components, complex.span, lineBreak: true) ]; } var prefixes = [complexes.first]; - for (var complex in complexes.skip(1)) { - var target = complex.components.last; if (complex.components.length == 1) { for (var i = 0; i < prefixes.length; i++) { - prefixes[i] = - prefixes[i].concatenate(complex, forceLineBreak: forceLineBreak); + prefixes[i] = prefixes[i] + .concatenate(complex, span, forceLineBreak: forceLineBreak); } continue; } prefixes = [ for (var prefix in prefixes) - for (var parentPrefix - in _weaveParents(prefix, complex) ?? const []) - parentPrefix.withAdditionalComponent(target, + for (var parentPrefix in _weaveParents(prefix, complex, span) ?? + const []) + parentPrefix.withAdditionalComponent(complex.components.last, span, forceLineBreak: forceLineBreak), ]; } @@ -219,9 +234,11 @@ List weave(List complexes, /// elements matched by `P`. Some `PC_i` are elided to reduce the size of the /// output. /// +/// The [span] will be used for any new combined selectors. +/// /// Returns `null` if this intersection is empty. Iterable? _weaveParents( - ComplexSelector prefix, ComplexSelector base) { + ComplexSelector prefix, ComplexSelector base, FileSpan span) { var leadingCombinators = _mergeLeadingCombinators( prefix.leadingCombinators, base.leadingCombinators); if (leadingCombinators == null) return null; @@ -229,29 +246,30 @@ Iterable? _weaveParents( // Make queues of _only_ the parent selectors. The prefix only contains // parents, but the complex selector has a target that we don't want to weave // in. - var queue1 = Queue.of(prefix.components); - var queue2 = Queue.of(base.components.exceptLast); + var queue1 = QueueList.from(prefix.components); + var queue2 = QueueList.from(base.components.exceptLast); - var trailingCombinators = _mergeTrailingCombinators(queue1, queue2); + var trailingCombinators = _mergeTrailingCombinators(queue1, queue2, span); if (trailingCombinators == null) return null; // Make sure all selectors that are required to be at the root are unified // with one another. - var rootish1 = _firstIfRootish(queue1); - var rootish2 = _firstIfRootish(queue2); - if (rootish1 != null && rootish2 != null) { - var rootish = unifyCompound( - rootish1.selector.components, rootish2.selector.components); - if (rootish == null) return null; - queue1.addFirst(ComplexSelectorComponent(rootish, rootish1.combinators)); - queue2.addFirst(ComplexSelectorComponent(rootish, rootish2.combinators)); - } else if (rootish1 != null || rootish2 != null) { - // If there's only one rootish selector, it should only appear in the first - // position of the resulting selector. We can ensure that happens by adding - // it to the beginning of _both_ queues. - var rootish = (rootish1 ?? rootish2)!; - queue1.addFirst(rootish); - queue2.addFirst(rootish); + switch ((_firstIfRootish(queue1), _firstIfRootish(queue2))) { + case (var rootish1?, var rootish2?): + var rootish = unifyCompound(rootish1.selector, rootish2.selector); + if (rootish == null) return null; + queue1.addFirst(ComplexSelectorComponent( + rootish, rootish1.combinators, rootish1.span)); + queue2.addFirst(ComplexSelectorComponent( + rootish, rootish2.combinators, rootish1.span)); + + case (var rootish?, null): + case (null, var rootish?): + // If there's only one rootish selector, it should only appear in the first + // position of the resulting selector. We can ensure that happens by adding + // it to the beginning of _both_ queues. + queue1.addFirst(rootish); + queue2.addFirst(rootish); } var groups1 = _groupSelectors(queue1); @@ -263,11 +281,11 @@ Iterable? _weaveParents( if (_complexIsParentSuperselector(group2, group1)) return group1; if (!_mustUnify(group1, group2)) return null; - var unified = unifyComplex( - [ComplexSelector(const [], group1), ComplexSelector(const [], group2)]); - if (unified == null) return null; - if (unified.length > 1) return null; - return unified.first.components; + var unified = unifyComplex([ + ComplexSelector(const [], group1, span), + ComplexSelector(const [], group2, span) + ], span); + return unified?.singleOrNull?.components; }); var choices = >>[]; @@ -291,8 +309,8 @@ Iterable? _weaveParents( return [ for (var path in paths(choices.where((choice) => choice.isNotEmpty))) - ComplexSelector( - leadingCombinators, [for (var components in path) ...components], + ComplexSelector(leadingCombinators, + [for (var components in path) ...components], span, lineBreak: prefix.lineBreak || base.lineBreak) ]; } @@ -301,17 +319,17 @@ Iterable? _weaveParents( /// appear in a complex selector's first component, removes and returns that /// element. ComplexSelectorComponent? _firstIfRootish( - Queue queue) { - if (queue.isEmpty) return null; - var first = queue.first; - for (var simple in first.selector.components) { - if (simple is PseudoSelector && - simple.isClass && - _rootishPseudoClasses.contains(simple.normalizedName)) { - queue.removeFirst(); - return first; + QueueList queue) { + if (queue case [var first, ...]) { + for (var simple in first.selector.components) { + if (simple case PseudoSelector(isClass: true, :var normalizedName) + when _rootishPseudoClasses.contains(normalizedName)) { + queue.removeFirst(); + return first; + } } } + return null; } @@ -319,17 +337,16 @@ ComplexSelectorComponent? _firstIfRootish( /// and [combinators2]. /// /// Returns `null` if the combinator lists can't be unified. -List? _mergeLeadingCombinators( - List? combinators1, List? combinators2) { - // Allow null arguments just to make calls to `Iterable.reduce()` easier. - if (combinators1 == null) return null; - if (combinators2 == null) return null; - if (combinators1.length > 1) return null; - if (combinators2.length > 1) return null; - if (combinators1.isEmpty) return combinators2; - if (combinators2.isEmpty) return combinators1; - return listEquals(combinators1, combinators2) ? combinators1 : null; -} +List>? _mergeLeadingCombinators( + List>? combinators1, + List>? combinators2) => + // Allow null arguments just to make calls to `Iterable.reduce()` easier. + switch ((combinators1, combinators2)) { + (null, _) || (_, null) => null, + (List(length: > 1), _) || (_, List(length: > 1)) => null, + ([], var combinators) || (var combinators, []) => combinators, + _ => listEquals(combinators1, combinators2) ? combinators1 : null + }; /// Extracts trailing [ComplexSelectorComponent]s with trailing combinators from /// [components1] and [components2] and merges them together into a single list. @@ -342,30 +359,39 @@ List? _mergeLeadingCombinators( /// /// If there are no combinators to be merged, returns an empty list. If the /// sequences can't be merged, returns `null`. +/// +/// The [span] will be used for any new combined selectors. List>>? _mergeTrailingCombinators( - Queue components1, - Queue components2, + QueueList components1, + QueueList components2, + FileSpan span, [QueueList>>? result]) { result ??= QueueList(); - var combinators1 = - components1.isEmpty ? const [] : components1.last.combinators; - var combinators2 = - components2.isEmpty ? const [] : components2.last.combinators; + var combinators1 = switch (components1) { + [..., var last] => last.combinators, + _ => const >[] + }; + var combinators2 = switch (components2) { + [..., var last] => last.combinators, + _ => const >[] + }; if (combinators1.isEmpty && combinators2.isEmpty) return result; - if (combinators1.length > 1 || combinators2.length > 1) return null; // This code looks complicated, but it's actually just a bunch of special // cases for interactions between different combinators. - var combinator1 = combinators1.isEmpty ? null : combinators1.first; - var combinator2 = combinators2.isEmpty ? null : combinators2.first; - if (combinator1 != null && combinator2 != null) { - var component1 = components1.removeLast(); - var component2 = components2.removeLast(); - - if (combinator1 == Combinator.followingSibling && - combinator2 == Combinator.followingSibling) { + switch (( + combinators1.firstOrNull?.value, + combinators2.firstOrNull?.value, + // Include the component lists in the pattern match so we can easily + // generalize cases across different orderings of the two combinators. + components1, + components2 + )) { + case (Combinator.followingSibling, Combinator.followingSibling, _, _): + var component1 = components1.removeLast(); + var component2 = components2.removeLast(); if (component1.selector.isSuperselector(component2.selector)) { result.addFirst([ [component2] @@ -380,91 +406,97 @@ List>>? _mergeTrailingCombinators( [component2, component1] ]; - var unified = unifyCompound( - component1.selector.components, component2.selector.components); - if (unified != null) { + if (unifyCompound(component1.selector, component2.selector) + case var unified?) { choices.add([ - ComplexSelectorComponent( - unified, const [Combinator.followingSibling]) + ComplexSelectorComponent(unified, [combinators1.first], span) ]); } result.addFirst(choices); } - } else if ((combinator1 == Combinator.followingSibling && - combinator2 == Combinator.nextSibling) || - (combinator1 == Combinator.nextSibling && - combinator2 == Combinator.followingSibling)) { - var followingSiblingComponent = - combinator1 == Combinator.followingSibling ? component1 : component2; - var nextSiblingComponent = - combinator1 == Combinator.followingSibling ? component2 : component1; - - if (followingSiblingComponent.selector - .isSuperselector(nextSiblingComponent.selector)) { + + case ( + Combinator.followingSibling, + Combinator.nextSibling, + var followingComponents, + var nextComponents + ) || + ( + Combinator.nextSibling, + Combinator.followingSibling, + var nextComponents, + var followingComponents + ): + var next = nextComponents.removeLast(); + var following = followingComponents.removeLast(); + if (following.selector.isSuperselector(next.selector)) { result.addFirst([ - [nextSiblingComponent] + [next] ]); } else { - var unified = unifyCompound( - component1.selector.components, component2.selector.components); result.addFirst([ - [followingSiblingComponent, nextSiblingComponent], - if (unified != null) - [ - ComplexSelectorComponent(unified, const [Combinator.nextSibling]) - ] + [following, next], + if (unifyCompound(following.selector, next.selector) + case var unified?) + [ComplexSelectorComponent(unified, next.combinators, span)] ]); } - } else if (combinator1 == Combinator.child && - (combinator2 == Combinator.nextSibling || - combinator2 == Combinator.followingSibling)) { - result.addFirst([ - [component2] - ]); - components1.add(component1); - } else if (combinator2 == Combinator.child && - (combinator1 == Combinator.nextSibling || - combinator1 == Combinator.followingSibling)) { + + case ( + Combinator.child, + Combinator.nextSibling || Combinator.followingSibling, + _, + var siblingComponents + ): + case ( + Combinator.nextSibling || Combinator.followingSibling, + Combinator.child, + var siblingComponents, + _ + ): result.addFirst([ - [component1] + [siblingComponents.removeLast()] ]); - components2.add(component2); - } else if (combinator1 == combinator2) { + + case (var combinator1?, var combinator2?, _, _) + when combinator1 == combinator2: var unified = unifyCompound( - component1.selector.components, component2.selector.components); + components1.removeLast().selector, components2.removeLast().selector); if (unified == null) return null; result.addFirst([ [ - ComplexSelectorComponent(unified, [combinator1]) + ComplexSelectorComponent(unified, [combinators1.first], span) ] ]); - } else { - return null; - } - return _mergeTrailingCombinators(components1, components2, result); - } else if (combinator1 != null) { - if (combinator1 == Combinator.child && - components2.isNotEmpty && - components2.last.selector.isSuperselector(components1.last.selector)) { - components2.removeLast(); - } - result.addFirst([ - [components1.removeLast()] - ]); - return _mergeTrailingCombinators(components1, components2, result); - } else { - if (combinator2 == Combinator.child && - components1.isNotEmpty && - components1.last.selector.isSuperselector(components2.last.selector)) { - components1.removeLast(); - } - result.addFirst([ - [components2.removeLast()] - ]); - return _mergeTrailingCombinators(components1, components2, result); + case ( + var combinator?, + null, + var combinatorComponents, + var descendantComponents + ): + case ( + null, + var combinator?, + var descendantComponents, + var combinatorComponents + ): + if (combinator == Combinator.child && + (descendantComponents.lastOrNull?.selector + .isSuperselector(combinatorComponents.last.selector) ?? + false)) { + descendantComponents.removeLast(); + } + result.addFirst([ + [combinatorComponents.removeLast()] + ]); + + case _: + return null; } + + return _mergeTrailingCombinators(components1, components2, span, result); } /// Returns whether [complex1] and [complex2] need to be unified to produce a @@ -512,13 +544,14 @@ List> _chunks( chunk2.add(queue2.removeFirst()); } - if (chunk1.isEmpty && chunk2.isEmpty) return []; - if (chunk1.isEmpty) return [chunk2]; - if (chunk2.isEmpty) return [chunk1]; - return [ - [...chunk1, ...chunk2], - [...chunk2, ...chunk1] - ]; + return switch ((chunk1, chunk2)) { + ([], []) => [], + ([], var chunk) || (var chunk, []) => [chunk], + _ => [ + [...chunk1, ...chunk2], + [...chunk2, ...chunk1] + ] + }; } /// Returns a list of all possible paths through the given lists. @@ -581,7 +614,9 @@ bool _complexIsParentSuperselector(List complex1, // TODO(nweiz): There's got to be a way to do this without a bunch of extra // allocations... var base = ComplexSelectorComponent( - CompoundSelector([PlaceholderSelector('')]), const []); + CompoundSelector([PlaceholderSelector('', bogusSpan)], bogusSpan), + const [], + bogusSpan); return complexIsSuperselector([...complex1, base], [...complex2, base]); } @@ -598,7 +633,7 @@ bool complexIsSuperselector(List complex1, var i1 = 0; var i2 = 0; - Combinator? previousCombinator; + CssValue? previousCombinator; while (true) { var remaining1 = complex1.length - i1; var remaining2 = complex2.length - i2; @@ -661,7 +696,7 @@ bool complexIsSuperselector(List complex1, previousCombinator = combinator1; if (complex1.length - i1 == 1) { - if (combinator1 == Combinator.followingSibling) { + if (combinator1?.value == Combinator.followingSibling) { // The selector `.foo ~ .bar` is only a superselector of selectors that // *exclusively* contain subcombinators of `~`. if (!complex2.take(complex2.length - 1).skip(i2).every((component) => @@ -682,29 +717,30 @@ bool complexIsSuperselector(List complex1, /// complex superselector and another, given that the earlier complex /// superselector had the combinator [previous]. bool _compatibleWithPreviousCombinator( - Combinator? previous, List parents) { + CssValue? previous, List parents) { if (parents.isEmpty) return true; if (previous == null) return true; // The child and next sibling combinators require that the *immediate* // following component be a superslector. - if (previous != Combinator.followingSibling) return false; + if (previous.value != Combinator.followingSibling) return false; // The following sibling combinator does allow intermediate components, but // only if they're all siblings. return parents.every((component) => - component.combinators.firstOrNull == Combinator.followingSibling || - component.combinators.firstOrNull == Combinator.nextSibling); + component.combinators.firstOrNull?.value == Combinator.followingSibling || + component.combinators.firstOrNull?.value == Combinator.nextSibling); } /// Returns whether [combinator1] is a supercombinator of [combinator2]. /// /// That is, whether `X combinator1 Y` is a superselector of `X combinator2 Y`. -bool _isSupercombinator(Combinator? combinator1, Combinator? combinator2) => +bool _isSupercombinator( + CssValue? combinator1, CssValue? combinator2) => combinator1 == combinator2 || - (combinator1 == null && combinator2 == Combinator.child) || - (combinator1 == Combinator.followingSibling && - combinator2 == Combinator.nextSibling); + (combinator1 == null && combinator2?.value == Combinator.child) || + (combinator1?.value == Combinator.followingSibling && + combinator2?.value == Combinator.nextSibling); /// Returns whether [compound1] is a superselector of [compound2]. /// @@ -724,26 +760,27 @@ bool compoundIsSuperselector( // // In addition, order matters when pseudo-elements are involved. The selectors // before them must - var tuple1 = _findPseudoElementIndexed(compound1); - var tuple2 = _findPseudoElementIndexed(compound2); - if (tuple1 != null && tuple2 != null) { - return tuple1.item1.isSuperselector(tuple2.item1) && - _compoundComponentsIsSuperselector( - compound1.components.take(tuple1.item2), - compound2.components.take(tuple2.item2), - parents: parents) && - _compoundComponentsIsSuperselector( - compound1.components.skip(tuple1.item2 + 1), - compound2.components.skip(tuple2.item2 + 1), - parents: parents); - } else if (tuple1 != null || tuple2 != null) { - return false; + switch (( + _findPseudoElementIndexed(compound1), + _findPseudoElementIndexed(compound2) + )) { + case ((var pseudo1, var index1), (var pseudo2, var index2)): + return pseudo1.isSuperselector(pseudo2) && + _compoundComponentsIsSuperselector(compound1.components.take(index1), + compound2.components.take(index2), parents: parents) && + _compoundComponentsIsSuperselector( + compound1.components.skip(index1 + 1), + compound2.components.skip(index2 + 1), + parents: parents); + + case (_?, _) || (_, _?): + return false; } // Every selector in [compound1.components] must have a matching selector in // [compound2.components]. for (var simple1 in compound1.components) { - if (simple1 is PseudoSelector && simple1.selector != null) { + if (simple1 case PseudoSelector(selector: _?)) { if (!_selectorPseudoIsSuperselector(simple1, compound2, parents: parents)) { return false; @@ -758,11 +795,10 @@ bool compoundIsSuperselector( /// If [compound] contains a pseudo-element, returns it and its index in /// [compound.components]. -Tuple2? _findPseudoElementIndexed( - CompoundSelector compound) { +(PseudoSelector, int)? _findPseudoElementIndexed(CompoundSelector compound) { for (var i = 0; i < compound.components.length; i++) { var simple = compound.components[i]; - if (simple is PseudoSelector && simple.isElement) return Tuple2(simple, i); + if (simple case PseudoSelector(isElement: true)) return (simple, i); } return null; } @@ -776,9 +812,11 @@ bool _compoundComponentsIsSuperselector( Iterable compound1, Iterable compound2, {Iterable? parents}) { if (compound1.isEmpty) return true; - if (compound2.isEmpty) compound2 = [UniversalSelector(namespace: '*')]; - return compoundIsSuperselector( - CompoundSelector(compound1), CompoundSelector(compound2), + if (compound2.isEmpty) { + compound2 = [UniversalSelector(bogusSpan, namespace: '*')]; + } + return compoundIsSuperselector(CompoundSelector(compound1, bogusSpan), + CompoundSelector(compound2, bogusSpan), parents: parents); } @@ -795,11 +833,10 @@ bool _compoundComponentsIsSuperselector( bool _selectorPseudoIsSuperselector( PseudoSelector pseudo1, CompoundSelector compound2, {Iterable? parents}) { - var selector1_ = pseudo1.selector; - if (selector1_ == null) { + var selector1 = pseudo1.selector; + if (selector1 == null) { throw ArgumentError("Selector $pseudo1 must have a selector argument."); } - var selector1 = selector1_; // dart-lang/sdk#45348 switch (pseudo1.normalizedName) { case 'is': @@ -813,7 +850,7 @@ bool _selectorPseudoIsSuperselector( complex1.leadingCombinators.isEmpty && complexIsSuperselector(complex1.components, [ ...?parents, - ComplexSelectorComponent(compound2, const []) + ComplexSelectorComponent(compound2, const [], compound2.span) ])); case 'has': @@ -830,22 +867,16 @@ bool _selectorPseudoIsSuperselector( return selector1.components.every((complex) { if (complex.isBogus) return false; - return compound2.components.any((simple2) { - if (simple2 is TypeSelector) { - return complex.components.last.selector.components.any( - (simple1) => simple1 is TypeSelector && simple1 != simple2); - } else if (simple2 is IDSelector) { - return complex.components.last.selector.components - .any((simple1) => simple1 is IDSelector && simple1 != simple2); - } else if (simple2 is PseudoSelector && - simple2.name == pseudo1.name) { - var selector2 = simple2.selector; - if (selector2 == null) return false; - return listIsSuperselector(selector2.components, [complex]); - } else { - return false; - } - }); + return compound2.components.any((simple2) => switch (simple2) { + TypeSelector() => complex.components.last.selector.components.any( + (simple1) => simple1 is TypeSelector && simple1 != simple2), + IDSelector() => complex.components.last.selector.components.any( + (simple1) => simple1 is IDSelector && simple1 != simple2), + PseudoSelector(selector: var selector2?) + when simple2.name == pseudo1.name => + listIsSuperselector(selector2.components, [complex]), + _ => false + }); }); case 'current': diff --git a/lib/src/extend/merged_extension.dart b/lib/src/extend/merged_extension.dart index ddffad5e1..b089379b1 100644 --- a/lib/src/extend/merged_extension.dart +++ b/lib/src/extend/merged_extension.dart @@ -11,7 +11,7 @@ import 'extension.dart'; /// /// This is used when multiple mandatory extensions exist to ensure that both of /// them are marked as resolved. -class MergedExtension extends Extension { +final class MergedExtension extends Extension { /// One of the merged extensions. final Extension left; @@ -50,8 +50,7 @@ class MergedExtension extends Extension { } MergedExtension._(this.left, this.right) - : super( - left.extender.selector, left.extender.span, left.target, left.span, + : super(left.extender.selector, left.target, left.span, mediaContext: left.mediaContext ?? right.mediaContext, optional: true); diff --git a/lib/src/functions/color.dart b/lib/src/functions/color.dart index b78488139..4b3a7d8dd 100644 --- a/lib/src/functions/color.dart +++ b/lib/src/functions/color.dart @@ -7,9 +7,11 @@ import 'dart:collection'; import 'package:collection/collection.dart'; import '../callable.dart'; +import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; +import '../util/map.dart'; import '../util/nullable.dart'; import '../util/number.dart'; import '../utils.dart'; @@ -48,7 +50,8 @@ final global = UnmodifiableListView([ space: ColorSpace.rgb, name: 'channels') }), - _function("invert", r"$color, $weight: 100%, $space: null", _invert), + _function("invert", r"$color, $weight: 100%, $space: null", + (arguments) => _invert(arguments, global: true)), // ### HSL _channelFunction("hue", (color) => color.hue, unit: 'deg', global: true), @@ -92,7 +95,8 @@ final global = UnmodifiableListView([ _function( "grayscale", r"$color", - (arguments) => arguments[0] is SassNumber + (arguments) => arguments[0] is SassNumber || arguments[0].isSpecialNumber + // Use the native CSS `grayscale` filter function. ? _functionString('grayscale', arguments) : _grayscale(arguments[0])), @@ -101,13 +105,13 @@ final global = UnmodifiableListView([ var degrees = _angleValue(arguments[1], "degrees"); var suggestedValue = SassNumber(degrees, 'deg'); - warn( + 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: true); + Deprecation.colorFunctions); return color.changeHsl(hue: color.hue + degrees); }), @@ -119,12 +123,12 @@ final global = UnmodifiableListView([ lightness: (color.lightness + amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); - warn( + warnForDeprecation( "lighten() is deprecated. " "${_suggestScaleAndAdjust(color, amount.value, 'lightness')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; }), @@ -135,17 +139,21 @@ final global = UnmodifiableListView([ lightness: (color.lightness - amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); - warn( + warnForDeprecation( "darken() is deprecated. " "${_suggestScaleAndAdjust(color, -amount.value, 'lightness')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; }), BuiltInCallable.overloadedFunction("saturate", { r"$amount": (arguments) { + if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) { + // Use the native CSS `saturate` filter function. + return _functionString("saturate", arguments); + } var number = arguments[0].assertNumber("amount"); return SassString("saturate(${number.toCssString()})", quotes: false); }, @@ -156,12 +164,12 @@ final global = UnmodifiableListView([ saturation: (color.saturation + amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); - warn( + warnForDeprecation( "saturate() is deprecated. " "${_suggestScaleAndAdjust(color, amount.value, 'saturation')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; } }), @@ -173,12 +181,12 @@ final global = UnmodifiableListView([ saturation: (color.saturation - amount.valueInRange(0, 100, "amount")) .clamp(0, 100)); - warn( + warnForDeprecation( "desaturate() is deprecated. " "${_suggestScaleAndAdjust(color, -amount.value, 'saturation')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; }), @@ -227,7 +235,8 @@ final global = UnmodifiableListView([ }), _function("opacity", r"$color", (arguments) { - if (arguments[0] is SassNumber) { + if (arguments[0] is SassNumber || arguments[0].isSpecialNumber) { + // Use the native CSS `opacity` filter function. return _functionString("opacity", arguments); } @@ -293,12 +302,12 @@ final module = BuiltInModule("color", functions: [ _function("invert", r"$color, $weight: 100%, $space: null", (arguments) { var result = _invert(arguments); if (result is SassString) { - warn( + warnForDeprecation( "Passing a number (${arguments[0]}) to color.invert() is " "deprecated.\n" "\n" "Recommendation: $result", - deprecation: true); + Deprecation.colorModuleCompat); } return result; }), @@ -316,12 +325,12 @@ final module = BuiltInModule("color", functions: [ _function("grayscale", r"$color", (arguments) { if (arguments[0] is SassNumber) { var result = _functionString("grayscale", arguments.take(1)); - warn( + warnForDeprecation( "Passing a number (${arguments[0]}) to color.grayscale() is " "deprecated.\n" "\n" "Recommendation: $result", - deprecation: true); + Deprecation.colorModuleCompat); return result; } @@ -358,11 +367,11 @@ final module = BuiltInModule("color", functions: [ !argument.hasQuotes && argument.text.contains(_microsoftFilterStart)) { var result = _functionString("alpha", arguments); - warn( + warnForDeprecation( "Using color.alpha() for a Microsoft filter is deprecated.\n" "\n" "Recommendation: $result", - deprecation: true); + Deprecation.colorModuleCompat); return result; } @@ -376,11 +385,11 @@ final module = BuiltInModule("color", functions: [ argument.text.contains(_microsoftFilterStart))) { // Support the proprietary Microsoft alpha() function. var result = _functionString("alpha", arguments); - warn( + warnForDeprecation( "Using color.alpha() for a Microsoft filter is deprecated.\n" "\n" "Recommendation: $result", - deprecation: true); + Deprecation.colorModuleCompat); return result; } @@ -393,12 +402,12 @@ final module = BuiltInModule("color", functions: [ _function("opacity", r"$color", (arguments) { if (arguments[0] is SassNumber) { var result = _functionString("opacity", arguments); - warn( + warnForDeprecation( "Passing a number (${arguments[0]} to color.opacity() is " "deprecated.\n" "\n" "Recommendation: $result", - deprecation: true); + Deprecation.colorModuleCompat); return result; } @@ -541,16 +550,19 @@ final _complement = }); /// The implementation of the `invert()` function. -Value _invert(List arguments) { +/// +/// 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) { + 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."; } - var result = _functionString("invert", arguments.take(1)); - return result; + // Use the native CSS `invert` filter function. + return _functionString("invert", arguments.take(1)); } var color = arguments[0].assertColor("color"); @@ -578,40 +590,30 @@ Value _invert(List arguments) { if (fuzzyEquals(weight, 0)) return color; var inSpace = color.toSpace(space); - SassColor inverted; - switch (space) { - case ColorSpace.hwb: - inverted = SassColor.hwb((inSpace.channel0 + 180) % 360, inSpace.channel2, - inSpace.channel1, inSpace.alpha); - break; - - case ColorSpace.hsl: - inverted = SassColor.hsl((inSpace.channel0 + 180) % 360, inSpace.channel1, - 100 - inSpace.channel2, inSpace.alpha); - break; - - case ColorSpace.lch: - inverted = SassColor.lch(100 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha); - break; - - case ColorSpace.oklch: - inverted = SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, - (inSpace.channel2 + 180) % 360, inSpace.alpha); - break; - - default: - var channel0 = space.channels[0] as LinearChannel; - var channel1 = space.channels[1] as LinearChannel; - var channel2 = space.channels[2] as LinearChannel; - inverted = SassColor.forSpaceInternal( + var inverted = switch (space) { + ColorSpace.hwb => SassColor.hwb((inSpace.channel0 + 180) % 360, + inSpace.channel2, inSpace.channel1, inSpace.alpha), + ColorSpace.hsl => SassColor.hsl((inSpace.channel0 + 180) % 360, + inSpace.channel1, 100 - inSpace.channel2, inSpace.alpha), + ColorSpace.lch => SassColor.lch(100 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha), + ColorSpace.oklch => SassColor.oklch(1 - inSpace.channel0, inSpace.channel1, + (inSpace.channel2 + 180) % 360, inSpace.alpha), + ColorSpace( + channels: [ + LinearChannel channel0, + LinearChannel channel1, + LinearChannel channel2 + ] + ) => + SassColor.forSpaceInternal( space, _invertChannel(channel0, inSpace.channel0), _invertChannel(channel1, inSpace.channel1), _invertChannel(channel2, inSpace.channel2), - inSpace.alpha); - break; - } + inSpace.alpha), + _ => throw UnsupportedError("Unknown color space $space.") + }; if (fuzzyEquals(weight, 1)) return inverted; return color.interpolate(inverted, InterpolationMethod(space), @@ -693,15 +695,15 @@ SassColor _updateComponents(List arguments, var oldChannels = color.channels; var channelArgs = List.filled(oldChannels.length, null); var channelInfo = color.space.channels; - for (var entry in keywords.entries) { - var channelIndex = channelInfo.indexWhere((info) => entry.key == info.name); + 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.", - entry.key); + name); } - channelArgs[channelIndex] = entry.value.assertNumber(entry.key); + channelArgs[channelIndex] = value.assertNumber(name); } var result = change @@ -726,8 +728,24 @@ SassColor _changeColor( channelArgs[0] ?? SassNumber(color.channel0), channelArgs[1] ?? SassNumber(color.channel1, latterUnits), channelArgs[2] ?? SassNumber(color.channel2, latterUnits), - alphaArg.andThen( - (alphaArg) => _percentageOrUnitless(alphaArg, 1, 'alpha')) ?? + alphaArg.andThen((alphaArg) { + if (!alphaArg.hasUnits) { + return alphaArg.value; + } else if (alphaArg.hasUnit('%')) { + return alphaArg.value / 100; + } else { + 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.value; + } + }) ?? color.alpha); } @@ -754,17 +772,15 @@ double _scaleChannel( var factor = (factorArg..assertUnit('%', channel.name)) .valueInRangeWithUnit(-100, 100, channel.name, '%') / 100; - if (factor == 0) { - return oldValue; - } else if (factor > 0) { - return oldValue >= channel.max + return switch (factor) { + 0 => oldValue, + > 0 => oldValue >= channel.max ? oldValue - : oldValue + (channel.max - oldValue) * factor; - } else { - return oldValue <= channel.min + : oldValue + (channel.max - oldValue) * factor, + _ => oldValue <= channel.min ? oldValue - : oldValue + (oldValue - channel.min) * factor; - } + : oldValue + (oldValue - channel.min) * factor + }; } /// Returns a copy of [color] with its channel values adjusted by the values in @@ -793,32 +809,34 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, SassNumber? adjustmentArg) { if (adjustmentArg == null) return oldValue; - if ((space == ColorSpace.hsl || space == ColorSpace.hwb) && - channel is! LinearChannel) { - // `_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')); - } else if (space == ColorSpace.hsl && channel is LinearChannel) { - // `_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, '%'); - } else if (channel == ColorChannel.alpha && 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. - warn( - "\$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: true); - adjustmentArg = SassNumber(adjustmentArg.value); + switch ((space, channel)) { + case (ColorSpace.hsl || ColorSpace.hwb, _) when channel is! LinearChannel: + // `_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()): + // `_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); } var result = oldValue + _channelFromValue(channel, adjustmentArg)!; @@ -835,17 +853,13 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue, ColorSpace? _sniffLegacyColorSpace(Map keywords) { for (var key in keywords.keys) { switch (key) { - case "red": - case "green": - case "blue": + case "red" || "green" || "blue": return ColorSpace.rgb; - case "saturation": - case "lightness": + case "saturation" || "lightness": return ColorSpace.hsl; - case "whiteness": - case "blackness": + case "whiteness" || "blackness": return ColorSpace.hwb; } } @@ -895,10 +909,10 @@ Value _rgb(String name, List arguments) { arguments[0].assertNumber("red"), arguments[1].assertNumber("green"), arguments[2].assertNumber("blue"), - alpha == null - ? 1.0 - : _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1), + alpha.andThen((alpha) => + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1)) ?? + 1, fromRgbFunction: true); } @@ -945,10 +959,10 @@ Value _hsl(String name, List arguments) { arguments[0].assertNumber("hue"), arguments[1].assertNumber("saturation"), arguments[2].assertNumber("lightness"), - alpha == null - ? 1.0 - : _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") - .clamp(0, 1)); + alpha.andThen((alpha) => + _percentageOrUnitless(alpha.assertNumber("alpha"), 1, "alpha") + .clamp(0, 1)) ?? + 1); } /// Asserts that [angle] is a number and returns its value in degrees. @@ -958,13 +972,13 @@ double _angleValue(Value angleValue, String name) { var angle = angleValue.assertNumber(name); if (angle.compatibleWithUnit('deg')) return angle.coerceValueToUnit('deg'); - warn( + warnForDeprecation( "\$$name: Passing a unit other than deg ($angle) is deprecated.\n" "\n" "To preserve current behavior: ${angle.unitSuggestion(name)}\n" "\n" "See https://sass-lang.com/d/function-units", - deprecation: true); + Deprecation.functionUnits); return angle.value; } @@ -972,13 +986,13 @@ double _angleValue(Value angleValue, String name) { void _checkPercent(SassNumber number, String name) { if (number.hasUnit('%')) return; - warn( + warnForDeprecation( "\$$name: Passing a number without unit % ($number) is deprecated.\n" "\n" "To preserve current behavior: ${number.unitSuggestion(name, '%')}\n" "\n" "More info: https://sass-lang.com/d/function-units", - deprecation: true); + Deprecation.functionUnits); } /// Asserts that [number] is a percentage or has no units, and normalizes the @@ -1058,12 +1072,12 @@ SassColor _opacify(String name, List arguments) { (color.alpha + amount.valueInRangeWithUnit(0, 1, "amount", "")) .clamp(0, 1)); - warn( + warnForDeprecation( "$name() is deprecated. " "${_suggestScaleAndAdjust(color, amount.value, 'alpha')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; } @@ -1075,12 +1089,12 @@ SassColor _transparentize(String name, List arguments) { (color.alpha - amount.valueInRangeWithUnit(0, 1, "amount", "")) .clamp(0, 1)); - warn( + warnForDeprecation( "$name() is deprecated. " "${_suggestScaleAndAdjust(color, -amount.value, 'alpha')}\n" "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; } @@ -1127,79 +1141,78 @@ ColorSpace _spaceOrDefault(SassColor color, Value space, [String? name]) => Value _parseChannels(String functionName, Value input, {ColorSpace? space, String? name}) { if (input.isVar) return _functionString(functionName, [input]); - var inputList = input.assertCommonListStyle(name, allowSlash: true); Value components; Value? alphaValue; - if (input.separator == ListSeparator.slash) { - if (inputList.length != 2) { + switch (input.assertCommonListStyle(name, allowSlash: true)) { + case [var components_, var alphaValue_] + when input.separator == ListSeparator.slash: + components = components_; + alphaValue = alphaValue_; + + case 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."); - } else { - components = inputList[0]; - alphaValue = inputList[1]; - } - } else if (inputList.isEmpty) { - components = input; - } else { - components = input; - var last = inputList.last; - if (last is SassString && !last.hasQuotes && last.text.contains('/')) { + + case [..., SassString(hasQuotes: false, :var text)] when text.contains('/'): return _functionString(functionName, [input]); - } else if (last is SassNumber) { - var slash = last.asSlash; - if (slash != null) { - components = SassList( - [...inputList.take(inputList.length - 1), slash.item1], - ListSeparator.space); - alphaValue = slash.item2; - } - } + + case [...var initial, SassNumber(asSlash: (var before, var after))]: + components = SassList([...initial, before], ListSeparator.space); + alphaValue = after; + + case _: + components = input; } List channels; SassString? spaceName; - var componentList = components.assertCommonListStyle(name, allowSlash: false); - if (componentList.isEmpty) { - throw SassScriptException('Color component list may not be empty.', name); - } else if (components.isVar) { - channels = [components]; - } else { - if (space == null) { - spaceName = componentList.first.assertString(name)..assertUnquoted(name); - space = - spaceName.isVar ? null : ColorSpace.fromName(spaceName.text, name); - channels = [...componentList.skip(1)]; - - if (const { - ColorSpace.rgb, - ColorSpace.hsl, - ColorSpace.hwb, - ColorSpace.lab, - ColorSpace.lch, - ColorSpace.oklab, - ColorSpace.oklch - }.contains(space)) { - throw SassScriptException( - "The color() function doesn't support the color space $space. Use " - "the $space() function instead.", - name); + switch (components.assertCommonListStyle(name, allowSlash: false)) { + case []: + throw SassScriptException('Color component list may not be empty.', name); + + 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; } - } else { - channels = componentList; - } - for (var channel in channels) { - if (!channel.isSpecialNumber && - channel is! SassNumber && - !_isNone(channel)) { - var channelName = - space?.channels[channels.indexOf(channel)].name ?? 'channel'; - throw SassScriptException( - 'Expected $channelName $channel to be a number.', name); + for (var channel in channels) { + if (!channel.isSpecialNumber && + channel is! SassNumber && + !_isNone(channel)) { + var channelName = + space?.channels[channels.indexOf(channel)].name ?? 'channel'; + throw SassScriptException( + 'Expected $channelName $channel to be a number.', name); + } } - } + + // dart-lang/sdk#51926 + case _: + throw "unreachable"; } if (alphaValue?.isSpecialNumber ?? false) { @@ -1310,16 +1323,14 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0, /// Converts a channel value from a [SassNumber] into a [double] according to /// [channel]. double? _channelFromValue(ColorChannel channel, SassNumber? value) => - value.andThen((value) { - if (channel is! LinearChannel) { - return value.coerceValueToUnit('deg', channel.name); - } else if (channel.requiresPercent && !value.hasUnit('%')) { - throw SassScriptException( - 'Expected $value to have unit "%".', channel.name); - } else { - return _percentageOrUnitless(value, channel.max, channel.name); - } - }); + value.andThen((value) => switch (channel) { + LinearChannel(requiresPercent: true) when !value.hasUnit('%') => + throw SassScriptException( + 'Expected $value to have unit "%".', channel.name), + LinearChannel() => + _percentageOrUnitless(value, channel.max, channel.name), + _ => value.coerceValueToUnit('deg', channel.name) + }); /// Returns whether [value] is an unquoted string case-insensitively equal to /// "none". @@ -1339,13 +1350,13 @@ BuiltInCallable _channelFunction( return _function(name, r"$color", (arguments) { var result = SassNumber(getter(arguments.first.assertColor("color")), unit); - warn( + warnForDeprecation( "${global ? '' : 'color.'}$name() is deprecated. Suggestion:\n" "\n" 'color.channel(\$color, $name)\n' "\n" "More info: https://sass-lang.com/d/color-functions", - deprecation: true); + Deprecation.colorFunctions); return result; }); diff --git a/lib/src/functions/list.dart b/lib/src/functions/list.dart index 533be29ad..1d911a88d 100644 --- a/lib/src/functions/list.dart +++ b/lib/src/functions/list.dart @@ -38,7 +38,7 @@ final _setNth = _function("set-nth", r"$list, $n, $value", (arguments) { var value = arguments[2]; var newList = list.asList.toList(); newList[list.sassIndexToListIndex(index, "n")] = value; - return arguments[0].withListContents(newList); + return list.withListContents(newList); }); final _join = _function( @@ -48,25 +48,20 @@ final _join = _function( var separatorParam = arguments[2].assertString("separator"); var bracketedParam = arguments[3]; - ListSeparator separator; - if (separatorParam.text == "auto") { - if (list1.separator != ListSeparator.undecided) { - separator = list1.separator; - } else if (list2.separator != ListSeparator.undecided) { - separator = list2.separator; - } else { - separator = ListSeparator.space; - } - } else if (separatorParam.text == "space") { - separator = ListSeparator.space; - } else if (separatorParam.text == "comma") { - separator = ListSeparator.comma; - } else if (separatorParam.text == "slash") { - separator = ListSeparator.slash; - } else { - throw SassScriptException( - '\$separator: Must be "space", "comma", "slash", or "auto".'); - } + var separator = switch (separatorParam.text) { + "auto" => switch ((list1.separator, list2.separator)) { + (ListSeparator.undecided, ListSeparator.undecided) => + ListSeparator.space, + (ListSeparator.undecided, var separator) || + (var separator, _) => + separator + }, + "space" => ListSeparator.space, + "comma" => ListSeparator.comma, + "slash" => ListSeparator.slash, + _ => throw SassScriptException( + '\$separator: Must be "space", "comma", "slash", or "auto".') + }; var bracketed = bracketedParam is SassString && bracketedParam.text == 'auto' ? list1.hasBrackets @@ -82,21 +77,16 @@ final _append = var value = arguments[1]; var separatorParam = arguments[2].assertString("separator"); - ListSeparator separator; - if (separatorParam.text == "auto") { - separator = list.separator == ListSeparator.undecided + var separator = switch (separatorParam.text) { + "auto" => list.separator == ListSeparator.undecided ? ListSeparator.space - : list.separator; - } else if (separatorParam.text == "space") { - separator = ListSeparator.space; - } else if (separatorParam.text == "comma") { - separator = ListSeparator.comma; - } else if (separatorParam.text == "slash") { - separator = ListSeparator.slash; - } else { - throw SassScriptException( - '\$separator: Must be "space", "comma", "slash", or "auto".'); - } + : list.separator, + "space" => ListSeparator.space, + "comma" => ListSeparator.comma, + "slash" => ListSeparator.slash, + _ => throw SassScriptException( + '\$separator: Must be "space", "comma", "slash", or "auto".') + }; var newList = [...list.asList, value]; return list.withListContents(newList, separator: separator); @@ -125,16 +115,14 @@ final _index = _function("index", r"$list, $value", (arguments) { return index == -1 ? sassNull : SassNumber(index + 1); }); -final _separator = _function("separator", r"$list", (arguments) { - switch (arguments[0].separator) { - case ListSeparator.comma: - return SassString("comma", quotes: false); - case ListSeparator.slash: - return SassString("slash", quotes: false); - default: - return SassString("space", quotes: false); - } -}); +final _separator = _function( + "separator", + r"$list", + (arguments) => switch (arguments[0].separator) { + ListSeparator.comma => SassString("comma", quotes: false), + ListSeparator.slash => SassString("slash", quotes: false), + _ => SassString("space", quotes: false) + }); final _isBracketed = _function("is-bracketed", r"$list", (arguments) => SassBoolean(arguments[0].hasBrackets)); diff --git a/lib/src/functions/map.dart b/lib/src/functions/map.dart index 232e70997..97628c1fd 100644 --- a/lib/src/functions/map.dart +++ b/lib/src/functions/map.dart @@ -9,7 +9,8 @@ import 'package:collection/collection.dart'; import '../callable.dart'; import '../exception.dart'; import '../module/built_in.dart'; -import '../utils.dart'; +import '../util/iterable.dart'; +import '../util/map.dart'; import '../value.dart'; /// The global definitions of Sass map functions. @@ -40,11 +41,8 @@ final _get = _function("get", r"$map, $key, $keys...", (arguments) { var keys = [arguments[1], ...arguments[2].asList]; for (var key in keys.exceptLast) { var value = map.contents[key]; - if (value is SassMap) { - map = value; - } else { - return sassNull; - } + if (value is! SassMap) return sassNull; + map = value; } return map.contents[keys.last] ?? sassNull; }); @@ -56,13 +54,16 @@ final _set = BuiltInCallable.overloadedFunction("set", { }, r"$map, $args...": (arguments) { var map = arguments[0].assertMap("map"); - var args = arguments[1].asList; - if (args.isEmpty) { - throw SassScriptException("Expected \$args to contain a key."); - } else if (args.length == 1) { - throw SassScriptException("Expected \$args to contain a value."); + switch (arguments[1].asList) { + case []: + throw SassScriptException("Expected \$args to contain a key."); + case [_]: + throw SassScriptException("Expected \$args to contain a value."); + case [...var keys, var value]: + return _modify(map, keys, (_) => value); + default: + throw '[BUG] Unreachable code'; } - return _modify(map, args.sublist(0, args.length - 1), (_) => args.last); }, }); @@ -74,18 +75,21 @@ final _merge = BuiltInCallable.overloadedFunction("merge", { }, r"$map1, $args...": (arguments) { var map1 = arguments[0].assertMap("map1"); - var args = arguments[1].asList; - if (args.isEmpty) { - throw SassScriptException("Expected \$args to contain a key."); - } else if (args.length == 1) { - throw SassScriptException("Expected \$args to contain a map."); + switch (arguments[1].asList) { + case []: + throw SassScriptException("Expected \$args to contain a key."); + case [_]: + throw SassScriptException("Expected \$args to contain a map."); + case [...var keys, var last]: + var map2 = last.assertMap("map2"); + return _modify(map1, keys, (oldValue) { + var nestedMap = oldValue.tryMap(); + if (nestedMap == null) return map2; + return SassMap({...nestedMap.contents, ...map2.contents}); + }); + default: + throw '[BUG] Unreachable code'; } - var map2 = args.last.assertMap("map2"); - return _modify(map1, args.exceptLast, (oldValue) { - var nestedMap = oldValue.tryMap(); - if (nestedMap == null) return map2; - return SassMap({...nestedMap.contents, ...map2.contents}); - }); }, }); @@ -100,8 +104,8 @@ final _deepRemove = var map = arguments[0].assertMap("map"); var keys = [arguments[1], ...arguments[2].asList]; return _modify(map, keys.exceptLast, (value) { - var nestedMap = value.tryMap(); - if (nestedMap != null && nestedMap.contents.containsKey(keys.last)) { + if (value.tryMap() case var nestedMap? + when nestedMap.contents.containsKey(keys.last)) { return SassMap(Map.of(nestedMap.contents)..remove(keys.last)); } return value; @@ -144,11 +148,8 @@ final _hasKey = _function("has-key", r"$map, $key, $keys...", (arguments) { var keys = [arguments[1], ...arguments[2].asList]; for (var key in keys.exceptLast) { var value = map.contents[key]; - if (value is SassMap) { - map = value; - } else { - return sassFalse; - } + if (value is! SassMap) return sassFalse; + map = value; } return SassBoolean(map.contents.containsKey(keys.last)); }); @@ -198,22 +199,16 @@ SassMap _deepMergeImpl(SassMap map1, SassMap map2) { if (map2.contents.isEmpty) return map1; var result = Map.of(map1.contents); - - map2.contents.forEach((key, value) { - var resultMap = result[key]?.tryMap(); - if (resultMap == null) { - result[key] = value; + for (var (key, value) in map2.contents.pairs) { + if ((result[key]?.tryMap(), value.tryMap()) + case (var resultMap?, var valueMap?)) { + var merged = _deepMergeImpl(resultMap, valueMap); + if (identical(merged, resultMap)) continue; + result[key] = merged; } else { - var valueMap = value.tryMap(); - if (valueMap != null) { - var merged = _deepMergeImpl(resultMap, valueMap); - if (identical(merged, resultMap)) return; - result[key] = merged; - } else { - result[key] = value; - } + result[key] = value; } - }); + } return SassMap(result); } diff --git a/lib/src/functions/math.dart b/lib/src/functions/math.dart index 66860f488..a85e5b1a4 100644 --- a/lib/src/functions/math.dart +++ b/lib/src/functions/math.dart @@ -8,6 +8,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; import '../callable.dart'; +import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../module/built_in.dart'; @@ -15,17 +16,36 @@ import '../value.dart'; /// The global definitions of Sass math functions. final global = UnmodifiableListView([ - _abs, _ceil, _floor, _max, _min, _percentage, _randomFunction, _round, - _unit, // + _function("abs", r"$number", (arguments) { + var number = arguments[0].assertNumber("number"); + if (number.hasUnit("%")) { + warnForDeprecation( + "Passing percentage units to the global abs() function is " + "deprecated.\n" + "In the future, this will emit a CSS abs() function to be resolved " + "by the browser.\n" + "To preserve current behavior: math.abs($number)" + "\n" + "To emit a CSS abs() now: abs(#{$number})\n" + "More info: https://sass-lang.com/d/abs-percent", + Deprecation.absPercent); + } + 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"), ]); /// The Sass math module. final module = BuiltInModule("math", functions: [ - _abs, _acos, _asin, _atan, _atan2, _ceil, _clamp, _cos, _compatible, // - _floor, _hypot, _isUnitless, _log, _max, _min, _percentage, _pow, // - _randomFunction, _round, _sin, _sqrt, _tan, _unit, _div + _numberFunction("abs", (value) => value.abs()), + _acos, _asin, _atan, _atan2, _ceil, _clamp, _cos, _compatible, _floor, // + _hypot, _isUnitless, _log, _max, _min, _percentage, _pow, _randomFunction, + _round, _sin, _sqrt, _tan, _unit, _div ], variables: { "e": SassNumber(math.e), "pi": SassNumber(math.pi), @@ -87,8 +107,6 @@ final _round = _numberFunction("round", (number) => number.round().toDouble()); /// Distance functions /// -final _abs = _numberFunction("abs", (value) => value.abs()); - final _hypot = _function("hypot", r"$numbers...", (arguments) { var numbers = arguments[0].asList.map((argument) => argument.assertNumber()).toList(); @@ -250,7 +268,7 @@ final _randomFunction = _function("random", r"$limit: null", (arguments) { var limit = arguments[0].assertNumber("limit"); if (limit.hasUnits) { - warn( + warnForDeprecation( "math.random() will no longer ignore \$limit units ($limit) in a " "future release.\n" "\n" @@ -261,7 +279,7 @@ final _randomFunction = _function("random", r"$limit: null", (arguments) { "math.random(math.div(\$limit, 1${limit.unitString}))\n" "\n" "More info: https://sass-lang.com/d/function-units", - deprecation: true); + Deprecation.functionUnits); } var limitScalar = limit.assertInt("limit"); diff --git a/lib/src/functions/meta.dart b/lib/src/functions/meta.dart index 91c7cf398..c1248ee7e 100644 --- a/lib/src/functions/meta.dart +++ b/lib/src/functions/meta.dart @@ -7,6 +7,7 @@ import 'dart:collection'; import 'package:collection/collection.dart'; import '../callable.dart'; +import '../util/map.dart'; import '../value.dart'; import '../visitor/serialize.dart'; @@ -35,34 +36,33 @@ final global = UnmodifiableListView([ (arguments) => SassString(serializeValue(arguments.first, inspect: true), quotes: false)), - _function("type-of", r"$value", (arguments) { - var value = arguments[0]; - if (value is SassArgumentList) { - return SassString("arglist", quotes: false); - } - if (value is SassBoolean) return SassString("bool", quotes: false); - if (value is SassColor) return SassString("color", quotes: false); - if (value is SassList) return SassString("list", quotes: false); - if (value is SassMap) return SassString("map", quotes: false); - if (value == sassNull) return SassString("null", quotes: false); - if (value is SassNumber) return SassString("number", quotes: false); - if (value is SassFunction) return SassString("function", quotes: false); - if (value is SassCalculation) { - return SassString("calculation", quotes: false); - } - assert(value is SassString); - return SassString("string", quotes: false); - }), + _function( + "type-of", + r"$value", + (arguments) => SassString( + switch (arguments[0]) { + SassArgumentList() => "arglist", + SassBoolean() => "bool", + SassColor() => "color", + SassList() => "list", + SassMap() => "map", + sassNull => "null", + SassNumber() => "number", + SassFunction() => "function", + SassCalculation() => "calculation", + SassString() => "string", + _ => throw "[BUG] Unknown value type ${arguments[0]}" + }, + quotes: false)), _function("keywords", r"$args", (arguments) { - var argumentList = arguments[0]; - if (argumentList is SassArgumentList) { + if (arguments[0] case SassArgumentList(:var keywords)) { return SassMap({ - for (var entry in argumentList.keywords.entries) - SassString(entry.key, quotes: false): entry.value + for (var (key, value) in keywords.pairs) + SassString(key, quotes: false): value }); } else { - throw "\$args: $argumentList is not an argument list."; + throw "\$args: ${arguments[0]} is not an argument list."; } }) ]); @@ -76,10 +76,11 @@ final local = UnmodifiableListView([ }), _function("calc-args", r"$calc", (arguments) { var calculation = arguments[0].assertCalculation("calc"); - return SassList(calculation.arguments.map((argument) { - if (argument is Value) return argument; - return SassString(argument.toString(), quotes: false); - }), ListSeparator.comma); + return SassList( + calculation.arguments.map((argument) => argument is Value + ? argument + : SassString(argument.toString(), quotes: false)), + ListSeparator.comma); }) ]); diff --git a/lib/src/functions/selector.dart b/lib/src/functions/selector.dart index 0be1fd866..d6fddf9de 100644 --- a/lib/src/functions/selector.dart +++ b/lib/src/functions/selector.dart @@ -63,6 +63,7 @@ final _append = _function("append", r"$selectors...", (arguments) { "\$selectors: At least one selector must be passed."); } + var span = EvaluationContext.current.currentCallableSpan; return selectors .map((selector) => selector.assertSelector()) .reduce((parent, child) { @@ -71,17 +72,18 @@ final _append = _function("append", r"$selectors...", (arguments) { throw SassScriptException("Can't append $complex to $parent."); } - var component = complex.components.first; + var [component, ...rest] = complex.components; var newCompound = _prependParent(component.selector); if (newCompound == null) { throw SassScriptException("Can't append $complex to $parent."); } return ComplexSelector(const [], [ - ComplexSelectorComponent(newCompound, component.combinators), - ...complex.components.skip(1) - ]); - })).resolveParentSelectors(parent); + ComplexSelectorComponent(newCompound, component.combinators, span), + ...rest + ], span); + }), span) + .resolveParentSelectors(parent); }).asSassList; }); @@ -119,8 +121,7 @@ final _unify = _function("unify", r"$selector1, $selector2", (arguments) { var selector2 = arguments[1].assertSelector(name: "selector2") ..assertNotBogus(name: "selector2"); - var result = selector1.unify(selector2); - return result == null ? sassNull : result.asSassList; + return selector1.unify(selector2)?.asSassList ?? sassNull; }); final _isSuperselector = @@ -149,17 +150,15 @@ final _parse = _function("parse", r"$selector", /// Adds a [ParentSelector] to the beginning of [compound], or returns `null` if /// that wouldn't produce a valid selector. CompoundSelector? _prependParent(CompoundSelector compound) { - var first = compound.components.first; - if (first is UniversalSelector) return null; - if (first is TypeSelector) { - if (first.name.namespace != null) return null; - return CompoundSelector([ - ParentSelector(suffix: first.name.name), - ...compound.components.skip(1) - ]); - } else { - return CompoundSelector([ParentSelector(), ...compound.components]); - } + var span = EvaluationContext.current.currentCallableSpan; + return switch (compound.components) { + [UniversalSelector(), ...] => null, + [TypeSelector type, ...] when type.name.namespace != null => null, + [TypeSelector type, ...var rest] => CompoundSelector( + [ParentSelector(span, suffix: type.name.name), ...rest], span), + var components => + CompoundSelector([ParentSelector(span), ...components], span) + }; } /// Like [BuiltInCallable.function], but always sets the URL to diff --git a/lib/src/import_cache.dart b/lib/src/import_cache.dart index 185a5a5bd..b85c78d07 100644 --- a/lib/src/import_cache.dart +++ b/lib/src/import_cache.dart @@ -5,7 +5,7 @@ // DO NOT EDIT. This file was generated from async_import_cache.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: cd71f3debc089cd05cd86e2eee32c2f10a05f489 +// Checksum: 1b6289e0dd362fcb02f331a16a30fe94050b4e17 // // ignore_for_file: unused_import @@ -13,20 +13,27 @@ 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 'package:tuple/tuple.dart'; import 'ast/sass.dart'; +import 'deprecation.dart'; import 'importer.dart'; +import 'importer/no_op.dart'; import 'importer/utils.dart'; import 'io.dart'; import 'logger.dart'; +import 'util/nullable.dart'; import 'utils.dart'; +/// A canonicalized URL and the importer that canonicalized it. +/// +/// This also includes the URL that was originally passed to the importer, which +/// may be resolved relative to a base URL. +typedef CanonicalizeResult = (Importer, Uri canonicalUrl, {Uri originalUrl}); + /// An in-memory cache of parsed stylesheets that have been imported by Sass. /// /// {@category Dependencies} -@sealed -class ImportCache { +final class ImportCache { /// The importers to use when loading new Sass files. final List _importers; @@ -35,15 +42,13 @@ class ImportCache { /// The canonicalized URLs for each non-canonical URL. /// - /// The second item in each key's tuple is true when this canonicalization is - /// for an `@import` rule. Otherwise, it's for a `@use` or `@forward` rule. - /// - /// This map's values are the same as the return value of [canonicalize]. + /// 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]. - final _canonicalizeCache = , Tuple3?>{}; + final _canonicalizeCache = <(Uri, {bool forImport}), CanonicalizeResult?>{}; /// The canonicalized URLs for each non-canonical URL that's resolved using a /// relative importer. @@ -56,8 +61,13 @@ class ImportCache { /// 4. The `baseUrl` passed to [canonicalize]. /// /// The map's values are the same as the return value of [canonicalize]. - final _relativeCanonicalizeCache = - , Tuple3?>{}; + final _relativeCanonicalizeCache = <( + Uri, { + bool forImport, + Importer baseImporter, + Uri? baseUrl + }), + CanonicalizeResult?>{}; /// The parsed stylesheets for each canonicalized import URL. final _importCache = {}; @@ -100,6 +110,7 @@ class ImportCache { static List _toImporters(Iterable? importers, Iterable? loadPaths, PackageConfig? packageConfig) { var sassPath = getEnvironmentVariable('SASS_PATH'); + if (isBrowser) return [...?importers]; return [ ...?importers, if (loadPaths != null) @@ -123,24 +134,36 @@ class ImportCache { /// If any importers understand [url], returns that importer as well as the /// canonicalized URL and the original URL (resolved relative to [baseUrl] if /// applicable). Otherwise, returns `null`. - Tuple3? canonicalize(Uri url, + CanonicalizeResult? canonicalize(Uri url, {Importer? baseImporter, Uri? baseUrl, bool forImport = false}) { + if (isBrowser && + (baseImporter == null || baseImporter is NoOpImporter) && + _importers.isEmpty) { + throw "Custom importers are required to load stylesheets when compiling in the browser."; + } + if (baseImporter != null) { - var relativeResult = _relativeCanonicalizeCache - .putIfAbsent(Tuple4(url, forImport, baseImporter, baseUrl), () { + var relativeResult = _relativeCanonicalizeCache.putIfAbsent(( + url, + forImport: forImport, + baseImporter: baseImporter, + baseUrl: baseUrl + ), () { var resolvedUrl = baseUrl?.resolveUri(url) ?? url; - var canonicalUrl = _canonicalize(baseImporter, resolvedUrl, forImport); - if (canonicalUrl == null) return null; - return Tuple3(baseImporter, canonicalUrl, resolvedUrl); + if (_canonicalize(baseImporter, resolvedUrl, forImport) + case var canonicalUrl?) { + return (baseImporter, canonicalUrl, originalUrl: resolvedUrl); + } else { + return null; + } }); if (relativeResult != null) return relativeResult; } - return _canonicalizeCache.putIfAbsent(Tuple2(url, forImport), () { + return _canonicalizeCache.putIfAbsent((url, forImport: forImport), () { for (var importer in _importers) { - var canonicalUrl = _canonicalize(importer, url, forImport); - if (canonicalUrl != null) { - return Tuple3(importer, canonicalUrl, url); + if (_canonicalize(importer, url, forImport) case var canonicalUrl?) { + return (importer, canonicalUrl, originalUrl: url); } } @@ -155,10 +178,10 @@ class ImportCache { ? inImportRule(() => importer.canonicalize(url)) : importer.canonicalize(url)); if (result?.scheme == '') { - _logger.warn(""" + _logger.warnForDeprecation(Deprecation.relativeCanonical, """ Importer $importer canonicalized $url to $result. Relative canonical URLs are deprecated and will eventually be disallowed. -""", deprecation: true); +"""); } return result; } @@ -172,15 +195,16 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// parsed stylesheet. Otherwise, returns `null`. /// /// Caches the result of the import and uses cached results if possible. - Tuple2? import(Uri url, + (Importer, Stylesheet)? import(Uri url, {Importer? baseImporter, Uri? baseUrl, bool forImport = false}) { - var tuple = canonicalize(url, - baseImporter: baseImporter, baseUrl: baseUrl, forImport: forImport); - if (tuple == null) return null; - var stylesheet = - importCanonical(tuple.item1, tuple.item2, originalUrl: tuple.item3); - if (stylesheet == null) return null; - return Tuple2(tuple.item1, stylesheet); + if (canonicalize(url, + baseImporter: baseImporter, baseUrl: baseUrl, forImport: forImport) + case (var importer, var canonicalUrl, :var originalUrl)) { + return importCanonical(importer, canonicalUrl, originalUrl: originalUrl) + .andThen((stylesheet) => (importer, stylesheet)); + } else { + return null; + } } /// Tries to load the canonicalized [canonicalUrl] using [importer]. @@ -216,21 +240,22 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// Return a human-friendly URL for [canonicalUrl] to use in a stack trace. /// /// Returns [canonicalUrl] as-is if it hasn't been loaded by this cache. - Uri humanize(Uri canonicalUrl) { - // Display the URL with the shortest path length. - var url = minBy( - _canonicalizeCache.values - .whereNotNull() - .where((tuple) => tuple.item2 == canonicalUrl) - .map((tuple) => tuple.item3), - (url) => url.path.length); - if (url == null) return canonicalUrl; - - // Use the canonicalized basename so that we display e.g. - // package:example/_example.scss rather than package:example/example in - // stack traces. - return url.resolve(p.url.basename(canonicalUrl.path)); - } + Uri humanize(Uri canonicalUrl) => + // If multiple original URLs canonicalize to the same thing, choose the + // shortest one. + minBy( + _canonicalizeCache.values + .whereNotNull() + .where((result) => result.$2 == canonicalUrl) + .map((result) => result.originalUrl), + (url) => url.path.length) + // Use the canonicalized basename so that we display e.g. + // package:example/_example.scss rather than package:example/example + // in stack traces. + .andThen((url) => url.resolve(p.url.basename(canonicalUrl.path))) ?? + // If we don't have an original URL cached, display the canonical URL + // as-is. + canonicalUrl; /// Returns the URL to use in the source map to refer to [canonicalUrl]. /// @@ -245,16 +270,9 @@ Relative canonical URLs are deprecated and will eventually be disallowed. /// @nodoc @internal void clearCanonicalize(Uri url) { - _canonicalizeCache.remove(Tuple2(url, false)); - _canonicalizeCache.remove(Tuple2(url, true)); - - var relativeKeysToClear = [ - for (var key in _relativeCanonicalizeCache.keys) - if (key.item1 == url) key - ]; - for (var key in relativeKeysToClear) { - _relativeCanonicalizeCache.remove(key); - } + _canonicalizeCache.remove((url, forImport: false)); + _canonicalizeCache.remove((url, forImport: true)); + _relativeCanonicalizeCache.removeWhere((key, _) => key.$1 == url); } /// Clears the cached parse tree for the stylesheet with the given diff --git a/lib/src/importer/node_to_dart/async.dart b/lib/src/importer/js_to_dart/async.dart similarity index 82% rename from lib/src/importer/node_to_dart/async.dart rename to lib/src/importer/js_to_dart/async.dart index 5cf06e225..529789d11 100644 --- a/lib/src/importer/node_to_dart/async.dart +++ b/lib/src/importer/js_to_dart/async.dart @@ -4,30 +4,31 @@ import 'dart:async'; +import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart'; -import '../../node/importer.dart'; -import '../../node/url.dart'; -import '../../node/utils.dart'; +import '../../js/importer.dart'; +import '../../js/url.dart'; +import '../../js/utils.dart'; import '../../util/nullable.dart'; import '../async.dart'; import '../result.dart'; /// A wrapper for a potentially-asynchronous JS API importer that exposes it as /// a Dart [AsyncImporter]. -class NodeToDartAsyncImporter extends AsyncImporter { +final class JSToDartAsyncImporter extends AsyncImporter { /// The wrapped canonicalize function. final Object? Function(String, CanonicalizeOptions) _canonicalize; /// The wrapped load function. final Object? Function(JSUrl) _load; - NodeToDartAsyncImporter(this._canonicalize, this._load); + JSToDartAsyncImporter(this._canonicalize, this._load); FutureOr canonicalize(Uri url) async { - var result = _canonicalize( - url.toString(), CanonicalizeOptions(fromImport: fromImport)); + var result = wrapJSExceptions(() => _canonicalize( + url.toString(), CanonicalizeOptions(fromImport: fromImport))); if (isPromise(result)) result = await promiseToFuture(result as Promise); if (result == null) return null; @@ -37,7 +38,7 @@ class NodeToDartAsyncImporter extends AsyncImporter { } FutureOr load(Uri url) async { - var result = _load(dartToJSUrl(url)); + var result = wrapJSExceptions(() => _load(dartToJSUrl(url))); if (isPromise(result)) result = await promiseToFuture(result as Promise); if (result == null) return null; diff --git a/lib/src/importer/node_to_dart/async_file.dart b/lib/src/importer/js_to_dart/async_file.dart similarity index 84% rename from lib/src/importer/node_to_dart/async_file.dart rename to lib/src/importer/js_to_dart/async_file.dart index ddab82882..374783f08 100644 --- a/lib/src/importer/node_to_dart/async_file.dart +++ b/lib/src/importer/js_to_dart/async_file.dart @@ -4,36 +4,37 @@ import 'dart:async'; +import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:node_interop/util.dart'; -import '../../node/importer.dart'; -import '../../node/url.dart'; -import '../../node/utils.dart'; +import '../../js/importer.dart'; +import '../../js/url.dart'; +import '../../js/utils.dart'; import '../async.dart'; import '../filesystem.dart'; import '../result.dart'; import '../utils.dart'; /// A filesystem importer to use for most implementation details of -/// [NodeToDartAsyncFileImporter]. +/// [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]. -class NodeToDartAsyncFileImporter extends AsyncImporter { +final class JSToDartAsyncFileImporter extends AsyncImporter { /// The wrapped `findFileUrl` function. final Object? Function(String, CanonicalizeOptions) _findFileUrl; - NodeToDartAsyncFileImporter(this._findFileUrl); + JSToDartAsyncFileImporter(this._findFileUrl); FutureOr canonicalize(Uri url) async { if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); - var result = _findFileUrl( - url.toString(), CanonicalizeOptions(fromImport: fromImport)); + var result = wrapJSExceptions(() => _findFileUrl( + url.toString(), CanonicalizeOptions(fromImport: fromImport))); if (isPromise(result)) result = await promiseToFuture(result as Promise); if (result == null) return null; if (!isJSUrl(result)) { diff --git a/lib/src/importer/node_to_dart/file.dart b/lib/src/importer/js_to_dart/file.dart similarity index 84% rename from lib/src/importer/node_to_dart/file.dart rename to lib/src/importer/js_to_dart/file.dart index 15c2bad7f..3772e87f5 100644 --- a/lib/src/importer/node_to_dart/file.dart +++ b/lib/src/importer/js_to_dart/file.dart @@ -2,33 +2,34 @@ // 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:node_interop/js.dart'; import '../../importer.dart'; -import '../../node/importer.dart'; -import '../../node/url.dart'; -import '../../node/utils.dart'; +import '../../js/importer.dart'; +import '../../js/url.dart'; +import '../../js/utils.dart'; import '../utils.dart'; /// A filesystem importer to use for most implementation details of -/// [NodeToDartAsyncFileImporter]. +/// [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]. -class NodeToDartFileImporter extends Importer { +final class JSToDartFileImporter extends Importer { /// The wrapped `findFileUrl` function. final Object? Function(String, CanonicalizeOptions) _findFileUrl; - NodeToDartFileImporter(this._findFileUrl); + JSToDartFileImporter(this._findFileUrl); Uri? canonicalize(Uri url) { if (url.scheme == 'file') return _filesystemImporter.canonicalize(url); - var result = _findFileUrl( - url.toString(), CanonicalizeOptions(fromImport: fromImport)); + var result = wrapJSExceptions(() => _findFileUrl( + url.toString(), CanonicalizeOptions(fromImport: fromImport))); if (result == null) return null; if (isPromise(result)) { diff --git a/lib/src/importer/node_to_dart/sync.dart b/lib/src/importer/js_to_dart/sync.dart similarity index 83% rename from lib/src/importer/node_to_dart/sync.dart rename to lib/src/importer/js_to_dart/sync.dart index 4352d8d3e..da05420ed 100644 --- a/lib/src/importer/node_to_dart/sync.dart +++ b/lib/src/importer/js_to_dart/sync.dart @@ -2,28 +2,29 @@ // 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:node_interop/js.dart'; import '../../importer.dart'; -import '../../node/importer.dart'; -import '../../node/url.dart'; -import '../../node/utils.dart'; +import '../../js/importer.dart'; +import '../../js/url.dart'; +import '../../js/utils.dart'; import '../../util/nullable.dart'; /// A wrapper for a synchronous JS API importer that exposes it as a Dart /// [Importer]. -class NodeToDartImporter extends Importer { +final class JSToDartImporter extends Importer { /// The wrapped canonicalize function. final Object? Function(String, CanonicalizeOptions) _canonicalize; /// The wrapped load function. final Object? Function(JSUrl) _load; - NodeToDartImporter(this._canonicalize, this._load); + JSToDartImporter(this._canonicalize, this._load); Uri? canonicalize(Uri url) { - var result = _canonicalize( - url.toString(), CanonicalizeOptions(fromImport: fromImport)); + var result = wrapJSExceptions(() => _canonicalize( + url.toString(), CanonicalizeOptions(fromImport: fromImport))); if (result == null) return null; if (isJSUrl(result)) return jsToDartUrl(result as JSUrl); @@ -37,7 +38,7 @@ class NodeToDartImporter extends Importer { } ImporterResult? load(Uri url) { - var result = _load(dartToJSUrl(url)); + var result = wrapJSExceptions(() => _load(dartToJSUrl(url))); if (result == null) return null; if (isPromise(result)) { diff --git a/lib/src/importer/legacy_node/implementation.dart b/lib/src/importer/legacy_node/implementation.dart index c08d5a30e..678d82138 100644 --- a/lib/src/importer/legacy_node/implementation.dart +++ b/lib/src/importer/legacy_node/implementation.dart @@ -4,15 +4,15 @@ import 'dart:async'; +import 'package:cli_pkg/js.dart'; import 'package:js/js.dart'; import 'package:path/path.dart' as p; -import 'package:tuple/tuple.dart'; import '../../io.dart'; -import '../../node/function.dart'; -import '../../node/legacy/importer_result.dart'; -import '../../node/legacy/render_context.dart'; -import '../../node/utils.dart'; +import '../../js/function.dart'; +import '../../js/legacy/importer_result.dart'; +import '../../js/legacy/render_context.dart'; +import '../../js/utils.dart'; import '../../util/nullable.dart'; import '../utils.dart'; @@ -39,7 +39,7 @@ import '../utils.dart'; /// 3. Filesystem imports relative to the working directory. /// 4. Filesystem imports relative to an `includePaths` path. /// 5. Filesystem imports relative to a `SASS_PATH` path. -class NodeImporter { +final class NodeImporter { /// The options for the `this` context in which importer functions are /// invoked. /// @@ -74,7 +74,7 @@ class NodeImporter { /// /// Returns the stylesheet at that path and the URL used to load it, or `null` /// if loading failed. - Tuple2? loadRelative( + (String contents, String url)? loadRelative( String url, Uri? previous, bool forImport) { if (p.url.isAbsolute(url)) { if (!url.startsWith('/') && !url.startsWith('file:')) return null; @@ -93,13 +93,14 @@ class NodeImporter { /// The [previous] URL is the URL of the stylesheet in which the import /// appeared. Returns the contents of the stylesheet and the URL to use as /// [previous] for imports within the loaded stylesheet. - Tuple2? load(String url, Uri? previous, bool forImport) { + (String contents, String url)? load( + String url, Uri? previous, bool forImport) { // The previous URL is always an absolute file path for filesystem imports. var previousString = _previousToString(previous); for (var importer in _importers) { - var value = - call2(importer, _renderContext(forImport), url, previousString); - if (value != null) { + if (wrapJSExceptions(() => + call2(importer, _renderContext(forImport), url, previousString)) + case var value?) { return _handleImportResult(url, previous, value, forImport); } } @@ -113,14 +114,13 @@ class NodeImporter { /// The [previous] URL is the URL of the stylesheet in which the import /// appeared. Returns the contents of the stylesheet and the URL to use as /// [previous] for imports within the loaded stylesheet. - Future?> loadAsync( + Future<(String contents, String url)?> loadAsync( String url, Uri? previous, bool forImport) async { // The previous URL is always an absolute file path for filesystem imports. var previousString = _previousToString(previous); for (var importer in _importers) { - var value = - await _callImporterAsync(importer, url, previousString, forImport); - if (value != null) { + if (await _callImporterAsync(importer, url, previousString, forImport) + case var value?) { return _handleImportResult(url, previous, value, forImport); } } @@ -129,18 +129,18 @@ class NodeImporter { } /// Converts [previous] to a string to pass to the importer function. - String _previousToString(Uri? previous) { - if (previous == null) return 'stdin'; - if (previous.scheme == 'file') return p.fromUri(previous); - return previous.toString(); - } + String _previousToString(Uri? previous) => switch (previous) { + null => 'stdin', + Uri(scheme: 'file') => p.fromUri(previous), + _ => previous.toString() + }; /// Tries to load a stylesheet at the given [url] from a load path (including /// the working directory), if that URL refers to the filesystem. /// /// Returns the stylesheet at that path and the URL used to load it, or `null` /// if loading failed. - Tuple2? _resolveLoadPathFromUrl(Uri url, bool forImport) => + (String, String)? _resolveLoadPathFromUrl(Uri url, bool forImport) => url.scheme == '' || url.scheme == 'file' ? _resolveLoadPath(p.fromUri(url), forImport) : null; @@ -150,15 +150,16 @@ class NodeImporter { /// /// Returns the stylesheet at that path and the URL used to load it, or `null` /// if loading failed. - Tuple2? _resolveLoadPath(String path, bool forImport) { + (String, String)? _resolveLoadPath(String path, bool forImport) { // 2: Filesystem imports relative to the working directory. - var cwdResult = _tryPath(p.absolute(path), forImport); - if (cwdResult != null) return cwdResult; + if (_tryPath(p.absolute(path), forImport) case var result?) return result; // 3: Filesystem imports relative to [_includePaths]. for (var includePath in _includePaths) { - var result = _tryPath(p.absolute(p.join(includePath, path)), forImport); - if (result != null) return result; + if (_tryPath(p.absolute(p.join(includePath, path)), forImport) + case var result?) { + return result; + } } return null; @@ -168,15 +169,15 @@ class NodeImporter { /// /// Returns the stylesheet at that path and the URL used to load it, or `null` /// if loading failed. - Tuple2? _tryPath(String path, bool forImport) => (forImport + (String, String)? _tryPath(String path, bool forImport) => (forImport ? inImportRule(() => resolveImportPath(path)) : resolveImportPath(path)) - .andThen((resolved) => - Tuple2(readFile(resolved), p.toUri(resolved).toString())); + .andThen( + (resolved) => (readFile(resolved), p.toUri(resolved).toString())); - /// Converts an importer's return [value] to a tuple that can be returned by - /// [load]. - Tuple2? _handleImportResult( + /// Converts an importer's return [value] to a (contents, url) pair that can + /// be returned by [load]. + (String, String)? _handleImportResult( String url, Uri? previous, Object value, bool forImport) { if (isJSError(value)) throw value; if (value is! NodeImporterResult) return null; @@ -189,9 +190,9 @@ class NodeImporter { } if (file == null) { - return Tuple2(contents ?? '', url); + return (contents ?? '', url); } else if (contents != null) { - return Tuple2(contents, p.toUri(file).toString()); + return (contents, p.toUri(file).toString()); } else { var resolved = loadRelative(p.toUri(file).toString(), previous, forImport) ?? @@ -206,8 +207,12 @@ class NodeImporter { String previousString, bool forImport) async { var completer = Completer(); - var result = call3(importer, _renderContext(forImport), url, previousString, - allowInterop(completer.complete)); + var result = wrapJSExceptions(() => call3( + importer, + _renderContext(forImport), + url, + previousString, + allowInterop(completer.complete))); if (isUndefined(result)) return await completer.future; return result; } diff --git a/lib/src/importer/legacy_node/interface.dart b/lib/src/importer/legacy_node/interface.dart index 5cef8cd30..0b6f5cb29 100644 --- a/lib/src/importer/legacy_node/interface.dart +++ b/lib/src/importer/legacy_node/interface.dart @@ -2,20 +2,19 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:tuple/tuple.dart'; - -class NodeImporter { +final class NodeImporter { NodeImporter(Object options, Iterable includePaths, Iterable importers); - Tuple2? loadRelative( + (String contents, String url)? loadRelative( String url, Uri? previous, bool forImport) => throw ''; - Tuple2? load(String url, Uri? previous, bool forImport) => + (String contents, String url)? load( + String url, Uri? previous, bool forImport) => throw ''; - Future?> loadAsync( + Future<(String contents, String url)?> loadAsync( String url, Uri? previous, bool forImport) => throw ''; } diff --git a/lib/src/importer/no_op.dart b/lib/src/importer/no_op.dart index e6261499d..e0c8b61cb 100644 --- a/lib/src/importer/no_op.dart +++ b/lib/src/importer/no_op.dart @@ -8,7 +8,7 @@ import '../importer.dart'; /// /// This is used for stylesheets which don't support relative imports, such as /// those created from Dart code with plain strings. -class NoOpImporter extends Importer { +final class NoOpImporter extends Importer { Uri? canonicalize(Uri url) => null; ImporterResult? load(Uri url) => null; bool couldCanonicalize(Uri url, Uri canonicalUrl) => false; diff --git a/lib/src/importer/utils.dart b/lib/src/importer/utils.dart index f9459bfd2..464a050f8 100644 --- a/lib/src/importer/utils.dart +++ b/lib/src/importer/utils.dart @@ -71,13 +71,12 @@ String? _tryPathAsDirectory(String path) { /// /// If it contains no paths, returns `null`. If it contains more than one, /// throws an exception. -String? _exactlyOne(List paths) { - if (paths.isEmpty) return null; - if (paths.length == 1) return paths.first; - - throw "It's not clear which file to import. Found:\n" + - paths.map((path) => " " + p.prettyUri(p.toUri(path))).join("\n"); -} +String? _exactlyOne(List paths) => switch (paths) { + [] => null, + [var path] => path, + _ => throw "It's not clear which file to import. Found:\n" + + paths.map((path) => " " + p.prettyUri(p.toUri(path))).join("\n") + }; /// If [fromImport] is `true`, invokes callback and returns the result. /// diff --git a/lib/src/interpolation_buffer.dart b/lib/src/interpolation_buffer.dart index 68cd37701..014c31fec 100644 --- a/lib/src/interpolation_buffer.dart +++ b/lib/src/interpolation_buffer.dart @@ -11,7 +11,7 @@ import 'ast/sass.dart'; /// /// Add text using [write] and related methods, and [Expression]s using [add]. /// Once that's done, call [interpolation] to build the result. -class InterpolationBuffer implements StringSink { +final class InterpolationBuffer implements StringSink { /// The buffer that accumulates plain text. final _text = StringBuffer(); @@ -49,10 +49,9 @@ class InterpolationBuffer implements StringSink { if (interpolation.contents.isEmpty) return; Iterable toAdd = interpolation.contents; - var first = interpolation.contents.first; - if (first is String) { + if (interpolation.contents case [String first, ...var rest]) { _text.write(first); - toAdd = interpolation.contents.skip(1); + toAdd = rest; } _flushText(); diff --git a/lib/src/interpolation_map.dart b/lib/src/interpolation_map.dart new file mode 100644 index 000000000..9f6788f53 --- /dev/null +++ b/lib/src/interpolation_map.dart @@ -0,0 +1,174 @@ +// 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 'dart:math' as math; + +import 'package:charcode/charcode.dart'; +import 'package:source_span/source_span.dart'; + +import 'ast/sass.dart'; +import 'util/character.dart'; + +/// A map from locations in a string generated from an [Interpolation] to the +/// original source code in the interpolation. +final class InterpolationMap { + /// The interpolation from which this map was generated. + final Interpolation _interpolation; + + /// Locations in the generated string. + /// + /// Each of these indicates the location in the generated string that + /// corresponds to the end of the component at the same index of + /// [_interpolation.contents]. Its length is always one less than + /// [_interpolation.contents] because the last element always ends the string. + final List _targetLocations; + + /// Creates a new interpolation map that maps the given [targetLocations] in + /// the generated string to the contents of the interpolation. + /// + /// Each target location at index `i` corresponds to the character in the + /// generated string after `interpolation.contents[i]`. + InterpolationMap( + this._interpolation, Iterable targetLocations) + : _targetLocations = List.unmodifiable(targetLocations) { + var expectedLocations = math.max(0, _interpolation.contents.length - 1); + if (_targetLocations.length != expectedLocations) { + throw ArgumentError( + "InterpolationMap must have $expectedLocations targetLocations if the " + "interpolation has ${_interpolation.contents.length} components."); + } + } + + /// Maps [error]'s span in the string generated from this interpolation to its + /// original source. + FormatException mapException(SourceSpanFormatException error) { + var target = error.span; + if (target == null) return error; + + var source = mapSpan(target); + var startIndex = _indexInContents(target.start); + var endIndex = _indexInContents(target.end); + + if (!_interpolation.contents + .skip(startIndex) + .take(endIndex - startIndex + 1) + .any((content) => content is Expression)) { + return SourceSpanFormatException(error.message, source, error.source); + } else { + return MultiSourceSpanFormatException(error.message, source, "", + {target: "error in interpolated output"}, error.source); + } + } + + /// Maps a span in the string generated from this interpolation to its + /// original source. + FileSpan mapSpan(SourceSpan target) => + switch ((_mapLocation(target.start), _mapLocation(target.end))) { + (FileSpan start, FileSpan end) => start.expand(end), + (FileSpan start, FileLocation end) => _interpolation.span.file + .span(_expandInterpolationSpanLeft(start.start), end.offset), + (FileLocation start, FileSpan end) => _interpolation.span.file + .span(start.offset, _expandInterpolationSpanRight(end.end)), + (FileLocation start, FileLocation end) => + _interpolation.span.file.span(start.offset, end.offset), + _ => throw '[BUG] Unreachable' + }; + + /// Maps a location in the string generated from this interpolation to its + /// original source. + /// + /// If [source] points to an un-interpolated portion of the original string, + /// this will return the corresponding [FileLocation]. If it points to text + /// generated from interpolation, this will return the full [FileSpan] for + /// that interpolated expression. + Object /* FileLocation|FileSpan */ _mapLocation(SourceLocation target) { + var index = _indexInContents(target); + if (_interpolation.contents[index] case Expression chunk) { + return chunk.span; + } + + var previousLocation = index == 0 + ? _interpolation.span.start + : _interpolation.span.file.location(_expandInterpolationSpanRight( + (_interpolation.contents[index - 1] as Expression).span.end)); + var offsetInString = + target.offset - (index == 0 ? 0 : _targetLocations[index - 1].offset); + + // This produces slightly incorrect mappings if there are _unnecessary_ + // escapes in the source file, but that's unlikely enough that it's probably + // not worth doing a reparse here to fix it. + return previousLocation.file + .location(previousLocation.offset + offsetInString); + } + + /// Return the index in [_interpolation.contents] at which [target] points. + int _indexInContents(SourceLocation target) { + for (var i = 0; i < _targetLocations.length; i++) { + if (target.offset < _targetLocations[i].offset) return i; + } + + return _interpolation.contents.length - 1; + } + + /// Given the start of a [FileSpan] covering an interpolated expression, returns + /// the offset of the interpolation's opening `#`. + /// + /// Note that this can be tricked by a `#{` that appears within a single-line + /// comment before the expression, but since it's only used for error + /// reporting that's probably fine. + int _expandInterpolationSpanLeft(FileLocation start) { + var source = start.file.codeUnits; + var i = start.offset - 1; + while (i >= 0) { + var prev = source[i--]; + if (prev == $lbrace) { + if (source[i] == $hash) break; + } else if (prev == $slash) { + var second = source[i--]; + if (second == $asterisk) { + while (true) { + var char = source[i--]; + if (char != $asterisk) continue; + + do { + char = source[i--]; + } while (char == $asterisk); + if (char == $slash) break; + } + } + } + } + + return i; + } + + /// Given the end of a [FileSpan] covering an interpolated expression, returns + /// the offset of the interpolation's closing `}`. + int _expandInterpolationSpanRight(FileLocation end) { + var source = end.file.codeUnits; + var i = end.offset; + while (i < source.length) { + var next = source[i++]; + if (next == $rbrace) break; + if (next == $slash) { + var second = source[i++]; + if (second == $slash) { + while (!source[i++].isNewline) {} + } else if (second == $asterisk) { + while (true) { + var char = source[i++]; + if (char != $asterisk) continue; + + do { + char = source[i++]; + } while (char == $asterisk); + if (char == $slash) break; + } + } + } + } + + return i; + } +} diff --git a/lib/src/io.dart b/lib/src/io.dart index c96f7a25a..029f45aec 100644 --- a/lib/src/io.dart +++ b/lib/src/io.dart @@ -6,13 +6,13 @@ import 'package:path/path.dart' as p; import 'io/interface.dart' if (dart.library.io) 'io/vm.dart' - if (dart.library.js) 'io/node.dart'; + if (dart.library.js) 'io/js.dart'; import 'utils.dart'; import 'util/character.dart'; export 'io/interface.dart' if (dart.library.io) 'io/vm.dart' - if (dart.library.js) 'io/node.dart'; + if (dart.library.js) 'io/js.dart'; /// A cache of return values for directories in [_realCasePath]. final _realCaseCache = {}; @@ -42,7 +42,7 @@ String _realCasePath(String path) { if (isWindows) { // Drive names are *always* case-insensitive, so convert them to uppercase. var prefix = p.rootPrefix(path); - if (prefix.isNotEmpty && isAlphabetic(prefix.codeUnitAt(0))) { + if (prefix.isNotEmpty && prefix.codeUnitAt(0).isAlphabetic) { path = prefix.toUpperCase() + path.substring(prefix.length); } } @@ -61,12 +61,13 @@ String _realCasePath(String path) { (realPath) => equalsIgnoreCase(p.basename(realPath), basename)) .toList(); - return matches.length != 1 - // If the file doesn't exist, or if there are multiple options (meaning - // the filesystem isn't actually case-insensitive), use `basename` - // as-is. - ? p.join(realDirname, basename) - : matches[0]; + return switch (matches) { + [var match] => match, + // If the file doesn't exist, or if there are multiple options + // (meaning the filesystem isn't actually case-insensitive), use + // `basename` as-is. + _ => p.join(realDirname, basename) + }; } on FileSystemException catch (_) { // If there's an error listing a directory, it's likely because we're // trying to reach too far out of the current directory into something diff --git a/lib/src/io/interface.dart b/lib/src/io/interface.dart index 0ee35bc53..618c3ff6e 100644 --- a/lib/src/io/interface.dart +++ b/lib/src/io/interface.dart @@ -4,30 +4,12 @@ import 'package:watcher/watcher.dart'; -/// An output sink that writes to this process's standard error. -class Stderr { - /// Writes the string representation of [object] to standard error. - void write(Object object) {} - - /// Writes the string representation of [object] to standard error, followed - /// by a newline. - /// - /// If [object] is `null`, just writes a newline. - void writeln([Object? object]) {} - - /// Flushes any buffered text. - void flush() {} -} - /// An error thrown by [readFile]. class FileSystemException { String get message => throw ''; String? get path => throw ''; } -/// The standard error for the current process. -Stderr get stderr => throw ''; - /// Whether the current process is running on Windows. bool get isWindows => throw ''; @@ -37,15 +19,22 @@ bool get isMacOS => throw ''; /// Returns whether or not stdout is connected to an interactive terminal. bool get hasTerminal => throw ''; -/// Whether we're running as Node.JS. +/// 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 ''; -/// The current working directory. -String get currentPath => throw ''; +/// Prints [message] (followed by a newline) to standard error or the +/// equivalent. +void printError(Object? message) => throw ''; /// Reads the file at [path] as a UTF-8 encoded string. /// diff --git a/lib/src/io/node.dart b/lib/src/io/js.dart similarity index 66% rename from lib/src/io/node.dart rename to lib/src/io/js.dart index 12e24d5a6..efc1955e4 100644 --- a/lib/src/io/node.dart +++ b/lib/src/io/js.dart @@ -8,14 +8,16 @@ import 'dart:js_util'; import 'package:js/js.dart'; import 'package:node_interop/fs.dart'; -import 'package:node_interop/node_interop.dart'; -import 'package:node_interop/stream.dart'; +import 'package:node_interop/node_interop.dart' hide process; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:watcher/watcher.dart'; import '../exception.dart'; -import '../node/chokidar.dart'; +import '../js/chokidar.dart'; + +@JS('process') +external final Process? process; // process is null in the browser class FileSystemException { final String message; @@ -26,21 +28,18 @@ class FileSystemException { String toString() => "${p.prettyUri(p.toUri(path))}: $message"; } -class Stderr { - final Writable _stderr; - - Stderr(this._stderr); - - void write(Object object) => _stderr.write(object.toString()); - - void writeln([Object? object]) { - _stderr.write("${object ?? ''}\n"); +void printError(Object? message) { + if (process case var process?) { + process.stderr.write("${message ?? ''}\n"); + } else { + console.error(message ?? ''); } - - void flush() {} } String readFile(String path) { + if (!isNode) { + 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 // it doesn't cause a substantial performance degradation for large files. See // also dart-lang/sdk#25377. @@ -61,13 +60,26 @@ String readFile(String path) { Object? _readFile(String path, [String? encoding]) => _systemErrorToFileSystemException(() => fs.readFileSync(path, encoding)); -void writeFile(String path, String contents) => - _systemErrorToFileSystemException(() => fs.writeFileSync(path, contents)); +void writeFile(String path, String contents) { + if (!isNode) { + throw UnsupportedError("writeFile() is only supported on Node.js"); + } + return _systemErrorToFileSystemException( + () => fs.writeFileSync(path, contents)); +} -void deleteFile(String path) => - _systemErrorToFileSystemException(() => fs.unlinkSync(path)); +void deleteFile(String path) { + if (!isNode) { + throw UnsupportedError("deleteFile() is only supported on Node.js"); + } + return _systemErrorToFileSystemException(() => fs.unlinkSync(path)); +} Future readStdin() async { + var process_ = process; + if (process_ == null) { + throw UnsupportedError("readStdin() is only supported on Node.js"); + } var completer = Completer(); String contents; var innerSink = StringConversionSink.withCallback((String result) { @@ -76,17 +88,17 @@ 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? _]) { // Callback for 'end' receives no args. assert(_ == null); sink.close(); })); - process.stdin.on('error', allowInterop(([Object? e]) { - stderr.writeln('Failed to read from stdin'); - stderr.writeln(e); + process_.stdin.on('error', allowInterop(([Object? e]) { + printError('Failed to read from stdin'); + printError(e); completer.completeError(e!); })); return completer.future; @@ -101,6 +113,9 @@ String _cleanErrorMessage(JsSystemError error) { } bool fileExists(String path) { + if (!isNode) { + throw UnsupportedError("fileExists() is only supported on Node.js"); + } return _systemErrorToFileSystemException(() { // `existsSync()` is faster than `statSync()`, but it doesn't clarify // whether the entity in question is a file or a directory. Since false @@ -119,6 +134,9 @@ bool fileExists(String path) { } bool dirExists(String path) { + if (!isNode) { + throw UnsupportedError("dirExists() is only supported on Node.js"); + } return _systemErrorToFileSystemException(() { // `existsSync()` is faster than `statSync()`, but it doesn't clarify // whether the entity in question is a file or a directory. Since false @@ -137,6 +155,9 @@ bool dirExists(String path) { } void ensureDir(String path) { + if (!isNode) { + throw UnsupportedError("ensureDir() is only supported on Node.js"); + } return _systemErrorToFileSystemException(() { try { fs.mkdirSync(path); @@ -151,6 +172,9 @@ void ensureDir(String path) { } Iterable listDir(String path, {bool recursive = false}) { + if (!isNode) { + throw UnsupportedError("listDir() is only supported on Node.js"); + } return _systemErrorToFileSystemException(() { if (!recursive) { return fs @@ -169,12 +193,18 @@ Iterable listDir(String path, {bool recursive = false}) { }); } -DateTime modificationTime(String path) => - _systemErrorToFileSystemException(() => - DateTime.fromMillisecondsSinceEpoch(fs.statSync(path).mtime.getTime())); +DateTime modificationTime(String path) { + if (!isNode) { + throw UnsupportedError("modificationTime() is only supported on Node.js"); + } + return _systemErrorToFileSystemException(() => + DateTime.fromMillisecondsSinceEpoch(fs.statSync(path).mtime.getTime())); +} -String? getEnvironmentVariable(String name) => - getProperty(process.env as Object, name) as String?; +String? getEnvironmentVariable(String name) { + var env = process?.env; + return env == null ? null : getProperty(env as Object, name) as String?; +} /// Runs callback and converts any [JsSystemError]s it throws into /// [FileSystemException]s. @@ -187,32 +217,42 @@ T _systemErrorToFileSystemException(T callback()) { } } -final stderr = Stderr(process.stderr); +/// Ignore `invalid_null_aware_operator` error, because [process.stdout.isTTY] +/// 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'; -/// We can't use [process.stdout.isTTY] from `node_interop` because of -/// pulyaevskiy/node-interop#93: it declares `isTTY` as always non-nullably -/// available, but in practice it's undefined if stdout isn't a TTY. -@JS('process.stdout.isTTY') -external bool? get isTTY; +bool get isMacOS => process?.platform == 'darwin'; -bool get hasTerminal => isTTY == true; +const bool isJS = true; -bool get isWindows => process.platform == 'win32'; +/// 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 isMacOS => process.platform == 'darwin'; +bool get isNode => _fsNullable != null; -bool get isNode => true; +bool get isBrowser => isJS && !isNode; // Node seems to support ANSI escapes on all terminals. bool get supportsAnsiEscapes => hasTerminal; -String get currentPath => process.cwd(); +int get exitCode => process?.exitCode ?? 0; -int get exitCode => process.exitCode; - -set exitCode(int code) => process.exitCode = code; +set exitCode(int code) => process?.exitCode = code; Future> watchDir(String path, {bool poll = false}) { + if (!isNode) { + throw UnsupportedError("watchDir() is only supported on Node.js"); + } var watcher = chokidar.watch( path, ChokidarOptions(disableGlobbing: true, usePolling: poll)); diff --git a/lib/src/io/vm.dart b/lib/src/io/vm.dart index bdc89916b..0174e09b4 100644 --- a/lib/src/io/vm.dart +++ b/lib/src/io/vm.dart @@ -16,16 +16,18 @@ import '../utils.dart'; export 'dart:io' show exitCode, FileSystemException; -io.Stdout get stderr => io.stderr; - bool get isWindows => io.Platform.isWindows; 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; @@ -35,7 +37,9 @@ bool get supportsAnsiEscapes { return io.stdout.supportsAnsiEscapes; } -String get currentPath => io.Directory.current.path; +void printError(Object? message) { + io.stderr.writeln(message); +} String readFile(String path) { var bytes = io.File(path).readAsBytesSync(); @@ -53,6 +57,7 @@ String readFile(String path) { throwWithTrace( SassException( "Invalid UTF-8.", sourceFile.location(stringOffset).pointSpan()), + error, stackTrace); } } diff --git a/lib/src/node.dart b/lib/src/js.dart similarity index 79% rename from lib/src/node.dart rename to lib/src/js.dart index 41513da75..cd1480719 100644 --- a/lib/src/node.dart +++ b/lib/src/js.dart @@ -2,19 +2,19 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'node/exception.dart'; -import 'node/exports.dart'; -import 'node/compile.dart'; -import 'node/legacy.dart'; -import 'node/legacy/types.dart'; -import 'node/legacy/value.dart'; -import 'node/logger.dart'; -import 'node/source_span.dart'; -import 'node/utils.dart'; -import 'node/value.dart'; +import 'js/exception.dart'; +import 'js/exports.dart'; +import 'js/compile.dart'; +import 'js/legacy.dart'; +import 'js/legacy/types.dart'; +import 'js/legacy/value.dart'; +import 'js/logger.dart'; +import 'js/source_span.dart'; +import 'js/utils.dart'; +import 'js/value.dart'; import 'value.dart'; -/// The entrypoint for the Node.js module. +/// The entrypoint for the JavaScript module. /// /// This sets up exports that can be called from JS. void main() { @@ -27,6 +27,9 @@ void main() { exports.Value = valueClass; exports.SassBoolean = booleanClass; exports.SassArgumentList = argumentListClass; + exports.SassCalculation = calculationClass; + exports.CalculationOperation = calculationOperationClass; + exports.CalculationInterpolation = calculationInterpolationClass; exports.SassColor = colorClass; exports.SassFunction = functionClass; exports.SassList = listClass; @@ -38,7 +41,7 @@ void main() { exports.sassFalse = sassFalse; exports.Exception = exceptionClass; exports.Logger = LoggerNamespace( - silent: NodeLogger( + silent: JSLogger( warn: allowInteropNamed('sass.Logger.silent.warn', (_, __) {}), debug: allowInteropNamed('sass.Logger.silent.debug', (_, __) {}))); diff --git a/lib/src/node/array.dart b/lib/src/js/array.dart similarity index 100% rename from lib/src/node/array.dart rename to lib/src/js/array.dart diff --git a/lib/src/node/chokidar.dart b/lib/src/js/chokidar.dart similarity index 100% rename from lib/src/node/chokidar.dart rename to lib/src/js/chokidar.dart diff --git a/lib/src/node/compile.dart b/lib/src/js/compile.dart similarity index 69% rename from lib/src/node/compile.dart rename to lib/src/js/compile.dart index a5a5e50f2..af5702308 100644 --- a/lib/src/node/compile.dart +++ b/lib/src/js/compile.dart @@ -2,19 +2,19 @@ // 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: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 '../../sass.dart'; import '../importer/no_op.dart'; -import '../importer/node_to_dart/async.dart'; -import '../importer/node_to_dart/async_file.dart'; -import '../importer/node_to_dart/file.dart'; -import '../importer/node_to_dart/sync.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 '../io.dart'; -import '../logger/node_to_dart.dart'; +import '../logger/js_to_dart.dart'; import '../util/nullable.dart'; import 'compile_options.dart'; import 'compile_result.dart'; @@ -27,6 +27,9 @@ 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) { + jsThrow(JsError("The compile() method is only available in Node.js.")); + } var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; try { @@ -38,7 +41,7 @@ NodeCompileResult compile(String path, [CompileOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: NodeToDartLogger(options?.logger, Logger.stderr(color: color), + logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), ascii: ascii), importers: options?.importers?.map(_parseImporter), functions: _parseFunctions(options?.functions).cast()); @@ -67,7 +70,7 @@ NodeCompileResult compileString(String text, [CompileStringOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: NodeToDartLogger(options?.logger, Logger.stderr(color: color), + logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), ascii: ascii), importers: options?.importers?.map(_parseImporter), importer: options?.importer.andThen(_parseImporter) ?? @@ -85,6 +88,9 @@ 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) { + jsThrow(JsError("The compileAsync() method is only available in Node.js.")); + } var color = options?.alertColor ?? hasTerminal; var ascii = options?.alertAscii ?? glyph.ascii; return _wrapAsyncSassExceptions(futureToPromise(() async { @@ -96,7 +102,7 @@ Promise compileAsync(String path, [CompileOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: NodeToDartLogger(options?.logger, Logger.stderr(color: color), + logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), ascii: ascii), importers: options?.importers ?.map((importer) => _parseAsyncImporter(importer)), @@ -124,7 +130,7 @@ Promise compileStringAsync(String text, [CompileStringOptions? options]) { verbose: options?.verbose ?? false, charset: options?.charset ?? true, sourceMap: options?.sourceMap ?? false, - logger: NodeToDartLogger(options?.logger, Logger.stderr(color: color), + logger: JSToDartLogger(options?.logger, Logger.stderr(color: color), ascii: ascii), importers: options?.importers ?.map((importer) => _parseAsyncImporter(importer)), @@ -167,11 +173,11 @@ Promise _wrapAsyncSassExceptions(Promise promise, : jsThrow(error as Object))); /// Converts an output style string to an instance of [OutputStyle]. -OutputStyle _parseOutputStyle(String? style) { - if (style == null || style == 'expanded') return OutputStyle.expanded; - if (style == 'compressed') return OutputStyle.compressed; - jsThrow(JsError('Unknown output style "$style".')); -} +OutputStyle _parseOutputStyle(String? style) => switch (style) { + null || 'expanded' => OutputStyle.expanded, + 'compressed' => OutputStyle.compressed, + _ => jsThrow(JsError('Unknown output style "$style".')) + }; /// Converts [importer] into an [AsyncImporter] that can be used with /// [compileAsync] or [compileStringAsync]. @@ -179,21 +185,22 @@ AsyncImporter _parseAsyncImporter(Object? importer) { if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as NodeImporter; - var findFileUrl = importer.findFileUrl; var canonicalize = importer.canonicalize; var load = importer.load; - if (findFileUrl == null) { - if (canonicalize == null || load == null) { - jsThrow(JsError( - "An importer must have either canonicalize and load methods, or a " - "findFileUrl method.")); + if (importer.findFileUrl case var findFileUrl?) { + if (canonicalize != null || load != null) { + jsThrow( + JsError("An importer may not have a findFileUrl method as well as " + "canonicalize and load methods.")); + } else { + return JSToDartAsyncFileImporter(findFileUrl); } - return NodeToDartAsyncImporter(canonicalize, load); - } else if (canonicalize != null || load != null) { - jsThrow(JsError("An importer may not have a findFileUrl method as well as " - "canonicalize and load methods.")); + } else if (canonicalize == null || load == null) { + jsThrow(JsError( + "An importer must have either canonicalize and load methods, or a " + "findFileUrl method.")); } else { - return NodeToDartAsyncFileImporter(findFileUrl); + return JSToDartAsyncImporter(canonicalize, load); } } @@ -202,24 +209,59 @@ Importer _parseImporter(Object? importer) { if (importer == null) jsThrow(JsError("Importers may not be null.")); importer as NodeImporter; - var findFileUrl = importer.findFileUrl; var canonicalize = importer.canonicalize; var load = importer.load; - if (findFileUrl == null) { - if (canonicalize == null || load == null) { - jsThrow(JsError( - "An importer must have either canonicalize and load methods, or a " - "findFileUrl method.")); + if (importer.findFileUrl case var findFileUrl?) { + if (canonicalize != null || load != null) { + jsThrow( + JsError("An importer may not have a findFileUrl method as well as " + "canonicalize and load methods.")); + } else { + return JSToDartFileImporter(findFileUrl); } - return NodeToDartImporter(canonicalize, load); - } else if (canonicalize != null || load != null) { - jsThrow(JsError("An importer may not have a findFileUrl method as well as " - "canonicalize and load methods.")); + } else if (canonicalize == null || load == null) { + jsThrow(JsError( + "An importer must have either canonicalize and load methods, or a " + "findFileUrl method.")); } else { - return NodeToDartFileImporter(findFileUrl); + return JSToDartImporter(canonicalize, load); } } +/// 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} +Value _simplifyValue(Value value) => switch (value) { + SassCalculation() => switch (( + // Match against... + value.name, // ...the calculation name + value.arguments // ...and simplified arguments + .map(_simplifyCalcArg) + .toList() + )) { + ('calc', [var first]) => first as Value, + ('calc', _) => + throw ArgumentError('calc() requires exactly one argument.'), + ('clamp', [var min, var value, var max]) => + SassCalculation.clamp(min, value, max), + ('clamp', _) => + throw ArgumentError('clamp() requires exactly 3 arguments.'), + ('min', var args) => SassCalculation.min(args), + ('max', var args) => SassCalculation.max(args), + (var name, _) => throw ArgumentError( + '"$name" is not a recognized calculation type.'), + }, + _ => value, + }; + +/// Handles simplifying calculation arguments, which are not guaranteed to be +/// Value instances. +Object _simplifyCalcArg(Object value) => switch (value) { + SassCalculation() => _simplifyValue(value), + CalculationOperation() => SassCalculation.operate(value.operator, + _simplifyCalcArg(value.left), _simplifyCalcArg(value.right)), + _ => value, + }; + /// Parses `functions` from [record] into a list of [Callable]s or /// [AsyncCallable]s. /// @@ -233,8 +275,9 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { if (!asynch) { late Callable callable; callable = Callable.fromSignature(signature, (arguments) { - var result = (callback as Function)(toJSArray(arguments)); - if (result is Value) return result; + var result = wrapJSExceptions( + () => (callback as Function)(toJSArray(arguments))); + if (result is Value) return _simplifyValue(result); if (isPromise(result)) { throw 'Invalid return value for custom function ' '"${callable.name}":\n' @@ -249,12 +292,13 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { } else { late AsyncCallable callable; callable = AsyncCallable.fromSignature(signature, (arguments) async { - var result = (callback as Function)(toJSArray(arguments)); + var result = wrapJSExceptions( + () => (callback as Function)(toJSArray(arguments))); if (isPromise(result)) { result = await promiseToFuture(result as Promise); } - if (result is Value) return result; + if (result is Value) return _simplifyValue(result); throw 'Invalid return value for custom function ' '"${callable.name}": $result is not a sass.Value.'; }); diff --git a/lib/src/node/compile_options.dart b/lib/src/js/compile_options.dart similarity index 96% rename from lib/src/node/compile_options.dart rename to lib/src/js/compile_options.dart index 911ee2132..603adba4e 100644 --- a/lib/src/node/compile_options.dart +++ b/lib/src/js/compile_options.dart @@ -20,7 +20,7 @@ class CompileOptions { external bool? get charset; external bool? get sourceMap; external bool? get sourceMapIncludeSources; - external NodeLogger? get logger; + external JSLogger? get logger; external List? get importers; external Object? get functions; } diff --git a/lib/src/node/compile_result.dart b/lib/src/js/compile_result.dart similarity index 100% rename from lib/src/node/compile_result.dart rename to lib/src/js/compile_result.dart diff --git a/lib/src/node/exception.dart b/lib/src/js/exception.dart similarity index 100% rename from lib/src/node/exception.dart rename to lib/src/js/exception.dart diff --git a/lib/src/node/exports.dart b/lib/src/js/exports.dart similarity index 86% rename from lib/src/node/exports.dart rename to lib/src/js/exports.dart index c301e270e..0dff13698 100644 --- a/lib/src/node/exports.dart +++ b/lib/src/js/exports.dart @@ -26,6 +26,9 @@ class Exports { // Value APIs external set Value(JSClass function); external set SassArgumentList(JSClass function); + external set SassCalculation(JSClass function); + external set CalculationOperation(JSClass function); + external set CalculationInterpolation(JSClass function); external set SassBoolean(JSClass function); external set SassColor(JSClass function); external set SassFunction(JSClass function); @@ -49,9 +52,9 @@ class Exports { @JS() @anonymous class LoggerNamespace { - external NodeLogger get silent; + external JSLogger get silent; - external factory LoggerNamespace({required NodeLogger silent}); + external factory LoggerNamespace({required JSLogger silent}); } @JS() diff --git a/lib/src/node/function.dart b/lib/src/js/function.dart similarity index 100% rename from lib/src/node/function.dart rename to lib/src/js/function.dart diff --git a/lib/src/node/immutable.dart b/lib/src/js/immutable.dart similarity index 92% rename from lib/src/node/immutable.dart rename to lib/src/js/immutable.dart index 354d9c306..b9dfc6f28 100644 --- a/lib/src/node/immutable.dart +++ b/lib/src/js/immutable.dart @@ -4,6 +4,8 @@ import 'package:js/js.dart'; +import '../util/map.dart'; + @JS('immutable.List') class ImmutableList { external factory ImmutableList([List? contents]); @@ -36,8 +38,8 @@ List jsToDartList(Object? list) => /// Converts a Dart map into an equivalent [ImmutableMap]. ImmutableMap dartMapToImmutableMap(Map dartMap) { var immutableMap = ImmutableMap().asMutable(); - for (var entry in dartMap.entries) { - immutableMap = immutableMap.set(entry.key, entry.value); + for (var (key, value) in dartMap.pairs) { + immutableMap = immutableMap.set(key, value); } return immutableMap.asImmutable(); } diff --git a/lib/src/node/importer.dart b/lib/src/js/importer.dart similarity index 100% rename from lib/src/node/importer.dart rename to lib/src/js/importer.dart diff --git a/lib/src/node/legacy.dart b/lib/src/js/legacy.dart similarity index 85% rename from lib/src/node/legacy.dart rename to lib/src/js/legacy.dart index 3cda764a5..66875da51 100644 --- a/lib/src/node/legacy.dart +++ b/lib/src/js/legacy.dart @@ -7,7 +7,7 @@ import 'dart:convert'; import 'dart:js_util'; import 'dart:typed_data'; -import 'package:js/js.dart'; +import 'package:cli_pkg/js.dart'; import 'package:node_interop/js.dart'; import 'package:path/path.dart' as p; @@ -18,7 +18,7 @@ import '../exception.dart'; import '../importer/legacy_node.dart'; import '../io.dart'; import '../logger.dart'; -import '../logger/node_to_dart.dart'; +import '../logger/js_to_dart.dart'; import '../syntax.dart'; import '../util/nullable.dart'; import '../utils.dart'; @@ -39,8 +39,10 @@ import 'utils.dart'; /// [render]: https://github.com/sass/node-sass#options void render( RenderOptions options, void callback(Object? error, RenderResult? result)) { - var fiber = options.fiber; - if (fiber != null) { + if (!isNode) { + jsThrow(JsError("The render() method is only available in Node.js.")); + } + if (options.fiber case var fiber?) { fiber.call(allowInterop(() { try { callback(null, renderSync(options)); @@ -70,9 +72,8 @@ Future _renderAsync(RenderOptions options) async { var start = DateTime.now(); CompileResult result; - var data = options.data; var file = options.file.andThen(p.absolute); - if (data != null) { + if (options.data case var data?) { result = await compileStringAsync(data, nodeImporter: _parseImporter(options, start), functions: _parseFunctions(options, start, asynch: true), @@ -86,8 +87,8 @@ Future _renderAsync(RenderOptions options) async { verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: NodeToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); } else if (file != null) { result = await compileAsync(file, nodeImporter: _parseImporter(options, start), @@ -101,8 +102,8 @@ Future _renderAsync(RenderOptions options) async { verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: NodeToDartLogger( - options.logger, Logger.stderr(color: hasTerminal))); + logger: + JSToDartLogger(options.logger, Logger.stderr(color: hasTerminal))); } else { throw ArgumentError("Either options.data or options.file must be set."); } @@ -117,13 +118,15 @@ Future _renderAsync(RenderOptions options) async { /// /// [render]: https://github.com/sass/node-sass#options RenderResult renderSync(RenderOptions options) { + if (!isNode) { + jsThrow(JsError("The renderSync() method is only available in Node.js.")); + } try { var start = DateTime.now(); CompileResult result; - var data = options.data; var file = options.file.andThen(p.absolute); - if (data != null) { + if (options.data case var data?) { result = compileString(data, nodeImporter: _parseImporter(options, start), functions: _parseFunctions(options, start).cast(), @@ -137,7 +140,7 @@ RenderResult renderSync(RenderOptions options) { verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: NodeToDartLogger( + logger: JSToDartLogger( options.logger, Logger.stderr(color: hasTerminal))); } else if (file != null) { result = compile(file, @@ -152,7 +155,7 @@ RenderResult renderSync(RenderOptions options) { verbose: options.verbose ?? false, charset: options.charset ?? true, sourceMap: _enableSourceMaps(options), - logger: NodeToDartLogger( + logger: JSToDartLogger( options.logger, Logger.stderr(color: hasTerminal))); } else { throw ArgumentError("Either options.data or options.file must be set."); @@ -170,15 +173,11 @@ RenderResult renderSync(RenderOptions options) { /// Converts an exception to a [JsError]. JsError _wrapException(Object exception, StackTrace stackTrace) { if (exception is SassException) { - String file; - var url = exception.span.sourceUrl; - if (url == null) { - file = 'stdin'; - } else if (url.scheme == 'file') { - file = p.fromUri(url); - } else { - file = url.toString(); - } + var file = switch (exception.span.sourceUrl) { + null => 'stdin', + Uri(scheme: 'file') && var url => p.fromUri(url), + var url => url.toString() + }; return _newRenderError(exception.toString().replaceFirst("Error: ", ""), getTrace(exception) ?? stackTrace, @@ -208,8 +207,7 @@ List _parseFunctions(RenderOptions options, DateTime start, var context = RenderContext(options: _contextOptions(options, start)); context.options.context = context; - var fiber = options.fiber; - if (fiber != null) { + if (options.fiber case var fiber?) { result.add(Callable.fromSignature(signature.trimLeft(), (arguments) { var currentFiber = fiber.current; var jsArguments = [ @@ -220,7 +218,8 @@ List _parseFunctions(RenderOptions options, DateTime start, scheduleMicrotask(() => currentFiber.run(result)); }) ]; - var result = (callback as JSFunction).apply(context, jsArguments); + var result = wrapJSExceptions( + () => (callback as JSFunction).apply(context, jsArguments)); return unwrapValue(isUndefined(result) // Run `fiber.yield()` in runZoned() so that Dart resets the current // zone once it's done. Otherwise, interweaving fibers can leave @@ -231,8 +230,9 @@ List _parseFunctions(RenderOptions options, DateTime start, } else if (!asynch) { result.add(Callable.fromSignature( signature.trimLeft(), - (arguments) => unwrapValue((callback as JSFunction) - .apply(context, arguments.map(wrapValue).toList())), + (arguments) => unwrapValue(wrapJSExceptions(() => + (callback as JSFunction) + .apply(context, arguments.map(wrapValue).toList()))), requireParens: false)); } else { result.add( @@ -242,7 +242,8 @@ List _parseFunctions(RenderOptions options, DateTime start, ...arguments.map(wrapValue), allowInterop(([Object? result]) => completer.complete(result)) ]; - var result = (callback as JSFunction).apply(context, jsArguments); + var result = wrapJSExceptions( + () => (callback as JSFunction).apply(context, jsArguments)); return unwrapValue( isUndefined(result) ? await completer.future : result); }, requireParens: false)); @@ -254,20 +255,16 @@ List _parseFunctions(RenderOptions options, DateTime start, /// Parses [importer] and [includePaths] from [RenderOptions] into a /// [NodeImporter]. NodeImporter _parseImporter(RenderOptions options, DateTime start) { - List importers; - if (options.importer == null) { - importers = []; - } else if (options.importer is List) { - importers = (options.importer as List).cast(); - } else { - importers = [options.importer as JSFunction]; - } + var importers = switch (options.importer) { + null => [], + List importers => importers.cast(), + var importer => [importer as JSFunction], + }; var contextOptions = importers.isNotEmpty ? _contextOptions(options, start) : Object(); - var fiber = options.fiber; - if (fiber != null) { + if (options.fiber case var fiber?) { importers = importers.map((importer) { return allowInteropCaptureThis( (Object thisArg, String url, String previous, [Object? _]) { @@ -312,31 +309,26 @@ RenderContextOptions _contextOptions(RenderOptions options, DateTime start) { } /// Parse [style] into an [OutputStyle]. -OutputStyle _parseOutputStyle(String? style) { - if (style == null || style == 'expanded') return OutputStyle.expanded; - if (style == 'compressed') return OutputStyle.compressed; - throw ArgumentError('Unsupported output style "$style".'); -} +OutputStyle _parseOutputStyle(String? style) => switch (style) { + null || 'expanded' => OutputStyle.expanded, + 'compressed' => OutputStyle.compressed, + _ => jsThrow(JsError('Unknown output style "$style".')) + }; /// Parses the indentation width into an [int]. -int? _parseIndentWidth(Object? width) { - if (width == null) return null; - return width is int ? width : int.parse(width.toString()); -} +int? _parseIndentWidth(Object? width) => switch (width) { + null => null, + int() => width, + _ => int.parse(width.toString()) + }; /// Parses the name of a line feed type into a [LineFeed]. -LineFeed _parseLineFeed(String? str) { - switch (str) { - case 'cr': - return LineFeed.cr; - case 'crlf': - return LineFeed.crlf; - case 'lfcr': - return LineFeed.lfcr; - default: - return LineFeed.lf; - } -} +LineFeed _parseLineFeed(String? str) => switch (str) { + 'cr' => LineFeed.cr, + 'crlf' => LineFeed.crlf, + 'lfcr' => LineFeed.lfcr, + _ => LineFeed.lf + }; /// Creates a [RenderResult] that exposes [result] in the Node Sass API format. RenderResult _newRenderResult( @@ -358,12 +350,10 @@ RenderResult _newRenderResult( sourceMap.sourceRoot = options.sourceMapRoot; var outFile = options.outFile; if (outFile == null) { - var file = options.file; - if (file == null) { - sourceMap.targetUrl = 'stdin.css'; - } else { - sourceMap.targetUrl = p.toUri(p.setExtension(file, '.css')).toString(); - } + sourceMap.targetUrl = switch (options.file) { + var file? => p.toUri(p.setExtension(file, '.css')).toString(), + _ => sourceMap.targetUrl = 'stdin.css' + }; } else { sourceMap.targetUrl = p.toUri(p.relative(outFile, from: sourceMapDir)).toString(); @@ -407,7 +397,7 @@ RenderResult _newRenderResult( duration: end.difference(start).inMilliseconds, includedFiles: [ for (var url in result.loadedUrls) - if (url.scheme == 'file') p.fromUri(url) else url.toString() + url.scheme == 'file' ? p.fromUri(url) : url.toString() ])); } diff --git a/lib/src/node/legacy/fiber.dart b/lib/src/js/legacy/fiber.dart similarity index 100% rename from lib/src/node/legacy/fiber.dart rename to lib/src/js/legacy/fiber.dart diff --git a/lib/src/node/legacy/importer_result.dart b/lib/src/js/legacy/importer_result.dart similarity index 100% rename from lib/src/node/legacy/importer_result.dart rename to lib/src/js/legacy/importer_result.dart diff --git a/lib/src/node/legacy/render_context.dart b/lib/src/js/legacy/render_context.dart similarity index 100% rename from lib/src/node/legacy/render_context.dart rename to lib/src/js/legacy/render_context.dart diff --git a/lib/src/node/legacy/render_options.dart b/lib/src/js/legacy/render_options.dart similarity index 96% rename from lib/src/node/legacy/render_options.dart rename to lib/src/js/legacy/render_options.dart index 2223ce359..3357166de 100644 --- a/lib/src/node/legacy/render_options.dart +++ b/lib/src/js/legacy/render_options.dart @@ -30,7 +30,7 @@ class RenderOptions { external bool? get quietDeps; external bool? get verbose; external bool? get charset; - external NodeLogger? get logger; + external JSLogger? get logger; external factory RenderOptions( {String? file, @@ -53,5 +53,5 @@ class RenderOptions { bool? quietDeps, bool? verbose, bool? charset, - NodeLogger? logger}); + JSLogger? logger}); } diff --git a/lib/src/node/legacy/render_result.dart b/lib/src/js/legacy/render_result.dart similarity index 100% rename from lib/src/node/legacy/render_result.dart rename to lib/src/js/legacy/render_result.dart diff --git a/lib/src/node/legacy/types.dart b/lib/src/js/legacy/types.dart similarity index 100% rename from lib/src/node/legacy/types.dart rename to lib/src/js/legacy/types.dart diff --git a/lib/src/node/legacy/value.dart b/lib/src/js/legacy/value.dart similarity index 75% rename from lib/src/node/legacy/value.dart rename to lib/src/js/legacy/value.dart index 4843e4e6f..0087aa1ae 100644 --- a/lib/src/node/legacy/value.dart +++ b/lib/src/js/legacy/value.dart @@ -4,7 +4,7 @@ import 'dart:js_util'; -import 'package:sass/src/node/utils.dart'; +import 'package:sass/src/js/utils.dart'; import '../../value.dart'; import 'value/color.dart'; @@ -39,11 +39,11 @@ Value unwrapValue(Object? object) { } /// Wraps a [Value] in a wrapper that exposes the Node Sass API for that value. -Object wrapValue(Value value) { - if (value is SassColor) return newNodeSassColor(value); - if (value is SassList) return newNodeSassList(value); - if (value is SassMap) return newNodeSassMap(value); - if (value is SassNumber) return newNodeSassNumber(value); - if (value is SassString) return newNodeSassString(value); - return value; -} +Object wrapValue(Value value) => switch (value) { + SassColor() => newNodeSassColor(value), + SassList() => newNodeSassList(value), + SassMap() => newNodeSassMap(value), + SassNumber() => newNodeSassNumber(value), + SassString() => newNodeSassString(value), + _ => value + }; diff --git a/lib/src/node/legacy/value/boolean.dart b/lib/src/js/legacy/value/boolean.dart similarity index 100% rename from lib/src/node/legacy/value/boolean.dart rename to lib/src/js/legacy/value/boolean.dart diff --git a/lib/src/node/legacy/value/color.dart b/lib/src/js/legacy/value/color.dart similarity index 100% rename from lib/src/node/legacy/value/color.dart rename to lib/src/js/legacy/value/color.dart diff --git a/lib/src/node/legacy/value/list.dart b/lib/src/js/legacy/value/list.dart similarity index 100% rename from lib/src/node/legacy/value/list.dart rename to lib/src/js/legacy/value/list.dart diff --git a/lib/src/node/legacy/value/map.dart b/lib/src/js/legacy/value/map.dart similarity index 90% rename from lib/src/node/legacy/value/map.dart rename to lib/src/js/legacy/value/map.dart index 2830ce47c..44b59c618 100644 --- a/lib/src/node/legacy/value/map.dart +++ b/lib/src/js/legacy/value/map.dart @@ -4,6 +4,7 @@ import 'package:js/js.dart'; +import '../../../util/map.dart'; import '../../../value.dart'; import '../../reflection.dart'; import '../value.dart'; @@ -39,14 +40,14 @@ final JSClass legacyMapClass = createJSClass('sass.types.Map', var newKey = unwrapValue(key); var newMap = {}; var i = 0; - for (var oldEntry in thisArg.dartValue.contents.entries) { + for (var (oldKey, oldValue) in thisArg.dartValue.contents.pairs) { if (i == index) { - newMap[newKey] = oldEntry.value; + newMap[newKey] = oldValue; } else { - if (newKey == oldEntry.key) { + if (newKey == oldKey) { throw ArgumentError.value(key, 'key', "is already in the map"); } - newMap[oldEntry.key] = oldEntry.value; + newMap[oldKey] = oldValue; } i++; } diff --git a/lib/src/node/legacy/value/null.dart b/lib/src/js/legacy/value/null.dart similarity index 100% rename from lib/src/node/legacy/value/null.dart rename to lib/src/js/legacy/value/null.dart diff --git a/lib/src/node/legacy/value/number.dart b/lib/src/js/legacy/value/number.dart similarity index 100% rename from lib/src/node/legacy/value/number.dart rename to lib/src/js/legacy/value/number.dart diff --git a/lib/src/node/legacy/value/string.dart b/lib/src/js/legacy/value/string.dart similarity index 100% rename from lib/src/node/legacy/value/string.dart rename to lib/src/js/legacy/value/string.dart diff --git a/lib/src/node/logger.dart b/lib/src/js/logger.dart similarity index 94% rename from lib/src/node/logger.dart rename to lib/src/js/logger.dart index a312e535f..fb28030d5 100644 --- a/lib/src/node/logger.dart +++ b/lib/src/js/logger.dart @@ -7,11 +7,11 @@ import 'package:source_span/source_span.dart'; @JS() @anonymous -class NodeLogger { +class JSLogger { external void Function(String message, WarnOptions options)? get warn; external void Function(String message, DebugOptions options)? get debug; - external factory NodeLogger( + external factory JSLogger( {void Function(String message, WarnOptions options)? warn, void Function(String message, DebugOptions options)? debug}); } diff --git a/lib/src/node/reflection.dart b/lib/src/js/reflection.dart similarity index 88% rename from lib/src/node/reflection.dart rename to lib/src/js/reflection.dart index cdf8c1467..5c4efa33e 100644 --- a/lib/src/node/reflection.dart +++ b/lib/src/js/reflection.dart @@ -10,6 +10,9 @@ import 'package:js/js_util.dart'; import 'utils.dart'; import 'utils.dart' as utils; +@JS("util") +external Object? get _util; + @JS("util.inspect.custom") external Symbol get _inspectSymbol; @@ -68,10 +71,21 @@ extension JSClassExtension on JSClass { /// Sets the custom inspect logic for this class to [body]. void setCustomInspect(String inspect(Object self)) { + if (_util == null) return; setProperty(prototype, _inspectSymbol, allowInteropCaptureThis((Object self, _, __, [___]) => inspect(self))); } + /// Defines a static method with the given [name] and [body]. + void defineStaticMethod(String name, Function body) { + setProperty(this, name, allowInteropNamed(name, body)); + } + + /// A shorthand for calling [defineStaticMethod] multiple times. + void defineStaticMethods(Map methods) { + methods.forEach(defineStaticMethod); + } + /// Defines a method with the given [name] and [body]. /// /// The [body] should take an initial `self` parameter, representing the diff --git a/lib/src/node/source_span.dart b/lib/src/js/source_span.dart similarity index 100% rename from lib/src/node/source_span.dart rename to lib/src/js/source_span.dart diff --git a/lib/src/node/url.dart b/lib/src/js/url.dart similarity index 97% rename from lib/src/node/url.dart rename to lib/src/js/url.dart index d9cee8bc7..3fa672a15 100644 --- a/lib/src/node/url.dart +++ b/lib/src/js/url.dart @@ -8,6 +8,7 @@ import 'package:js/js.dart'; /// /// See https://developer.mozilla.org/en-US/docs/Web/API/URL. @JS('URL') +@anonymous class JSUrl { external JSUrl(String url, [String base]); } diff --git a/lib/src/node/utils.dart b/lib/src/js/utils.dart similarity index 92% rename from lib/src/node/utils.dart rename to lib/src/js/utils.dart index 168993098..687484c9a 100644 --- a/lib/src/node/utils.dart +++ b/lib/src/js/utils.dart @@ -218,25 +218,18 @@ Map objectToMap(Object object) { } /// Converts a JavaScript separator string into a [ListSeparator]. -ListSeparator jsToDartSeparator(String? separator) { - switch (separator) { - case ' ': - return ListSeparator.space; - case ',': - return ListSeparator.comma; - case '/': - return ListSeparator.slash; - case null: - return ListSeparator.undecided; - default: - jsThrow(JsError('Unknown separator "$separator".')); - } -} +ListSeparator jsToDartSeparator(String? separator) => switch (separator) { + ' ' => ListSeparator.space, + ',' => ListSeparator.comma, + '/' => ListSeparator.slash, + null => ListSeparator.undecided, + _ => jsThrow(JsError('Unknown separator "$separator".')) + }; /// Converts a syntax string to an instance of [Syntax]. -Syntax parseSyntax(String? syntax) { - if (syntax == null || syntax == 'scss') return Syntax.scss; - if (syntax == 'indented') return Syntax.sass; - if (syntax == 'css') return Syntax.css; - jsThrow(JsError('Unknown syntax "$syntax".')); -} +Syntax parseSyntax(String? syntax) => switch (syntax) { + null || 'scss' => Syntax.scss, + 'indented' => Syntax.sass, + 'css' => Syntax.css, + _ => jsThrow(JsError('Unknown syntax "$syntax".')) + }; diff --git a/lib/src/node/value.dart b/lib/src/js/value.dart similarity index 94% rename from lib/src/node/value.dart rename to lib/src/js/value.dart index 5fcc26b12..f8697efea 100644 --- a/lib/src/node/value.dart +++ b/lib/src/js/value.dart @@ -10,6 +10,7 @@ import 'reflection.dart'; export 'value/argument_list.dart'; export 'value/boolean.dart'; +export 'value/calculation.dart'; export 'value/color.dart'; export 'value/function.dart'; export 'value/list.dart'; @@ -36,6 +37,8 @@ final JSClass valueClass = () { 'get': (Value self, num index) => index < 1 && index >= -1 ? self : undefined, 'assertBoolean': (Value self, [String? name]) => self.assertBoolean(name), + 'assertCalculation': (Value self, [String? name]) => + self.assertCalculation(name), 'assertColor': (Value self, [String? name]) => self.assertColor(name), 'assertFunction': (Value self, [String? name]) => self.assertFunction(name), 'assertMap': (Value self, [String? name]) => self.assertMap(name), diff --git a/lib/src/node/value/argument_list.dart b/lib/src/js/value/argument_list.dart similarity index 100% rename from lib/src/node/value/argument_list.dart rename to lib/src/js/value/argument_list.dart diff --git a/lib/src/node/value/boolean.dart b/lib/src/js/value/boolean.dart similarity index 100% rename from lib/src/node/value/boolean.dart rename to lib/src/js/value/boolean.dart diff --git a/lib/src/js/value/calculation.dart b/lib/src/js/value/calculation.dart new file mode 100644 index 000000000..6154de77b --- /dev/null +++ b/lib/src/js/value/calculation.dart @@ -0,0 +1,133 @@ +// 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:collection/collection.dart'; +import 'package:node_interop/js.dart'; +import 'package:sass/src/js/immutable.dart'; +import 'package:sass/src/js/utils.dart'; + +import '../../value.dart'; +import '../reflection.dart'; + +/// Check that [arg] is a valid argument to a calculation function. +void _assertCalculationValue(Object arg) => switch (arg) { + SassNumber() || + SassString(hasQuotes: false) || + SassCalculation() || + CalculationOperation() || + CalculationInterpolation() => + null, + _ => jsThrow(JsError( + 'Argument `$arg` must be one of SassNumber, unquoted SassString, ' + 'SassCalculation, CalculationOperation, CalculationInterpolation')), + }; + +/// Check that [arg] is an unquoted string or interpolation. +bool _isValidClampArg(Object? arg) => switch (arg) { + CalculationInterpolation() || SassString(hasQuotes: false) => true, + _ => false, + }; + +/// The JavaScript `SassCalculation` class. +final JSClass calculationClass = () { + var jsClass = + createJSClass('sass.SassCalculation', (Object self, [Object? _]) { + jsThrow(JsError("new sass.SassCalculation() isn't allowed")); + }); + + jsClass.defineStaticMethods({ + 'calc': (Object argument) { + _assertCalculationValue(argument); + return SassCalculation.unsimplified('calc', [argument]); + }, + 'min': (Object arguments) { + var argList = jsToDartList(arguments).cast(); + argList.forEach(_assertCalculationValue); + return SassCalculation.unsimplified('min', argList); + }, + 'max': (Object arguments) { + var argList = jsToDartList(arguments).cast(); + argList.forEach(_assertCalculationValue); + return SassCalculation.unsimplified('max', argList); + }, + 'clamp': (Object min, [Object? value, Object? max]) { + 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()}`')); + } + [min, value, max].whereNotNull().forEach(_assertCalculationValue); + return SassCalculation.unsimplified( + 'clamp', [min, value, max].whereNotNull()); + } + }); + + jsClass.defineMethods({ + 'assertCalculation': (SassCalculation self, [String? name]) => self, + }); + + jsClass.defineGetters({ + // The `name` getter is included by default by `createJSClass` + 'arguments': (SassCalculation self) => ImmutableList(self.arguments), + }); + + getJSClass(SassCalculation.unsimplified('calc', [SassNumber(1)])) + .injectSuperclass(jsClass); + return jsClass; +}(); + +/// The JavaScript `CalculationOperation` class. +final JSClass calculationOperationClass = () { + var jsClass = createJSClass('sass.CalculationOperation', + (Object self, String strOperator, Object left, Object right) { + var operator = CalculationOperator.values + .firstWhereOrNull((value) => value.operator == strOperator); + if (operator == null) { + jsThrow(JsError('Invalid operator: $strOperator')); + } + _assertCalculationValue(left); + _assertCalculationValue(right); + return SassCalculation.operateInternal(operator, left, right, + inMinMax: false, simplify: false); + }); + + jsClass.defineMethods({ + 'equals': (CalculationOperation self, Object other) => self == other, + 'hashCode': (CalculationOperation self) => self.hashCode, + }); + + jsClass.defineGetters({ + 'operator': (CalculationOperation self) => self.operator.operator, + 'left': (CalculationOperation self) => self.left, + 'right': (CalculationOperation self) => self.right, + }); + + getJSClass(SassCalculation.operateInternal( + CalculationOperator.plus, SassNumber(1), SassNumber(1), + inMinMax: false, simplify: false)) + .injectSuperclass(jsClass); + return jsClass; +}(); + +/// The JavaScript `CalculationInterpolation` class. +final JSClass calculationInterpolationClass = () { + var jsClass = createJSClass('sass.CalculationInterpolation', + (Object self, String value) => CalculationInterpolation(value)); + + jsClass.defineMethods({ + 'equals': (CalculationInterpolation self, Object other) => self == other, + 'hashCode': (CalculationInterpolation self) => self.hashCode, + }); + + jsClass.defineGetters({ + 'value': (CalculationInterpolation self) => self.value, + }); + + getJSClass(CalculationInterpolation('')).injectSuperclass(jsClass); + return jsClass; +}(); diff --git a/lib/src/node/value/color.dart b/lib/src/js/value/color.dart similarity index 77% rename from lib/src/node/value/color.dart rename to lib/src/js/value/color.dart index c326ee36c..9f2871fbf 100644 --- a/lib/src/node/value/color.dart +++ b/lib/src/js/value/color.dart @@ -6,6 +6,7 @@ import 'package:js/js.dart'; import '../../value.dart'; import '../reflection.dart'; +import '../utils.dart'; /// The JavaScript `SassColor` class. final JSClass colorClass = () { @@ -13,11 +14,11 @@ final JSClass colorClass = () { if (color.red != null) { return SassColor.rgb(color.red!, color.green!, color.blue!, color.alpha); } else if (color.saturation != null) { - return SassColor.hsl( - color.hue!, color.saturation!, color.lightness!, color.alpha); + return SassColor.hsl(color.hue!, color.saturation!, color.lightness!, + _handleNullAlpha(color.alpha)); } else { - return SassColor.hwb( - color.hue!, color.whiteness!, color.blackness!, color.alpha); + return SassColor.hwb(color.hue!, color.whiteness!, color.blackness!, + _handleNullAlpha(color.alpha)); } }); @@ -39,15 +40,11 @@ final JSClass colorClass = () { } else if (options.red != null || options.green != null || options.blue != null) { - var red = options.red; - var green = options.green; - var blue = options.blue; - var alpha = options.alpha; return self.changeChannels({ - if (red != null) "red": red, - if (green != null) "green": green, - if (blue != null) "blue": blue, - if (alpha != null) "alpha": alpha + if (options.red case var red?) "red": red, + if (options.green case var green?) "green": green, + if (options.blue case var blue?) "blue": blue, + if (options.alpha case var alpha?) "alpha": alpha }); } else { return self.changeAlpha(options.alpha ?? self.alpha); @@ -70,6 +67,12 @@ final JSClass colorClass = () { return jsClass; }(); +/// 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; + @JS() @anonymous class _Channels { diff --git a/lib/src/node/value/function.dart b/lib/src/js/value/function.dart similarity index 100% rename from lib/src/node/value/function.dart rename to lib/src/js/value/function.dart diff --git a/lib/src/node/value/list.dart b/lib/src/js/value/list.dart similarity index 100% rename from lib/src/node/value/list.dart rename to lib/src/js/value/list.dart diff --git a/lib/src/node/value/map.dart b/lib/src/js/value/map.dart similarity index 88% rename from lib/src/node/value/map.dart rename to lib/src/js/value/map.dart index d1235846f..3e13373d6 100644 --- a/lib/src/node/value/map.dart +++ b/lib/src/js/value/map.dart @@ -4,6 +4,7 @@ import 'package:node_interop/js.dart'; +import '../../util/map.dart'; import '../../value.dart'; import '../immutable.dart'; import '../reflection.dart'; @@ -25,8 +26,8 @@ final JSClass mapClass = () { if (index < 0) index = self.lengthAsList + index; if (index < 0 || index >= self.lengthAsList) return undefined; - var entry = self.contents.entries.elementAt(index); - return SassList([entry.key, entry.value], ListSeparator.space); + var (key, value) = self.contents.pairs.elementAt(index); + return SassList([key, value], ListSeparator.space); } else { return self.contents[indexOrKey] ?? undefined; } diff --git a/lib/src/node/value/number.dart b/lib/src/js/value/number.dart similarity index 100% rename from lib/src/node/value/number.dart rename to lib/src/js/value/number.dart diff --git a/lib/src/node/value/string.dart b/lib/src/js/value/string.dart similarity index 100% rename from lib/src/node/value/string.dart rename to lib/src/js/value/string.dart diff --git a/lib/src/logger.dart b/lib/src/logger.dart index d395d8e90..a329b3b79 100644 --- a/lib/src/logger.dart +++ b/lib/src/logger.dart @@ -2,9 +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 'package:stack_trace/stack_trace.dart'; +import 'deprecation.dart'; +import 'logger/deprecation_handling.dart'; import 'logger/stderr.dart'; /// An interface for loggers that print messages produced by Sass stylesheets. @@ -34,8 +37,23 @@ abstract class Logger { void debug(String message, SourceSpan span); } +/// An extension to add a `warnForDeprecation` method to loggers without +/// making a breaking API change. +@internal +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) { + warn(message, span: span, trace: trace, deprecation: true); + } + } +} + /// A logger that emits no messages. -class _QuietLogger implements Logger { +final class _QuietLogger implements Logger { void warn(String message, {FileSpan? span, Trace? trace, bool deprecation = false}) {} void debug(String message, SourceSpan span) {} diff --git a/lib/src/logger/deprecation_handling.dart b/lib/src/logger/deprecation_handling.dart new file mode 100644 index 000000000..4b185651b --- /dev/null +++ b/lib/src/logger/deprecation_handling.dart @@ -0,0 +1,101 @@ +// Copyright 2018 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 'package:source_span/source_span.dart'; +import 'package:stack_trace/stack_trace.dart'; + +import '../deprecation.dart'; +import '../exception.dart'; +import '../logger.dart'; + +/// The maximum number of repetitions of the same warning +/// [DeprecationHandlingLogger] 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 { + /// A map of how many times each deprecation has been emitted by this logger. + final _warningCounts = {}; + + final Logger _inner; + + /// Deprecation warnings of one of these types will cause an error to be + /// thrown. + /// + /// Future deprecations in this list will still cause an error even if they + /// are not also in [futureDeprecations]. + final Set fatalDeprecations; + + /// Future deprecations that the user has explicitly opted into. + final Set futureDeprecations; + + /// Whether repetitions of the same warning should be limited to no more than + /// [_maxRepetitions]. + final bool limitRepetition; + + DeprecationHandlingLogger(this._inner, + {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); + } + + /// 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 + /// 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, + {FileSpan? span, Trace? trace}) { + if (fatalDeprecations.contains(deprecation)) { + message += "\n\nThis is only an error because you've set the " + '$deprecation deprecation to be fatal.\n' + 'Remove this setting if you need to keep using this feature.'; + throw switch ((span, trace)) { + (var span?, var trace?) => SassRuntimeException(message, span, trace), + (var span?, null) => SassException(message, span), + _ => SassScriptException(message) + }; + } + + if (deprecation.isFuture && !futureDeprecations.contains(deprecation)) { + return; + } + + if (limitRepetition) { + var count = + _warningCounts[deprecation] = (_warningCounts[deprecation] ?? 0) + 1; + if (count > _maxRepetitions) return; + } + + warn(message, span: span, trace: trace, deprecation: true); + } + + void debug(String message, SourceSpan span) => _inner.debug(message, span); + + /// Prints a warning indicating the number of deprecation warnings that were + /// omitted due to repetition. + /// + /// The [js] flag indicates whether this is running in JS mode, in which case + /// it doesn't mention "verbose mode" because the JS API doesn't support that. + void summarize({required bool js}) { + var total = _warningCounts.values + .where((count) => count > _maxRepetitions) + .map((count) => count - _maxRepetitions) + .sum; + if (total > 0) { + _inner.warn("$total repetitive deprecation warnings omitted." + + (js ? "" : "\nRun in verbose mode to see all warnings.")); + } + } +} diff --git a/lib/src/logger/node_to_dart.dart b/lib/src/logger/js_to_dart.dart similarity index 78% rename from lib/src/logger/node_to_dart.dart rename to lib/src/logger/js_to_dart.dart index bb16798fc..aa58a243d 100644 --- a/lib/src/logger/node_to_dart.dart +++ b/lib/src/logger/js_to_dart.dart @@ -8,14 +8,14 @@ import 'package:stack_trace/stack_trace.dart'; import 'package:term_glyph/term_glyph.dart' as glyph; import '../logger.dart'; -import '../node/logger.dart'; +import '../js/logger.dart'; -/// A wrapper around a [NodeLogger] that exposes it as a Dart [Logger]. -class NodeToDartLogger implements Logger { +/// A wrapper around a [JSLogger] that exposes it as a Dart [Logger]. +final class JSToDartLogger implements Logger { /// The wrapped logger object. - final NodeLogger? _node; + final JSLogger? _node; - /// The fallback logger to use if the [NodeLogger] doesn't define a method. + /// The fallback logger to use if the [JSLogger] doesn't define a method. final Logger _fallback; /// Whether to use only ASCII characters when highlighting sections of source @@ -24,33 +24,31 @@ class NodeToDartLogger implements Logger { /// This defaults to [glyph.ascii]. final bool _ascii; - NodeToDartLogger(this._node, this._fallback, {bool? ascii}) + JSToDartLogger(this._node, this._fallback, {bool? ascii}) : _ascii = ascii ?? glyph.ascii; void warn(String message, {FileSpan? span, Trace? trace, bool deprecation = false}) { - var warn = _node?.warn; - if (warn == null) { - _withAscii(() { - _fallback.warn(message, - span: span, trace: trace, deprecation: deprecation); - }); - } else { + if (_node?.warn case var warn?) { warn( message, WarnOptions( span: span ?? (undefined as SourceSpan?), stack: trace.toString(), deprecation: deprecation)); + } else { + _withAscii(() { + _fallback.warn(message, + span: span, trace: trace, deprecation: deprecation); + }); } } void debug(String message, SourceSpan span) { - var debug = _node?.debug; - if (debug == null) { - _withAscii(() => _fallback.debug(message, span)); - } else { + if (_node?.debug case var debug?) { debug(message, DebugOptions(span: span)); + } else { + _withAscii(() => _fallback.debug(message, span)); } } diff --git a/lib/src/logger/stderr.dart b/lib/src/logger/stderr.dart index d9d1f90d6..fc001008f 100644 --- a/lib/src/logger/stderr.dart +++ b/lib/src/logger/stderr.dart @@ -10,8 +10,8 @@ import '../io.dart'; import '../logger.dart'; import '../utils.dart'; -/// A logger that prints warnings to standard error. -class StderrLogger implements Logger { +/// A logger that prints warnings to standard error or browser console. +final class StderrLogger implements Logger { /// Whether to use terminal colors in messages. final bool color; @@ -19,35 +19,39 @@ class StderrLogger implements Logger { void warn(String message, {FileSpan? span, Trace? trace, bool deprecation = false}) { + var result = StringBuffer(); if (color) { // Bold yellow. - stderr.write('\u001b[33m\u001b[1m'); - if (deprecation) stderr.write('Deprecation '); - stderr.write('Warning\u001b[0m'); + result.write('\u001b[33m\u001b[1m'); + if (deprecation) result.write('Deprecation '); + result.write('Warning\u001b[0m'); } else { - if (deprecation) stderr.write('DEPRECATION '); - stderr.write('WARNING'); + if (deprecation) result.write('DEPRECATION '); + result.write('WARNING'); } if (span == null) { - stderr.writeln(': $message'); + result.writeln(': $message'); } else if (trace != null) { // If there's a span and a trace, the span's location information is // probably duplicated in the trace, so we just use it for highlighting. - stderr.writeln(': $message\n\n${span.highlight(color: color)}'); + result.writeln(': $message\n\n${span.highlight(color: color)}'); } else { - stderr.writeln(' on ${span.message("\n" + message, color: color)}'); + result.writeln(' on ${span.message("\n" + message, color: color)}'); } - if (trace != null) stderr.writeln(indent(trace.toString().trimRight(), 4)); - stderr.writeln(); + if (trace != null) result.writeln(indent(trace.toString().trimRight(), 4)); + + printError(result); } void debug(String message, SourceSpan span) { + var result = StringBuffer(); var url = span.start.sourceUrl == null ? '-' : p.prettyUri(span.start.sourceUrl); - stderr.write('$url:${span.start.line + 1} '); - stderr.write(color ? '\u001b[1mDebug\u001b[0m' : 'DEBUG'); - stderr.writeln(': $message'); + result.write('$url:${span.start.line + 1} '); + result.write(color ? '\u001b[1mDebug\u001b[0m' : 'DEBUG'); + result.write(': $message'); + printError(result.toString()); } } diff --git a/lib/src/logger/terse.dart b/lib/src/logger/terse.dart deleted file mode 100644 index 83258da15..000000000 --- a/lib/src/logger/terse.dart +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2018 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 'package:source_span/source_span.dart'; -import 'package:stack_trace/stack_trace.dart'; - -import '../logger.dart'; - -/// The maximum number of repetitions of the same warning [TerseLogger] will -/// emit before hiding the rest. -const _maxRepetitions = 5; - -/// A logger that wraps an inner logger to omit repeated deprecation warnings. -/// -/// A warning is considered "repeated" if the first paragraph is the same as -/// another warning that's already been emitted. -class TerseLogger implements Logger { - /// A map from the first paragraph of a warning to the number of times this - /// logger has emitted a warning with that line. - final _warningCounts = {}; - - final Logger _inner; - - TerseLogger(this._inner); - - void warn(String message, - {FileSpan? span, Trace? trace, bool deprecation = false}) { - if (deprecation) { - var firstParagraph = message.split("\n\n").first; - var count = _warningCounts[firstParagraph] = - (_warningCounts[firstParagraph] ?? 0) + 1; - if (count > _maxRepetitions) return; - } - - _inner.warn(message, span: span, trace: trace, deprecation: deprecation); - } - - void debug(String message, SourceSpan span) => _inner.debug(message, span); - - /// Prints a warning indicating the number of deprecation warnings that were - /// omitted. - /// - /// The [node] flag indicates whether this is running in Node.js mode, in - /// which case it doesn't mention "verbose mode" because the Node API doesn't - /// support that. - void summarize({required bool node}) { - var total = _warningCounts.values - .where((count) => count > _maxRepetitions) - .map((count) => count - _maxRepetitions) - .sum; - if (total > 0) { - _inner.warn("$total repetitive deprecation warnings omitted." + - (node ? "" : "\nRun in verbose mode to see all warnings.")); - } - } -} diff --git a/lib/src/logger/tracking.dart b/lib/src/logger/tracking.dart index 75522c7d3..efa463787 100644 --- a/lib/src/logger/tracking.dart +++ b/lib/src/logger/tracking.dart @@ -8,7 +8,7 @@ import 'package:stack_trace/stack_trace.dart'; import '../logger.dart'; /// An logger that wraps another logger and keeps track of when it is used. -class TrackingLogger implements Logger { +final class TrackingLogger implements Logger { final Logger _logger; /// Whether [warn] has been called on this logger. diff --git a/lib/src/module.dart b/lib/src/module.dart index 8d570ade6..b545b2751 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -11,7 +11,7 @@ import 'extend/extension_store.dart'; import 'value.dart'; /// The interface for a Sass module. -abstract class Module { +abstract interface class Module { /// The canonical URL for this module's source file. /// /// This may be `null` if the module was loaded from a string without a URL @@ -53,6 +53,10 @@ abstract class Module { /// The module's CSS tree. CssStylesheet get css; + /// A map from modules in [upstream] to loud comments written in this module + /// that should be emitted before the given module. + Map, List> get preModuleComments; + /// Whether this module *or* any modules in [upstream] contain any CSS. bool get transitivelyContainsCss; diff --git a/lib/src/module/built_in.dart b/lib/src/module/built_in.dart index 1c5a56ed1..e19d9bdb0 100644 --- a/lib/src/module/built_in.dart +++ b/lib/src/module/built_in.dart @@ -13,7 +13,7 @@ import '../module.dart'; import '../value.dart'; /// A module provided by Sass, available under the special `sass:` URL space. -class BuiltInModule implements Module { +final class BuiltInModule implements Module { final Uri url; final Map functions; final Map mixins; @@ -23,6 +23,7 @@ class BuiltInModule implements Module { Map get variableNodes => const {}; ExtensionStore get extensionStore => ExtensionStore.empty; CssStylesheet get css => CssStylesheet.empty(url: url); + Map, List> get preModuleComments => const {}; bool get transitivelyContainsCss => false; bool get transitivelyContainsExtensions => false; diff --git a/lib/src/module/forwarded_view.dart b/lib/src/module/forwarded_view.dart index 4540702a9..c6cb42647 100644 --- a/lib/src/module/forwarded_view.dart +++ b/lib/src/module/forwarded_view.dart @@ -25,6 +25,8 @@ class ForwardedModuleView implements Module { List> get upstream => _inner.upstream; ExtensionStore get extensionStore => _inner.extensionStore; CssStylesheet get css => _inner.css; + Map, List> get preModuleComments => + _inner.preModuleComments; bool get transitivelyContainsCss => _inner.transitivelyContainsCss; bool get transitivelyContainsExtensions => _inner.transitivelyContainsExtensions; @@ -86,16 +88,15 @@ class ForwardedModuleView implements Module { } void setVariable(String name, Value value, AstNode nodeWithSpan) { - var shownVariables = _rule.shownVariables; - var hiddenVariables = _rule.hiddenVariables; - if (shownVariables != null && !shownVariables.contains(name)) { + if (_rule.shownVariables case var shownVariables? + when !shownVariables.contains(name)) { throw SassScriptException("Undefined variable."); - } else if (hiddenVariables != null && hiddenVariables.contains(name)) { + } else if (_rule.hiddenVariables case var hiddenVariables? + when hiddenVariables.contains(name)) { throw SassScriptException("Undefined variable."); } - var prefix = _rule.prefix; - if (prefix != null) { + if (_rule.prefix case var prefix?) { if (!name.startsWith(prefix)) { throw SassScriptException("Undefined variable."); } @@ -109,8 +110,7 @@ class ForwardedModuleView implements Module { Object variableIdentity(String name) { assert(variables.containsKey(name)); - var prefix = _rule.prefix; - if (prefix != null) { + if (_rule.prefix case var prefix?) { assert(name.startsWith(prefix)); name = name.substring(prefix.length); } diff --git a/lib/src/module/shadowed_view.dart b/lib/src/module/shadowed_view.dart index cee4ef0d7..b355bc2b8 100644 --- a/lib/src/module/shadowed_view.dart +++ b/lib/src/module/shadowed_view.dart @@ -14,7 +14,7 @@ import '../value.dart'; /// A [Module] that only exposes members that aren't shadowed by a given /// blocklist of member names. -class ShadowedModuleView implements Module { +final class ShadowedModuleView implements Module { /// The wrapped module. final Module _inner; @@ -22,6 +22,8 @@ class ShadowedModuleView implements Module { List> get upstream => _inner.upstream; ExtensionStore get extensionStore => _inner.extensionStore; CssStylesheet get css => _inner.css; + Map, List> get preModuleComments => + _inner.preModuleComments; bool get transitivelyContainsCss => _inner.transitivelyContainsCss; bool get transitivelyContainsExtensions => _inner.transitivelyContainsExtensions; @@ -79,7 +81,7 @@ class ShadowedModuleView implements Module { if (!variables.containsKey(name)) { throw SassScriptException("Undefined variable."); } else { - return _inner.setVariable(name, value, nodeWithSpan); + _inner.setVariable(name, value, nodeWithSpan); } } diff --git a/lib/src/parse/at_root_query.dart b/lib/src/parse/at_root_query.dart index f46207675..11eee11f2 100644 --- a/lib/src/parse/at_root_query.dart +++ b/lib/src/parse/at_root_query.dart @@ -5,13 +5,16 @@ 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}) - : super(contents, url: url, logger: logger); + AtRootQueryParser(String contents, + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) + : super(contents, + url: url, logger: logger, interpolationMap: interpolationMap); AtRootQuery parse() { return wrapSpanFormatException(() { diff --git a/lib/src/parse/css.dart b/lib/src/parse/css.dart index bc56d5b5d..acbb51fe7 100644 --- a/lib/src/parse/css.dart +++ b/lib/src/parse/css.dart @@ -46,36 +46,34 @@ class CssParser extends ScssParser { var name = interpolatedIdentifier(); whitespace(); - switch (name.asPlain) { - case "at-root": - case "content": - case "debug": - case "each": - case "error": - case "extend": - case "for": - case "function": - case "if": - case "include": - case "mixin": - case "return": - case "warn": - case "while": - almostAnyValue(); - error("This at-rule isn't allowed in plain CSS.", - scanner.spanFrom(start)); - - case "import": - return _cssImportRule(start); - case "media": - return mediaRule(start); - case "-moz-document": - return mozDocumentRule(start, name); - case "supports": - return supportsRule(start); - default: - return unknownAtRule(start, name); - } + return switch (name.asPlain) { + "at-root" || + "content" || + "debug" || + "each" || + "error" || + "extend" || + "for" || + "function" || + "if" || + "include" || + "mixin" || + "return" || + "warn" || + "while" => + _forbiddenAtRoot(start), + "import" => _cssImportRule(start), + "media" => mediaRule(start), + "-moz-document" => mozDocumentRule(start, name), + "supports" => supportsRule(start), + _ => unknownAtRule(start, name) + }; + } + + /// Throws an error for a forbidden at-rule. + Never _forbiddenAtRoot(LineScannerState start) { + almostAnyValue(); + error("This at-rule isn't allowed in plain CSS.", scanner.spanFrom(start)); } /// Consumes a plain-CSS `@import` rule that disallows interpolation. @@ -83,14 +81,10 @@ class CssParser extends ScssParser { /// [start] should point before the `@`. ImportRule _cssImportRule(LineScannerState start) { var urlStart = scanner.state; - var next = scanner.peekChar(); - Expression url; - if (next == $u || next == $U) { - url = dynamicUrl(); - } else { - url = - StringExpression(interpolatedString().asInterpolation(static: true)); - } + var url = switch (scanner.peekChar()) { + $u || $U => dynamicUrl(), + _ => StringExpression(interpolatedString().asInterpolation(static: true)) + }; var urlSpan = scanner.spanFrom(urlStart); whitespace(); @@ -108,8 +102,9 @@ class CssParser extends ScssParser { var plain = identifier.asPlain!; // CSS doesn't allow non-plain identifiers var lower = plain.toLowerCase(); - var specialFunction = trySpecialFunction(lower, start); - if (specialFunction != null) return specialFunction; + if (trySpecialFunction(lower, start) case var specialFunction?) { + return specialFunction; + } var beforeArguments = scanner.state; if (!scanner.scanChar($lparen)) return StringExpression(identifier); diff --git a/lib/src/parse/keyframe_selector.dart b/lib/src/parse/keyframe_selector.dart index a301cb486..71908c3e3 100644 --- a/lib/src/parse/keyframe_selector.dart +++ b/lib/src/parse/keyframe_selector.dart @@ -4,14 +4,17 @@ 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}) - : super(contents, url: url, logger: logger); + KeyframeSelectorParser(String contents, + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) + : super(contents, + url: url, logger: logger, interpolationMap: interpolationMap); List parse() { return wrapSpanFormatException(() { @@ -41,33 +44,32 @@ class KeyframeSelectorParser extends Parser { if (scanner.scanChar($plus)) buffer.writeCharCode($plus); var second = scanner.peekChar(); - if (!isDigit(second) && second != $dot) { + if (!second.isDigit && second != $dot) { scanner.error("Expected number."); } - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { buffer.writeCharCode(scanner.readChar()); } if (scanner.peekChar() == $dot) { buffer.writeCharCode(scanner.readChar()); - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { buffer.writeCharCode(scanner.readChar()); } } if (scanIdentChar($e)) { buffer.writeCharCode($e); - var next = scanner.peekChar(); - if (next == $plus || next == $minus) { + if (scanner.peekChar() case $plus || $minus) { buffer.writeCharCode(scanner.readChar()); } - if (!isDigit(scanner.peekChar())) scanner.error("Expected digit."); + if (!scanner.peekChar().isDigit) scanner.error("Expected digit."); - while (isDigit(scanner.peekChar())) { + do { buffer.writeCharCode(scanner.readChar()); - } + } while (scanner.peekChar().isDigit); } scanner.expectChar($percent); diff --git a/lib/src/parse/media_query.dart b/lib/src/parse/media_query.dart index d38a472f5..be86a1994 100644 --- a/lib/src/parse/media_query.dart +++ b/lib/src/parse/media_query.dart @@ -5,14 +5,17 @@ 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}) - : super(contents, url: url, logger: logger); + MediaQueryParser(String contents, + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) + : super(contents, + url: url, logger: logger, interpolationMap: interpolationMap); List parse() { return wrapSpanFormatException(() { diff --git a/lib/src/parse/parser.dart b/lib/src/parse/parser.dart index 58c1e73e1..dba0bef99 100644 --- a/lib/src/parse/parser.dart +++ b/lib/src/parse/parser.dart @@ -8,8 +8,11 @@ import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; import '../exception.dart'; +import '../interpolation_map.dart'; import '../logger.dart'; import '../util/character.dart'; +import '../util/lazy_file_span.dart'; +import '../util/map.dart'; import '../utils.dart'; /// The abstract base class for all parsers. @@ -25,6 +28,11 @@ class Parser { @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. + final InterpolationMap? _interpolationMap; + /// Parses [text] as a CSS identifier and returns the result. /// /// Throws a [SassFormatException] if parsing fails. @@ -48,9 +56,11 @@ class Parser { Parser(text, logger: logger)._isVariableDeclarationLike(); @protected - Parser(String contents, {Object? url, Logger? logger}) + Parser(String contents, + {Object? url, Logger? logger, InterpolationMap? interpolationMap}) : scanner = SpanScanner(contents, sourceUrl: url), - logger = logger ?? const Logger.stderr(); + logger = logger ?? const Logger.stderr(), + _interpolationMap = interpolationMap; String _parseIdentifier() { return wrapSpanFormatException(() { @@ -81,7 +91,7 @@ class Parser { /// Consumes whitespace, but not comments. @protected void whitespaceWithoutComments() { - while (!scanner.isDone && isWhitespace(scanner.peekChar())) { + while (!scanner.isDone && scanner.peekChar().isWhitespace) { scanner.readChar(); } } @@ -89,7 +99,7 @@ class Parser { /// Consumes spaces and tabs. @protected void spaces() { - while (!scanner.isDone && isSpaceOrTab(scanner.peekChar())) { + while (!scanner.isDone && scanner.peekChar().isSpaceOrTab) { scanner.readChar(); } } @@ -101,23 +111,22 @@ class Parser { bool scanComment() { if (scanner.peekChar() != $slash) return false; - var next = scanner.peekChar(1); - if (next == $slash) { - silentComment(); - return true; - } else if (next == $asterisk) { - loudComment(); - return true; - } else { - return false; + switch (scanner.peekChar(1)) { + case $slash: + silentComment(); + return true; + case $asterisk: + loudComment(); + return true; + case _: + return false; } } /// Like [whitespace], but throws an error if no whitespace is consumed. @protected void expectWhitespace() { - if (scanner.isDone || - !(isWhitespace(scanner.peekChar()) || scanComment())) { + if (scanner.isDone || !(scanner.peekChar().isWhitespace || scanComment())) { scanner.error("Expected whitespace."); } @@ -128,7 +137,7 @@ class Parser { @protected void silentComment() { scanner.expect("//"); - while (!scanner.isDone && !isNewline(scanner.peekChar())) { + while (!scanner.isDone && !scanner.peekChar().isNewline) { scanner.readChar(); } } @@ -172,18 +181,18 @@ class Parser { } } - var first = scanner.peekChar(); - if (first == null) { - scanner.error("Expected identifier."); - } else if (normalize && first == $underscore) { - scanner.readChar(); - text.writeCharCode($dash); - } else if (isNameStart(first)) { - text.writeCharCode(scanner.readChar()); - } else if (first == $backslash) { - text.write(escape(identifierStart: true)); - } else { - scanner.error("Expected identifier."); + switch (scanner.peekChar()) { + case null: + scanner.error("Expected identifier."); + case $underscore when normalize: + scanner.readChar(); + text.writeCharCode($dash); + case int(isNameStart: true): + text.writeCharCode(scanner.readChar()); + case $backslash: + text.write(escape(identifierStart: true)); + case _: + scanner.error("Expected identifier."); } _identifierBody(text, normalize: normalize, unit: unit); @@ -202,24 +211,24 @@ class Parser { /// Like [_identifierBody], but parses the body into the [text] buffer. void _identifierBody(StringBuffer text, {bool normalize = false, bool unit = false}) { + loop: while (true) { - var next = scanner.peekChar(); - if (next == null) { - break; - } else if (unit && next == $dash) { - // Disallow `-` followed by a dot or a digit digit in units. - var second = scanner.peekChar(1); - if (second != null && (second == $dot || isDigit(second))) break; - text.writeCharCode(scanner.readChar()); - } else if (normalize && next == $underscore) { - scanner.readChar(); - text.writeCharCode($dash); - } else if (isName(next)) { - text.writeCharCode(scanner.readChar()); - } else if (next == $backslash) { - text.write(escape()); - } else { - break; + switch (scanner.peekChar()) { + case null: + break loop; + case $dash when unit: + // Disallow `-` followed by a dot or a digit digit in units. + if (scanner.peekChar(1) case $dot || int(isDigit: true)) break loop; + text.writeCharCode(scanner.readChar()); + case $underscore when normalize: + scanner.readChar(); + text.writeCharCode($dash); + case int(isName: true): + text.writeCharCode(scanner.readChar()); + case $backslash: + text.write(escape()); + case _: + break loop; } } } @@ -230,8 +239,9 @@ class Parser { /// quotes and its escapes are resolved. @protected String string() { - // NOTE: this logic is largely duplicated in ScssParser._interpolatedString. - // Most changes here should be mirrored there. + // NOTE: this logic is largely duplicated in + // StylesheetParser.interpolatedString. Most changes here should be mirrored + // there. var quote = scanner.readChar(); if (quote != $single_quote && quote != $double_quote) { @@ -239,22 +249,23 @@ class Parser { } var buffer = StringBuffer(); + loop: while (true) { - var next = scanner.peekChar(); - if (next == quote) { - scanner.readChar(); - break; - } else if (next == null || isNewline(next)) { - scanner.error("Expected ${String.fromCharCode(quote)}."); - } else if (next == $backslash) { - if (isNewline(scanner.peekChar(1))) { - scanner.readChar(); + switch (scanner.peekChar()) { + case var next when next == quote: scanner.readChar(); - } else { - buffer.writeCharCode(escapeCharacter()); - } - } else { - buffer.writeCharCode(scanner.readChar()); + break loop; + case null || int(isNewline: true): + scanner.error("Expected ${String.fromCharCode(quote)}."); + case $backslash: + if (scanner.peekChar(1).isNewline) { + scanner.readChar(); + scanner.readChar(); + } else { + buffer.writeCharCode(escapeCharacter()); + } + case _: + buffer.writeCharCode(scanner.readChar()); } } @@ -268,12 +279,12 @@ class Parser { @protected double naturalNumber() { var first = scanner.readChar(); - if (!isDigit(first)) { + if (!first.isDigit) { scanner.error("Expected digit.", position: scanner.position - 1); } var number = asDecimal(first).toDouble(); - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { number *= 10; number += asDecimal(scanner.readChar()); } @@ -297,16 +308,16 @@ class Parser { while (true) { var next = scanner.peekChar(); switch (next) { + case null: + break loop; + case $backslash: buffer.write(escape(identifierStart: true)); wroteNewline = false; - break; - case $double_quote: - case $single_quote: + case $double_quote || $single_quote: buffer.write(rawText(string)); wroteNewline = false; - break; case $slash: if (scanner.peekChar(1) == $asterisk) { @@ -315,67 +326,48 @@ class Parser { buffer.writeCharCode(scanner.readChar()); } wroteNewline = false; - break; - case $space: - case $tab: - if (wroteNewline || !isWhitespace(scanner.peekChar(1))) { + case $space || $tab: + if (wroteNewline || !scanner.peekChar(1).isWhitespace) { buffer.writeCharCode($space); } scanner.readChar(); - break; - case $lf: - case $cr: - case $ff: - if (!isNewline(scanner.peekChar(-1))) buffer.writeln(); + case $lf || $cr || $ff: + if (!scanner.peekChar(-1).isNewline) buffer.writeln(); scanner.readChar(); wroteNewline = true; - break; - case $lparen: - case $lbrace: - case $lbracket: - buffer.writeCharCode(next!); // dart-lang/sdk#45357 + case $lparen || $lbrace || $lbracket: + buffer.writeCharCode(next); brackets.add(opposite(scanner.readChar())); wroteNewline = false; - break; - case $rparen: - case $rbrace: - case $rbracket: + case $rparen || $rbrace || $rbracket: if (brackets.isEmpty) break loop; - buffer.writeCharCode(next!); // dart-lang/sdk#45357 + buffer.writeCharCode(next); scanner.expectChar(brackets.removeLast()); wroteNewline = false; - break; case $semicolon: if (brackets.isEmpty) break loop; buffer.writeCharCode(scanner.readChar()); - break; - case $u: - case $U: - var url = tryUrl(); - if (url != null) { + case $u || $U: + if (tryUrl() case var url?) { buffer.write(url); } else { buffer.writeCharCode(scanner.readChar()); } wroteNewline = false; - break; default: - if (next == null) break loop; - if (lookingAtIdentifier()) { buffer.write(identifier()); } else { buffer.writeCharCode(scanner.readChar()); } wroteNewline = false; - break; } } @@ -403,26 +395,29 @@ class Parser { // Match Ruby Sass's behavior: parse a raw URL() if possible, and if not // backtrack and re-parse as a function expression. var buffer = StringBuffer()..write("url("); + loop: while (true) { - var next = scanner.peekChar(); - if (next == null) { - break; - } else if (next == $backslash) { - buffer.write(escape()); - } else if (next == $percent || - next == $ampersand || - next == $hash || - (next >= $asterisk && next <= $tilde) || - next >= 0x0080) { - buffer.writeCharCode(scanner.readChar()); - } else if (isWhitespace(next)) { - whitespace(); - if (scanner.peekChar() != $rparen) break; - } else if (next == $rparen) { - buffer.writeCharCode(scanner.readChar()); - return buffer.toString(); - } else { - break; + switch (scanner.peekChar()) { + case null: + break loop; + case $backslash: + buffer.write(escape()); + case $percent || + $ampersand || + $hash || + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + (>= $asterisk && <= $tilde) || + >= 0x0080: + buffer.writeCharCode(scanner.readChar()); + case int(isWhitespace: true): + whitespace(); + if (scanner.peekChar() != $rparen) break loop; + case $rparen: + buffer.writeCharCode(scanner.readChar()); + return buffer.toString(); + case _: + break loop; } } @@ -451,25 +446,25 @@ class Parser { var start = scanner.position; scanner.expectChar($backslash); var value = 0; - var first = scanner.peekChar(); - if (first == null) { - scanner.error("Expected escape sequence."); - } else if (isNewline(first)) { - scanner.error("Expected escape sequence."); - } else if (isHex(first)) { - for (var i = 0; i < 6; i++) { - var next = scanner.peekChar(); - if (next == null || !isHex(next)) break; - value *= 16; - value += asHex(scanner.readChar()); - } + switch (scanner.peekChar()) { + case null: + scanner.error("Expected escape sequence."); + case int(isNewline: true): + scanner.error("Expected escape sequence."); + case int(isHex: true): + for (var i = 0; i < 6; i++) { + var next = scanner.peekChar(); + if (next == null || !next.isHex) break; + value *= 16; + value += asHex(scanner.readChar()); + } - scanCharIf(isWhitespace); - } else { - value = scanner.readChar(); + scanCharIf((char) => char.isWhitespace); + case _: + value = scanner.readChar(); } - if (identifierStart ? isNameStart(value) : isName(value)) { + if (identifierStart ? value.isNameStart : value.isName) { try { return String.fromCharCode(value); } on RangeError { @@ -478,7 +473,7 @@ class Parser { } } else if (value <= 0x1F || value == 0x7F || - (identifierStart && isDigit(value))) { + (identifierStart && value.isDigit)) { var buffer = StringBuffer()..writeCharCode($backslash); if (value > 0xF) buffer.writeCharCode(hexCharFor(value >> 4)); buffer.writeCharCode(hexCharFor(value & 0xF)); @@ -513,14 +508,15 @@ class Parser { ? actual == char : characterEqualsIgnoreCase(char, actual); - var next = scanner.peekChar(); - if (next != null && matches(next)) { - scanner.readChar(); - return true; - } else if (next == $backslash) { - var start = scanner.state; - if (matches(escapeCharacter())) return true; - scanner.state = start; + switch (scanner.peekChar()) { + case var next? when matches(next): + scanner.readChar(); + return true; + + case $backslash: + var start = scanner.state; + if (matches(escapeCharacter())) return true; + scanner.state = start; } return false; } @@ -545,26 +541,16 @@ class Parser { /// /// [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#starts-with-a-number @protected - bool lookingAtNumber() { - var first = scanner.peekChar(); - if (first == null) return false; - if (isDigit(first)) return true; - - if (first == $dot) { - var second = scanner.peekChar(1); - return second != null && isDigit(second); - } else if (first == $plus || first == $minus) { - var second = scanner.peekChar(1); - if (second == null) return false; - if (isDigit(second)) return true; - if (second != $dot) return false; - - var third = scanner.peekChar(2); - return third != null && isDigit(third); - } else { - return false; - } - } + bool lookingAtNumber() => switch (scanner.peekChar()) { + int(isDigit: true) => true, + $dot => scanner.peekChar(1)?.isDigit ?? false, + $plus || $minus => switch (scanner.peekChar(1)) { + int(isDigit: true) => true, + $dot => scanner.peekChar(2)?.isDigit ?? false, + _ => false + }, + _ => false + }; /// Returns whether the scanner is immediately before a plain CSS identifier. /// @@ -579,14 +565,14 @@ class Parser { // See also [ScssParser._lookingAtInterpolatedIdentifier]. forward ??= 0; - var first = scanner.peekChar(forward); - if (first == null) return false; - if (isNameStart(first) || first == $backslash) return true; - if (first != $dash) return false; - - var second = scanner.peekChar(forward + 1); - if (second == null) return false; - return isNameStart(second) || second == $backslash || second == $dash; + return switch (scanner.peekChar(forward)) { + int(isNameStart: true) || $backslash => true, + $dash => switch (scanner.peekChar(forward + 1)) { + int(isNameStart: true) || $backslash || $dash => true, + _ => false + }, + _ => false + }; } /// Returns whether the scanner is immediately before a sequence of characters @@ -594,7 +580,7 @@ class Parser { @protected bool lookingAtIdentifierBody() { var next = scanner.peekChar(); - return next != null && (isName(next) || next == $backslash); + return next != null && (next.isName || next == $backslash); } /// Consumes an identifier if its name exactly matches [text]. @@ -626,7 +612,7 @@ class Parser { return result; } - /// Consumes [text] as an identifer, but doesn't verify whether there's + /// Consumes [text] as an identifier, but doesn't verify whether there's /// additional identifier text afterwards. /// /// Returns `true` if the full [text] is consumed and `false` otherwise, but @@ -662,6 +648,16 @@ class Parser { return scanner.substring(start); } + /// Like [scanner.spanFrom], but passes the span through [_interpolationMap] + /// if it's available. + @protected + FileSpan spanFrom(LineScannerState state) { + var span = scanner.spanFrom(state); + return _interpolationMap == null + ? 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); @@ -675,7 +671,7 @@ class Parser { if (trace == null) { throw exception; } else { - throwWithTrace(exception, trace); + throwWithTrace(exception, error, trace); } } @@ -688,6 +684,7 @@ class Parser { } on SourceSpanFormatException catch (error, stackTrace) { throwWithTrace( SourceSpanFormatException(message, error.span, error.source), + error, stackTrace); } } @@ -710,40 +707,74 @@ class Parser { @protected T wrapSpanFormatException(T callback()) { try { - return callback(); + try { + return callback(); + } on SourceSpanFormatException catch (error, stackTrace) { + var map = _interpolationMap; + if (map == null) rethrow; + + throwWithTrace(map.mapException(error), error, stackTrace); + } } on SourceSpanFormatException catch (error, stackTrace) { var span = error.span as FileSpan; - if (startsWithIgnoreCase(error.message, "expected") && span.length == 0) { - var startPosition = _firstNewlineBefore(span.start.offset); - if (startPosition != span.start.offset) { - span = span.file.span(startPosition, startPosition); - } + if (startsWithIgnoreCase(error.message, "expected")) { + span = _adjustExceptionSpan(span); } - throwWithTrace(SassFormatException(error.message, span), stackTrace); + throwWithTrace( + SassFormatException(error.message, span), error, stackTrace); + } on MultiSourceSpanFormatException catch (error, stackTrace) { + var span = error.span as FileSpan; + var secondarySpans = error.secondarySpans.cast(); + if (startsWithIgnoreCase(error.message, "expected")) { + span = _adjustExceptionSpan(span); + secondarySpans = { + for (var (span, description) in secondarySpans.pairs) + _adjustExceptionSpan(span): description + }; + } + + throwWithTrace( + MultiSpanSassFormatException( + error.message, span, error.primaryLabel, secondarySpans), + error, + stackTrace); } } - /// If [position] is separated from the previous non-whitespace character in - /// `scanner.string` by one or more newlines, returns the offset of the last + /// Moves span to [_firstNewlineBefore] if necessary. + FileSpan _adjustExceptionSpan(FileSpan span) { + if (span.length > 0) return span; + + var start = _firstNewlineBefore(span.start); + return start == span.start ? span : start.pointSpan(); + } + + /// If [location] is separated from the previous non-whitespace character in + /// `scanner.string` by one or more newlines, returns the location of the last /// separating newline. /// - /// Otherwise returns [position]. + /// Otherwise returns [location]. /// /// This helps avoid missing token errors pointing at the next closing bracket /// rather than the line where the problem actually occurred. - int _firstNewlineBefore(int position) { - var index = position - 1; + FileLocation _firstNewlineBefore(FileLocation location) { + var text = location.file.getText(0, location.offset); + var index = location.offset - 1; int? lastNewline; while (index >= 0) { - var codeUnit = scanner.string.codeUnitAt(index); - if (!isWhitespace(codeUnit)) return lastNewline ?? position; - if (isNewline(codeUnit)) lastNewline = index; + var codeUnit = text.codeUnitAt(index); + if (!codeUnit.isWhitespace) { + return lastNewline == null + ? location + : location.file.location(lastNewline); + } + if (codeUnit.isNewline) lastNewline = index; index--; } - // If the document *only* contains whitespace before [position], always - // return [position]. - return position; + // If the document *only* contains whitespace before [location], always + // return [location]. + return location; } } diff --git a/lib/src/parse/sass.dart b/lib/src/parse/sass.dart index a5b3343d6..95fd054c5 100644 --- a/lib/src/parse/sass.dart +++ b/lib/src/parse/sass.dart @@ -49,7 +49,7 @@ class SassParser extends StylesheetParser { buffer.addInterpolation(almostAnyValue(omitComments: true)); buffer.writeCharCode($lf); } while (buffer.trailingString.trimRight().endsWith(',') && - scanCharIf(isNewline)); + scanCharIf((char) => char.isNewline)); return buffer.interpolation(scanner.spanFrom(start)); } @@ -62,18 +62,14 @@ class SassParser extends StylesheetParser { position: _nextIndentationEnd!.position); } - bool atEndOfStatement() { - var next = scanner.peekChar(); - return next == null || isNewline(next); - } + bool atEndOfStatement() => scanner.peekChar()?.isNewline ?? true; bool lookingAtChildren() => atEndOfStatement() && _peekIndentation() > currentIndentation; Import importArgument() { switch (scanner.peekChar()) { - case $u: - case $U: + case $u || $U: var start = scanner.state; if (scanIdentifier("url")) { if (scanner.scanChar($lparen)) { @@ -83,10 +79,8 @@ class SassParser extends StylesheetParser { scanner.state = start; } } - break; - case $single_quote: - case $double_quote: + case $single_quote || $double_quote: return super.importArgument(); } @@ -95,7 +89,7 @@ class SassParser extends StylesheetParser { while (next != null && next != $comma && next != $semicolon && - !isNewline(next)) { + !next.isNewline) { scanner.readChar(); next = scanner.peekChar(); } @@ -136,23 +130,20 @@ class SassParser extends StylesheetParser { List children(Statement child()) { var children = []; _whileIndentedLower(() { - var parsedChild = _child(child); - if (parsedChild != null) children.add(parsedChild); + if (_child(child) case var parsedChild?) children.add(parsedChild); }); return children; } List statements(Statement? statement()) { - var first = scanner.peekChar(); - if (first == $tab || first == $space) { + if (scanner.peekChar() case $tab || $space) { scanner.error("Indenting at the beginning of the document is illegal.", position: 0, length: scanner.position); } var statements = []; while (!scanner.isDone) { - var child = _child(statement); - if (child != null) statements.add(child); + if (_child(statement) case var child?) statements.add(child); var indentation = _readIndentation(); assert(indentation == 0); } @@ -164,31 +155,17 @@ class SassParser extends StylesheetParser { /// This consumes children that are allowed at all levels of the document; the /// [child] parameter is called to consume any children that are specifically /// allowed in the caller's context. - Statement? _child(Statement? child()) { - switch (scanner.peekChar()) { - // Ignore empty lines. - case $cr: - case $lf: - case $ff: - return null; - - case $dollar: - return variableDeclarationWithoutNamespace(); - - case $slash: - switch (scanner.peekChar(1)) { - case $slash: - return _silentComment(); - case $asterisk: - return _loudComment(); - default: - return child(); - } - - default: - return child(); - } - } + Statement? _child(Statement? child()) => switch (scanner.peekChar()) { + // Ignore empty lines. + $cr || $lf || $ff => null, + $dollar => variableDeclarationWithoutNamespace(), + $slash => switch (scanner.peekChar(1)) { + $slash => _silentComment(), + $asterisk => _loudComment(), + _ => child() + }, + _ => child() + }; /// Consumes an indented-style silent comment. SilentComment _silentComment() { @@ -212,7 +189,7 @@ class SassParser extends StylesheetParser { buffer.writeCharCode($space); } - while (!scanner.isDone && !isNewline(scanner.peekChar())) { + while (!scanner.isDone && !scanner.peekChar().isNewline) { buffer.writeCharCode(scanner.readChar()); } buffer.writeln(); @@ -248,7 +225,7 @@ class SassParser extends StylesheetParser { // If the first line is empty, ignore it. var beginningOfComment = scanner.position; spaces(); - if (isNewline(scanner.peekChar())) { + if (scanner.peekChar().isNewline) { _readIndentation(); buffer.writeCharCode($space); } else { @@ -266,11 +243,8 @@ class SassParser extends StylesheetParser { loop: while (!scanner.isDone) { - var next = scanner.peekChar(); - switch (next) { - case $lf: - case $cr: - case $ff: + switch (scanner.peekChar()) { + case $lf || $cr || $ff: break loop; case $hash: @@ -279,11 +253,9 @@ class SassParser extends StylesheetParser { } else { buffer.writeCharCode(scanner.readChar()); } - break; - default: + case _: buffer.writeCharCode(scanner.readChar()); - break; } } @@ -319,7 +291,7 @@ class SassParser extends StylesheetParser { scanner.expect("/*"); while (true) { var next = scanner.readChar(); - if (isNewline(next)) scanner.error("expected */."); + if (next.isNewline) scanner.error("expected */."); if (next != $asterisk) continue; do { @@ -338,8 +310,7 @@ class SassParser extends StylesheetParser { scanner.readChar(); if (scanner.peekChar() == $lf) scanner.readChar(); return; - case $lf: - case $ff: + case $lf || $ff: scanner.readChar(); return; default: @@ -348,19 +319,15 @@ class SassParser extends StylesheetParser { } /// Returns whether the scanner is immediately before *two* newlines. - bool _lookingAtDoubleNewline() { - switch (scanner.peekChar()) { - case $cr: - var nextChar = scanner.peekChar(1); - if (nextChar == $lf) return isNewline(scanner.peekChar(2)); - return nextChar == $cr || nextChar == $ff; - case $lf: - case $ff: - return isNewline(scanner.peekChar(1)); - default: - return false; - } - } + bool _lookingAtDoubleNewline() => switch (scanner.peekChar()) { + $cr => switch (scanner.peekChar(1)) { + $lf => scanner.peekChar(2).isNewline, + $cr || $ff => true, + _ => false + }, + $lf || $ff => scanner.peekChar(1).isNewline, + _ => false + }; /// As long as the scanner's position is indented beneath the starting line, /// runs [body] to consume the next statement. @@ -394,8 +361,7 @@ class SassParser extends StylesheetParser { /// Returns the indentation level of the next line. int _peekIndentation() { - var cached = _nextIndentation; - if (cached != null) return cached; + if (_nextIndentation case var cached?) return cached; if (scanner.isDone) { _nextIndentation = 0; @@ -404,7 +370,7 @@ class SassParser extends StylesheetParser { } var start = scanner.state; - if (!scanCharIf(isNewline)) { + if (!scanCharIf((char) => char.isNewline)) { scanner.error("Expected newline.", position: scanner.position); } @@ -416,14 +382,15 @@ class SassParser extends StylesheetParser { containsSpace = false; nextIndentation = 0; + loop: while (true) { - var next = scanner.peekChar(); - if (next == $space) { - containsSpace = true; - } else if (next == $tab) { - containsTab = true; - } else { - break; + switch (scanner.peekChar()) { + case $space: + containsSpace = true; + case $tab: + containsTab = true; + case _: + break loop; } nextIndentation++; scanner.readChar(); @@ -435,7 +402,7 @@ class SassParser extends StylesheetParser { scanner.state = start; return 0; } - } while (scanCharIf(isNewline)); + } while (scanCharIf((char) => char.isNewline)); _checkIndentationConsistency(containsTab, containsSpace); diff --git a/lib/src/parse/scss.dart b/lib/src/parse/scss.dart index 047a52eee..cc432e9c8 100644 --- a/lib/src/parse/scss.dart +++ b/lib/src/parse/scss.dart @@ -5,6 +5,7 @@ import 'package:charcode/charcode.dart'; import '../ast/sass.dart'; +import '../deprecation.dart'; import '../interpolation_buffer.dart'; import '../logger.dart'; import '../util/character.dart'; @@ -23,8 +24,7 @@ class ScssParser extends StylesheetParser { void expectStatementSeparator([String? name]) { whitespaceWithoutComments(); if (scanner.isDone) return; - var next = scanner.peekChar(); - if (next == $semicolon || next == $rbrace) return; + if (scanner.peekChar() case $semicolon || $rbrace) return; scanner.expectChar($semicolon); } @@ -45,13 +45,13 @@ class ScssParser extends StylesheetParser { if (scanner.scanChar($at)) { if (scanIdentifier('else', caseSensitive: true)) return true; if (scanIdentifier('elseif', caseSensitive: true)) { - logger.warn( + 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), - deprecation: true); + span: scanner.spanFrom(beforeAt)); scanner.position -= 2; return true; } @@ -68,28 +68,22 @@ class ScssParser extends StylesheetParser { switch (scanner.peekChar()) { case $dollar: children.add(variableDeclarationWithoutNamespace()); - break; case $slash: switch (scanner.peekChar(1)) { case $slash: children.add(_silentComment()); whitespaceWithoutComments(); - break; case $asterisk: children.add(_loudComment()); whitespaceWithoutComments(); - break; default: children.add(child()); - break; } - break; case $semicolon: scanner.readChar(); whitespaceWithoutComments(); - break; case $rbrace: scanner.expectChar($rbrace); @@ -97,7 +91,6 @@ class ScssParser extends StylesheetParser { default: children.add(child()); - break; } } } @@ -109,34 +102,25 @@ class ScssParser extends StylesheetParser { switch (scanner.peekChar()) { case $dollar: statements.add(variableDeclarationWithoutNamespace()); - break; case $slash: switch (scanner.peekChar(1)) { case $slash: statements.add(_silentComment()); whitespaceWithoutComments(); - break; case $asterisk: statements.add(_loudComment()); whitespaceWithoutComments(); - break; default: - var child = statement(); - if (child != null) statements.add(child); - break; + if (statement() case var child?) statements.add(child); } - break; case $semicolon: scanner.readChar(); whitespaceWithoutComments(); - break; default: - var child = statement(); - if (child != null) statements.add(child); - break; + if (statement() case var child?) statements.add(child); } } return statements; @@ -148,9 +132,9 @@ class ScssParser extends StylesheetParser { scanner.expect("//"); do { - while (!scanner.isDone && !isNewline(scanner.readChar())) {} + while (!scanner.isDone && !scanner.readChar().isNewline) {} if (scanner.isDone) break; - whitespaceWithoutComments(); + spaces(); } while (scanner.scan("//")); if (plainCss) { @@ -167,6 +151,7 @@ class ScssParser extends StylesheetParser { var start = scanner.state; scanner.expect("/*"); var buffer = InterpolationBuffer()..write("/*"); + loop: while (true) { switch (scanner.peekChar()) { case $hash: @@ -179,7 +164,7 @@ class ScssParser extends StylesheetParser { case $asterisk: buffer.writeCharCode(scanner.readChar()); - if (scanner.peekChar() != $slash) break; + if (scanner.peekChar() != $slash) continue loop; buffer.writeCharCode(scanner.readChar()); return LoudComment(buffer.interpolation(scanner.spanFrom(start))); @@ -187,16 +172,13 @@ class ScssParser extends StylesheetParser { case $cr: scanner.readChar(); if (scanner.peekChar() != $lf) buffer.writeCharCode($lf); - break; case $ff: scanner.readChar(); buffer.writeCharCode($lf); - break; default: buffer.writeCharCode(scanner.readChar()); - break; } } } diff --git a/lib/src/parse/selector.dart b/lib/src/parse/selector.dart index 0a270ccf8..75df8b205 100644 --- a/lib/src/parse/selector.dart +++ b/lib/src/parse/selector.dart @@ -4,7 +4,9 @@ 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'; @@ -37,11 +39,13 @@ class SelectorParser extends Parser { SelectorParser(String contents, {Object? url, Logger? logger, + InterpolationMap? interpolationMap, bool allowParent = true, bool allowPlaceholder = true}) : _allowParent = allowParent, _allowPlaceholder = allowPlaceholder, - super(contents, url: url, logger: logger); + super(contents, + url: url, logger: logger, interpolationMap: interpolationMap); SelectorList parse() { return wrapSpanFormatException(() { @@ -77,14 +81,14 @@ class SelectorParser extends Parser { /// Consumes a selector list. SelectorList _selectorList() { + var start = scanner.state; var previousLine = scanner.line; var components = [_complexSelector()]; whitespace(); while (scanner.scanChar($comma)) { whitespace(); - var next = scanner.peekChar(); - if (next == $comma) continue; + if (scanner.peekChar() == $comma) continue; if (scanner.isDone) break; var lineBreak = scanner.line != previousLine; @@ -92,7 +96,7 @@ class SelectorParser extends Parser { components.add(_complexSelector(lineBreak: lineBreak)); } - return SelectorList(components); + return SelectorList(components, spanFrom(start)); } /// Consumes a complex selector. @@ -100,54 +104,57 @@ class SelectorParser extends Parser { /// If [lineBreak] is `true`, that indicates that there was a line break /// before this selector. ComplexSelector _complexSelector({bool lineBreak = false}) { + var start = scanner.state; + + var componentStart = scanner.state; CompoundSelector? lastCompound; - var combinators = []; + var combinators = >[]; - List? initialCombinators; + List>? initialCombinators; var components = []; loop: while (true) { whitespace(); - var next = scanner.peekChar(); - switch (next) { + switch (scanner.peekChar()) { case $plus: + var combinatorStart = scanner.state; scanner.readChar(); - combinators.add(Combinator.nextSibling); - break; + combinators + .add(CssValue(Combinator.nextSibling, spanFrom(combinatorStart))); case $gt: + var combinatorStart = scanner.state; scanner.readChar(); - combinators.add(Combinator.child); - break; + combinators + .add(CssValue(Combinator.child, spanFrom(combinatorStart))); case $tilde: + var combinatorStart = scanner.state; scanner.readChar(); - combinators.add(Combinator.followingSibling); - break; - - default: - if (next == null || - (!const { - $lbracket, - $dot, - $hash, - $percent, - $colon, - $ampersand, - $asterisk, - $pipe - }.contains(next) && - !lookingAtIdentifier())) { - break loop; - } - + combinators.add( + CssValue(Combinator.followingSibling, spanFrom(combinatorStart))); + + case null: + break loop; + + case $lbracket || + $dot || + $hash || + $percent || + $colon || + $ampersand || + $asterisk || + $pipe: + case _ when lookingAtIdentifier(): if (lastCompound != null) { - components.add(ComplexSelectorComponent(lastCompound, combinators)); + components.add(ComplexSelectorComponent( + lastCompound, combinators, spanFrom(componentStart))); } else if (combinators.isNotEmpty) { assert(initialCombinators == null); initialCombinators = combinators; + componentStart = scanner.state; } lastCompound = _compoundSelector(); @@ -156,31 +163,36 @@ class SelectorParser extends Parser { scanner.error( '"&" may only used at the beginning of a compound selector.'); } - break; + + case _: + break loop; } } if (lastCompound != null) { - components.add(ComplexSelectorComponent(lastCompound, combinators)); + components.add(ComplexSelectorComponent( + lastCompound, combinators, spanFrom(componentStart))); } else if (combinators.isNotEmpty) { initialCombinators = combinators; } else { scanner.error("expected selector."); } - return ComplexSelector(initialCombinators ?? const [], components, + return ComplexSelector( + initialCombinators ?? const [], components, spanFrom(start), lineBreak: lineBreak); } /// Consumes a compound selector. CompoundSelector _compoundSelector() { + var start = scanner.state; var components = [_simpleSelector()]; while (isSimpleSelectorStart(scanner.peekChar())) { components.add(_simpleSelector(allowParent: false)); } - return CompoundSelector(components); + return CompoundSelector(components, spanFrom(start)); } /// Consumes a simple selector. @@ -221,12 +233,15 @@ class SelectorParser extends Parser { /// Consumes an attribute selector. AttributeSelector _attributeSelector() { + var start = scanner.state; scanner.expectChar($lbracket); whitespace(); var name = _attributeName(); whitespace(); - if (scanner.scanChar($rbracket)) return AttributeSelector(name); + if (scanner.scanChar($rbracket)) { + return AttributeSelector(name, spanFrom(start)); + } var operator = _attributeOperator(); whitespace(); @@ -238,12 +253,13 @@ class SelectorParser extends Parser { whitespace(); next = scanner.peekChar(); - var modifier = next != null && isAlphabetic(next) + var modifier = next != null && next.isAlphabetic ? String.fromCharCode(scanner.readChar()) : null; scanner.expectChar($rbracket); - return AttributeSelector.withOperator(name, operator, value, + return AttributeSelector.withOperator( + name, operator, value, spanFrom(start), modifier: modifier); } @@ -301,40 +317,45 @@ class SelectorParser extends Parser { /// Consumes a class selector. ClassSelector _classSelector() { + var start = scanner.state; scanner.expectChar($dot); var name = identifier(); - return ClassSelector(name); + return ClassSelector(name, spanFrom(start)); } /// Consumes an ID selector. IDSelector _idSelector() { + var start = scanner.state; scanner.expectChar($hash); var name = identifier(); - return IDSelector(name); + return IDSelector(name, spanFrom(start)); } /// Consumes a placeholder selector. PlaceholderSelector _placeholderSelector() { + var start = scanner.state; scanner.expectChar($percent); var name = identifier(); - return PlaceholderSelector(name); + return PlaceholderSelector(name, spanFrom(start)); } /// Consumes a parent selector. ParentSelector _parentSelector() { + var start = scanner.state; scanner.expectChar($ampersand); var suffix = lookingAtIdentifierBody() ? identifierBody() : null; - return ParentSelector(suffix: suffix); + return ParentSelector(spanFrom(start), suffix: suffix); } /// Consumes a pseudo selector. PseudoSelector _pseudoSelector() { + var start = scanner.state; scanner.expectChar($colon); var element = scanner.scanChar($colon); var name = identifier(); if (!scanner.scanChar($lparen)) { - return PseudoSelector(name, element: element); + return PseudoSelector(name, spanFrom(start), element: element); } whitespace(); @@ -352,7 +373,7 @@ class SelectorParser extends Parser { } else if (unvendored == "nth-child" || unvendored == "nth-last-child") { argument = _aNPlusB(); whitespace(); - if (isWhitespace(scanner.peekChar(-1)) && scanner.peekChar() != $rparen) { + if (scanner.peekChar(-1).isWhitespace && scanner.peekChar() != $rparen) { expectIdentifier("of"); argument += " of"; whitespace(); @@ -364,7 +385,7 @@ class SelectorParser extends Parser { } scanner.expectChar($rparen); - return PseudoSelector(name, + return PseudoSelector(name, spanFrom(start), element: element, argument: argument, selector: selector); } @@ -374,27 +395,23 @@ class SelectorParser extends Parser { String _aNPlusB() { var buffer = StringBuffer(); switch (scanner.peekChar()) { - case $e: - case $E: + case $e || $E: expectIdentifier("even"); return "even"; - case $o: - case $O: + case $o || $O: expectIdentifier("odd"); return "odd"; - case $plus: - case $minus: + case $plus || $minus: buffer.writeCharCode(scanner.readChar()); break; } - var first = scanner.peekChar(); - if (first != null && isDigit(first)) { - while (isDigit(scanner.peekChar())) { + if (scanner.peekChar().isDigit) { + do { buffer.writeCharCode(scanner.readChar()); - } + } while (scanner.peekChar().isDigit); whitespace(); if (!scanIdentChar($n)) return buffer.toString(); } else { @@ -408,11 +425,10 @@ class SelectorParser extends Parser { buffer.writeCharCode(scanner.readChar()); whitespace(); - var last = scanner.peekChar(); - if (last == null || !isDigit(last)) scanner.error("Expected a number."); - while (isDigit(scanner.peekChar())) { + if (!scanner.peekChar().isDigit) scanner.error("Expected a number."); + do { buffer.writeCharCode(scanner.readChar()); - } + } while (scanner.peekChar().isDigit); return buffer.toString(); } @@ -420,32 +436,29 @@ class SelectorParser extends Parser { /// /// These are combined because either one could start with `*`. SimpleSelector _typeOrUniversalSelector() { - var first = scanner.peekChar(); - if (first == $asterisk) { - scanner.readChar(); - if (!scanner.scanChar($pipe)) return UniversalSelector(); - if (scanner.scanChar($asterisk)) { - return UniversalSelector(namespace: "*"); - } else { - return TypeSelector(QualifiedName(identifier(), namespace: "*")); - } - } else if (first == $pipe) { - scanner.readChar(); - if (scanner.scanChar($asterisk)) { - return UniversalSelector(namespace: ""); - } else { - return TypeSelector(QualifiedName(identifier(), namespace: "")); - } + var start = scanner.state; + if (scanner.scanChar($asterisk)) { + if (!scanner.scanChar($pipe)) return UniversalSelector(spanFrom(start)); + return scanner.scanChar($asterisk) + ? UniversalSelector(spanFrom(start), namespace: "*") + : TypeSelector( + QualifiedName(identifier(), namespace: "*"), spanFrom(start)); + } else if (scanner.scanChar($pipe)) { + return scanner.scanChar($asterisk) + ? UniversalSelector(spanFrom(start), namespace: "") + : TypeSelector( + QualifiedName(identifier(), namespace: ""), spanFrom(start)); } var nameOrNamespace = identifier(); if (!scanner.scanChar($pipe)) { - return TypeSelector(QualifiedName(nameOrNamespace)); + return TypeSelector(QualifiedName(nameOrNamespace), spanFrom(start)); } else if (scanner.scanChar($asterisk)) { - return UniversalSelector(namespace: nameOrNamespace); + return UniversalSelector(spanFrom(start), namespace: nameOrNamespace); } else { return TypeSelector( - QualifiedName(identifier(), namespace: nameOrNamespace)); + QualifiedName(identifier(), namespace: nameOrNamespace), + spanFrom(start)); } } } diff --git a/lib/src/parse/stylesheet.dart b/lib/src/parse/stylesheet.dart index d0bc43dbc..9e9f7b2ed 100644 --- a/lib/src/parse/stylesheet.dart +++ b/lib/src/parse/stylesheet.dart @@ -7,10 +7,10 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; -import 'package:tuple/tuple.dart'; import '../ast/sass.dart'; import '../color_names.dart'; +import '../deprecation.dart'; import '../exception.dart'; import '../interpolation_buffer.dart'; import '../logger.dart'; @@ -143,7 +143,7 @@ abstract class StylesheetParser extends Parser { /// option and returns its name and declaration. /// /// If [requireParens] is `false`, this allows parentheses to be omitted. - Tuple2 parseSignature( + (String name, ArgumentDeclaration) parseSignature( {bool requireParens = true}) { return wrapSpanFormatException(() { var name = identifier(); @@ -151,7 +151,7 @@ abstract class StylesheetParser extends Parser { ? _argumentDeclaration() : ArgumentDeclaration.empty(scanner.emptySpan); scanner.expectDone(); - return Tuple2(name, arguments); + return (name, arguments); }); } @@ -183,7 +183,7 @@ abstract class StylesheetParser extends Parser { case $rbrace: scanner.error('unmatched "}".', length: 1); - default: + case _: return _inStyleRule || _inUnknownAtRule || _inMixin || _inContentBlock ? _declarationOrStyleRule() : _variableDeclarationOrStyleRule(); @@ -227,18 +227,32 @@ abstract class StylesheetParser extends Parser { var global = false; var flagStart = scanner.state; while (scanner.scanChar($exclamation)) { - var flag = identifier(); - if (flag == 'default') { - guarded = true; - } else if (flag == 'global') { - if (namespace != null) { - error("!global isn't allowed for variables in other modules.", - scanner.spanFrom(flagStart)); - } + 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)); + } + guarded = true; - global = true; - } else { - error("Invalid flag name.", scanner.spanFrom(flagStart)); + case 'global': + if (namespace != null) { + 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)); + } + global = true; + + case _: + error("Invalid flag name.", scanner.spanFrom(flagStart)); } whitespace(); @@ -268,14 +282,12 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; var variableOrInterpolation = _variableDeclarationOrInterpolation(); - if (variableOrInterpolation is VariableDeclaration) { - return variableOrInterpolation; - } else { - return _styleRule( - InterpolationBuffer() - ..addInterpolation(variableOrInterpolation as Interpolation), - start); - } + return variableOrInterpolation is VariableDeclaration + ? variableOrInterpolation + : _styleRule( + InterpolationBuffer() + ..addInterpolation(variableOrInterpolation as Interpolation), + start); } /// Consumes a [VariableDeclaration], a [Declaration], or a [StyleRule]. @@ -335,14 +347,8 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; var nameBuffer = InterpolationBuffer(); - // Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val" - // hacks. - var first = scanner.peekChar(); var startsWithPunctuation = false; - if (first == $colon || - first == $asterisk || - first == $dot || - (first == $hash && scanner.peekChar(1) != $lbrace)) { + if (_lookingAtPotentialPropertyHack()) { startsWithPunctuation = true; nameBuffer.writeCharCode(scanner.readChar()); nameBuffer.write(rawText(whitespace)); @@ -512,13 +518,7 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; Interpolation name; - // Allow the "*prop: val", ":prop: val", "#prop: val", and ".prop: val" - // hacks. - var first = scanner.peekChar(); - if (first == $colon || - first == $asterisk || - first == $dot || - (first == $hash && scanner.peekChar(1) != $lbrace)) { + if (_lookingAtPotentialPropertyHack()) { var nameBuffer = InterpolationBuffer(); nameBuffer.writeCharCode(scanner.readChar()); nameBuffer.write(rawText(whitespace)); @@ -571,10 +571,9 @@ abstract class StylesheetParser extends Parser { } /// Consumes a statement that's allowed within a declaration. - Statement _declarationChild() { - if (scanner.peekChar() == $at) return _declarationAtRule(); - return _propertyOrVariableDeclaration(parseCustomProperties: false); - } + Statement _declarationChild() => scanner.peekChar() == $at + ? _declarationAtRule() + : _propertyOrVariableDeclaration(parseCustomProperties: false); // ## At Rules @@ -658,32 +657,19 @@ abstract class StylesheetParser extends Parser { /// Consumes an at-rule allowed within a property declaration. Statement _declarationAtRule() { var start = scanner.state; - var name = _plainAtRuleName(); - - switch (name) { - case "content": - return _contentRule(start); - case "debug": - return _debugRule(start); - case "each": - return _eachRule(start, _declarationChild); - case "else": - return _disallowedAtRule(start); - case "error": - return _errorRule(start); - case "for": - return _forRule(start, _declarationChild); - case "if": - return _ifRule(start, _declarationChild); - case "include": - return _includeRule(start); - case "warn": - return _warnRule(start); - case "while": - return _whileRule(start, _declarationChild); - default: - return _disallowedAtRule(start); - } + return switch (_plainAtRuleName()) { + "content" => _contentRule(start), + "debug" => _debugRule(start), + "each" => _eachRule(start, _declarationChild), + "else" => _disallowedAtRule(start), + "error" => _errorRule(start), + "for" => _forRule(start, _declarationChild), + "if" => _ifRule(start, _declarationChild), + "include" => _includeRule(start), + "warn" => _warnRule(start), + "while" => _whileRule(start, _declarationChild), + _ => _disallowedAtRule(start) + }; } /// Consumes a statement allowed within a function. @@ -714,28 +700,18 @@ abstract class StylesheetParser extends Parser { } var start = scanner.state; - switch (_plainAtRuleName()) { - case "debug": - return _debugRule(start); - case "each": - return _eachRule(start, _functionChild); - case "else": - return _disallowedAtRule(start); - case "error": - return _errorRule(start); - case "for": - return _forRule(start, _functionChild); - case "if": - return _ifRule(start, _functionChild); - case "return": - return _returnRule(start); - case "warn": - return _warnRule(start); - case "while": - return _whileRule(start, _functionChild); - default: - return _disallowedAtRule(start); - } + return switch (_plainAtRuleName()) { + "debug" => _debugRule(start), + "each" => _eachRule(start, _functionChild), + "else" => _disallowedAtRule(start), + "error" => _errorRule(start), + "for" => _forRule(start, _functionChild), + "if" => _ifRule(start, _functionChild), + "return" => _returnRule(start), + "warn" => _warnRule(start), + "while" => _whileRule(start, _functionChild), + _ => _disallowedAtRule(start), + }; } /// Consumes an at-rule's name, with interpolation disallowed. @@ -884,16 +860,16 @@ abstract class StylesheetParser extends Parser { scanner.spanFrom(start)); } - switch (unvendor(name)) { - case "calc": - case "element": - case "expression": - case "url": - case "and": - case "or": - case "not": - case "clamp": - error("Invalid function name.", scanner.spanFrom(start)); + if (unvendor(name) + case "calc" || + "element" || + "expression" || + "url" || + "and" || + "or" || + "not" || + "clamp") { + error("Invalid function name.", scanner.spanFrom(start)); } whitespace(); @@ -962,13 +938,9 @@ abstract class StylesheetParser extends Parser { Set? hiddenMixinsAndFunctions; Set? hiddenVariables; if (scanIdentifier("show")) { - var members = _memberList(); - shownMixinsAndFunctions = members.item1; - shownVariables = members.item2; + (shownMixinsAndFunctions, shownVariables) = _memberList(); } else if (scanIdentifier("hide")) { - var members = _memberList(); - hiddenMixinsAndFunctions = members.item1; - hiddenVariables = members.item2; + (hiddenMixinsAndFunctions, hiddenVariables) = _memberList(); } var configuration = _configuration(allowGuarded: true); @@ -998,7 +970,7 @@ abstract class StylesheetParser extends Parser { /// /// The plain identifiers are returned in the first set, and the variable /// names in the second. - Tuple2, Set> _memberList() { + (Set, Set) _memberList() { var identifiers = {}; var variables = {}; do { @@ -1013,7 +985,7 @@ abstract class StylesheetParser extends Parser { whitespace(); } while (scanner.scanChar($comma)); - return Tuple2(identifiers, variables); + return (identifiers, variables); } /// Consumes an `@if` rule. @@ -1056,6 +1028,14 @@ abstract class StylesheetParser extends Parser { do { 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); + } if ((_inControlDirective || _inMixin) && argument is DynamicImport) { _disallowedAtRule(start); } @@ -1074,8 +1054,7 @@ abstract class StylesheetParser extends Parser { @protected Import importArgument() { var start = scanner.state; - var next = scanner.peekChar(); - if (next == $u || next == $U) { + if (scanner.peekChar() case $u || $U) { var url = dynamicUrl(); whitespace(); var modifiers = tryImportModifiers(); @@ -1121,10 +1100,11 @@ abstract class StylesheetParser extends Parser { if (url.length < 5) return false; if (url.endsWith(".css")) return true; - var first = url.codeUnitAt(0); - if (first == $slash) return url.codeUnitAt(1) == $slash; - if (first != $h) return false; - return url.startsWith("http://") || url.startsWith("https://"); + return switch (url.codeUnitAt(0)) { + $slash => url.codeUnitAt(1) == $slash, + $h => url.startsWith("http://") || url.startsWith("https://"), + _ => false + }; } /// Consumes a sequence of modifiers (such as media or supports queries) @@ -1192,8 +1172,7 @@ abstract class StylesheetParser extends Parser { } else if (scanner.peekChar() == $lparen) { return _supportsCondition(); } else { - var function = _tryImportSupportsFunction(); - if (function != null) return function; + if (_tryImportSupportsFunction() case var function?) return function; var start = scanner.state; var name = _expression(); @@ -1328,11 +1307,9 @@ abstract class StylesheetParser extends Parser { var identifierStart = scanner.state; var identifier = this.identifier(); switch (identifier) { - case "url": - case "url-prefix": - case "domain": - var contents = _tryUrlContents(identifierStart, name: identifier); - if (contents != null) { + case "url" || "url-prefix" || "domain": + if (_tryUrlContents(identifierStart, name: identifier) + case var contents?) { buffer.addInterpolation(contents); } else { scanner.expectChar($lparen); @@ -1355,7 +1332,6 @@ abstract class StylesheetParser extends Parser { !trailing.endsWith('url-prefix("")')) { needsDeprecationWarning = true; } - break; case "regexp": buffer.write("regexp("); @@ -1364,7 +1340,6 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($rparen); buffer.writeCharCode($rparen); needsDeprecationWarning = true; - break; default: error("Invalid function name.", scanner.spanFrom(identifierStart)); @@ -1381,13 +1356,13 @@ abstract class StylesheetParser extends Parser { var value = buffer.interpolation(scanner.spanFrom(valueStart)); return _withChildren(_statement, start, (children, span) { if (needsDeprecationWarning) { - logger.warn( + 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, - deprecation: true); + span: span); } return AtRule(name, span, value: value, children: children); @@ -1489,8 +1464,7 @@ abstract class StylesheetParser extends Parser { var guarded = false; var flagStart = scanner.state; if (allowGuarded && scanner.scanChar($exclamation)) { - var flag = identifier(); - if (flag == 'default') { + if (identifier() == 'default') { guarded = true; whitespace(); } else { @@ -1547,8 +1521,9 @@ abstract class StylesheetParser extends Parser { _inUnknownAtRule = true; Interpolation? value; - var next = scanner.peekChar(); - if (next != $exclamation && !atEndOfStatement()) value = almostAnyValue(); + if (scanner.peekChar() != $exclamation && !atEndOfStatement()) { + value = almostAnyValue(); + } AtRule rule; if (lookingAtChildren()) { @@ -1715,7 +1690,7 @@ abstract class StylesheetParser extends Parser { var wasInParentheses = _inParentheses; // We use the convention below of referring to nullable variables that are - // shared across anonymous functions in this method with a trailling + // shared across anonymous functions in this method with a trailing // underscore. This allows us to copy them to non-underscored local // variables to make it easier for Dart's type system to reason about their // local nullability. @@ -1781,13 +1756,13 @@ abstract class StylesheetParser extends Parser { singleExpression_ = BinaryOperationExpression(operator, left, right); allowSlash = false; - if (operator == BinaryOperator.plus || - operator == BinaryOperator.minus) { + if (operator case BinaryOperator.plus || BinaryOperator.minus) { if (scanner.string.substring( right.span.start.offset - 1, right.span.start.offset) == operator.operator && - isWhitespace(scanner.string.codeUnitAt(left.span.end.offset))) { - logger.warn( + scanner.string.codeUnitAt(left.span.end.offset).isWhitespace) { + logger.warnForDeprecation( + Deprecation.strictUnary, "This operation is parsed as:\n" "\n" " $left ${operator.operator} $right\n" @@ -1804,8 +1779,7 @@ abstract class StylesheetParser extends Parser { "\n" "More info and automated migrator: " "https://sass-lang.com/d/strict-unary", - span: singleExpression_!.span, - deprecation: true); + span: singleExpression_!.span); } } } @@ -1880,17 +1854,15 @@ abstract class StylesheetParser extends Parser { resolveOperations(); var spaceExpressions = spaceExpressions_; - if (spaceExpressions != null) { - var singleExpression = singleExpression_; - if (singleExpression == null) scanner.error("Expected expression."); - - spaceExpressions.add(singleExpression); - singleExpression_ = ListExpression( - spaceExpressions, - ListSeparator.space, - spaceExpressions.first.span.expand(singleExpression.span)); - spaceExpressions_ = null; - } + if (spaceExpressions == null) return; + + var singleExpression = singleExpression_; + if (singleExpression == null) scanner.error("Expected expression."); + + spaceExpressions.add(singleExpression); + singleExpression_ = ListExpression(spaceExpressions, ListSeparator.space, + spaceExpressions.first.span.expand(singleExpression.span)); + spaceExpressions_ = null; } loop: @@ -1898,33 +1870,28 @@ abstract class StylesheetParser extends Parser { whitespace(); if (until != null && until()) break; - var first = scanner.peekChar(); - switch (first) { + switch (scanner.peekChar()) { + case null: + break loop; + case $lparen: // Parenthesized numbers can't be slash-separated. addSingleExpression(_parentheses()); - break; case $lbracket: addSingleExpression(_expression(bracketList: true)); - break; case $dollar: addSingleExpression(_variable()); - break; case $ampersand: addSingleExpression(_selector()); - break; - case $single_quote: - case $double_quote: + case $single_quote || $double_quote: addSingleExpression(interpolatedString()); - break; case $hash: addSingleExpression(_hashExpression()); - break; case $equal: scanner.readChar(); @@ -1934,57 +1901,47 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($equal); addOperator(BinaryOperator.equals); } - break; case $exclamation: - var next = scanner.peekChar(1); - if (next == $equal) { - scanner.readChar(); - scanner.readChar(); - addOperator(BinaryOperator.notEquals); - } else if (next == null || - equalsLetterIgnoreCase($i, next) || - isWhitespace(next)) { - addSingleExpression(_importantExpression()); - } else { - break loop; + switch (scanner.peekChar(1)) { + case $equal: + scanner.readChar(); + scanner.readChar(); + addOperator(BinaryOperator.notEquals); + case null || $i || $I || int(isWhitespace: true): + addSingleExpression(_importantExpression()); + case _: + break loop; } - break; case $langle: scanner.readChar(); addOperator(scanner.scanChar($equal) ? BinaryOperator.lessThanOrEquals : BinaryOperator.lessThan); - break; case $rangle: scanner.readChar(); addOperator(scanner.scanChar($equal) ? BinaryOperator.greaterThanOrEquals : BinaryOperator.greaterThan); - break; case $asterisk: scanner.readChar(); addOperator(BinaryOperator.times); - break; + + case $plus when singleExpression_ == null: + addSingleExpression(_unaryOperation()); case $plus: - if (singleExpression_ == null) { - addSingleExpression(_unaryOperation()); - } else { - scanner.readChar(); - addOperator(BinaryOperator.plus); - } - break; + scanner.readChar(); + addOperator(BinaryOperator.plus); case $minus: - var next = scanner.peekChar(1); - if ((isDigit(next) || next == $dot) && + if (scanner.peekChar(1) case int(isDigit: true) || $dot // Make sure `1-2` parses as `1 - 2`, not `1 (-2)`. - (singleExpression_ == null || - isWhitespace(scanner.peekChar(-1)))) { + when singleExpression_ == null || + scanner.peekChar(-1).isWhitespace) { addSingleExpression(_number()); } else if (_lookingAtInterpolatedIdentifier()) { addSingleExpression(identifierLike()); @@ -1994,117 +1951,48 @@ abstract class StylesheetParser extends Parser { scanner.readChar(); addOperator(BinaryOperator.minus); } - break; + + case $slash when singleExpression_ == null: + addSingleExpression(_unaryOperation()); case $slash: - if (singleExpression_ == null) { - addSingleExpression(_unaryOperation()); - } else { - scanner.readChar(); - addOperator(BinaryOperator.dividedBy); - } - break; + scanner.readChar(); + addOperator(BinaryOperator.dividedBy); case $percent: scanner.readChar(); addOperator(BinaryOperator.modulo); - break; - case $0: - case $1: - case $2: - case $3: - case $4: - case $5: - case $6: - case $7: - case $8: - case $9: + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + case >= $0 && <= $9: addSingleExpression(_number()); - break; + + case $dot when scanner.peekChar(1) == $dot: + break loop; case $dot: - if (scanner.peekChar(1) == $dot) break loop; addSingleExpression(_number()); - break; - case $a: - if (!plainCss && scanIdentifier("and")) { - addOperator(BinaryOperator.and); - } else { - addSingleExpression(identifierLike()); - } - break; + case $a when !plainCss && scanIdentifier("and"): + addOperator(BinaryOperator.and); - case $o: - if (!plainCss && scanIdentifier("or")) { - addOperator(BinaryOperator.or); - } else { - addSingleExpression(identifierLike()); - } - break; + case $o when !plainCss && scanIdentifier("or"): + addOperator(BinaryOperator.or); - case $u: - case $U: - if (scanner.peekChar(1) == $plus) { - addSingleExpression(_unicodeRange()); - } else { - addSingleExpression(identifierLike()); - } - break; + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + case $u || $U when scanner.peekChar(1) == $plus: + addSingleExpression(_unicodeRange()); - case $b: - case $c: - case $d: - case $e: - case $f: - case $g: - case $h: - case $i: - case $j: - case $k: - case $l: - case $m: - case $n: - case $p: - case $q: - case $r: - case $s: - case $t: - case $v: - case $w: - case $x: - case $y: - case $z: - case $A: - case $B: - case $C: - case $D: - case $E: - case $F: - case $G: - case $H: - case $I: - case $J: - case $K: - case $L: - case $M: - case $N: - case $O: - case $P: - case $Q: - case $R: - case $S: - case $T: - case $V: - case $W: - case $X: - case $Y: - case $Z: - case $_: - case $backslash: + // ignore: non_constant_relational_pattern_expression + case (>= $a && <= $z) || + // ignore: non_constant_relational_pattern_expression + (>= $A && <= $Z) || + $_ || + $backslash || + >= 0x80: addSingleExpression(identifierLike()); - break; case $comma: // If we discover we're parsing a list whose first element is a @@ -2131,20 +2019,15 @@ abstract class StylesheetParser extends Parser { scanner.readChar(); allowSlash = true; singleExpression_ = null; - break; - default: - if (first != null && first >= 0x80) { - addSingleExpression(identifierLike()); - break; - } else { - break loop; - } + case _: + break loop; } } if (bracketList) scanner.expectChar($rbracket); + // TODO(dart-lang/sdk#52756): Use patterns to null-check these values. var commaExpressions = commaExpressions_; var spaceExpressions = spaceExpressions_; if (commaExpressions != null) { @@ -2186,119 +2069,36 @@ abstract class StylesheetParser extends Parser { (expression is BinaryOperationExpression && expression.allowsSlash); /// Consumes an expression that doesn't contain any top-level whitespace. - Expression _singleExpression() { - var first = scanner.peekChar(); - switch (first) { - // Note: when adding a new case, make sure it's reflected in - // [_lookingAtExpression] and [_expression]. - case $lparen: - return _parentheses(); - case $slash: - return _unaryOperation(); - case $dot: - return _number(); - case $lbracket: - return _expression(bracketList: true); - case $dollar: - return _variable(); - case $ampersand: - return _selector(); - - case $single_quote: - case $double_quote: - return interpolatedString(); - - case $hash: - return _hashExpression(); - - case $plus: - return _plusExpression(); - - case $minus: - return _minusExpression(); - - case $exclamation: - return _importantExpression(); - - case $u: - case $U: - if (scanner.peekChar(1) == $plus) { - return _unicodeRange(); - } else { - return identifierLike(); - } - - case $0: - case $1: - case $2: - case $3: - case $4: - case $5: - case $6: - case $7: - case $8: - case $9: - return _number(); - - case $a: - case $b: - case $c: - case $d: - case $e: - case $f: - case $g: - case $h: - case $i: - case $j: - case $k: - case $l: - case $m: - case $n: - case $o: - case $p: - case $q: - case $r: - case $s: - case $t: - case $v: - case $w: - case $x: - case $y: - case $z: - case $A: - case $B: - case $C: - case $D: - case $E: - case $F: - case $G: - case $H: - case $I: - case $J: - case $K: - case $L: - case $M: - case $N: - case $O: - case $P: - case $Q: - case $R: - case $S: - case $T: - case $V: - case $W: - case $X: - case $Y: - case $Z: - case $_: - case $backslash: - return identifierLike(); - - default: - if (first != null && first >= 0x80) return identifierLike(); - scanner.error("Expected expression."); - } - } + Expression _singleExpression() => switch (scanner.peekChar()) { + // Note: when adding a new case, make sure it's reflected in + // [_lookingAtExpression] and [_expression]. + null => scanner.error("Expected expression."), + $lparen => _parentheses(), + $slash => _unaryOperation(), + $dot => _number(), + $lbracket => _expression(bracketList: true), + $dollar => _variable(), + $ampersand => _selector(), + $single_quote || $double_quote => interpolatedString(), + $hash => _hashExpression(), + $plus => _plusExpression(), + $minus => _minusExpression(), + $exclamation => _importantExpression(), + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + $u || $U when scanner.peekChar(1) == $plus => _unicodeRange(), + // ignore: non_constant_relational_pattern_expression + >= $0 && <= $9 => _number(), + // ignore: non_constant_relational_pattern_expression + (>= $a && <= $z) || + // ignore: non_constant_relational_pattern_expression + (>= $A && <= $Z) || + $_ || + $backslash || + >= 0x80 => + identifierLike(), + _ => scanner.error("Expected expression.") + }; /// Consumes a parenthesized expression. Expression _parentheses() { @@ -2352,7 +2152,7 @@ abstract class StylesheetParser extends Parser { /// as the expression before the colon and [start] the point before the /// opening parenthesis. MapExpression _map(Expression first, LineScannerState start) { - var pairs = [Tuple2(first, expressionUntilComma())]; + var pairs = [(first, expressionUntilComma())]; while (scanner.scanChar($comma)) { whitespace(); @@ -2362,7 +2162,7 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($colon); whitespace(); var value = expressionUntilComma(); - pairs.add(Tuple2(key, value)); + pairs.add((key, value)); } scanner.expectChar($rparen); @@ -2377,8 +2177,7 @@ abstract class StylesheetParser extends Parser { var start = scanner.state; scanner.expectChar($hash); - var first = scanner.peekChar(); - if (first != null && isDigit(first)) { + if (scanner.peekChar()?.isDigit ?? false) { return ColorExpression(_hexColorContents(start), scanner.spanFrom(start)); } @@ -2405,14 +2204,14 @@ abstract class StylesheetParser extends Parser { int green; int blue; double? alpha; - if (!isHex(scanner.peekChar())) { + if (!scanner.peekChar().isHex) { // #abc red = (digit1 << 4) + digit1; green = (digit2 << 4) + digit2; blue = (digit3 << 4) + digit3; } else { var digit4 = _hexDigit(); - if (!isHex(scanner.peekChar())) { + if (!scanner.peekChar().isHex) { // #abcd red = (digit1 << 4) + digit1; green = (digit2 << 4) + digit2; @@ -2423,7 +2222,7 @@ abstract class StylesheetParser extends Parser { green = (digit3 << 4) + digit4; blue = (_hexDigit() << 4) + _hexDigit(); - if (isHex(scanner.peekChar())) { + if (scanner.peekChar().isHex) { alpha = ((_hexDigit() << 4) + _hexDigit()) / 0xff; } } @@ -2433,7 +2232,7 @@ abstract class StylesheetParser extends Parser { red, green, blue, - alpha, + alpha ?? 1, // Don't emit four- or eight-digit hex colors as hex, since that's not // yet well-supported in browsers. alpha == null ? SpanColorFormat(scanner.spanFrom(start)) : null); @@ -2443,35 +2242,29 @@ abstract class StylesheetParser extends Parser { /// hex color. bool _isHexColor(Interpolation interpolation) { var plain = interpolation.asPlain; - if (plain == null) return false; - if (plain.length != 3 && - plain.length != 4 && - plain.length != 6 && - plain.length != 8) { + if (plain case String(length: 3 || 4 || 6 || 8)) { + return plain.codeUnits.every((char) => char.isHex); + } else { return false; } - return plain.codeUnits.every(isHex); } // Consumes a single hexadecimal digit. - int _hexDigit() { - var char = scanner.peekChar(); - if (char == null || !isHex(char)) scanner.error("Expected hex digit."); - return asHex(scanner.readChar()); - } + int _hexDigit() => (scanner.peekChar()?.isHex ?? false) + ? asHex(scanner.readChar()) + : scanner.error("Expected hex digit."); /// Consumes an expression that starts with a `+`. Expression _plusExpression() { assert(scanner.peekChar() == $plus); var next = scanner.peekChar(1); - return isDigit(next) || next == $dot ? _number() : _unaryOperation(); + return next.isDigit || next == $dot ? _number() : _unaryOperation(); } /// Consumes an expression that starts with a `-`. Expression _minusExpression() { assert(scanner.peekChar() == $minus); - var next = scanner.peekChar(1); - if (isDigit(next) || next == $dot) return _number(); + if (scanner.peekChar(1) case int(isDigit: true) || $dot) return _number(); if (_lookingAtInterpolatedIdentifier()) return identifierLike(); return _unaryOperation(); } @@ -2505,18 +2298,12 @@ abstract class StylesheetParser extends Parser { /// Returns the unary operator corresponding to [character], or `null` if /// the character is not a unary operator. - UnaryOperator? _unaryOperatorFor(int character) { - switch (character) { - case $plus: - return UnaryOperator.plus; - case $minus: - return UnaryOperator.minus; - case $slash: - return UnaryOperator.divide; - default: - return null; - } - } + UnaryOperator? _unaryOperatorFor(int character) => switch (character) { + $plus => UnaryOperator.plus, + $minus => UnaryOperator.minus, + $slash => UnaryOperator.divide, + _ => null + }; /// Consumes a number expression. NumberExpression _number() { @@ -2555,11 +2342,11 @@ abstract class StylesheetParser extends Parser { /// /// Doesn't support scientific notation. void _consumeNaturalNumber() { - if (!isDigit(scanner.readChar())) { + if (!scanner.readChar().isDigit) { scanner.error("Expected digit.", position: scanner.position - 1); } - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { scanner.readChar(); } } @@ -2572,13 +2359,13 @@ abstract class StylesheetParser extends Parser { void _tryDecimal({bool allowTrailingDot = false}) { if (scanner.peekChar() != $dot) return; - if (!isDigit(scanner.peekChar(1))) { + if (!scanner.peekChar(1).isDigit) { if (allowTrailingDot) return; scanner.error("Expected digit.", position: scanner.position + 1); } scanner.readChar(); - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { scanner.readChar(); } } @@ -2589,13 +2376,13 @@ abstract class StylesheetParser extends Parser { if (first != $e && first != $E) return; var next = scanner.peekChar(1); - if (!isDigit(next) && next != $minus && next != $plus) return; + if (!next.isDigit && next != $minus && next != $plus) return; scanner.readChar(); - if (next == $plus || next == $minus) scanner.readChar(); - if (!isDigit(scanner.peekChar())) scanner.error("Expected digit."); + if (next case $plus || $minus) scanner.readChar(); + if (!scanner.peekChar().isDigit) scanner.error("Expected digit."); - while (isDigit(scanner.peekChar())) { + while (scanner.peekChar().isDigit) { scanner.readChar(); } } @@ -2607,7 +2394,7 @@ abstract class StylesheetParser extends Parser { scanner.expectChar($plus); var firstRangeLength = 0; - while (scanCharIf((char) => char != null && isHex(char))) { + while (scanCharIf((char) => char != null && char.isHex)) { firstRangeLength++; } @@ -2629,7 +2416,7 @@ abstract class StylesheetParser extends Parser { if (scanner.scanChar($minus)) { var secondRangeStart = scanner.state; var secondRangeLength = 0; - while (scanCharIf((char) => char != null && isHex(char))) { + while (scanCharIf((char) => char != null && char.isHex)) { secondRangeLength++; } @@ -2684,8 +2471,8 @@ abstract class StylesheetParser extends Parser { /// Consumes a quoted string expression. StringExpression interpolatedString() { - // NOTE: this logic is largely duplicated in ScssParser.interpolatedString. - // Most changes here should be mirrored there. + // NOTE: this logic is largely duplicated in Parser.string. Most changes + // here should be mirrored there. var start = scanner.state; var quote = scanner.readChar(); @@ -2695,30 +2482,27 @@ abstract class StylesheetParser extends Parser { } var buffer = InterpolationBuffer(); + loop: while (true) { - var next = scanner.peekChar(); - if (next == quote) { - scanner.readChar(); - break; - } else if (next == null || isNewline(next)) { - scanner.error("Expected ${String.fromCharCode(quote)}."); - } else if (next == $backslash) { - var second = scanner.peekChar(1); - if (isNewline(second)) { + switch (scanner.peekChar()) { + case var next when next == quote: scanner.readChar(); - scanner.readChar(); - if (second == $cr) scanner.scanChar($lf); - } else { - buffer.writeCharCode(escapeCharacter()); - } - } else if (next == $hash) { - if (scanner.peekChar(1) == $lbrace) { + break loop; + case null || int(isNewline: true): + scanner.error("Expected ${String.fromCharCode(quote)}."); + case $backslash: + var second = scanner.peekChar(1); + if (second.isNewline) { + scanner.readChar(); + scanner.readChar(); + if (second == $cr) scanner.scanChar($lf); + } else { + buffer.writeCharCode(escapeCharacter()); + } + case $hash when scanner.peekChar(1) == $lbrace: buffer.add(singleInterpolation()); - } else { + case _: buffer.writeCharCode(scanner.readChar()); - } - } else { - buffer.writeCharCode(scanner.readChar()); } } @@ -2756,38 +2540,39 @@ abstract class StylesheetParser extends Parser { return BooleanExpression(true, identifier.span); } - var color = colorsByName[lower]; - if (color != null) { + if (colorsByName[lower] case var color?) { color = SassColor.rgbInternal(color.red, color.green, color.blue, color.alpha, SpanColorFormat(identifier.span)); return ColorExpression(color, identifier.span); } } - var specialFunction = trySpecialFunction(lower, start); - if (specialFunction != null) return specialFunction; + if (trySpecialFunction(lower, start) case var specialFunction?) { + return specialFunction; + } } switch (scanner.peekChar()) { + case $dot when scanner.peekChar(1) == $dot: + return StringExpression(identifier); + case $dot: - if (scanner.peekChar(1) == $dot) return StringExpression(identifier); scanner.readChar(); - + // TODO(dart-lang/sdk#52757): Make this a separate case. if (plain != null) return namespacedExpression(plain, start); error("Interpolation isn't allowed in namespaces.", identifier.span); + case $lparen when plain != null: + return FunctionExpression( + plain, + _argumentInvocation(allowEmptySecondArg: lower == 'var'), + scanner.spanFrom(start)); + case $lparen: - if (plain == null) { - return InterpolatedFunctionExpression( - identifier, _argumentInvocation(), scanner.spanFrom(start)); - } else { - return FunctionExpression( - plain, - _argumentInvocation(allowEmptySecondArg: lower == 'var'), - scanner.spanFrom(start)); - } + return InterpolatedFunctionExpression( + identifier, _argumentInvocation(), scanner.spanFrom(start)); - default: + case _: return StringExpression(identifier); } } @@ -2816,42 +2601,38 @@ abstract class StylesheetParser extends Parser { /// [name]. @protected Expression? trySpecialFunction(String name, LineScannerState start) { - var calculation = - scanner.peekChar() == $lparen ? _tryCalculation(name, start) : null; - if (calculation != null) return calculation; + if (scanner.peekChar() == $lparen) { + if (_tryCalculation(name, start) case var calculation?) { + return calculation; + } + } var normalized = unvendor(name); InterpolationBuffer buffer; switch (normalized) { - case "calc": - case "element": - case "expression": - if (!scanner.scanChar($lparen)) return null; + case "calc" || "element" || "expression" when scanner.scanChar($lparen): buffer = InterpolationBuffer() ..write(name) ..writeCharCode($lparen); - break; - case "progid": - if (!scanner.scanChar($colon)) return null; + case "progid" when scanner.scanChar($colon): buffer = InterpolationBuffer() ..write(name) ..writeCharCode($colon); var next = scanner.peekChar(); - while (next != null && (isAlphabetic(next) || next == $dot)) { + while (next != null && (next.isAlphabetic || next == $dot)) { buffer.writeCharCode(scanner.readChar()); next = scanner.peekChar(); } scanner.expectChar($lparen); buffer.writeCharCode($lparen); - break; case "url": return _tryUrlContents(start) .andThen((contents) => StringExpression(contents)); - default: + case _: return null; } @@ -2874,8 +2655,7 @@ abstract class StylesheetParser extends Parser { var arguments = _calculationArguments(1); return CalculationExpression(name, arguments, scanner.spanFrom(start)); - case "min": - case "max": + case "min" || "max": // min() and max() are parsed as calculations if possible, and otherwise // are parsed as normal Sass functions. var beforeArguments = scanner.state; @@ -2893,7 +2673,7 @@ abstract class StylesheetParser extends Parser { var arguments = _calculationArguments(3); return CalculationExpression(name, arguments, scanner.spanFrom(start)); - default: + case _: return null; } } @@ -2905,8 +2685,7 @@ abstract class StylesheetParser extends Parser { /// Otherwise, any number greater than zero are consumed. List _calculationArguments([int? maxArgs]) { scanner.expectChar($lparen); - var interpolation = _tryCalculationInterpolation(); - if (interpolation != null) { + if (_tryCalculationInterpolation() case var interpolation?) { scanner.expectChar($rparen); return [interpolation]; } @@ -2933,22 +2712,20 @@ abstract class StylesheetParser extends Parser { while (true) { var next = scanner.peekChar(); - if (next == $plus || next == $minus) { - if (!isWhitespace(scanner.peekChar(-1)) || - !isWhitespace(scanner.peekChar(1))) { - scanner.error( - '"+" and "-" must be surrounded by whitespace in calculations.'); - } + if (next != $plus && next != $minus) return sum; - scanner.readChar(); - whitespace(); - sum = BinaryOperationExpression( - next == $plus ? BinaryOperator.plus : BinaryOperator.minus, - sum, - _calculationProduct()); - } else { - return sum; + if (!scanner.peekChar(-1).isWhitespace || + !scanner.peekChar(1).isWhitespace) { + scanner.error( + '"+" and "-" must be surrounded by whitespace in calculations.'); } + + scanner.readChar(); + whitespace(); + sum = BinaryOperationExpression( + next == $plus ? BinaryOperator.plus : BinaryOperator.minus, + sum, + _calculationProduct()); } } @@ -2959,57 +2736,64 @@ abstract class StylesheetParser extends Parser { while (true) { whitespace(); var next = scanner.peekChar(); - if (next == $asterisk || next == $slash) { - scanner.readChar(); - whitespace(); - product = BinaryOperationExpression( - next == $asterisk ? BinaryOperator.times : BinaryOperator.dividedBy, - product, - _calculationValue()); - } else { - return product; - } + if (next != $asterisk && next != $slash) return product; + + scanner.readChar(); + whitespace(); + product = BinaryOperationExpression( + next == $asterisk ? BinaryOperator.times : BinaryOperator.dividedBy, + product, + _calculationValue()); } } /// Parses a single calculation value. Expression _calculationValue() { - var next = scanner.peekChar(); - if (next == $plus || next == $minus || next == $dot || isDigit(next)) { - return _number(); - } else if (next == $dollar) { - return _variable(); - } else if (next == $lparen) { - var start = scanner.state; - scanner.readChar(); + switch (scanner.peekChar()) { + case $plus || $dot || int(isDigit: true): + return _number(); + case $dollar: + return _variable(); + case $lparen: + var start = scanner.state; + scanner.readChar(); + + Expression? value = _tryCalculationInterpolation(); + if (value == null) { + whitespace(); + value = _calculationSum(); + } - Expression? value = _tryCalculationInterpolation(); - if (value == null) { whitespace(); - value = _calculationSum(); - } + scanner.expectChar($rparen); + return ParenthesizedExpression(value, scanner.spanFrom(start)); + case _ when lookingAtIdentifier(): + var start = scanner.state; + var ident = identifier(); + if (scanner.scanChar($dot)) return namespacedExpression(ident, start); + if (scanner.peekChar() != $lparen) { + return StringExpression( + Interpolation([ident], scanner.spanFrom(start)), + quotes: false); + } - whitespace(); - scanner.expectChar($rparen); - return ParenthesizedExpression(value, scanner.spanFrom(start)); - } else if (!lookingAtIdentifier()) { - scanner.error("Expected number, variable, function, or calculation."); - } else { - var start = scanner.state; - var ident = identifier(); - if (scanner.scanChar($dot)) return namespacedExpression(ident, start); - if (scanner.peekChar() != $lparen) scanner.error('Expected "(" or ".".'); + var lowerCase = ident.toLowerCase(); + if (_tryCalculation(lowerCase, start) case var calculation?) { + return calculation; + } else if (lowerCase == "if") { + return IfExpression(_argumentInvocation(), scanner.spanFrom(start)); + } else { + return FunctionExpression( + ident, _argumentInvocation(), scanner.spanFrom(start)); + } - var lowerCase = ident.toLowerCase(); - var calculation = _tryCalculation(lowerCase, start); - if (calculation != null) { - return calculation; - } else if (lowerCase == "if") { - return IfExpression(_argumentInvocation(), scanner.spanFrom(start)); - } else { - return FunctionExpression( - ident, _argumentInvocation(), scanner.spanFrom(start)); - } + // This has to go after [lookingAtIdentifier] because a hyphen can start + // an identifier as well as a number. + case $minus: + return _number(); + + case _: + scanner.error("Expected number, variable, function, or calculation."); } } @@ -3034,16 +2818,12 @@ abstract class StylesheetParser extends Parser { case $backslash: scanner.readChar(); scanner.readChar(); - break; case $slash: if (!scanComment()) scanner.readChar(); - break; - case $single_quote: - case $double_quote: + case $single_quote || $double_quote: interpolatedString(); - break; case $hash: if (parens == 0 && scanner.peekChar(1) == $lbrace) { @@ -3051,7 +2831,6 @@ abstract class StylesheetParser extends Parser { return true; } scanner.readChar(); - break; case $lparen: parens++; @@ -3063,7 +2842,6 @@ abstract class StylesheetParser extends Parser { // dart-lang/sdk#45357 brackets.add(opposite(next!)); scanner.readChar(); - break; case $rparen: parens--; @@ -3077,9 +2855,8 @@ abstract class StylesheetParser extends Parser { return false; } scanner.readChar(); - break; - default: + case _: scanner.readChar(); } } @@ -3105,32 +2882,32 @@ abstract class StylesheetParser extends Parser { var buffer = InterpolationBuffer() ..write(name ?? 'url') ..writeCharCode($lparen); + loop: while (true) { - var next = scanner.peekChar(); - if (next == null) { - break; - } else if (next == $backslash) { - buffer.write(escape()); - } else if (next == $exclamation || - next == $percent || - next == $ampersand || - (next >= $asterisk && next <= $tilde) || - next >= 0x0080) { - buffer.writeCharCode(scanner.readChar()); - } else if (next == $hash) { - if (scanner.peekChar(1) == $lbrace) { + switch (scanner.peekChar()) { + case null: + break loop; + case $backslash: + buffer.write(escape()); + case $hash when scanner.peekChar(1) == $lbrace: buffer.add(singleInterpolation()); - } else { + case $exclamation || + $percent || + $ampersand || + $hash || + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + (>= $asterisk && <= $tilde) || + >= 0x80: buffer.writeCharCode(scanner.readChar()); - } - } else if (isWhitespace(next)) { - whitespaceWithoutComments(); - if (scanner.peekChar() != $rparen) break; - } else if (next == $rparen) { - buffer.writeCharCode(scanner.readChar()); - return buffer.interpolation(scanner.spanFrom(start)); - } else { - break; + case int(isWhitespace: true): + whitespaceWithoutComments(); + if (scanner.peekChar() != $rparen) break loop; + case $rparen: + buffer.writeCharCode(scanner.readChar()); + return buffer.interpolation(scanner.spanFrom(start)); + case _: + break loop; } } @@ -3143,8 +2920,9 @@ abstract class StylesheetParser extends Parser { Expression dynamicUrl() { var start = scanner.state; expectIdentifier("url"); - var contents = _tryUrlContents(start); - if (contents != null) return StringExpression(contents); + if (_tryUrlContents(start) case var contents?) { + return StringExpression(contents); + } return InterpolatedFunctionExpression( Interpolation(["url"], scanner.spanFrom(start)), @@ -3177,18 +2955,14 @@ abstract class StylesheetParser extends Parser { loop: while (true) { - var next = scanner.peekChar(); - switch (next) { + switch (scanner.peekChar()) { case $backslash: // Write a literal backslash because this text will be re-parsed. buffer.writeCharCode(scanner.readChar()); buffer.writeCharCode(scanner.readChar()); - break; - case $double_quote: - case $single_quote: + case $double_quote || $single_quote: buffer.addInterpolation(interpolatedString().asInterpolation()); - break; case $slash: var commentStart = scanner.position; @@ -3197,57 +2971,41 @@ abstract class StylesheetParser extends Parser { } else { buffer.writeCharCode(scanner.readChar()); } - break; - case $hash: - if (scanner.peekChar(1) == $lbrace) { - // Add a full interpolated identifier to handle cases like - // "#{...}--1", since "--1" isn't a valid identifier on its own. - buffer.addInterpolation(interpolatedIdentifier()); - } else { - buffer.writeCharCode(scanner.readChar()); - } - break; + case $hash when scanner.peekChar(1) == $lbrace: + // Add a full interpolated identifier to handle cases like + // "#{...}--1", since "--1" isn't a valid identifier on its own. + buffer.addInterpolation(interpolatedIdentifier()); - case $cr: - case $lf: - case $ff: + case $cr || $lf || $ff: if (indented) break loop; buffer.writeCharCode(scanner.readChar()); - break; - case $exclamation: - case $semicolon: - case $lbrace: - case $rbrace: + case $exclamation || $semicolon || $lbrace || $rbrace: break loop; - case $u: - case $U: + case $u || $U: var beforeUrl = scanner.state; if (!scanIdentifier("url")) { buffer.writeCharCode(scanner.readChar()); - break; + continue loop; } - var contents = _tryUrlContents(beforeUrl); - if (contents == null) { + if (_tryUrlContents(beforeUrl) case var contents?) { + buffer.addInterpolation(contents); + } else { scanner.state = beforeUrl; buffer.writeCharCode(scanner.readChar()); - } else { - buffer.addInterpolation(contents); } - break; - default: - if (next == null) break loop; + case null: + break loop; - if (lookingAtIdentifier()) { - buffer.write(identifier()); - } else { - buffer.writeCharCode(scanner.readChar()); - } - break; + case _ when lookingAtIdentifier(): + buffer.write(identifier()); + + case _: + buffer.writeCharCode(scanner.readChar()); } } @@ -3279,115 +3037,92 @@ abstract class StylesheetParser extends Parser { var wroteNewline = false; loop: while (true) { - var next = scanner.peekChar(); - switch (next) { + switch (scanner.peekChar()) { case $backslash: buffer.write(escape(identifierStart: true)); wroteNewline = false; - break; - case $double_quote: - case $single_quote: + case $double_quote || $single_quote: buffer.addInterpolation(interpolatedString().asInterpolation()); wroteNewline = false; - break; - case $slash: - if (scanner.peekChar(1) == $asterisk) { - buffer.write(rawText(loudComment)); - } else { - buffer.writeCharCode(scanner.readChar()); - } + case $slash when scanner.peekChar(1) == $asterisk: + buffer.write(rawText(loudComment)); wroteNewline = false; - break; - case $hash: - if (scanner.peekChar(1) == $lbrace) { - // Add a full interpolated identifier to handle cases like - // "#{...}--1", since "--1" isn't a valid identifier on its own. - buffer.addInterpolation(interpolatedIdentifier()); - } else { - buffer.writeCharCode(scanner.readChar()); - } + // Add a full interpolated identifier to handle cases like "#{...}--1", + // since "--1" isn't a valid identifier on its own. + case $hash when scanner.peekChar(1) == $lbrace: + buffer.addInterpolation(interpolatedIdentifier()); wroteNewline = false; - break; - case $space: - case $tab: - if (wroteNewline || !isWhitespace(scanner.peekChar(1))) { - buffer.writeCharCode(scanner.readChar()); - } else { - scanner.readChar(); - } - break; + case $space || $tab + when !wroteNewline && scanner.peekChar(1).isWhitespace: + // Collapse whitespace into a single character unless it's following a + // newline, in which case we assume it's indentation. + scanner.readChar(); - case $lf: - case $cr: - case $ff: - if (indented) break loop; - if (!isNewline(scanner.peekChar(-1))) buffer.writeln(); + case $space || $tab: + buffer.writeCharCode(scanner.readChar()); + + case $lf || $cr || $ff when indented: + break loop; + + case $lf || $cr || $ff: + // Collapse multiple newlines into one. + if (!scanner.peekChar(-1).isNewline) buffer.writeln(); scanner.readChar(); wroteNewline = true; - break; - case $lparen: - case $lbrace: - case $lbracket: - buffer.writeCharCode(next!); // dart-lang/sdk#45357 - brackets.add(opposite(scanner.readChar())); + case $lparen || $lbrace || $lbracket: + var bracket = scanner.readChar(); + buffer.writeCharCode(bracket); + brackets.add(opposite(bracket)); wroteNewline = false; - break; - case $rparen: - case $rbrace: - case $rbracket: + case $rparen || $rbrace || $rbracket: if (brackets.isEmpty) break loop; - buffer.writeCharCode(next!); // dart-lang/sdk#45357 - scanner.expectChar(brackets.removeLast()); + var bracket = brackets.removeLast(); + scanner.expectChar(bracket); + buffer.writeCharCode(bracket); wroteNewline = false; - break; case $semicolon: if (!allowSemicolon && brackets.isEmpty) break loop; buffer.writeCharCode(scanner.readChar()); wroteNewline = false; - break; case $colon: if (!allowColon && brackets.isEmpty) break loop; buffer.writeCharCode(scanner.readChar()); wroteNewline = false; - break; - case $u: - case $U: + case $u || $U: var beforeUrl = scanner.state; if (!scanIdentifier("url")) { buffer.writeCharCode(scanner.readChar()); wroteNewline = false; - break; + continue loop; } - var contents = _tryUrlContents(beforeUrl); - if (contents == null) { + if (_tryUrlContents(beforeUrl) case var contents?) { + buffer.addInterpolation(contents); + } else { scanner.state = beforeUrl; buffer.writeCharCode(scanner.readChar()); - } else { - buffer.addInterpolation(contents); } wroteNewline = false; - break; - default: - if (next == null) break loop; + case null: + break loop; - if (lookingAtIdentifier()) { - buffer.write(identifier()); - } else { - buffer.writeCharCode(scanner.readChar()); - } + case _ when lookingAtIdentifier(): + buffer.write(identifier()); + wroteNewline = false; + + case _: + buffer.writeCharCode(scanner.readChar()); wroteNewline = false; - break; } } @@ -3412,17 +3147,17 @@ abstract class StylesheetParser extends Parser { } } - var first = scanner.peekChar(); - if (first == null) { - scanner.error("Expected identifier."); - } else if (isNameStart(first)) { - buffer.writeCharCode(scanner.readChar()); - } else if (first == $backslash) { - buffer.write(escape(identifierStart: true)); - } else if (first == $hash && scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); - } else { - scanner.error("Expected identifier."); + switch (scanner.peekChar()) { + case null: + scanner.error("Expected identifier."); + case int(isNameStart: true): + buffer.writeCharCode(scanner.readChar()); + case $backslash: + buffer.write(escape(identifierStart: true)); + case $hash when scanner.peekChar(1) == $lbrace: + buffer.add(singleInterpolation()); + case _: + scanner.error("Expected identifier."); } _interpolatedIdentifierBody(buffer); @@ -3432,21 +3167,19 @@ abstract class StylesheetParser extends Parser { /// Consumes a chunk of a possibly-interpolated CSS identifier after the name /// start, and adds the contents to the [buffer] buffer. void _interpolatedIdentifierBody(InterpolationBuffer buffer) { + loop: while (true) { - var next = scanner.peekChar(); - if (next == null) { - break; - } else if (next == $underscore || - next == $dash || - isAlphanumeric(next) || - next >= 0x0080) { - buffer.writeCharCode(scanner.readChar()); - } else if (next == $backslash) { - buffer.write(escape()); - } else if (next == $hash && scanner.peekChar(1) == $lbrace) { - buffer.add(singleInterpolation()); - } else { - break; + switch (scanner.peekChar()) { + case null: + break loop; + case $underscore || $dash || int(isAlphanumeric: true) || >= 0x80: + buffer.writeCharCode(scanner.readChar()); + case $backslash: + buffer.write(escape()); + case $hash when scanner.peekChar(1) == $lbrace: + buffer.add(singleInterpolation()); + case _: + break loop; } } } @@ -3616,11 +3349,10 @@ abstract class StylesheetParser extends Parser { buffer.add(_expression()); } else { var next = scanner.peekChar(); - if (next == $langle || next == $rangle || next == $equal) { + if (next case $langle || $rangle || $equal) { buffer.writeCharCode($space); buffer.writeCharCode(scanner.readChar()); - if ((next == $langle || next == $rangle) && - scanner.scanChar($equal)) { + if (next case $langle || $rangle when scanner.scanChar($equal)) { buffer.writeCharCode($equal); } buffer.writeCharCode($space); @@ -3628,9 +3360,8 @@ abstract class StylesheetParser extends Parser { whitespace(); buffer.add(_expressionUntilComparison()); - if ((next == $langle || next == $rangle) && - // dart-lang/sdk#45356 - scanner.scanChar(next!)) { + // dart-lang/sdk#45356 + if (next case $langle || $rangle when scanner.scanChar(next!)) { buffer.writeCharCode($space); buffer.writeCharCode(next); if (scanner.scanChar($equal)) buffer.writeCharCode($equal); @@ -3650,11 +3381,12 @@ abstract class StylesheetParser extends Parser { /// Consumes an expression until it reaches a top-level `<`, `>`, or a `=` /// that's not `==`. - Expression _expressionUntilComparison() => _expression(until: () { - var next = scanner.peekChar(); - if (next == $equal) return scanner.peekChar(1) != $equal; - return next == $langle || next == $rangle; - }); + Expression _expressionUntilComparison() => _expression( + until: () => switch (scanner.peekChar()) { + $equal => scanner.peekChar(1) != $equal, + $langle || $rangle => true, + _ => false + }); // ## Supports Conditions @@ -3704,12 +3436,10 @@ abstract class StylesheetParser extends Parser { allowEmpty: true, allowSemicolon: true); scanner.expectChar($rparen); return SupportsFunction(identifier, arguments, scanner.spanFrom(start)); - } else if (identifier.contents.length != 1 || - identifier.contents.first is! Expression) { - error("Expected @supports condition.", identifier.span); + } else if (identifier.contents case [Expression expression]) { + return SupportsInterpolation(expression, scanner.spanFrom(start)); } else { - return SupportsInterpolation( - identifier.contents.first as Expression, scanner.spanFrom(start)); + error("Expected @supports condition.", identifier.span); } } @@ -3751,8 +3481,7 @@ abstract class StylesheetParser extends Parser { _inParentheses = wasInParentheses; var identifier = interpolatedIdentifier(); - var operation = _trySupportsOperation(identifier, nameStart); - if (operation != null) { + if (_trySupportsOperation(identifier, nameStart) case var operation?) { scanner.expectChar($rparen); return operation; } @@ -3782,9 +3511,8 @@ abstract class StylesheetParser extends Parser { SupportsDeclaration _supportsDeclarationValue( Expression name, LineScannerState start) { Expression value; - if (name is StringExpression && - !name.hasQuotes && - name.text.initialPlain.startsWith("--")) { + if (name case StringExpression(hasQuotes: false, :var text) + when text.initialPlain.startsWith("--")) { value = StringExpression(_interpolatedDeclarationValue()); } else { whitespace(); @@ -3841,58 +3569,65 @@ abstract class StylesheetParser extends Parser { /// start escapes and it considers interpolation to be valid in an identifier. /// /// [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#would-start-an-identifier - bool _lookingAtInterpolatedIdentifier() { - // See also [ScssParser._lookingAtIdentifier]. - - var first = scanner.peekChar(); - if (first == null) return false; - if (isNameStart(first) || first == $backslash) return true; - if (first == $hash) return scanner.peekChar(1) == $lbrace; - - if (first != $dash) return false; - var second = scanner.peekChar(1); - if (second == null) return false; - if (second == $hash) return scanner.peekChar(2) == $lbrace; - return isNameStart(second) || second == $backslash || second == $dash; - } + bool _lookingAtInterpolatedIdentifier() => + // See also [ScssParser._lookingAtIdentifier]. + + switch (scanner.peekChar()) { + null => false, + int(isNameStart: true) || $backslash => true, + $hash => scanner.peekChar(1) == $lbrace, + $dash => switch (scanner.peekChar(1)) { + null => false, + $hash => scanner.peekChar(2) == $lbrace, + int(isNameStart: true) || $backslash || $dash => true, + _ => false + }, + _ => false + }; + + /// Returns whether the scanner is immediately before a character that could + /// start a `*prop: val`, `:prop: val`, `#prop: val`, or `.prop: val` hack. + bool _lookingAtPotentialPropertyHack() => switch (scanner.peekChar()) { + $colon || $asterisk || $dot => true, + $hash => scanner.peekChar(1) != $lbrace, + _ => false + }; /// Returns whether the scanner is immediately before a sequence of characters /// that could be part of an CSS identifier body. /// /// The identifier body may include interpolation. - bool _lookingAtInterpolatedIdentifierBody() { - var first = scanner.peekChar(); - if (first == null) return false; - if (isName(first) || first == $backslash) return true; - return first == $hash && scanner.peekChar(1) == $lbrace; - } + bool _lookingAtInterpolatedIdentifierBody() => switch (scanner.peekChar()) { + null => false, + int(isName: true) || $backslash => true, + $hash => scanner.peekChar(1) == $lbrace, + _ => false + }; /// Returns whether the scanner is immediately before a SassScript expression. - bool _lookingAtExpression() { - var character = scanner.peekChar(); - if (character == null) return false; - if (character == $dot) return scanner.peekChar(1) != $dot; - if (character == $exclamation) { - var next = scanner.peekChar(1); - return next == null || - equalsLetterIgnoreCase($i, next) || - isWhitespace(next); - } - - return character == $lparen || - character == $slash || - character == $lbracket || - character == $single_quote || - character == $double_quote || - character == $hash || - character == $plus || - character == $minus || - character == $backslash || - character == $dollar || - character == $ampersand || - isNameStart(character) || - isDigit(character); - } + bool _lookingAtExpression() => switch (scanner.peekChar()) { + null => false, + $dot => scanner.peekChar(1) != $dot, + $exclamation => switch (scanner.peekChar(1)) { + null || $i || $I || int(isWhitespace: true) => true, + _ => false + }, + $lparen || + $slash || + $lbracket || + $single_quote || + $double_quote || + $hash || + $plus || + $minus || + $backslash || + $dollar || + $ampersand || + int(isNameStart: true) || + int(isDigit: true) => + true, + _ => false + }; // ## Utilities diff --git a/lib/src/stylesheet_graph.dart b/lib/src/stylesheet_graph.dart index 0b19cb76e..3109fc5f0 100644 --- a/lib/src/stylesheet_graph.dart +++ b/lib/src/stylesheet_graph.dart @@ -4,14 +4,24 @@ import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; -import 'package:tuple/tuple.dart'; import 'ast/sass.dart'; import 'import_cache.dart'; import 'importer.dart'; +import 'util/map.dart'; import 'util/nullable.dart'; import 'visitor/find_dependencies.dart'; +/// Maps from non-canonicalized imported URLs in [stylesheet] to nodes, which +/// appears within [baseUrl] imported by [baseImporter]. +/// +/// [modules] contains stylesheets depended on by module loads, while [imports] +/// contains those depended on via `@import`. +typedef _UpstreamNodes = ({ + Map modules, + Map imports +}); + /// A graph of the import relationships between stylesheets, available via /// [nodes]. class StylesheetGraph { @@ -66,12 +76,14 @@ class StylesheetGraph { /// /// Returns `null` if the import cache can't find a stylesheet at [url]. StylesheetNode? _add(Uri url, [Importer? baseImporter, Uri? baseUrl]) { - var tuple = _ignoreErrors(() => importCache.canonicalize(url, + var result = _ignoreErrors(() => importCache.canonicalize(url, baseImporter: baseImporter, baseUrl: baseUrl)); - if (tuple == null) return null; - - addCanonical(tuple.item1, tuple.item2, tuple.item3); - return nodes[tuple.item2]; + if (result case (var importer, var canonicalUrl, :var originalUrl)) { + addCanonical(importer, canonicalUrl, originalUrl); + return nodes[canonicalUrl]; + } else { + return null; + } } /// Adds the stylesheet at the canonicalized [canonicalUrl] and all the @@ -92,14 +104,13 @@ class StylesheetGraph { Set addCanonical( Importer importer, Uri canonicalUrl, Uri originalUrl, {bool recanonicalize = true}) { - var node = _nodes[canonicalUrl]; - if (node != null) return const {}; + if (_nodes[canonicalUrl] != null) return const {}; var stylesheet = _ignoreErrors(() => importCache .importCanonical(importer, canonicalUrl, originalUrl: originalUrl)); if (stylesheet == null) return const {}; - node = StylesheetNode._(stylesheet, importer, canonicalUrl, + var node = StylesheetNode._(stylesheet, importer, canonicalUrl, _upstreamNodes(stylesheet, importer, canonicalUrl)); _nodes[canonicalUrl] = node; @@ -111,19 +122,22 @@ class StylesheetGraph { /// Returns two maps from non-canonicalized imported URLs in [stylesheet] to /// nodes, which appears within [baseUrl] imported by [baseImporter]. /// - /// The first map contains stylesheets depended on via `@use` and `@forward` - /// while the second map contains those depended on via `@import`. - Tuple2, Map> _upstreamNodes( + /// The first map contains stylesheets depended on via module loads while the + /// second map contains those depended on via `@import`. + _UpstreamNodes _upstreamNodes( Stylesheet stylesheet, Importer baseImporter, Uri baseUrl) { var active = {baseUrl}; - var tuple = findDependencies(stylesheet); - return Tuple2({ - for (var url in tuple.item1) - url: _nodeFor(url, baseImporter, baseUrl, active) - }, { - for (var url in tuple.item2) - url: _nodeFor(url, baseImporter, baseUrl, active, forImport: true) - }); + var dependencies = findDependencies(stylesheet); + return ( + modules: { + for (var url in dependencies.modules) + url: _nodeFor(url, baseImporter, baseUrl, active) + }, + imports: { + for (var url in dependencies.imports) + url: _nodeFor(url, baseImporter, baseUrl, active, forImport: true) + } + ); } /// Re-parses the stylesheet at [canonicalUrl] and updates the dependency graph @@ -151,7 +165,7 @@ class StylesheetGraph { node._stylesheet = stylesheet; var upstream = _upstreamNodes(stylesheet, node.importer, canonicalUrl); - node._replaceUpstream(upstream.item1, upstream.item2); + node._replaceUpstream(upstream.modules, upstream.imports); return true; } @@ -226,13 +240,13 @@ class StylesheetGraph { {required bool forImport}) { var map = forImport ? node.upstreamImports : node.upstream; var newMap = {}; - map.forEach((url, upstream) { - if (!importer.couldCanonicalize(url, canonicalUrl)) return; + for (var (url, upstream) in map.pairs) { + if (!importer.couldCanonicalize(url, canonicalUrl)) continue; importCache.clearCanonicalize(url); // If the import produces a different canonicalized URL than it did // before, it changed and the stylesheet needs to be recompiled. - Tuple3? result; + CanonicalizeResult? result; try { result = importCache.canonicalize(url, baseImporter: node.importer, @@ -244,11 +258,11 @@ class StylesheetGraph { // recompiled. } - var newCanonicalUrl = result?.item2; - if (newCanonicalUrl == upstream?.canonicalUrl) return; + var newCanonicalUrl = result?.$2; + if (newCanonicalUrl == upstream?.canonicalUrl) continue; - newMap[url] = result == null ? null : nodes[result.item2]; - }); + newMap[url] = result == null ? null : nodes[newCanonicalUrl]; + } return newMap; } @@ -260,26 +274,24 @@ class StylesheetGraph { StylesheetNode? _nodeFor( Uri url, Importer baseImporter, Uri baseUrl, Set active, {bool forImport = false}) { - var tuple = _ignoreErrors(() => importCache.canonicalize(url, + var result = _ignoreErrors(() => importCache.canonicalize(url, baseImporter: baseImporter, baseUrl: baseUrl, forImport: forImport)); // If an import fails, let the evaluator surface that error rather than // surfacing it here. - if (tuple == null) return null; - var importer = tuple.item1; - var canonicalUrl = tuple.item2; - var resolvedUrl = tuple.item3; + if (result == null) return null; + var (importer, canonicalUrl, :originalUrl) = result; // Don't use [putIfAbsent] here because we want to avoid adding an entry if // the import fails. - if (_nodes.containsKey(canonicalUrl)) return _nodes[canonicalUrl]; + if (_nodes[canonicalUrl] case var node?) return node; /// If we detect a circular import, act as though it doesn't exist. A better /// error will be produced during compilation. if (active.contains(canonicalUrl)) return null; var stylesheet = _ignoreErrors(() => importCache - .importCanonical(importer, canonicalUrl, originalUrl: resolvedUrl)); + .importCanonical(importer, canonicalUrl, originalUrl: originalUrl)); if (stylesheet == null) return null; active.add(canonicalUrl); @@ -341,11 +353,11 @@ class StylesheetNode { final _downstream = {}; StylesheetNode._(this._stylesheet, this.importer, this.canonicalUrl, - Tuple2, Map> allUpstream) - : _upstream = allUpstream.item1, - _upstreamImports = allUpstream.item2 { + _UpstreamNodes allUpstream) + : _upstream = allUpstream.modules, + _upstreamImports = allUpstream.imports { for (var node in upstream.values.followedBy(upstreamImports.values)) { - if (node != null) node._downstream.add(this); + node?._downstream.add(this); } } diff --git a/lib/src/syntax.dart b/lib/src/syntax.dart index 9c737c957..ab9fc9557 100644 --- a/lib/src/syntax.dart +++ b/lib/src/syntax.dart @@ -18,16 +18,11 @@ enum Syntax { css('CSS'); /// Returns the default syntax to use for a file loaded from [path]. - static Syntax forPath(String path) { - switch (p.extension(path)) { - case '.sass': - return Syntax.sass; - case '.css': - return Syntax.css; - default: - return Syntax.scss; - } - } + static Syntax forPath(String path) => switch (p.extension(path)) { + '.sass' => Syntax.sass, + '.css' => Syntax.css, + _ => Syntax.scss + }; /// The name of the syntax. final String _name; diff --git a/lib/src/util/box.dart b/lib/src/util/box.dart new file mode 100644 index 000000000..cfd076669 --- /dev/null +++ b/lib/src/util/box.dart @@ -0,0 +1,34 @@ +// 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. + +/// An unmodifiable reference to a value that may be mutated elsewhere. +/// +/// This uses reference equality based on the underlying [ModifiableBox], even +/// when the underlying type uses value equality. +class Box { + final ModifiableBox _inner; + + T get value => _inner.value; + + Box._(this._inner); + + bool operator ==(Object? other) => other is Box && other._inner == _inner; + + int get hashCode => _inner.hashCode; +} + +/// A mutable reference to a (presumably immutable) value. +/// +/// This always uses reference equality, even when the underlying type uses +/// value equality. +class ModifiableBox { + T value; + + ModifiableBox(this.value); + + /// Returns an unmodifiable reference to this box. + /// + /// The underlying modifiable box may still be modified. + Box seal() => Box._(this); +} diff --git a/lib/src/util/character.dart b/lib/src/util/character.dart index 0282fda43..ea4085d29 100644 --- a/lib/src/util/character.dart +++ b/lib/src/util/character.dart @@ -10,69 +10,79 @@ import 'package:charcode/charcode.dart'; /// lowercase equivalents. const _asciiCaseBit = 0x20; -/// Returns whether [character] is an ASCII whitespace character. -bool isWhitespace(int? character) => - isSpaceOrTab(character) || isNewline(character); - -/// Returns whether [character] is an ASCII newline. -bool isNewline(int? character) => - character == $lf || character == $cr || character == $ff; - -/// Returns whether [character] is a space or a tab character. -bool isSpaceOrTab(int? character) => character == $space || character == $tab; - -/// Returns whether [character] is a letter or number. -bool isAlphanumeric(int character) => - isAlphabetic(character) || isDigit(character); - -/// Returns whether [character] is a letter. -bool isAlphabetic(int character) => - (character >= $a && character <= $z) || - (character >= $A && character <= $Z); - -/// Returns whether [character] is a number. -bool isDigit(int? character) => - character != null && character >= $0 && character <= $9; - -/// Returns whether [character] is legal as the start of a Sass identifier. -bool isNameStart(int character) => - character == $_ || isAlphabetic(character) || character >= 0x0080; - -/// Returns whether [character] is legal in the body of a Sass identifier. -bool isName(int character) => - isNameStart(character) || isDigit(character) || character == $minus; - -/// Returns whether [character] is a hexadecimal digit. -bool isHex(int? character) { - if (character == null) return false; - if (isDigit(character)) return true; - if (character >= $a && character <= $f) return true; - if (character >= $A && character <= $F) return true; - return false; +// Define these checks as extension getters so they can be used in pattern +// matches. +extension CharacterExtension on int { + /// Returns whether [character] is a letter or number. + bool get isAlphanumeric => isAlphabetic || isDigit; + + /// Returns whether [character] is a letter. + bool get isAlphabetic => + (this >= $a && this <= $z) || (this >= $A && this <= $Z); + + /// Returns whether [character] is a number. + bool get isDigit => this >= $0 && this <= $9; + + /// Returns whether [character] is legal as the start of a Sass identifier. + bool get isNameStart => this == $_ || isAlphabetic || this >= 0x0080; + + /// Returns whether [character] is legal in the body of a Sass identifier. + bool get isName => isNameStart || isDigit || this == $minus; + + /// Returns whether [character] is the beginning of a UTF-16 surrogate pair. + bool get isHighSurrogate => + // A character is a high surrogate exactly if it matches 0b110110XXXXXXXXXX. + // 0x36 == 0b110110. + this >> 10 == 0x36; + + /// Returns whether [character] is a Unicode private-use code point in the Basic + /// Multilingual Plane. + /// + /// See https://en.wikipedia.org/wiki/Private_Use_Areas for details. + bool get isPrivateUseBMP => this >= 0xE000 && this <= 0xF8FF; + + /// Returns whether [character] is the high surrogate for a code point in a + /// Unicode private-use supplementary plane. + /// + /// See https://en.wikipedia.org/wiki/Private_Use_Areas for details. + bool get isPrivateUseHighSurrogate => + // Supplementary Private Use Area-A's and B's high surrogates range from + // 0xDB80 to 0xDBFF, which covers exactly the range 0b110110111XXXXXXX. + // 0b110110111 == 0x1B7. + this >> 7 == 0x1B7; + + /// Returns whether [character] is a hexadecimal digit. + bool get isHex => + isDigit || (this >= $a && this <= $f) || (this >= $A && this <= $F); } -/// Returns whether [character] is the beginning of a UTF-16 surrogate pair. -bool isHighSurrogate(int character) => - // A character is a high surrogate exactly if it matches 0b110110XXXXXXXXXX. - // 0x36 == 0b110110. - character >> 10 == 0x36; - -/// Returns whether [character] is a Unicode private-use code point in the Basic -/// Multilingual Plane. -/// -/// See https://en.wikipedia.org/wiki/Private_Use_Areas for details. -bool isPrivateUseBMP(int character) => - character >= 0xE000 && character <= 0xF8FF; +// Like [CharacterExtension], but these are defined on nullable ints because +// they only use equality comparisons. +// +// This also extends a few [CharacterExtension] getters to return `false` for +// null values. +extension NullableCharacterExtension on int? { + /// Returns whether [character] is an ASCII whitespace character. + bool get isWhitespace => isSpaceOrTab || isNewline; + + /// Returns whether [character] is an ASCII newline. + bool get isNewline => this == $lf || this == $cr || this == $ff; + + /// Returns whether [character] is a space or a tab character. + bool get isSpaceOrTab => this == $space || this == $tab; + + /// Returns whether [character] is a number. + bool get isDigit { + var self = this; + return self != null && self.isDigit; + } -/// Returns whether [character] is the high surrogate for a code point in a -/// Unicode private-use supplementary plane. -/// -/// See https://en.wikipedia.org/wiki/Private_Use_Areas for details. -bool isPrivateUseHighSurrogate(int character) => - // Supplementary Private Use Area-A's and B's high surrogates range from - // 0xDB80 to 0xDBFF, which covers exactly the range 0b110110111XXXXXXX. - // 0b110110111 == 0x1B7. - character >> 7 == 0x1B7; + /// Returns whether [character] is a hexadecimal digit. + bool get isHex { + var self = this; + return self != null && self.isHex; + } +} /// Combines a UTF-16 high and low surrogate pair into a single code unit. /// @@ -104,10 +114,15 @@ bool isPrivate(String identifier) { /// /// Assumes that [character] is a hex digit. int asHex(int character) { - assert(isHex(character)); - if (character <= $9) return character - $0; - if (character <= $F) return 10 + character - $A; - return 10 + character - $a; + assert(character.isHex); + return switch (character) { + // dart-lang/sdk#52740 + // ignore: non_constant_relational_pattern_expression + <= $9 => character - $0, + // ignore: non_constant_relational_pattern_expression + <= $F => 10 + character - $A, + _ => 10 + character - $a + }; } /// Returns the hexadecimal digit for [number]. @@ -136,19 +151,13 @@ int decimalCharFor(int number) { /// Assumes that [character] is a left-hand brace-like character, and returns /// the right-hand version. -int opposite(int character) { - switch (character) { - case $lparen: - return $rparen; - case $lbrace: - return $rbrace; - case $lbracket: - return $rbracket; - default: - throw ArgumentError( - '"${String.fromCharCode(character)}" isn\'t a brace-like character.'); - } -} +int opposite(int character) => switch (character) { + $lparen => $rparen, + $lbrace => $rbrace, + $lbracket => $rbracket, + _ => throw ArgumentError( + '"${String.fromCharCode(character)}" isn\'t a brace-like character.') + }; /// Returns [character], converted to upper case if it's an ASCII lowercase /// letter. diff --git a/lib/src/util/iterable.dart b/lib/src/util/iterable.dart new file mode 100644 index 000000000..c5d7e16a2 --- /dev/null +++ b/lib/src/util/iterable.dart @@ -0,0 +1,23 @@ +// 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. + +extension IterableExtension on Iterable { + /// Returns the first `T` returned by [callback] for an element of [iterable], + /// or `null` if it returns `null` for every element. + T? search(T? Function(E element) callback) { + for (var element in this) { + if (callback(element) case var value?) return value; + } + return null; + } + + /// Returns a view of this list that covers all elements except the last. + /// + /// Note this is only efficient for an iterable with a known length. + Iterable get exceptLast { + var size = length - 1; + if (size < 0) throw StateError('Iterable may not be empty'); + return take(size); + } +} diff --git a/lib/src/util/lazy_file_span.dart b/lib/src/util/lazy_file_span.dart new file mode 100644 index 000000000..628221cfb --- /dev/null +++ b/lib/src/util/lazy_file_span.dart @@ -0,0 +1,58 @@ +// 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:source_span/source_span.dart'; + +/// A wrapper for [FileSpan] that allows an expensive creation process to be +/// deferred until the span is actually needed. +class LazyFileSpan implements FileSpan { + /// The function that creates the underlying span. + final FileSpan Function() _builder; + + /// The underlying span this wraps, which is created the first time this + /// getter is referenced. + FileSpan get span => _span ??= _builder(); + FileSpan? _span; + + /// Creates a new [LazyFileSpan] that defers calling [builder] until the + /// underlying span is needed. + LazyFileSpan(FileSpan Function() builder) : _builder = builder; + + @override + int compareTo(SourceSpan other) => span.compareTo(other); + + @override + String get context => span.context; + + @override + FileLocation get end => span.end; + + @override + FileSpan expand(FileSpan other) => span.expand(other); + + @override + SourceFile get file => span.file; + + @override + String highlight({color}) => span.highlight(color: color); + + @override + int get length => span.length; + + @override + String message(String message, {color}) => + span.message(message, color: color); + + @override + Uri? get sourceUrl => span.sourceUrl; + + @override + FileLocation get start => span.start; + + @override + String get text => span.text; + + @override + SourceSpan union(SourceSpan other) => span.union(other); +} diff --git a/lib/src/util/map.dart b/lib/src/util/map.dart new file mode 100644 index 000000000..70037fd64 --- /dev/null +++ b/lib/src/util/map.dart @@ -0,0 +1,19 @@ +// 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. + +extension MapExtensions on Map { + /// 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 + /// [key] to the result. + V putOrMerge(K key, V value, V Function(V oldValue, V newValue) merge) => + containsKey(key) + ? this[key] = merge(this[key] as V, value) + : this[key] = value; + + // 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)); +} diff --git a/lib/src/util/merged_map_view.dart b/lib/src/util/merged_map_view.dart index df749871f..cb0b05458 100644 --- a/lib/src/util/merged_map_view.dart +++ b/lib/src/util/merged_map_view.dart @@ -47,12 +47,11 @@ class MergedMapView extends MapBase { V? operator [](Object? key) => _mapsByKey[key as K]?[key]; operator []=(K key, V value) { - var child = _mapsByKey[key]; - if (child == null) { + if (_mapsByKey[key] case var child?) { + child[key] = value; + } else { throw UnsupportedError("New entries may not be added to MergedMapView."); } - - child[key] = value; } V? remove(Object? key) { diff --git a/lib/src/util/multi_dir_watcher.dart b/lib/src/util/multi_dir_watcher.dart index 9543a1c08..77c1d12c5 100644 --- a/lib/src/util/multi_dir_watcher.dart +++ b/lib/src/util/multi_dir_watcher.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import 'package:watcher/watcher.dart'; import '../io.dart'; +import 'map.dart'; /// Watches multiple directories which may change over time recursively for changes. /// @@ -37,10 +38,8 @@ class MultiDirWatcher { /// from [directory]. Future watch(String directory) { var isParentOfExistingDir = false; - for (var entry in _watchers.entries.toList()) { - var existingDir = entry.key!; // dart-lang/path#100 - var existingWatcher = entry.value; - + // dart-lang/path#100 + for (var (existingDir!, existingWatcher) in _watchers.pairs.toList()) { if (!isParentOfExistingDir && (p.equals(existingDir, directory) || p.isWithin(existingDir, directory))) { diff --git a/lib/src/util/number.dart b/lib/src/util/number.dart index cec845879..f65eab058 100644 --- a/lib/src/util/number.dart +++ b/lib/src/util/number.dart @@ -108,10 +108,11 @@ double? fuzzyCheckRange(double number, num min, num max) { /// /// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the /// appropriate value. [name] is used in error reporting. -double fuzzyAssertRange(double number, double min, double max, [String? name]) { +double fuzzyAssertRange(double number, int min, int max, [String? name]) { var result = fuzzyCheckRange(number, min, max); if (result != null) return result; - throw RangeError.value(number, name, "must be between $min and $max"); + throw RangeError.range( + number, min, max, name, "must be between $min and $max"); } /// Return [num1] modulo [num2], using Sass's [floored division] modulo diff --git a/lib/src/util/span.dart b/lib/src/util/span.dart index d54b4f427..46866f60d 100644 --- a/lib/src/util/span.dart +++ b/lib/src/util/span.dart @@ -9,6 +9,10 @@ import 'package:string_scanner/string_scanner.dart'; import '../utils.dart'; import 'character.dart'; +/// A span that points nowhere, only used for fake AST nodes that will never be +/// presented to the user. +final bogusSpan = SourceFile.decoded([]).span(0); + extension SpanExtensions on FileSpan { /// Returns this span with all whitespace trimmed from both sides. FileSpan trim() => trimLeft().trimRight(); @@ -16,7 +20,7 @@ extension SpanExtensions on FileSpan { /// Returns this span with all leading whitespace trimmed. FileSpan trimLeft() { var start = 0; - while (isWhitespace(text.codeUnitAt(start))) { + while (text.codeUnitAt(start).isWhitespace) { start++; } return subspan(start); @@ -25,7 +29,7 @@ extension SpanExtensions on FileSpan { /// Returns this span with all trailing whitespace trimmed. FileSpan trimRight() { var end = text.length - 1; - while (isWhitespace(text.codeUnitAt(end))) { + while (text.codeUnitAt(end).isWhitespace) { end--; } return subspan(0, end + 1); @@ -90,14 +94,15 @@ extension SpanExtensions on FileSpan { /// Consumes an identifier from [scanner]. void _scanIdentifier(StringScanner scanner) { + loop: while (!scanner.isDone) { - var char = scanner.peekChar()!; - if (char == $backslash) { - consumeEscapedCharacter(scanner); - } else if (isName(char)) { - scanner.readChar(); - } else { - break; + switch (scanner.peekChar()) { + case $backslash: + consumeEscapedCharacter(scanner); + case int(isName: true): + scanner.readChar(); + case _: + break loop; } } } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index f08f6e5de..51e88a839 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -10,12 +10,13 @@ import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:string_scanner/string_scanner.dart'; import 'package:term_glyph/term_glyph.dart' as glyph; -import 'package:tuple/tuple.dart'; import 'ast/sass.dart'; import 'exception.dart'; import 'parse/scss.dart'; import 'util/character.dart'; +import 'util/iterable.dart'; +import 'util/map.dart'; /// The URL used in stack traces when no source URL is available. final _noSourceUrl = Uri.parse("-"); @@ -50,13 +51,14 @@ String a(String word) => [$a, $e, $i, $o, $u].contains(word.codeUnitAt(0)) ? "an $word" : "a $word"; /// Returns a bulleted list of items in [bullets]. -String bulletedList(Iterable bullets) { - return bullets.map((element) { - var lines = element.split("\n"); - return "${glyph.bullet} ${lines.first}" + - (lines.length > 1 ? "\n" + indent(lines.skip(1).join("\n"), 2) : ""); - }).join("\n"); -} +String bulletedList(Iterable bullets) => bullets.map((element) { + var lines = element.split("\n"); + return "${glyph.bullet} ${lines.first}" + + switch (lines) { + [_, ...var rest] => "\n" + indent(rest.join("\n"), 2), + _ => "" + }; + }).join("\n"); /// Returns the number of times [codeUnit] appears in [string]. int countOccurrences(String string, int codeUnit) { @@ -98,7 +100,7 @@ String trimAsciiRight(String string, {bool excludeEscape = false}) { /// whitespace, or [null] if [string] is entirely spaces. int? _firstNonWhitespace(String string) { for (var i = 0; i < string.length; i++) { - if (!isWhitespace(string.codeUnitAt(i))) return i; + if (!string.codeUnitAt(i).isWhitespace) return i; } return null; } @@ -111,10 +113,10 @@ int? _firstNonWhitespace(String string) { int? _lastNonWhitespace(String string, {bool excludeEscape = false}) { for (var i = string.length - 1; i >= 0; i--) { var codeUnit = string.codeUnitAt(i); - if (!isWhitespace(codeUnit)) { + if (!codeUnit.isWhitespace) { if (excludeEscape && i != 0 && - i != string.length && + i != string.length - 1 && codeUnit == $backslash) { return i + 1; } else { @@ -153,13 +155,6 @@ List flattenVertically(Iterable> iterable) { return result; } -/// Returns the first element of [iterable], or `null` if the iterable is empty. -// TODO(nweiz): Use package:collection -T? firstOrNull(Iterable iterable) { - var iterator = iterable.iterator; - return iterator.moveNext() ? iterator.current : null; -} - /// Returns [value] if it's a [T] or null otherwise. T? castOrNull(Object? value) => value is T ? value : null; @@ -170,7 +165,7 @@ T? castOrNull(Object? value) => value is T ? value : null; int codepointIndexToCodeUnitIndex(String string, int codepointIndex) { var codeUnitIndex = 0; for (var i = 0; i < codepointIndex; i++) { - if (isHighSurrogate(string.codeUnitAt(codeUnitIndex++))) codeUnitIndex++; + if (string.codeUnitAt(codeUnitIndex++).isHighSurrogate) codeUnitIndex++; } return codeUnitIndex; } @@ -183,7 +178,7 @@ int codeUnitIndexToCodepointIndex(String string, int codeUnitIndex) { var codepointIndex = 0; for (var i = 0; i < codeUnitIndex; i++) { codepointIndex++; - if (isHighSurrogate(string.codeUnitAt(i))) i++; + if (string.codeUnitAt(i).isHighSurrogate) i++; } return codepointIndex; } @@ -342,8 +337,7 @@ void removeFirstWhere(List list, bool test(T value), {void orElse()?}) { void mapAddAll2( Map> destination, Map> source) { source.forEach((key, inner) { - var innerDestination = destination[key]; - if (innerDestination != null) { + if (destination[key] case var innerDestination?) { innerDestination.addAll(inner); } else { destination[key] = inner; @@ -388,11 +382,11 @@ Future putIfAbsentAsync( /// Returns a deep copy of a map that contains maps. Map> copyMapOfMap(Map> map) => - {for (var entry in map.entries) entry.key: Map.of(entry.value)}; + {for (var (key, child) in map.pairs) key: Map.of(child)}; /// Returns a deep copy of a map that contains lists. Map> copyMapOfList(Map> map) => - {for (var entry in map.entries) entry.key: entry.value.toList()}; + {for (var (key, list) in map.pairs) key: list.toList()}; /// Consumes an escape sequence from [scanner] and returns the character it /// represents. @@ -400,39 +394,37 @@ int consumeEscapedCharacter(StringScanner scanner) { // See https://drafts.csswg.org/css-syntax-3/#consume-escaped-code-point. scanner.expectChar($backslash); - var first = scanner.peekChar(); - if (first == null) { - return 0xFFFD; - } else if (isNewline(first)) { - scanner.error("Expected escape sequence."); - } else if (isHex(first)) { - var value = 0; - for (var i = 0; i < 6; i++) { - var next = scanner.peekChar(); - if (next == null || !isHex(next)) break; - value = (value << 4) + asHex(scanner.readChar()); - } - if (isWhitespace(scanner.peekChar())) scanner.readChar(); - - if (value == 0 || - (value >= 0xD800 && value <= 0xDFFF) || - value >= 0x10FFFF) { + switch (scanner.peekChar()) { + case null: return 0xFFFD; - } else { - return value; - } - } else { - return scanner.readChar(); + case int(isNewline: true): + scanner.error("Expected escape sequence."); + case int(isHex: true): + var value = 0; + for (var i = 0; i < 6; i++) { + var next = scanner.peekChar(); + if (next == null || !next.isHex) break; + value = (value << 4) + asHex(scanner.readChar()); + } + if (scanner.peekChar().isWhitespace) scanner.readChar(); + + return switch (value) { + 0 || (>= 0xD800 && <= 0xDFFF) || >= 0x10FFFF => 0xFFFD, + _ => value + }; + case _: + return scanner.readChar(); } } // TODO(nweiz): Use a built-in solution for this when dart-lang/sdk#10297 is // fixed. -/// Throws [error] with [trace] stored as its stack trace. +/// Throws [error] with [originalError]'s stack trace (which defaults to +/// [trace]) stored as its stack trace. /// /// Note that [trace] is only accessible via [getTrace]. -Never throwWithTrace(Object error, StackTrace trace) { - attachTrace(error, trace); +Never throwWithTrace(Object error, Object originalError, StackTrace trace) { + attachTrace(error, getTrace(originalError) ?? trace); throw error; } @@ -440,7 +432,7 @@ Never throwWithTrace(Object error, StackTrace trace) { /// /// In most cases, [throwWithTrace] should be used instead of this. void attachTrace(Object error, StackTrace trace) { - if (error is String || error is num || error is bool) return; + if (error case String() || num() || bool()) return; // Non-`Error` objects thrown in Node will have empty stack traces. We don't // want to store these because they don't have any useful information. @@ -454,36 +446,13 @@ void attachTrace(Object error, StackTrace trace) { StackTrace? getTrace(Object error) => error is String || error is num || error is bool ? null : _traces[error]; -extension MapExtension on Map { - /// 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 - /// [key] to the result. - V putOrMerge(K key, V value, V Function(V oldValue, V newValue) merge) => - containsKey(key) - ? this[key] = merge(this[key] as V, value) - : this[key] = value; -} - -extension IterableExtension on Iterable { - /// Returns a view of this list that covers all elements except the last. - /// - /// Note this is only efficient for an iterable with a known length. - Iterable get exceptLast { - var size = length - 1; - if (size < 0) throw StateError('Iterable may not be empty'); - return take(size); - } -} - /// Parses a function signature of the format allowed by Node Sass's functions /// option and returns its name and declaration. /// /// If [requireParens] is `false`, this allows parentheses to be omitted. /// /// Throws a [SassFormatException] if parsing fails. -Tuple2 parseSignature(String signature, +(String name, ArgumentDeclaration) parseSignature(String signature, {bool requireParens = true}) { try { return ScssParser(signature).parseSignature(requireParens: requireParens); @@ -491,6 +460,7 @@ Tuple2 parseSignature(String signature, throwWithTrace( SassFormatException( 'Invalid signature "$signature": ${error.message}', error.span), + error, stackTrace); } } diff --git a/lib/src/value.dart b/lib/src/value.dart index 8bcb71957..b2f3d9c96 100644 --- a/lib/src/value.dart +++ b/lib/src/value.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'ast/selector.dart'; +import 'deprecation.dart'; import 'evaluation_context.dart'; import 'exception.dart'; import 'utils.dart'; @@ -123,7 +124,7 @@ abstract class Value { int sassIndexToListIndex(Value sassIndex, [String? name]) { var indexValue = sassIndex.assertNumber(name); if (indexValue.hasUnits) { - warn( + warnForDeprecation( "\$$name: Passing a number with unit ${indexValue.unitString} is " "deprecated.\n" "\n" @@ -131,7 +132,7 @@ abstract class Value { "${indexValue.unitSuggestion(name ?? 'index')}\n" "\n" "More info: https://sass-lang.com/d/function-units", - deprecation: true); + Deprecation.functionUnits); } var index = indexValue.assertInt(name); @@ -234,8 +235,7 @@ abstract class Value { /// Throws a [SassScriptException] if [this] isn't a type or a structure that /// can be parsed as a selector. String _selectorString([String? name]) { - var string = _selectorStringOrNull(); - if (string != null) return string; + if (_selectorStringOrNull() case var string?) return string; throw SassScriptException( "$this is not a valid selector: it must be a string,\n" @@ -249,40 +249,35 @@ abstract class Value { /// Returns `null` if [this] isn't a type or a structure that can be parsed as /// a selector. String? _selectorStringOrNull() { - if (this is SassString) return (this as SassString).text; - if (this is! SassList) return null; - var list = this as SassList; - if (list.asList.isEmpty) return null; + var self = this; + if (self is SassString) return self.text; + if (self is! SassList) return null; + if (self.asList.isEmpty) return null; var result = []; - switch (list.separator) { + switch (self.separator) { case ListSeparator.comma: - for (var complex in list.asList) { - if (complex is SassString) { - result.add(complex.text); - } else if (complex is SassList && - complex.separator == ListSeparator.space) { - var string = complex._selectorStringOrNull(); - if (string == null) return null; - result.add(string); - } else { - return null; + for (var complex in self.asList) { + switch (complex) { + case SassString(): + result.add(complex.text); + case SassList(separator: ListSeparator.space): + var string = complex._selectorStringOrNull(); + if (string == null) return null; + result.add(string); + case _: + return null; } } - break; case ListSeparator.slash: return null; - default: - for (var compound in list.asList) { - if (compound is SassString) { - result.add(compound.text); - } else { - return null; - } + case _: + for (var compound in self.asList) { + if (compound is! SassString) return null; + result.add(compound.text); } - break; } - return result.join(list.separator == ListSeparator.comma ? ', ' : ' '); + return result.join(self.separator == ListSeparator.comma ? ', ' : ' '); } /// Returns a new list containing [contents] that defaults to this value's @@ -346,28 +341,21 @@ abstract class Value { /// /// @nodoc @internal - Value plus(Value other) { - if (other is SassString) { - return SassString(toCssString() + other.text, quotes: other.hasQuotes); - } else if (other is SassCalculation) { - throw SassScriptException('Undefined operation "$this + $other".'); - } else { - return SassString(toCssString() + other.toCssString(), quotes: false); - } - } + Value plus(Value other) => switch (other) { + SassString() => + SassString(toCssString() + other.text, quotes: other.hasQuotes), + SassCalculation() => + throw SassScriptException('Undefined operation "$this + $other".'), + _ => SassString(toCssString() + other.toCssString(), quotes: false) + }; /// The SassScript `-` operation. /// /// @nodoc @internal - Value minus(Value other) { - if (other is SassCalculation) { - throw SassScriptException('Undefined operation "$this - $other".'); - } else { - return SassString("${toCssString()}-${other.toCssString()}", - quotes: false); - } - } + Value minus(Value other) => other is SassCalculation + ? throw SassScriptException('Undefined operation "$this - $other".') + : SassString("${toCssString()}-${other.toCssString()}", quotes: false); /// The SassScript `/` operation. /// @@ -453,6 +441,7 @@ extension SassApiValue on Value { throwWithTrace( SassScriptException( error.toString().replaceFirst("Error: ", ""), name), + error, stackTrace); } } @@ -477,6 +466,7 @@ extension SassApiValue on Value { throwWithTrace( SassScriptException( error.toString().replaceFirst("Error: ", ""), name), + error, stackTrace); } } @@ -501,6 +491,7 @@ extension SassApiValue on Value { throwWithTrace( SassScriptException( error.toString().replaceFirst("Error: ", ""), name), + error, stackTrace); } } @@ -525,6 +516,7 @@ extension SassApiValue on Value { throwWithTrace( SassScriptException( error.toString().replaceFirst("Error: ", ""), name), + error, stackTrace); } } diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index d52e5d283..b39011bf0 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -20,8 +20,7 @@ import '../visitor/serialize.dart'; /// works with are always fully simplified. /// /// {@category Value} -@sealed -class SassCalculation extends Value { +final class SassCalculation extends Value { /// The calculation's name, such as `"calc"`. final String name; @@ -38,9 +37,9 @@ class SassCalculation extends Value { /// Creates a new calculation with the given [name] and [arguments] /// that will not be simplified. @internal - static Value unsimplified(String name, Iterable arguments) { - return SassCalculation._(name, List.unmodifiable(arguments)); - } + static SassCalculation unsimplified( + String name, Iterable arguments) => + SassCalculation._(name, List.unmodifiable(arguments)); /// Creates a `calc()` calculation with the given [argument]. /// @@ -51,12 +50,12 @@ class SassCalculation extends Value { /// This automatically simplifies the calculation, so it may return a /// [SassNumber] rather than a [SassCalculation]. It throws an exception if it /// can determine that the calculation will definitely produce invalid CSS. - static Value calc(Object argument) { - argument = _simplify(argument); - if (argument is SassNumber) return argument; - if (argument is SassCalculation) return argument; - return SassCalculation._("calc", List.unmodifiable([argument])); - } + static Value calc(Object argument) => switch (_simplify(argument)) { + SassNumber value => value, + SassCalculation value => value, + var simplified => + SassCalculation._("calc", List.unmodifiable([simplified])) + }; /// Creates a `min()` calculation with the given [arguments]. /// @@ -182,14 +181,11 @@ class SassCalculation extends Value { static Object operateInternal( CalculationOperator operator, Object left, Object right, {required bool inMinMax, required bool simplify}) { - if (!simplify) { - return CalculationOperation._(operator, left, right); - } + if (!simplify) return CalculationOperation._(operator, left, right); left = _simplify(left); right = _simplify(right); - if (operator == CalculationOperator.plus || - operator == CalculationOperator.minus) { + if (operator case CalculationOperator.plus || CalculationOperator.minus) { if (left is SassNumber && right is SassNumber && (inMinMax @@ -228,23 +224,20 @@ class SassCalculation extends Value { List.unmodifiable(args.map(_simplify)); /// Simplifies a calculation argument. - static Object _simplify(Object arg) { - if (arg is SassNumber || - arg is CalculationInterpolation || - arg is CalculationOperation) { - return arg; - } else if (arg is SassString) { - if (!arg.hasQuotes) return arg; - throw SassScriptException( - "Quoted string $arg can't be used in a calculation."); - } else if (arg is SassCalculation) { - return arg.name == 'calc' ? arg.arguments[0] : arg; - } else if (arg is Value) { - throw SassScriptException("Value $arg can't be used in a calculation."); - } else { - throw ArgumentError("Unexpected calculation argument $arg."); - } - } + static Object _simplify(Object arg) => switch (arg) { + SassNumber() || + CalculationInterpolation() || + CalculationOperation() => + arg, + SassString(hasQuotes: false) => arg, + SassString() => throw SassScriptException( + "Quoted string $arg can't be used in a calculation."), + SassCalculation(name: 'calc', arguments: [var value]) => value, + SassCalculation() => arg, + Value() => throw SassScriptException( + "Value $arg can't be used in a calculation."), + _ => throw ArgumentError("Unexpected calculation argument $arg.") + }; /// Verifies that all the numbers in [args] aren't known to be incompatible /// with one another, and that they don't have units that are too complex for @@ -254,8 +247,7 @@ class SassCalculation extends Value { // _EvaluateVisitor._verifyCompatibleNumbers and most changes here should // also be reflected there. for (var arg in args) { - if (arg is! SassNumber) continue; - if (arg.numeratorUnits.length > 1 || arg.denominatorUnits.isNotEmpty) { + if (arg case SassNumber(hasComplexUnits: true)) { throw SassScriptException( "Number $arg isn't compatible with CSS calculations."); } @@ -326,24 +318,29 @@ class SassCalculation extends Value { /// A binary operation that can appear in a [SassCalculation]. /// /// {@category Value} -@sealed -class CalculationOperation { +final class CalculationOperation { + /// We use a getters to allow overriding the logic in the JS API + /// implementation. + /// The operator. - final CalculationOperator operator; + CalculationOperator get operator => _operator; + final CalculationOperator _operator; /// The left-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. - final Object left; + Object get left => _left; + final Object _left; /// The right-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. - final Object right; + Object get right => _right; + final Object _right; - CalculationOperation._(this.operator, this.left, this.right); + CalculationOperation._(this._operator, this._left, this._right); bool operator ==(Object other) => other is CalculationOperation && @@ -403,9 +400,13 @@ enum CalculationOperator { /// {@category Value} @sealed class CalculationInterpolation { - final String value; + /// We use a getters to allow overriding the logic in the JS API + /// implementation. + + String get value => _value; + final String _value; - CalculationInterpolation(this.value); + CalculationInterpolation(this._value); bool operator ==(Object other) => other is CalculationInterpolation && value == other.value; diff --git a/lib/src/value/color.dart b/lib/src/value/color.dart index 922662591..fb12a5e75 100644 --- a/lib/src/value/color.dart +++ b/lib/src/value/color.dart @@ -70,18 +70,11 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel0Powerless { - switch (space) { - case ColorSpace.hsl: - return fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0); - - case ColorSpace.hwb: - return fuzzyEquals(channel1 + channel2, 100); - - default: - return false; - } - } + bool get isChannel0Powerless => switch (space) { + ColorSpace.hsl => fuzzyEquals(channel1, 0) || fuzzyEquals(channel2, 0), + ColorSpace.hwb => fuzzyEquals(channel1 + channel2, 100), + _ => false + }; /// This color's first channel. /// @@ -114,21 +107,15 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel1Powerless { - switch (space) { - case ColorSpace.hsl: - return fuzzyEquals(channel2, 0); - - case ColorSpace.lab: - case ColorSpace.oklab: - case ColorSpace.lch: - case ColorSpace.oklch: - return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); - - default: - return false; - } - } + bool get isChannel1Powerless => switch (space) { + ColorSpace.hsl => fuzzyEquals(channel2, 0), + ColorSpace.lab || + ColorSpace.oklab || + ColorSpace.lch || + ColorSpace.oklch => + fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), + _ => false + }; /// This color's second channel. /// @@ -152,22 +139,15 @@ class SassColor extends Value { /// /// @nodoc @internal - bool get isChannel2Powerless { - switch (space) { - case ColorSpace.lab: - case ColorSpace.oklab: - return fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100); - - case ColorSpace.lch: - case ColorSpace.oklch: - return fuzzyEquals(channel0, 0) || + bool get isChannel2Powerless => switch (space) { + ColorSpace.lab || + ColorSpace.oklab => + fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100), + ColorSpace.lch || ColorSpace.oklch => fuzzyEquals(channel0, 0) || fuzzyEquals(channel0, 100) || - fuzzyEquals(channel1, 0); - - default: - return false; - } - } + fuzzyEquals(channel1, 0), + _ => false + }; /// This color's third channel. /// @@ -267,8 +247,13 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.rgb]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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.rgb(num? red, num? green, num? blue, [num? alpha]) => + factory SassColor.rgb(num? red, num? green, num? blue, [num? alpha = 1]) => SassColor.rgbInternal(red, green, blue, alpha); /// Like [SassColor.rgb], but also takes a [format] parameter. @@ -282,9 +267,14 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.hsl]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [num? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.hsl, _normalizeHue(hue?.toDouble()), @@ -296,9 +286,14 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.hwb]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [num? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.hwb, _normalizeHue(hue?.toDouble()), @@ -310,65 +305,112 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.srgb]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.srgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.srgbLinear]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.srgbLinear, red, green, blue, alpha); /// Creates a color in [ColorSpace.displayP3]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.displayP3, red, green, blue, alpha); /// Creates a color in [ColorSpace.a98Rgb]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.a98Rgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.prophotoRgb]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.prophotoRgb, red, green, blue, alpha); /// Creates a color in [ColorSpace.rec2020]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.rec2020, red, green, blue, alpha); /// Creates a color in [ColorSpace.xyzD50]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + factory SassColor.xyzD50(double? x, double? y, double? z, + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.xyzD50, x, y, z, alpha); /// Creates a color in [ColorSpace.xyzD65]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + factory SassColor.xyzD65(double? x, double? y, double? z, + [double? alpha = 1]) => SassColor.forSpaceInternal(ColorSpace.xyzD65, x, y, z, alpha); /// Creates a color in [ColorSpace.lab]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.lab, lightness.andThen( @@ -379,9 +421,14 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.lch]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.lch, lightness.andThen( @@ -392,9 +439,14 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.oklab]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.oklab, lightness.andThen( @@ -405,9 +457,14 @@ class SassColor extends Value { /// Creates a color in [ColorSpace.oklch]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) => + [double? alpha = 1]) => SassColor.forSpaceInternal( ColorSpace.oklch, lightness.andThen( @@ -418,10 +475,15 @@ class SassColor extends Value { /// Creates a color in the color space named [space]. /// + /// **Note:** Passing `null` to [alpha] represents a [missing component], not + /// the default value of `1` + /// + /// [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]) { + [double? alpha = 1]) { if (channels.length != space.channels.length) { throw RangeError.value(channels.length, "channels.length", 'must be exactly ${space.channels.length} for color space "$space"'); @@ -451,7 +513,10 @@ class SassColor extends Value { SassColor.forSpaceInternal(this._space, this.channel0OrNull, this.channel1OrNull, this.channel2OrNull, double? alpha, [this.format]) - : _alpha = alpha == null ? 1 : fuzzyAssertRange(alpha, 0, 1, "alpha") { + // TODO(nweiz): Support missing alpha channels. + : _alpha = + alpha.andThen((alpha) => fuzzyAssertRange(alpha, 0, 1, "alpha")) ?? + 1.0 { assert(format == null || _space == ColorSpace.rgb); assert( !(space == ColorSpace.hsl || space == ColorSpace.hwb) || @@ -475,11 +540,13 @@ class SassColor extends Value { /// Throws a [RangeError] if [channel] isn't a finite number. void _checkChannel(double? channel, String name) { - if (channel == null) return; - if (channel.isNaN) { - throw RangeError.value(channel, name, 'must be a number.'); - } else if (!channel.isFinite) { - throw RangeError.value(channel, name, 'must be finite.'); + switch (channel) { + case null: + return; + case double(isNaN: true): + throw RangeError.value(channel, name, 'must be a number.'); + case double(isFinite: false): + throw RangeError.value(channel, name, 'must be finite.'); } } @@ -632,20 +699,18 @@ class SassColor extends Value { assert(!current.isInGamut); assert(current.space == space); - if (space == ColorSpace.rgb) { - return SassColor.rgb( - fuzzyClamp(current.channel0, 0, 255), - fuzzyClamp(current.channel1, 0, 255), - fuzzyClamp(current.channel2, 0, 255), - current.alpha); - } else { - return SassColor.forSpaceInternal( - space, - fuzzyClamp(current.channel0, 0, 1), - fuzzyClamp(current.channel1, 0, 1), - fuzzyClamp(current.channel2, 0, 1), - current.alpha); - } + return space == ColorSpace.rgb + ? SassColor.rgb( + fuzzyClamp(current.channel0, 0, 255), + fuzzyClamp(current.channel1, 0, 255), + fuzzyClamp(current.channel2, 0, 255), + current.alpha) + : SassColor.forSpaceInternal( + space, + fuzzyClamp(current.channel0, 0, 1), + fuzzyClamp(current.channel1, 0, 1), + fuzzyClamp(current.channel2, 0, 1), + current.alpha); } /// Returns the ΔEOK measure between [color1] and [color2]. @@ -855,7 +920,6 @@ class SassColor extends Value { var channel2_1 = (missing2_1 ? color1 : color2).channel1; var channel2_2 = (missing2_2 ? color1 : color2).channel2; - // TODO: handle missing channels var thisMultiplier = alpha * weight; var otherMultiplier = other.alpha * (1 - weight); var mixedAlpha = alpha * weight + other.alpha * (1 - weight); @@ -872,40 +936,27 @@ class SassColor extends Value { : (channel1_2 * thisMultiplier + channel2_2 * otherMultiplier) / mixedAlpha; - SassColor mixed; - switch (method.space) { - case ColorSpace.hsl: - case ColorSpace.hwb: - mixed = SassColor.forSpaceInternal( - method.space, - missing1_0 && missing2_0 - ? null - : _interpolateHues(channel1_0, channel2_0, method.hue!, weight), - mixed1, - mixed2, - mixedAlpha); - break; - - case ColorSpace.lch: - case ColorSpace.oklch: - mixed = SassColor.forSpaceInternal( - method.space, - mixed0, - mixed1, - missing1_2 && missing2_2 - ? null - : _interpolateHues(channel1_2, channel2_2, method.hue!, weight), - mixedAlpha); - break; - - default: - assert(!space.isPolar); - mixed = SassColor.forSpaceInternal( - method.space, mixed0, mixed1, mixed2, mixedAlpha); - break; + 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) } - - return mixed.toSpace(space); + .toSpace(space); } /// Returns whether [output], which was converted to its color space from @@ -936,30 +987,28 @@ class SassColor extends Value { // Algorithms from https://www.w3.org/TR/css-color-4/#hue-interpolation switch (method) { case HueInterpolationMethod.shorter: - var difference = hue2 - hue1; - if (difference > 180) { - hue1 += 360; - } else if (difference < -180) { - hue2 += 360; + switch (hue2 - hue1) { + case > 180: + hue1 += 360; + case < -180: + hue2 += 360; } - break; case HueInterpolationMethod.longer: - var difference = hue2 - hue1; - if (difference > 0 && difference < 180) { - hue2 += 360; - } else if (difference > -180 && difference <= 0) { - hue1 += 360; + switch (hue2 - hue1) { + case > 0 && < 180: + hue2 += 360; + case > -180 && <= 0: + hue1 += 360; } - break; - case HueInterpolationMethod.increasing: - if (hue2 < hue1) hue2 += 360; - break; + case HueInterpolationMethod.increasing when hue2 < hue1: + hue2 += 360; + + case HueInterpolationMethod.decreasing when hue1 < hue2: + hue1 += 360; - case HueInterpolationMethod.decreasing: - if (hue1 < hue2) hue1 += 360; - break; + case _: // do nothing } return hue1 * weight + hue2 * (1 - weight); diff --git a/lib/src/value/color/channel.dart b/lib/src/value/color/channel.dart index 475516ebf..6fe9f9dac 100644 --- a/lib/src/value/color/channel.dart +++ b/lib/src/value/color/channel.dart @@ -28,32 +28,16 @@ class ColorChannel { /// 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) { - case "red": - case "x": - return other.name == "red" || other.name == "x"; - - case "green": - case "y": - return other.name == "green" || other.name == "y"; - - case "blue": - case "z": - return other.name == "blue" || other.name == "z"; - - case "chroma": - case "saturation": - return other.name == "chroma" || other.name == "saturation"; - - case "lightness": - case "hue": - return other.name == name; - - default: - return false; - } - } + bool isAnalogous(ColorChannel other) => switch ((name, other.name)) { + ("red" || "x", "red" || "x") || + ("green" || "y", "gren" || "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. diff --git a/lib/src/value/color/interpolation_method.dart b/lib/src/value/color/interpolation_method.dart index c77c4f3d6..6a4a67535 100644 --- a/lib/src/value/color/interpolation_method.dart +++ b/lib/src/value/color/interpolation_method.dart @@ -105,20 +105,13 @@ enum HueInterpolationMethod { /// 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]) { - var text = (value.assertString(name)..assertUnquoted()).text.toLowerCase(); - switch (text) { - case 'shorter': - return HueInterpolationMethod.shorter; - case 'longer': - return HueInterpolationMethod.longer; - case 'increasing': - return HueInterpolationMethod.increasing; - case 'decreasing': - return HueInterpolationMethod.decreasing; - default: - throw SassScriptException( - 'Unknown hue interpolation method $value.', name); - } - } + 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 index 1934c128c..882f5be91 100644 --- a/lib/src/value/color/space.dart +++ b/lib/src/value/color/space.dart @@ -25,8 +25,6 @@ import 'space/srgb_linear.dart'; import 'space/xyz_d50.dart'; import 'space/xyz_d65.dart'; -// TODO: limit instance methods to sass_api - /// A color space whose channel names and semantics Sass knows. /// /// {@category Value} @@ -150,43 +148,26 @@ abstract class ColorSpace { /// /// 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()) { - case 'rgb': - return rgb; - case 'hwb': - return hwb; - case 'hsl': - return hsl; - case 'srgb': - return srgb; - case 'srgb-linear': - return srgbLinear; - case 'display-p3': - return displayP3; - case 'a98-rgb': - return a98Rgb; - case 'prophoto-rgb': - return prophotoRgb; - case 'rec2020': - return rec2020; - case 'xyz': - case 'xyz-d65': - return xyzD65; - case 'xyz-d50': - return xyzD50; - case 'lab': - return lab; - case 'lch': - return lch; - case 'oklab': - return oklab; - case 'oklch': - return oklch; - default: - throw SassScriptException('Unknown color space "$name".', argumentName); - } - } + 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]. /// @@ -199,23 +180,12 @@ abstract class ColorSpace { @internal SassColor convert(ColorSpace dest, double channel0, double channel1, double channel2, double alpha) { - var linearDest = dest; - switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: - linearDest = ColorSpace.srgb; - break; - - case ColorSpace.lab: - case ColorSpace.lch: - linearDest = ColorSpace.xyzD50; - break; - - case ColorSpace.oklab: - case ColorSpace.oklch: - linearDest = ColorSpace.lms; - break; - } + var linearDest = switch (dest) { + ColorSpace.hsl || ColorSpace.hwb => ColorSpace.srgb, + ColorSpace.lab || ColorSpace.lch => ColorSpace.xyzD50, + ColorSpace.oklab || ColorSpace.oklch => ColorSpace.lms, + _ => dest + }; double transformed0; double transformed1; @@ -239,20 +209,18 @@ abstract class ColorSpace { matrix[6] * linear0 + matrix[7] * linear1 + matrix[8] * linear2); } - switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: - case ColorSpace.lab: - case ColorSpace.lch: - case ColorSpace.oklab: - case ColorSpace.oklch: - return linearDest.convert( - dest, transformed0, transformed1, transformed2, alpha); - - default: - return SassColor.forSpaceInternal( - dest, transformed0, transformed1, transformed2, alpha); - } + return switch (dest) { + ColorSpace.hsl || + ColorSpace.hwb || + ColorSpace.lab || + ColorSpace.lch || + ColorSpace.oklab || + ColorSpace.oklch => + linearDest.convert( + dest, transformed0, transformed1, transformed2, alpha), + _ => SassColor.forSpaceInternal( + dest, transformed0, transformed1, transformed2, alpha) + }; } /// Converts a channel in this color space into an element of a vector that diff --git a/lib/src/value/color/space/a98_rgb.dart b/lib/src/value/color/space/a98_rgb.dart index 72469d068..940ea8c94 100644 --- a/lib/src/value/color/space/a98_rgb.dart +++ b/lib/src/value/color/space/a98_rgb.dart @@ -33,26 +33,17 @@ class A98RgbColorSpace extends ColorSpace { channel.sign * math.pow(channel.abs(), 256 / 563); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearA98RgbToLinearSrgb; - case ColorSpace.displayP3: - return linearA98RgbToLinearDisplayP3; - case ColorSpace.prophotoRgb: - return linearA98RgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearA98RgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearA98RgbToXyzD65; - case ColorSpace.xyzD50: - return linearA98RgbToXyzD50; - case ColorSpace.lms: - return linearA98RgbToLms; - default: - return super.transformationMatrix(dest); - } - } + 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 index 42568d2fd..2cff9f830 100644 --- a/lib/src/value/color/space/display_p3.dart +++ b/lib/src/value/color/space/display_p3.dart @@ -28,26 +28,17 @@ class DisplayP3ColorSpace extends ColorSpace { double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearDisplayP3ToLinearSrgb; - case ColorSpace.a98Rgb: - return linearDisplayP3ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearDisplayP3ToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearDisplayP3ToLinearRec2020; - case ColorSpace.xyzD65: - return linearDisplayP3ToXyzD65; - case ColorSpace.xyzD50: - return linearDisplayP3ToXyzD50; - case ColorSpace.lms: - return linearDisplayP3ToLms; - default: - return super.transformationMatrix(dest); - } - } + 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/lms.dart b/lib/src/value/color/space/lms.dart index b59791687..aac56eb63 100644 --- a/lib/src/value/color/space/lms.dart +++ b/lib/src/value/color/space/lms.dart @@ -90,26 +90,17 @@ class LmsColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return lmsToLinearSrgb; - case ColorSpace.a98Rgb: - return lmsToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return lmsToLinearProphotoRgb; - case ColorSpace.displayP3: - return lmsToLinearDisplayP3; - case ColorSpace.rec2020: - return lmsToLinearRec2020; - case ColorSpace.xyzD65: - return lmsToXyzD65; - case ColorSpace.xyzD50: - return lmsToXyzD50; - default: - return super.transformationMatrix(dest); - } - } + 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 index c63f292af..de63b7673 100644 --- a/lib/src/value/color/space/oklab.dart +++ b/lib/src/value/color/space/oklab.dart @@ -30,9 +30,7 @@ class OklabColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double lightness, double a, double b, double alpha) { - if (dest == ColorSpace.oklch) { - return labToLch(dest, lightness, a, b, alpha); - } + if (dest == ColorSpace.oklch) return labToLch(dest, lightness, a, b, alpha); // Algorithm from https://www.w3.org/TR/css-color-4/#color-conversion-code return ColorSpace.lms.convert( diff --git a/lib/src/value/color/space/prophoto_rgb.dart b/lib/src/value/color/space/prophoto_rgb.dart index 7cf0ddb28..5fcddafdf 100644 --- a/lib/src/value/color/space/prophoto_rgb.dart +++ b/lib/src/value/color/space/prophoto_rgb.dart @@ -39,26 +39,17 @@ class ProphotoRgbColorSpace extends ColorSpace { } @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearProphotoRgbToLinearSrgb; - case ColorSpace.a98Rgb: - return linearProphotoRgbToLinearA98Rgb; - case ColorSpace.displayP3: - return linearProphotoRgbToLinearDisplayP3; - case ColorSpace.rec2020: - return linearProphotoRgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearProphotoRgbToXyzD65; - case ColorSpace.xyzD50: - return linearProphotoRgbToXyzD50; - case ColorSpace.lms: - return linearProphotoRgbToLms; - default: - return super.transformationMatrix(dest); - } - } + 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 index 9c456859b..414977cab 100644 --- a/lib/src/value/color/space/rec2020.dart +++ b/lib/src/value/color/space/rec2020.dart @@ -47,26 +47,17 @@ class Rec2020ColorSpace extends ColorSpace { } @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return linearRec2020ToLinearSrgb; - case ColorSpace.a98Rgb: - return linearRec2020ToLinearA98Rgb; - case ColorSpace.displayP3: - return linearRec2020ToLinearDisplayP3; - case ColorSpace.prophotoRgb: - return linearRec2020ToLinearProphotoRgb; - case ColorSpace.xyzD65: - return linearRec2020ToXyzD65; - case ColorSpace.xyzD50: - return linearRec2020ToXyzD50; - case ColorSpace.lms: - return linearRec2020ToLms; - default: - return super.transformationMatrix(dest); - } - } + 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/srgb.dart b/lib/src/value/color/space/srgb.dart index 6682e7644..677456818 100644 --- a/lib/src/value/color/space/srgb.dart +++ b/lib/src/value/color/space/srgb.dart @@ -30,8 +30,7 @@ class SrgbColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double red, double green, double blue, double alpha) { switch (dest) { - case ColorSpace.hsl: - case ColorSpace.hwb: + case ColorSpace.hsl || ColorSpace.hwb: if (fuzzyCheckRange(red, 0, 1) == null || fuzzyCheckRange(green, 0, 1) == null || fuzzyCheckRange(blue, 0, 1) == null) { @@ -107,24 +106,14 @@ class SrgbColorSpace extends ColorSpace { double fromLinear(double channel) => srgbAndDisplayP3FromLinear(channel); @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.displayP3: - return linearSrgbToLinearDisplayP3; - case ColorSpace.a98Rgb: - return linearSrgbToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearSrgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearSrgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearSrgbToXyzD65; - case ColorSpace.xyzD50: - return linearSrgbToXyzD50; - case ColorSpace.lms: - return linearSrgbToLms; - default: - return super.transformationMatrix(dest); - } - } + 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 index c0cc17a6c..b9a0fd13a 100644 --- a/lib/src/value/color/space/srgb_linear.dart +++ b/lib/src/value/color/space/srgb_linear.dart @@ -23,24 +23,21 @@ class SrgbLinearColorSpace extends ColorSpace { const SrgbLinearColorSpace() : super('srgb-linear', rgbChannels); - SassColor convert( - ColorSpace dest, double red, double green, double blue, double alpha) { - switch (dest) { - case ColorSpace.rgb: - case ColorSpace.hsl: - case ColorSpace.hwb: - case ColorSpace.srgb: - return ColorSpace.srgb.convert( - dest, - srgbAndDisplayP3FromLinear(red), - srgbAndDisplayP3FromLinear(green), - srgbAndDisplayP3FromLinear(blue), - alpha); - - default: - return super.convert(dest, red, green, blue, alpha); - } - } + 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, + srgbAndDisplayP3FromLinear(red), + srgbAndDisplayP3FromLinear(green), + srgbAndDisplayP3FromLinear(blue), + alpha), + _ => super.convert(dest, red, green, blue, alpha) + }; @protected double toLinear(double channel) => channel; @@ -49,24 +46,14 @@ class SrgbLinearColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.displayP3: - return linearSrgbToLinearDisplayP3; - case ColorSpace.a98Rgb: - return linearSrgbToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return linearSrgbToLinearProphotoRgb; - case ColorSpace.rec2020: - return linearSrgbToLinearRec2020; - case ColorSpace.xyzD65: - return linearSrgbToXyzD65; - case ColorSpace.xyzD50: - return linearSrgbToXyzD50; - case ColorSpace.lms: - return linearSrgbToLms; - default: - return super.transformationMatrix(dest); - } - } + 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 index 805985dd1..b1e16f2d9 100644 --- a/lib/src/value/color/space/utils.dart +++ b/lib/src/value/color/space/utils.dart @@ -38,15 +38,12 @@ double hueToRgb(double m1, double m2, double hue) { if (hue < 0) hue += 1; if (hue > 1) hue -= 1; - if (hue < 1 / 6) { - return m1 + (m2 - m1) * hue * 6; - } else if (hue < 1 / 2) { - return m2; - } else if (hue < 2 / 3) { - return m1 + (m2 - m1) * (2 / 3 - hue) * 6; - } else { - return m1; - } + 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 diff --git a/lib/src/value/color/space/xyz_d50.dart b/lib/src/value/color/space/xyz_d50.dart index 7b7c6c781..d135a85b1 100644 --- a/lib/src/value/color/space/xyz_d50.dart +++ b/lib/src/value/color/space/xyz_d50.dart @@ -27,8 +27,7 @@ class XyzD50ColorSpace extends ColorSpace { SassColor convert( ColorSpace dest, double x, double y, double z, double alpha) { switch (dest) { - case ColorSpace.lab: - case ColorSpace.lch: + 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 / d50[0]); @@ -55,26 +54,17 @@ class XyzD50ColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return xyzD50ToLinearSrgb; - case ColorSpace.a98Rgb: - return xyzD50ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return xyzD50ToLinearProphotoRgb; - case ColorSpace.displayP3: - return xyzD50ToLinearDisplayP3; - case ColorSpace.rec2020: - return xyzD50ToLinearRec2020; - case ColorSpace.xyzD65: - return xyzD50ToXyzD65; - case ColorSpace.lms: - return xyzD50ToLms; - default: - return super.transformationMatrix(dest); - } - } + 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 index 997267aaa..979e7b126 100644 --- a/lib/src/value/color/space/xyz_d65.dart +++ b/lib/src/value/color/space/xyz_d65.dart @@ -28,26 +28,17 @@ class XyzD65ColorSpace extends ColorSpace { double fromLinear(double channel) => channel; @protected - Float64List transformationMatrix(ColorSpace dest) { - switch (dest) { - case ColorSpace.srgbLinear: - case ColorSpace.srgb: - case ColorSpace.rgb: - return xyzD65ToLinearSrgb; - case ColorSpace.a98Rgb: - return xyzD65ToLinearA98Rgb; - case ColorSpace.prophotoRgb: - return xyzD65ToLinearProphotoRgb; - case ColorSpace.displayP3: - return xyzD65ToLinearDisplayP3; - case ColorSpace.rec2020: - return xyzD65ToLinearRec2020; - case ColorSpace.xyzD50: - return xyzD65ToXyzD50; - case ColorSpace.lms: - return xyzD65ToLms; - default: - return super.transformationMatrix(dest); - } - } + 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/map.dart b/lib/src/value/map.dart index bafbc4e12..b9895eda4 100644 --- a/lib/src/value/map.dart +++ b/lib/src/value/map.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import '../visitor/interface/value.dart'; import '../value.dart'; import '../utils.dart'; +import '../util/map.dart'; /// A SassScript map. /// @@ -29,13 +30,10 @@ class SassMap extends Value { ListSeparator get separator => contents.isEmpty ? ListSeparator.undecided : ListSeparator.comma; - List get asList { - var result = []; - contents.forEach((key, value) { - result.add(SassList([key, value], ListSeparator.space)); - }); - return result; - } + List get asList => [ + for (var (key, value) in contents.pairs) + SassList([key, value], ListSeparator.space) + ]; /// @nodoc @internal diff --git a/lib/src/value/number.dart b/lib/src/value/number.dart index f18af1e73..aebfec13d 100644 --- a/lib/src/value/number.dart +++ b/lib/src/value/number.dart @@ -5,9 +5,9 @@ import 'dart:math'; import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; import '../exception.dart'; +import '../util/map.dart'; import '../util/number.dart'; import '../utils.dart'; import '../value.dart'; @@ -143,7 +143,7 @@ const _conversions = { }, }; -/// A map from human-readable names of unit types to the convertable units that +/// A map from human-readable names of unit types to the convertible units that /// fall into those types. const _unitsByType = { "length": ["in", "cm", "pc", "mm", "q", "pt", "px"], @@ -155,8 +155,8 @@ const _unitsByType = { /// A map from units to the human-readable names of those unit types. final _typesByUnit = { - for (var entry in _unitsByType.entries) - for (var unit in entry.value) unit: entry.key + for (var (type, units) in _unitsByType.pairs) + for (var unit in units) unit: type }; /// Returns the number of [unit1]s per [unit2]. @@ -167,9 +167,8 @@ final _typesByUnit = { @internal double? conversionFactor(String unit1, String unit2) { if (unit1 == unit2) return 1; - var innerMap = _conversions[unit1]; - if (innerMap == null) return null; - return innerMap[unit2]; + if (_conversions[unit1] case var innerMap?) return innerMap[unit2]; + return null; } /// A SassScript number. @@ -217,12 +216,18 @@ abstract class SassNumber extends Value { /// should use [assertUnit]. bool get hasUnits; + /// 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. + bool get hasComplexUnits; + /// The representation of this number as two slash-separated numbers, if it /// has one. /// /// @nodoc @internal - final Tuple2? asSlash; + final (SassNumber, SassNumber)? asSlash; /// Whether [this] is an integer, according to [fuzzyEquals]. /// @@ -253,47 +258,44 @@ abstract class SassNumber extends Value { factory SassNumber.withUnits(num value, {List? numeratorUnits, List? denominatorUnits}) { var valueDouble = value.toDouble(); - if (denominatorUnits == null || denominatorUnits.isEmpty) { - if (numeratorUnits == null || numeratorUnits.isEmpty) { + switch ((numeratorUnits, denominatorUnits)) { + case (null || [], null || []): return UnitlessSassNumber(valueDouble); - } else if (numeratorUnits.length == 1) { - return SingleUnitSassNumber(valueDouble, numeratorUnits[0]); - } else { + case ([var unit], null || []): + return SingleUnitSassNumber(valueDouble, unit); + // TODO(dart-lang/language#3160): Remove extra null checks + case (var numerators?, null || []): return ComplexSassNumber( - valueDouble, List.unmodifiable(numeratorUnits), const []); - } - } else if (numeratorUnits == null || numeratorUnits.isEmpty) { - return ComplexSassNumber( - valueDouble, const [], List.unmodifiable(denominatorUnits)); - } else { - var numerators = numeratorUnits.toList(); - var unsimplifiedDenominators = denominatorUnits.toList(); - - var denominators = []; - for (var denominator in unsimplifiedDenominators) { - var simplifiedAway = false; - for (var i = 0; i < numerators.length; i++) { - var factor = conversionFactor(denominator, numerators[i]); - if (factor == null) continue; - valueDouble *= factor; - numerators.removeAt(i); - simplifiedAway = true; - break; - } - if (!simplifiedAway) denominators.add(denominator); - } + valueDouble, List.unmodifiable(numerators), const []); + case (null || [], var denominators?): + return ComplexSassNumber( + valueDouble, const [], List.unmodifiable(denominators)); + } - if (denominatorUnits.isEmpty) { - if (numeratorUnits.isEmpty) { - return UnitlessSassNumber(valueDouble); - } else if (numeratorUnits.length == 1) { - return SingleUnitSassNumber(valueDouble, numeratorUnits.single); - } + // dart-lang/language#3160 as well + var numerators = numeratorUnits!.toList(); + var unsimplifiedDenominators = denominatorUnits!.toList(); + + var denominators = []; + for (var denominator in unsimplifiedDenominators) { + var simplifiedAway = false; + for (var i = 0; i < numerators.length; i++) { + var factor = conversionFactor(denominator, numerators[i]); + if (factor == null) continue; + valueDouble *= factor; + numerators.removeAt(i); + simplifiedAway = true; + break; } - - return ComplexSassNumber(valueDouble, List.unmodifiable(numerators), - List.unmodifiable(denominators)); + if (!simplifiedAway) denominators.add(denominator); } + + return switch ((numerators, denominators)) { + ([], []) => UnitlessSassNumber(valueDouble), + ([var unit], []) => SingleUnitSassNumber(valueDouble, unit), + _ => ComplexSassNumber(valueDouble, List.unmodifiable(numerators), + List.unmodifiable(denominators)) + }; } /// @nodoc @@ -315,7 +317,7 @@ abstract class SassNumber extends Value { @internal SassNumber withoutSlash() => asSlash == null ? this : withValue(value); - /// Returns a copy of [this] with [asSlash] set to a tuple containing + /// Returns a copy of [this] with [asSlash] set to a pair containing /// [numerator] and [denominator]. /// /// @nodoc @@ -331,8 +333,7 @@ abstract class SassNumber extends Value { /// from a function argument, [name] is the argument name (without the `$`). /// It's used for error reporting. int assertInt([String? name]) { - var integer = fuzzyAsInt(value); - if (integer != null) return integer; + if (fuzzyAsInt(value) case var integer?) return integer; throw SassScriptException("$this is not an int.", name); } @@ -343,8 +344,7 @@ abstract class SassNumber extends Value { /// came from a function argument, [name] is the argument name (without the /// `$`). It's used for error reporting. double valueInRange(num min, num max, [String? name]) { - var result = fuzzyCheckRange(value, min, max); - if (result != null) return result; + if (fuzzyCheckRange(value, min, max) case var result?) return result; throw SassScriptException( "Expected $this to be within $min$unitString and $max$unitString.", name); @@ -360,8 +360,7 @@ abstract class SassNumber extends Value { /// @nodoc @internal double valueInRangeWithUnit(num min, num max, String name, String unit) { - var result = fuzzyCheckRange(value, min, max); - if (result != null) return result; + if (fuzzyCheckRange(value, min, max) case var result?) return result; throw SassScriptException( "Expected $this to be within $min$unit and $max$unit.", name); } @@ -795,28 +794,19 @@ abstract class SassNumber extends Value { SassNumber multiplyUnits(double value, List otherNumerators, List otherDenominators) { // Short-circuit without allocating any new unit lists if possible. - if (numeratorUnits.isEmpty) { - if (otherDenominators.isEmpty && - !_areAnyConvertible(denominatorUnits, otherNumerators)) { - return SassNumber.withUnits(value, - numeratorUnits: otherNumerators, - denominatorUnits: denominatorUnits); - } else if (denominatorUnits.isEmpty) { - return SassNumber.withUnits(value, - numeratorUnits: otherNumerators, - denominatorUnits: otherDenominators); - } - } else if (otherNumerators.isEmpty) { - if (otherDenominators.isEmpty) { + switch (( + numeratorUnits, + denominatorUnits, + otherNumerators, + otherDenominators + )) { + case (var numerators, var denominators, [], []) || + ([], [], var numerators, var denominators): + case ([], var denominators, var numerators, []) || + (var numerators, [], [], var denominators) + when !_areAnyConvertible(numerators, denominators): return SassNumber.withUnits(value, - numeratorUnits: numeratorUnits, - denominatorUnits: otherDenominators); - } else if (denominatorUnits.isEmpty && - !_areAnyConvertible(numeratorUnits, otherDenominators)) { - return SassNumber.withUnits(value, - numeratorUnits: numeratorUnits, - denominatorUnits: otherDenominators); - } + numeratorUnits: numerators, denominatorUnits: denominators); } var newNumerators = []; @@ -848,53 +838,45 @@ abstract class SassNumber extends Value { /// Returns whether there exists a unit in [units1] that can be converted to a /// unit in [units2]. - bool _areAnyConvertible(List units1, List units2) { - return units1.any((unit1) { - var innerMap = _conversions[unit1]; - if (innerMap == null) return units2.contains(unit1); - return units2.any(innerMap.containsKey); - }); - } + bool _areAnyConvertible(List units1, List units2) => + units1.any((unit1) => switch (_conversions[unit1]) { + var innerMap? => units2.any(innerMap.containsKey), + _ => units2.contains(unit1) + }); /// Returns a human-readable string representation of [numerators] and /// [denominators]. - String _unitString(List numerators, List denominators) { - if (numerators.isEmpty) { - if (denominators.isEmpty) return "no units"; - if (denominators.length == 1) return denominators.single + "^-1"; - return "(${denominators.join('*')})^-1"; - } - - if (denominators.isEmpty) return numerators.join("*"); - - return "${numerators.join("*")}/${denominators.join("*")}"; - } + String _unitString(List numerators, List denominators) => + switch ((numerators, denominators)) { + ([], []) => "no units", + ([], [var denominator]) => "$denominator^-1", + ([], _) => "(${denominators.join('*')})^-1", + (_, []) => numerators.join("*"), + _ => "${numerators.join("*")}/${denominators.join("*")}" + }; bool operator ==(Object other) { - if (other is SassNumber) { - if (numeratorUnits.length != other.numeratorUnits.length || - denominatorUnits.length != other.denominatorUnits.length) { - return false; - } - if (!hasUnits) return fuzzyEquals(value, other.value); - - if (!listEquals(_canonicalizeUnitList(numeratorUnits), - _canonicalizeUnitList(other.numeratorUnits)) || - !listEquals(_canonicalizeUnitList(denominatorUnits), - _canonicalizeUnitList(other.denominatorUnits))) { - return false; - } + if (other is! SassNumber) return false; + if (numeratorUnits.length != other.numeratorUnits.length || + denominatorUnits.length != other.denominatorUnits.length) { + return false; + } + if (!hasUnits) return fuzzyEquals(value, other.value); - return fuzzyEquals( - value * - _canonicalMultiplier(numeratorUnits) / - _canonicalMultiplier(denominatorUnits), - other.value * - _canonicalMultiplier(other.numeratorUnits) / - _canonicalMultiplier(other.denominatorUnits)); - } else { + if (!listEquals(_canonicalizeUnitList(numeratorUnits), + _canonicalizeUnitList(other.numeratorUnits)) || + !listEquals(_canonicalizeUnitList(denominatorUnits), + _canonicalizeUnitList(other.denominatorUnits))) { return false; } + + return fuzzyEquals( + value * + _canonicalMultiplier(numeratorUnits) / + _canonicalMultiplier(denominatorUnits), + other.value * + _canonicalMultiplier(other.numeratorUnits) / + _canonicalMultiplier(other.denominatorUnits)); } int get hashCode => hashCache ??= fuzzyHashCode(value * diff --git a/lib/src/value/number/complex.dart b/lib/src/value/number/complex.dart index adbc362e4..71c143b0d 100644 --- a/lib/src/value/number/complex.dart +++ b/lib/src/value/number/complex.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; import '../../value.dart'; import '../number.dart'; @@ -24,6 +23,7 @@ class ComplexSassNumber extends SassNumber { final List _denominatorUnits; bool get hasUnits => true; + bool get hasComplexUnits => true; ComplexSassNumber( double value, List numeratorUnits, List denominatorUnits) @@ -31,7 +31,7 @@ class ComplexSassNumber extends SassNumber { ComplexSassNumber._( double value, this._numeratorUnits, this._denominatorUnits, - [Tuple2? asSlash]) + [(SassNumber, SassNumber)? asSlash]) : super.protected(value, asSlash) { assert(numeratorUnits.length > 1 || denominatorUnits.isNotEmpty); } @@ -52,6 +52,6 @@ class ComplexSassNumber extends SassNumber { ComplexSassNumber._(value.toDouble(), numeratorUnits, denominatorUnits); SassNumber withSlash(SassNumber numerator, SassNumber denominator) => - ComplexSassNumber._(value, numeratorUnits, denominatorUnits, - Tuple2(numerator, denominator)); + ComplexSassNumber._( + value, numeratorUnits, denominatorUnits, (numerator, denominator)); } diff --git a/lib/src/value/number/single_unit.dart b/lib/src/value/number/single_unit.dart index c718d79a9..e5fc09814 100644 --- a/lib/src/value/number/single_unit.dart +++ b/lib/src/value/number/single_unit.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; import '../../util/number.dart'; import '../../utils.dart'; @@ -18,8 +17,11 @@ import '../number.dart'; /// sets in this list. const _knownCompatibilities = [ { - "em", "ex", "ch", "rem", "vw", "vh", "vmin", "vmax", "cm", "mm", "q", // - "in", "pt", "pc", "px" + "em", "rem", "ex", "rex", "cap", "rcap", "ch", "rch", "ic", "ric", "lh", // + "rlh", "vw", "lvw", "svw", "dvw", "vh", "lvh", "svh", "dvh", "vi", "lvi", // + "svi", "dvi", "vb", "lvb", "svb", "dvb", "vmin", "lvmin", "svmin", // + "dvmin", "vmax", "lvmax", "svmax", "dvmax", "cqw", "cqh", "cqi", "cqb", // + "cqmin", "cqmax", "cm", "mm", "q", "in", "pt", "pc", "px" }, {"deg", "grad", "rad", "turn"}, {"s", "ms"}, @@ -46,16 +48,17 @@ class SingleUnitSassNumber extends SassNumber { List get denominatorUnits => const []; bool get hasUnits => true; + bool get hasComplexUnits => false; SingleUnitSassNumber(double value, this._unit, - [Tuple2? asSlash]) + [(SassNumber, SassNumber)? asSlash]) : super.protected(value, asSlash); SassNumber withValue(num value) => SingleUnitSassNumber(value.toDouble(), _unit); SassNumber withSlash(SassNumber numerator, SassNumber denominator) => - SingleUnitSassNumber(value, _unit, Tuple2(numerator, denominator)); + SingleUnitSassNumber(value, _unit, (numerator, denominator)); bool hasUnit(String unit) => unit == _unit; diff --git a/lib/src/value/number/unitless.dart b/lib/src/value/number/unitless.dart index 506ef6d5b..06b54d39b 100644 --- a/lib/src/value/number/unitless.dart +++ b/lib/src/value/number/unitless.dart @@ -3,7 +3,6 @@ // https://opensource.org/licenses/MIT. import 'package:meta/meta.dart'; -import 'package:tuple/tuple.dart'; import '../../util/number.dart'; import '../../value.dart'; @@ -18,14 +17,15 @@ class UnitlessSassNumber extends SassNumber { List get denominatorUnits => const []; bool get hasUnits => false; + bool get hasComplexUnits => false; - UnitlessSassNumber(double value, [Tuple2? asSlash]) + UnitlessSassNumber(double value, [(SassNumber, SassNumber)? asSlash]) : super.protected(value, asSlash); SassNumber withValue(num value) => UnitlessSassNumber(value.toDouble()); SassNumber withSlash(SassNumber numerator, SassNumber denominator) => - UnitlessSassNumber(value, Tuple2(numerator, denominator)); + UnitlessSassNumber(value, (numerator, denominator)); bool hasUnit(String unit) => false; diff --git a/lib/src/value/string.dart b/lib/src/value/string.dart index 68cd98679..e3455d442 100644 --- a/lib/src/value/string.dart +++ b/lib/src/value/string.dart @@ -70,43 +70,32 @@ class SassString extends Value { if (hasQuotes) return false; if (text.length < "min(_)".length) return false; - var first = text.codeUnitAt(0); - if (equalsLetterIgnoreCase($c, first)) { - var second = text.codeUnitAt(1); - if (equalsLetterIgnoreCase($l, second)) { - if (!equalsLetterIgnoreCase($a, text.codeUnitAt(2))) return false; - if (!equalsLetterIgnoreCase($m, text.codeUnitAt(3))) return false; - if (!equalsLetterIgnoreCase($p, text.codeUnitAt(4))) return false; - return text.codeUnitAt(5) == $lparen; - } else if (equalsLetterIgnoreCase($a, second)) { - if (!equalsLetterIgnoreCase($l, text.codeUnitAt(2))) return false; - if (!equalsLetterIgnoreCase($c, text.codeUnitAt(3))) return false; - return text.codeUnitAt(4) == $lparen; - } else { - return false; - } - } else if (equalsLetterIgnoreCase($v, first)) { - if (!equalsLetterIgnoreCase($a, text.codeUnitAt(1))) return false; - if (!equalsLetterIgnoreCase($r, text.codeUnitAt(2))) return false; - return text.codeUnitAt(3) == $lparen; - } else if (equalsLetterIgnoreCase($e, first)) { - if (!equalsLetterIgnoreCase($n, text.codeUnitAt(1))) return false; - if (!equalsLetterIgnoreCase($v, text.codeUnitAt(2))) return false; - return text.codeUnitAt(3) == $lparen; - } else if (equalsLetterIgnoreCase($m, first)) { - var second = text.codeUnitAt(1); - if (equalsLetterIgnoreCase($a, second)) { - if (!equalsLetterIgnoreCase($x, text.codeUnitAt(2))) return false; - return text.codeUnitAt(3) == $lparen; - } else if (equalsLetterIgnoreCase($i, second)) { - if (!equalsLetterIgnoreCase($n, text.codeUnitAt(2))) return false; - return text.codeUnitAt(3) == $lparen; - } else { - return false; - } - } else { - return false; - } + return switch (text.codeUnitAt(0)) { + $c || $C => switch (text.codeUnitAt(1)) { + $l || $L => equalsLetterIgnoreCase($a, text.codeUnitAt(2)) && + equalsLetterIgnoreCase($m, text.codeUnitAt(3)) && + equalsLetterIgnoreCase($p, text.codeUnitAt(4)) && + text.codeUnitAt(5) == $lparen, + $a || $A => equalsLetterIgnoreCase($l, text.codeUnitAt(2)) && + equalsLetterIgnoreCase($c, text.codeUnitAt(3)) && + text.codeUnitAt(4) == $lparen, + _ => false + }, + $v || $V => equalsLetterIgnoreCase($a, text.codeUnitAt(1)) && + equalsLetterIgnoreCase($r, text.codeUnitAt(2)) && + text.codeUnitAt(3) == $lparen, + $e || $E => equalsLetterIgnoreCase($n, text.codeUnitAt(1)) && + equalsLetterIgnoreCase($v, text.codeUnitAt(2)) && + text.codeUnitAt(3) == $lparen, + $m || $M => switch (text.codeUnitAt(1)) { + $a || $A => equalsLetterIgnoreCase($x, text.codeUnitAt(2)) && + text.codeUnitAt(3) == $lparen, + $i || $I => equalsLetterIgnoreCase($n, text.codeUnitAt(2)) && + text.codeUnitAt(3) == $lparen, + _ => false + }, + _ => false + }; } /// @nodoc @@ -213,13 +202,9 @@ class SassString extends Value { /// @nodoc @internal - Value plus(Value other) { - if (other is SassString) { - return SassString(text + other.text, quotes: hasQuotes); - } else { - return SassString(text + other.toCssString(), quotes: hasQuotes); - } - } + Value plus(Value other) => other is SassString + ? SassString(text + other.text, quotes: hasQuotes) + : SassString(text + other.toCssString(), quotes: hasQuotes); bool operator ==(Object other) => other is SassString && text == other.text; diff --git a/lib/src/visitor/async_evaluate.dart b/lib/src/visitor/async_evaluate.dart index e88f40afd..2f811170f 100644 --- a/lib/src/visitor/async_evaluate.dart +++ b/lib/src/visitor/async_evaluate.dart @@ -3,6 +3,7 @@ // https://opensource.org/licenses/MIT. import 'dart:async'; +import 'dart:collection'; import 'dart:math' as math; import 'package:charcode/charcode.dart'; @@ -10,7 +11,6 @@ import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; -import 'package:tuple/tuple.dart'; import '../ast/css.dart'; import '../ast/css/modifiable.dart'; @@ -23,6 +23,7 @@ import '../callable.dart'; import '../color_names.dart'; import '../configuration.dart'; import '../configured_value.dart'; +import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../extend/extension_store.dart'; @@ -31,6 +32,7 @@ import '../functions.dart'; 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'; @@ -38,9 +40,12 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/map.dart'; import '../util/multi_span.dart'; import '../util/nullable.dart'; +import '../util/span.dart'; import '../value.dart'; +import 'expression_to_calc.dart'; import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; @@ -91,7 +96,7 @@ Future evaluateAsync(Stylesheet stylesheet, /// A class that can evaluate multiple independent statements and expressions /// in the context of a single module. -class AsyncEvaluator { +final class AsyncEvaluator { /// The visitor that evaluates each expression and statement. final _EvaluateVisitor _visitor; @@ -120,7 +125,7 @@ class AsyncEvaluator { } /// A visitor that executes Sass code to produce a CSS tree. -class _EvaluateVisitor +final class _EvaluateVisitor implements StatementVisitor>, ExpressionVisitor>, @@ -160,7 +165,7 @@ class _EvaluateVisitor /// /// We only want to emit one warning per location, to avoid blowing up users' /// consoles with redundant warnings. - final _warningsEmitted = >{}; + final _warningsEmitted = <(String, SourceSpan)>{}; /// Whether to avoid emitting warnings for files loaded from dependencies. final bool _quietDeps; @@ -253,13 +258,13 @@ class _EvaluateVisitor /// The dynamic call stack representing function invocations, mixin /// invocations, and imports surrounding the current context. /// - /// Each member is a tuple of the span where the stack trace starts and the + /// Each member is a pair of the span where the stack trace starts and the /// name of the member being invoked. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final _stack = >[]; + final _stack = <(String, AstNode)>[]; /// Whether we're running in Node Sass-compatibility mode. bool get _asNodeSass => _nodeImporter != null; @@ -303,6 +308,13 @@ class _EvaluateVisitor /// stylesheet. List? _outOfOrderImports; + /// A map from modules loaded by the current module to loud comments written + /// in this module that should appear before the loaded module. + /// + /// This is `null` unless there are any pre-module comments in the current + /// stylesheet. + Map>? _preModuleComments; + /// The extension store that tracks extensions and style rules for the current /// module. ExtensionStore get _extensionStore => @@ -388,8 +400,8 @@ class _EvaluateVisitor } return SassMap({ - for (var entry in module.variables.entries) - SassString(entry.key): entry.value + for (var (name, value) in module.variables.pairs) + SassString(name): value }); }, url: "sass:meta"), @@ -401,8 +413,8 @@ class _EvaluateVisitor } return SassMap({ - for (var entry in module.functions.entries) - SassString(entry.key): SassFunction(entry.value) + for (var (name, value) in module.functions.pairs) + SassString(name): SassFunction(value) }); }, url: "sass:meta"), @@ -412,19 +424,20 @@ class _EvaluateVisitor var css = arguments[1].isTruthy; var module = arguments[2].realNull?.assertString("module"); - if (css && module != null) { - throw r"$css and $module may not both be passed at once."; + if (css) { + if (module != null) { + throw r"$css and $module may not both be passed at once."; + } + return SassFunction(PlainCssCallable(name.text)); } - var callable = css - ? PlainCssCallable(name.text) - : _addExceptionSpan( - _callableNode!, - () => _getFunction(name.text.replaceAll("_", "-"), - namespace: module?.text)); - if (callable != null) return SassFunction(callable); + var callable = _addExceptionSpan( + _callableNode!, + () => _getFunction(name.text.replaceAll("_", "-"), + namespace: module?.text)); + if (callable == null) throw "Function not found: $name"; - throw "Function not found: $name"; + return SassFunction(callable); }, url: "sass:meta"), AsyncBuiltInCallable.function("call", r"$function, $args...", @@ -439,18 +452,18 @@ class _EvaluateVisitor ? null : ValueExpression( SassMap({ - for (var entry in args.keywords.entries) - SassString(entry.key, quotes: false): entry.value + for (var (name, value) in args.keywords.pairs) + SassString(name, quotes: false): value }), callableNode.span)); if (function is SassString) { - warn( + warnForDeprecation( "Passing a string to call() is deprecated and will be illegal in " "Dart Sass 2.0.0.\n" "\n" "Recommendation: call(get-function($function))", - deprecation: true); + Deprecation.callString); var callableNode = _callableNode!; var expression = @@ -495,7 +508,7 @@ class _EvaluateVisitor } await _loadModule(url, "load-css()", callableNode, - (module) => _combineCss(module, clone: true).accept(this), + (module, _) => _combineCss(module, clone: true).accept(this), baseUrl: callableNode.span.sourceUrl, configuration: configuration, namesInErrors: true); @@ -518,30 +531,33 @@ class _EvaluateVisitor } Future run(AsyncImporter? importer, Stylesheet node) async { - return withEvaluationContext(_EvaluationContext(this, node), () async { - var url = node.span.sourceUrl; - if (url != null) { - _activeModules[url] = null; - if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); - } - - var module = await _execute(importer, node); + try { + return await withEvaluationContext(_EvaluationContext(this, node), + () async { + if (node.span.sourceUrl case var url?) { + _activeModules[url] = null; + if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); + } - return EvaluateResult(_combineCss(module), _loadedUrls); - }); + var module = await _addExceptionTrace(() => _execute(importer, node)); + return (stylesheet: _combineCss(module), loadedUrls: _loadedUrls); + }); + } on SassException catch (error, stackTrace) { + throwWithTrace(error.withLoadedUrls(_loadedUrls), error, stackTrace); + } } Future runExpression(AsyncImporter? importer, Expression expression) => withEvaluationContext( _EvaluationContext(this, expression), - () => _withFakeStylesheet( - importer, expression, () => expression.accept(this))); + () => _withFakeStylesheet(importer, expression, + () => _addExceptionTrace(() => expression.accept(this)))); Future runStatement(AsyncImporter? importer, Statement statement) => withEvaluationContext( _EvaluationContext(this, statement), - () => _withFakeStylesheet( - importer, statement, () => statement.accept(this))); + () => _withFakeStylesheet(importer, statement, + () => _addExceptionTrace(() => statement.accept(this)))); /// Asserts that [value] is not `null` and returns it. /// @@ -571,7 +587,9 @@ class _EvaluateVisitor } } - /// Loads the module at [url] and passes it to [callback]. + /// Loads the module at [url] and passes it to [callback], along with a + /// boolean indicating whether this is the first time the module has been + /// loaded. /// /// This first tries loading [url] relative to [baseUrl], which defaults to /// `_stylesheet.span.sourceUrl`. @@ -588,12 +606,11 @@ class _EvaluateVisitor /// The [stackFrame] and [nodeWithSpan] are used for the name and location of /// the stack frame for the duration of the [callback]. Future _loadModule(Uri url, String stackFrame, AstNode nodeWithSpan, - FutureOr callback(Module module), + FutureOr callback(Module module, bool firstLoad), {Uri? baseUrl, Configuration? configuration, bool namesInErrors = false}) async { - var builtInModule = _builtInModules[url]; - if (builtInModule != null) { + if (_builtInModules[url] case var builtInModule?) { if (configuration is ExplicitConfiguration) { throw _exception( namesInErrors @@ -602,34 +619,41 @@ class _EvaluateVisitor configuration.nodeWithSpan.span); } - await _addExceptionSpanAsync(nodeWithSpan, () => callback(builtInModule)); + // Always consider built-in stylesheets to be "already loaded", since they + // never require additional execution to load and never produce CSS. + await _addExceptionSpanAsync( + nodeWithSpan, () => callback(builtInModule, false)); return; } await _withStackFrame(stackFrame, nodeWithSpan, () async { - var result = await _loadStylesheet(url.toString(), nodeWithSpan.span, + var (stylesheet, :importer, :isDependency) = await _loadStylesheet( + url.toString(), nodeWithSpan.span, baseUrl: baseUrl); - var stylesheet = result.stylesheet; var canonicalUrl = stylesheet.span.sourceUrl; - if (canonicalUrl != null && _activeModules.containsKey(canonicalUrl)) { - var message = namesInErrors - ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " - "loaded." - : "Module loop: this module is already being loaded."; - - throw _activeModules[canonicalUrl].andThen((previousLoad) => - _multiSpanException(message, "new load", - {previousLoad.span: "original load"})) ?? - _exception(message); + if (canonicalUrl != null) { + if (_activeModules.containsKey(canonicalUrl)) { + var message = namesInErrors + ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " + "loaded." + : "Module loop: this module is already being loaded."; + + throw _activeModules[canonicalUrl].andThen((previousLoad) => + _multiSpanException(message, "new load", + {previousLoad.span: "original load"})) ?? + _exception(message); + } else { + _activeModules[canonicalUrl] = nodeWithSpan; + } } - if (canonicalUrl != null) _activeModules[canonicalUrl] = nodeWithSpan; + var firstLoad = !_modules.containsKey(canonicalUrl); var oldInDependency = _inDependency; - _inDependency = result.isDependency; + _inDependency = isDependency; Module module; try { - module = await _execute(result.importer, stylesheet, + module = await _execute(importer, stylesheet, configuration: configuration, nodeWithSpan: nodeWithSpan, namesInErrors: namesInErrors); @@ -638,29 +662,9 @@ class _EvaluateVisitor _inDependency = oldInDependency; } - try { - await callback(module); - } on SassRuntimeException { - rethrow; - } on MultiSpanSassException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - error.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(error.span)), - stackTrace); - } on SassException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, error.span), stackTrace); - } on MultiSpanSassScriptException catch (error, stackTrace) { - throwWithTrace( - _multiSpanException( - error.message, error.primaryLabel, error.secondarySpans), - stackTrace); - } on SassScriptException catch (error, stackTrace) { - throwWithTrace(_exception(error.message), stackTrace); - } + await _addExceptionSpanAsync( + nodeWithSpan, () => callback(module, firstLoad), + addStackFrame: false); }); } @@ -678,8 +682,7 @@ class _EvaluateVisitor bool namesInErrors = false}) async { var url = stylesheet.span.sourceUrl; - var alreadyLoaded = _modules[url]; - if (alreadyLoaded != null) { + if (_modules[url] case var alreadyLoaded?) { var currentConfiguration = configuration ?? _configuration; if (!_moduleConfigurations[url]!.sameOriginal(currentConfiguration) && currentConfiguration is ExplicitConfiguration) { @@ -708,11 +711,13 @@ class _EvaluateVisitor var environment = AsyncEnvironment(); late CssStylesheet css; + late Map>? preModuleComments; var extensionStore = ExtensionStore(); await _withEnvironment(environment, () async { var oldImporter = _importer; var oldStylesheet = __stylesheet; var oldRoot = __root; + var oldPreModuleComments = _preModuleComments; var oldParent = __parent; var oldEndOfImports = __endOfImports; var oldOutOfOrderImports = _outOfOrderImports; @@ -743,10 +748,12 @@ class _EvaluateVisitor css = _outOfOrderImports == null ? root : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + preModuleComments = _preModuleComments; _importer = oldImporter; __stylesheet = oldStylesheet; __root = oldRoot; + _preModuleComments = oldPreModuleComments; __parent = oldParent; __endOfImports = oldEndOfImports; _outOfOrderImports = oldOutOfOrderImports; @@ -760,7 +767,8 @@ class _EvaluateVisitor _configuration = oldConfiguration; }); - var module = environment.toModule(css, extensionStore); + var module = environment.toModule( + css, preModuleComments ?? const {}, extensionStore); if (url != null) { _modules[url] = module; _moduleConfigurations[url] = _configuration; @@ -772,16 +780,15 @@ class _EvaluateVisitor /// Returns a copy of [_root.children] with [_outOfOrderImports] inserted /// after [_endOfImports], if necessary. - List _addOutOfOrderImports() { - var outOfOrderImports = _outOfOrderImports; - if (outOfOrderImports == null) return _root.children; - - return [ - ..._root.children.take(_endOfImports), - ...outOfOrderImports, - ..._root.children.skip(_endOfImports) - ]; - } + List _addOutOfOrderImports() => + switch (_outOfOrderImports) { + null => _root.children, + var outOfOrderImports => [ + ..._root.children.take(_endOfImports), + ...outOfOrderImports, + ..._root.children.skip(_endOfImports) + ] + }; /// Returns a new stylesheet containing [root]'s CSS as well as the CSS of all /// modules transitively used by [root]. @@ -793,21 +800,16 @@ class _EvaluateVisitor CssStylesheet _combineCss(Module root, {bool clone = false}) { if (!root.upstream.any((module) => module.transitivelyContainsCss)) { var selectors = root.extensionStore.simpleSelectors; - var unsatisfiedExtension = firstOrNull(root.extensionStore - .extensionsWhereTarget((target) => !selectors.contains(target))); - if (unsatisfiedExtension != null) { + if (root.extensionStore + .extensionsWhereTarget((target) => !selectors.contains(target)) + .firstOrNull + case var unsatisfiedExtension?) { _throwForUnsatisfiedExtension(unsatisfiedExtension); } return root.css; } - var sortedModules = _topologicalModules(root); - if (clone) { - sortedModules = sortedModules.map((module) => module.cloneCss()).toList(); - } - _extendModules(sortedModules); - // The imports (and comments between them) that should be included at the // beginning of the final document. var imports = []; @@ -815,19 +817,46 @@ class _EvaluateVisitor // The CSS statements in the final document. var css = []; - for (var module in sortedModules.reversed) { + /// The modules in reverse topological order. + var sorted = Queue(); + + /// The modules that have been visited so far. Note that if [cloneCss] is + /// true, this contains the original modules, not the copies. + var seen = {}; + + void visitModule(Module module) { + if (!seen.add(module)) return; + if (clone) module = module.cloneCss(); + + for (var upstream in module.upstream) { + if (upstream.transitivelyContainsCss) { + if (module.preModuleComments[upstream] case var comments?) { + // Intermix the top-level comments with plain CSS `@import`s until we + // start to have actual CSS defined, at which point start treating it as + // normal CSS. + (css.isEmpty ? imports : css).addAll(comments); + } + visitModule(upstream); + } + } + + sorted.addFirst(module); var statements = module.css.children; var index = _indexAfterImports(statements); imports.addAll(statements.getRange(0, index)); css.addAll(statements.getRange(index, statements.length)); } + visitModule(root); + + if (root.transitivelyContainsExtensions) _extendModules(sorted); + return CssStylesheet(imports + css, root.css.span); } - /// Extends the selectors in each module with the extensions defined in - /// downstream modules. - void _extendModules(List sortedModules) { + /// Destructively updates the selectors in each module with the extensions + /// defined in downstream modules. + void _extendModules(Iterable sortedModules) { // All the [ExtensionStore]s directly downstream of a given module (indexed // by its canonical URL). It's important that we create this in topological // order, so that by the time we're processing a module we've already filled @@ -858,11 +887,11 @@ class _EvaluateVisitor if (module.extensionStore.isEmpty) continue; for (var upstream in module.upstream) { - var url = upstream.url; - if (url == null) continue; - downstreamExtensionStores - .putIfAbsent(url, () => []) - .add(module.extensionStore); + if (upstream.url case var url?) { + downstreamExtensionStores + .putIfAbsent(url, () => []) + .add(module.extensionStore); + } } // Remove all extensions that are now satisfied after adding downstream @@ -885,43 +914,19 @@ class _EvaluateVisitor extension.span); } - /// Returns all modules transitively used by [root] in topological order, - /// ignoring modules that contain no CSS. - List _topologicalModules(Module root) { - // Construct a topological ordering using depth-first traversal, as in - // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search. - var seen = {}; - var sorted = QueueList(); - - void visitModule(Module module) { - // Each module is added to the beginning of [sorted], which means the - // returned list contains sibling modules in the opposite order of how - // they appear in the document. Then when the list is reversed to generate - // the CSS, they're put back in their original order. - for (var upstream in module.upstream) { - if (upstream.transitivelyContainsCss && seen.add(upstream)) { - visitModule(upstream); - } - } - - sorted.addFirst(module); - } - - visitModule(root); - - return sorted; - } - /// Returns the index of the first node in [statements] that comes after all /// static imports. int _indexAfterImports(List statements) { var lastImport = -1; + loop: for (var i = 0; i < statements.length; i++) { - var statement = statements[i]; - if (statement is CssImport) { - lastImport = i; - } else if (statement is! CssComment) { - break; + switch (statements[i]) { + case CssImport(): + lastImport = i; + case CssComment(): + continue loop; + case _: + break loop; } } return lastImport + 1; @@ -938,12 +943,11 @@ class _EvaluateVisitor Future visitAtRootRule(AtRootRule node) async { var query = AtRootQuery.defaultQuery; - var unparsedQuery = node.query; - if (unparsedQuery != null) { - var resolved = - await _performInterpolation(unparsedQuery, warnForColor: true); - query = _adjustParseError( - unparsedQuery, () => AtRootQuery.parse(resolved, logger: _logger)); + if (node.query case var unparsedQuery?) { + var (resolved, map) = + await _performInterpolationWithMap(unparsedQuery, warnForColor: true); + query = + AtRootQuery.parse(resolved, interpolationMap: map, logger: _logger); } var parent = _parent; @@ -951,13 +955,12 @@ class _EvaluateVisitor while (parent is! CssStylesheet) { if (!query.excludes(parent)) included.add(parent); - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw StateError( "CssNodes must have a CssStylesheet transitive parent node."); } - - parent = grandparent; } var root = _trimIncluded(included); @@ -973,10 +976,10 @@ class _EvaluateVisitor } var innerCopy = root; - if (included.isNotEmpty) { - innerCopy = included.first.copyWithoutChildren(); + if (included case [var first, ...var rest]) { + innerCopy = first.copyWithoutChildren(); var outerCopy = innerCopy; - for (var node in included.skip(1)) { + for (var node in rest) { var copy = node.copyWithoutChildren(); copy.addChild(outerCopy); outerCopy = copy; @@ -1013,21 +1016,20 @@ class _EvaluateVisitor while (parent != nodes[i]) { innermostContiguous = null; - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError( "Expected ${nodes[i]} to be an ancestor of $this."); } - - parent = grandparent; } innermostContiguous ??= i; - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError("Expected ${nodes[i]} to be an ancestor of $this."); } - parent = grandparent; } if (parent != _root) return _root; @@ -1124,29 +1126,34 @@ class _EvaluateVisitor throw _exception( "Declarations may only be used within style rules.", node.span); } + if (_declarationName != null && node.isCustomProperty) { + throw _exception( + 'Declarations whose names begin with "--" may not be nested.', + node.span); + } var name = await _interpolationToValue(node.name, warnForColor: true); - if (_declarationName != null) { - name = CssValue("$_declarationName-${name.value}", name.span); - } - var cssValue = await node.value.andThen( - (value) async => CssValue(await value.accept(this), value.span)); - - // If the value is an empty list, preserve it, because converting it to CSS - // will throw an error that we want the user to see. - if (cssValue != null && - (!cssValue.value.isBlank || _isEmptyList(cssValue.value))) { - _parent.addChild(ModifiableCssDeclaration(name, cssValue, node.span, - parsedAsCustomProperty: node.isCustomProperty, - valueSpanForMap: - _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); - } else if (name.value.startsWith('--') && cssValue != null) { - throw _exception( - "Custom property values may not be empty.", cssValue.span); + if (_declarationName case var declarationName?) { + name = CssValue("$declarationName-${name.value}", name.span); + } + + if (node.value case var expression?) { + var value = await expression.accept(this); + // If the value is an empty list, preserve it, because converting it to CSS + // will throw an error that we want the user to see. + if (!value.isBlank || _isEmptyList(value)) { + _parent.addChild(ModifiableCssDeclaration( + name, CssValue(value, expression.span), node.span, + parsedAsCustomProperty: node.isCustomProperty, + valueSpanForMap: + _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); + } else if (name.value.startsWith('--')) { + throw _exception( + "Custom property values may not be empty.", expression.span); + } } - var children = node.children; - if (children != null) { + if (node.children case var children?) { var oldDeclarationName = _declarationName; _declarationName = name.value; await _environment.scope(() async { @@ -1166,11 +1173,12 @@ class _EvaluateVisitor Future visitEachRule(EachRule node) async { var list = await node.list.accept(this); var nodeWithSpan = _expressionNode(node.list); - var setVariables = node.variables.length == 1 - ? (Value value) => _environment.setLocalVariable(node.variables.first, - _withoutSlash(value, nodeWithSpan), nodeWithSpan) - : (Value value) => - _setMultipleVariables(node.variables, value, nodeWithSpan); + var setVariables = switch (node.variables) { + [var variable] => (Value value) => _environment.setLocalVariable( + variable, _withoutSlash(value, nodeWithSpan), nodeWithSpan), + var variables => (Value value) => + _setMultipleVariables(variables, value, nodeWithSpan) + }; return _environment.scope(() { return _handleReturn(list.asList, (element) { setVariables(element); @@ -1216,20 +1224,16 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - MultiSpan(styleRule.selector.span, 'invalid selector', + MultiSpan(complex.span.trimRight(), 'invalid selector', {node.span: '@extend rule'}), - deprecation: true); + Deprecation.bogusCombinators); } - var targetText = - await _interpolationToValue(node.selector, warnForColor: true); + var (targetText, targetMap) = + await _performInterpolationWithMap(node.selector, warnForColor: true); - var list = _adjustParseError( - targetText, - () => SelectorList.parse( - trimAscii(targetText.value, excludeEscape: true), - logger: _logger, - allowParent: false)); + var list = SelectorList.parse(trimAscii(targetText, excludeEscape: true), + interpolationMap: targetMap, logger: _logger, allowParent: false); for (var complex in list.components) { var compound = complex.singleCompound; @@ -1237,7 +1241,7 @@ class _EvaluateVisitor // If the selector was a compound selector but not a simple // selector, emit a more explicit error. throw SassFormatException( - "complex selectors may not be extended.", targetText.span); + "complex selectors may not be extended.", complex.span); } var simple = compound.singleSimple; @@ -1246,7 +1250,7 @@ class _EvaluateVisitor "compound selectors may no longer be extended.\n" "Consider `@extend ${compound.components.join(', ')}` instead.\n" "See https://sass-lang.com/d/extend-compound for details.\n", - targetText.span); + compound.span); } _extensionStore.addExtension( @@ -1288,7 +1292,9 @@ class _EvaluateVisitor await _withParent(ModifiableCssAtRule(name, node.span, value: value), () async { var styleRule = _styleRule; - if (styleRule == null || _inKeyframes) { + if (styleRule == null || _inKeyframes || name.value == 'font-face') { + // Special-cased at-rules within style blocks are pulled out to the + // root. Equivalent to prepending "@at-root" on them. for (var child in children) { await child.accept(this); } @@ -1338,9 +1344,11 @@ class _EvaluateVisitor numeratorUnits: fromNumber.numeratorUnits, denominatorUnits: fromNumber.denominatorUnits), nodeWithSpan); - var result = await _handleReturn( - node.children, (child) => child.accept(this)); - if (result != null) return result; + if (await _handleReturn( + node.children, (child) => child.accept(this)) + case var result?) { + return result; + } } return null; }, semiGlobal: true); @@ -1354,7 +1362,8 @@ class _EvaluateVisitor var newConfiguration = await _addForwardConfiguration(adjustedConfiguration, node); - await _loadModule(node.url, "@forward", node, (module) { + await _loadModule(node.url, "@forward", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.forwardModule(module, node); }, configuration: newConfiguration); @@ -1378,7 +1387,8 @@ class _EvaluateVisitor _assertConfigurationIsEmpty(newConfiguration); } else { _configuration = adjustedConfiguration; - await _loadModule(node.url, "@forward", node, (module) { + await _loadModule(node.url, "@forward", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.forwardModule(module, node); }); _configuration = oldConfiguration; @@ -1394,8 +1404,8 @@ class _EvaluateVisitor var newValues = Map.of(configuration.values); for (var variable in node.configuration) { if (variable.isGuarded) { - var oldValue = configuration.remove(variable.name); - if (oldValue != null && oldValue.value != sassNull) { + if (configuration.remove(variable.name) case var oldValue? + when oldValue.value != sassNull) { newValues[variable.name] = oldValue; continue; } @@ -1416,6 +1426,20 @@ class _EvaluateVisitor } } + /// Adds any comments in [_root.children] to [_preModuleComments] for + /// [module]. + void _registerCommentsForModule(Module module) { + // If we're not in a module (for example, we're evaluating a line of code + // for the repl), there's nothing to register comments for. + if (__root == null) return; + if (_root.children.isEmpty || !module.transitivelyContainsCss) return; + (_preModuleComments ??= {}) + .putIfAbsent(module, () => []) + .addAll(_root.children.cast()); + _root.clearChildren(); + _endOfImports = 0; + } + /// Remove configured values from [upstream] that have been removed from /// [downstream], unless they match a name in [except]. void _removeUsedConfiguration( @@ -1442,14 +1466,14 @@ class _EvaluateVisitor if (configuration is! ExplicitConfiguration) return; if (configuration.isEmpty) return; - var entry = configuration.values.entries.first; + var (name, value) = configuration.values.pairs.first; throw _exception( nameInError - ? "\$${entry.key} was not declared with !default in the @used " + ? "\$$name was not declared with !default in the @used " "module." : "This variable was not declared with !default in the @used " "module.", - entry.value.configurationSpan); + value.configurationSpan); } Future visitFunctionRule(FunctionRule node) async { @@ -1466,14 +1490,12 @@ class _EvaluateVisitor break; } } - if (clause == null) return null; - return await _environment.scope( + return await clause.andThen>((clause) => _environment.scope( () => _handleReturn( - clause!.children, // dart-lang/sdk#45348 - (child) => child.accept(this)), + clause.children, (child) => child.accept(this)), semiGlobal: true, - when: clause.hasDeclarations); + when: clause.hasDeclarations)); } Future visitImportRule(ImportRule node) async { @@ -1490,9 +1512,8 @@ class _EvaluateVisitor /// Adds the stylesheet imported by [import] to the current document. Future _visitDynamicImport(DynamicImport import) { return _withStackFrame("@import", import, () async { - var result = + var (stylesheet, :importer, :isDependency) = await _loadStylesheet(import.urlString, import.span, forImport: true); - var stylesheet = result.stylesheet; var url = stylesheet.span.sourceUrl; if (url != null) { @@ -1513,9 +1534,9 @@ class _EvaluateVisitor var oldImporter = _importer; var oldStylesheet = _stylesheet; var oldInDependency = _inDependency; - _importer = result.importer; + _importer = importer; _stylesheet = stylesheet; - _inDependency = result.isDependency; + _inDependency = isDependency; await visitStylesheet(stylesheet); _importer = oldImporter; _stylesheet = oldStylesheet; @@ -1544,7 +1565,7 @@ class _EvaluateVisitor var oldOutOfOrderImports = _outOfOrderImports; var oldConfiguration = _configuration; var oldInDependency = _inDependency; - _importer = result.importer; + _importer = importer; _stylesheet = stylesheet; if (loadsUserDefinedModules) { _root = ModifiableCssStylesheet(stylesheet.span); @@ -1552,7 +1573,7 @@ class _EvaluateVisitor _endOfImports = 0; _outOfOrderImports = null; } - _inDependency = result.isDependency; + _inDependency = isDependency; // This configuration is only used if it passes through a `@forward` // rule, so we avoid creating unnecessary ones for performance reasons. @@ -1612,43 +1633,43 @@ class _EvaluateVisitor assert(_importSpan == null); _importSpan = span; - var importCache = _importCache; - if (importCache != null) { + if (_importCache case var importCache?) { baseUrl ??= _stylesheet.span.sourceUrl; - var tuple = await importCache.canonicalize(Uri.parse(url), - baseImporter: _importer, baseUrl: baseUrl, forImport: forImport); - - if (tuple != null) { - var isDependency = _inDependency || tuple.item1 != _importer; - var stylesheet = await importCache.importCanonical( - tuple.item1, tuple.item2, - originalUrl: tuple.item3, quiet: _quietDeps && isDependency); - if (stylesheet != null) { - _loadedUrls.add(tuple.item2); - return _LoadedStylesheet(stylesheet, - importer: tuple.item1, isDependency: isDependency); + if (await importCache.canonicalize(Uri.parse(url), + baseImporter: _importer, baseUrl: baseUrl, forImport: forImport) + case (var importer, var canonicalUrl, :var originalUrl)) { + // 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. + _loadedUrls.add(canonicalUrl); + + var isDependency = _inDependency || importer != _importer; + if (await importCache.importCanonical(importer, canonicalUrl, + originalUrl: originalUrl, quiet: _quietDeps && isDependency) + case var stylesheet?) { + return (stylesheet, importer: importer, isDependency: isDependency); } } } else { - var result = await _importLikeNode( - url, baseUrl ?? _stylesheet.span.sourceUrl, forImport); - if (result != null) { - result.stylesheet.span.sourceUrl.andThen(_loadedUrls.add); + if (await _importLikeNode( + url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) + case var result?) { + result.$1.span.sourceUrl.andThen(_loadedUrls.add); return result; } } - if (url.startsWith('package:') && isNode) { + if (url.startsWith('package:') && isJS) { // Special-case this error message, since it's tripped people up in the // past. throw "\"package:\" URLs aren't supported on this platform."; } else { throw "Can't find stylesheet to import."; } - } on SassException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, error.span), stackTrace); - } on Error catch (error, stackTrace) { - throwWithTrace(_exception(error.toString()), stackTrace); + } on SassException { + rethrow; + } on ArgumentError catch (error, stackTrace) { + throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { String? message; try { @@ -1656,7 +1677,7 @@ class _EvaluateVisitor } catch (_) { message = error.toString(); } - throwWithTrace(_exception(message), stackTrace); + throwWithTrace(_exception(message), error, stackTrace); } finally { _importSpan = null; } @@ -1678,15 +1699,15 @@ class _EvaluateVisitor isDependency = true; } - var contents = result.item1; - var url = result.item2; - - return _LoadedStylesheet( - Stylesheet.parse(contents, - url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, - url: url, - logger: _quietDeps && isDependency ? Logger.quiet : _logger), - isDependency: isDependency); + var (contents, url) = result; + return ( + Stylesheet.parse( + contents, url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, + url: url, + logger: _quietDeps && isDependency ? Logger.quiet : _logger), + importer: null, + isDependency: isDependency + ); } /// Adds a CSS import for [import]. @@ -1710,45 +1731,47 @@ class _EvaluateVisitor } Future visitIncludeRule(IncludeRule node) async { + var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); var mixin = _addExceptionSpan(node, () => _environment.getMixin(node.name, namespace: node.namespace)); - if (mixin == null) { - throw _exception("Undefined mixin.", node.span); - } + switch (mixin) { + case null: + throw _exception("Undefined mixin.", node.span); - var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); - if (mixin is AsyncBuiltInCallable) { - if (node.content != null) { + case AsyncBuiltInCallable() when node.content != null: throw _exception("Mixin doesn't accept a content block.", node.span); - } - await _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); - } else if (mixin is UserDefinedCallable) { - if (node.content != null && - !(mixin.declaration as MixinRule).hasContent) { + case AsyncBuiltInCallable(): + await _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); + + case UserDefinedCallable( + declaration: MixinRule(hasContent: false) + ) + when node.content != null: throw MultiSpanSassRuntimeException( "Mixin doesn't accept a content block.", node.spanWithoutContent, "invocation", {mixin.declaration.arguments.spanWithName: "declaration"}, _stackTrace(node.spanWithoutContent)); - } - var contentCallable = node.content.andThen((content) => - UserDefinedCallable(content, _environment.closure(), - inDependency: _inDependency)); - await _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, - () async { - await _environment.withContent(contentCallable, () async { - await _environment.asMixin(() async { - for (var statement in mixin.declaration.children) { - await _addErrorSpan(nodeWithSpan, () => statement.accept(this)); - } + case UserDefinedCallable(): + var contentCallable = node.content.andThen((content) => + UserDefinedCallable(content, _environment.closure(), + inDependency: _inDependency)); + await _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, + () async { + await _environment.withContent(contentCallable, () async { + await _environment.asMixin(() async { + for (var statement in mixin.declaration.children) { + await _addErrorSpan(nodeWithSpan, () => statement.accept(this)); + } + }); }); }); - }); - } else { - throw UnsupportedError("Unknown callable type $mixin."); + + case _: + throw UnsupportedError("Unknown callable type $mixin."); } return null; @@ -1798,12 +1821,7 @@ class _EvaluateVisitor ModifiableCssMediaRule(mergedQueries ?? queries, node.span), () async { await _withMediaQueries(mergedQueries ?? queries, mergedSources, () async { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - await child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the media query so that // declarations immediately inside @media have somewhere to go. // @@ -1814,6 +1832,10 @@ class _EvaluateVisitor await child.accept(this); } }, scopeWhen: false); + } else { + for (var child in node.children) { + await child.accept(this); + } } }); }, @@ -1831,12 +1853,10 @@ class _EvaluateVisitor /// queries. Future> _visitMediaQueries( Interpolation interpolation) async { - var resolved = - await _performInterpolation(interpolation, warnForColor: true); - - // TODO(nweiz): Remove this type argument when sdk#31398 is fixed. - return _adjustParseError>(interpolation, - () => CssMediaQuery.parseList(resolved, logger: _logger)); + var (resolved, map) = + await _performInterpolationWithMap(interpolation, warnForColor: true); + return CssMediaQuery.parseList(resolved, + logger: _logger, interpolationMap: map); } /// Returns a list of queries that selects for contexts that match both @@ -1849,11 +1869,16 @@ class _EvaluateVisitor Iterable queries1, Iterable queries2) { var queries = []; for (var query1 in queries1) { + inner: for (var query2 in queries2) { - var result = query1.merge(query2); - if (result == MediaQueryMergeResult.empty) continue; - if (result == MediaQueryMergeResult.unrepresentable) return null; - queries.add((result as MediaQuerySuccessfulMergeResult).query); + switch (query1.merge(query2)) { + case MediaQueryMergeResult.empty: + continue inner; + case MediaQueryMergeResult.unrepresentable: + return null; + case MediaQuerySuccessfulMergeResult result: + queries.add(result.query); + } } } return queries; @@ -1873,16 +1898,16 @@ class _EvaluateVisitor "Style rules may not be used within nested declarations.", node.span); } - var selectorText = await _interpolationToValue(node.selector, - trim: true, warnForColor: true); + var (selectorText, selectorMap) = + await _performInterpolationWithMap(node.selector, warnForColor: true); + if (_inKeyframes) { // NOTE: this logic is largely duplicated in [visitCssKeyframeBlock]. Most // changes here should be mirrored there. - var parsedSelector = _adjustParseError( - node.selector, - () => KeyframeSelectorParser(selectorText.value, logger: _logger) - .parse()); + var parsedSelector = KeyframeSelectorParser(selectorText, + logger: _logger, interpolationMap: selectorMap) + .parse(); var rule = ModifiableCssKeyframeBlock( CssValue(List.unmodifiable(parsedSelector), node.selector.span), node.span); @@ -1896,20 +1921,15 @@ class _EvaluateVisitor return null; } - var parsedSelector = _adjustParseError( - node.selector, - () => SelectorList.parse(selectorText.value, + var parsedSelector = SelectorList.parse(selectorText, + interpolationMap: selectorMap, allowParent: !_stylesheet.plainCss, allowPlaceholder: !_stylesheet.plainCss, - logger: _logger)); - parsedSelector = _addExceptionSpan( - node.selector, - () => parsedSelector.resolveParentSelectors( - _styleRuleIgnoringAtRoot?.originalSelector, - implicitParent: !_atRootExcludingStyleRule)); - - var selector = _extensionStore.addSelector( - parsedSelector, node.selector.span, _mediaQueries); + logger: _logger) + .resolveParentSelectors(_styleRuleIgnoringAtRoot?.originalSelector, + implicitParent: !_atRootExcludingStyleRule); + + var selector = _extensionStore.addSelector(parsedSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, originalSelector: parsedSelector); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; @@ -1936,16 +1956,16 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - node.selector.span, - deprecation: true); + 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', - node.selector.span, - deprecation: true); + complex.span.trimRight(), + Deprecation.bogusCombinators); } else { _warn( 'The selector "${complex.toString().trim()}" is only valid for ' @@ -1958,13 +1978,13 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - MultiSpan(node.selector.span, 'invalid selector', { + MultiSpan(complex.span.trimRight(), 'invalid selector', { rule.children.first.span: "this is not a style rule" + (rule.children.every((child) => child is CssComment) ? '\n(try converting to a //-style comment)' : '') }), - deprecation: true); + Deprecation.bogusCombinators); } } } @@ -1991,12 +2011,7 @@ class _EvaluateVisitor await _visitSupportsCondition(node.condition), node.condition.span); await _withParent(ModifiableCssSupportsRule(condition, node.span), () async { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - await child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the supports rule so that // declarations immediately inside @supports have somewhere to go. // @@ -2007,6 +2022,10 @@ class _EvaluateVisitor await child.accept(this); } }); + } else { + for (var child in node.children) { + await child.accept(this); + } } }, through: (node) => node is CssStyleRule, @@ -2016,31 +2035,37 @@ class _EvaluateVisitor } /// Evaluates [condition] and converts it to a plain CSS string. - Future _visitSupportsCondition(SupportsCondition condition) async { - if (condition is SupportsOperation) { - return "${await _parenthesize(condition.left, condition.operator)} " - "${condition.operator} " - "${await _parenthesize(condition.right, condition.operator)}"; - } else if (condition is SupportsNegation) { - return "not ${await _parenthesize(condition.condition)}"; - } else if (condition is SupportsInterpolation) { - return await _evaluateToCss(condition.expression, quote: false); - } else if (condition is SupportsDeclaration) { - var oldInSupportsDeclaration = _inSupportsDeclaration; - _inSupportsDeclaration = true; - var result = "(${await _evaluateToCss(condition.name)}:" - "${condition.isCustomProperty ? '' : ' '}" - "${await _evaluateToCss(condition.value)})"; + Future _visitSupportsCondition(SupportsCondition condition) async => + switch (condition) { + SupportsOperation operation => + "${await _parenthesize(operation.left, operation.operator)} " + "${operation.operator} " + "${await _parenthesize(operation.right, operation.operator)}", + SupportsNegation negation => + "not ${await _parenthesize(negation.condition)}", + SupportsInterpolation interpolation => + await _evaluateToCss(interpolation.expression, quote: false), + SupportsDeclaration declaration => await _withSupportsDeclaration( + () async => "(${await _evaluateToCss(declaration.name)}:" + "${declaration.isCustomProperty ? '' : ' '}" + "${await _evaluateToCss(declaration.value)})"), + SupportsFunction function => + "${await _performInterpolation(function.name)}(" + "${await _performInterpolation(function.arguments)})", + SupportsAnything anything => + "(${await _performInterpolation(anything.contents)})", + var condition => throw ArgumentError( + "Unknown supports condition type ${condition.runtimeType}.") + }; + + /// Runs [callback] in a context where [_inSupportsDeclaration] is true. + Future _withSupportsDeclaration(FutureOr callback()) async { + var oldInSupportsDeclaration = _inSupportsDeclaration; + _inSupportsDeclaration = true; + try { + return await callback(); + } finally { _inSupportsDeclaration = oldInSupportsDeclaration; - return result; - } else if (condition is SupportsFunction) { - return "${await _performInterpolation(condition.name)}(" - "${await _performInterpolation(condition.arguments)})"; - } else if (condition is SupportsAnything) { - return "(${await _performInterpolation(condition.contents)})"; - } else { - throw ArgumentError( - "Unknown supports condition type ${condition.runtimeType}."); } } @@ -2052,20 +2077,22 @@ class _EvaluateVisitor /// necessary if [condition] is also a [SupportsOperation]. Future _parenthesize(SupportsCondition condition, [String? operator]) async { - if ((condition is SupportsNegation) || - (condition is SupportsOperation && - (operator == null || operator != condition.operator))) { - return "(${await _visitSupportsCondition(condition)})"; - } else { - return await _visitSupportsCondition(condition); + switch (condition) { + case SupportsNegation(): + case SupportsOperation() + when operator == null || operator != condition.operator: + return "(${await _visitSupportsCondition(condition)})"; + + case _: + return await _visitSupportsCondition(condition); } } Future visitVariableDeclaration(VariableDeclaration node) async { if (node.isGuarded) { if (node.namespace == null && _environment.atRoot) { - var override = _configuration.remove(node.name); - if (override != null && override.value != sassNull) { + if (_configuration.remove(node.name) case var override? + when override.value != sassNull) { _addExceptionSpan(node, () { _environment.setVariable( node.name, override.value, override.assignmentNode, @@ -2095,7 +2122,7 @@ class _EvaluateVisitor "Recommendation: add `${node.originalName}: null` at the " "stylesheet root.", node.span, - deprecation: true); + Deprecation.newGlobal); } var value = @@ -2123,7 +2150,8 @@ class _EvaluateVisitor configuration = ExplicitConfiguration(values, node); } - await _loadModule(node.url, "@use", node, (module) { + await _loadModule(node.url, "@use", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.addModule(module, node, namespace: node.namespace); }, configuration: configuration); _assertConfigurationIsEmpty(configuration); @@ -2143,9 +2171,11 @@ class _EvaluateVisitor Future visitWhileRule(WhileRule node) { return _environment.scope(() async { while ((await node.condition.accept(this)).isTruthy) { - var result = await _handleReturn( - node.children, (child) => child.accept(this)); - if (result != null) return result; + if (await _handleReturn( + node.children, (child) => child.accept(this)) + case var result?) { + return result; + } } return null; }, semiGlobal: true, when: node.hasDeclarations); @@ -2156,95 +2186,70 @@ class _EvaluateVisitor Future visitBinaryOperationExpression(BinaryOperationExpression node) { return _addExceptionSpanAsync(node, () async { var left = await node.left.accept(this); - switch (node.operator) { - case BinaryOperator.singleEquals: - var right = await node.right.accept(this); - return left.singleEquals(right); - - case BinaryOperator.or: - return left.isTruthy ? left : await node.right.accept(this); - - case BinaryOperator.and: - return left.isTruthy ? await node.right.accept(this) : left; - - case BinaryOperator.equals: - var right = await node.right.accept(this); - return SassBoolean(left == right); - - case BinaryOperator.notEquals: - var right = await node.right.accept(this); - return SassBoolean(left != right); - - case BinaryOperator.greaterThan: - var right = await node.right.accept(this); - return left.greaterThan(right); - - case BinaryOperator.greaterThanOrEquals: - var right = await node.right.accept(this); - return left.greaterThanOrEquals(right); - - case BinaryOperator.lessThan: - var right = await node.right.accept(this); - return left.lessThan(right); - - case BinaryOperator.lessThanOrEquals: - var right = await node.right.accept(this); - return left.lessThanOrEquals(right); - - case BinaryOperator.plus: - var right = await node.right.accept(this); - return left.plus(right); - - case BinaryOperator.minus: - var right = await node.right.accept(this); - return left.minus(right); - - case BinaryOperator.times: - var right = await node.right.accept(this); - return left.times(right); - - case BinaryOperator.dividedBy: - var right = await node.right.accept(this); - var result = left.dividedBy(right); - if (node.allowsSlash && left is SassNumber && right is SassNumber) { - return (result as SassNumber).withSlash(left, right); - } else { - if (left is SassNumber && right is SassNumber) { - String recommendation(Expression expression) { - if (expression is BinaryOperationExpression && - expression.operator == BinaryOperator.dividedBy) { - return "math.div(${recommendation(expression.left)}, " - "${recommendation(expression.right)})"; - } else if (expression is ParenthesizedExpression) { - return expression.expression.toString(); - } else { - return expression.toString(); - } - } - - _warn( - "Using / for division outside of calc() is deprecated " - "and will be removed in Dart Sass 2.0.0.\n" - "\n" - "Recommendation: ${recommendation(node)} or calc($node)\n" - "\n" - "More info and automated migrator: " - "https://sass-lang.com/d/slash-div", - node.span, - deprecation: true); - } + return switch (node.operator) { + BinaryOperator.singleEquals => + left.singleEquals(await node.right.accept(this)), + BinaryOperator.or => + left.isTruthy ? left : await node.right.accept(this), + BinaryOperator.and => + left.isTruthy ? await node.right.accept(this) : left, + BinaryOperator.equals => + SassBoolean(left == await node.right.accept(this)), + BinaryOperator.notEquals => + SassBoolean(left != await node.right.accept(this)), + BinaryOperator.greaterThan => + left.greaterThan(await node.right.accept(this)), + BinaryOperator.greaterThanOrEquals => + left.greaterThanOrEquals(await node.right.accept(this)), + BinaryOperator.lessThan => left.lessThan(await node.right.accept(this)), + BinaryOperator.lessThanOrEquals => + left.lessThanOrEquals(await node.right.accept(this)), + BinaryOperator.plus => left.plus(await node.right.accept(this)), + BinaryOperator.minus => left.minus(await node.right.accept(this)), + BinaryOperator.times => left.times(await node.right.accept(this)), + BinaryOperator.dividedBy => + _slash(left, await node.right.accept(this), node), + BinaryOperator.modulo => left.modulo(await node.right.accept(this)) + }; + }); + } - return result; - } + /// Returns the result of the SassScript `/` operation between [left] and + /// [right] in [node]. + Value _slash(Value left, Value right, BinaryOperationExpression node) { + var result = left.dividedBy(right); + switch ((left, right)) { + case (SassNumber left, SassNumber right) when node.allowsSlash: + return (result as SassNumber).withSlash(left, right); + + case (SassNumber(), SassNumber()): + String recommendation(Expression expression) => switch (expression) { + BinaryOperationExpression( + operator: BinaryOperator.dividedBy, + :var left, + :var right + ) => + "math.div(${recommendation(left)}, ${recommendation(right)})", + ParenthesizedExpression() => expression.expression.toString(), + _ => expression.toString() + }; - case BinaryOperator.modulo: - var right = await node.right.accept(this); - return left.modulo(right); + _warn( + "Using / for division outside of calc() is deprecated " + "and will be removed in Dart Sass 2.0.0.\n" + "\n" + "Recommendation: ${recommendation(node)} or " + "${expressionToCalc(node)}\n" + "\n" + "More info and automated migrator: " + "https://sass-lang.com/d/slash-div", + node.span, + Deprecation.slashDiv); + return result; - default: - throw ArgumentError("Unknown binary operator ${node.operator}."); - } - }); + case _: + return result; + } } Future visitValueExpression(ValueExpression node) async => node.value; @@ -2260,18 +2265,12 @@ class _EvaluateVisitor UnaryOperationExpression node) async { var operand = await node.operand.accept(this); return _addExceptionSpan(node, () { - switch (node.operator) { - case UnaryOperator.plus: - return operand.unaryPlus(); - case UnaryOperator.minus: - return operand.unaryMinus(); - case UnaryOperator.divide: - return operand.unaryDivide(); - case UnaryOperator.not: - return operand.unaryNot(); - default: - throw StateError("Unknown unary operator ${node.operator}."); - } + return switch (node.operator) { + UnaryOperator.plus => operand.unaryPlus(), + UnaryOperator.minus => operand.unaryMinus(), + UnaryOperator.divide => operand.unaryDivide(), + UnaryOperator.not => operand.unaryNot() + }; }); } @@ -2279,16 +2278,13 @@ class _EvaluateVisitor SassBoolean(node.value); Future visitIfExpression(IfExpression node) async { - var pair = await _evaluateMacroArguments(node); - var positional = pair.item1; - var named = pair.item2; - + var (positional, named) = await _evaluateMacroArguments(node); _verifyArguments(positional.length, named, IfExpression.declaration, node); // ignore: prefer_is_empty - var condition = positional.length > 0 ? positional[0] : named["condition"]!; - var ifTrue = positional.length > 1 ? positional[1] : named["if-true"]!; - var ifFalse = positional.length > 2 ? positional[2] : named["if-false"]!; + var condition = positional.elementAtOrNull(0) ?? named["condition"]!; + var ifTrue = positional.elementAtOrNull(1) ?? named["if-true"]!; + var ifFalse = positional.elementAtOrNull(2) ?? named["if-false"]!; var result = (await condition.accept(this)).isTruthy ? ifTrue : ifFalse; return _withoutSlash(await result.accept(this), _expressionNode(result)); @@ -2313,28 +2309,20 @@ class _EvaluateVisitor } try { - switch (node.name) { - case "calc": - assert(arguments.length == 1); - return SassCalculation.calc(arguments[0]); - case "min": - return SassCalculation.min(arguments); - case "max": - return SassCalculation.max(arguments); - case "clamp": - return SassCalculation.clamp( - arguments[0], - arguments.length > 1 ? arguments[1] : null, - arguments.length > 2 ? arguments[2] : null); - default: - throw UnsupportedError('Unknown calculation name "${node.name}".'); - } + return switch (node.name) { + "calc" => SassCalculation.calc(arguments[0]), + "min" => SassCalculation.min(arguments), + "max" => SassCalculation.max(arguments), + "clamp" => SassCalculation.clamp(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + _ => throw UnsupportedError('Unknown calculation name "${node.name}".') + }; } on SassScriptException catch (error, stackTrace) { // The simplification logic in the [SassCalculation] static methods will // throw an error if the arguments aren't compatible, but we have access // to the original spans so we can throw a more informative error. _verifyCompatibleNumbers(arguments, node.arguments); - throwWithTrace(_exception(error.message, node.span), stackTrace); + throwWithTrace(_exception(error.message, node.span), error, stackTrace); } } @@ -2348,9 +2336,7 @@ class _EvaluateVisitor // SassCalculation._verifyCompatibleNumbers and most changes here should // also be reflected there. for (var i = 0; i < args.length; i++) { - var arg = args[i]; - if (arg is! SassNumber) continue; - if (arg.numeratorUnits.length > 1 || arg.denominatorUnits.isNotEmpty) { + if (args[i] case SassNumber arg when arg.hasComplexUnits) { throw _exception("Number $arg isn't compatible with CSS calculations.", nodesWithSpans[i].span); } @@ -2382,57 +2368,70 @@ class _EvaluateVisitor /// old global `min()` and `max()` functions. Future _visitCalculationValue(Expression node, {required bool inMinMax}) async { - if (node is ParenthesizedExpression) { - var inner = node.expression; - var result = await _visitCalculationValue(inner, inMinMax: inMinMax); - return inner is FunctionExpression && - inner.name.toLowerCase() == 'var' && - result is SassString && - !result.hasQuotes - ? SassString('(${result.text})', quotes: false) - : result; - } else if (node is StringExpression) { - assert(!node.hasQuotes); - return CalculationInterpolation(await _performInterpolation(node.text)); - } else if (node is BinaryOperationExpression) { - return await _addExceptionSpanAsync( - node, - () async => SassCalculation.operateInternal( - _binaryOperatorToCalculationOperator(node.operator), - await _visitCalculationValue(node.left, inMinMax: inMinMax), - await _visitCalculationValue(node.right, inMinMax: inMinMax), - inMinMax: inMinMax, - simplify: !_inSupportsDeclaration)); - } else { - assert(node is NumberExpression || - node is CalculationExpression || - node is VariableExpression || - node is FunctionExpression || - node is IfExpression); - var result = await node.accept(this); - if (result is SassNumber || result is SassCalculation) return result; - if (result is SassString && !result.hasQuotes) return result; - throw _exception( - "Value $result can't be used in a calculation.", node.span); + switch (node) { + case ParenthesizedExpression(expression: var inner): + var result = await _visitCalculationValue(inner, inMinMax: inMinMax); + return inner is FunctionExpression && + inner.name.toLowerCase() == 'var' && + result is SassString && + !result.hasQuotes + ? SassString('(${result.text})', quotes: false) + : result; + + case StringExpression(text: Interpolation(asPlain: var text?)): + assert(!node.hasQuotes); + return switch (text.toLowerCase()) { + 'pi' => SassNumber(math.pi), + 'e' => SassNumber(math.e), + 'infinity' => SassNumber(double.infinity), + '-infinity' => SassNumber(double.negativeInfinity), + 'nan' => SassNumber(double.nan), + _ => SassString(text, quotes: false) + }; + + // If there's actual interpolation, create a CalculationInterpolation. + // Otherwise, create an UnquotedString. The main difference is that + // UnquotedStrings don't get extra defensive parentheses. + case StringExpression(): + assert(!node.hasQuotes); + return CalculationInterpolation(await _performInterpolation(node.text)); + + case BinaryOperationExpression(:var operator, :var left, :var right): + return await _addExceptionSpanAsync( + node, + () async => SassCalculation.operateInternal( + _binaryOperatorToCalculationOperator(operator), + await _visitCalculationValue(left, inMinMax: inMinMax), + await _visitCalculationValue(right, inMinMax: inMinMax), + inMinMax: inMinMax, + simplify: !_inSupportsDeclaration)); + + case _: + assert(node is NumberExpression || + node is CalculationExpression || + node is VariableExpression || + node is FunctionExpression || + node is IfExpression); + return switch (await node.accept(this)) { + SassNumber result => result, + SassCalculation result => result, + SassString result when !result.hasQuotes => result, + var result => throw _exception( + "Value $result can't be used in a calculation.", node.span) + }; } } /// Returns the [CalculationOperator] that corresponds to [operator]. CalculationOperator _binaryOperatorToCalculationOperator( - BinaryOperator operator) { - switch (operator) { - case BinaryOperator.plus: - return CalculationOperator.plus; - case BinaryOperator.minus: - return CalculationOperator.minus; - case BinaryOperator.times: - return CalculationOperator.times; - case BinaryOperator.dividedBy: - return CalculationOperator.dividedBy; - default: - throw UnsupportedError("Invalid calculation operator $operator."); - } - } + BinaryOperator operator) => + switch (operator) { + BinaryOperator.plus => CalculationOperator.plus, + BinaryOperator.minus => CalculationOperator.minus, + BinaryOperator.times => CalculationOperator.times, + BinaryOperator.dividedBy => CalculationOperator.dividedBy, + _ => throw UnsupportedError("Invalid calculation operator $operator.") + }; Future visitColorExpression(ColorExpression node) async => node.value; @@ -2446,22 +2445,22 @@ class _EvaluateVisitor Future visitMapExpression(MapExpression node) async { var map = {}; var keyNodes = {}; - for (var pair in node.pairs) { - var keyValue = await pair.item1.accept(this); - var valueValue = await pair.item2.accept(this); + for (var (key, value) in node.pairs) { + var keyValue = await key.accept(this); + var valueValue = await value.accept(this); var oldValue = map[keyValue]; if (oldValue != null) { var oldValueSpan = keyNodes[keyValue]?.span; throw MultiSpanSassRuntimeException( 'Duplicate key.', - pair.item1.span, + key.span, 'second key', {if (oldValueSpan != null) oldValueSpan: 'first key'}, - _stackTrace(pair.item1.span)); + _stackTrace(key.span)); } map[keyValue] = valueValue; - keyNodes[keyValue] = pair.item1; + keyNodes[keyValue] = key; } return SassMap(map); } @@ -2615,22 +2614,32 @@ class _EvaluateVisitor } var buffer = StringBuffer("${callable.name}("); - var first = true; - for (var argument in arguments.positional) { - if (first) { - first = false; - } else { - buffer.write(", "); - } + try { + var first = true; + for (var argument in arguments.positional) { + if (first) { + first = false; + } else { + buffer.write(", "); + } - buffer.write(await _evaluateToCss(argument)); - } + buffer.write(await _evaluateToCss(argument)); + } - var restArg = arguments.rest; - if (restArg != null) { - var rest = await restArg.accept(this); - if (!first) buffer.write(", "); - buffer.write(_serialize(rest, restArg)); + var restArg = arguments.rest; + if (restArg != null) { + var rest = await restArg.accept(this); + if (!first) buffer.write(", "); + buffer.write(_serialize(rest, restArg)); + } + } on SassRuntimeException catch (error) { + if (!error.message.endsWith("isn't a valid CSS value.")) rethrow; + throw MultiSpanSassRuntimeException( + error.message, + error.span, + "value", + {nodeWithSpan.span: "unknown function treated as plain CSS"}, + error.trace); } buffer.writeCharCode($rparen); @@ -2650,9 +2659,8 @@ class _EvaluateVisitor _callableNode = nodeWithSpan; var namedSet = MapKeySet(evaluated.named); - var tuple = callable.callbackFor(evaluated.positional.length, namedSet); - var overload = tuple.item1; - var callback = tuple.item2; + var (overload, callback) = + callable.callbackFor(evaluated.positional.length, namedSet); _addExceptionSpan(nodeWithSpan, () => overload.verify(evaluated.positional.length, namedSet)); @@ -2686,27 +2694,10 @@ class _EvaluateVisitor Value result; try { - result = await callback(evaluated.positional); - } on SassRuntimeException { + result = await _addExceptionSpanAsync( + nodeWithSpan, () => callback(evaluated.positional)); + } on SassException { rethrow; - } on MultiSpanSassScriptException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - nodeWithSpan.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(nodeWithSpan.span)), - stackTrace); - } on MultiSpanSassException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - error.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(error.span)), - stackTrace); } catch (error, stackTrace) { String? message; try { @@ -2714,7 +2705,7 @@ class _EvaluateVisitor } catch (_) { message = error.toString(); } - throwWithTrace(_exception(message, nodeWithSpan.span), stackTrace); + throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); } _callableNode = oldCallableNode; @@ -2750,17 +2741,21 @@ class _EvaluateVisitor var named = {}; var namedNodes = {}; - for (var entry in arguments.named.entries) { - var nodeForSpan = _expressionNode(entry.value); - named[entry.key] = - _withoutSlash(await entry.value.accept(this), nodeForSpan); - namedNodes[entry.key] = nodeForSpan; + for (var (name, value) in arguments.named.pairs) { + var nodeForSpan = _expressionNode(value); + named[name] = _withoutSlash(await value.accept(this), nodeForSpan); + namedNodes[name] = nodeForSpan; } var restArgs = arguments.rest; if (restArgs == null) { - return _ArgumentResults(positional, positionalNodes, named, namedNodes, - ListSeparator.undecided); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: ListSeparator.undecided + ); } var rest = await restArgs.accept(this); @@ -2791,8 +2786,13 @@ class _EvaluateVisitor var keywordRestArgs = arguments.keywordRest; if (keywordRestArgs == null) { - return _ArgumentResults( - positional, positionalNodes, named, namedNodes, separator); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: separator + ); } var keywordRest = await keywordRestArgs.accept(this); @@ -2803,8 +2803,13 @@ class _EvaluateVisitor for (var key in keywordRest.contents.keys) (key as SassString).text: keywordRestNodeForSpan }); - return _ArgumentResults( - positional, positionalNodes, named, namedNodes, separator); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: separator + ); } else { throw _exception( "Variable keyword arguments must be a map (was $keywordRest).", @@ -2817,12 +2822,11 @@ class _EvaluateVisitor /// /// Returns the arguments as expressions so that they can be lazily evaluated /// for macros such as `if()`. - Future, Map>> + Future<(List positional, Map named)> _evaluateMacroArguments(CallableInvocation invocation) async { var restArgs_ = invocation.arguments.rest; if (restArgs_ == null) { - return Tuple2( - invocation.arguments.positional, invocation.arguments.named); + return (invocation.arguments.positional, invocation.arguments.named); } var restArgs = restArgs_; // dart-lang/sdk#45348 @@ -2848,7 +2852,7 @@ class _EvaluateVisitor } var keywordRestArgs_ = invocation.arguments.keywordRest; - if (keywordRestArgs_ == null) return Tuple2(positional, named); + if (keywordRestArgs_ == null) return (positional, named); var keywordRestArgs = keywordRestArgs_; // dart-lang/sdk#45348 var keywordRest = await keywordRestArgs.accept(this); @@ -2861,7 +2865,7 @@ class _EvaluateVisitor (value) => ValueExpression( _withoutSlash(value, keywordRestNodeForSpan), keywordRestArgs.span)); - return Tuple2(positional, named); + return (positional, named); } else { throw _exception( "Variable keyword arguments must be a map (was $keywordRest).", @@ -2911,15 +2915,17 @@ class _EvaluateVisitor var oldInSupportsDeclaration = _inSupportsDeclaration; _inSupportsDeclaration = false; var result = SassString( - (await mapAsync(node.text.contents, (value) async { - if (value is String) return value; - var expression = value as Expression; - var result = await expression.accept(this); - return result is SassString - ? result.text - : _serialize(result, expression, quote: false); - })) - .join(), + [ + for (var value in node.text.contents) + switch (value) { + String() => value, + Expression() => switch (await value.accept(this)) { + SassString(:var text) => text, + var result => _serialize(result, value, quote: false) + }, + _ => throw UnsupportedError("Unknown interpolation value $value") + } + ].join(), quotes: node.hasQuotes); _inSupportsDeclaration = oldInSupportsDeclaration; return result; @@ -2992,7 +2998,7 @@ class _EvaluateVisitor Future visitCssDeclaration(CssDeclaration node) async { _parent.addChild(ModifiableCssDeclaration(node.name, node.value, node.span, - parsedAsCustomProperty: node.isCustomProperty, + parsedAsCustomProperty: node.parsedAsCustomProperty, valueSpanForMap: node.valueSpanForMap)); } @@ -3046,12 +3052,7 @@ class _EvaluateVisitor () async { await _withMediaQueries(mergedQueries ?? node.queries, mergedSources, () async { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - await child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the media query so that // declarations immediately inside @media have somewhere to go. // @@ -3062,6 +3063,10 @@ class _EvaluateVisitor await child.accept(this); } }, scopeWhen: false); + } else { + for (var child in node.children) { + await child.accept(this); + } } }); }, @@ -3083,11 +3088,10 @@ class _EvaluateVisitor } var styleRule = _styleRule; - var originalSelector = node.selector.value.resolveParentSelectors( + var originalSelector = node.selector.resolveParentSelectors( styleRule?.originalSelector, implicitParent: !_atRootExcludingStyleRule); - var selector = _extensionStore.addSelector( - originalSelector, node.selector.span, _mediaQueries); + var selector = _extensionStore.addSelector(originalSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, originalSelector: originalSelector); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; @@ -3101,8 +3105,7 @@ class _EvaluateVisitor }, through: (node) => node is CssStyleRule, scopeWhen: false); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; - if (styleRule == null && _parent.children.isNotEmpty) { - var lastChild = _parent.children.last; + if (_parent.children case [..., var lastChild] when styleRule == null) { lastChild.isGroupEnd = true; } } @@ -3125,12 +3128,7 @@ class _EvaluateVisitor await _withParent(ModifiableCssSupportsRule(node.condition, node.span), () async { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - await child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the supports rule so that // declarations immediately inside @supports have somewhere to go. // @@ -3141,6 +3139,10 @@ class _EvaluateVisitor await child.accept(this); } }); + } else { + for (var child in node.children) { + await child.accept(this); + } } }, through: (node) => node is CssStyleRule, scopeWhen: false); } @@ -3154,8 +3156,7 @@ class _EvaluateVisitor Future _handleReturn( List list, Future callback(T value)) async { for (var value in list) { - var result = await callback(value); - if (result != null) return result; + if (await callback(value) case var result?) return result; } return null; } @@ -3189,16 +3190,46 @@ class _EvaluateVisitor /// values passed into the interpolation. Future _performInterpolation(Interpolation interpolation, {bool warnForColor = false}) async { + var (result, _) = await _performInterpolationHelper(interpolation, + sourceMap: true, warnForColor: warnForColor); + return result; + } + + /// Like [_performInterpolation], but also returns a [InterpolationMap] that + /// can map spans from the resulting string back to the original + /// [interpolation]. + Future<(String, InterpolationMap)> _performInterpolationWithMap( + Interpolation interpolation, + {bool warnForColor = false}) async { + var (result, map) = await _performInterpolationHelper(interpolation, + sourceMap: true, warnForColor: warnForColor); + return (result, map!); + } + + /// A helper that implements the core logic of both [_performInterpolation] + /// and [_performInterpolationWithMap]. + Future<(String, InterpolationMap?)> _performInterpolationHelper( + Interpolation interpolation, + {required bool sourceMap, + bool warnForColor = false}) async { + var targetLocations = sourceMap ? [] : null; var oldInSupportsDeclaration = _inSupportsDeclaration; _inSupportsDeclaration = false; - var result = (await mapAsync(interpolation.contents, (value) async { - if (value is String) return value; + var buffer = StringBuffer(); + var first = true; + for (var value in interpolation.contents) { + if (!first) targetLocations?.add(SourceLocation(buffer.length)); + first = false; + + if (value is String) { + buffer.write(value); + continue; + } + var expression = value as Expression; var result = await expression.accept(this); - if (warnForColor && - result is SassColor && - namesByColor.containsKey(result)) { + if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, StringExpression(Interpolation([""], interpolation.span), @@ -3215,11 +3246,15 @@ class _EvaluateVisitor expression.span); } - return _serialize(result, expression, quote: false); - })) - .join(); + buffer.write(_serialize(result, expression, quote: false)); + } _inSupportsDeclaration = oldInSupportsDeclaration; - return result; + + return ( + buffer.toString(), + targetLocations.andThen( + (targetLocations) => InterpolationMap(interpolation, targetLocations)) + ); } /// Evaluates [expression] and calls `toCssString()` and wraps a @@ -3294,12 +3329,12 @@ class _EvaluateVisitor var parent = _parent; if (through != null) { while (through(parent)) { - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError( "through() must return false for at least one parent of $node."); } - parent = grandparent; } // If the parent has a (visible) following sibling, we shouldn't add to @@ -3308,8 +3343,14 @@ class _EvaluateVisitor if (parent.hasFollowingSibling) { // A node with siblings must have a parent var grandparent = parent.parent!; - parent = parent.copyWithoutChildren(); - grandparent.addChild(parent); + if (parent.equalsIgnoringChildren(grandparent.children.last)) { + // If we've already made a copy of [parent] and nothing else has been + // added after it, re-use it. + parent = grandparent.children.last as ModifiableCssParentNode; + } else { + parent = parent.copyWithoutChildren(); + grandparent.addChild(parent); + } } } @@ -3353,7 +3394,7 @@ class _EvaluateVisitor /// real work to manufacture a source span. Future _withStackFrame( String member, AstNode nodeWithSpan, Future callback()) async { - _stack.add(Tuple2(_member, nodeWithSpan)); + _stack.add((_member, nodeWithSpan)); var oldMember = _member; _member = member; var result = await callback(); @@ -3365,16 +3406,12 @@ class _EvaluateVisitor /// Like [Value.withoutSlash], but produces a deprecation warning if [value] /// was a slash-separated number. Value _withoutSlash(Value value, AstNode nodeForSpan) { - if (value is SassNumber && value.asSlash != null) { - String recommendation(SassNumber number) { - var asSlash = number.asSlash; - if (asSlash != null) { - return "math.div(${recommendation(asSlash.item1)}, " - "${recommendation(asSlash.item2)})"; - } else { - return number.toString(); - } - } + if (value case SassNumber(asSlash: _?)) { + String recommendation(SassNumber number) => switch (number.asSlash) { + (var before, var after) => + "math.div(${recommendation(before)}, ${recommendation(after)})", + _ => number.toString() + }; _warn( "Using / for division is deprecated and will be removed in Dart Sass " @@ -3385,7 +3422,7 @@ class _EvaluateVisitor "More info and automated migrator: " "https://sass-lang.com/d/slash-div", nodeForSpan.span, - deprecation: true); + Deprecation.slashDiv); } return value.withoutSlash(); @@ -3401,22 +3438,28 @@ class _EvaluateVisitor /// If [span] is passed, it's used for the innermost stack frame. Trace _stackTrace([FileSpan? span]) { var frames = [ - ..._stack.map((tuple) => _stackFrame(tuple.item1, tuple.item2.span)), + for (var (member, nodeWithSpan) in _stack) + _stackFrame(member, nodeWithSpan.span), if (span != null) _stackFrame(_member, span) ]; return Trace(frames.reversed); } /// Emits a warning with the given [message] about the given [span]. - void _warn(String message, FileSpan span, {bool deprecation = false}) { + void _warn(String message, FileSpan span, [Deprecation? deprecation]) { if (_quietDeps && (_inDependency || (_currentCallable?.inDependency ?? false))) { return; } - if (!_warningsEmitted.add(Tuple2(message, span))) return; - _logger.warn(message, - span: span, trace: _stackTrace(span), deprecation: deprecation); + if (!_warningsEmitted.add((message, span))) return; + var trace = _stackTrace(span); + if (deprecation == null) { + _logger.warn(message, span: span, trace: trace); + } else { + _logger.warnForDeprecation(deprecation, message, + span: span, trace: trace); + } } /// Returns a [SassRuntimeException] with the given [message]. @@ -3424,7 +3467,7 @@ class _EvaluateVisitor /// If [span] is passed, it's used for the innermost stack frame. SassRuntimeException _exception(String message, [FileSpan? span]) => SassRuntimeException( - message, span ?? _stack.last.item2.span, _stackTrace(span)); + message, span ?? _stack.last.$2.span, _stackTrace(span)); /// Returns a [MultiSpanSassRuntimeException] with the given [message], /// [primaryLabel], and [secondaryLabels]. @@ -3432,36 +3475,8 @@ class _EvaluateVisitor /// The primary span is taken from the current stack trace span. SassRuntimeException _multiSpanException(String message, String primaryLabel, Map secondaryLabels) => - MultiSpanSassRuntimeException(message, _stack.last.item2.span, - primaryLabel, secondaryLabels, _stackTrace()); - - /// Runs [callback], and adjusts any [SassFormatException] to be within - /// [nodeWithSpan]'s source span. - /// - /// Specifically, this adjusts format exceptions so that the errors are - /// reported as though the text being parsed were exactly in [span]. This may - /// not be quite accurate if the source text contained interpolation, but - /// it'll still produce a useful error. - /// - /// This takes an [AstNode] rather than a [FileSpan] so it can avoid calling - /// [AstNode.span] if the span isn't required, since some nodes need to do - /// real work to manufacture a source span. - T _adjustParseError(AstNode nodeWithSpan, T callback()) { - try { - return callback(); - } on SassFormatException catch (error, stackTrace) { - var errorText = error.span.file.getText(0); - var span = nodeWithSpan.span; - var syntheticFile = span.file - .getText(0) - .replaceRange(span.start.offset, span.end.offset, errorText); - var syntheticSpan = - SourceFile.fromString(syntheticFile, url: span.file.url).span( - span.start.offset + error.span.start.offset, - span.start.offset + error.span.end.offset); - throwWithTrace(_exception(error.message, syntheticSpan), stackTrace); - } - } + MultiSpanSassRuntimeException(message, _stack.last.$2.span, primaryLabel, + secondaryLabels, _stackTrace()); /// Runs [callback], and converts any [SassScriptException]s it throws to /// [SassRuntimeException]s with [nodeWithSpan]'s source span. @@ -3469,39 +3484,50 @@ class _EvaluateVisitor /// This takes an [AstNode] rather than a [FileSpan] so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - T _addExceptionSpan(AstNode nodeWithSpan, T callback()) { + /// + /// If [addStackFrame] is true (the default), this will add an innermost stack + /// frame for [nodeWithSpan]. Otherwise, it will use the existing stack as-is. + T _addExceptionSpan(AstNode nodeWithSpan, T callback(), + {bool addStackFrame = true}) { try { return callback(); - } on MultiSpanSassScriptException catch (error, stackTrace) { + } on SassScriptException catch (error, stackTrace) { throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - nodeWithSpan.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(nodeWithSpan.span)), + error + .withSpan(nodeWithSpan.span) + .withTrace(_stackTrace(addStackFrame ? nodeWithSpan.span : null)), + error, stackTrace); - } on SassScriptException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, nodeWithSpan.span), stackTrace); } } /// Like [_addExceptionSpan], but for an asynchronous [callback]. Future _addExceptionSpanAsync( - AstNode nodeWithSpan, FutureOr callback()) async { + AstNode nodeWithSpan, FutureOr callback(), + {bool addStackFrame = true}) async { try { return await callback(); - } on MultiSpanSassScriptException catch (error, stackTrace) { + } on SassScriptException catch (error, stackTrace) { throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - nodeWithSpan.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(nodeWithSpan.span)), + error + .withSpan(nodeWithSpan.span) + .withTrace(_stackTrace(addStackFrame ? nodeWithSpan.span : null)), + error, stackTrace); - } on SassScriptException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, nodeWithSpan.span), stackTrace); + } + } + + /// Runs [callback], and converts any [SassException]s that aren't already + /// [SassRuntimeException]s to [SassRuntimeException]s with the current stack + /// trace. + Future _addExceptionTrace(FutureOr callback()) async { + try { + return await callback(); + } on SassRuntimeException { + rethrow; + } on SassException catch (error, stackTrace) { + throwWithTrace( + error.withTrace(_stackTrace(error.span)), error, stackTrace); } } @@ -3515,6 +3541,7 @@ class _EvaluateVisitor if (!error.span.text.startsWith("@error")) rethrow; throwWithTrace( SassRuntimeException(error.message, nodeWithSpan.span, _stackTrace()), + error, stackTrace); } } @@ -3531,7 +3558,7 @@ class _EvaluateVisitor /// because it will add the parent selector to the CSS if the `@import` appeared /// in a nested context, but the parent selector was already added when the /// imported stylesheet was evaluated. -class _ImportedCssVisitor implements ModifiableCssVisitor { +final class _ImportedCssVisitor implements ModifiableCssVisitor { /// The visitor in whose context this was created. final _EvaluateVisitor _visitor; @@ -3591,89 +3618,79 @@ class _ImportedCssVisitor implements ModifiableCssVisitor { /// The result of compiling a Sass document to a CSS tree, along with metadata /// about the compilation process. -class EvaluateResult { +typedef EvaluateResult = ({ /// The CSS syntax tree. - final CssStylesheet stylesheet; + CssStylesheet stylesheet, /// The canonical URLs of all stylesheets loaded during compilation. - final Set loadedUrls; - - EvaluateResult(this.stylesheet, this.loadedUrls); -} + Set loadedUrls +}); /// An implementation of [EvaluationContext] using the information available in /// [_EvaluateVisitor]. -class _EvaluationContext implements EvaluationContext { +final class _EvaluationContext implements EvaluationContext { /// The visitor backing this context. final _EvaluateVisitor _visitor; /// The AST node whose span should be used for [warn] if no other span is - /// avaiable. + /// available. final AstNode _defaultWarnNodeWithSpan; _EvaluationContext(this._visitor, this._defaultWarnNodeWithSpan); FileSpan get currentCallableSpan { - var callableNode = _visitor._callableNode; - if (callableNode != null) return callableNode.span; + if (_visitor._callableNode case var callableNode?) return callableNode.span; throw StateError("No Sass callable is currently being evaluated."); } - void warn(String message, {bool deprecation = false}) { + void warn(String message, [Deprecation? deprecation]) { _visitor._warn( message, _visitor._importSpan ?? _visitor._callableNode?.span ?? _defaultWarnNodeWithSpan.span, - deprecation: deprecation); + deprecation); } } /// The result of evaluating arguments to a function or mixin. -class _ArgumentResults { +typedef _ArgumentResults = ({ /// Arguments passed by position. - final List positional; + List positional, /// The [AstNode]s that hold the spans for each [positional] argument. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final List positionalNodes; + List positionalNodes, /// Arguments passed by name. - final Map named; + Map named, /// The [AstNode]s that hold the spans for each [named] argument. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final Map namedNodes; + Map namedNodes, /// The separator used for the rest argument list, if any. - final ListSeparator separator; - - _ArgumentResults(this.positional, this.positionalNodes, this.named, - this.namedNodes, this.separator); -} + ListSeparator separator +}); /// The result of loading a stylesheet via [AsyncEvaluator._loadStylesheet]. -class _LoadedStylesheet { +typedef _LoadedStylesheet = ( /// The stylesheet itself. - final Stylesheet stylesheet; - + Stylesheet stylesheet, { /// The importer that was used to load the stylesheet. /// /// This is `null` when running in Node Sass compatibility mode. - final AsyncImporter? importer; + AsyncImporter? importer, /// Whether this load counts as a dependency. /// /// That is, whether this was (transitively) loaded through a load path or /// importer rather than relative to the entrypoint. - final bool isDependency; - - _LoadedStylesheet(this.stylesheet, - {this.importer, required this.isDependency}); -} + bool isDependency +}); diff --git a/lib/src/visitor/clone_css.dart b/lib/src/visitor/clone_css.dart index 73b0f8b76..d011c986a 100644 --- a/lib/src/visitor/clone_css.dart +++ b/lib/src/visitor/clone_css.dart @@ -2,34 +2,31 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:tuple/tuple.dart'; - import '../ast/css.dart'; import '../ast/css/modifiable.dart'; import '../ast/selector.dart'; import '../extend/extension_store.dart'; +import '../util/box.dart'; import 'interface/css.dart'; /// Returns deep copies of both [stylesheet] and [extender]. /// /// The [extender] must be associated with [stylesheet]. -Tuple2 cloneCssStylesheet( +(ModifiableCssStylesheet, ExtensionStore) cloneCssStylesheet( CssStylesheet stylesheet, ExtensionStore extensionStore) { - var result = extensionStore.clone(); - var newExtensionStore = result.item1; - var oldToNewSelectors = result.item2; + var (newExtensionStore, oldToNewSelectors) = extensionStore.clone(); - return Tuple2( - _CloneCssVisitor(oldToNewSelectors).visitCssStylesheet(stylesheet), - newExtensionStore); + return ( + _CloneCssVisitor(oldToNewSelectors).visitCssStylesheet(stylesheet), + newExtensionStore + ); } /// A visitor that creates a deep (and mutable) copy of a [CssStylesheet]. -class _CloneCssVisitor implements CssVisitor { +final class _CloneCssVisitor implements CssVisitor { /// A map from selectors in the original stylesheet to selectors generated for /// the new stylesheet using [ExtensionStore.clone]. - final Map, ModifiableCssValue> - _oldToNewSelectors; + final Map> _oldToNewSelectors; _CloneCssVisitor(this._oldToNewSelectors); @@ -58,17 +55,16 @@ class _CloneCssVisitor implements CssVisitor { _visitChildren(ModifiableCssMediaRule(node.queries, node.span), node); ModifiableCssStyleRule visitCssStyleRule(CssStyleRule node) { - var newSelector = _oldToNewSelectors[node.selector]; - if (newSelector == null) { + if (_oldToNewSelectors[node.selector] case var newSelector?) { + return _visitChildren( + ModifiableCssStyleRule(newSelector, node.span, + originalSelector: node.originalSelector), + node); + } else { throw StateError( "The ExtensionStore and CssStylesheet passed to cloneCssStylesheet() " "must come from the same compilation."); } - - return _visitChildren( - ModifiableCssStyleRule(newSelector, node.span, - originalSelector: node.originalSelector), - node); } ModifiableCssStylesheet visitCssStylesheet(CssStylesheet node) => diff --git a/lib/src/visitor/evaluate.dart b/lib/src/visitor/evaluate.dart index fd97f020e..4872ba4ec 100644 --- a/lib/src/visitor/evaluate.dart +++ b/lib/src/visitor/evaluate.dart @@ -5,13 +5,14 @@ // DO NOT EDIT. This file was generated from async_evaluate.dart. // See tool/grind/synchronize.dart for details. // -// Checksum: a92baa2c76cd9dd9bece8f386aec1b502d8e1fa1 +// Checksum: 4dbb0910bb0250c07a9ae7c302d9c9403d94c12a // // ignore_for_file: unused_import import 'async_evaluate.dart' show EvaluateResult; export 'async_evaluate.dart' show EvaluateResult; +import 'dart:collection'; import 'dart:math' as math; import 'package:charcode/charcode.dart'; @@ -19,7 +20,6 @@ import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; -import 'package:tuple/tuple.dart'; import '../ast/css.dart'; import '../ast/css/modifiable.dart'; @@ -32,6 +32,7 @@ import '../callable.dart'; import '../color_names.dart'; import '../configuration.dart'; import '../configured_value.dart'; +import '../deprecation.dart'; import '../evaluation_context.dart'; import '../exception.dart'; import '../extend/extension_store.dart'; @@ -40,6 +41,7 @@ import '../functions.dart'; 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'; @@ -47,9 +49,12 @@ import '../module/built_in.dart'; import '../parse/keyframe_selector.dart'; import '../syntax.dart'; import '../utils.dart'; +import '../util/map.dart'; import '../util/multi_span.dart'; import '../util/nullable.dart'; +import '../util/span.dart'; import '../value.dart'; +import 'expression_to_calc.dart'; import 'interface/css.dart'; import 'interface/expression.dart'; import 'interface/modifiable_css.dart'; @@ -99,7 +104,7 @@ EvaluateResult evaluate(Stylesheet stylesheet, /// A class that can evaluate multiple independent statements and expressions /// in the context of a single module. -class Evaluator { +final class Evaluator { /// The visitor that evaluates each expression and statement. final _EvaluateVisitor _visitor; @@ -128,7 +133,7 @@ class Evaluator { } /// A visitor that executes Sass code to produce a CSS tree. -class _EvaluateVisitor +final class _EvaluateVisitor implements StatementVisitor, ExpressionVisitor, @@ -168,7 +173,7 @@ class _EvaluateVisitor /// /// We only want to emit one warning per location, to avoid blowing up users' /// consoles with redundant warnings. - final _warningsEmitted = >{}; + final _warningsEmitted = <(String, SourceSpan)>{}; /// Whether to avoid emitting warnings for files loaded from dependencies. final bool _quietDeps; @@ -261,13 +266,13 @@ class _EvaluateVisitor /// The dynamic call stack representing function invocations, mixin /// invocations, and imports surrounding the current context. /// - /// Each member is a tuple of the span where the stack trace starts and the + /// Each member is a pair of the span where the stack trace starts and the /// name of the member being invoked. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final _stack = >[]; + final _stack = <(String, AstNode)>[]; /// Whether we're running in Node Sass-compatibility mode. bool get _asNodeSass => _nodeImporter != null; @@ -311,6 +316,13 @@ class _EvaluateVisitor /// stylesheet. List? _outOfOrderImports; + /// A map from modules loaded by the current module to loud comments written + /// in this module that should appear before the loaded module. + /// + /// This is `null` unless there are any pre-module comments in the current + /// stylesheet. + Map, List>? _preModuleComments; + /// The extension store that tracks extensions and style rules for the current /// module. ExtensionStore get _extensionStore => @@ -396,8 +408,8 @@ class _EvaluateVisitor } return SassMap({ - for (var entry in module.variables.entries) - SassString(entry.key): entry.value + for (var (name, value) in module.variables.pairs) + SassString(name): value }); }, url: "sass:meta"), @@ -409,8 +421,8 @@ class _EvaluateVisitor } return SassMap({ - for (var entry in module.functions.entries) - SassString(entry.key): SassFunction(entry.value) + for (var (name, value) in module.functions.pairs) + SassString(name): SassFunction(value) }); }, url: "sass:meta"), @@ -420,19 +432,20 @@ class _EvaluateVisitor var css = arguments[1].isTruthy; var module = arguments[2].realNull?.assertString("module"); - if (css && module != null) { - throw r"$css and $module may not both be passed at once."; + if (css) { + if (module != null) { + throw r"$css and $module may not both be passed at once."; + } + return SassFunction(PlainCssCallable(name.text)); } - var callable = css - ? PlainCssCallable(name.text) - : _addExceptionSpan( - _callableNode!, - () => _getFunction(name.text.replaceAll("_", "-"), - namespace: module?.text)); - if (callable != null) return SassFunction(callable); + var callable = _addExceptionSpan( + _callableNode!, + () => _getFunction(name.text.replaceAll("_", "-"), + namespace: module?.text)); + if (callable == null) throw "Function not found: $name"; - throw "Function not found: $name"; + return SassFunction(callable); }, url: "sass:meta"), BuiltInCallable.function("call", r"$function, $args...", (arguments) { @@ -446,18 +459,18 @@ class _EvaluateVisitor ? null : ValueExpression( SassMap({ - for (var entry in args.keywords.entries) - SassString(entry.key, quotes: false): entry.value + for (var (name, value) in args.keywords.pairs) + SassString(name, quotes: false): value }), callableNode.span)); if (function is SassString) { - warn( + warnForDeprecation( "Passing a string to call() is deprecated and will be illegal in " "Dart Sass 2.0.0.\n" "\n" "Recommendation: call(get-function($function))", - deprecation: true); + Deprecation.callString); var callableNode = _callableNode!; var expression = @@ -500,7 +513,7 @@ class _EvaluateVisitor } _loadModule(url, "load-css()", callableNode, - (module) => _combineCss(module, clone: true).accept(this), + (module, _) => _combineCss(module, clone: true).accept(this), baseUrl: callableNode.span.sourceUrl, configuration: configuration, namesInErrors: true); @@ -523,30 +536,32 @@ class _EvaluateVisitor } EvaluateResult run(Importer? importer, Stylesheet node) { - return withEvaluationContext(_EvaluationContext(this, node), () { - var url = node.span.sourceUrl; - if (url != null) { - _activeModules[url] = null; - if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); - } - - var module = _execute(importer, node); + try { + return withEvaluationContext(_EvaluationContext(this, node), () { + if (node.span.sourceUrl case var url?) { + _activeModules[url] = null; + if (!(_asNodeSass && url.toString() == 'stdin')) _loadedUrls.add(url); + } - return EvaluateResult(_combineCss(module), _loadedUrls); - }); + var module = _addExceptionTrace(() => _execute(importer, node)); + return (stylesheet: _combineCss(module), loadedUrls: _loadedUrls); + }); + } on SassException catch (error, stackTrace) { + throwWithTrace(error.withLoadedUrls(_loadedUrls), error, stackTrace); + } } Value runExpression(Importer? importer, Expression expression) => withEvaluationContext( _EvaluationContext(this, expression), - () => _withFakeStylesheet( - importer, expression, () => expression.accept(this))); + () => _withFakeStylesheet(importer, expression, + () => _addExceptionTrace(() => expression.accept(this)))); void runStatement(Importer? importer, Statement statement) => withEvaluationContext( _EvaluationContext(this, statement), - () => _withFakeStylesheet( - importer, statement, () => statement.accept(this))); + () => _withFakeStylesheet(importer, statement, + () => _addExceptionTrace(() => statement.accept(this)))); /// Asserts that [value] is not `null` and returns it. /// @@ -576,7 +591,9 @@ class _EvaluateVisitor } } - /// Loads the module at [url] and passes it to [callback]. + /// Loads the module at [url] and passes it to [callback], along with a + /// boolean indicating whether this is the first time the module has been + /// loaded. /// /// This first tries loading [url] relative to [baseUrl], which defaults to /// `_stylesheet.span.sourceUrl`. @@ -593,12 +610,11 @@ class _EvaluateVisitor /// The [stackFrame] and [nodeWithSpan] are used for the name and location of /// the stack frame for the duration of the [callback]. void _loadModule(Uri url, String stackFrame, AstNode nodeWithSpan, - void callback(Module module), + void callback(Module module, bool firstLoad), {Uri? baseUrl, Configuration? configuration, bool namesInErrors = false}) { - var builtInModule = _builtInModules[url]; - if (builtInModule != null) { + if (_builtInModules[url] case var builtInModule?) { if (configuration is ExplicitConfiguration) { throw _exception( namesInErrors @@ -607,34 +623,39 @@ class _EvaluateVisitor configuration.nodeWithSpan.span); } - _addExceptionSpan(nodeWithSpan, () => callback(builtInModule)); + // Always consider built-in stylesheets to be "already loaded", since they + // never require additional execution to load and never produce CSS. + _addExceptionSpan(nodeWithSpan, () => callback(builtInModule, false)); return; } _withStackFrame(stackFrame, nodeWithSpan, () { - var result = + var (stylesheet, :importer, :isDependency) = _loadStylesheet(url.toString(), nodeWithSpan.span, baseUrl: baseUrl); - var stylesheet = result.stylesheet; var canonicalUrl = stylesheet.span.sourceUrl; - if (canonicalUrl != null && _activeModules.containsKey(canonicalUrl)) { - var message = namesInErrors - ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " - "loaded." - : "Module loop: this module is already being loaded."; - - throw _activeModules[canonicalUrl].andThen((previousLoad) => - _multiSpanException(message, "new load", - {previousLoad.span: "original load"})) ?? - _exception(message); + if (canonicalUrl != null) { + if (_activeModules.containsKey(canonicalUrl)) { + var message = namesInErrors + ? "Module loop: ${p.prettyUri(canonicalUrl)} is already being " + "loaded." + : "Module loop: this module is already being loaded."; + + throw _activeModules[canonicalUrl].andThen((previousLoad) => + _multiSpanException(message, "new load", + {previousLoad.span: "original load"})) ?? + _exception(message); + } else { + _activeModules[canonicalUrl] = nodeWithSpan; + } } - if (canonicalUrl != null) _activeModules[canonicalUrl] = nodeWithSpan; + var firstLoad = !_modules.containsKey(canonicalUrl); var oldInDependency = _inDependency; - _inDependency = result.isDependency; + _inDependency = isDependency; Module module; try { - module = _execute(result.importer, stylesheet, + module = _execute(importer, stylesheet, configuration: configuration, nodeWithSpan: nodeWithSpan, namesInErrors: namesInErrors); @@ -643,29 +664,8 @@ class _EvaluateVisitor _inDependency = oldInDependency; } - try { - callback(module); - } on SassRuntimeException { - rethrow; - } on MultiSpanSassException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - error.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(error.span)), - stackTrace); - } on SassException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, error.span), stackTrace); - } on MultiSpanSassScriptException catch (error, stackTrace) { - throwWithTrace( - _multiSpanException( - error.message, error.primaryLabel, error.secondarySpans), - stackTrace); - } on SassScriptException catch (error, stackTrace) { - throwWithTrace(_exception(error.message), stackTrace); - } + _addExceptionSpan(nodeWithSpan, () => callback(module, firstLoad), + addStackFrame: false); }); } @@ -683,8 +683,7 @@ class _EvaluateVisitor bool namesInErrors = false}) { var url = stylesheet.span.sourceUrl; - var alreadyLoaded = _modules[url]; - if (alreadyLoaded != null) { + if (_modules[url] case var alreadyLoaded?) { var currentConfiguration = configuration ?? _configuration; if (!_moduleConfigurations[url]!.sameOriginal(currentConfiguration) && currentConfiguration is ExplicitConfiguration) { @@ -713,11 +712,13 @@ class _EvaluateVisitor var environment = Environment(); late CssStylesheet css; + late Map, List>? preModuleComments; var extensionStore = ExtensionStore(); _withEnvironment(environment, () { var oldImporter = _importer; var oldStylesheet = __stylesheet; var oldRoot = __root; + var oldPreModuleComments = _preModuleComments; var oldParent = __parent; var oldEndOfImports = __endOfImports; var oldOutOfOrderImports = _outOfOrderImports; @@ -748,10 +749,12 @@ class _EvaluateVisitor css = _outOfOrderImports == null ? root : CssStylesheet(_addOutOfOrderImports(), stylesheet.span); + preModuleComments = _preModuleComments; _importer = oldImporter; __stylesheet = oldStylesheet; __root = oldRoot; + _preModuleComments = oldPreModuleComments; __parent = oldParent; __endOfImports = oldEndOfImports; _outOfOrderImports = oldOutOfOrderImports; @@ -765,7 +768,8 @@ class _EvaluateVisitor _configuration = oldConfiguration; }); - var module = environment.toModule(css, extensionStore); + var module = environment.toModule( + css, preModuleComments ?? const {}, extensionStore); if (url != null) { _modules[url] = module; _moduleConfigurations[url] = _configuration; @@ -777,16 +781,15 @@ class _EvaluateVisitor /// Returns a copy of [_root.children] with [_outOfOrderImports] inserted /// after [_endOfImports], if necessary. - List _addOutOfOrderImports() { - var outOfOrderImports = _outOfOrderImports; - if (outOfOrderImports == null) return _root.children; - - return [ - ..._root.children.take(_endOfImports), - ...outOfOrderImports, - ..._root.children.skip(_endOfImports) - ]; - } + List _addOutOfOrderImports() => + switch (_outOfOrderImports) { + null => _root.children, + var outOfOrderImports => [ + ..._root.children.take(_endOfImports), + ...outOfOrderImports, + ..._root.children.skip(_endOfImports) + ] + }; /// Returns a new stylesheet containing [root]'s CSS as well as the CSS of all /// modules transitively used by [root]. @@ -798,21 +801,16 @@ class _EvaluateVisitor CssStylesheet _combineCss(Module root, {bool clone = false}) { if (!root.upstream.any((module) => module.transitivelyContainsCss)) { var selectors = root.extensionStore.simpleSelectors; - var unsatisfiedExtension = firstOrNull(root.extensionStore - .extensionsWhereTarget((target) => !selectors.contains(target))); - if (unsatisfiedExtension != null) { + if (root.extensionStore + .extensionsWhereTarget((target) => !selectors.contains(target)) + .firstOrNull + case var unsatisfiedExtension?) { _throwForUnsatisfiedExtension(unsatisfiedExtension); } return root.css; } - var sortedModules = _topologicalModules(root); - if (clone) { - sortedModules = sortedModules.map((module) => module.cloneCss()).toList(); - } - _extendModules(sortedModules); - // The imports (and comments between them) that should be included at the // beginning of the final document. var imports = []; @@ -820,19 +818,46 @@ class _EvaluateVisitor // The CSS statements in the final document. var css = []; - for (var module in sortedModules.reversed) { + /// The modules in reverse topological order. + var sorted = Queue>(); + + /// The modules that have been visited so far. Note that if [cloneCss] is + /// true, this contains the original modules, not the copies. + var seen = >{}; + + void visitModule(Module module) { + if (!seen.add(module)) return; + if (clone) module = module.cloneCss(); + + for (var upstream in module.upstream) { + if (upstream.transitivelyContainsCss) { + if (module.preModuleComments[upstream] case var comments?) { + // Intermix the top-level comments with plain CSS `@import`s until we + // start to have actual CSS defined, at which point start treating it as + // normal CSS. + (css.isEmpty ? imports : css).addAll(comments); + } + visitModule(upstream); + } + } + + sorted.addFirst(module); var statements = module.css.children; var index = _indexAfterImports(statements); imports.addAll(statements.getRange(0, index)); css.addAll(statements.getRange(index, statements.length)); } + visitModule(root); + + if (root.transitivelyContainsExtensions) _extendModules(sorted); + return CssStylesheet(imports + css, root.css.span); } - /// Extends the selectors in each module with the extensions defined in - /// downstream modules. - void _extendModules(List> sortedModules) { + /// Destructively updates the selectors in each module with the extensions + /// defined in downstream modules. + void _extendModules(Iterable> sortedModules) { // All the [ExtensionStore]s directly downstream of a given module (indexed // by its canonical URL). It's important that we create this in topological // order, so that by the time we're processing a module we've already filled @@ -863,11 +888,11 @@ class _EvaluateVisitor if (module.extensionStore.isEmpty) continue; for (var upstream in module.upstream) { - var url = upstream.url; - if (url == null) continue; - downstreamExtensionStores - .putIfAbsent(url, () => []) - .add(module.extensionStore); + if (upstream.url case var url?) { + downstreamExtensionStores + .putIfAbsent(url, () => []) + .add(module.extensionStore); + } } // Remove all extensions that are now satisfied after adding downstream @@ -890,43 +915,19 @@ class _EvaluateVisitor extension.span); } - /// Returns all modules transitively used by [root] in topological order, - /// ignoring modules that contain no CSS. - List> _topologicalModules(Module root) { - // Construct a topological ordering using depth-first traversal, as in - // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search. - var seen = >{}; - var sorted = QueueList>(); - - void visitModule(Module module) { - // Each module is added to the beginning of [sorted], which means the - // returned list contains sibling modules in the opposite order of how - // they appear in the document. Then when the list is reversed to generate - // the CSS, they're put back in their original order. - for (var upstream in module.upstream) { - if (upstream.transitivelyContainsCss && seen.add(upstream)) { - visitModule(upstream); - } - } - - sorted.addFirst(module); - } - - visitModule(root); - - return sorted; - } - /// Returns the index of the first node in [statements] that comes after all /// static imports. int _indexAfterImports(List statements) { var lastImport = -1; + loop: for (var i = 0; i < statements.length; i++) { - var statement = statements[i]; - if (statement is CssImport) { - lastImport = i; - } else if (statement is! CssComment) { - break; + switch (statements[i]) { + case CssImport(): + lastImport = i; + case CssComment(): + continue loop; + case _: + break loop; } } return lastImport + 1; @@ -943,11 +944,11 @@ class _EvaluateVisitor Value? visitAtRootRule(AtRootRule node) { var query = AtRootQuery.defaultQuery; - var unparsedQuery = node.query; - if (unparsedQuery != null) { - var resolved = _performInterpolation(unparsedQuery, warnForColor: true); - query = _adjustParseError( - unparsedQuery, () => AtRootQuery.parse(resolved, logger: _logger)); + if (node.query case var unparsedQuery?) { + var (resolved, map) = + _performInterpolationWithMap(unparsedQuery, warnForColor: true); + query = + AtRootQuery.parse(resolved, interpolationMap: map, logger: _logger); } var parent = _parent; @@ -955,13 +956,12 @@ class _EvaluateVisitor while (parent is! CssStylesheet) { if (!query.excludes(parent)) included.add(parent); - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw StateError( "CssNodes must have a CssStylesheet transitive parent node."); } - - parent = grandparent; } var root = _trimIncluded(included); @@ -977,10 +977,10 @@ class _EvaluateVisitor } var innerCopy = root; - if (included.isNotEmpty) { - innerCopy = included.first.copyWithoutChildren(); + if (included case [var first, ...var rest]) { + innerCopy = first.copyWithoutChildren(); var outerCopy = innerCopy; - for (var node in included.skip(1)) { + for (var node in rest) { var copy = node.copyWithoutChildren(); copy.addChild(outerCopy); outerCopy = copy; @@ -1017,21 +1017,20 @@ class _EvaluateVisitor while (parent != nodes[i]) { innermostContiguous = null; - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError( "Expected ${nodes[i]} to be an ancestor of $this."); } - - parent = grandparent; } innermostContiguous ??= i; - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError("Expected ${nodes[i]} to be an ancestor of $this."); } - parent = grandparent; } if (parent != _root) return _root; @@ -1128,29 +1127,34 @@ class _EvaluateVisitor throw _exception( "Declarations may only be used within style rules.", node.span); } + if (_declarationName != null && node.isCustomProperty) { + throw _exception( + 'Declarations whose names begin with "--" may not be nested.', + node.span); + } var name = _interpolationToValue(node.name, warnForColor: true); - if (_declarationName != null) { - name = CssValue("$_declarationName-${name.value}", name.span); - } - var cssValue = - node.value.andThen((value) => CssValue(value.accept(this), value.span)); - - // If the value is an empty list, preserve it, because converting it to CSS - // will throw an error that we want the user to see. - if (cssValue != null && - (!cssValue.value.isBlank || _isEmptyList(cssValue.value))) { - _parent.addChild(ModifiableCssDeclaration(name, cssValue, node.span, - parsedAsCustomProperty: node.isCustomProperty, - valueSpanForMap: - _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); - } else if (name.value.startsWith('--') && cssValue != null) { - throw _exception( - "Custom property values may not be empty.", cssValue.span); + if (_declarationName case var declarationName?) { + name = CssValue("$declarationName-${name.value}", name.span); + } + + if (node.value case var expression?) { + var value = expression.accept(this); + // If the value is an empty list, preserve it, because converting it to CSS + // will throw an error that we want the user to see. + if (!value.isBlank || _isEmptyList(value)) { + _parent.addChild(ModifiableCssDeclaration( + name, CssValue(value, expression.span), node.span, + parsedAsCustomProperty: node.isCustomProperty, + valueSpanForMap: + _sourceMap ? node.value.andThen(_expressionNode)?.span : null)); + } else if (name.value.startsWith('--')) { + throw _exception( + "Custom property values may not be empty.", expression.span); + } } - var children = node.children; - if (children != null) { + if (node.children case var children?) { var oldDeclarationName = _declarationName; _declarationName = name.value; _environment.scope(() { @@ -1170,11 +1174,12 @@ class _EvaluateVisitor Value? visitEachRule(EachRule node) { var list = node.list.accept(this); var nodeWithSpan = _expressionNode(node.list); - var setVariables = node.variables.length == 1 - ? (Value value) => _environment.setLocalVariable(node.variables.first, - _withoutSlash(value, nodeWithSpan), nodeWithSpan) - : (Value value) => - _setMultipleVariables(node.variables, value, nodeWithSpan); + var setVariables = switch (node.variables) { + [var variable] => (Value value) => _environment.setLocalVariable( + variable, _withoutSlash(value, nodeWithSpan), nodeWithSpan), + var variables => (Value value) => + _setMultipleVariables(variables, value, nodeWithSpan) + }; return _environment.scope(() { return _handleReturn(list.asList, (element) { setVariables(element); @@ -1219,19 +1224,16 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - MultiSpan(styleRule.selector.span, 'invalid selector', + MultiSpan(complex.span.trimRight(), 'invalid selector', {node.span: '@extend rule'}), - deprecation: true); + Deprecation.bogusCombinators); } - var targetText = _interpolationToValue(node.selector, warnForColor: true); + var (targetText, targetMap) = + _performInterpolationWithMap(node.selector, warnForColor: true); - var list = _adjustParseError( - targetText, - () => SelectorList.parse( - trimAscii(targetText.value, excludeEscape: true), - logger: _logger, - allowParent: false)); + var list = SelectorList.parse(trimAscii(targetText, excludeEscape: true), + interpolationMap: targetMap, logger: _logger, allowParent: false); for (var complex in list.components) { var compound = complex.singleCompound; @@ -1239,7 +1241,7 @@ class _EvaluateVisitor // If the selector was a compound selector but not a simple // selector, emit a more explicit error. throw SassFormatException( - "complex selectors may not be extended.", targetText.span); + "complex selectors may not be extended.", complex.span); } var simple = compound.singleSimple; @@ -1248,7 +1250,7 @@ class _EvaluateVisitor "compound selectors may no longer be extended.\n" "Consider `@extend ${compound.components.join(', ')}` instead.\n" "See https://sass-lang.com/d/extend-compound for details.\n", - targetText.span); + compound.span); } _extensionStore.addExtension( @@ -1289,7 +1291,9 @@ class _EvaluateVisitor _withParent(ModifiableCssAtRule(name, node.span, value: value), () { var styleRule = _styleRule; - if (styleRule == null || _inKeyframes) { + if (styleRule == null || _inKeyframes || name.value == 'font-face') { + // Special-cased at-rules within style blocks are pulled out to the + // root. Equivalent to prepending "@at-root" on them. for (var child in children) { child.accept(this); } @@ -1339,9 +1343,11 @@ class _EvaluateVisitor numeratorUnits: fromNumber.numeratorUnits, denominatorUnits: fromNumber.denominatorUnits), nodeWithSpan); - var result = _handleReturn( - node.children, (child) => child.accept(this)); - if (result != null) return result; + if (_handleReturn( + node.children, (child) => child.accept(this)) + case var result?) { + return result; + } } return null; }, semiGlobal: true); @@ -1355,7 +1361,8 @@ class _EvaluateVisitor var newConfiguration = _addForwardConfiguration(adjustedConfiguration, node); - _loadModule(node.url, "@forward", node, (module) { + _loadModule(node.url, "@forward", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.forwardModule(module, node); }, configuration: newConfiguration); @@ -1379,7 +1386,8 @@ class _EvaluateVisitor _assertConfigurationIsEmpty(newConfiguration); } else { _configuration = adjustedConfiguration; - _loadModule(node.url, "@forward", node, (module) { + _loadModule(node.url, "@forward", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.forwardModule(module, node); }); _configuration = oldConfiguration; @@ -1395,8 +1403,8 @@ class _EvaluateVisitor var newValues = Map.of(configuration.values); for (var variable in node.configuration) { if (variable.isGuarded) { - var oldValue = configuration.remove(variable.name); - if (oldValue != null && oldValue.value != sassNull) { + if (configuration.remove(variable.name) case var oldValue? + when oldValue.value != sassNull) { newValues[variable.name] = oldValue; continue; } @@ -1416,6 +1424,20 @@ class _EvaluateVisitor } } + /// Adds any comments in [_root.children] to [_preModuleComments] for + /// [module]. + void _registerCommentsForModule(Module module) { + // If we're not in a module (for example, we're evaluating a line of code + // for the repl), there's nothing to register comments for. + if (__root == null) return; + if (_root.children.isEmpty || !module.transitivelyContainsCss) return; + (_preModuleComments ??= {}) + .putIfAbsent(module, () => []) + .addAll(_root.children.cast()); + _root.clearChildren(); + _endOfImports = 0; + } + /// Remove configured values from [upstream] that have been removed from /// [downstream], unless they match a name in [except]. void _removeUsedConfiguration( @@ -1442,14 +1464,14 @@ class _EvaluateVisitor if (configuration is! ExplicitConfiguration) return; if (configuration.isEmpty) return; - var entry = configuration.values.entries.first; + var (name, value) = configuration.values.pairs.first; throw _exception( nameInError - ? "\$${entry.key} was not declared with !default in the @used " + ? "\$$name was not declared with !default in the @used " "module." : "This variable was not declared with !default in the @used " "module.", - entry.value.configurationSpan); + value.configurationSpan); } Value? visitFunctionRule(FunctionRule node) { @@ -1466,14 +1488,12 @@ class _EvaluateVisitor break; } } - if (clause == null) return null; - return _environment.scope( + return clause.andThen((clause) => _environment.scope( () => _handleReturn( - clause!.children, // dart-lang/sdk#45348 - (child) => child.accept(this)), + clause.children, (child) => child.accept(this)), semiGlobal: true, - when: clause.hasDeclarations); + when: clause.hasDeclarations)); } Value? visitImportRule(ImportRule node) { @@ -1490,9 +1510,8 @@ class _EvaluateVisitor /// Adds the stylesheet imported by [import] to the current document. void _visitDynamicImport(DynamicImport import) { return _withStackFrame("@import", import, () { - var result = + var (stylesheet, :importer, :isDependency) = _loadStylesheet(import.urlString, import.span, forImport: true); - var stylesheet = result.stylesheet; var url = stylesheet.span.sourceUrl; if (url != null) { @@ -1513,9 +1532,9 @@ class _EvaluateVisitor var oldImporter = _importer; var oldStylesheet = _stylesheet; var oldInDependency = _inDependency; - _importer = result.importer; + _importer = importer; _stylesheet = stylesheet; - _inDependency = result.isDependency; + _inDependency = isDependency; visitStylesheet(stylesheet); _importer = oldImporter; _stylesheet = oldStylesheet; @@ -1544,7 +1563,7 @@ class _EvaluateVisitor var oldOutOfOrderImports = _outOfOrderImports; var oldConfiguration = _configuration; var oldInDependency = _inDependency; - _importer = result.importer; + _importer = importer; _stylesheet = stylesheet; if (loadsUserDefinedModules) { _root = ModifiableCssStylesheet(stylesheet.span); @@ -1552,7 +1571,7 @@ class _EvaluateVisitor _endOfImports = 0; _outOfOrderImports = null; } - _inDependency = result.isDependency; + _inDependency = isDependency; // This configuration is only used if it passes through a `@forward` // rule, so we avoid creating unnecessary ones for performance reasons. @@ -1611,42 +1630,43 @@ class _EvaluateVisitor assert(_importSpan == null); _importSpan = span; - var importCache = _importCache; - if (importCache != null) { + if (_importCache case var importCache?) { baseUrl ??= _stylesheet.span.sourceUrl; - var tuple = importCache.canonicalize(Uri.parse(url), - baseImporter: _importer, baseUrl: baseUrl, forImport: forImport); - - if (tuple != null) { - var isDependency = _inDependency || tuple.item1 != _importer; - var stylesheet = importCache.importCanonical(tuple.item1, tuple.item2, - originalUrl: tuple.item3, quiet: _quietDeps && isDependency); - if (stylesheet != null) { - _loadedUrls.add(tuple.item2); - return _LoadedStylesheet(stylesheet, - importer: tuple.item1, isDependency: isDependency); + if (importCache.canonicalize(Uri.parse(url), + baseImporter: _importer, baseUrl: baseUrl, forImport: forImport) + case (var importer, var canonicalUrl, :var originalUrl)) { + // 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. + _loadedUrls.add(canonicalUrl); + + var isDependency = _inDependency || importer != _importer; + if (importCache.importCanonical(importer, canonicalUrl, + originalUrl: originalUrl, quiet: _quietDeps && isDependency) + case var stylesheet?) { + return (stylesheet, importer: importer, isDependency: isDependency); } } } else { - var result = _importLikeNode( - url, baseUrl ?? _stylesheet.span.sourceUrl, forImport); - if (result != null) { - result.stylesheet.span.sourceUrl.andThen(_loadedUrls.add); + if (_importLikeNode( + url, baseUrl ?? _stylesheet.span.sourceUrl, forImport) + case var result?) { + result.$1.span.sourceUrl.andThen(_loadedUrls.add); return result; } } - if (url.startsWith('package:') && isNode) { + if (url.startsWith('package:') && isJS) { // Special-case this error message, since it's tripped people up in the // past. throw "\"package:\" URLs aren't supported on this platform."; } else { throw "Can't find stylesheet to import."; } - } on SassException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, error.span), stackTrace); - } on Error catch (error, stackTrace) { - throwWithTrace(_exception(error.toString()), stackTrace); + } on SassException { + rethrow; + } on ArgumentError catch (error, stackTrace) { + throwWithTrace(_exception(error.toString()), error, stackTrace); } catch (error, stackTrace) { String? message; try { @@ -1654,7 +1674,7 @@ class _EvaluateVisitor } catch (_) { message = error.toString(); } - throwWithTrace(_exception(message), stackTrace); + throwWithTrace(_exception(message), error, stackTrace); } finally { _importSpan = null; } @@ -1676,15 +1696,15 @@ class _EvaluateVisitor isDependency = true; } - var contents = result.item1; - var url = result.item2; - - return _LoadedStylesheet( - Stylesheet.parse(contents, - url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, - url: url, - logger: _quietDeps && isDependency ? Logger.quiet : _logger), - isDependency: isDependency); + var (contents, url) = result; + return ( + Stylesheet.parse( + contents, url.startsWith('file') ? Syntax.forPath(url) : Syntax.scss, + url: url, + logger: _quietDeps && isDependency ? Logger.quiet : _logger), + importer: null, + isDependency: isDependency + ); } /// Adds a CSS import for [import]. @@ -1708,44 +1728,46 @@ class _EvaluateVisitor } Value? visitIncludeRule(IncludeRule node) { + var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); var mixin = _addExceptionSpan(node, () => _environment.getMixin(node.name, namespace: node.namespace)); - if (mixin == null) { - throw _exception("Undefined mixin.", node.span); - } + switch (mixin) { + case null: + throw _exception("Undefined mixin.", node.span); - var nodeWithSpan = AstNode.fake(() => node.spanWithoutContent); - if (mixin is BuiltInCallable) { - if (node.content != null) { + case BuiltInCallable() when node.content != null: throw _exception("Mixin doesn't accept a content block.", node.span); - } - _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); - } else if (mixin is UserDefinedCallable) { - if (node.content != null && - !(mixin.declaration as MixinRule).hasContent) { + case BuiltInCallable(): + _runBuiltInCallable(node.arguments, mixin, nodeWithSpan); + + case UserDefinedCallable( + declaration: MixinRule(hasContent: false) + ) + when node.content != null: throw MultiSpanSassRuntimeException( "Mixin doesn't accept a content block.", node.spanWithoutContent, "invocation", {mixin.declaration.arguments.spanWithName: "declaration"}, _stackTrace(node.spanWithoutContent)); - } - var contentCallable = node.content.andThen((content) => - UserDefinedCallable(content, _environment.closure(), - inDependency: _inDependency)); - _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, () { - _environment.withContent(contentCallable, () { - _environment.asMixin(() { - for (var statement in mixin.declaration.children) { - _addErrorSpan(nodeWithSpan, () => statement.accept(this)); - } + case UserDefinedCallable(): + var contentCallable = node.content.andThen((content) => + UserDefinedCallable(content, _environment.closure(), + inDependency: _inDependency)); + _runUserDefinedCallable(node.arguments, mixin, nodeWithSpan, () { + _environment.withContent(contentCallable, () { + _environment.asMixin(() { + for (var statement in mixin.declaration.children) { + _addErrorSpan(nodeWithSpan, () => statement.accept(this)); + } + }); }); }); - }); - } else { - throw UnsupportedError("Unknown callable type $mixin."); + + case _: + throw UnsupportedError("Unknown callable type $mixin."); } return null; @@ -1794,12 +1816,7 @@ class _EvaluateVisitor _withParent(ModifiableCssMediaRule(mergedQueries ?? queries, node.span), () { _withMediaQueries(mergedQueries ?? queries, mergedSources, () { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the media query so that // declarations immediately inside @media have somewhere to go. // @@ -1810,6 +1827,10 @@ class _EvaluateVisitor child.accept(this); } }, scopeWhen: false); + } else { + for (var child in node.children) { + child.accept(this); + } } }); }, @@ -1826,11 +1847,10 @@ class _EvaluateVisitor /// Evaluates [interpolation] and parses the result as a list of media /// queries. List _visitMediaQueries(Interpolation interpolation) { - var resolved = _performInterpolation(interpolation, warnForColor: true); - - // TODO(nweiz): Remove this type argument when sdk#31398 is fixed. - return _adjustParseError>(interpolation, - () => CssMediaQuery.parseList(resolved, logger: _logger)); + var (resolved, map) = + _performInterpolationWithMap(interpolation, warnForColor: true); + return CssMediaQuery.parseList(resolved, + logger: _logger, interpolationMap: map); } /// Returns a list of queries that selects for contexts that match both @@ -1843,11 +1863,16 @@ class _EvaluateVisitor Iterable queries1, Iterable queries2) { var queries = []; for (var query1 in queries1) { + inner: for (var query2 in queries2) { - var result = query1.merge(query2); - if (result == MediaQueryMergeResult.empty) continue; - if (result == MediaQueryMergeResult.unrepresentable) return null; - queries.add((result as MediaQuerySuccessfulMergeResult).query); + switch (query1.merge(query2)) { + case MediaQueryMergeResult.empty: + continue inner; + case MediaQueryMergeResult.unrepresentable: + return null; + case MediaQuerySuccessfulMergeResult result: + queries.add(result.query); + } } } return queries; @@ -1867,16 +1892,16 @@ class _EvaluateVisitor "Style rules may not be used within nested declarations.", node.span); } - var selectorText = - _interpolationToValue(node.selector, trim: true, warnForColor: true); + var (selectorText, selectorMap) = + _performInterpolationWithMap(node.selector, warnForColor: true); + if (_inKeyframes) { // NOTE: this logic is largely duplicated in [visitCssKeyframeBlock]. Most // changes here should be mirrored there. - var parsedSelector = _adjustParseError( - node.selector, - () => KeyframeSelectorParser(selectorText.value, logger: _logger) - .parse()); + var parsedSelector = KeyframeSelectorParser(selectorText, + logger: _logger, interpolationMap: selectorMap) + .parse(); var rule = ModifiableCssKeyframeBlock( CssValue(List.unmodifiable(parsedSelector), node.selector.span), node.span); @@ -1890,20 +1915,15 @@ class _EvaluateVisitor return null; } - var parsedSelector = _adjustParseError( - node.selector, - () => SelectorList.parse(selectorText.value, + var parsedSelector = SelectorList.parse(selectorText, + interpolationMap: selectorMap, allowParent: !_stylesheet.plainCss, allowPlaceholder: !_stylesheet.plainCss, - logger: _logger)); - parsedSelector = _addExceptionSpan( - node.selector, - () => parsedSelector.resolveParentSelectors( - _styleRuleIgnoringAtRoot?.originalSelector, - implicitParent: !_atRootExcludingStyleRule)); - - var selector = _extensionStore.addSelector( - parsedSelector, node.selector.span, _mediaQueries); + logger: _logger) + .resolveParentSelectors(_styleRuleIgnoringAtRoot?.originalSelector, + implicitParent: !_atRootExcludingStyleRule); + + var selector = _extensionStore.addSelector(parsedSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, originalSelector: parsedSelector); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; @@ -1930,16 +1950,16 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - node.selector.span, - deprecation: true); + 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', - node.selector.span, - deprecation: true); + complex.span.trimRight(), + Deprecation.bogusCombinators); } else { _warn( 'The selector "${complex.toString().trim()}" is only valid for ' @@ -1952,13 +1972,13 @@ class _EvaluateVisitor 'This will be an error in Dart Sass 2.0.0.\n' '\n' 'More info: https://sass-lang.com/d/bogus-combinators', - MultiSpan(node.selector.span, 'invalid selector', { + MultiSpan(complex.span.trimRight(), 'invalid selector', { rule.children.first.span: "this is not a style rule" + (rule.children.every((child) => child is CssComment) ? '\n(try converting to a //-style comment)' : '') }), - deprecation: true); + Deprecation.bogusCombinators); } } } @@ -1984,12 +2004,7 @@ class _EvaluateVisitor var condition = CssValue(_visitSupportsCondition(node.condition), node.condition.span); _withParent(ModifiableCssSupportsRule(condition, node.span), () { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the supports rule so that // declarations immediately inside @supports have somewhere to go. // @@ -2000,6 +2015,10 @@ class _EvaluateVisitor child.accept(this); } }); + } else { + for (var child in node.children) { + child.accept(this); + } } }, through: (node) => node is CssStyleRule, @@ -2009,31 +2028,35 @@ class _EvaluateVisitor } /// Evaluates [condition] and converts it to a plain CSS string. - String _visitSupportsCondition(SupportsCondition condition) { - if (condition is SupportsOperation) { - return "${_parenthesize(condition.left, condition.operator)} " - "${condition.operator} " - "${_parenthesize(condition.right, condition.operator)}"; - } else if (condition is SupportsNegation) { - return "not ${_parenthesize(condition.condition)}"; - } else if (condition is SupportsInterpolation) { - return _evaluateToCss(condition.expression, quote: false); - } else if (condition is SupportsDeclaration) { - var oldInSupportsDeclaration = _inSupportsDeclaration; - _inSupportsDeclaration = true; - var result = "(${_evaluateToCss(condition.name)}:" - "${condition.isCustomProperty ? '' : ' '}" - "${_evaluateToCss(condition.value)})"; + String _visitSupportsCondition(SupportsCondition condition) => + switch (condition) { + SupportsOperation operation => + "${_parenthesize(operation.left, operation.operator)} " + "${operation.operator} " + "${_parenthesize(operation.right, operation.operator)}", + SupportsNegation negation => "not ${_parenthesize(negation.condition)}", + SupportsInterpolation interpolation => + _evaluateToCss(interpolation.expression, quote: false), + SupportsDeclaration declaration => + _withSupportsDeclaration(() => "(${_evaluateToCss(declaration.name)}:" + "${declaration.isCustomProperty ? '' : ' '}" + "${_evaluateToCss(declaration.value)})"), + SupportsFunction function => "${_performInterpolation(function.name)}(" + "${_performInterpolation(function.arguments)})", + SupportsAnything anything => + "(${_performInterpolation(anything.contents)})", + var condition => throw ArgumentError( + "Unknown supports condition type ${condition.runtimeType}.") + }; + + /// Runs [callback] in a context where [_inSupportsDeclaration] is true. + T _withSupportsDeclaration(T callback()) { + var oldInSupportsDeclaration = _inSupportsDeclaration; + _inSupportsDeclaration = true; + try { + return callback(); + } finally { _inSupportsDeclaration = oldInSupportsDeclaration; - return result; - } else if (condition is SupportsFunction) { - return "${_performInterpolation(condition.name)}(" - "${_performInterpolation(condition.arguments)})"; - } else if (condition is SupportsAnything) { - return "(${_performInterpolation(condition.contents)})"; - } else { - throw ArgumentError( - "Unknown supports condition type ${condition.runtimeType}."); } } @@ -2044,20 +2067,22 @@ class _EvaluateVisitor /// [SupportsOperation], and is used to determine whether parentheses are /// necessary if [condition] is also a [SupportsOperation]. String _parenthesize(SupportsCondition condition, [String? operator]) { - if ((condition is SupportsNegation) || - (condition is SupportsOperation && - (operator == null || operator != condition.operator))) { - return "(${_visitSupportsCondition(condition)})"; - } else { - return _visitSupportsCondition(condition); + switch (condition) { + case SupportsNegation(): + case SupportsOperation() + when operator == null || operator != condition.operator: + return "(${_visitSupportsCondition(condition)})"; + + case _: + return _visitSupportsCondition(condition); } } Value? visitVariableDeclaration(VariableDeclaration node) { if (node.isGuarded) { if (node.namespace == null && _environment.atRoot) { - var override = _configuration.remove(node.name); - if (override != null && override.value != sassNull) { + if (_configuration.remove(node.name) case var override? + when override.value != sassNull) { _addExceptionSpan(node, () { _environment.setVariable( node.name, override.value, override.assignmentNode, @@ -2087,7 +2112,7 @@ class _EvaluateVisitor "Recommendation: add `${node.originalName}: null` at the " "stylesheet root.", node.span, - deprecation: true); + Deprecation.newGlobal); } var value = _withoutSlash(node.expression.accept(this), node.expression); @@ -2114,7 +2139,8 @@ class _EvaluateVisitor configuration = ExplicitConfiguration(values, node); } - _loadModule(node.url, "@use", node, (module) { + _loadModule(node.url, "@use", node, (module, firstLoad) { + if (firstLoad) _registerCommentsForModule(module); _environment.addModule(module, node, namespace: node.namespace); }, configuration: configuration); _assertConfigurationIsEmpty(configuration); @@ -2133,9 +2159,11 @@ class _EvaluateVisitor Value? visitWhileRule(WhileRule node) { return _environment.scope(() { while (node.condition.accept(this).isTruthy) { - var result = _handleReturn( - node.children, (child) => child.accept(this)); - if (result != null) return result; + if (_handleReturn( + node.children, (child) => child.accept(this)) + case var result?) { + return result; + } } return null; }, semiGlobal: true, when: node.hasDeclarations); @@ -2146,95 +2174,65 @@ class _EvaluateVisitor Value visitBinaryOperationExpression(BinaryOperationExpression node) { return _addExceptionSpan(node, () { var left = node.left.accept(this); - switch (node.operator) { - case BinaryOperator.singleEquals: - var right = node.right.accept(this); - return left.singleEquals(right); - - case BinaryOperator.or: - return left.isTruthy ? left : node.right.accept(this); - - case BinaryOperator.and: - return left.isTruthy ? node.right.accept(this) : left; - - case BinaryOperator.equals: - var right = node.right.accept(this); - return SassBoolean(left == right); - - case BinaryOperator.notEquals: - var right = node.right.accept(this); - return SassBoolean(left != right); - - case BinaryOperator.greaterThan: - var right = node.right.accept(this); - return left.greaterThan(right); - - case BinaryOperator.greaterThanOrEquals: - var right = node.right.accept(this); - return left.greaterThanOrEquals(right); - - case BinaryOperator.lessThan: - var right = node.right.accept(this); - return left.lessThan(right); - - case BinaryOperator.lessThanOrEquals: - var right = node.right.accept(this); - return left.lessThanOrEquals(right); - - case BinaryOperator.plus: - var right = node.right.accept(this); - return left.plus(right); - - case BinaryOperator.minus: - var right = node.right.accept(this); - return left.minus(right); - - case BinaryOperator.times: - var right = node.right.accept(this); - return left.times(right); - - case BinaryOperator.dividedBy: - var right = node.right.accept(this); - var result = left.dividedBy(right); - if (node.allowsSlash && left is SassNumber && right is SassNumber) { - return (result as SassNumber).withSlash(left, right); - } else { - if (left is SassNumber && right is SassNumber) { - String recommendation(Expression expression) { - if (expression is BinaryOperationExpression && - expression.operator == BinaryOperator.dividedBy) { - return "math.div(${recommendation(expression.left)}, " - "${recommendation(expression.right)})"; - } else if (expression is ParenthesizedExpression) { - return expression.expression.toString(); - } else { - return expression.toString(); - } - } - - _warn( - "Using / for division outside of calc() is deprecated " - "and will be removed in Dart Sass 2.0.0.\n" - "\n" - "Recommendation: ${recommendation(node)} or calc($node)\n" - "\n" - "More info and automated migrator: " - "https://sass-lang.com/d/slash-div", - node.span, - deprecation: true); - } + return switch (node.operator) { + BinaryOperator.singleEquals => + left.singleEquals(node.right.accept(this)), + BinaryOperator.or => left.isTruthy ? left : node.right.accept(this), + BinaryOperator.and => left.isTruthy ? node.right.accept(this) : left, + BinaryOperator.equals => SassBoolean(left == node.right.accept(this)), + BinaryOperator.notEquals => + SassBoolean(left != node.right.accept(this)), + BinaryOperator.greaterThan => left.greaterThan(node.right.accept(this)), + BinaryOperator.greaterThanOrEquals => + left.greaterThanOrEquals(node.right.accept(this)), + BinaryOperator.lessThan => left.lessThan(node.right.accept(this)), + BinaryOperator.lessThanOrEquals => + left.lessThanOrEquals(node.right.accept(this)), + BinaryOperator.plus => left.plus(node.right.accept(this)), + BinaryOperator.minus => left.minus(node.right.accept(this)), + BinaryOperator.times => left.times(node.right.accept(this)), + BinaryOperator.dividedBy => _slash(left, node.right.accept(this), node), + BinaryOperator.modulo => left.modulo(node.right.accept(this)) + }; + }); + } - return result; - } + /// Returns the result of the SassScript `/` operation between [left] and + /// [right] in [node]. + Value _slash(Value left, Value right, BinaryOperationExpression node) { + var result = left.dividedBy(right); + switch ((left, right)) { + case (SassNumber left, SassNumber right) when node.allowsSlash: + return (result as SassNumber).withSlash(left, right); + + case (SassNumber(), SassNumber()): + String recommendation(Expression expression) => switch (expression) { + BinaryOperationExpression( + operator: BinaryOperator.dividedBy, + :var left, + :var right + ) => + "math.div(${recommendation(left)}, ${recommendation(right)})", + ParenthesizedExpression() => expression.expression.toString(), + _ => expression.toString() + }; - case BinaryOperator.modulo: - var right = node.right.accept(this); - return left.modulo(right); + _warn( + "Using / for division outside of calc() is deprecated " + "and will be removed in Dart Sass 2.0.0.\n" + "\n" + "Recommendation: ${recommendation(node)} or " + "${expressionToCalc(node)}\n" + "\n" + "More info and automated migrator: " + "https://sass-lang.com/d/slash-div", + node.span, + Deprecation.slashDiv); + return result; - default: - throw ArgumentError("Unknown binary operator ${node.operator}."); - } - }); + case _: + return result; + } } Value visitValueExpression(ValueExpression node) => node.value; @@ -2249,18 +2247,12 @@ class _EvaluateVisitor Value visitUnaryOperationExpression(UnaryOperationExpression node) { var operand = node.operand.accept(this); return _addExceptionSpan(node, () { - switch (node.operator) { - case UnaryOperator.plus: - return operand.unaryPlus(); - case UnaryOperator.minus: - return operand.unaryMinus(); - case UnaryOperator.divide: - return operand.unaryDivide(); - case UnaryOperator.not: - return operand.unaryNot(); - default: - throw StateError("Unknown unary operator ${node.operator}."); - } + return switch (node.operator) { + UnaryOperator.plus => operand.unaryPlus(), + UnaryOperator.minus => operand.unaryMinus(), + UnaryOperator.divide => operand.unaryDivide(), + UnaryOperator.not => operand.unaryNot() + }; }); } @@ -2268,16 +2260,13 @@ class _EvaluateVisitor SassBoolean(node.value); Value visitIfExpression(IfExpression node) { - var pair = _evaluateMacroArguments(node); - var positional = pair.item1; - var named = pair.item2; - + var (positional, named) = _evaluateMacroArguments(node); _verifyArguments(positional.length, named, IfExpression.declaration, node); // ignore: prefer_is_empty - var condition = positional.length > 0 ? positional[0] : named["condition"]!; - var ifTrue = positional.length > 1 ? positional[1] : named["if-true"]!; - var ifFalse = positional.length > 2 ? positional[2] : named["if-false"]!; + var condition = positional.elementAtOrNull(0) ?? named["condition"]!; + var ifTrue = positional.elementAtOrNull(1) ?? named["if-true"]!; + var ifFalse = positional.elementAtOrNull(2) ?? named["if-false"]!; var result = condition.accept(this).isTruthy ? ifTrue : ifFalse; return _withoutSlash(result.accept(this), _expressionNode(result)); @@ -2302,28 +2291,20 @@ class _EvaluateVisitor } try { - switch (node.name) { - case "calc": - assert(arguments.length == 1); - return SassCalculation.calc(arguments[0]); - case "min": - return SassCalculation.min(arguments); - case "max": - return SassCalculation.max(arguments); - case "clamp": - return SassCalculation.clamp( - arguments[0], - arguments.length > 1 ? arguments[1] : null, - arguments.length > 2 ? arguments[2] : null); - default: - throw UnsupportedError('Unknown calculation name "${node.name}".'); - } + return switch (node.name) { + "calc" => SassCalculation.calc(arguments[0]), + "min" => SassCalculation.min(arguments), + "max" => SassCalculation.max(arguments), + "clamp" => SassCalculation.clamp(arguments[0], + arguments.elementAtOrNull(1), arguments.elementAtOrNull(2)), + _ => throw UnsupportedError('Unknown calculation name "${node.name}".') + }; } on SassScriptException catch (error, stackTrace) { // The simplification logic in the [SassCalculation] static methods will // throw an error if the arguments aren't compatible, but we have access // to the original spans so we can throw a more informative error. _verifyCompatibleNumbers(arguments, node.arguments); - throwWithTrace(_exception(error.message, node.span), stackTrace); + throwWithTrace(_exception(error.message, node.span), error, stackTrace); } } @@ -2337,9 +2318,7 @@ class _EvaluateVisitor // SassCalculation._verifyCompatibleNumbers and most changes here should // also be reflected there. for (var i = 0; i < args.length; i++) { - var arg = args[i]; - if (arg is! SassNumber) continue; - if (arg.numeratorUnits.length > 1 || arg.denominatorUnits.isNotEmpty) { + if (args[i] case SassNumber arg when arg.hasComplexUnits) { throw _exception("Number $arg isn't compatible with CSS calculations.", nodesWithSpans[i].span); } @@ -2370,57 +2349,70 @@ class _EvaluateVisitor /// subtracted with numbers with units, for backwards-compatibility with the /// old global `min()` and `max()` functions. Object _visitCalculationValue(Expression node, {required bool inMinMax}) { - if (node is ParenthesizedExpression) { - var inner = node.expression; - var result = _visitCalculationValue(inner, inMinMax: inMinMax); - return inner is FunctionExpression && - inner.name.toLowerCase() == 'var' && - result is SassString && - !result.hasQuotes - ? SassString('(${result.text})', quotes: false) - : result; - } else if (node is StringExpression) { - assert(!node.hasQuotes); - return CalculationInterpolation(_performInterpolation(node.text)); - } else if (node is BinaryOperationExpression) { - return _addExceptionSpan( - node, - () => SassCalculation.operateInternal( - _binaryOperatorToCalculationOperator(node.operator), - _visitCalculationValue(node.left, inMinMax: inMinMax), - _visitCalculationValue(node.right, inMinMax: inMinMax), - inMinMax: inMinMax, - simplify: !_inSupportsDeclaration)); - } else { - assert(node is NumberExpression || - node is CalculationExpression || - node is VariableExpression || - node is FunctionExpression || - node is IfExpression); - var result = node.accept(this); - if (result is SassNumber || result is SassCalculation) return result; - if (result is SassString && !result.hasQuotes) return result; - throw _exception( - "Value $result can't be used in a calculation.", node.span); + switch (node) { + case ParenthesizedExpression(expression: var inner): + var result = _visitCalculationValue(inner, inMinMax: inMinMax); + return inner is FunctionExpression && + inner.name.toLowerCase() == 'var' && + result is SassString && + !result.hasQuotes + ? SassString('(${result.text})', quotes: false) + : result; + + case StringExpression(text: Interpolation(asPlain: var text?)): + assert(!node.hasQuotes); + return switch (text.toLowerCase()) { + 'pi' => SassNumber(math.pi), + 'e' => SassNumber(math.e), + 'infinity' => SassNumber(double.infinity), + '-infinity' => SassNumber(double.negativeInfinity), + 'nan' => SassNumber(double.nan), + _ => SassString(text, quotes: false) + }; + + // If there's actual interpolation, create a CalculationInterpolation. + // Otherwise, create an UnquotedString. The main difference is that + // UnquotedStrings don't get extra defensive parentheses. + case StringExpression(): + assert(!node.hasQuotes); + return CalculationInterpolation(_performInterpolation(node.text)); + + case BinaryOperationExpression(:var operator, :var left, :var right): + return _addExceptionSpan( + node, + () => SassCalculation.operateInternal( + _binaryOperatorToCalculationOperator(operator), + _visitCalculationValue(left, inMinMax: inMinMax), + _visitCalculationValue(right, inMinMax: inMinMax), + inMinMax: inMinMax, + simplify: !_inSupportsDeclaration)); + + case _: + assert(node is NumberExpression || + node is CalculationExpression || + node is VariableExpression || + node is FunctionExpression || + node is IfExpression); + return switch (node.accept(this)) { + SassNumber result => result, + SassCalculation result => result, + SassString result when !result.hasQuotes => result, + var result => throw _exception( + "Value $result can't be used in a calculation.", node.span) + }; } } /// Returns the [CalculationOperator] that corresponds to [operator]. CalculationOperator _binaryOperatorToCalculationOperator( - BinaryOperator operator) { - switch (operator) { - case BinaryOperator.plus: - return CalculationOperator.plus; - case BinaryOperator.minus: - return CalculationOperator.minus; - case BinaryOperator.times: - return CalculationOperator.times; - case BinaryOperator.dividedBy: - return CalculationOperator.dividedBy; - default: - throw UnsupportedError("Invalid calculation operator $operator."); - } - } + BinaryOperator operator) => + switch (operator) { + BinaryOperator.plus => CalculationOperator.plus, + BinaryOperator.minus => CalculationOperator.minus, + BinaryOperator.times => CalculationOperator.times, + BinaryOperator.dividedBy => CalculationOperator.dividedBy, + _ => throw UnsupportedError("Invalid calculation operator $operator.") + }; SassColor visitColorExpression(ColorExpression node) => node.value; @@ -2432,22 +2424,22 @@ class _EvaluateVisitor SassMap visitMapExpression(MapExpression node) { var map = {}; var keyNodes = {}; - for (var pair in node.pairs) { - var keyValue = pair.item1.accept(this); - var valueValue = pair.item2.accept(this); + for (var (key, value) in node.pairs) { + var keyValue = key.accept(this); + var valueValue = value.accept(this); var oldValue = map[keyValue]; if (oldValue != null) { var oldValueSpan = keyNodes[keyValue]?.span; throw MultiSpanSassRuntimeException( 'Duplicate key.', - pair.item1.span, + key.span, 'second key', {if (oldValueSpan != null) oldValueSpan: 'first key'}, - _stackTrace(pair.item1.span)); + _stackTrace(key.span)); } map[keyValue] = valueValue; - keyNodes[keyValue] = pair.item1; + keyNodes[keyValue] = key; } return SassMap(map); } @@ -2598,22 +2590,32 @@ class _EvaluateVisitor } var buffer = StringBuffer("${callable.name}("); - var first = true; - for (var argument in arguments.positional) { - if (first) { - first = false; - } else { - buffer.write(", "); - } + try { + var first = true; + for (var argument in arguments.positional) { + if (first) { + first = false; + } else { + buffer.write(", "); + } - buffer.write(_evaluateToCss(argument)); - } + buffer.write(_evaluateToCss(argument)); + } - var restArg = arguments.rest; - if (restArg != null) { - var rest = restArg.accept(this); - if (!first) buffer.write(", "); - buffer.write(_serialize(rest, restArg)); + var restArg = arguments.rest; + if (restArg != null) { + var rest = restArg.accept(this); + if (!first) buffer.write(", "); + buffer.write(_serialize(rest, restArg)); + } + } on SassRuntimeException catch (error) { + if (!error.message.endsWith("isn't a valid CSS value.")) rethrow; + throw MultiSpanSassRuntimeException( + error.message, + error.span, + "value", + {nodeWithSpan.span: "unknown function treated as plain CSS"}, + error.trace); } buffer.writeCharCode($rparen); @@ -2633,9 +2635,8 @@ class _EvaluateVisitor _callableNode = nodeWithSpan; var namedSet = MapKeySet(evaluated.named); - var tuple = callable.callbackFor(evaluated.positional.length, namedSet); - var overload = tuple.item1; - var callback = tuple.item2; + var (overload, callback) = + callable.callbackFor(evaluated.positional.length, namedSet); _addExceptionSpan(nodeWithSpan, () => overload.verify(evaluated.positional.length, namedSet)); @@ -2669,27 +2670,10 @@ class _EvaluateVisitor Value result; try { - result = callback(evaluated.positional); - } on SassRuntimeException { + result = + _addExceptionSpan(nodeWithSpan, () => callback(evaluated.positional)); + } on SassException { rethrow; - } on MultiSpanSassScriptException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - nodeWithSpan.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(nodeWithSpan.span)), - stackTrace); - } on MultiSpanSassException catch (error, stackTrace) { - throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - error.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(error.span)), - stackTrace); } catch (error, stackTrace) { String? message; try { @@ -2697,7 +2681,7 @@ class _EvaluateVisitor } catch (_) { message = error.toString(); } - throwWithTrace(_exception(message, nodeWithSpan.span), stackTrace); + throwWithTrace(_exception(message, nodeWithSpan.span), error, stackTrace); } _callableNode = oldCallableNode; @@ -2732,16 +2716,21 @@ class _EvaluateVisitor var named = {}; var namedNodes = {}; - for (var entry in arguments.named.entries) { - var nodeForSpan = _expressionNode(entry.value); - named[entry.key] = _withoutSlash(entry.value.accept(this), nodeForSpan); - namedNodes[entry.key] = nodeForSpan; + for (var (name, value) in arguments.named.pairs) { + var nodeForSpan = _expressionNode(value); + named[name] = _withoutSlash(value.accept(this), nodeForSpan); + namedNodes[name] = nodeForSpan; } var restArgs = arguments.rest; if (restArgs == null) { - return _ArgumentResults(positional, positionalNodes, named, namedNodes, - ListSeparator.undecided); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: ListSeparator.undecided + ); } var rest = restArgs.accept(this); @@ -2772,8 +2761,13 @@ class _EvaluateVisitor var keywordRestArgs = arguments.keywordRest; if (keywordRestArgs == null) { - return _ArgumentResults( - positional, positionalNodes, named, namedNodes, separator); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: separator + ); } var keywordRest = keywordRestArgs.accept(this); @@ -2784,8 +2778,13 @@ class _EvaluateVisitor for (var key in keywordRest.contents.keys) (key as SassString).text: keywordRestNodeForSpan }); - return _ArgumentResults( - positional, positionalNodes, named, namedNodes, separator); + return ( + positional: positional, + positionalNodes: positionalNodes, + named: named, + namedNodes: namedNodes, + separator: separator + ); } else { throw _exception( "Variable keyword arguments must be a map (was $keywordRest).", @@ -2798,12 +2797,11 @@ class _EvaluateVisitor /// /// Returns the arguments as expressions so that they can be lazily evaluated /// for macros such as `if()`. - Tuple2, Map> _evaluateMacroArguments( - CallableInvocation invocation) { + (List positional, Map named) + _evaluateMacroArguments(CallableInvocation invocation) { var restArgs_ = invocation.arguments.rest; if (restArgs_ == null) { - return Tuple2( - invocation.arguments.positional, invocation.arguments.named); + return (invocation.arguments.positional, invocation.arguments.named); } var restArgs = restArgs_; // dart-lang/sdk#45348 @@ -2829,7 +2827,7 @@ class _EvaluateVisitor } var keywordRestArgs_ = invocation.arguments.keywordRest; - if (keywordRestArgs_ == null) return Tuple2(positional, named); + if (keywordRestArgs_ == null) return (positional, named); var keywordRestArgs = keywordRestArgs_; // dart-lang/sdk#45348 var keywordRest = keywordRestArgs.accept(this); @@ -2842,7 +2840,7 @@ class _EvaluateVisitor (value) => ValueExpression( _withoutSlash(value, keywordRestNodeForSpan), keywordRestArgs.span)); - return Tuple2(positional, named); + return (positional, named); } else { throw _exception( "Variable keyword arguments must be a map (was $keywordRest).", @@ -2892,14 +2890,17 @@ class _EvaluateVisitor var oldInSupportsDeclaration = _inSupportsDeclaration; _inSupportsDeclaration = false; var result = SassString( - node.text.contents.map((value) { - if (value is String) return value; - var expression = value as Expression; - var result = expression.accept(this); - return result is SassString - ? result.text - : _serialize(result, expression, quote: false); - }).join(), + [ + for (var value in node.text.contents) + switch (value) { + String() => value, + Expression() => switch (value.accept(this)) { + SassString(:var text) => text, + var result => _serialize(result, value, quote: false) + }, + _ => throw UnsupportedError("Unknown interpolation value $value") + } + ].join(), quotes: node.hasQuotes); _inSupportsDeclaration = oldInSupportsDeclaration; return result; @@ -2970,7 +2971,7 @@ class _EvaluateVisitor void visitCssDeclaration(CssDeclaration node) { _parent.addChild(ModifiableCssDeclaration(node.name, node.value, node.span, - parsedAsCustomProperty: node.isCustomProperty, + parsedAsCustomProperty: node.parsedAsCustomProperty, valueSpanForMap: node.valueSpanForMap)); } @@ -3022,12 +3023,7 @@ class _EvaluateVisitor _withParent( ModifiableCssMediaRule(mergedQueries ?? node.queries, node.span), () { _withMediaQueries(mergedQueries ?? node.queries, mergedSources, () { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the media query so that // declarations immediately inside @media have somewhere to go. // @@ -3038,6 +3034,10 @@ class _EvaluateVisitor child.accept(this); } }, scopeWhen: false); + } else { + for (var child in node.children) { + child.accept(this); + } } }); }, @@ -3059,11 +3059,10 @@ class _EvaluateVisitor } var styleRule = _styleRule; - var originalSelector = node.selector.value.resolveParentSelectors( + var originalSelector = node.selector.resolveParentSelectors( styleRule?.originalSelector, implicitParent: !_atRootExcludingStyleRule); - var selector = _extensionStore.addSelector( - originalSelector, node.selector.span, _mediaQueries); + var selector = _extensionStore.addSelector(originalSelector, _mediaQueries); var rule = ModifiableCssStyleRule(selector, node.span, originalSelector: originalSelector); var oldAtRootExcludingStyleRule = _atRootExcludingStyleRule; @@ -3077,8 +3076,7 @@ class _EvaluateVisitor }, through: (node) => node is CssStyleRule, scopeWhen: false); _atRootExcludingStyleRule = oldAtRootExcludingStyleRule; - if (styleRule == null && _parent.children.isNotEmpty) { - var lastChild = _parent.children.last; + if (_parent.children case [..., var lastChild] when styleRule == null) { lastChild.isGroupEnd = true; } } @@ -3100,12 +3098,7 @@ class _EvaluateVisitor } _withParent(ModifiableCssSupportsRule(node.condition, node.span), () { - var styleRule = _styleRule; - if (styleRule == null) { - for (var child in node.children) { - child.accept(this); - } - } else { + if (_styleRule case var styleRule?) { // If we're in a style rule, copy it into the supports rule so that // declarations immediately inside @supports have somewhere to go. // @@ -3116,6 +3109,10 @@ class _EvaluateVisitor child.accept(this); } }); + } else { + for (var child in node.children) { + child.accept(this); + } } }, through: (node) => node is CssStyleRule, scopeWhen: false); } @@ -3128,8 +3125,7 @@ class _EvaluateVisitor /// returned `null`. Value? _handleReturn(List list, Value? callback(T value)) { for (var value in list) { - var result = callback(value); - if (result != null) return result; + if (callback(value) case var result?) return result; } return null; } @@ -3162,16 +3158,46 @@ class _EvaluateVisitor /// values passed into the interpolation. String _performInterpolation(Interpolation interpolation, {bool warnForColor = false}) { + var (result, _) = _performInterpolationHelper(interpolation, + sourceMap: true, warnForColor: warnForColor); + return result; + } + + /// Like [_performInterpolation], but also returns a [InterpolationMap] that + /// can map spans from the resulting string back to the original + /// [interpolation]. + (String, InterpolationMap) _performInterpolationWithMap( + Interpolation interpolation, + {bool warnForColor = false}) { + var (result, map) = _performInterpolationHelper(interpolation, + sourceMap: true, warnForColor: warnForColor); + return (result, map!); + } + + /// A helper that implements the core logic of both [_performInterpolation] + /// and [_performInterpolationWithMap]. + (String, InterpolationMap?) _performInterpolationHelper( + Interpolation interpolation, + {required bool sourceMap, + bool warnForColor = false}) { + var targetLocations = sourceMap ? [] : null; var oldInSupportsDeclaration = _inSupportsDeclaration; _inSupportsDeclaration = false; - var result = interpolation.contents.map((value) { - if (value is String) return value; + var buffer = StringBuffer(); + var first = true; + for (var value in interpolation.contents) { + if (!first) targetLocations?.add(SourceLocation(buffer.length)); + first = false; + + if (value is String) { + buffer.write(value); + continue; + } + var expression = value as Expression; var result = expression.accept(this); - if (warnForColor && - result is SassColor && - namesByColor.containsKey(result)) { + if (warnForColor && namesByColor.containsKey(result)) { var alternative = BinaryOperationExpression( BinaryOperator.plus, StringExpression(Interpolation([""], interpolation.span), @@ -3188,10 +3214,15 @@ class _EvaluateVisitor expression.span); } - return _serialize(result, expression, quote: false); - }).join(); + buffer.write(_serialize(result, expression, quote: false)); + } _inSupportsDeclaration = oldInSupportsDeclaration; - return result; + + return ( + buffer.toString(), + targetLocations.andThen( + (targetLocations) => InterpolationMap(interpolation, targetLocations)) + ); } /// Evaluates [expression] and calls `toCssString()` and wraps a @@ -3264,12 +3295,12 @@ class _EvaluateVisitor var parent = _parent; if (through != null) { while (through(parent)) { - var grandparent = parent.parent; - if (grandparent == null) { + if (parent.parent case var grandparent?) { + parent = grandparent; + } else { throw ArgumentError( "through() must return false for at least one parent of $node."); } - parent = grandparent; } // If the parent has a (visible) following sibling, we shouldn't add to @@ -3278,8 +3309,14 @@ class _EvaluateVisitor if (parent.hasFollowingSibling) { // A node with siblings must have a parent var grandparent = parent.parent!; - parent = parent.copyWithoutChildren(); - grandparent.addChild(parent); + if (parent.equalsIgnoringChildren(grandparent.children.last)) { + // If we've already made a copy of [parent] and nothing else has been + // added after it, re-use it. + parent = grandparent.children.last as ModifiableCssParentNode; + } else { + parent = parent.copyWithoutChildren(); + grandparent.addChild(parent); + } } } @@ -3321,7 +3358,7 @@ class _EvaluateVisitor /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. T _withStackFrame(String member, AstNode nodeWithSpan, T callback()) { - _stack.add(Tuple2(_member, nodeWithSpan)); + _stack.add((_member, nodeWithSpan)); var oldMember = _member; _member = member; var result = callback(); @@ -3333,16 +3370,12 @@ class _EvaluateVisitor /// Like [Value.withoutSlash], but produces a deprecation warning if [value] /// was a slash-separated number. Value _withoutSlash(Value value, AstNode nodeForSpan) { - if (value is SassNumber && value.asSlash != null) { - String recommendation(SassNumber number) { - var asSlash = number.asSlash; - if (asSlash != null) { - return "math.div(${recommendation(asSlash.item1)}, " - "${recommendation(asSlash.item2)})"; - } else { - return number.toString(); - } - } + if (value case SassNumber(asSlash: _?)) { + String recommendation(SassNumber number) => switch (number.asSlash) { + (var before, var after) => + "math.div(${recommendation(before)}, ${recommendation(after)})", + _ => number.toString() + }; _warn( "Using / for division is deprecated and will be removed in Dart Sass " @@ -3353,7 +3386,7 @@ class _EvaluateVisitor "More info and automated migrator: " "https://sass-lang.com/d/slash-div", nodeForSpan.span, - deprecation: true); + Deprecation.slashDiv); } return value.withoutSlash(); @@ -3369,22 +3402,28 @@ class _EvaluateVisitor /// If [span] is passed, it's used for the innermost stack frame. Trace _stackTrace([FileSpan? span]) { var frames = [ - ..._stack.map((tuple) => _stackFrame(tuple.item1, tuple.item2.span)), + for (var (member, nodeWithSpan) in _stack) + _stackFrame(member, nodeWithSpan.span), if (span != null) _stackFrame(_member, span) ]; return Trace(frames.reversed); } /// Emits a warning with the given [message] about the given [span]. - void _warn(String message, FileSpan span, {bool deprecation = false}) { + void _warn(String message, FileSpan span, [Deprecation? deprecation]) { if (_quietDeps && (_inDependency || (_currentCallable?.inDependency ?? false))) { return; } - if (!_warningsEmitted.add(Tuple2(message, span))) return; - _logger.warn(message, - span: span, trace: _stackTrace(span), deprecation: deprecation); + if (!_warningsEmitted.add((message, span))) return; + var trace = _stackTrace(span); + if (deprecation == null) { + _logger.warn(message, span: span, trace: trace); + } else { + _logger.warnForDeprecation(deprecation, message, + span: span, trace: trace); + } } /// Returns a [SassRuntimeException] with the given [message]. @@ -3392,7 +3431,7 @@ class _EvaluateVisitor /// If [span] is passed, it's used for the innermost stack frame. SassRuntimeException _exception(String message, [FileSpan? span]) => SassRuntimeException( - message, span ?? _stack.last.item2.span, _stackTrace(span)); + message, span ?? _stack.last.$2.span, _stackTrace(span)); /// Returns a [MultiSpanSassRuntimeException] with the given [message], /// [primaryLabel], and [secondaryLabels]. @@ -3400,57 +3439,43 @@ class _EvaluateVisitor /// The primary span is taken from the current stack trace span. SassRuntimeException _multiSpanException(String message, String primaryLabel, Map secondaryLabels) => - MultiSpanSassRuntimeException(message, _stack.last.item2.span, - primaryLabel, secondaryLabels, _stackTrace()); + MultiSpanSassRuntimeException(message, _stack.last.$2.span, primaryLabel, + secondaryLabels, _stackTrace()); - /// Runs [callback], and adjusts any [SassFormatException] to be within - /// [nodeWithSpan]'s source span. - /// - /// Specifically, this adjusts format exceptions so that the errors are - /// reported as though the text being parsed were exactly in [span]. This may - /// not be quite accurate if the source text contained interpolation, but - /// it'll still produce a useful error. + /// Runs [callback], and converts any [SassScriptException]s it throws to + /// [SassRuntimeException]s with [nodeWithSpan]'s source span. /// /// This takes an [AstNode] rather than a [FileSpan] so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - T _adjustParseError(AstNode nodeWithSpan, T callback()) { + /// + /// If [addStackFrame] is true (the default), this will add an innermost stack + /// frame for [nodeWithSpan]. Otherwise, it will use the existing stack as-is. + T _addExceptionSpan(AstNode nodeWithSpan, T callback(), + {bool addStackFrame = true}) { try { return callback(); - } on SassFormatException catch (error, stackTrace) { - var errorText = error.span.file.getText(0); - var span = nodeWithSpan.span; - var syntheticFile = span.file - .getText(0) - .replaceRange(span.start.offset, span.end.offset, errorText); - var syntheticSpan = - SourceFile.fromString(syntheticFile, url: span.file.url).span( - span.start.offset + error.span.start.offset, - span.start.offset + error.span.end.offset); - throwWithTrace(_exception(error.message, syntheticSpan), stackTrace); + } on SassScriptException catch (error, stackTrace) { + throwWithTrace( + error + .withSpan(nodeWithSpan.span) + .withTrace(_stackTrace(addStackFrame ? nodeWithSpan.span : null)), + error, + stackTrace); } } - /// Runs [callback], and converts any [SassScriptException]s it throws to - /// [SassRuntimeException]s with [nodeWithSpan]'s source span. - /// - /// This takes an [AstNode] rather than a [FileSpan] so it can avoid calling - /// [AstNode.span] if the span isn't required, since some nodes need to do - /// real work to manufacture a source span. - T _addExceptionSpan(AstNode nodeWithSpan, T callback()) { + /// Runs [callback], and converts any [SassException]s that aren't already + /// [SassRuntimeException]s to [SassRuntimeException]s with the current stack + /// trace. + T _addExceptionTrace(T callback()) { try { return callback(); - } on MultiSpanSassScriptException catch (error, stackTrace) { + } on SassRuntimeException { + rethrow; + } on SassException catch (error, stackTrace) { throwWithTrace( - MultiSpanSassRuntimeException( - error.message, - nodeWithSpan.span, - error.primaryLabel, - error.secondarySpans, - _stackTrace(nodeWithSpan.span)), - stackTrace); - } on SassScriptException catch (error, stackTrace) { - throwWithTrace(_exception(error.message, nodeWithSpan.span), stackTrace); + error.withTrace(_stackTrace(error.span)), error, stackTrace); } } @@ -3464,6 +3489,7 @@ class _EvaluateVisitor if (!error.span.text.startsWith("@error")) rethrow; throwWithTrace( SassRuntimeException(error.message, nodeWithSpan.span, _stackTrace()), + error, stackTrace); } } @@ -3480,7 +3506,7 @@ class _EvaluateVisitor /// because it will add the parent selector to the CSS if the `@import` appeared /// in a nested context, but the parent selector was already added when the /// imported stylesheet was evaluated. -class _ImportedCssVisitor implements ModifiableCssVisitor { +final class _ImportedCssVisitor implements ModifiableCssVisitor { /// The visitor in whose context this was created. final _EvaluateVisitor _visitor; @@ -3540,77 +3566,69 @@ class _ImportedCssVisitor implements ModifiableCssVisitor { /// An implementation of [EvaluationContext] using the information available in /// [_EvaluateVisitor]. -class _EvaluationContext implements EvaluationContext { +final class _EvaluationContext implements EvaluationContext { /// The visitor backing this context. final _EvaluateVisitor _visitor; /// The AST node whose span should be used for [warn] if no other span is - /// avaiable. + /// available. final AstNode _defaultWarnNodeWithSpan; _EvaluationContext(this._visitor, this._defaultWarnNodeWithSpan); FileSpan get currentCallableSpan { - var callableNode = _visitor._callableNode; - if (callableNode != null) return callableNode.span; + if (_visitor._callableNode case var callableNode?) return callableNode.span; throw StateError("No Sass callable is currently being evaluated."); } - void warn(String message, {bool deprecation = false}) { + void warn(String message, [Deprecation? deprecation]) { _visitor._warn( message, _visitor._importSpan ?? _visitor._callableNode?.span ?? _defaultWarnNodeWithSpan.span, - deprecation: deprecation); + deprecation); } } /// The result of evaluating arguments to a function or mixin. -class _ArgumentResults { +typedef _ArgumentResults = ({ /// Arguments passed by position. - final List positional; + List positional, /// The [AstNode]s that hold the spans for each [positional] argument. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final List positionalNodes; + List positionalNodes, /// Arguments passed by name. - final Map named; + Map named, /// The [AstNode]s that hold the spans for each [named] argument. /// /// This stores [AstNode]s rather than [FileSpan]s so it can avoid calling /// [AstNode.span] if the span isn't required, since some nodes need to do /// real work to manufacture a source span. - final Map namedNodes; + Map namedNodes, /// The separator used for the rest argument list, if any. - final ListSeparator separator; - - _ArgumentResults(this.positional, this.positionalNodes, this.named, - this.namedNodes, this.separator); -} + ListSeparator separator +}); /// The result of loading a stylesheet via [Evaluator._loadStylesheet]. -class _LoadedStylesheet { +typedef _LoadedStylesheet = ( /// The stylesheet itself. - final Stylesheet stylesheet; - + Stylesheet stylesheet, { /// The importer that was used to load the stylesheet. /// /// This is `null` when running in Node Sass compatibility mode. - final Importer? importer; + Importer? importer, /// Whether this load counts as a dependency. /// /// That is, whether this was (transitively) loaded through a load path or /// importer rather than relative to the entrypoint. - final bool isDependency; - - _LoadedStylesheet(this.stylesheet, - {this.importer, required this.isDependency}); -} + bool isDependency +}); diff --git a/lib/src/visitor/expression_to_calc.dart b/lib/src/visitor/expression_to_calc.dart new file mode 100644 index 000000000..961735655 --- /dev/null +++ b/lib/src/visitor/expression_to_calc.dart @@ -0,0 +1,51 @@ +// 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 '../ast/sass.dart'; +import 'replace_expression.dart'; + +/// Converts [expression] to an equivalent `calc()`. +/// +/// This assumes that [expression] already returns a number. It's intended for +/// use in end-user messaging, and may not produce directly evaluable +/// expressions. +CalculationExpression expressionToCalc(Expression expression) => + CalculationExpression.calc( + expression.accept(const _MakeExpressionCalculationSafe()), + expression.span); + +/// A visitor that replaces constructs that can't be used in a calculation with +/// those that can. +class _MakeExpressionCalculationSafe with ReplaceExpressionVisitor { + const _MakeExpressionCalculationSafe(); + + Expression visitCalculationExpression(CalculationExpression node) => node; + + Expression visitBinaryOperationExpression(BinaryOperationExpression node) => node + .operator == + BinaryOperator.modulo + // `calc()` doesn't support `%` for modulo but Sass doesn't yet support the + // `mod()` calculation function because there's no browser support, so we have + // to work around it by wrapping the call in a Sass function. + ? FunctionExpression( + 'max', ArgumentInvocation([node], const {}, node.span), node.span, + namespace: 'math') + : super.visitBinaryOperationExpression(node); + + Expression visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + node; + + Expression visitUnaryOperationExpression(UnaryOperationExpression node) => + switch (node.operator) { + // `calc()` doesn't support unary operations. + UnaryOperator.plus => node.operand, + UnaryOperator.minus => BinaryOperationExpression(BinaryOperator.times, + NumberExpression(-1, node.span), node.operand), + _ => + // Other unary operations don't produce numbers, so keep them as-is to + // give the user a more useful syntax error after serialization. + super.visitUnaryOperationExpression(node) + }; +} diff --git a/lib/src/visitor/find_dependencies.dart b/lib/src/visitor/find_dependencies.dart index 362157a0c..94607e952 100644 --- a/lib/src/visitor/find_dependencies.dart +++ b/lib/src/visitor/find_dependencies.dart @@ -2,30 +2,38 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import 'package:tuple/tuple.dart'; +import 'package:collection/collection.dart'; import '../ast/sass.dart'; import 'recursive_statement.dart'; -/// Returns two lists of dependencies for [stylesheet]. -/// -/// The first is a list of URLs from all `@use` and `@forward` rules in -/// [stylesheet] (excluding built-in modules). The second is a list of all -/// imports in [stylesheet]. +/// Returns [stylesheet]'s statically-declared dependencies. /// /// {@category Dependencies} -Tuple2, List> findDependencies(Stylesheet stylesheet) => +DependencyReport findDependencies(Stylesheet stylesheet) => _FindDependenciesVisitor().run(stylesheet); -/// A visitor that traverses a stylesheet and records, all `@import`, `@use`, -/// and `@forward` rules (excluding built-in modules) it contains. +/// A visitor that traverses a stylesheet and records all its dependencies on +/// other stylesheets. class _FindDependenciesVisitor with RecursiveStatementVisitor { - final _usesAndForwards = []; - final _imports = []; + final _uses = {}; + final _forwards = {}; + final _metaLoadCss = {}; + final _imports = {}; + + /// The namespaces under which `sass:meta` has been `@use`d in this stylesheet. + /// + /// If this contains `null`, it means `sass:meta` was loaded without a + /// namespace. + final _metaNamespaces = {}; - Tuple2, List> run(Stylesheet stylesheet) { + DependencyReport run(Stylesheet stylesheet) { visitStylesheet(stylesheet); - return Tuple2(_usesAndForwards, _imports); + return DependencyReport._( + uses: UnmodifiableSetView(_uses), + forwards: UnmodifiableSetView(_forwards), + metaLoadCss: UnmodifiableSetView(_metaLoadCss), + imports: UnmodifiableSetView(_imports)); } // These can never contain imports. @@ -38,11 +46,15 @@ class _FindDependenciesVisitor with RecursiveStatementVisitor { void visitSupportsCondition(SupportsCondition condition) {} void visitUseRule(UseRule node) { - if (node.url.scheme != 'sass') _usesAndForwards.add(node.url); + if (node.url.scheme != 'sass') { + _uses.add(node.url); + } else if (node.url.toString() == 'sass:meta') { + _metaNamespaces.add(node.namespace); + } } void visitForwardRule(ForwardRule node) { - if (node.url.scheme != 'sass') _usesAndForwards.add(node.url); + if (node.url.scheme != 'sass') _forwards.add(node.url); } void visitImportRule(ImportRule node) { @@ -50,4 +62,50 @@ class _FindDependenciesVisitor with RecursiveStatementVisitor { if (import is DynamicImport) _imports.add(import.url); } } + + void visitIncludeRule(IncludeRule node) { + if (node.name != 'load-css') return; + if (!_metaNamespaces.contains(node.namespace)) return; + + if (node.arguments.positional + case [StringExpression(text: Interpolation(asPlain: var url?))]) { + try { + _metaLoadCss.add(Uri.parse(url)); + } on FormatException { + // Ignore invalid URLs. + } + } + } +} + +/// A struct of different types of dependencies a Sass stylesheet can contain. +final class DependencyReport { + /// An unmodifiable set of all `@use`d URLs in the stylesheet (excluding + /// built-in modules). + final Set uses; + + /// An unmodifiable set of all `@forward`ed URLs in the stylesheet (excluding + /// built-in modules). + final Set forwards; + + /// An unmodifiable set of all URLs loaded by `meta.load-css()` calls with + /// static string arguments outside of mixins. + final Set metaLoadCss; + + /// An unmodifiable set of all dynamically `@import`ed URLs in the + /// stylesheet. + final Set imports; + + /// An unmodifiable set of all URLs in [uses], [forwards], and [metaLoadCss]. + Set get modules => UnionSet({uses, forwards, metaLoadCss}); + + /// An unmodifiable set of all URLs in [uses], [forwards], [metaLoadCss], and + /// [imports]. + Set get all => UnionSet({uses, forwards, metaLoadCss, imports}); + + DependencyReport._( + {required this.uses, + required this.forwards, + required this.metaLoadCss, + required this.imports}); } diff --git a/lib/src/visitor/interface/css.dart b/lib/src/visitor/interface/css.dart index 06485582a..032149b02 100644 --- a/lib/src/visitor/interface/css.dart +++ b/lib/src/visitor/interface/css.dart @@ -8,7 +8,7 @@ import 'modifiable_css.dart'; /// An interface for [visitors][] that traverse CSS statements. /// /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern -abstract class CssVisitor implements ModifiableCssVisitor { +abstract interface class CssVisitor implements ModifiableCssVisitor { T visitCssAtRule(CssAtRule node); T visitCssComment(CssComment node); T visitCssDeclaration(CssDeclaration node); diff --git a/lib/src/visitor/interface/expression.dart b/lib/src/visitor/interface/expression.dart index 0a642ec64..db5f70f32 100644 --- a/lib/src/visitor/interface/expression.dart +++ b/lib/src/visitor/interface/expression.dart @@ -9,7 +9,7 @@ import '../../ast/sass.dart'; /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern /// /// {@category Visitor} -abstract class ExpressionVisitor { +abstract interface class ExpressionVisitor { T visitBinaryOperationExpression(BinaryOperationExpression node); T visitBooleanExpression(BooleanExpression node); T visitCalculationExpression(CalculationExpression node); diff --git a/lib/src/visitor/interface/modifiable_css.dart b/lib/src/visitor/interface/modifiable_css.dart index 643dc9b0a..2683f2f15 100644 --- a/lib/src/visitor/interface/modifiable_css.dart +++ b/lib/src/visitor/interface/modifiable_css.dart @@ -7,7 +7,7 @@ import '../../ast/css/modifiable.dart'; /// An interface for [visitors][] that traverse CSS statements. /// /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern -abstract class ModifiableCssVisitor { +abstract interface class ModifiableCssVisitor { T visitCssAtRule(ModifiableCssAtRule node); T visitCssComment(ModifiableCssComment node); T visitCssDeclaration(ModifiableCssDeclaration node); diff --git a/lib/src/visitor/interface/selector.dart b/lib/src/visitor/interface/selector.dart index 91b68913c..a680e1aed 100644 --- a/lib/src/visitor/interface/selector.dart +++ b/lib/src/visitor/interface/selector.dart @@ -9,7 +9,7 @@ import '../../ast/selector.dart'; /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern /// /// {@category Visitor} -abstract class SelectorVisitor { +abstract interface class SelectorVisitor { T visitAttributeSelector(AttributeSelector attribute); T visitClassSelector(ClassSelector klass); T visitComplexSelector(ComplexSelector complex); diff --git a/lib/src/visitor/interface/statement.dart b/lib/src/visitor/interface/statement.dart index c1bf6e470..610c488bf 100644 --- a/lib/src/visitor/interface/statement.dart +++ b/lib/src/visitor/interface/statement.dart @@ -9,7 +9,7 @@ import '../../ast/sass.dart'; /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern /// /// {@category Visitor} -abstract class StatementVisitor { +abstract interface class StatementVisitor { T visitAtRootRule(AtRootRule node); T visitAtRule(AtRule node); T visitContentBlock(ContentBlock node); diff --git a/lib/src/visitor/interface/value.dart b/lib/src/visitor/interface/value.dart index 5b98de42e..db25c86d5 100644 --- a/lib/src/visitor/interface/value.dart +++ b/lib/src/visitor/interface/value.dart @@ -7,7 +7,7 @@ import '../../value.dart'; /// An interface for [visitors][] that traverse SassScript values. /// /// [visitors]: https://en.wikipedia.org/wiki/Visitor_pattern -abstract class ValueVisitor { +abstract interface class ValueVisitor { T visitBoolean(SassBoolean value); T visitCalculation(SassCalculation value); T visitColor(SassColor value); diff --git a/lib/src/visitor/recursive_ast.dart b/lib/src/visitor/recursive_ast.dart index aee77c869..0b31aafe2 100644 --- a/lib/src/visitor/recursive_ast.dart +++ b/lib/src/visitor/recursive_ast.dart @@ -80,11 +80,11 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } } - node.lastClause.andThen((lastClause) { + if (node.lastClause case var lastClause?) { for (var child in lastClause.children) { child.accept(this); } - }); + } } void visitImportRule(ImportRule node) { @@ -185,9 +185,9 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor } void visitMapExpression(MapExpression node) { - for (var pair in node.pairs) { - pair.item1.accept(this); - pair.item2.accept(this); + for (var (key, value) in node.pairs) { + key.accept(this); + value.accept(this); } } @@ -247,16 +247,17 @@ mixin RecursiveAstVisitor on RecursiveStatementVisitor /// [SupportsCondition] they encounter. @protected void visitSupportsCondition(SupportsCondition condition) { - if (condition is SupportsOperation) { - visitSupportsCondition(condition.left); - visitSupportsCondition(condition.right); - } else if (condition is SupportsNegation) { - visitSupportsCondition(condition.condition); - } else if (condition is SupportsInterpolation) { - visitExpression(condition.expression); - } else if (condition is SupportsDeclaration) { - visitExpression(condition.name); - visitExpression(condition.value); + switch (condition) { + case SupportsOperation(): + visitSupportsCondition(condition.left); + visitSupportsCondition(condition.right); + case SupportsNegation(): + visitSupportsCondition(condition.condition); + case SupportsInterpolation(): + visitExpression(condition.expression); + case SupportsDeclaration(): + visitExpression(condition.name); + visitExpression(condition.value); } } diff --git a/lib/src/visitor/recursive_statement.dart b/lib/src/visitor/recursive_statement.dart index 408bb5874..3d89a84d9 100644 --- a/lib/src/visitor/recursive_statement.dart +++ b/lib/src/visitor/recursive_statement.dart @@ -53,11 +53,11 @@ mixin RecursiveStatementVisitor implements StatementVisitor { } } - node.lastClause.andThen((lastClause) { + if (node.lastClause case var lastClause?) { for (var child in lastClause.children) { child.accept(this); } - }); + } } void visitImportRule(ImportRule node) {} diff --git a/lib/src/visitor/replace_expression.dart b/lib/src/visitor/replace_expression.dart new file mode 100644 index 000000000..b330cfbbf --- /dev/null +++ b/lib/src/visitor/replace_expression.dart @@ -0,0 +1,136 @@ +// 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 '../ast/sass.dart'; +import '../exception.dart'; +import '../util/map.dart'; +import 'interface/expression.dart'; + +/// A visitor that recursively traverses each expression in a SassScript AST and +/// replaces its contents with the values returned by nested recursion. +/// +/// In addition to the methods from [ExpressionVisitor], this has more general +/// protected methods that can be overridden to add behavior for a wide variety +/// of AST nodes: +/// +/// * [visitArgumentInvocation] +/// * [visitSupportsCondition] +/// * [visitInterpolation] +/// +/// {@category Visitor} +mixin ReplaceExpressionVisitor implements ExpressionVisitor { + Expression visitCalculationExpression(CalculationExpression node) => + CalculationExpression(node.name, + node.arguments.map((argument) => argument.accept(this)), node.span); + + Expression visitBinaryOperationExpression(BinaryOperationExpression node) => + BinaryOperationExpression( + node.operator, node.left.accept(this), node.right.accept(this)); + + Expression visitBooleanExpression(BooleanExpression node) => node; + + Expression visitColorExpression(ColorExpression node) => node; + + Expression visitFunctionExpression( + FunctionExpression node) => + FunctionExpression( + node.originalName, visitArgumentInvocation(node.arguments), node.span, + namespace: node.namespace); + + Expression visitInterpolatedFunctionExpression( + InterpolatedFunctionExpression node) => + InterpolatedFunctionExpression(visitInterpolation(node.name), + visitArgumentInvocation(node.arguments), node.span); + + Expression visitIfExpression(IfExpression node) => + IfExpression(visitArgumentInvocation(node.arguments), node.span); + + Expression visitListExpression(ListExpression node) => ListExpression( + node.contents.map((item) => item.accept(this)), node.separator, node.span, + brackets: node.hasBrackets); + + Expression visitMapExpression(MapExpression node) => MapExpression([ + for (var (key, value) in node.pairs) + (key.accept(this), value.accept(this)) + ], node.span); + + Expression visitNullExpression(NullExpression node) => node; + + Expression visitNumberExpression(NumberExpression node) => node; + + Expression visitParenthesizedExpression(ParenthesizedExpression node) => + ParenthesizedExpression(node.expression.accept(this), node.span); + + Expression visitSelectorExpression(SelectorExpression node) => node; + + Expression visitStringExpression(StringExpression node) => + StringExpression(visitInterpolation(node.text), quotes: node.hasQuotes); + + Expression visitSupportsExpression(SupportsExpression node) => + SupportsExpression(visitSupportsCondition(node.condition)); + + Expression visitUnaryOperationExpression(UnaryOperationExpression node) => + UnaryOperationExpression( + node.operator, node.operand.accept(this), node.span); + + Expression visitValueExpression(ValueExpression node) => node; + + Expression visitVariableExpression(VariableExpression node) => node; + + /// Replaces each expression in an [invocation]. + /// + /// The default implementation of the visit methods calls this to replace any + /// argument invocation in an expression. + @protected + ArgumentInvocation visitArgumentInvocation(ArgumentInvocation invocation) => + ArgumentInvocation( + invocation.positional.map((expression) => expression.accept(this)), + { + for (var (name, value) in invocation.named.pairs) + name: value.accept(this) + }, + invocation.span, + rest: invocation.rest?.accept(this), + keywordRest: invocation.keywordRest?.accept(this)); + + /// Replaces each expression in [condition]. + /// + /// The default implementation of the visit methods call this to visit any + /// [SupportsCondition] they encounter. + @protected + SupportsCondition visitSupportsCondition(SupportsCondition condition) { + if (condition is SupportsOperation) { + return SupportsOperation( + visitSupportsCondition(condition.left), + visitSupportsCondition(condition.right), + condition.operator, + condition.span); + } else if (condition is SupportsNegation) { + return SupportsNegation( + visitSupportsCondition(condition.condition), condition.span); + } else if (condition is SupportsInterpolation) { + return SupportsInterpolation( + condition.expression.accept(this), condition.span); + } else if (condition is SupportsDeclaration) { + return SupportsDeclaration(condition.name.accept(this), + condition.value.accept(this), condition.span); + } else { + throw SassException( + "BUG: Unknown SupportsCondition $condition.", condition.span); + } + } + + /// Replaces each expression in an [interpolation]. + /// + /// The default implementation of the visit methods call this to visit any + /// interpolation in an expression. + @protected + Interpolation visitInterpolation(Interpolation interpolation) => + Interpolation( + interpolation.contents + .map((node) => node is Expression ? node.accept(this) : node), + interpolation.span); +} diff --git a/lib/src/visitor/selector_search.dart b/lib/src/visitor/selector_search.dart new file mode 100644 index 000000000..8029ad8c7 --- /dev/null +++ b/lib/src/visitor/selector_search.dart @@ -0,0 +1,37 @@ +// 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 '../ast/selector.dart'; +import '../util/iterable.dart'; +import '../util/nullable.dart'; +import 'interface/selector.dart'; + +/// A [SelectorVisitor] whose `visit*` methods default to returning `null`, but +/// which returns the first non-`null` value returned by any method. +/// +/// This can be extended to find the first instance of particular nodes in the +/// AST. +/// +/// {@category Visitor} +mixin SelectorSearchVisitor implements SelectorVisitor { + T? visitAttributeSelector(AttributeSelector attribute) => null; + T? visitClassSelector(ClassSelector klass) => null; + T? visitIDSelector(IDSelector id) => null; + T? visitParentSelector(ParentSelector placeholder) => null; + T? visitPlaceholderSelector(PlaceholderSelector placeholder) => null; + T? visitTypeSelector(TypeSelector type) => null; + T? visitUniversalSelector(UniversalSelector universal) => null; + + T? visitComplexSelector(ComplexSelector complex) => complex.components + .search((component) => visitCompoundSelector(component.selector)); + + T? visitCompoundSelector(CompoundSelector compound) => + compound.components.search((simple) => simple.accept(this)); + + T? visitPseudoSelector(PseudoSelector pseudo) => + pseudo.selector.andThen(visitSelectorList); + + T? visitSelectorList(SelectorList list) => + list.components.search(visitComplexSelector); +} diff --git a/lib/src/visitor/serialize.dart b/lib/src/visitor/serialize.dart index 55364e899..ad3b9bdde 100644 --- a/lib/src/visitor/serialize.dart +++ b/lib/src/visitor/serialize.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:charcode/charcode.dart'; +import 'package:collection/collection.dart'; import 'package:source_maps/source_maps.dart'; import 'package:string_scanner/string_scanner.dart'; @@ -18,6 +19,7 @@ import '../parse/parser.dart'; import '../utils.dart'; import '../util/character.dart'; import '../util/no_source_map_buffer.dart'; +import '../util/nullable.dart'; import '../util/number.dart'; import '../util/source_map_buffer.dart'; import '../util/span.dart'; @@ -61,18 +63,15 @@ SerializeResult serialize(CssNode node, var css = visitor._buffer.toString(); String prefix; if (charset && css.codeUnits.any((codeUnit) => codeUnit > 0x7F)) { - if (style == OutputStyle.compressed) { - prefix = '\uFEFF'; - } else { - prefix = '@charset "UTF-8";\n'; - } + prefix = style == OutputStyle.compressed ? '\uFEFF' : '@charset "UTF-8";\n'; } else { prefix = ''; } - return SerializeResult(prefix + css, - sourceMap: - sourceMap ? visitor._buffer.buildSourceMap(prefix: prefix) : null); + return ( + prefix + css, + sourceMap: sourceMap ? visitor._buffer.buildSourceMap(prefix: prefix) : null + ); } /// Converts [value] to a CSS string. @@ -103,7 +102,7 @@ String serializeSelector(Selector selector, {bool inspect = false}) { } /// A visitor that converts CSS syntax trees to plain strings. -class _SerializeVisitor +final class _SerializeVisitor implements CssVisitor, ValueVisitor, SelectorVisitor { /// A buffer that contains the CSS produced so far. final SourceMapBuffer _buffer; @@ -182,18 +181,17 @@ class _SerializeVisitor // Ignore sourceMappingURL and sourceURL comments. if (node.text.startsWith(RegExp(r"/\*# source(Mapping)?URL="))) return; - var minimumIndentation = _minimumIndentation(node.text); - assert(minimumIndentation != -1); - if (minimumIndentation == null) { + if (_minimumIndentation(node.text) case var minimumIndentation?) { + assert(minimumIndentation != -1); + minimumIndentation = + math.min(minimumIndentation, node.span.start.column); + + _writeIndentation(); + _writeWithIndent(node.text, minimumIndentation); + } else { _writeIndentation(); _buffer.write(node.text); - return; } - - minimumIndentation = math.min(minimumIndentation, node.span.start.column); - - _writeIndentation(); - _writeWithIndent(node.text, minimumIndentation); }); } @@ -204,8 +202,7 @@ class _SerializeVisitor _buffer.writeCharCode($at); _write(node.name); - var value = node.value; - if (value != null) { + if (node.value case var value?) { _buffer.writeCharCode($space); _write(value); } @@ -247,8 +244,7 @@ class _SerializeVisitor _writeOptionalSpace(); _for(node.url, () => _writeImportUrl(node.url.value)); - var modifiers = node.modifiers; - if (modifiers != null) { + if (node.modifiers case var modifiers?) { _writeOptionalSpace(); _buffer.write(modifiers); } @@ -287,20 +283,17 @@ class _SerializeVisitor } void _visitMediaQuery(CssMediaQuery query) { - if (query.modifier != null) { - _buffer.write(query.modifier); + if (query.modifier case var modifier?) { + _buffer.write(modifier); _buffer.writeCharCode($space); } - if (query.type != null) { - _buffer.write(query.type); - if (query.conditions.isNotEmpty) { - _buffer.write(" and "); - } + if (query.type case var type?) { + _buffer.write(type); + if (query.conditions.isNotEmpty) _buffer.write(" and "); } - if (query.conditions.length == 1 && - query.conditions.first.startsWith("(not ")) { + if (query.conditions case [var first] when first.startsWith("(not ")) { _buffer.write("not "); var condition = query.conditions.first; _buffer.write(condition.substring("(not ".length, condition.length - 1)); @@ -314,7 +307,7 @@ class _SerializeVisitor void visitCssStyleRule(CssStyleRule node) { _writeIndentation(); - _for(node.selector, () => node.selector.value.accept(this)); + _for(node.selector, () => node.selector.accept(this)); _writeOptionalSpace(); _visitChildren(node); } @@ -362,10 +355,11 @@ class _SerializeVisitor throwWithTrace( MultiSpanSassException(error.message, node.value.span, error.primaryLabel, error.secondarySpans), + error, stackTrace); } on SassScriptException catch (error, stackTrace) { throwWithTrace( - SassException(error.message, node.value.span), stackTrace); + SassException(error.message, node.value.span), error, stackTrace); } } } @@ -381,7 +375,7 @@ class _SerializeVisitor } _buffer.writeCharCode($space); - while (isWhitespace(scanner.peekChar())) { + while (scanner.peekChar().isWhitespace) { scanner.readChar(); } } @@ -391,19 +385,16 @@ class _SerializeVisitor void _writeReindentedValue(CssDeclaration node) { var value = (node.value.value as SassString).text; - var minimumIndentation = _minimumIndentation(value); - if (minimumIndentation == null) { - _buffer.write(value); - return; - } else if (minimumIndentation == -1) { - _buffer.write(trimAsciiRight(value, excludeEscape: true)); - _buffer.writeCharCode($space); - return; + switch (_minimumIndentation(value)) { + case null: + _buffer.write(value); + case -1: + _buffer.write(trimAsciiRight(value, excludeEscape: true)); + _buffer.writeCharCode($space); + case var minimumIndentation: + _writeWithIndent( + value, math.min(minimumIndentation, node.name.span.start.column)); } - - minimumIndentation = - math.min(minimumIndentation, node.name.span.start.column); - _writeWithIndent(value, minimumIndentation); } /// Returns the indentation level of the least-indented non-empty line in @@ -446,11 +437,12 @@ class _SerializeVisitor } while (true) { - assert(isWhitespace(scanner.peekChar(-1))); + assert(scanner.peekChar(-1).isWhitespace); // Scan forward until we hit non-whitespace or the end of [text]. var lineStart = scanner.position; var newlines = 1; + inner: while (true) { // If we hit the end of [text], we still need to preserve the fact that // whitespace exists because it could matter for custom properties. @@ -459,11 +451,15 @@ class _SerializeVisitor return; } - var next = scanner.readChar(); - if (next == $space || next == $tab) continue; - if (next != $lf) break; - lineStart = scanner.position; - newlines++; + switch (scanner.readChar()) { + case $space || $tab: + continue inner; + case $lf: + lineStart = scanner.position; + newlines++; + case _: + break inner; + } } _writeTimes($lf, newlines); @@ -492,31 +488,62 @@ class _SerializeVisitor } void _writeCalculationValue(Object value) { - if (value is Value) { - value.accept(this); - } else if (value is CalculationInterpolation) { - _buffer.write(value.value); - } else if (value is CalculationOperation) { - var left = value.left; - var parenthesizeLeft = left is CalculationInterpolation || - (left is CalculationOperation && - left.operator.precedence < value.operator.precedence); - if (parenthesizeLeft) _buffer.writeCharCode($lparen); - _writeCalculationValue(left); - if (parenthesizeLeft) _buffer.writeCharCode($rparen); - - var operatorWhitespace = !_isCompressed || value.operator.precedence == 1; - if (operatorWhitespace) _buffer.writeCharCode($space); - _buffer.write(value.operator.operator); - if (operatorWhitespace) _buffer.writeCharCode($space); - - var right = value.right; - var parenthesizeRight = right is CalculationInterpolation || - (right is CalculationOperation && - _parenthesizeCalculationRhs(value.operator, right.operator)); - if (parenthesizeRight) _buffer.writeCharCode($lparen); - _writeCalculationValue(right); - if (parenthesizeRight) _buffer.writeCharCode($rparen); + 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(value: double(isFinite: false)): + switch (value.value) { + case double.infinity: + _buffer.write('infinity'); + case double.negativeInfinity: + _buffer.write('-infinity'); + case double(isNaN: true): + _buffer.write('NaN'); + } + + if (value.numeratorUnits.firstOrNull case var unit?) { + _writeOptionalSpace(); + _buffer.writeCharCode($asterisk); + _writeOptionalSpace(); + _buffer.writeCharCode($1); + _buffer.write(unit); + } + + case Value(): + value.accept(this); + + case CalculationInterpolation(): + _buffer.write(value.value); + + case CalculationOperation(:var operator, :var left, :var right): + var parenthesizeLeft = left is CalculationInterpolation || + (left is CalculationOperation && + left.operator.precedence < operator.precedence); + if (parenthesizeLeft) _buffer.writeCharCode($lparen); + _writeCalculationValue(left); + if (parenthesizeLeft) _buffer.writeCharCode($rparen); + + var operatorWhitespace = !_isCompressed || operator.precedence == 1; + if (operatorWhitespace) _buffer.writeCharCode($space); + _buffer.write(operator.operator); + if (operatorWhitespace) _buffer.writeCharCode($space); + + var parenthesizeRight = right is CalculationInterpolation || + (right is CalculationOperation && + _parenthesizeCalculationRhs(operator, right.operator)) || + (operator == CalculationOperator.dividedBy && + right is SassNumber && + !right.value.isFinite && + right.hasUnits); + if (parenthesizeRight) _buffer.writeCharCode($lparen); + _writeCalculationValue(right); + if (parenthesizeRight) _buffer.writeCharCode($rparen); } } @@ -525,23 +552,20 @@ class _SerializeVisitor /// /// In `a ? (b # c)`, `outer` is `?` and `right` is `#`. bool _parenthesizeCalculationRhs( - CalculationOperator outer, CalculationOperator right) { - if (outer == CalculationOperator.dividedBy) return true; - if (outer == CalculationOperator.plus) return false; - return right == CalculationOperator.plus || - right == CalculationOperator.minus; - } + CalculationOperator outer, CalculationOperator right) => + switch (outer) { + CalculationOperator.dividedBy => true, + CalculationOperator.plus => false, + _ => right == CalculationOperator.plus || + right == CalculationOperator.minus + }; void visitColor(SassColor value) { switch (value.space) { - case ColorSpace.rgb: - case ColorSpace.hsl: - case ColorSpace.hwb: + case ColorSpace.rgb || ColorSpace.hsl || ColorSpace.hwb: _writeLegacyColor(value); - break; - case ColorSpace.lab: - case ColorSpace.oklab: + case ColorSpace.lab || ColorSpace.oklab: _buffer ..write(value.space) ..writeCharCode($lparen); @@ -555,10 +579,8 @@ class _SerializeVisitor _writeChannel(value.channel2OrNull); _maybeWriteSlashAlpha(value.alpha); _buffer.writeCharCode($rparen); - break; - case ColorSpace.lch: - case ColorSpace.oklch: + case ColorSpace.lch || ColorSpace.oklch: _buffer ..write(value.space) ..writeCharCode($lparen); @@ -573,9 +595,8 @@ class _SerializeVisitor if (!_isCompressed && !value.isChannel2Missing) _buffer.write('deg'); _maybeWriteSlashAlpha(value.alpha); _buffer.writeCharCode($rparen); - break; - default: + case _: _buffer ..write('color(') ..write(value.space) @@ -583,7 +604,6 @@ class _SerializeVisitor _writeBetween(value.channelsOrNull, ' ', _writeChannel); _maybeWriteSlashAlpha(value.alpha); _buffer.writeCharCode($rparen); - break; } } @@ -651,22 +671,21 @@ class _SerializeVisitor return; } - var format = color.format; - if (format != null) { - if (format == ColorFormat.rgbFunction) { + switch (color.format) { + case ColorFormat.rgbFunction: _writeRgb(color); - } else { - _buffer.write((format as SpanColorFormat).original); - } - return; + 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. - var rgb = color.toSpace(ColorSpace.rgb); - var name = namesByColor[rgb]; if (opaque) { - if (name != null) { + var rgb = color.toSpace(ColorSpace.rgb); + if (namesByColor[rgb] case var name?) { _buffer.write(name); return; } @@ -702,9 +721,9 @@ class _SerializeVisitor var greenInt = rgb.channel1.round(); var blueInt = rgb.channel2.round(); - var name = namesByColor[rgb]; var shortHex = _canUseShortHex(redInt, greenInt, blueInt); - if (name != null && name.length <= (shortHex ? 4 : 7)) { + if (namesByColor[rgb] case var name? + when name.length <= (shortHex ? 4 : 7)) { _buffer.write(name); } else if (shortHex) { _buffer.writeCharCode($hash); @@ -760,7 +779,6 @@ class _SerializeVisitor var hsl = color.toSpace(ColorSpace.hsl); _buffer.write(opaque ? "hsl(" : "hsla("); _writeNumber(hsl.channel('hue')); - _buffer.write("deg"); _buffer.write(_commaSeparator); _writeNumber(hsl.channel('saturation')); _buffer.writeCharCode($percent); @@ -855,40 +873,29 @@ class _SerializeVisitor } /// Returns the string to use to separate list items for lists with the given [separator]. - String _separatorString(ListSeparator separator) { - switch (separator) { - case ListSeparator.comma: - return _commaSeparator; - case ListSeparator.slash: - return _isCompressed ? "/" : " / "; - case ListSeparator.space: - return " "; - default: + String _separatorString(ListSeparator separator) => switch (separator) { + ListSeparator.comma => _commaSeparator, + ListSeparator.slash => _isCompressed ? "/" : " / ", + ListSeparator.space => " ", // This should never be used, but it may still be returned since // [_separatorString] is invoked eagerly by [writeList] even for lists // with only one elements. - return ""; - } - } + _ => "" + }; /// Returns whether [value] needs parentheses as an element in a list with the /// given [separator]. - bool _elementNeedsParens(ListSeparator separator, Value value) { - if (value is SassList) { - if (value.asList.length < 2) return false; - if (value.hasBrackets) return false; - switch (separator) { - case ListSeparator.comma: - return value.separator == ListSeparator.comma; - case ListSeparator.slash: - return value.separator == ListSeparator.comma || - value.separator == ListSeparator.slash; - default: - return value.separator != ListSeparator.undecided; - } - } - return false; - } + bool _elementNeedsParens(ListSeparator separator, Value value) => + switch (value) { + SassList(asList: List(length: > 1), hasBrackets: false) => switch ( + separator) { + ListSeparator.comma => value.separator == ListSeparator.comma, + ListSeparator.slash => value.separator == ListSeparator.comma || + value.separator == ListSeparator.slash, + _ => value.separator != ListSeparator.undecided, + }, + _ => false + }; void visitMap(SassMap map) { if (!_inspect) { @@ -918,25 +925,26 @@ class _SerializeVisitor } void visitNumber(SassNumber value) { - var asSlash = value.asSlash; - if (asSlash != null) { - visitNumber(asSlash.item1); + if (value.asSlash case (var before, var after)) { + visitNumber(before); _buffer.writeCharCode($slash); - visitNumber(asSlash.item2); + visitNumber(after); + return; + } + + if (!value.value.isFinite) { + visitCalculation(SassCalculation.unsimplified('calc', [value])); return; } _writeNumber(value.value); if (!_inspect) { - if (value.numeratorUnits.length > 1 || - value.denominatorUnits.isNotEmpty) { + if (value.hasComplexUnits) { throw SassScriptException("$value isn't a valid CSS value."); } - if (value.numeratorUnits.isNotEmpty) { - _buffer.write(value.numeratorUnits.first); - } + if (value.numeratorUnits case [var first]) _buffer.write(first); } else { _buffer.write(value.unitString); } @@ -959,10 +967,9 @@ class _SerializeVisitor // 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. - var integer = fuzzyAsInt(number); - if (integer != null) { - // Node.js still uses exponential notation for integers, so we have to - // handle it here. + 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())); return; } @@ -1173,80 +1180,74 @@ class _SerializeVisitor for (var i = 0; i < string.length; i++) { var char = string.codeUnitAt(i); switch (char) { + case $single_quote when forceDoubleQuote: + buffer.writeCharCode($single_quote); + + case $single_quote when includesDoubleQuote: + _visitQuotedString(string, forceDoubleQuote: true); + return; + case $single_quote: - if (forceDoubleQuote) { - buffer.writeCharCode($single_quote); - } else if (includesDoubleQuote) { - _visitQuotedString(string, forceDoubleQuote: true); - return; - } else { - includesSingleQuote = true; - buffer.writeCharCode($single_quote); - } - break; + includesSingleQuote = true; + buffer.writeCharCode($single_quote); + + case $double_quote when forceDoubleQuote: + buffer.writeCharCode($backslash); + buffer.writeCharCode($double_quote); + + case $double_quote when includesSingleQuote: + _visitQuotedString(string, forceDoubleQuote: true); + return; case $double_quote: - if (forceDoubleQuote) { - buffer.writeCharCode($backslash); - buffer.writeCharCode($double_quote); - } else if (includesSingleQuote) { - _visitQuotedString(string, forceDoubleQuote: true); - return; - } else { - includesDoubleQuote = true; - buffer.writeCharCode($double_quote); - } - break; + includesDoubleQuote = true; + buffer.writeCharCode($double_quote); // Write newline characters and unprintable ASCII characters as escapes. - case $nul: - case $soh: - case $stx: - case $etx: - case $eot: - case $enq: - case $ack: - case $bel: - case $bs: - case $lf: - case $vt: - case $ff: - case $cr: - case $so: - case $si: - case $dle: - case $dc1: - case $dc2: - case $dc3: - case $dc4: - case $nak: - case $syn: - case $etb: - case $can: - case $em: - case $sub: - case $esc: - case $fs: - case $gs: - case $rs: - case $us: + case $nul || + $soh || + $stx || + $etx || + $eot || + $enq || + $ack || + $bel || + $bs || + $lf || + $vt || + $ff || + $cr || + $so || + $si || + $dle || + $dc1 || + $dc2 || + $dc3 || + $dc4 || + $nak || + $syn || + $etb || + $can || + $em || + $sub || + $esc || + $fs || + $gs || + $rs || + $us: _writeEscape(buffer, char, string, i); - break; case $backslash: buffer.writeCharCode($backslash); buffer.writeCharCode($backslash); - break; - default: - var newIndex = _tryPrivateUseCharacter(buffer, char, string, i); - if (newIndex != null) { + case _: + if (_tryPrivateUseCharacter(buffer, char, string, i) + case var newIndex?) { i = newIndex; - break; + } else { + buffer.writeCharCode(char); } - - buffer.writeCharCode(char); - break; } } @@ -1264,27 +1265,22 @@ class _SerializeVisitor void _visitUnquotedString(String string) { var afterNewline = false; for (var i = 0; i < string.length; i++) { - var char = string.codeUnitAt(i); - switch (char) { + switch (string.codeUnitAt(i)) { case $lf: _buffer.writeCharCode($space); afterNewline = true; - break; case $space: if (!afterNewline) _buffer.writeCharCode($space); - break; - default: + case var char: afterNewline = false; - var newIndex = _tryPrivateUseCharacter(_buffer, char, string, i); - if (newIndex != null) { + if (_tryPrivateUseCharacter(_buffer, char, string, i) + case var newIndex?) { i = newIndex; - break; + } else { + _buffer.writeCharCode(char); } - - _buffer.writeCharCode(char); - break; } } } @@ -1305,12 +1301,12 @@ class _SerializeVisitor StringBuffer buffer, int codeUnit, String string, int i) { if (_isCompressed) return null; - if (isPrivateUseBMP(codeUnit)) { + if (codeUnit.isPrivateUseBMP) { _writeEscape(buffer, codeUnit, string, i); return i; } - if (isPrivateUseHighSurrogate(codeUnit) && string.length > i + 1) { + if (codeUnit.isPrivateUseHighSurrogate && string.length > i + 1) { _writeEscape(buffer, combineSurrogates(codeUnit, string.codeUnitAt(i + 1)), string, i + 1); return i + 1; @@ -1331,7 +1327,7 @@ class _SerializeVisitor if (string.length == i + 1) return; var next = string.codeUnitAt(i + 1); - if (isHex(next) || next == $space || next == $tab) { + if (next case int(isHex: true) || $space || $tab) { buffer.writeCharCode($space); } } @@ -1342,8 +1338,7 @@ class _SerializeVisitor _buffer.writeCharCode($lbracket); _buffer.write(attribute.name); - var value = attribute.value; - if (value != null) { + if (attribute.value case var value?) { _buffer.write(attribute.op); if (Parser.isIdentifier(value) && // Emit identifiers that start with `--` with quotes, because IE11 @@ -1356,7 +1351,7 @@ class _SerializeVisitor _visitQuotedString(value); if (attribute.modifier != null) _writeOptionalSpace(); } - if (attribute.modifier != null) _buffer.write(attribute.modifier); + attribute.modifier.andThen(_buffer.write); } _buffer.writeCharCode($rbracket); } @@ -1368,8 +1363,11 @@ class _SerializeVisitor void visitComplexSelector(ComplexSelector complex) { _writeCombinators(complex.leadingCombinators); - if (complex.leadingCombinators.isNotEmpty && - complex.components.isNotEmpty) { + if (complex + case ComplexSelector( + leadingCombinators: [_, ...], + components: [_, ...] + )) { _writeOptionalSpace(); } @@ -1387,7 +1385,7 @@ class _SerializeVisitor /// Writes [combinators] to [_buffer], with spaces in between in expanded /// mode. - void _writeCombinators(List combinators) => + void _writeCombinators(List> combinators) => _writeBetween(combinators, _isCompressed ? '' : ' ', _buffer.write); void visitCompoundSelector(CompoundSelector compound) { @@ -1431,7 +1429,7 @@ class _SerializeVisitor void visitParentSelector(ParentSelector parent) { _buffer.writeCharCode($ampersand); - if (parent.suffix != null) _buffer.write(parent.suffix); + parent.suffix.andThen(_buffer.write); } void visitPlaceholderSelector(PlaceholderSelector placeholder) { @@ -1440,11 +1438,12 @@ class _SerializeVisitor } void visitPseudoSelector(PseudoSelector pseudo) { - var innerSelector = pseudo.selector; // `:not(%a)` is semantically identical to `*`. - if (innerSelector != null && - pseudo.name == 'not' && - innerSelector.isInvisible) { + if (pseudo + case PseudoSelector( + name: 'not', + selector: SelectorList(isInvisible: true) + )) { return; } @@ -1458,7 +1457,7 @@ class _SerializeVisitor _buffer.write(pseudo.argument); if (pseudo.selector != null) _buffer.writeCharCode($space); } - if (innerSelector != null) visitSelectorList(innerSelector); + pseudo.selector.andThen(visitSelectorList); _buffer.writeCharCode($rparen); } @@ -1493,7 +1492,7 @@ class _SerializeVisitor for (var child in parent.children) { if (_isInvisible(child)) continue; - if (previous != null && _requiresSemicolon(previous)) { + if (previous.andThen(_requiresSemicolon) ?? false) { _buffer.writeCharCode($semicolon); } @@ -1541,6 +1540,7 @@ class _SerializeVisitor // (shespanigans?), since we're compressing all whitespace anyway. if (_isCompressed) return false; if (node is! CssComment) return false; + if (node.span.sourceUrl != previous.span.sourceUrl) return false; if (!previous.span.contains(node.span)) { return node.span.start.line == previous.span.end.line; @@ -1672,14 +1672,13 @@ enum LineFeed { } /// The result of converting a CSS AST to CSS text. -class SerializeResult { +typedef SerializeResult = ( /// The serialized CSS. - final String css; + String css, /// The source map indicating how the source files map to [css]. /// /// This is `null` if source mapping was disabled for this compilation. - final SingleMapping? sourceMap; - - SerializeResult(this.css, {this.sourceMap}); -} + { + SingleMapping? sourceMap +}); diff --git a/lib/src/visitor/statement_search.dart b/lib/src/visitor/statement_search.dart index c1651b80e..6abd7fe9f 100644 --- a/lib/src/visitor/statement_search.dart +++ b/lib/src/visitor/statement_search.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import '../ast/sass.dart'; +import '../util/iterable.dart'; import '../util/nullable.dart'; import 'interface/statement.dart'; import 'recursive_statement.dart'; @@ -44,10 +45,10 @@ mixin StatementSearchVisitor implements StatementVisitor { T? visitFunctionRule(FunctionRule node) => visitCallableDeclaration(node); T? visitIfRule(IfRule node) => - node.clauses._search( - (clause) => clause.children._search((child) => child.accept(this))) ?? + node.clauses.search( + (clause) => clause.children.search((child) => child.accept(this))) ?? node.lastClause.andThen((lastClause) => - lastClause.children._search((child) => child.accept(this))); + lastClause.children.search((child) => child.accept(this))); T? visitImportRule(ImportRule node) => null; @@ -92,17 +93,5 @@ mixin StatementSearchVisitor implements StatementVisitor { /// call this. @protected T? visitChildren(List children) => - children._search((child) => child.accept(this)); -} - -extension _IterableExtension on Iterable { - /// Returns the first `T` returned by [callback] for an element of [iterable], - /// or `null` if it returns `null` for every element. - T? _search(T? Function(E element) callback) { - for (var element in this) { - var value = callback(element); - if (value != null) return value; - } - return null; - } + children.search((child) => child.accept(this)); } diff --git a/package.json b/package.json index 823058eef..531856ade 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", "intercept-stdout": "^0.1.2" + }, + "dependencies": { + "sass": "^1.63.5" } } diff --git a/package/package.json b/package/package.json index 7acfeabad..73023b861 100644 --- a/package/package.json +++ b/package/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/nex3" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.0.0" }, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 5f2f9f900..02f85b423 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,4 +1,4 @@ -## 5.0.0 +## 9.0.0 * **Breaking change:** Remove the `SassApiColor.hasCalculatedRgb` and `.hasCalculatedHsl` extension methods. These can now be determined by checking @@ -49,6 +49,143 @@ * Added `SassNumber.convertValueToUnit()` as a shorthand for `SassNumber.convertValue()` with a single numerator. +## 8.2.1 + +* No user-visible changes. + +## 8.2.0 + +* No user-visible changes. + +## 8.1.1 + +* No user-visible changes. + +## 8.1.0 + +* No user-visible changes. + +## 8.0.0 + +* Various classes now use Dart 3 [class modifiers] to more specifically restrict + their usage to the intended patterns. + + [class modifiers]: https://dart.dev/language/class-modifiers + +* All uses of classes from the `tuple` package have been replaced by record + types. + +## 7.2.2 + +* No user-visible changes. + +## 7.2.1 + +* No user-visible changes. + +## 7.2.0 + +* No user-visible changes. + +## 7.1.6 + +* No user-visible changes. + +## 7.1.5 + +* No user-visible changes. + +## 7.1.4 + +* No user-visible changes. + +## 7.1.3 + +* No user-visible changes. + +## 7.1.2 + +* No user-visible changes. + +## 7.1.1 + +* No user-visible changes. + +## 7.1.0 + +* No user-visible changes. + +## 7.0.0 + +* Silent comments in SCSS that are separated by blank lines are now parsed as + separate `SilentComment` nodes rather than a single conjoined node. + +## 6.3.0 + +* No user-visible changes. + +## 6.2.0 + +* No user-visible changes. + +## 6.1.0 + +* No user-visible changes. + +## 6.0.3 + +* No user-visible changes. + +## 6.0.2 + +* No user-visible changes. + +## 6.0.1 + +* No user-visible changes. + +## 6.0.0 + +* **Breaking change:** All selector AST node constructors now require a + `FileSpan` and expose a `span` field. + +* **Breaking change:** The `CssStyleRule.selector` field is now a plain + `SelectorList` rather than a `CssValue`. + +* **Breaking change:** The `ModifiableCssValue` class has been removed. + +* Add an `InterpolationMap` class which represents a mapping from an + interpolation's source to the string it generated. + +* Add an `interpolationMap` parameter to `CssMediaQuery.parseList()`, + `AtRootQuery.parse()`, `ComplexSelector.parse`, `CompoundSelector.parse`, + `ListSelector.parse`, and `SimpleSelector.parse`. + +* Add a `SelectorSearchVisitor` mixin, which can be used to return the first + instance of a selector in an AST matching a certain criterion. + +## 5.1.1 + +* No user-visible changes. + +## 5.1.0 + +* Add `BinaryOperation.isAssociative`. + +* Add a `ReplaceExpressionVisitor`, which recursively visits all expressions in + an AST and rebuilds them with replacement components. + +## 5.0.1 + +* No user-visible changes. + +## 5.0.0 + +* **Breaking change:** Instead of a `Tuple`, `findDependencies()` now returns a + `DependencyReport` object with named fields. This provides finer-grained + access to import URLs, as well as information about `meta.load-css()` calls + with non-interpolated string literal arguments. + ## 4.2.2 * No user-visible changes. diff --git a/pkg/sass_api/lib/sass_api.dart b/pkg/sass_api/lib/sass_api.dart index c839afe17..1f4b076e3 100644 --- a/pkg/sass_api/lib/sass_api.dart +++ b/pkg/sass_api/lib/sass_api.dart @@ -17,6 +17,7 @@ export 'package:sass/src/ast/selector.dart'; export 'package:sass/src/async_import_cache.dart'; export 'package:sass/src/exception.dart' show SassFormatException; export 'package:sass/src/import_cache.dart'; +export 'package:sass/src/interpolation_map.dart'; export 'package:sass/src/value.dart' hide ColorFormat, SpanColorFormat; export 'package:sass/src/visitor/find_dependencies.dart'; export 'package:sass/src/visitor/interface/expression.dart'; @@ -25,6 +26,8 @@ export 'package:sass/src/visitor/interface/statement.dart'; export 'package:sass/src/visitor/recursive_ast.dart'; export 'package:sass/src/visitor/recursive_selector.dart'; export 'package:sass/src/visitor/recursive_statement.dart'; +export 'package:sass/src/visitor/replace_expression.dart'; +export 'package:sass/src/visitor/selector_search.dart'; export 'package:sass/src/visitor/statement_search.dart'; /// Parses [text] as a CSS identifier and returns the result. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index cc68f8941..4a56eb80b 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: 5.0.0-dev +version: 9.0.0-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: - sass: 1.58.0 + sass: 1.67.0 dev_dependencies: - dartdoc: ^5.0.0 + dartdoc: ^6.0.0 dependency_overrides: sass: { path: ../.. } diff --git a/pubspec.yaml b/pubspec.yaml index 530438851..20be0937a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.58.0-dev +version: 1.67.0-dev # TODO: update the color-functions deprecation when this is updated description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -8,44 +8,48 @@ executables: sass: sass environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: args: ^2.0.0 async: ^2.5.0 charcode: ^1.2.0 + cli_pkg: ^2.5.0 cli_repl: ^0.2.1 collection: ^1.16.0 + http: "^1.1.0" + js: ^0.6.3 meta: ^1.3.0 node_interop: ^2.1.0 - js: ^0.6.3 package_config: ^2.0.0 path: ^1.8.0 + pool: ^1.5.1 + protobuf: ">=2.0.0 <4.0.0" + pub_semver: ^2.0.0 source_maps: ^0.10.10 - source_span: ^1.8.1 + source_span: ^1.10.0 stack_trace: ^1.10.0 + stream_channel: ^2.1.0 stream_transform: ^2.0.0 string_scanner: ^1.1.0 term_glyph: ^1.2.0 - tuple: ^2.0.0 + typed_data: ^1.1.0 watcher: ^1.0.0 - http: ^0.13.3 dev_dependencies: - analyzer: ^4.7.0 + analyzer: ">=5.13.0 <7.0.0" archive: ^3.1.2 - cli_pkg: ^2.1.4 crypto: ^3.0.0 dart_style: ^2.0.0 dartdoc: ^6.0.0 grinder: ^0.9.0 - node_preamble: ^2.0.0 + node_preamble: ^2.0.2 lints: ^2.0.0 + protoc_plugin: ">=20.0.0 <22.0.0" pub_api_client: ^2.1.1 - pub_semver: ^2.0.0 pubspec_parse: ^1.0.0 - stream_channel: ^2.1.0 test: ^1.16.7 test_descriptor: ^2.0.0 test_process: ^2.0.0 yaml: ^3.1.0 + cli_util: ^0.4.0 diff --git a/test/browser_test.dart b/test/browser_test.dart new file mode 100644 index 000000000..4e4aa4af8 --- /dev/null +++ b/test/browser_test.dart @@ -0,0 +1,175 @@ +@TestOn('browser') + +import 'package:js/js.dart'; +import 'package:node_interop/js.dart'; +import 'package:node_interop/util.dart'; +import 'package:sass/src/js/compile_options.dart'; +import 'package:sass/src/js/legacy/render_options.dart'; +import 'package:sass/src/js/legacy/render_result.dart'; +import 'package:test/test.dart'; +import 'ensure_npm_package.dart'; +import 'package:sass/src/js/compile_result.dart'; + +@JS() +external Sass get sass; + +@JS() +class Sass { + external NodeCompileResult compileString(String text, + [CompileStringOptions? options]); + external Promise compileStringAsync(String text, + [CompileStringOptions? options]); + external NodeCompileResult compile(String path, [CompileOptions? options]); + external Promise compileAsync(String path, [CompileOptions? options]); + external void render( + RenderOptions options, void callback(Error error, RenderResult result)); + external RenderResult renderSync(RenderOptions options); + external String get info; +} + +void main() { + setUpAll(ensureNpmPackage); + + test('compileAsync() is not available', () { + expect(() => sass.compileAsync('index.scss'), throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Error: The compileAsync() method is only available in Node.js.")); + return true; + }))); + }); + + test('compile() is not available', () { + expect(() => sass.compile('index.scss'), throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Error: The compile() method is only available in Node.js.")); + return true; + }))); + }); + + test('render() is not available', () { + expect(() => sass.render(RenderOptions(), allowInterop((error, result) {})), + throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Error: The render() method is only available in Node.js.")); + return true; + }))); + }); + + test('renderSync() is not available', () { + expect(() => sass.renderSync(RenderOptions()), throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Error: The renderSync() method is only available in Node.js.")); + return true; + }))); + }); + + test('info produces output', () { + expect(sass.info, startsWith("dart-sass\t")); + }); + + test('compileString() produces output', () { + var result = sass.compileString('foo {bar: baz}'); + expect(result.css, equals('foo {\n bar: baz;\n}')); + }); + + test('compileString() produces a sourceMap', () { + var opts = jsify({'sourceMap': true}) as CompileStringOptions; + var result = sass.compileString('foo {bar: baz}', opts); + expect(result.sourceMap, isA()); + + var sourceMap = result.sourceMap!; + + expect(getProperty(sourceMap, 'version'), isA()); + expect(getProperty>(sourceMap, 'sources'), isList); + expect(getProperty>(sourceMap, 'names'), isList); + expect(getProperty(sourceMap, 'mappings'), isA()); + }); + + test('compileString() produces a sourceMap with source content', () { + var opts = jsify({'sourceMap': true, 'sourceMapIncludeSources': true}) + as CompileStringOptions; + var result = sass.compileString('foo {bar: baz}', opts); + expect(result.sourceMap, isA()); + + var sourceMap = result.sourceMap!; + + expect(getProperty>(sourceMap, 'sourcesContent'), isList); + expect(getProperty>(sourceMap, 'sourcesContent'), isNotEmpty); + }); + + test('compileStringAsync() produces output', () async { + var result = sass.compileStringAsync('foo {bar: baz}'); + result = await promiseToFuture(result); + expect((result as NodeCompileResult).css, equals('foo {\n bar: baz;\n}')); + }); + + test('compileStringAsync() produces a sourceMap', () async { + var opts = jsify({'sourceMap': true}) as CompileStringOptions; + var result = sass.compileStringAsync('foo {bar: baz}', opts); + result = await promiseToFuture(result); + var sourceMap = (result as NodeCompileResult).sourceMap; + + expect(sourceMap, isA()); + + sourceMap = sourceMap!; + + expect(getProperty(sourceMap, 'version'), isA()); + expect(getProperty>(sourceMap, 'sources'), isList); + expect(getProperty>(sourceMap, 'names'), isList); + expect(getProperty(sourceMap, 'mappings'), isA()); + }); + + test('compileStringAsync() produces a sourceMap with source content', + () async { + var opts = jsify({'sourceMap': true, 'sourceMapIncludeSources': true}) + as CompileStringOptions; + var result = sass.compileStringAsync('foo {bar: baz}', opts); + result = await promiseToFuture(result); + var sourceMap = (result as NodeCompileResult).sourceMap; + + expect(sourceMap, isA()); + + sourceMap = sourceMap!; + + expect(getProperty>(sourceMap, 'sourcesContent'), isList); + expect(getProperty>(sourceMap, 'sourcesContent'), isNotEmpty); + }); + + test('compileString() throws error if importing without custom importer', () { + expect(() => sass.compileString("@use 'other';"), + throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Custom importers are required to load stylesheets when compiling in the browser.")); + return true; + }))); + }); + + test('compileStringAsync() throws error if importing without custom importer', + () async { + var result = sass.compileStringAsync("@use 'other';"); + expect(() async => await promiseToFuture(result), + throwsA(predicate((error) { + expect(error, const TypeMatcher()); + expect( + error.toString(), + startsWith( + "Custom importers are required to load stylesheets when compiling in the browser.")); + return true; + }))); + }); +} diff --git a/test/browser_test.html b/test/browser_test.html new file mode 100644 index 000000000..6dcd79177 --- /dev/null +++ b/test/browser_test.html @@ -0,0 +1,24 @@ + + + + + Browser Test + + + + + + + + + + diff --git a/test/cli/dart/colon_args_test.dart b/test/cli/dart/colon_args_test.dart index e43ae9ed8..6a8da9f78 100644 --- a/test/cli/dart/colon_args_test.dart +++ b/test/cli/dart/colon_args_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import '../dart_test.dart'; diff --git a/test/cli/dart/errors_test.dart b/test/cli/dart/errors_test.dart index 57d37f2bb..478d2129f 100644 --- a/test/cli/dart/errors_test.dart +++ b/test/cli/dart/errors_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; diff --git a/test/cli/dart/repl_test.dart b/test/cli/dart/repl_test.dart index 58ece455d..d19641e14 100644 --- a/test/cli/dart/repl_test.dart +++ b/test/cli/dart/repl_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import '../dart_test.dart'; diff --git a/test/cli/dart/source_maps_test.dart b/test/cli/dart/source_maps_test.dart index acdac9c7c..133bf719d 100644 --- a/test/cli/dart/source_maps_test.dart +++ b/test/cli/dart/source_maps_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import '../dart_test.dart'; diff --git a/test/cli/dart/update_test.dart b/test/cli/dart/update_test.dart index 2b5e6f68c..ef401f916 100644 --- a/test/cli/dart/update_test.dart +++ b/test/cli/dart/update_test.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. // OS X's modification time reporting is flaky, so we skip these tests on it. -@TestOn('!mac-os') +@TestOn('vm && !mac-os') import 'package:test/test.dart'; diff --git a/test/cli/dart/watch_test.dart b/test/cli/dart/watch_test.dart index 7782e239c..b50568d4f 100644 --- a/test/cli/dart/watch_test.dart +++ b/test/cli/dart/watch_test.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. // OS X's modification time reporting is flaky, so we skip these tests on it. -@TestOn('!mac-os') +@TestOn('vm && !mac-os') // File watching is inherently flaky at the OS level. To mitigate this, we do a // few retries when the tests fail. diff --git a/test/cli/dart_test.dart b/test/cli/dart_test.dart index b296a4d1d..27a14c626 100644 --- a/test/cli/dart_test.dart +++ b/test/cli/dart_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'dart:convert'; import 'package:cli_pkg/testing.dart' as pkg; diff --git a/test/cli/node/colon_args_test.dart b/test/cli/node/colon_args_test.dart index b4f87fa69..0fb26c139 100644 --- a/test/cli/node/colon_args_test.dart +++ b/test/cli/node/colon_args_test.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') @Tags(['node']) import 'package:test/test.dart'; diff --git a/test/cli/node/errors_test.dart b/test/cli/node/errors_test.dart index dc9068b5d..87220b350 100644 --- a/test/cli/node/errors_test.dart +++ b/test/cli/node/errors_test.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') @Tags(['node']) import 'package:test/test.dart'; diff --git a/test/cli/node/repl_test.dart b/test/cli/node/repl_test.dart index 949cb847c..3fe23d2f4 100644 --- a/test/cli/node/repl_test.dart +++ b/test/cli/node/repl_test.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') @Tags(['node']) import 'package:test/test.dart'; diff --git a/test/cli/node/source_maps_test.dart b/test/cli/node/source_maps_test.dart index 555ddfa22..cf9939c3b 100644 --- a/test/cli/node/source_maps_test.dart +++ b/test/cli/node/source_maps_test.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') @Tags(['node']) import 'package:test/test.dart'; diff --git a/test/cli/node/update_test.dart b/test/cli/node/update_test.dart index 6f1ec04bd..614f25da6 100644 --- a/test/cli/node/update_test.dart +++ b/test/cli/node/update_test.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. // OS X's modification time reporting is flaky, so we skip these tests on it. -@TestOn('!mac-os') +@TestOn('vm && !mac-os') @Tags(['node']) import 'package:test/test.dart'; diff --git a/test/cli/node/watch_test.dart b/test/cli/node/watch_test.dart index 8bcd001a9..780b649d7 100644 --- a/test/cli/node/watch_test.dart +++ b/test/cli/node/watch_test.dart @@ -3,7 +3,7 @@ // https://opensource.org/licenses/MIT. // OS X's modification time reporting is flaky, so we skip these tests on it. -@TestOn('!mac-os') +@TestOn('vm && !mac-os') @Tags(['node']) // File watching is inherently flaky at the OS level. To mitigate this, we do a diff --git a/test/cli/node_test.dart b/test/cli/node_test.dart index a00b4d8b1..9169cc6aa 100644 --- a/test/cli/node_test.dart +++ b/test/cli/node_test.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') @Tags(['node']) import 'dart:convert'; diff --git a/test/cli/shared.dart b/test/cli/shared.dart index 2b92179f1..63c990685 100644 --- a/test/cli/shared.dart +++ b/test/cli/shared.dart @@ -813,6 +813,72 @@ void sharedTests( }); }); + group("with --fatal-deprecation", () { + test("set to a specific deprecation, errors as intended", () async { + await d.file("test.scss", "a {b: (4/2)}").create(); + var sass = await runSass(["--fatal-deprecation=slash-div", "test.scss"]); + expect(sass.stdout, emitsDone); + await sass.shouldExit(65); + }); + + test("set to version, errors as intended", () async { + await d.file("test.scss", "a {b: (4/2)}").create(); + var sass = await runSass(["--fatal-deprecation=1.33.0", "test.scss"]); + expect(sass.stdout, emitsDone); + await sass.shouldExit(65); + }); + + test("set to lower version, only warns", () async { + await d.file("test.scss", "a {b: (4/2)}").create(); + var sass = await runSass(["--fatal-deprecation=1.32.0", "test.scss"]); + expect( + sass.stdout, + emitsInOrder([ + "a {", + " b: 2;", + "}", + ])); + expect(sass.stderr, emitsThrough(contains("DEPRECATION WARNING"))); + await sass.shouldExit(0); + }); + + test("set to future version, usage error", () async { + await d.file("test.scss", "a {b: (4/2)}").create(); + var sass = await runSass(["--fatal-deprecation=1000.0.0", "test.scss"]); + expect(sass.stdout, emitsThrough(contains("Invalid version 1000.0.0"))); + await sass.shouldExit(64); + }); + }); + + group("with --future-deprecation", () { + test("set to a deprecation, warns as intended", () async { + await d.file("_lib.scss", "a{b:c}").create(); + await d.file("test.scss", "@import 'lib'").create(); + var sass = await runSass(["--future-deprecation=import", "test.scss"]); + expect( + sass.stdout, + emitsInOrder([ + "a {", + " b: c;", + "}", + ])); + expect(sass.stderr, emitsThrough(contains("DEPRECATION WARNING"))); + await sass.shouldExit(0); + }); + + test("set alongside --fatal-deprecation, errors as intended", () async { + await d.file("_lib.scss", "a{b:c}").create(); + await d.file("test.scss", "@import 'lib'").create(); + var sass = await runSass([ + "--future-deprecation=import", + "--fatal-deprecation=import", + "test.scss" + ]); + expect(sass.stdout, emitsDone); + await sass.shouldExit(65); + }); + }); + test("doesn't unassign variables", () async { // This is a regression test for one of the strangest errors I've ever // encountered. Every bit of what's going on was necessary to reproduce it, diff --git a/test/cli/shared/repl.dart b/test/cli/shared/repl.dart index 12f33ea2d..eeefe4f42 100644 --- a/test/cli/shared/repl.dart +++ b/test/cli/shared/repl.dart @@ -195,12 +195,14 @@ void sharedTests(Future runSass(Iterable arguments)) { test("a runtime error", () async { var sass = await runSass(["--interactive"]); - sass.stdin.writeln("max(2, 1 + blue)"); + sass.stdin.writeln("@use 'sass:math'"); + sass.stdin.writeln("math.max(2, 1 + blue)"); await expectLater( sass.stdout, emitsInOrder([ - ">> max(2, 1 + blue)", - " ^^^^^^^^", + ">> @use 'sass:math'", + ">> math.max(2, 1 + blue)", + " ^^^^^^^^", 'Error: Undefined operation "1 + blue".' ])); await sass.kill(); @@ -300,13 +302,15 @@ void sharedTests(Future runSass(Iterable arguments)) { group("and colorizes", () { test("an error in the source text", () async { var sass = await runSass(["--interactive", "--color"]); - sass.stdin.writeln("max(2, 1 + blue)"); + sass.stdin.writeln("@use 'sass:math'"); + sass.stdin.writeln("math.max(2, 1 + blue)"); await expectLater( sass.stdout, emitsInOrder([ - ">> max(2, 1 + blue)", - "\u001b[31m\u001b[1F\u001b[10C1 + blue", - " ^^^^^^^^", + ">> @use 'sass:math'", + ">> math.max(2, 1 + blue)", + "\u001b[31m\u001b[1F\u001b[15C1 + blue", + " ^^^^^^^^", '\u001b[0mError: Undefined operation "1 + blue".' ])); await sass.kill(); diff --git a/test/cli/shared/update.dart b/test/cli/shared/update.dart index fb45398c9..01aa73949 100644 --- a/test/cli/shared/update.dart +++ b/test/cli/shared/update.dart @@ -18,7 +18,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test.scss", "a {b: c}").create(); var sass = await update(["test.scss:out.css"]); - expect(sass.stdout, emits('Compiled test.scss to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.shouldExit(0); await d @@ -32,7 +32,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test.scss", "a {b: c}").create(); var sass = await update(["test.scss:out.css"]); - expect(sass.stdout, emits('Compiled test.scss to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.shouldExit(0); await d @@ -45,14 +45,14 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test.scss", "@import 'other'").create(); var sass = await update(["test.scss:out.css"]); - expect(sass.stdout, emits('Compiled test.scss to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.shouldExit(0); await tick; await d.file("other.scss", "x {y: z}").create(); sass = await update(["test.scss:out.css"]); - expect(sass.stdout, emits('Compiled test.scss to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.shouldExit(0); await d @@ -66,16 +66,16 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test2.scss", r"$var: 2; @import 'other'").create(); var sass = await update(["test1.scss:out1.css", "test2.scss:out2.css"]); - expect(sass.stdout, emits('Compiled test1.scss to out1.css.')); - expect(sass.stdout, emits('Compiled test2.scss to out2.css.')); + expect(sass.stdout, emits(endsWith('Compiled test1.scss to out1.css.'))); + expect(sass.stdout, emits(endsWith('Compiled test2.scss to out2.css.'))); await sass.shouldExit(0); await tick; await d.file("other.scss", r"x {y: $var}").create(); sass = await update(["test1.scss:out1.css", "test2.scss:out2.css"]); - expect(sass.stdout, emits('Compiled test1.scss to out1.css.')); - expect(sass.stdout, emits('Compiled test2.scss to out2.css.')); + expect(sass.stdout, emits(endsWith('Compiled test1.scss to out1.css.'))); + expect(sass.stdout, emits(endsWith('Compiled test2.scss to out2.css.'))); await sass.shouldExit(0); await d @@ -90,7 +90,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await update(["-:out.css"]); sass.stdin.writeln("a {b: c}"); sass.stdin.close(); - expect(sass.stdout, emits('Compiled stdin to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled stdin to out.css.'))); await sass.shouldExit(0); await d @@ -100,7 +100,7 @@ void sharedTests(Future runSass(Iterable arguments)) { sass = await update(["-:out.css"]); sass.stdin.writeln("x {y: z}"); sass.stdin.close(); - expect(sass.stdout, emits('Compiled stdin to out.css.')); + expect(sass.stdout, emits(endsWith('Compiled stdin to out.css.'))); await sass.shouldExit(0); await d @@ -142,7 +142,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test2.scss", "d {e: f}").create(); var sass = await update(["test1.scss:out1.css", "test2.scss:out2.css"]); - expect(sass.stdout, emits('Compiled test2.scss to out2.css.')); + expect(sass.stdout, emits(endsWith('Compiled test2.scss to out2.css.'))); await sass.shouldExit(0); await d.file("out1.css", "x {y: z}").validate(); diff --git a/test/cli/shared/watch.dart b/test/cli/shared/watch.dart index 252db8fdb..b46fd97a0 100644 --- a/test/cli/shared/watch.dart +++ b/test/cli/shared/watch.dart @@ -40,7 +40,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await sass.kill(); @@ -71,8 +71,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await expectLater(sass.stderr, emits(message)); await expectLater( sass.stderr, emitsThrough(contains('test1.scss 1:7'))); - await expectLater( - sass.stdout, emitsThrough('Compiled test2.scss to out2.css.')); + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test2.scss to out2.css.'))); await expectLater(sass.stdout, _watchingForChanges); await sass.kill(); @@ -95,8 +95,8 @@ void sharedTests(Future runSass(Iterable arguments)) { sass.stderr, emits('Error: Expected expression.')); await expectLater( sass.stderr, emitsThrough(contains('test1.scss 1:7'))); - await expectLater( - sass.stdout, emitsThrough('Compiled test2.scss to out2.css.')); + await expectLater(sass.stdout, + emitsThrough(endsWith('Compiled test2.scss to out2.css.'))); await expectLater(sass.stdout, _watchingForChanges); await sass.kill(); @@ -138,13 +138,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("test.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -156,14 +156,14 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); var sass = await watch(["dir:out"]); - await expectLater( - sass.stdout, emits(_compiled('dir/test.scss', 'out/test.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test.scss', 'out/test.css')))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.dir("dir", [d.file("test.scss", "x {y: z}")]).create(); - await expectLater( - sass.stdout, emits(_compiled('dir/test.scss', 'out/test.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test.scss', 'out/test.css')))); await sass.kill(); await d.dir("out", [ @@ -178,13 +178,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["."]); await expectLater( - sass.stdout, emits('Compiled test.scss to test.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to test.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("test.scss", "r {o: g}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to test.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to test.css.'))); // When using the native Node.js watcher on Linux, the "modify" event // from writing test.css can interfere with the "modify" event from @@ -194,7 +194,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to test.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to test.css.'))); await sass.kill(); @@ -210,13 +210,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("_other.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -230,13 +230,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("_other.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -250,13 +250,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("_other.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -264,6 +264,100 @@ void sharedTests(Future runSass(Iterable arguments)) { .validate(); }); + group("through meta.load-css", () { + test("with the default namespace", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", """ + @use 'sass:meta'; + @include meta.load-css('other'); + """).create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await expectLater(sass.stdout, _watchingForChanges); + await tickIfPoll(); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("with a custom namespace", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", """ + @use 'sass:meta' as m; + @include m.load-css('other'); + """).create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await expectLater(sass.stdout, _watchingForChanges); + await tickIfPoll(); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test("with no namespace", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", """ + @use 'sass:meta' as *; + @include load-css('other'); + """).create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await expectLater(sass.stdout, _watchingForChanges); + await tickIfPoll(); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + + test(r"with $with", () async { + await d.file("_other.scss", "a {b: c}").create(); + await d.file("test.scss", r""" + @use 'sass:meta'; + @include meta.load-css('other', $with: ()); + """).create(); + + var sass = await watch(["test.scss:out.css"]); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await expectLater(sass.stdout, _watchingForChanges); + await tickIfPoll(); + + await d.file("_other.scss", "x {y: z}").create(); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); + await sass.kill(); + + await d + .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) + .validate(); + }); + }); + // Regression test for #550 test("with an error that's later fixed", () async { await d.file("_other.scss", "a {b: c}").create(); @@ -271,7 +365,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -284,7 +378,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("_other.scss", "q {r: s}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await tick; await d .file("out.css", equalsIgnoringWhitespace("q { r: s; }")) @@ -292,7 +386,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("_other.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await tick; await d .file("out.css", equalsIgnoringWhitespace("x { y: z; }")) @@ -307,7 +401,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -319,7 +413,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.file("test.scss", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -332,7 +426,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -351,7 +445,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["--stop-on-error", "test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -376,7 +470,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -397,13 +491,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["-I", "dir", "test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); d.file("_other.scss").io.deleteSync(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -424,7 +518,7 @@ void sharedTests(Future runSass(Iterable arguments)) { d.file("_other.sass").io.deleteSync(); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -447,8 +541,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await tickIfPoll(); await d.file("_other.scss", "a {b: c}").create(); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -469,8 +563,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await tickIfPoll(); await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -492,7 +586,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir2", [d.file("_other.scss", "a {b: c}")]).create(); await expectLater(sass.stdout, - emits(_compiled('dir1/test.scss', 'out/test.css'))); + emits(endsWith(_compiled('dir1/test.scss', 'out/test.css')))); await sass.kill(); await d @@ -507,7 +601,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -527,14 +621,14 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch( ["-I", "dir1", "-I", "dir2", "test.scss:out.css"]); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.dir("dir1", [d.file("_other.scss", "x {y: z}")]).create(); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -547,14 +641,14 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); var sass = await watch(["-I", "dir", "test.scss:out.css"]); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("_other.scss", "x {y: z}").create(); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -568,14 +662,14 @@ void sharedTests(Future runSass(Iterable arguments)) { .dir("other", [d.file("_index.scss", "a {b: c}")]).create(); var sass = await watch(["test.scss:out.css"]); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("_other.scss", "x {y: z}").create(); - await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + await expectLater(sass.stdout, + emits(endsWith('Compiled test.scss to out.css.'))); await sass.kill(); await d @@ -599,8 +693,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); await expectLater( sass.stdout, - emits('Compiled ${p.join('dir', 'test.scss')} to ' - '${p.join('out', 'test.css')}.')); + emits(endsWith('Compiled ${p.join('dir', 'test.scss')} to ' + '${p.join('out', 'test.css')}.'))); await sass.kill(); await d.dir("out", [ @@ -615,13 +709,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.css:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.css to out.css.')); + sass.stdout, emits(endsWith('Compiled test.css to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.file("test.css", "x {y: z}").create(); await expectLater( - sass.stdout, emits('Compiled test.css to out.css.')); + sass.stdout, emits(endsWith('Compiled test.css to out.css.'))); await sass.kill(); await d @@ -641,17 +735,19 @@ void sharedTests(Future runSass(Iterable arguments)) { await expectLater( sass.stdout, emitsInAnyOrder([ - _compiled('dir/test1.scss', 'out/test1.css'), - _compiled('dir/test2.scss', 'out/test2.css') + endsWith(_compiled('dir/test1.scss', 'out/test1.css')), + endsWith(_compiled('dir/test2.scss', 'out/test2.css')) ])); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.dir("dir", [d.file("test2.scss", "x {y: z}")]).create(); - await expectLater( - sass.stdout, emits(_compiled('dir/test2.scss', 'out/test2.css'))); - expect(sass.stdout, - neverEmits(_compiled('dir/test1.scss', 'out/test1.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test2.scss', 'out/test2.css')))); + expect( + sass.stdout, + neverEmits( + endsWith(_compiled('dir/test1.scss', 'out/test1.css')))); await tick; await sass.kill(); }); @@ -665,12 +761,13 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["-I", "dir", "test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); await d.dir("dir", [d.file("_other.scss", "a {b: c}")]).create(); - expect(sass.stdout, neverEmits('Compiled test.scss to out.css.')); + expect(sass.stdout, + neverEmits(endsWith('Compiled test.scss to out.css.'))); await tick; await sass.kill(); @@ -686,7 +783,7 @@ void sharedTests(Future runSass(Iterable arguments)) { var sass = await watch(["test.scss:out.css"]); await expectLater( - sass.stdout, emits('Compiled test.scss to out.css.')); + sass.stdout, emits(endsWith('Compiled test.scss to out.css.'))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -701,8 +798,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); var sass = await watch(["dir:out"]); - await expectLater( - sass.stdout, emits(_compiled('dir/test.scss', 'out/test.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test.scss', 'out/test.css')))); await expectLater(sass.stdout, _watchingForChanges); await tickIfPoll(); @@ -723,8 +820,8 @@ void sharedTests(Future runSass(Iterable arguments)) { await tickIfPoll(); await d.dir("dir", [d.file("test.scss", "a {b: c}")]).create(); - await expectLater( - sass.stdout, emits(_compiled('dir/test.scss', 'out/test.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test.scss', 'out/test.css')))); await sass.kill(); await d.dir("out", [ @@ -741,7 +838,7 @@ void sharedTests(Future runSass(Iterable arguments)) { await d.dir("dir", [d.file("_test.scss", "a {b: c}")]).create(); expect(sass.stdout, - neverEmits(_compiled('dir/test.scss', 'out/test.css'))); + neverEmits(endsWith(_compiled('dir/test.scss', 'out/test.css')))); await tick; await sass.kill(); @@ -763,8 +860,8 @@ void sharedTests(Future runSass(Iterable arguments)) { // did incorrectly trigger a compilation, it would emit a message // before the message for this change. await d.file("dir/test2.scss", "x {y: z}").create(); - await expectLater( - sass.stdout, emits(_compiled('dir/test2.scss', 'dir/test2.css'))); + await expectLater(sass.stdout, + emits(endsWith(_compiled('dir/test2.scss', 'dir/test2.css')))); await sass.kill(); diff --git a/test/compressed_test.dart b/test/compressed_test.dart index 22fda58d1..3e383b3f1 100644 --- a/test/compressed_test.dart +++ b/test/compressed_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; diff --git a/test/dart_api/function_test.dart b/test/dart_api/function_test.dart index ba6558eb3..bebefde0d 100644 --- a/test/dart_api/function_test.dart +++ b/test/dart_api/function_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; diff --git a/test/dart_api/importer_test.dart b/test/dart_api/importer_test.dart index 8ee254e47..bf701f2b3 100644 --- a/test/dart_api/importer_test.dart +++ b/test/dart_api/importer_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'dart:convert'; import 'package:test/test.dart'; diff --git a/test/dart_api/logger_test.dart b/test/dart_api/logger_test.dart index 19daf6189..a1c0ec93f 100644 --- a/test/dart_api/logger_test.dart +++ b/test/dart_api/logger_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:source_span/source_span.dart'; import 'package:stack_trace/stack_trace.dart'; diff --git a/test/dart_api/value/boolean_test.dart b/test/dart_api/value/boolean_test.dart index 53c5378f0..8a0579707 100644 --- a/test/dart_api/value/boolean_test.dart +++ b/test/dart_api/value/boolean_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -26,6 +28,7 @@ void main() { }); test("isn't any other type", () { + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); @@ -52,6 +55,7 @@ void main() { }); test("isn't any other type", () { + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/calculation_test.dart b/test/dart_api/value/calculation_test.dart new file mode 100644 index 000000000..594842cee --- /dev/null +++ b/test/dart_api/value/calculation_test.dart @@ -0,0 +1,73 @@ +// Copyright 2018 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') + +import 'package:test/test.dart'; + +import 'package:sass/sass.dart'; + +import 'utils.dart'; + +void main() { + group("new SassCalculation", () { + late Value value; + setUp(() => value = SassCalculation.unsimplified('calc', [SassNumber(1)])); + + test("is a calculation", () { + expect(value.assertCalculation(), equals(value)); + }); + + test("isn't any other type", () { + expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertColor, throwsSassScriptException); + expect(value.assertFunction, throwsSassScriptException); + expect(value.assertMap, throwsSassScriptException); + expect(value.tryMap(), isNull); + expect(value.assertNumber, throwsSassScriptException); + expect(value.assertString, throwsSassScriptException); + }); + }); + + group('SassCalculation simplifies', () { + test('calc()', () { + expect(SassCalculation.calc(SassNumber(1)).assertNumber(), + equals(SassNumber(1))); + }); + + test('min()', () { + expect(SassCalculation.min([SassNumber(1), SassNumber(2)]).assertNumber(), + equals(SassNumber(1))); + }); + + test('max()', () { + expect(SassCalculation.max([SassNumber(1), SassNumber(2)]).assertNumber(), + equals(SassNumber(2))); + }); + + test('clamp()', () { + expect( + SassCalculation.clamp(SassNumber(1), SassNumber(2), SassNumber(3)) + .assertNumber(), + equals(SassNumber(2))); + }); + + test('operations', () { + expect( + SassCalculation.calc(SassCalculation.operate( + CalculationOperator.plus, + SassCalculation.operate( + CalculationOperator.minus, + SassCalculation.operate( + CalculationOperator.times, + SassCalculation.operate(CalculationOperator.dividedBy, + SassNumber(5), SassNumber(2)), + SassNumber(3)), + SassNumber(4)), + SassNumber(5))) + .assertNumber(), + equals(SassNumber(8.5))); + }); + }); +} diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index 786467e35..9f8c1dbaf 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. @Skip("TODO(nweiz): Update these for the new Color API") +@TestOn('vm') import 'package:test/test.dart'; @@ -181,6 +182,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); expect(value.tryMap(), isNull); diff --git a/test/dart_api/value/function_test.dart b/test/dart_api/value/function_test.dart index 41cae71a2..03776d07c 100644 --- a/test/dart_api/value/function_test.dart +++ b/test/dart_api/value/function_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -27,6 +29,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); expect(value.tryMap(), isNull); diff --git a/test/dart_api/value/list_test.dart b/test/dart_api/value/list_test.dart index 2c38b6f32..e49605ce5 100644 --- a/test/dart_api/value/list_test.dart +++ b/test/dart_api/value/list_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -105,6 +107,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/map_test.dart b/test/dart_api/value/map_test.dart index a7817f769..e82ef7f17 100644 --- a/test/dart_api/value/map_test.dart +++ b/test/dart_api/value/map_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -131,6 +133,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertNumber, throwsSassScriptException); diff --git a/test/dart_api/value/null_test.dart b/test/dart_api/value/null_test.dart index 289e472ae..4badc075a 100644 --- a/test/dart_api/value/null_test.dart +++ b/test/dart_api/value/null_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -22,6 +24,7 @@ void main() { test("isn't any type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/number_test.dart b/test/dart_api/value/number_test.dart index 37f36d207..42741fdf9 100644 --- a/test/dart_api/value/number_test.dart +++ b/test/dart_api/value/number_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'dart:math' as math; import 'package:test/test.dart'; @@ -128,6 +130,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/string_test.dart b/test/dart_api/value/string_test.dart index 1e93ef67c..61d8023b2 100644 --- a/test/dart_api/value/string_test.dart +++ b/test/dart_api/value/string_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -32,6 +34,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api_test.dart b/test/dart_api_test.dart index 036a78200..60691aad3 100644 --- a/test/dart_api_test.dart +++ b/test/dart_api_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:package_config/package_config.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; diff --git a/test/deprecations_test.dart b/test/deprecations_test.dart new file mode 100644 index 000000000..157963477 --- /dev/null +++ b/test/deprecations_test.dart @@ -0,0 +1,133 @@ +// Copyright 2018 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') + +import 'package:test/test.dart'; + +import 'package:sass/sass.dart'; + +void main() { + // Deprecated in all version of Dart Sass + test("callString is violated by passing a string to call", () { + _expectDeprecation("a { b: call(random)}", Deprecation.callString); + }); + + // Deprecated in 1.3.2 + test("elseIf is violated by using @elseif instead of @else if", () { + _expectDeprecation("@if false {} @elseif {}", Deprecation.elseif); + }); + + // Deprecated in 1.7.2 + test("mozDocument is violated by most @-moz-document rules", () { + _expectDeprecation( + "@-moz-document url-prefix(foo) {}", Deprecation.mozDocument); + }); + + // Deprecated in 1.17.2 + test("newGlobal is violated by declaring a new variable with !global", () { + _expectDeprecation(r"a {$foo: bar !global;}", Deprecation.newGlobal); + }); + + // Deprecated in 1.23.0 + group("colorModuleCompat is violated by", () { + var color = "@use 'sass:color'; a { b: color"; + + test("passing a number to color.invert", () { + _expectDeprecation("$color.invert(0)}", Deprecation.colorModuleCompat); + }); + + test("passing a number to color.grayscale", () { + _expectDeprecation("$color.grayscale(0)}", Deprecation.colorModuleCompat); + }); + + test("passing a number to color.opacity", () { + _expectDeprecation("$color.opacity(0)}", Deprecation.colorModuleCompat); + }); + + test("using color.alpha for a microsoft filter", () { + _expectDeprecation( + "$color.alpha(foo=bar)}", Deprecation.colorModuleCompat); + }); + }); + + // Deprecated in 1.33.0 + test("slashDiv is violated by using / for division", () { + _expectDeprecation(r"a {b: (4/2)}", Deprecation.slashDiv); + }); + + // Deprecated in 1.54.0 + group("bogusCombinators is violated by", () { + test("adjacent combinators", () { + _expectDeprecation("a > > a {b: c}", Deprecation.bogusCombinators); + }); + + test("leading combinators", () { + _expectDeprecation("a > {b: c}", Deprecation.bogusCombinators); + }); + + test("trailing combinators", () { + _expectDeprecation("> a {b: c}", Deprecation.bogusCombinators); + }); + }); + + // Deprecated in 1.55.0 + group("strictUnary is violated by", () { + test("an ambiguous + operator", () { + _expectDeprecation(r"a {b: 1 +2}", Deprecation.strictUnary); + }); + + test("an ambiguous - operator", () { + _expectDeprecation(r"a {$x: 2; b: 1 -$x}", Deprecation.strictUnary); + }); + }); + + // Deprecated in various Sass versions <=1.56.0 + group("functionUnits is violated by", () { + test("a hue with a non-angle unit", () { + _expectDeprecation("a {b: hsl(10px, 0%, 0%)}", Deprecation.functionUnits); + }); + + test("a saturation/lightness with a non-percent unit", () { + _expectDeprecation( + "a {b: hsl(10deg, 0px, 0%)}", Deprecation.functionUnits); + }); + + test("a saturation/lightness with no unit", () { + _expectDeprecation("a {b: hsl(10deg, 0%, 0)}", Deprecation.functionUnits); + }); + + test("an alpha value with a non-percent unit", () { + _expectDeprecation( + r"@use 'sass:color'; a {b: color.change(red, $alpha: 1px)}", + Deprecation.functionUnits); + }); + + test("calling math.random with units", () { + _expectDeprecation("@use 'sass:math'; a {b: math.random(100px)}", + Deprecation.functionUnits); + }); + + test("calling list.nth with units", () { + _expectDeprecation("@use 'sass:list'; a {b: list.nth(1 2, 1px)}", + Deprecation.functionUnits); + }); + + test("calling list.set-nth with units", () { + _expectDeprecation("@use 'sass:list'; a {b: list.set-nth(1 2, 1px, 3)}", + Deprecation.functionUnits); + }); + }); +} + +/// Confirms that [source] will error if [deprecation] is fatal. +void _expectDeprecation(String source, Deprecation deprecation) { + try { + compileStringToResult(source, fatalDeprecations: {deprecation}); + } catch (e) { + if (e.toString().contains("$deprecation deprecation to be fatal")) return; + fail('Unexpected error: $e'); + } + fail("No error for violating $deprecation."); +} diff --git a/test/doc_comments_test.dart b/test/doc_comments_test.dart index 7e20c5311..82e2f5252 100644 --- a/test/doc_comments_test.dart +++ b/test/doc_comments_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + 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 1de67ad40..5cc2a2411 100644 --- a/test/double_check_test.dart +++ b/test/double_check_test.dart @@ -2,10 +2,11 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + 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'; @@ -16,21 +17,13 @@ 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:", () { - /// The pattern of a checksum in a generated file. - var checksumPattern = RegExp(r"^// Checksum: (.*)$", multiLine: true); - synchronize.sources.forEach((sourcePath, targetPath) { test(targetPath, () { - var message = "$targetPath is out-of-date.\n" - "Run pub run grinder to update it."; - - var target = File(targetPath).readAsStringSync(); - var match = checksumPattern.firstMatch(target); - if (match == null) fail(message); - - var source = File(sourcePath).readAsBytesSync(); - var expectedHash = sha1.convert(source).toString(); - expect(match[1], equals(expectedHash), reason: message); + if (File(targetPath).readAsStringSync() != + synchronize.synchronizeFile(sourcePath)) { + fail("$targetPath is out-of-date.\n" + "Run `dart pub run grinder` to update it."); + } }); }); }, diff --git a/test/embedded/embedded_process.dart b/test/embedded/embedded_process.dart new file mode 100644 index 000000000..30279b0ba --- /dev/null +++ b/test/embedded/embedded_process.dart @@ -0,0 +1,218 @@ +// 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. + +@TestOn('vm') + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:cli_pkg/testing.dart' as pkg; +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/embedded/util/length_delimited_transformer.dart'; + +import 'utils.dart'; + +/// A wrapper for [Process] that provides a convenient API for testing the +/// embedded Sass process. +/// +/// If the test fails, this will automatically print out any stderr and protocol +/// buffers from the process to aid debugging. +/// +/// This API is based on the `test_process` package. +class EmbeddedProcess { + /// The underlying process. + final Process _process; + + /// A [StreamQueue] that emits each outbound protocol buffer from the process. + /// + /// The initial int is the compilation ID. + StreamQueue<(int, OutboundMessage)> get outbound => _outbound; + late StreamQueue<(int, OutboundMessage)> _outbound; + + /// A [StreamQueue] that emits each line of stderr from the process. + StreamQueue get stderr => _stderr; + late StreamQueue _stderr; + + /// A splitter that can emit new copies of [outbound]. + final StreamSplitter<(int, OutboundMessage)> _outboundSplitter; + + /// A splitter that can emit new copies of [stderr]. + final StreamSplitter _stderrSplitter; + + /// A sink into which inbound messages can be passed to the process. + /// + /// The initial int is the compilation ID. + final Sink<(int, InboundMessage)> inbound; + + /// The raw standard input byte sink. + IOSink get stdin => _process.stdin; + + /// A log that includes lines from [stderr] and human-friendly serializations + /// of protocol buffers from [outbound] + final _log = []; + + /// Whether [_log] has been passed to [printOnFailure] yet. + var _loggedOutput = false; + + /// Returns a [Future] which completes to the exit code of the process, once + /// it completes. + Future get exitCode => _process.exitCode; + + /// The process ID of the process. + int get pid => _process.pid; + + /// Completes to [_process]'s exit code if it's exited, otherwise completes to + /// `null` immediately. + Future get _exitCodeOrNull async { + var exitCode = + await this.exitCode.timeout(Duration.zero, onTimeout: () => -1); + return exitCode == -1 ? null : exitCode; + } + + /// Starts a process. + /// + /// [executable], [workingDirectory], [environment], + /// [includeParentEnvironment], and [runInShell] have the same meaning as for + /// [Process.start]. + /// + /// If [forwardOutput] is `true`, the process's [outbound] messages and + /// [stderr] will be printed to the console as they appear. This is only + /// intended to be set temporarily to help when debugging test failures. + static Future start( + {String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + bool forwardOutput = false}) async { + var process = await Process.start(pkg.executableRunner("sass"), + [...pkg.executableArgs("sass"), "--embedded"], + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell); + + return EmbeddedProcess._(process, forwardOutput: forwardOutput); + } + + /// Creates a [EmbeddedProcess] for [process]. + /// + /// The [forwardOutput] argument is the same as that to [start]. + EmbeddedProcess._(Process process, {bool forwardOutput = false}) + : _process = process, + _outboundSplitter = StreamSplitter( + process.stdout.transform(lengthDelimitedDecoder).map((packet) { + var (compilationId, buffer) = parsePacket(packet); + return (compilationId, OutboundMessage.fromBuffer(buffer)); + })), + _stderrSplitter = StreamSplitter(process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter())), + inbound = StreamSinkTransformer<(int, InboundMessage), + List>.fromHandlers(handleData: (pair, sink) { + var (compilationId, message) = pair; + sink.add(serializePacket(compilationId, message)); + }).bind( + StreamSinkTransformer.fromStreamTransformer(lengthDelimitedEncoder) + .bind(process.stdin)) { + addTearDown(_tearDown); + expect(_process.exitCode.then((_) => _logOutput()), completes, + reason: "Process `sass --embedded` never exited."); + + _outbound = StreamQueue(_outboundSplitter.split()); + _stderr = StreamQueue(_stderrSplitter.split()); + + _outboundSplitter.split().listen((pair) { + for (var line in pair.$2.toDebugString().split("\n")) { + if (forwardOutput) print(line); + _log.add(" $line"); + } + }); + + _stderrSplitter.split().listen((line) { + if (forwardOutput) print(line); + _log.add("[e] $line"); + }); + } + + /// A callback that's run when the test completes. + Future _tearDown() async { + // If the process is already dead, do nothing. + if (await _exitCodeOrNull != null) return; + + _process.kill(ProcessSignal.sigkill); + + // Log output now rather than waiting for the exitCode callback so that + // it's visible even if we time out waiting for the process to die. + await _logOutput(); + } + + /// Formats the contents of [_log] and passes them to [printOnFailure]. + Future _logOutput() async { + if (_loggedOutput) return; + _loggedOutput = true; + + var exitCodeOrNull = await _exitCodeOrNull; + + // Wait a timer tick to ensure that all available lines have been flushed to + // [_log]. + await Future.delayed(Duration.zero); + + var buffer = StringBuffer(); + buffer.write("Process `dart_sass_embedded` "); + if (exitCodeOrNull == null) { + buffer.write("was killed with SIGKILL in a tear-down."); + } else { + buffer.write("exited with exitCode $exitCodeOrNull."); + } + buffer.writeln(" Output:"); + buffer.writeln(_log.join("\n")); + + printOnFailure(buffer.toString()); + } + + /// Sends [message] to the process with the default compilation ID. + void send(InboundMessage message) => + inbound.add((defaultCompilationId, message)); + + /// Fetches the next message from [outbound] and asserts that it has the + /// default compilation ID. + Future receive() async { + var (actualCompilationId, message) = await outbound.next; + expect(actualCompilationId, equals(defaultCompilationId), + reason: "Expected default compilation ID"); + return message; + } + + /// Closes the process's stdin and waits for it to exit gracefully. + Future close() async { + stdin.close(); + await shouldExit(0); + } + + /// Kills the process (with SIGKILL on POSIX operating systems), and returns a + /// future that completes once it's dead. + /// + /// If this is called after the process is already dead, it does nothing. + Future kill() async { + _process.kill(ProcessSignal.sigkill); + await exitCode; + } + + /// Waits for the process to exit, and verifies that the exit code matches + /// [expectedExitCode] (if given). + /// + /// If this is called after the process is already dead, it verifies its + /// existing exit code. + Future shouldExit([int? expectedExitCode]) async { + var exitCode = await this.exitCode; + if (expectedExitCode == null) return; + expect(exitCode, expectedExitCode, + reason: "Process `dart_sass_embedded` had an unexpected exit code."); + } +} diff --git a/test/embedded/file_importer_test.dart b/test/embedded/file_importer_test.dart new file mode 100644 index 000000000..8d5bf4ec2 --- /dev/null +++ b/test/embedded/file_importer_test.dart @@ -0,0 +1,287 @@ +// Copyright 2021 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. + +@TestOn('vm') + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; + +import 'embedded_process.dart'; +import 'utils.dart'; + +void main() { + late EmbeddedProcess process; + setUp(() async { + process = await EmbeddedProcess.start(); + }); + + group("emits a protocol error", () { + late OutboundMessage_FileImportRequest request; + + setUp(() async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + request = await getFileImportRequest(process); + }); + + test("for a response without a corresponding request ID", () async { + process.send(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id + 1)); + + await expectParamsError( + process, + errorId, + "Response ID ${request.id + 1} doesn't match any outstanding " + "requests in compilation $defaultCompilationId."); + await process.shouldExit(76); + }); + + test("for a response that doesn't match the request type", () async { + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + + await expectParamsError( + process, + errorId, + "Request ID ${request.id} doesn't match response type " + "InboundMessage_CanonicalizeResponse in compilation " + "$defaultCompilationId."); + await process.shouldExit(76); + }); + }); + + group("emits a compile failure", () { + late OutboundMessage_FileImportRequest request; + + setUp(() async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + request = await getFileImportRequest(process); + }); + + group("for a FileImportResponse with a URL", () { + test("that's empty", () async { + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = "")); + + await _expectImportError( + process, 'The file importer must return an absolute URL, was ""'); + await process.close(); + }); + + test("that's relative", () async { + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = "foo")); + + await _expectImportError(process, + 'The file importer must return an absolute URL, was "foo"'); + await process.close(); + }); + + test("that's not file:", () async { + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = "other:foo")); + + await _expectImportError(process, + 'The file importer must return a file: URL, was "other:foo"'); + await process.close(); + }); + }); + }); + + group("includes in FileImportRequest", () { + var compilationId = 1234; + var importerId = 5679; + late OutboundMessage_FileImportRequest request; + setUp(() async { + process.send( + compileString("@import 'other'", id: compilationId, importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = importerId + ])); + request = await getFileImportRequest(process); + }); + + test("a known importerId", () async { + expect(request.importerId, equals(importerId)); + await process.kill(); + }); + + test("the imported URL", () async { + expect(request.url, equals("other")); + await process.kill(); + }); + + test("whether the import came from an @import", () async { + expect(request.fromImport, isTrue); + await process.kill(); + }); + }); + + test("errors cause compilation to fail", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + var request = await getFileImportRequest(process); + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..error = "oh no")); + + 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')); + await process.close(); + }); + + test("null results count as not found", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + var request = await getFileImportRequest(process); + process.send(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.close(); + }); + + group("attempts importers in order", () { + test("with multiple file importers", () async { + process.send(compileString("@import 'other'", importers: [ + for (var i = 0; i < 10; i++) + InboundMessage_CompileRequest_Importer()..fileImporterId = i + ])); + + for (var i = 0; i < 10; i++) { + var request = await getFileImportRequest(process); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } + + await process.kill(); + }); + + test("with a mixture of file and normal importers", () async { + process.send(compileString("@import 'other'", importers: [ + for (var i = 0; i < 10; i++) + if (i % 2 == 0) + InboundMessage_CompileRequest_Importer()..fileImporterId = i + else + InboundMessage_CompileRequest_Importer()..importerId = i + ])); + + for (var i = 0; i < 10; i++) { + if (i % 2 == 0) { + var request = await getFileImportRequest(process); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } else { + var request = await getCanonicalizeRequest(process); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + } + } + + await process.kill(); + }); + }); + + 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(); + + process.send(compileString("@import 'midstream'", importers: [ + for (var i = 0; i < 10; i++) + InboundMessage_CompileRequest_Importer()..fileImporterId = i + ])); + + for (var i = 0; i < 5; i++) { + var request = await getFileImportRequest(process); + expect(request.url, equals("midstream")); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } + + var request = await getFileImportRequest(process); + expect(request.importerId, equals(5)); + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = p.toUri(d.path("midstream")).toString())); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + group("handles an importer for a string compile request", () { + setUp(() async { + await d.file("other.scss", "a {b: c}").create(); + }); + + test("without a base URL", () async { + process.send(compileString("@import 'other'", + importer: InboundMessage_CompileRequest_Importer() + ..fileImporterId = 1)); + + var request = await getFileImportRequest(process); + expect(request.url, equals("other")); + + process.send(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = p.toUri(d.path("other")).toString())); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + test("with a base URL", () async { + process.send(compileString("@import 'other'", + url: p.toUri(d.path("input")).toString(), + importer: InboundMessage_CompileRequest_Importer() + ..fileImporterId = 1)); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + }); +} + +/// 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 { + var failure = await getCompileFailure(process); + expect(failure.message, equals(message)); + expect(failure.span.text, equals("'other'")); +} diff --git a/test/embedded/function_test.dart b/test/embedded/function_test.dart new file mode 100644 index 000000000..8667b6e81 --- /dev/null +++ b/test/embedded/function_test.dart @@ -0,0 +1,1946 @@ +// 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. + +@TestOn('vm') + +import 'package:test/test.dart'; + +import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; + +import 'embedded_process.dart'; +import 'utils.dart'; + +final _true = Value()..singleton = SingletonValue.TRUE; +final _false = Value()..singleton = SingletonValue.FALSE; +final _null = Value()..singleton = SingletonValue.NULL; + +late EmbeddedProcess _process; + +void main() { + setUp(() async { + _process = await EmbeddedProcess.start(); + }); + + group("emits a compile failure for a custom function with a signature", () { + test("that's empty", () async { + _process.send(compileString("a {b: c}", functions: [r""])); + await _expectFunctionError( + _process, r'Invalid signature "": Expected identifier.'); + await _process.close(); + }); + + test("that's just a name", () async { + _process.send(compileString("a {b: c}", functions: [r"foo"])); + await _expectFunctionError( + _process, r'Invalid signature "foo": expected "(".'); + await _process.close(); + }); + + test("without a closing paren", () async { + _process.send(compileString("a {b: c}", functions: [r"foo($bar"])); + await _expectFunctionError( + _process, r'Invalid signature "foo($bar": expected ")".'); + await _process.close(); + }); + + test("with text after the closing paren", () async { + _process.send(compileString("a {b: c}", functions: [r"foo() "])); + await _expectFunctionError( + _process, r'Invalid signature "foo() ": expected no more input.'); + await _process.close(); + }); + + test("with invalid arguments", () async { + _process.send(compileString("a {b: c}", functions: [r"foo($)"])); + await _expectFunctionError( + _process, r'Invalid signature "foo($)": Expected identifier.'); + await _process.close(); + }); + }); + + group("includes in FunctionCallRequest", () { + test("the function name", () async { + _process.send(compileString("a {b: foo()}", functions: ["foo()"])); + var request = await getFunctionCallRequest(_process); + expect(request.name, equals("foo")); + await _process.kill(); + }); + + group("arguments", () { + test("that are empty", () async { + _process.send(compileString("a {b: foo()}", functions: ["foo()"])); + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + await _process.kill(); + }); + + test("by position", () async { + _process.send(compileString("a {b: foo(true, null, false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = await getFunctionCallRequest(_process); + expect(request.arguments, equals([_true, _null, _false])); + await _process.kill(); + }); + + test("by name", () async { + _process.send(compileString( + r"a {b: foo($arg3: true, $arg1: null, $arg2: false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = await getFunctionCallRequest(_process); + expect(request.arguments, equals([_null, _false, _true])); + await _process.kill(); + }); + + test("by position and name", () async { + _process.send(compileString( + r"a {b: foo(true, $arg3: null, $arg2: false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = await getFunctionCallRequest(_process); + expect(request.arguments, equals([_true, _false, _null])); + await _process.kill(); + }); + + test("from defaults", () async { + _process.send(compileString(r"a {b: foo(1, $arg3: 2)}", + functions: [r"foo($arg1: null, $arg2: true, $arg3: false)"])); + var request = await getFunctionCallRequest(_process); + expect( + request.arguments, + equals([ + Value()..number = (Value_Number()..value = 1.0), + _true, + Value()..number = (Value_Number()..value = 2.0) + ])); + await _process.kill(); + }); + + group("from argument lists", () { + test("with no named arguments", () async { + _process.send(compileString("a {b: foo(true, false, null)}", + functions: [r"foo($arg, $args...)"])); + var request = await getFunctionCallRequest(_process); + + expect( + request.arguments, + equals([ + _true, + Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..separator = ListSeparator.COMMA + ..contents.addAll([_false, _null])) + ])); + await _process.kill(); + }); + + test("with named arguments", () async { + _process.send(compileString(r"a {b: foo(true, $arg: false)}", + functions: [r"foo($args...)"])); + var request = await getFunctionCallRequest(_process); + + expect( + request.arguments, + equals([ + Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..separator = ListSeparator.COMMA + ..contents.addAll([_true]) + ..keywords.addAll({"arg": _false})) + ])); + await _process.kill(); + }); + + test("throws if named arguments are unused", () async { + _process.send(compileString(r"a {b: foo($arg: false)}", + functions: [r"foo($args...)"])); + var request = await getFunctionCallRequest(_process); + + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _true)); + + var failure = await getCompileFailure(_process); + expect(failure.message, equals(r"No argument named $arg.")); + await _process.close(); + }); + + test("doesn't throw if named arguments are used", () async { + _process.send(compileString(r"a {b: foo($arg: false)}", + functions: [r"foo($args...)"])); + var request = await getFunctionCallRequest(_process); + + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..accessedArgumentLists + .add(request.arguments.first.argumentList.id) + ..success = _true)); + + await expectSuccess(_process, equals("a {\n b: true;\n}")); + await _process.close(); + }); + }); + }); + }); + + test("returns the result as a SassScript value", () async { + _process.send(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); + var request = await getFunctionCallRequest(_process); + + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.add("px"))))); + + await expectSuccess(_process, equals("a {\n b: 3px;\n}")); + await _process.close(); + }); + + group("calls a first-class function", () { + test("defined in the compiler and passed to and from the host", () async { + _process.send(compileString(r""" + @use "sass:math"; + @use "sass:meta"; + + a {b: call(foo(meta.get-function("abs", $module: "math")), -1)} + """, functions: [r"foo($arg)"])); + + var request = await getFunctionCallRequest(_process); + var value = request.arguments.single; + expect(value.hasCompilerFunction(), isTrue); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + await expectSuccess(_process, equals("a {\n b: 1;\n}")); + await _process.close(); + }); + + test("defined in the host", () async { + _process.send( + compileString("a {b: call(foo(), true)}", functions: [r"foo()"])); + + var hostFunctionId = 5678; + var request = await getFunctionCallRequest(_process); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = hostFunctionId + ..signature = r"bar($arg)")))); + + request = await getFunctionCallRequest(_process); + expect(request.functionId, equals(hostFunctionId)); + expect(request.arguments, equals([_true])); + + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _false)); + + await expectSuccess(_process, equals("a {\n b: false;\n}")); + await _process.close(); + }); + + test("defined in the host and passed to and from the host", () async { + _process.send(compileString(r""" + $function: get-host-function(); + $function: round-trip($function); + a {b: call($function, true)} + """, functions: [r"get-host-function()", r"round-trip($function)"])); + + var hostFunctionId = 5678; + var request = await getFunctionCallRequest(_process); + expect(request.name, equals("get-host-function")); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = hostFunctionId + ..signature = r"bar($arg)")))); + + request = await getFunctionCallRequest(_process); + expect(request.name, equals("round-trip")); + var value = request.arguments.single; + expect(value.hasCompilerFunction(), isTrue); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + request = await getFunctionCallRequest(_process); + expect(request.functionId, equals(hostFunctionId)); + expect(request.arguments, equals([_true])); + + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _false)); + + await expectSuccess(_process, equals("a {\n b: false;\n}")); + await _process.close(); + }); + }); + + group("serializes to protocol buffers", () { + group("a string that's", () { + group("quoted", () { + test("and empty", () async { + var value = (await _protofy('""')).string; + expect(value.text, isEmpty); + expect(value.quoted, isTrue); + }); + + test("and non-empty", () async { + var value = (await _protofy('"foo bar"')).string; + expect(value.text, equals("foo bar")); + expect(value.quoted, isTrue); + }); + }); + + group("unquoted", () { + test("and empty", () async { + var value = (await _protofy('unquote("")')).string; + expect(value.text, isEmpty); + expect(value.quoted, isFalse); + }); + + test("and non-empty", () async { + var value = (await _protofy('"foo bar"')).string; + expect(value.text, equals("foo bar")); + expect(value.quoted, isTrue); + }); + }); + }); + + group("a number", () { + group("that's unitless", () { + test("and an integer", () async { + var value = (await _protofy('1')).number; + expect(value.value, equals(1.0)); + expect(value.numerators, isEmpty); + expect(value.denominators, isEmpty); + }); + + test("and a float", () async { + var value = (await _protofy('1.5')).number; + expect(value.value, equals(1.5)); + expect(value.numerators, isEmpty); + expect(value.denominators, isEmpty); + }); + }); + + test("with one numerator", () async { + var value = (await _protofy('1em')).number; + expect(value.value, equals(1.0)); + expect(value.numerators, ["em"]); + expect(value.denominators, isEmpty); + }); + + test("with multiple numerators", () async { + var value = (await _protofy('1em * 1px * 1foo')).number; + expect(value.value, equals(1.0)); + expect(value.numerators, unorderedEquals(["em", "px", "foo"])); + expect(value.denominators, isEmpty); + }); + + test("with one denominator", () async { + var value = (await _protofy('math.div(1,1em)')).number; + expect(value.value, equals(1.0)); + expect(value.numerators, isEmpty); + expect(value.denominators, ["em"]); + }); + + test("with multiple denominators", () async { + var value = + (await _protofy('math.div(math.div(math.div(1, 1em), 1px), 1foo)')) + .number; + expect(value.value, equals(1.0)); + expect(value.numerators, isEmpty); + expect(value.denominators, unorderedEquals(["em", "px", "foo"])); + }); + + test("with numerators and denominators", () async { + var value = + (await _protofy('1em * math.div(math.div(1px, 1s), 1foo)')).number; + expect(value.value, equals(1.0)); + expect(value.numerators, unorderedEquals(["em", "px"])); + expect(value.denominators, unorderedEquals(["s", "foo"])); + }); + }); + + group("a color that's", () { + group("rgb", () { + group("without alpha:", () { + test("black", () async { + expect(await _protofy('#000'), _rgb(0, 0, 0, 1.0)); + }); + + test("white", () async { + expect(await _protofy('#fff'), equals(_rgb(255, 255, 255, 1.0))); + }); + + test("in the middle", () async { + expect(await _protofy('#abc'), equals(_rgb(0xaa, 0xbb, 0xcc, 1.0))); + }); + }); + + group("with alpha", () { + test("0", () async { + expect(await _protofy('rgb(10, 20, 30, 0)'), + equals(_rgb(10, 20, 30, 0.0))); + }); + + test("1", () async { + expect(await _protofy('rgb(10, 20, 30, 1)'), + equals(_rgb(10, 20, 30, 1.0))); + }); + + test("between 0 and 1", () async { + expect(await _protofy('rgb(10, 20, 30, 0.123)'), + equals(_rgb(10, 20, 30, 0.123))); + }); + }); + }); + + group("hsl", () { + group("without alpha:", () { + group("hue", () { + test("0", () async { + expect(await _protofy('hsl(0, 50%, 50%)'), _hsl(0, 50, 50, 1.0)); + }); + + test("360", () async { + expect( + await _protofy('hsl(360, 50%, 50%)'), _hsl(0, 50, 50, 1.0)); + }); + + test("below 0", () async { + expect(await _protofy('hsl(-100, 50%, 50%)'), + _hsl(260, 50, 50, 1.0)); + }); + + test("between 0 and 360", () async { + expect( + await _protofy('hsl(100, 50%, 50%)'), _hsl(100, 50, 50, 1.0)); + }); + + test("above 360", () async { + expect( + await _protofy('hsl(560, 50%, 50%)'), _hsl(200, 50, 50, 1.0)); + }); + }); + + group("saturation", () { + test("0", () async { + expect(await _protofy('hsl(0, 0%, 50%)'), _hsl(0, 0, 50, 1.0)); + }); + + test("100", () async { + expect( + await _protofy('hsl(0, 100%, 50%)'), _hsl(0, 100, 50, 1.0)); + }); + + test("in the middle", () async { + expect(await _protofy('hsl(0, 42%, 50%)'), _hsl(0, 42, 50, 1.0)); + }); + }); + + group("lightness", () { + test("0", () async { + expect(await _protofy('hsl(0, 50%, 0%)'), _hsl(0, 50, 0, 1.0)); + }); + + test("100", () async { + expect( + await _protofy('hsl(0, 50%, 100%)'), _hsl(0, 50, 100, 1.0)); + }); + + test("in the middle", () async { + expect(await _protofy('hsl(0, 50%, 42%)'), _hsl(0, 50, 42, 1.0)); + }); + }); + }); + + group("with alpha", () { + test("0", () async { + expect(await _protofy('hsl(10, 20%, 30%, 0)'), + equals(_hsl(10, 20, 30, 0.0))); + }); + + test("1", () async { + expect(await _protofy('hsl(10, 20%, 30%, 1)'), + equals(_hsl(10, 20, 30, 1.0))); + }); + + test("between 0 and 1", () async { + expect(await _protofy('hsl(10, 20%, 30%, 0.123)'), + equals(_hsl(10, 20, 30, 0.123))); + }); + }); + }); + }); + + group("a list", () { + group("with no elements", () { + group("with brackets", () { + test("with unknown separator", () async { + var list = (await _protofy("[]")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.UNDECIDED)); + }); + + test("with a comma separator", () async { + var list = + (await _protofy(r"list.join([], [], $separator: comma)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = + (await _protofy(r"list.join([], [], $separator: space)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = + (await _protofy(r"list.join([], [], $separator: slash)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + }); + + group("without brackets", () { + test("with unknown separator", () async { + var list = (await _protofy("()")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.UNDECIDED)); + }); + + test("with a comma separator", () async { + var list = + (await _protofy(r"list.join((), (), $separator: comma)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = + (await _protofy(r"list.join((), (), $separator: space)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = + (await _protofy(r"list.join((), (), $separator: slash)")).list; + expect(list.contents, isEmpty); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + }); + }); + + group("with one element", () { + group("with brackets", () { + test("with unknown separator", () async { + var list = (await _protofy("[true]")).list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.UNDECIDED)); + }); + + test("with a comma separator", () async { + var list = (await _protofy(r"[true,]")).list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = + (await _protofy(r"list.join([true], [], $separator: space)")) + .list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = + (await _protofy(r"list.join([true], [], $separator: slash)")) + .list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + }); + + group("without brackets", () { + test("with a comma separator", () async { + var list = (await _protofy(r"(true,)")).list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = + (await _protofy(r"list.join(true, (), $separator: space)")) + .list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = + (await _protofy(r"list.join(true, (), $separator: slash)")) + .list; + expect(list.contents, equals([_true])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + }); + }); + + group("with multiple elements", () { + group("with brackets", () { + test("with a comma separator", () async { + var list = (await _protofy(r"[true, null, false]")).list; + expect(list.contents, equals([_true, _null, _false])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = (await _protofy(r"[true null false]")).list; + expect(list.contents, equals([_true, _null, _false])); + expect(list.hasBrackets, isTrue); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + }); + + group("without brackets", () { + test("with a comma separator", () async { + var list = (await _protofy(r"true, null, false")).list; + expect(list.contents, equals([_true, _null, _false])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = (await _protofy(r"true null false")).list; + expect(list.contents, equals([_true, _null, _false])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = (await _protofy(r"list.slash(true, null, false)")).list; + expect(list.contents, equals([_true, _null, _false])); + expect(list.hasBrackets, isFalse); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + }); + }); + }); + + group("an argument list", () { + test("that's empty", () async { + var list = (await _protofy(r"capture-args()")).argumentList; + expect(list.contents, isEmpty); + expect(list.keywords, isEmpty); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with arguments", () async { + var list = + (await _protofy(r"capture-args(true, null, false)")).argumentList; + expect(list.contents, [_true, _null, _false]); + expect(list.keywords, isEmpty); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + + test("with a space separator", () async { + var list = + (await _protofy(r"capture-args(true null false...)")).argumentList; + expect(list.contents, [_true, _null, _false]); + expect(list.keywords, isEmpty); + expect(list.separator, equals(ListSeparator.SPACE)); + }); + + test("with a slash separator", () async { + var list = + (await _protofy(r"capture-args(list.slash(true, null, false)...)")) + .argumentList; + expect(list.contents, [_true, _null, _false]); + expect(list.keywords, isEmpty); + expect(list.separator, equals(ListSeparator.SLASH)); + }); + + test("with keywords", () async { + var list = (await _protofy(r"capture-args($arg1: true, $arg2: false)")) + .argumentList; + expect(list.contents, isEmpty); + expect(list.keywords, equals({"arg1": _true, "arg2": _false})); + expect(list.separator, equals(ListSeparator.COMMA)); + }); + }); + + group("a map", () { + test("with no elements", () async { + expect((await _protofy("map.remove((1: 2), 1)")).map.entries, isEmpty); + }); + + test("with one element", () async { + expect( + (await _protofy("(true: false)")).map.entries, + equals([ + Value_Map_Entry() + ..key = _true + ..value = _false + ])); + }); + + test("with multiple elements", () async { + expect( + (await _protofy("(true: false, 1: 2, a: b)")).map.entries, + equals([ + Value_Map_Entry() + ..key = _true + ..value = _false, + Value_Map_Entry() + ..key = (Value()..number = (Value_Number()..value = 1.0)) + ..value = (Value()..number = (Value_Number()..value = 2.0)), + Value_Map_Entry() + ..key = (Value() + ..string = (Value_String() + ..text = "a" + ..quoted = false)) + ..value = (Value() + ..string = (Value_String() + ..text = "b" + ..quoted = false)) + ])); + }); + }); + + group("a calculation", () { + test("with a string argument", () async { + expect( + (await _protofy("calc(var(--foo))")).calculation, + equals(Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..string = "var(--foo)"))); + }); + + test("with an interpolation argument", () async { + expect( + (await _protofy("calc(#{var(--foo)})")).calculation, + equals(Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..interpolation = "var(--foo)"))); + }); + + test("with number arguments", () async { + expect( + (await _protofy("clamp(1%, 2px, 3em)")).calculation, + equals(Value_Calculation() + ..name = "clamp" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 3.0 + ..numerators.add("em"))))); + }); + + test("with a calculation argument", () async { + expect( + (await _protofy("min(max(1%, 2px), 3em)")).calculation, + equals(Value_Calculation() + ..name = "min" + ..arguments.add(Value_Calculation_CalculationValue() + ..calculation = (Value_Calculation() + ..name = "max" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 3.0 + ..numerators.add("em"))))); + }); + + test("with an operation", () async { + expect( + (await _protofy("calc(1% + 2px)")).calculation, + equals(Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..operation = (Value_Calculation_CalculationOperation() + ..operator = CalculationOperator.PLUS + ..left = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..right = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))))))); + }); + }); + + test("true", () async { + expect((await _protofy("true")), equals(_true)); + }); + + test("false", () async { + expect((await _protofy("false")), equals(_false)); + }); + + test("true", () async { + expect((await _protofy("null")), equals(_null)); + }); + }); + + group("deserializes from protocol buffer", () { + group("a string that's", () { + group("quoted", () { + test("and empty", () async { + expect( + await _deprotofy(Value() + ..string = (Value_String() + ..text = "" + ..quoted = true)), + '""'); + }); + + test("and non-empty", () async { + expect( + await _deprotofy(Value() + ..string = (Value_String() + ..text = "foo bar" + ..quoted = true)), + '"foo bar"'); + }); + }); + + group("unquoted", () { + test("and empty", () async { + // We can't use [_deprotofy] here because a property with an empty + // value won't render at all. + await _assertRoundTrips(Value() + ..string = (Value_String() + ..text = "" + ..quoted = false)); + }); + + test("and non-empty", () async { + expect( + await _deprotofy(Value() + ..string = (Value_String() + ..text = "foo bar" + ..quoted = false)), + "foo bar"); + }); + }); + }); + + group("a number", () { + group("that's unitless", () { + test("and an integer", () async { + expect( + await _deprotofy(Value()..number = (Value_Number()..value = 1.0)), + "1"); + }); + + test("and a float", () async { + expect( + await _deprotofy(Value()..number = (Value_Number()..value = 1.5)), + "1.5"); + }); + }); + + test("with one numerator", () async { + expect( + await _deprotofy(Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.add("em"))), + "1em"); + }); + + test("with multiple numerators", () async { + expect( + await _deprotofy( + Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.addAll(["em", "px", "foo"])), + inspect: true), + "1em*px*foo"); + }); + + test("with one denominator", () async { + expect( + await _deprotofy( + Value() + ..number = (Value_Number() + ..value = 1 + ..denominators.add("em")), + inspect: true), + "1em^-1"); + }); + + test("with multiple denominators", () async { + expect( + await _deprotofy( + Value() + ..number = (Value_Number() + ..value = 1 + ..denominators.addAll(["em", "px", "foo"])), + inspect: true), + "1(em*px*foo)^-1"); + }); + + test("with numerators and denominators", () async { + expect( + await _deprotofy( + Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.addAll(["em", "px"]) + ..denominators.addAll(["s", "foo"])), + inspect: true), + "1em*px/s*foo"); + }); + }); + + group("a color that's", () { + group("rgb", () { + group("without alpha:", () { + test("black", () async { + expect(await _deprotofy(_rgb(0, 0, 0, 1.0)), equals('black')); + }); + + test("white", () async { + expect(await _deprotofy(_rgb(255, 255, 255, 1.0)), equals('white')); + }); + + test("in the middle", () async { + 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('rgb(256, 0, 0)')); + }); + + test("with green above 255", () async { + expect(await _deprotofy(_rgb(0, 256, 0, 1.0)), + equals('rgb(0, 256, 0)')); + }); + + test("with blue above 255", () async { + expect(await _deprotofy(_rgb(0, 0, 256, 1.0)), + equals('rgb(0, 0, 256)')); + }); + }); + + group("with alpha", () { + test("0", () async { + expect(await _deprotofy(_rgb(10, 20, 30, 0.0)), + equals('rgba(10, 20, 30, 0)')); + }); + + test("between 0 and 1", () async { + expect(await _deprotofy(_rgb(10, 20, 30, 0.123)), + equals('rgba(10, 20, 30, 0.123)')); + }); + }); + }); + + group("hsl", () { + group("without alpha:", () { + group("hue", () { + test("0", () async { + 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)), "hsl(0, 50%, 50%)"); + }); + + test("below 0", () async { + 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)), + "hsl(100, 50%, 50%)"); + }); + + test("above 360", () async { + 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)), "hsl(0, 0%, 50%)"); + }); + + test("100", () async { + 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)), "hsl(0, 42%, 50%)"); + }); + }); + + group("lightness", () { + test("0", () async { + 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)), "hsl(0, 50%, 100%)"); + }); + + test("in the middle", () async { + 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)), + "hsla(10, 20%, 30%, 0)"); + }); + + test("between 0 and 1", () async { + expect(await _deprotofy(_hsl(10, 20, 30, 0.123)), + "hsla(10, 20%, 30%, 0.123)"); + }); + }); + }); + }); + + group("a list", () { + group("with no elements", () { + group("with brackets", () { + group("with unknown separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = true + ..separator = ListSeparator.UNDECIDED), + "[]"); + }); + + group("with a comma separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = true + ..separator = ListSeparator.COMMA), + "[]"); + }); + + group("with a space separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = true + ..separator = ListSeparator.SPACE), + "[]"); + }); + + group("with a slash separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = true + ..separator = ListSeparator.SLASH), + "[]"); + }); + }); + + group("without brackets", () { + group("with unknown separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = false + ..separator = ListSeparator.UNDECIDED), + "()", + inspect: true); + }); + + group("with a comma separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = false + ..separator = ListSeparator.COMMA), + "()", + inspect: true); + }); + + group("with a space separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = false + ..separator = ListSeparator.SPACE), + "()", + inspect: true); + }); + + group("with a slash separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..hasBrackets = false + ..separator = ListSeparator.SLASH), + "()", + inspect: true); + }); + }); + }); + + group("with one element", () { + group("with brackets", () { + group("with unknown separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = true + ..separator = ListSeparator.UNDECIDED), + "[true]"); + }); + + test("with a comma separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = true + ..separator = ListSeparator.COMMA), + inspect: true), + "[true,]"); + }); + + group("with a space separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = true + ..separator = ListSeparator.SPACE), + "[true]"); + }); + + group("with a slash separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = true + ..separator = ListSeparator.SLASH), + "[true]"); + }); + }); + + group("without brackets", () { + group("with unknown separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = false + ..separator = ListSeparator.UNDECIDED), + "true"); + }); + + test("with a comma separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = false + ..separator = ListSeparator.COMMA), + inspect: true), + "(true,)"); + }); + + group("with a space separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = false + ..separator = ListSeparator.SPACE), + "true"); + }); + + group("with a slash separator", () { + _testSerializationAndRoundTrip( + Value() + ..list = (Value_List() + ..contents.add(_true) + ..hasBrackets = false + ..separator = ListSeparator.SLASH), + "true"); + }); + }); + }); + + group("with multiple elements", () { + group("with brackets", () { + test("with a comma separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = true + ..separator = ListSeparator.COMMA), + inspect: true), + "[true, null, false]"); + }); + + test("with a space separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = true + ..separator = ListSeparator.SPACE), + inspect: true), + "[true null false]"); + }); + + test("with a slash separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = true + ..separator = ListSeparator.SLASH), + inspect: true), + "[true / null / false]"); + }); + }); + + group("without brackets", () { + test("with a comma separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = false + ..separator = ListSeparator.COMMA), + inspect: true), + "true, null, false"); + }); + + test("with a space separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = false + ..separator = ListSeparator.SPACE), + inspect: true), + "true null false"); + }); + + test("with a slash separator", () async { + expect( + await _deprotofy( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _null, _false]) + ..hasBrackets = false + ..separator = ListSeparator.SLASH), + inspect: true), + "true / null / false"); + }); + }); + }); + }); + + group("an argument list", () { + test("with no elements", () async { + expect( + await _roundTrip(Value() + ..argumentList = + (Value_ArgumentList()..separator = ListSeparator.UNDECIDED)), + equals(Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..separator = ListSeparator.UNDECIDED))); + }); + + test("with comma separator", () async { + expect( + await _roundTrip(Value() + ..argumentList = (Value_ArgumentList() + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.COMMA)), + equals(Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.COMMA))); + }); + + test("with space separator", () async { + expect( + await _roundTrip(Value() + ..argumentList = (Value_ArgumentList() + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.SPACE)), + equals(Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.SPACE))); + }); + + test("with slash separator", () async { + expect( + await _roundTrip(Value() + ..argumentList = (Value_ArgumentList() + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.SLASH)), + equals(Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..contents.addAll([_true, _false, _null]) + ..separator = ListSeparator.SLASH))); + }); + + test("with keywords", () async { + expect( + await _roundTrip(Value() + ..argumentList = (Value_ArgumentList() + ..keywords.addAll({"arg1": _true, "arg2": _false}) + ..separator = ListSeparator.COMMA)), + equals(Value() + ..argumentList = (Value_ArgumentList() + ..id = 1 + ..keywords.addAll({"arg1": _true, "arg2": _false}) + ..separator = ListSeparator.COMMA))); + }); + }); + + group("a map", () { + group("with no elements", () { + _testSerializationAndRoundTrip(Value()..map = Value_Map(), "()", + inspect: true); + }); + + test("with one element", () async { + expect( + await _deprotofy( + Value() + ..map = (Value_Map() + ..entries.add(Value_Map_Entry() + ..key = _true + ..value = _false)), + inspect: true), + "(true: false)"); + }); + + test("with multiple elements", () async { + expect( + await _deprotofy( + Value() + ..map = (Value_Map() + ..entries.addAll([ + Value_Map_Entry() + ..key = _true + ..value = _false, + Value_Map_Entry() + ..key = + (Value()..number = (Value_Number()..value = 1.0)) + ..value = + (Value()..number = (Value_Number()..value = 2.0)), + Value_Map_Entry() + ..key = (Value() + ..string = (Value_String() + ..text = "a" + ..quoted = false)) + ..value = (Value() + ..string = (Value_String() + ..text = "b" + ..quoted = false)) + ])), + inspect: true), + "(true: false, 1: 2, a: b)"); + }); + }); + + group("a calculation", () { + test("with a string argument", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..string = "var(--foo)"))), + "calc(var(--foo))"); + }); + + test("with an interpolation argument", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..interpolation = "var(--foo)"))), + "calc(var(--foo))"); + }); + + test("with number arguments", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "clamp" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 3.0 + ..numerators.add("em"))))), + "clamp(1%, 2px, 3em)"); + }); + + test("with a calculation argument", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "min" + ..arguments.add(Value_Calculation_CalculationValue() + ..calculation = (Value_Calculation() + ..name = "max" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 3.0 + ..numerators.add("em"))))), + "min(max(1%, 2px), 3em)"); + }); + + test("with an operation", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..operation = (Value_Calculation_CalculationOperation() + ..operator = CalculationOperator.PLUS + ..left = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..right = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))))))), + "calc(1% + 2px)"); + }); + + group("simplifies", () { + test("an operation", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..operation = (Value_Calculation_CalculationOperation() + ..operator = CalculationOperator.PLUS + ..left = (Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..right = (Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0)))))), + "3"); + }); + + test("a nested operation", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..operation = (Value_Calculation_CalculationOperation() + ..operator = CalculationOperator.PLUS + ..left = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("%"))) + ..right = (Value_Calculation_CalculationValue() + ..operation = (Value_Calculation_CalculationOperation() + ..operator = CalculationOperator.PLUS + ..left = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("px"))) + ..right = (Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 3.0 + ..numerators.add("px"))))))))), + "calc(1% + 5px)"); + }); + + test("min", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "min" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 3.0)))), + "1"); + }); + + test("max", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "max" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 3.0)))), + "3"); + }); + + test("clamp", () async { + expect( + await _deprotofy(Value() + ..calculation = (Value_Calculation() + ..name = "clamp" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 3.0)))), + "2"); + }); + }); + }); + + test("true", () async { + expect(await _deprotofy(_true), equals("true")); + }); + + test("false", () async { + expect(await _deprotofy(_false), equals("false")); + }); + + test("null", () async { + expect(await _deprotofy(_null, inspect: true), equals("null")); + }); + + group("and rejects", () { + group("a color", () { + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + }); + + test("a list with multiple elements and an unknown separator", () async { + await _expectDeprotofyError( + Value() + ..list = (Value_List() + ..contents.addAll([_true, _false]) + ..separator = ListSeparator.UNDECIDED), + endsWith("can't have an undecided separator because it has 2 " + "elements")); + }); + + test("an arglist with an unknown id", () async { + await _expectDeprotofyError( + Value()..argumentList = (Value_ArgumentList()..id = 1), + equals( + "Value.ArgumentList.id 1 doesn't match any known argument lists")); + }); + + group("a calculation", () { + group("with too many arguments", () { + test("for calc", () async { + await _expectDeprotofyError( + Value() + ..calculation = (Value_Calculation() + ..name = "calc" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0))), + equals("Value.Calculation.arguments must have exactly one " + "argument for calc().")); + }); + + test("for clamp", () async { + await _expectDeprotofyError( + Value() + ..calculation = (Value_Calculation() + ..name = "clamp" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 1.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 2.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 3.0)) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number()..value = 4.0))), + equals("Value.Calculation.arguments must have 1 to 3 " + "arguments for clamp().")); + }); + }); + + group("with too few arguments", () { + test("for calc", () async { + await _expectDeprotofyError( + Value()..calculation = (Value_Calculation()..name = "calc"), + equals("Value.Calculation.arguments must have exactly one " + "argument for calc().")); + }); + + test("for clamp", () async { + await _expectDeprotofyError( + Value()..calculation = (Value_Calculation()..name = "clamp"), + equals("Value.Calculation.arguments must have 1 to 3 " + "arguments for clamp().")); + }); + + test("for min", () async { + await _expectDeprotofyError( + Value()..calculation = (Value_Calculation()..name = "min"), + equals("Value.Calculation.arguments must have at least 1 " + "argument for min().")); + }); + + test("for max", () async { + await _expectDeprotofyError( + Value()..calculation = (Value_Calculation()..name = "max"), + equals("Value.Calculation.arguments must have at least 1 " + "argument for max().")); + }); + }); + + test("reports a compilation failure when simplification fails", + () async { + _process.send(compileString("a {b: foo()}", functions: [r"foo()"])); + + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..calculation = (Value_Calculation() + ..name = "min" + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 1.0 + ..numerators.add("px"))) + ..arguments.add(Value_Calculation_CalculationValue() + ..number = (Value_Number() + ..value = 2.0 + ..numerators.add("s"))))))); + + var failure = await getCompileFailure(_process); + expect(failure.message, equals("1px and 2s are incompatible.")); + expect(_process.close(), completes); + }); + }); + + 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()"])); + + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = 1234 + ..signature = signature)))); + + var failure = await getCompileFailure(_process); + expect(failure.message, message); + expect(_process.close(), completes); + } + + test("that's empty", () async { + await expectSignatureError( + "", r'Invalid signature "": Expected identifier.'); + }); + + test("that's just a name", () async { + await expectSignatureError( + "foo", r'Invalid signature "foo": expected "(".'); + }); + + test("without a closing paren", () async { + await expectSignatureError( + r"foo($bar", r'Invalid signature "foo($bar": expected ")".'); + }); + + test("with text after the closing paren", () async { + await expectSignatureError(r"foo() ", + r'Invalid signature "foo() ": expected no more input.'); + }); + + test("with invalid arguments", () async { + await expectSignatureError( + r"foo($)", r'Invalid signature "foo($)": Expected identifier.'); + }); + }); + }); + }); +} + +/// Evaluates [sassScript] in the compiler, passes it to a custom function, and +/// returns the protocol buffer result. +Future _protofy(String sassScript) async { + _process.send(compileString(""" +@use 'sass:list'; +@use 'sass:map'; +@use 'sass:math'; +@use 'sass:meta'; + +@function capture-args(\$args...) { + \$_: meta.keywords(\$args); + @return \$args; +} + +\$_: foo(($sassScript)); +""", functions: [r"foo($arg)"])); + var request = await getFunctionCallRequest(_process); + expect(_process.kill(), completes); + return request.arguments.single; +} + +/// Defines two tests: one that asserts that [value] is serialized to the CSS +/// value [expected], and one that asserts that it survives a round trip in the +/// same protocol buffer format. +/// +/// This is necessary for values that can be serialized but also have metadata +/// that's not visible in the serialized form. +void _testSerializationAndRoundTrip(Value value, String expected, + {bool inspect = false}) { + test("is serialized correctly", + () async => expect(await _deprotofy(value, inspect: inspect), expected)); + + test("preserves metadata", () => _assertRoundTrips(value)); +} + +/// Sends [value] to the compiler and returns its string serialization. +/// +/// If [inspect] is true, this returns the value as serialized by the +/// `meta.inspect()` function. +Future _deprotofy(Value value, {bool inspect = false}) async { + _process.send(compileString( + inspect ? "a {b: inspect(foo())}" : "a {b: foo()}", + functions: [r"foo()"])); + + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + var success = await getCompileSuccess(_process); + expect(_process.close(), completes); + return RegExp(r" b: (.*);").firstMatch(success.css)![1]!; +} + +/// Asserts that [value] causes a parameter error with a message matching +/// [message] when deserializing it from a protocol buffer. +Future _expectDeprotofyError(Value value, Object message) async { + _process.send(compileString("a {b: foo()}", functions: [r"foo()"])); + + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + await expectParamsError(_process, errorId, message); + await _process.kill(); +} + +/// Sends [value] to the compiler to convert to a native Sass value, then sends +/// it back out to the host as a protocol buffer and asserts the two buffers are +/// identical. +/// +/// Generally [_deprotofy] should be used instead unless there are details about +/// the internal structure of the value that won't show up in its string +/// representation. +Future _assertRoundTrips(Value value) async => + expect(await _roundTrip(value), equals(value)); + +/// Sends [value] to the compiler to convert to a native Sass value, then sends +/// it back out to the host as a protocol buffer and returns the result. +Future _roundTrip(Value value) async { + _process.send(compileString(""" +\$_: outbound(inbound()); +""", functions: ["inbound()", r"outbound($arg)"])); + + var request = await getFunctionCallRequest(_process); + expect(request.arguments, isEmpty); + _process.send(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + request = await getFunctionCallRequest(_process); + expect(_process.kill(), completes); + return request.arguments.single; +} + +/// 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 + ..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 + ..alpha = alpha); + +/// Asserts that [process] emits a [CompileFailure] result with the given +/// [message] on its protobuf stream and causes the compilation to fail. +Future _expectFunctionError( + EmbeddedProcess process, Object message) async { + var failure = await getCompileFailure(process); + expect(failure.message, equals(message)); +} diff --git a/test/embedded/importer_test.dart b/test/embedded/importer_test.dart new file mode 100644 index 000000000..71574cb4e --- /dev/null +++ b/test/embedded/importer_test.dart @@ -0,0 +1,499 @@ +// 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. + +@TestOn('vm') + +import 'package:source_maps/source_maps.dart' as source_maps; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; + +import 'embedded_process.dart'; +import 'utils.dart'; + +void main() { + late EmbeddedProcess process; + setUp(() async { + process = await EmbeddedProcess.start(); + }); + + group("emits a protocol error", () { + test("for a response without a corresponding request ID", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id + 1)); + + await expectParamsError( + process, + errorId, + "Response ID ${request.id + 1} doesn't match any outstanding " + "requests in compilation $defaultCompilationId."); + await process.shouldExit(76); + }); + + test("for a response that doesn't match the request type", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse()..id = request.id)); + + await expectParamsError( + process, + errorId, + "Request ID ${request.id} doesn't match response type " + "InboundMessage_ImportResponse in compilation " + "$defaultCompilationId."); + await process.shouldExit(76); + }); + + test("for an unset importer", () async { + process.send(compileString("a {b: c}", + importers: [InboundMessage_CompileRequest_Importer()])); + await expectParamsError( + process, errorId, "Missing mandatory field Importer.importer"); + await process.shouldExit(76); + }); + }); + + group("canonicalization", () { + group("emits a compile failure", () { + test("for a canonicalize response with an empty URL", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..url = "")); + + await _expectImportError( + process, 'The importer must return an absolute URL, was ""'); + await process.close(); + }); + + test("for a canonicalize response with a relative URL", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..url = "relative")); + + await _expectImportError(process, + 'The importer must return an absolute URL, was "relative"'); + await process.close(); + }); + }); + + group("includes in CanonicalizeRequest", () { + var importerId = 5679; + late OutboundMessage_CanonicalizeRequest request; + setUp(() async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + request = await getCanonicalizeRequest(process); + }); + + test("a known importerId", () async { + expect(request.importerId, equals(importerId)); + await process.kill(); + }); + + test("the imported URL", () async { + expect(request.url, equals("other")); + await process.kill(); + }); + }); + + test("errors cause compilation to fail", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..error = "oh no")); + + 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')); + await process.close(); + }); + + test("null results count as not found", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.close(); + }); + + test("attempts importers in order", () async { + process.send(compileString("@import 'other'", importers: [ + for (var i = 0; i < 10; i++) + InboundMessage_CompileRequest_Importer()..importerId = i + ])); + + for (var i = 0; i < 10; i++) { + var request = await getCanonicalizeRequest(process); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + } + + await process.close(); + }); + + test("tries resolved URL using the original importer first", () async { + process.send(compileString("@import 'midstream'", importers: [ + for (var i = 0; i < 10; i++) + InboundMessage_CompileRequest_Importer()..importerId = i + ])); + + for (var i = 0; i < 5; i++) { + var request = await getCanonicalizeRequest(process); + expect(request.url, equals("midstream")); + expect(request.importerId, equals(i)); + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + } + + var canonicalize = await getCanonicalizeRequest(process); + expect(canonicalize.importerId, equals(5)); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalize.id + ..url = "custom:foo/bar")); + + var import = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = import.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "@import 'upstream'"))); + + canonicalize = await getCanonicalizeRequest(process); + expect(canonicalize.importerId, equals(5)); + expect(canonicalize.url, equals("custom:foo/upstream")); + + await process.kill(); + }); + }); + + group("importing", () { + group("emits a compile failure", () { + test("for an import result with a relative sourceMapUrl", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var import = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = import.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..sourceMapUrl = "relative"))); + + await _expectImportError(process, + 'The importer must return an absolute URL, was "relative"'); + await process.close(); + }); + }); + + group("includes in ImportRequest", () { + var importerId = 5678; + late OutboundMessage_ImportRequest request; + setUp(() async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + + var canonicalize = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalize.id + ..url = "custom:foo")); + + request = await getImportRequest(process); + }); + + test("a known importerId", () async { + expect(request.importerId, equals(importerId)); + await process.kill(); + }); + + test("the canonical URL", () async { + expect(request.url, equals("custom:foo")); + await process.kill(); + }); + }); + + test("null results count as not found", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var canonicalizeRequest = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalizeRequest.id + ..url = "o:other")); + + var importRequest = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = + (InboundMessage_ImportResponse()..id = importRequest.id)); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.close(); + }); + + test("errors cause compilation to fail", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..error = "oh no")); + + 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')); + await process.close(); + }); + + test("can return an SCSS file", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: 1px + 2px}"))); + + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("can return an indented syntax file", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a\n b: 1px + 2px" + ..syntax = Syntax.INDENTED))); + + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("can return a plain CSS file", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..syntax = Syntax.CSS))); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + 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 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..sourceMapUrl = ""))); + + await expectSuccess(process, "a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, [startsWith("data:")]); + }); + await process.close(); + }); + + test("uses a non-empty source map URL", () async { + process.send(compileString("@import 'other'", + sourceMap: true, + importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..sourceMapUrl = "file:///asdf"))); + + await expectSuccess(process, "a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, equals(["file:///asdf"])); + }); + await process.close(); + }); + }); + + test("handles an importer for a string compile request", () async { + process.send(compileString("@import 'other'", + importer: InboundMessage_CompileRequest_Importer()..importerId = 1)); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: 1px + 2px}"))); + + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + group("load paths", () { + 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: [ + InboundMessage_CompileRequest_Importer()..path = d.path("dir") + ])); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + test("are accessed in order", () async { + for (var i = 0; i < 3; i++) { + await d.dir("dir$i", [d.file("other$i.scss", "a {b: $i}")]).create(); + } + + process.send(compileString("@import 'other2'", importers: [ + for (var i = 0; i < 3; i++) + InboundMessage_CompileRequest_Importer()..path = d.path("dir$i") + ])); + + await expectSuccess(process, "a { b: 2; }"); + await process.close(); + }); + + 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: [ + InboundMessage_CompileRequest_Importer()..path = d.path("dir"), + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + 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: [ + InboundMessage_CompileRequest_Importer()..importerId = 1, + InboundMessage_CompileRequest_Importer()..path = d.path("dir") + ])); + await _canonicalize(process); + + var request = await getImportRequest(process); + process.send(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "x {y: z}"))); + + await expectSuccess(process, "x { y: z; }"); + await process.close(); + }); + }); +} + +/// Handles a `CanonicalizeRequest` and returns a response with a generic +/// canonical URL. +/// +/// This is used when testing import requests, to avoid duplicating a bunch of +/// generic code for canonicalization. It shouldn't be used for testing +/// canonicalization itself. +Future _canonicalize(EmbeddedProcess process) async { + var request = await getCanonicalizeRequest(process); + process.send(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..url = "custom:other")); +} + +/// 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 { + var failure = await getCompileFailure(process); + expect(failure.message, equals(message)); + expect(failure.span.text, equals("'other'")); +} diff --git a/test/embedded/length_delimited_test.dart b/test/embedded/length_delimited_test.dart new file mode 100644 index 000000000..e329d1fdc --- /dev/null +++ b/test/embedded/length_delimited_test.dart @@ -0,0 +1,129 @@ +// 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. + +@TestOn('vm') + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:sass/src/embedded/util/length_delimited_transformer.dart'; + +import 'package:async/async.dart'; +import 'package:test/test.dart'; + +void main() { + group("encoder", () { + late Sink> sink; + late Stream> stream; + setUp(() { + var controller = StreamController>(); + sink = controller.sink; + stream = controller.stream + .map((chunk) => Uint8List.fromList(chunk)) + .transform(lengthDelimitedEncoder); + }); + + test("encodes an empty message", () { + sink.add([]); + sink.close(); + expect(collectBytes(stream), completion(equals([0]))); + }); + + test("encodes a message of length 1", () { + sink.add([123]); + sink.close(); + expect(collectBytes(stream), completion(equals([1, 123]))); + }); + + test("encodes a message of length greater than 256", () { + sink.add(List.filled(300, 1)); + sink.close(); + expect(collectBytes(stream), + completion(equals([172, 2, ...List.filled(300, 1)]))); + }); + + test("encodes multiple messages", () { + sink.add([10]); + sink.add([20, 30]); + sink.add([40, 50, 60]); + sink.close(); + expect(collectBytes(stream), + completion(equals([1, 10, 2, 20, 30, 3, 40, 50, 60]))); + }); + }); + + group("decoder", () { + late Sink> sink; + late StreamQueue queue; + setUp(() { + var controller = StreamController>(); + sink = controller.sink; + queue = StreamQueue(controller.stream.transform(lengthDelimitedDecoder)); + }); + + group("decodes an empty message", () { + test("from a single chunk", () { + sink.add([0]); + expect(queue, emits(isEmpty)); + }); + + test("from a chunk that contains more data", () { + sink.add([0, 1, 100]); + expect(queue, emits(isEmpty)); + }); + }); + + group("decodes a longer message", () { + test("from a single chunk", () { + sink.add([172, 2, ...List.filled(300, 1)]); + expect(queue, emits(List.filled(300, 1))); + }); + + test("from multiple chunks", () { + sink + ..add([172]) + ..add([2, 1]) + ..add(List.filled(299, 1)); + expect(queue, emits(List.filled(300, 1))); + }); + + test("from one chunk per byte", () { + for (var byte in [172, 2, ...List.filled(300, 1)]) { + sink.add([byte]); + } + expect(queue, emits(List.filled(300, 1))); + }); + + test("from a chunk that contains more data", () { + sink.add([172, 2, ...List.filled(300, 1), 1, 10]); + expect(queue, emits(List.filled(300, 1))); + }); + }); + + group("decodes multiple messages", () { + test("from single chunk", () { + sink.add([4, 1, 2, 3, 4, 2, 101, 102]); + expect(queue, emits([1, 2, 3, 4])); + expect(queue, emits([101, 102])); + }); + + test("from multiple chunks", () { + sink + ..add([4]) + ..add([1, 2, 3, 4, 172]) + ..add([2, ...List.filled(300, 1)]); + expect(queue, emits([1, 2, 3, 4])); + expect(queue, emits(List.filled(300, 1))); + }); + + test("from one chunk per byte", () { + for (var byte in [4, 1, 2, 3, 4, 172, 2, ...List.filled(300, 1)]) { + sink.add([byte]); + } + expect(queue, emits([1, 2, 3, 4])); + expect(queue, emits(List.filled(300, 1))); + }); + }); + }); +} diff --git a/test/embedded/protocol_test.dart b/test/embedded/protocol_test.dart new file mode 100644 index 000000000..9e293365e --- /dev/null +++ b/test/embedded/protocol_test.dart @@ -0,0 +1,590 @@ +// 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. + +@TestOn('vm') + +import 'package:path/path.dart' as p; +import 'package:pub_semver/pub_semver.dart'; +import 'package:source_maps/source_maps.dart' as source_maps; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; + +import 'embedded_process.dart'; +import 'utils.dart'; + +void main() { + late EmbeddedProcess process; + setUp(() async { + process = await EmbeddedProcess.start(); + }); + + group("exits upon protocol error", () { + test("caused by an empty message", () async { + process.send(InboundMessage()); + await expectParseError(process, "InboundMessage.message is not set."); + await process.shouldExit(76); + }); + + test("caused by an unterminated compilation ID varint", () async { + process.stdin.add([1, 0x81]); + await expectParseError( + process, "Invalid compilation ID: continuation bit always set.", + compilationId: errorId); + await process.shouldExit(76); + }); + + test("caused by a 33-bit compilation ID varint", () async { + var varint = serializeVarint(0x100000000); + process.stdin.add([...serializeVarint(varint.length), ...varint]); + await expectParseError( + process, "Varint compilation ID was longer than 32 bits.", + compilationId: errorId); + await process.shouldExit(76); + }); + + test("caused by an invalid protobuf", () async { + process.stdin.add([2, 1, 0]); + await expectParseError( + process, "Protocol message contained an invalid tag (zero).", + compilationId: 1); + await process.shouldExit(76); + }); + + test("caused by a response to an inactive compilation", () async { + process.send(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = 1)); + await expectParamsError( + process, + errorId, + "Response ID 1 doesn't match any outstanding requests in " + "compilation $defaultCompilationId."); + await process.shouldExit(76); + }); + + test("caused by duplicate compilation IDs", () async { + process.send(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await getCanonicalizeRequest(process); + + process.send(compileString("a {b: c}")); + await expectParamsError( + process, + errorId, + "A CompileRequest with compilation ID $defaultCompilationId is " + "already active."); + await process.shouldExit(76); + }); + }); + + test("a version response is valid", () async { + process.inbound.add(( + 0, + InboundMessage() + ..versionRequest = (InboundMessage_VersionRequest()..id = 123) + )); + var (compilationId, OutboundMessage(versionResponse: response)) = + await process.outbound.next; + expect(compilationId, equals(0)); + expect(response.id, equals(123)); + + 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")); + await process.close(); + }); + + group("compiles CSS from", () { + test("an SCSS string by default", () async { + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("an SCSS string explicitly", () async { + process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.SCSS)); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("an indented syntax string", () async { + process.send(compileString("a\n b: 1px + 2px", syntax: Syntax.INDENTED)); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("a plain CSS string", () async { + process.send(compileString("a {b: c}", syntax: Syntax.CSS)); + await expectSuccess(process, "a { b: c; }"); + await process.close(); + }); + + test("an absolute path", () async { + await d.file("test.scss", "a {b: 1px + 2px}").create(); + + process.send(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest() + ..path = p.absolute(d.path("test.scss")))); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + + test("a relative path", () async { + await d.file("test.scss", "a {b: 1px + 2px}").create(); + + process.send(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest() + ..path = p.relative(d.path("test.scss")))); + await expectSuccess(process, "a { b: 3px; }"); + await process.close(); + }); + }); + + group("compiles CSS in", () { + test("expanded mode", () async { + process + .send(compileString("a {b: 1px + 2px}", style: OutputStyle.EXPANDED)); + await expectSuccess(process, equals("a {\n b: 3px;\n}")); + await process.close(); + }); + + test("compressed mode", () async { + process.send( + compileString("a {b: 1px + 2px}", style: OutputStyle.COMPRESSED)); + await expectSuccess(process, equals("a{b:3px}")); + await process.close(); + }); + }); + + group("exits when stdin is closed", () { + test("immediately", () async { + process.stdin.close(); + await process.shouldExit(0); + }); + + test("after compiling CSS", () async { + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, equals("a {\n b: 3px;\n}")); + process.stdin.close(); + await process.shouldExit(0); + }); + + test("while compiling CSS", () async { + process.send(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); + await getFunctionCallRequest(process); + process.stdin.close(); + await process.shouldExit(0); + }); + }); + + test("handles many concurrent compilation requests", () async { + var totalRequests = 1000; + for (var i = 1; i <= totalRequests; i++) { + process.inbound + .add((i, compileString("a {b: foo() + 2px}", functions: [r"foo()"]))); + } + + var successes = 0; + process.outbound.rest.listen((pair) { + var (compilationId, message) = pair; + expect(compilationId, + allOf(greaterThan(0), lessThanOrEqualTo(totalRequests))); + + if (message.hasFunctionCallRequest()) { + process.inbound.add(( + compilationId, + InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = message.functionCallRequest.id + ..success = (Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.add("px")))) + )); + } else if (message.hasCompileResponse()) { + var response = message.compileResponse; + expect(response.hasSuccess(), isTrue); + expect(response.success.css, equalsIgnoringWhitespace("a { b: 3px; }")); + + successes++; + if (successes == totalRequests) { + process.stdin.close(); + } + } else { + fail("Unexpected message ${message.toDebugString()}"); + } + }); + + await process.shouldExit(0); + }); + + // Regression test for sass/dart-sass#2004 + test("handles many sequential compilation requests", () async { + var totalRequests = 1000; + for (var i = 1; i <= totalRequests; i++) { + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, "a { b: 3px; }"); + } + await process.close(); + }); + + test("closes gracefully with many in-flight compilations", () async { + // This should always be equal to the size of + // [IsolateDispatcher._isolatePool], since that's as many concurrent + // compilations as we can realistically have anyway. + var totalRequests = 15; + for (var i = 1; i <= totalRequests; i++) { + process.inbound + .add((i, compileString("a {b: foo() + 2px}", functions: [r"foo()"]))); + } + + await process.close(); + }); + + test("doesn't include a source map by default", () async { + process.send(compileString("a {b: 1px + 2px}")); + await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty); + await process.close(); + }); + + test("doesn't include a source map with source_map: false", () async { + process.send(compileString("a {b: 1px + 2px}", sourceMap: false)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: isEmpty); + await process.close(); + }); + + test("includes a source map if source_map is true", () async { + process.send(compileString("a {b: 1px + 2px}", sourceMap: true)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNull); + return true; + }); + await process.close(); + }); + + test( + "includes a source map without content if source_map is true and source_map_include_sources is false", + () async { + process.send(compileString("a {b: 1px + 2px}", + sourceMap: true, sourceMapIncludeSources: false)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNull); + return true; + }); + await process.close(); + }); + + test( + "includes a source map with content if source_map is true and source_map_include_sources is true", + () async { + process.send(compileString("a {b: 1px + 2px}", + sourceMap: true, sourceMapIncludeSources: true)); + await expectSuccess(process, "a { b: 3px; }", sourceMap: (String map) { + var mapping = source_maps.parse(map); + var span = mapping.spanFor(2, 5)!; + expect(span.start.line, equals(0)); + expect(span.start.column, equals(3)); + expect(span.end, equals(span.start)); + expect(mapping, isA()); + expect((mapping as source_maps.SingleMapping).files[0], isNotNull); + return true; + }); + await process.close(); + }); + + group("emits a log event", () { + group("for a @debug rule", () { + test("with correct fields", () async { + process.send(compileString("a {@debug hello}")); + + var logEvent = await getLogEvent(process); + expect(logEvent.type, equals(LogEventType.DEBUG)); + expect(logEvent.message, equals("hello")); + expect(logEvent.span.text, equals("@debug hello")); + expect(logEvent.span.start, equals(location(3, 0, 3))); + expect(logEvent.span.end, equals(location(15, 0, 15))); + expect(logEvent.span.context, equals("a {@debug hello}")); + expect(logEvent.stackTrace, isEmpty); + expect(logEvent.formatted, equals('-:1 DEBUG: hello\n')); + await process.kill(); + }); + + test("formatted with terminal colors", () async { + process.send(compileString("a {@debug hello}", alertColor: true)); + var logEvent = await getLogEvent(process); + expect( + logEvent.formatted, equals('-:1 \u001b[1mDebug\u001b[0m: hello\n')); + await process.kill(); + }); + }); + + group("for a @warn rule", () { + test("with correct fields", () async { + process.send(compileString("a {@warn hello}")); + + var logEvent = await getLogEvent(process); + expect(logEvent.type, equals(LogEventType.WARNING)); + expect(logEvent.message, equals("hello")); + expect(logEvent.span, equals(SourceSpan())); + expect(logEvent.stackTrace, equals("- 1:4 root stylesheet\n")); + expect( + logEvent.formatted, + equals('WARNING: hello\n' + ' - 1:4 root stylesheet\n')); + await process.kill(); + }); + + test("formatted with terminal colors", () async { + process.send(compileString("a {@warn hello}", alertColor: true)); + var logEvent = await getLogEvent(process); + expect( + logEvent.formatted, + equals('\x1B[33m\x1B[1mWarning\x1B[0m: hello\n' + ' - 1:4 root stylesheet\n')); + await process.kill(); + }); + + test("encoded in ASCII", () async { + process.send(compileString("a {@debug a && b}", alertAscii: true)); + 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' + ' ,\n' + '1 | a {@debug a && b}\n' + ' | ^^\n' + ' \'\n')); + await process.kill(); + }); + }); + + test("for a parse-time deprecation warning", () async { + process.send(compileString("@if true {} @elseif true {}")); + + var logEvent = await getLogEvent(process); + expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING)); + expect( + logEvent.message, + equals( + '@elseif is deprecated and will not be supported in future Sass ' + 'versions.\n' + '\n' + 'Recommendation: @else if')); + expect(logEvent.span.text, equals("@elseif")); + 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); + await process.kill(); + }); + + test("for a runtime deprecation warning", () async { + process.send(compileString("a {\$var: value !global}")); + + var logEvent = await getLogEvent(process); + expect(logEvent.type, equals(LogEventType.DEPRECATION_WARNING)); + expect( + logEvent.message, + equals("As of Dart Sass 2.0.0, !global assignments won't be able to " + "declare new variables.\n" + "\n" + "Recommendation: add `\$var: null` at the stylesheet root.")); + expect(logEvent.span.text, equals("\$var: value !global")); + expect(logEvent.span.start, equals(location(3, 0, 3))); + expect(logEvent.span.end, equals(location(22, 0, 22))); + expect(logEvent.span.context, equals("a {\$var: value !global}")); + expect(logEvent.stackTrace, "- 1:4 root stylesheet\n"); + await process.kill(); + }); + }); + + group("gracefully handles an error", () { + test("from invalid syntax", () async { + process.send(compileString("a {b: }")); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("Expected expression.")); + expect(failure.span.text, isEmpty); + expect(failure.span.start, equals(location(6, 0, 6))); + expect(failure.span.end, equals(location(6, 0, 6))); + expect(failure.span.url, isEmpty); + expect(failure.span.context, equals("a {b: }")); + expect(failure.stackTrace, equals("- 1:7 root stylesheet\n")); + await process.close(); + }); + + test("from the runtime", () async { + process.send(compileString("a {b: 1px + 1em}")); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("1px and 1em have incompatible units.")); + expect(failure.span.text, "1px + 1em"); + expect(failure.span.start, equals(location(6, 0, 6))); + expect(failure.span.end, equals(location(15, 0, 15))); + expect(failure.span.url, isEmpty); + expect(failure.span.context, equals("a {b: 1px + 1em}")); + expect(failure.stackTrace, equals("- 1:7 root stylesheet\n")); + await process.close(); + }); + + test("from a missing file", () async { + process.send(InboundMessage() + ..compileRequest = + (InboundMessage_CompileRequest()..path = d.path("test.scss"))); + + var failure = await getCompileFailure(process); + expect(failure.message, startsWith("Cannot open file: ")); + expect(failure.message.replaceFirst("Cannot open file: ", "").trim(), + equalsPath(d.path('test.scss'))); + expect(failure.span.text, equals('')); + expect(failure.span.context, equals('')); + expect(failure.span.start, equals(SourceSpan_SourceLocation())); + expect(failure.span.end, equals(SourceSpan_SourceLocation())); + expect(failure.span.url, equals(p.toUri(d.path('test.scss')).toString())); + expect(failure.stackTrace, isEmpty); + await process.close(); + }); + + test("with a multi-line source span", () async { + process.send(compileString(""" +a { + b: 1px + + 1em; +} +""")); + + var failure = await getCompileFailure(process); + expect(failure.span.text, "1px +\n 1em"); + expect(failure.span.start, equals(location(9, 1, 5))); + expect(failure.span.end, equals(location(23, 2, 8))); + expect(failure.span.url, isEmpty); + expect(failure.span.context, equals(" b: 1px +\n 1em;\n")); + expect(failure.stackTrace, equals("- 2:6 root stylesheet\n")); + await process.close(); + }); + + test("with multiple stack trace entries", () async { + process.send(compileString(""" +@function fail() { + @return 1px + 1em; +} + +a { + b: fail(); +} +""")); + + var failure = await getCompileFailure(process); + expect( + failure.stackTrace, + equals("- 2:11 fail()\n" + "- 6:6 root stylesheet\n")); + await process.close(); + }); + + group("and includes the URL from", () { + test("a string input", () async { + process.send(compileString("a {b: 1px + 1em}", url: "foo://bar/baz")); + + var failure = await getCompileFailure(process); + expect(failure.span.url, equals("foo://bar/baz")); + expect( + failure.stackTrace, equals("foo://bar/baz 1:7 root stylesheet\n")); + await process.close(); + }); + + test("a path input", () async { + await d.file("test.scss", "a {b: 1px + 1em}").create(); + var path = d.path("test.scss"); + process.send(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest()..path = path)); + + var failure = await getCompileFailure(process); + expect(p.fromUri(failure.span.url), equalsPath(path)); + expect(failure.stackTrace, endsWith(" 1:7 root stylesheet\n")); + expect(failure.stackTrace.split(" ").first, equalsPath(path)); + await process.close(); + }); + }); + + test("caused by using Sass features in CSS", () async { + process.send(compileString("a {b: 1px + 2px}", syntax: Syntax.CSS)); + + var failure = await getCompileFailure(process); + expect(failure.message, equals("Operators aren't allowed in plain CSS.")); + expect(failure.span.text, "+"); + expect(failure.span.start, equals(location(10, 0, 10))); + expect(failure.span.end, equals(location(11, 0, 11))); + expect(failure.span.url, isEmpty); + expect(failure.span.context, equals("a {b: 1px + 2px}")); + expect(failure.stackTrace, equals("- 1:11 root stylesheet\n")); + await process.close(); + }); + + group("and provides a formatted", () { + test("message", () async { + process.send(compileString("a {b: 1px + 1em}")); + + var failure = await getCompileFailure(process); + expect( + failure.formatted, + equals('Error: 1px and 1em have incompatible units.\n' + ' ╷\n' + '1 │ a {b: 1px + 1em}\n' + ' │ ^^^^^^^^^\n' + ' ╵\n' + ' - 1:7 root stylesheet')); + await process.close(); + }); + + test("message with terminal colors", () async { + process.send(compileString("a {b: 1px + 1em}", alertColor: true)); + + var failure = await getCompileFailure(process); + expect( + failure.formatted, + equals('Error: 1px and 1em have incompatible units.\n' + '\x1B[34m ╷\x1B[0m\n' + '\x1B[34m1 │\x1B[0m a {b: \x1B[31m1px + 1em\x1B[0m}\n' + '\x1B[34m │\x1B[0m \x1B[31m ^^^^^^^^^\x1B[0m\n' + '\x1B[34m ╵\x1B[0m\n' + ' - 1:7 root stylesheet')); + await process.close(); + }); + + test("message with ASCII encoding", () async { + process.send(compileString("a {b: 1px + 1em}", alertAscii: true)); + + var failure = await getCompileFailure(process); + expect( + failure.formatted, + equals('Error: 1px and 1em have incompatible units.\n' + ' ,\n' + '1 | a {b: 1px + 1em}\n' + ' | ^^^^^^^^^\n' + ' \'\n' + ' - 1:7 root stylesheet')); + await process.close(); + }); + }); + }); +} diff --git a/test/embedded/utils.dart b/test/embedded/utils.dart new file mode 100644 index 000000000..68c1e2f83 --- /dev/null +++ b/test/embedded/utils.dart @@ -0,0 +1,207 @@ +// 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 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'package:sass/src/embedded/embedded_sass.pb.dart'; +import 'package:sass/src/embedded/utils.dart'; + +import 'embedded_process.dart'; + +/// An arbitrary compilation ID to use for tests where the specific ID doesn't +/// matter. +const defaultCompilationId = 4321; + +/// Returns a (compilation ID, [InboundMessage]) pair that compiles the given +/// plain CSS string. +InboundMessage compileString(String css, + {int? id, + bool? alertColor, + bool? alertAscii, + Syntax? syntax, + OutputStyle? style, + String? url, + bool? sourceMap, + bool? sourceMapIncludeSources, + Iterable? importers, + InboundMessage_CompileRequest_Importer? importer, + Iterable? functions}) { + var input = InboundMessage_CompileRequest_StringInput()..source = css; + if (syntax != null) input.syntax = syntax; + if (url != null) input.url = url; + if (importer != null) input.importer = importer; + + var request = InboundMessage_CompileRequest()..string = input; + if (importers != null) request.importers.addAll(importers); + if (style != null) request.style = style; + if (sourceMap != null) request.sourceMap = sourceMap; + if (sourceMapIncludeSources != null) { + request.sourceMapIncludeSources = sourceMapIncludeSources; + } + if (functions != null) request.globalFunctions.addAll(functions); + if (alertColor != null) request.alertColor = alertColor; + if (alertAscii != null) request.alertAscii = alertAscii; + + return InboundMessage()..compileRequest = request; +} + +/// Asserts that [process] emits a [ProtocolError] parse error with the given +/// [message] on its protobuf stream and prints a notice on stderr. +Future expectParseError(EmbeddedProcess process, Object message, + {int compilationId = defaultCompilationId}) async { + var (actualCompilationId, actualMessage) = await process.outbound.next; + expect(actualCompilationId, equals(compilationId)); + expect(actualMessage, + isProtocolError(errorId, ProtocolErrorType.PARSE, message)); + + var stderrPrefix = "Host caused parse error: "; + await expectLater( + process.stderr, + message is String + ? emitsInOrder("$stderrPrefix$message".split("\n")) + : emits(startsWith(stderrPrefix))); +} + +/// Asserts that [process] emits a [ProtocolError] params error with the given +/// [message] on its protobuf stream and prints a notice on stderr. +Future expectParamsError(EmbeddedProcess process, int id, Object message, + {int compilationId = defaultCompilationId}) async { + var (actualCompilationId, actualMessage) = await process.outbound.next; + expect(actualCompilationId, equals(compilationId)); + expect(actualMessage, isProtocolError(id, ProtocolErrorType.PARAMS, message)); + + var stderrPrefix = "Host caused params error" + "${id == errorId ? '' : " with request $id"}: "; + await expectLater( + process.stderr, + message is String + ? emitsInOrder("$stderrPrefix$message".split("\n")) + : emits(startsWith(stderrPrefix))); +} + +/// Asserts that an [OutboundMessage] is a [ProtocolError] with the given [id], +/// [type], and optionally [message]. +Matcher isProtocolError(int id, ProtocolErrorType type, [Object? message]) => + predicate((value) { + expect(value, isA()); + var outboundMessage = value as OutboundMessage; + expect(outboundMessage.hasError(), isTrue, + reason: "Expected $outboundMessage to be a ProtocolError"); + expect(outboundMessage.error.id, equals(id)); + expect(outboundMessage.error.type, equals(type)); + if (message != null) expect(outboundMessage.error.message, message); + return true; + }); + +/// Asserts [process] emits a `CanonicalizeRequest` with the default compilation +/// ID and returns it. +Future getCanonicalizeRequest( + EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasCanonicalizeRequest(), isTrue, + reason: "Expected $message to have a CanonicalizeRequest"); + return message.canonicalizeRequest; +} + +/// Asserts [process] emits an `ImportRequest` with the default compilation ID +/// and returns it. +Future getImportRequest( + EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasImportRequest(), isTrue, + reason: "Expected $message to have a ImportRequest"); + return message.importRequest; +} + +/// Asserts that [process] emits a `FileImportRequest` with the default +/// compilation ID and returns it. +Future getFileImportRequest( + EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasFileImportRequest(), isTrue, + reason: "Expected $message to have a FileImportRequest"); + return message.fileImportRequest; +} + +/// Asserts that [process] emits a `FunctionCallRequest` with the default +/// compilation ID and returns it. +Future getFunctionCallRequest( + EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasFunctionCallRequest(), isTrue, + reason: "Expected $message to have a FunctionCallRequest"); + return message.functionCallRequest; +} + +/// Asserts that [process] emits a with the default compilation ID +/// `CompileResponse.Failure` and returns it. +Future getCompileFailure( + EmbeddedProcess process) async { + var response = await getCompileResponse(process); + expect(response.hasFailure(), isTrue, + reason: "Expected $response to be a failure"); + return response.failure; +} + +/// Asserts that [process] emits a with the default compilation ID +/// `CompileResponse.Success` and returns it. +Future getCompileSuccess( + EmbeddedProcess process) async { + var response = await getCompileResponse(process); + expect(response.hasSuccess(), isTrue, + reason: "Expected $response to be a success"); + return response.success; +} + +/// Asserts that [process] emits a `CompileResponse` and with the default +/// compilation ID returns it. +Future getCompileResponse( + EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasCompileResponse(), isTrue, + reason: "Expected $message to have a CompileResponse"); + return message.compileResponse; +} + +/// Asserts that [process] emits a `LogEvent` and returns with the default +/// compilation ID it. +Future getLogEvent(EmbeddedProcess process) async { + var message = await process.receive(); + expect(message.hasLogEvent(), isTrue, + reason: "Expected $message to have a LogEvent"); + return message.logEvent; +} + +/// Asserts that [process] emits a `CompileResponse` with CSS that matches +/// [css], with a source map that matches [sourceMap] (if passed). +/// +/// If [css] is a [String], this automatically wraps it in +/// [equalsIgnoringWhitespace]. +/// +/// If [sourceMap] is a function, `response.success.sourceMap` is passed to it. +/// Otherwise, it's treated as a matcher for `response.success.sourceMap`. +Future expectSuccess(EmbeddedProcess process, Object css, + {Object? sourceMap}) async { + var success = await getCompileSuccess(process); + expect(success.css, css is String ? equalsIgnoringWhitespace(css) : css); + if (sourceMap is void Function(String)) { + sourceMap(success.sourceMap); + } else if (sourceMap != null) { + expect(success.sourceMap, sourceMap); + } +} + +/// Returns a [SourceSpan_SourceLocation] with the given [offset], [line], and +/// [column]. +SourceSpan_SourceLocation location(int offset, int line, int column) => + SourceSpan_SourceLocation() + ..offset = offset + ..line = line + ..column = column; + +/// Returns a matcher that verifies whether the given value refers to the same +/// path as [expected]. +Matcher equalsPath(String expected) => predicate( + (actual) => p.equals(actual, expected), "equals $expected"); diff --git a/test/output_test.dart b/test/output_test.dart index ff132d118..2023ae65b 100644 --- a/test/output_test.dart +++ b/test/output_test.dart @@ -6,6 +6,8 @@ // just covers tests that explicitly validate out that's considered too // implementation-specific to verify in sass-spec. +@TestOn('vm') + import 'package:test/test.dart'; import 'package:sass/sass.dart'; @@ -92,7 +94,7 @@ void main() { group("for floating-point numbers", () { test("Infinity", () { expect(compileString("a {b: 1e999}"), - equalsIgnoringWhitespace("a { b: Infinity; }")); + equalsIgnoringWhitespace("a { b: calc(infinity); }")); }); test(">= 1e21", () { diff --git a/test/repo_test.dart b/test/repo_test.dart index 540c736bf..54d89fdbc 100644 --- a/test/repo_test.dart +++ b/test/repo_test.dart @@ -2,6 +2,8 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'dart:io'; import 'package:path/path.dart' as p; diff --git a/test/source_map_test.dart b/test/source_map_test.dart index 3f838c93c..61b91120f 100644 --- a/test/source_map_test.dart +++ b/test/source_map_test.dart @@ -2,12 +2,13 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +@TestOn('vm') + import 'package:charcode/charcode.dart'; import 'package:source_maps/source_maps.dart'; import 'package:source_span/source_span.dart'; import 'package:string_scanner/string_scanner.dart'; import 'package:test/test.dart'; -import 'package:tuple/tuple.dart'; import 'package:sass/sass.dart'; import 'package:sass/src/utils.dart'; @@ -718,31 +719,22 @@ void _expectSourceMap(String sass, String scss, String css, /// Like [_expectSourceMap], but with only SCSS source. void _expectScssSourceMap(String scss, String css, {Importer? importer, OutputStyle? style}) { - var scssTuple = _extractLocations(_reindent(scss)); - var scssText = scssTuple.item1; - var scssLocations = _tuplesToMap(scssTuple.item2); - - var cssTuple = _extractLocations(_reindent(css)); - var cssText = cssTuple.item1; - var cssLocations = cssTuple.item2; + var (scssText, scssLocations) = _extractLocations(_reindent(scss)); + var (cssText, cssLocations) = _extractLocations(_reindent(css)); late SingleMapping scssMap; var scssOutput = compileString(scssText, sourceMap: (map) => scssMap = map, importer: importer, style: style); expect(scssOutput, equals(cssText)); - _expectMapMatches(scssMap, scssText, cssText, scssLocations, cssLocations); + _expectMapMatches( + scssMap, scssText, cssText, _pairsToMap(scssLocations), cssLocations); } /// Like [_expectSourceMap], but with only indented source. void _expectSassSourceMap(String sass, String css, {Importer? importer, OutputStyle? style}) { - var sassTuple = _extractLocations(_reindent(sass)); - var sassText = sassTuple.item1; - var sassLocations = _tuplesToMap(sassTuple.item2); - - var cssTuple = _extractLocations(_reindent(css)); - var cssText = cssTuple.item1; - var cssLocations = cssTuple.item2; + var (sassText, sassLocations) = _extractLocations(_reindent(sass)); + var (cssText, cssLocations) = _extractLocations(_reindent(css)); late SingleMapping sassMap; var sassOutput = compileString(sassText, @@ -751,7 +743,8 @@ void _expectSassSourceMap(String sass, String css, importer: importer, style: style); expect(sassOutput, equals(cssText)); - _expectMapMatches(sassMap, sassText, cssText, sassLocations, cssLocations); + _expectMapMatches( + sassMap, sassText, cssText, _pairsToMap(sassLocations), cssLocations); } /// Returns [string] with leading whitespace stripped from each line so that the @@ -768,11 +761,10 @@ String _reindent(String string) { } /// Parses and removes the location annotations from [text]. -Tuple2>> _extractLocations( - String text) { +(String, List<(String, SourceLocation)>) _extractLocations(String text) { var scanner = StringScanner(text); var buffer = StringBuffer(); - var locations = >[]; + var locations = <(String, SourceLocation)>[]; var offset = 0; var line = 0; @@ -784,8 +776,10 @@ Tuple2>> _extractLocations( while (!scanner.scan("}}")) { scanner.readChar(); } - locations.add(Tuple2(scanner.substring(start, scanner.position - 2), - SourceLocation(offset, line: line, column: column))); + locations.add(( + scanner.substring(start, scanner.position - 2), + SourceLocation(offset, line: line, column: column) + )); } else if (scanner.scanChar($lf)) { offset++; line++; @@ -798,16 +792,16 @@ Tuple2>> _extractLocations( } } - return Tuple2(buffer.toString(), locations); + return (buffer.toString(), locations); } -/// Converts a list of tuples to a map, asserting that each key appears only +/// Converts a list of pairs to a map, asserting that each key appears only /// once. -Map _tuplesToMap(Iterable> tuples) { +Map _pairsToMap(Iterable<(K, V)> pairs) { var map = {}; - for (var tuple in tuples) { - expect(map, isNot(contains(tuple.item1))); - map[tuple.item1] = tuple.item2; + for (var (key, value) in pairs) { + expect(map, isNot(contains(key))); + map[key] = value; } return map; } @@ -819,17 +813,15 @@ void _expectMapMatches( String sourceText, String targetText, Map sourceLocations, - List> targetLocations) { + List<(String, SourceLocation)> targetLocations) { expect(sourceLocations.keys, - equals({for (var tuple in targetLocations) tuple.item1})); + equals({for (var (name, _) in targetLocations) name})); String actualMap() => "\nActual map:\n\n" + _mapToString(map, sourceText, targetText) + "\n"; var entryIter = _entriesForMap(map).iterator; - for (var tuple in targetLocations) { - var name = tuple.item1; - var expectedTarget = tuple.item2; + for (var (name, expectedTarget) in targetLocations) { var expectedSource = sourceLocations[name]!; if (!entryIter.moveNext()) { @@ -883,17 +875,17 @@ String _mapToString(SingleMapping map, String sourceText, String targetText) { // A map from lines and columns in [sourceText] to the names of the entries // with those source locations. - var entryNames = , String>{}; + var entryNames = <(int, int), String>{}; var i = 0; for (var entry in entriesInSourceOrder) { entryNames.putIfAbsent( - Tuple2(entry.source.line, entry.source.column), () => (++i).toString()); + (entry.source.line, entry.source.column), () => (++i).toString()); } var sourceScanner = LineScanner(sourceText); var sourceBuffer = StringBuffer(); while (!sourceScanner.isDone) { - var name = entryNames[Tuple2(sourceScanner.line, sourceScanner.column)]; + var name = entryNames[(sourceScanner.line, sourceScanner.column)]; if (name != null) sourceBuffer.write("{{$name}}"); sourceBuffer.writeCharCode(sourceScanner.readChar()); } @@ -905,7 +897,7 @@ String _mapToString(SingleMapping map, String sourceText, String targetText) { var entry = entryIter.current; if (targetScanner.line == entry.target.line && targetScanner.column == entry.target.column) { - var name = entryNames[Tuple2(entry.source.line, entry.source.column)]; + var name = entryNames[(entry.source.line, entry.source.column)]; targetBuffer.write("{{$name}}"); if (!entryIter.moveNext()) { targetBuffer.write(targetScanner.rest); diff --git a/tool/grind.dart b/tool/grind.dart index 70e1f7081..ce65138df 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -28,14 +28,16 @@ void main(List args) { pkg.executables.value = {"sass": "bin/sass.dart"}; pkg.chocolateyNuspec.value = _nuspec; pkg.homebrewRepo.value = "sass/homebrew-sass"; - pkg.homebrewFormula.value = "sass.rb"; + pkg.homebrewFormula.value = "Formula/sass.rb"; pkg.jsRequires.value = [ + pkg.JSRequire("immutable", target: pkg.JSRequireTarget.all), pkg.JSRequire("chokidar", target: pkg.JSRequireTarget.cli), pkg.JSRequire("readline", target: pkg.JSRequireTarget.cli), - pkg.JSRequire("immutable", target: pkg.JSRequireTarget.all), - pkg.JSRequire("util", target: pkg.JSRequireTarget.all), + pkg.JSRequire("fs", target: pkg.JSRequireTarget.node), + pkg.JSRequire("stream", target: pkg.JSRequireTarget.node), + pkg.JSRequire("util", target: pkg.JSRequireTarget.node), ]; - pkg.jsModuleMainLibrary.value = "lib/src/node.dart"; + pkg.jsModuleMainLibrary.value = "lib/src/js.dart"; pkg.npmPackageJson.fn = () => json.decode(File("package/package.json").readAsStringSync()) as Map; @@ -44,6 +46,39 @@ void main(List args) { pkg.standaloneName.value = "dart-sass"; pkg.githubUser.fn = () => Platform.environment["GH_USER"]; pkg.githubPassword.fn = () => Platform.environment["GH_TOKEN"]; + pkg.jsEsmExports.value = { + 'compile', + 'compileAsync', + 'compileString', + 'compileStringAsync', + 'Logger', + 'SassArgumentList', + 'SassBoolean', + 'SassCalculation', + 'CalculationOperation', + 'CalculationInterpolation', + 'SassColor', + 'SassFunction', + 'SassList', + 'SassMap', + 'SassNumber', + 'SassString', + 'Value', + 'CustomFunction', + 'ListSeparator', + 'sassFalse', + 'sassNull', + 'sassTrue', + 'Exception', + 'PromiseOr', + 'info', + 'render', + 'renderSync', + 'TRUE', + 'FALSE', + 'NULL', + 'types', + }; pkg.githubReleaseNotes.fn = () => "To install Sass ${pkg.version}, download one of the packages below " @@ -57,7 +92,26 @@ void main(List args) { "\n" "${pkg.githubReleaseNotes.defaultValue}"; + pkg.environmentConstants.fn = () { + if (!Directory('build/language').existsSync()) { + fail('Run `dart run grinder protobuf` before building Dart Sass ' + 'executables.'); + } + + return { + ...pkg.environmentConstants.defaultValue, + "protocol-version": File('build/language/spec/EMBEDDED_PROTOCOL_VERSION') + .readAsStringSync() + .trim(), + "compiler-version": pkg.pubspec.version!.toString(), + }; + }; + pkg.addAllTasks(); + + afterTask("pkg-npm-dev", _addDefaultExport); + afterTask("pkg-npm-release", _addDefaultExport); + grind(args); } @@ -67,8 +121,7 @@ void all() {} @Task('Run the Dart formatter.') void format() { - run('dart', - arguments: ['run', 'dart_style:format', '--overwrite', '--fix', '.']); + run('dart', arguments: ['format', '--fix', '.']); } @Task('Installs dependencies from npm.') @@ -76,7 +129,8 @@ void npmInstall() => run(Platform.isWindows ? "npm.cmd" : "npm", arguments: ["install"]); @Task('Runs the tasks that are required for running tests.') -@Depends(format, synchronize, "pkg-npm-dev", npmInstall, "pkg-standalone-dev") +@Depends(format, synchronize, protobuf, "pkg-npm-dev", npmInstall, + "pkg-standalone-dev") void beforeTest() {} String get _nuspec => """ @@ -165,3 +219,71 @@ void _matchError(Match match, String message, {Object? url}) { var file = SourceFile.fromString(match.input, url: url); throw SourceSpanException(message, file.span(match.start, match.end)); } + +@Task('Compile the protocol buffer definition to a Dart library.') +Future protobuf() async { + Directory('build').createSync(recursive: true); + + // Make sure we use the version of protoc_plugin defined by our pubspec, + // rather than whatever version the developer might have globally installed. + log("Writing protoc-gen-dart"); + if (Platform.isWindows) { + File('build/protoc-gen-dart.bat').writeAsStringSync(''' +@echo off +dart run protoc_plugin %* +'''); + } else { + File('build/protoc-gen-dart').writeAsStringSync(''' +#!/bin/sh +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: { + "PATH": 'build' + + (Platform.isWindows ? ";" : ":") + + Platform.environment["PATH"]! + })); +} + +/// After building the NPM package, add default exports to +/// `build/npm/sass.node.mjs`. +/// +/// See sass/dart-sass#2008. +void _addDefaultExport() { + var buffer = StringBuffer(); + buffer.writeln(File("build/npm/sass.node.mjs").readAsStringSync()); + + buffer.writeln(""" +let printedDefaultExportDeprecation = false; +function defaultExportDeprecation() { + if (printedDefaultExportDeprecation) return; + printedDefaultExportDeprecation = true; + console.error( + "`import sass from 'sass'` is deprecated.\\n" + + "Please use `import * as sass from 'sass'` instead."); +} +"""); + + buffer.writeln("export default {"); + for (var export in pkg.jsEsmExports.value!) { + buffer.write(""" + get $export() { + defaultExportDeprecation(); + return cjs.$export; + }, +"""); + } + + buffer.writeln("};"); + + File("build/npm/sass.node.mjs").writeAsStringSync(buffer.toString()); +} diff --git a/tool/grind/benchmark.dart b/tool/grind/benchmark.dart index a94cf7c8f..82ef95d3f 100644 --- a/tool/grind/benchmark.dart +++ b/tool/grind/benchmark.dart @@ -8,6 +8,7 @@ import 'dart:math' as math; import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; +import 'package:sass/src/util/nullable.dart'; import 'utils.dart'; @@ -86,11 +87,11 @@ Future _writeNTimes(String path, String text, num times, log("Generating $path..."); var sink = file.openWrite(); - if (header != null) sink.writeln(header); + header.andThen(sink.writeln); for (var i = 0; i < times; i++) { sink.writeln(text); } - if (footer != null) sink.writeln(footer); + footer.andThen(sink.writeln); await sink.close(); } diff --git a/tool/grind/frameworks.dart b/tool/grind/frameworks.dart index 038c954f0..014bfbdc2 100644 --- a/tool/grind/frameworks.dart +++ b/tool/grind/frameworks.dart @@ -46,8 +46,7 @@ Future _findLatestRelease(String slug, {Pattern? pattern}) async { var page = 1; while (releases.isNotEmpty) { - for (var release in releases) { - var tagName = release['tag_name'] as String; + for (var {'tag_name': String tagName} in releases) { if (pattern.allMatches(tagName).isNotEmpty) return tagName; } diff --git a/tool/grind/subpackages.dart b/tool/grind/subpackages.dart index 9ac765341..c58719aeb 100644 --- a/tool/grind/subpackages.dart +++ b/tool/grind/subpackages.dart @@ -6,6 +6,7 @@ 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; @@ -15,22 +16,8 @@ import 'package:yaml/yaml.dart'; import 'utils.dart'; /// The path in which pub expects to find its credentials file. -final String _pubCredentialsPath = () { - // This follows the same logic as pub: - // https://github.com/dart-lang/pub/blob/d99b0d58f4059d7bb4ac4616fd3d54ec00a2b5d4/lib/src/system_cache.dart#L34-L43 - String cacheDir; - var pubCache = Platform.environment['PUB_CACHE']; - if (pubCache != null) { - cacheDir = pubCache; - } else if (Platform.isWindows) { - var appData = Platform.environment['APPDATA']!; - cacheDir = p.join(appData, 'Pub', 'Cache'); - } else { - cacheDir = p.join(Platform.environment['HOME']!, '.pub-cache'); - } - - return p.join(cacheDir, 'credentials.json'); -}(); +final String _pubCredentialsPath = + p.join(applicationConfigHome('dart'), 'pub-credentials.json'); @Task('Deploy sub-packages to pub.') Future deploySubPackages() async { diff --git a/tool/grind/synchronize.dart b/tool/grind/synchronize.dart index ac0e45fd2..87ab7af5d 100644 --- a/tool/grind/synchronize.dart +++ b/tool/grind/synchronize.dart @@ -4,16 +4,19 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:analyzer/dart/analysis/features.dart'; import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_style/dart_style.dart'; import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; +import 'package:source_span/source_span.dart'; import 'package:sass/src/util/nullable.dart'; @@ -43,15 +46,19 @@ final _sharedClasses = const ['EvaluateResult']; /// to a synchronous equivalent. @Task('Compile async code to synchronous code.') void synchronize() { - sources.forEach((source, target) { - var visitor = _Visitor(File(source).readAsStringSync(), source); - - parseFile(path: source, featureSet: FeatureSet.latestLanguageVersion()) - .unit - .accept(visitor); - var formatted = DartFormatter().format(visitor.result); - File(target).writeAsStringSync(formatted); - }); + sources.forEach((source, target) => + File(target).writeAsStringSync(synchronizeFile(source))); +} + +/// Returns the result of synchronizing [source]. +String synchronizeFile(String source) { + source = p.absolute(source); + var visitor = _Visitor(File(source).readAsStringSync(), source); + + parseFile(path: source, featureSet: FeatureSet.latestLanguageVersion()) + .unit + .accept(visitor); + return DartFormatter().format(visitor.result); } /// The visitor that traverses the asynchronous parse tree and converts it to @@ -65,12 +72,21 @@ class _Visitor extends RecursiveAstVisitor { /// The source of the original asynchronous file. final String _source; + /// The path from which [_source] was loaded. + final String _path; + /// The current position in [_source]. var _position = 0; /// The buffer in which the text of the synchronous file is built up. final _buffer = StringBuffer(); + /// Returns the [SourceFile] which is being rewritten. + /// + /// This is only used for debugging and error reporting. + SourceFile get _sourceFile => + SourceFile.fromString(_source, url: p.toUri(_path)); + /// The synchronous text. String get result { _buffer.write(_source.substring(_position)); @@ -78,11 +94,11 @@ class _Visitor extends RecursiveAstVisitor { return _buffer.toString(); } - _Visitor(this._source, String path) { + _Visitor(this._source, this._path) { var afterHeader = "\n".allMatches(_source).skip(3).first.end; _buffer.writeln(_source.substring(0, afterHeader)); _buffer.writeln(""" -// DO NOT EDIT. This file was generated from ${p.basename(path)}. +// DO NOT EDIT. This file was generated from ${p.basename(_path)}. // See tool/grind/synchronize.dart for details. // // Checksum: ${sha1.convert(utf8.encode(_source))} @@ -90,12 +106,12 @@ class _Visitor extends RecursiveAstVisitor { // ignore_for_file: unused_import """); - if (p.basename(path) == 'async_evaluate.dart') { + if (p.basename(_path) == 'async_evaluate.dart') { _buffer.writeln(); _buffer.writeln("import 'async_evaluate.dart' show EvaluateResult;"); _buffer.writeln("export 'async_evaluate.dart' show EvaluateResult;"); _buffer.writeln(); - } else if (p.basename(path) == 'async_compile.dart') { + } else if (p.basename(_path) == 'async_compile.dart') { _buffer.writeln(); _buffer.writeln("export 'async_compile.dart';"); _buffer.writeln(); @@ -128,10 +144,32 @@ class _Visitor extends RecursiveAstVisitor { } void visitClassDeclaration(ClassDeclaration node) { - if (_sharedClasses.contains(node.name2.lexeme)) { + if (_sharedClasses.contains(node.name.lexeme)) { _skipNode(node); } else { - super.visitClassDeclaration(node); + for (var child in node.sortedCommentAndAnnotations) { + child.accept(this); + } + _rename(node.name); + node.typeParameters?.accept(this); + node.extendsClause?.accept(this); + node.withClause?.accept(this); + node.implementsClause?.accept(this); + node.nativeClause?.accept(this); + node.members.accept(this); + } + } + + void visitGenericTypeAlias(GenericTypeAlias node) { + if (_sharedClasses.contains(node.name.lexeme)) { + _skipNode(node); + } else { + for (var child in node.sortedCommentAndAnnotations) { + child.accept(this); + } + _rename(node.name); + node.typeParameters?.accept(this); + node.type.accept(this); } } @@ -140,8 +178,17 @@ class _Visitor extends RecursiveAstVisitor { node.visitChildren(this); } + void visitFunctionDeclaration(FunctionDeclaration node) { + for (var child in node.sortedCommentAndAnnotations) { + child.accept(this); + } + node.returnType?.accept(this); + _rename(node.name); + node.functionExpression.accept(this); + } + void visitMethodDeclaration(MethodDeclaration node) { - if (_synchronizeName(node.name2.lexeme) != node.name2.lexeme) { + if (_synchronizeName(node.name.lexeme) != node.name.lexeme) { // If the file defines any asynchronous versions of synchronous functions, // remove them. _skipNode(node); @@ -160,8 +207,11 @@ class _Visitor extends RecursiveAstVisitor { void visitMethodInvocation(MethodInvocation node) { // Convert async utility methods to their synchronous equivalents. - if (node.target == null && - ["mapAsync", "putIfAbsentAsync"].contains(node.methodName.name)) { + if (node + case MethodInvocation( + target: null, + methodName: SimpleIdentifier(name: "mapAsync" || "putIfAbsentAsync") + )) { _writeTo(node); var arguments = node.argumentList.arguments; _write(arguments.first); @@ -185,17 +235,16 @@ class _Visitor extends RecursiveAstVisitor { } void visitNamedType(NamedType node) { - if (["Future", "FutureOr"].contains(node.name.name)) { - _skip(node.name.beginToken); - var typeArguments = node.typeArguments; - if (typeArguments != null) { + if (node.name2.lexeme case "Future" || "FutureOr") { + _skip(node.name2); + if (node.typeArguments case var typeArguments?) { _skip(typeArguments.leftBracket); typeArguments.arguments.first.accept(this); _skip(typeArguments.rightBracket); } else { _buffer.write("void"); } - } else if (node.name.name == "Module") { + } else if (node.name2.lexeme == "Module") { _skipNode(node); _buffer.write("Module"); } else { @@ -203,10 +252,22 @@ class _Visitor extends RecursiveAstVisitor { } } + /// Writes through [node]'s (synchronized) name. + /// + /// Assumes [node] has a name field with type [Token]. + void _rename(Token token) { + _skip(token); + _buffer.write(_synchronizeName(token.lexeme)); + } + /// Writes [_source] to [_buffer] up to the beginning of [token], then puts /// [_position] after [token] so it doesn't get written. void _skip(Token? token) { if (token == null) return; + if (token.offset < _position) { + throw _alreadyEmittedException(_spanForToken(token)); + } + _buffer.write(_source.substring(_position, token.offset)); _position = token.end; } @@ -220,6 +281,10 @@ class _Visitor extends RecursiveAstVisitor { /// Writes [_source] to [_buffer] up to the beginning of [node]. void _writeTo(AstNode node) { + if (node.beginToken.offset < _position) { + throw _alreadyEmittedException(_spanForNode(node)); + } + _buffer.write(_source.substring(_position, node.beginToken.offset)); _position = node.beginToken.offset; } @@ -228,6 +293,10 @@ class _Visitor extends RecursiveAstVisitor { /// /// This leaves [_position] at the end of [node]. void _write(AstNode node) { + if (node.beginToken.offset < _position) { + throw _alreadyEmittedException(_spanForNode(node)); + } + _position = node.beginToken.offset; node.accept(this); _buffer.write(_source.substring(_position, node.endToken.end)); @@ -244,4 +313,26 @@ class _Visitor extends RecursiveAstVisitor { return name; } } + + SourceSpanException _alreadyEmittedException(SourceSpan span) { + var lines = _buffer.toString().split("\n"); + return SourceSpanException( + "Node was already emitted. Last 3 lines:\n\n" + + lines + .slice(math.max(lines.length - 3, 0)) + .map((line) => " $line") + .join("\n") + + "\n", + span); + } + + /// Returns a [FileSpan] that represents [token]'s position in the source + /// file. + SourceSpan _spanForToken(Token token) => + _sourceFile.span(token.offset, token.end); + + /// Returns a [FileSpan] that represents [token]'s position in the source + /// file. + SourceSpan _spanForNode(AstNode node) => + _sourceFile.span(node.beginToken.offset, node.endToken.end); } diff --git a/tool/grind/utils.dart b/tool/grind/utils.dart index 1135865ee..12fd8ed1e 100644 --- a/tool/grind/utils.dart +++ b/tool/grind/utils.dart @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -9,6 +10,9 @@ import 'package:cli_pkg/cli_pkg.dart' as pkg; import 'package:grinder/grinder.dart'; import 'package:path/path.dart' as p; +// Work around the lack of google/grinder.dart#402. +import 'package:grinder/src/singleton.dart'; + /// Options for [run] that tell Git to commit using SassBot's name and email. final sassBotEnvironment = RunOptions(environment: { "GIT_AUTHOR_NAME": pkg.botName.value, @@ -36,9 +40,8 @@ void ensureBuild() { /// Returns the environment variable named [name], or throws an exception if it /// can't be found. String environment(String name) { - var value = Platform.environment[name]; - if (value == null) fail("Required environment variable $name not found."); - return value; + if (Platform.environment[name] case var value?) return value; + fail("Required environment variable $name not found."); } /// Ensure that the repository at [url] is cloned into the build directory and @@ -75,3 +78,21 @@ String cloneOrCheckout(String url, String ref, {String? name}) { return path; } + +/// Registers [callback] to run after the task named [taskName]. +/// +/// This must be called after the base [taskName] is registered. +void afterTask(String taskName, FutureOr callback()) { + // This takes advantage of the fact that Grinder's task list is mutable to + // override the existing task with our new one. + var index = grinder.tasks.indexWhere((task) => task.name == taskName); + if (index == -1) fail("There is no task named $taskName."); + + var oldTask = grinder.tasks[index]; + grinder.tasks[index] = GrinderTask(taskName, + description: oldTask.description, + depends: oldTask.depends, taskFunction: (TaskArgs args) async { + await oldTask.execute(context, args); + await callback(); + }); +} diff --git a/tool/utils.dart b/tool/utils.dart new file mode 100644 index 000000000..2eb303b1d --- /dev/null +++ b/tool/utils.dart @@ -0,0 +1,42 @@ +// Copyright 2019 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'dart:io'; + +import 'package:grinder/grinder.dart'; +import 'package:path/path.dart' as p; + +/// Ensure that the repository at [url] is cloned into the build directory and +/// pointing to the latest master revision. +/// +/// Returns the path to the repository. +Future cloneOrPull(String url) async => + cloneOrCheckout(url, "origin/main"); + +/// Ensure that the repository at [url] is cloned into the build directory and +/// pointing to [ref]. +/// +/// Returns the path to the repository. +Future cloneOrCheckout(String url, String ref) async { + var name = p.url.basename(url); + if (p.url.extension(name) == ".git") name = p.url.withoutExtension(name); + + var path = p.join("build", name); + + if (Directory(p.join(path, '.git')).existsSync()) { + log("Updating $url"); + await runAsync("git", + arguments: ["fetch", "origin"], workingDirectory: path); + } else { + delete(Directory(path)); + await runAsync("git", arguments: ["clone", url, path]); + await runAsync("git", + arguments: ["config", "advice.detachedHead", "false"], + workingDirectory: path); + } + await runAsync("git", arguments: ["checkout", ref], workingDirectory: path); + log(""); + + return path; +}