diff --git a/.github/util/build-protobuf/action.yml b/.github/util/build-protobuf/action.yml new file mode 100644 index 000000000..9b87e9012 --- /dev/null +++ b/.github/util/build-protobuf/action.yml @@ -0,0 +1,18 @@ +name: Build Protobuf +description: Check out and build the Dart Sass embedded protocol buffer. +inputs: + github-token: {required: true} +runs: + using: composite + steps: + - uses: bufbuild/buf-setup-action@v1.13.1 + with: {github_token: "${{ inputs.github-token }}"} + + - name: Check out embedded Sass protocol + uses: sass/clone-linked-repo@v1 + with: {repo: sass/embedded-protocol, path: build/embedded-protocol} + + - name: Generate Dart from protobuf + run: dart run grinder protobuf + env: {UPDATE_SASS_PROTOCOL: false} + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed2c72b72..23a858c64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ name: CI defaults: - run: - shell: bash + run: {shell: bash} env: # Note: when changing this, also change @@ -17,6 +16,48 @@ 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: dart-lang/setup-dart@v1 + - run: dart pub get + + - uses: ./.github/util/build-protobuf + 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: 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 + sass_spec_language: name: "Language Tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.async_label }}" runs-on: ubuntu-latest @@ -34,30 +75,30 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: "${{ matrix.dart_channel }}" + - 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 + with: {repo: sass/sass-spec} - uses: actions/setup-node@v3 - with: - node-version: "${{ env.DEFAULT_NODE_VERSION }}" + with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} - run: npm install working-directory: sass-spec + + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + - name: Run specs run: npm run sass-spec -- --dart .. $extra_args working-directory: sass-spec - env: - extra_args: "${{ matrix.async_args }}" + env: {extra_args: "${{ matrix.async_args }}"} # The versions should be kept up-to-date with the latest LTS Node releases. # They next need to be rotated April 2023. 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: @@ -80,19 +121,19 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: "${{ matrix.dart_channel }}" + - 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 }}" + with: {node-version: "${{ matrix.node_version }}"} - run: npm install + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + - name: Check out sass-spec uses: sass/clone-linked-repo@v1 - with: - repo: sass/sass-spec + with: {repo: sass/sass-spec} - name: Install sass-spec dependencies run: npm install @@ -111,8 +152,74 @@ jobs: 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 April 2023. 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: dart-lang/setup-dart@v1 + with: {sdk: stable} + - run: dart pub get + + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + + - name: Check out the embedded host + uses: sass/clone-linked-repo@v1 + with: {repo: sass/embedded-host-node} + + - name: Check out the JS API definition + uses: sass/clone-linked-repo@v1 + with: {repo: sass/sass, path: language} + + - name: Initialize embedded host + run: | + npm install + npm run init -- --protocol-path=../build/embedded-protocol \ + --compiler-path=.. --api-path=../language + npm run compile + mv {`pwd`/,dist/}lib/src/vendor/dart-sass + working-directory: embedded-host-node + + - name: Check out sass-spec + uses: sass/clone-linked-repo@v1 + with: {repo: sass/sass-spec} + + - name: Install sass-spec dependencies + run: npm install + working-directory: sass-spec + + - 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 ../language + working-directory: sass-spec + sass_spec_js_browser: - name: "JS API Tests | Dart ${{ matrix.dart_channel }} | Browser" + name: "JS API Tests | Pure JS | Dart ${{ matrix.dart_channel }} | Browser" strategy: matrix: @@ -123,11 +230,13 @@ jobs: steps: - uses: actions/checkout@v3 - uses: browser-actions/setup-chrome@v1 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: ${{ matrix.dart_channel }} + - uses: dart-lang/setup-dart@v1 + with: {sdk: "${{ matrix.dart_channel }}"} - run: dart pub get + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + - name: Check out sass-spec uses: sass/clone-linked-repo@v1 with: @@ -170,13 +279,16 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: "${{ matrix.dart_channel }}" + - uses: dart-lang/setup-dart@v1 + with: {sdk: "${{ matrix.dart_channel }}"} - run: dart pub get + + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + - run: dart run grinder pkg-standalone-dev - name: Run tests - run: dart run test -x node -r expanded + run: dart run test -x node # Unit tests that use Node.js, defined in test/. # @@ -207,17 +319,19 @@ jobs: node_version: 18 steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: "${{ matrix.dart_channel }}" + - 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 }}" + with: {node-version: "${{ matrix.node_version }}"} - run: npm install + + - uses: ./.github/util/build-protobuf + with: {github-token: "${{ github.token }}"} + - run: dart run grinder before-test - name: Run tests - run: dart run test -t node -j 2 -r expanded + run: dart run test -t node -j 2 browser-test: name: "Browser Tests | Dart ${{ matrix.dart_channel }}" @@ -231,44 +345,15 @@ jobs: steps: - uses: actions/checkout@v3 - uses: browser-actions/setup-chrome@v1 - - uses: dart-lang/setup-dart@v1.2 - with: - sdk: ${{ matrix.dart_channel }} + - uses: dart-lang/setup-dart@v1 + with: {sdk: "${{ matrix.dart_channel }}"} - run: dart pub get - run: dart run grinder before-test - run: google-chrome --version - - run: dart run test -p chrome -j 2 -r expanded + - run: dart run test -p chrome -j 2 env: CHROME_EXECUTABLE: chrome - static_analysis: - name: Static analysis - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - - run: dart pub get - - name: Analyze Dart - run: dart analyze --fatal-warnings --fatal-infos . - - dartdoc: - name: Dartdoc - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 - - 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 - double_check: name: Double-check runs-on: ubuntu-latest @@ -279,11 +364,12 @@ jobs: - node_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.2 + - uses: dart-lang/setup-dart@v1 - run: dart pub get - name: Run checks run: dart run grinder double-check-before-release @@ -300,11 +386,10 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: dart-lang/setup-dart@v1 - run: dart pub get - run: dart run grinder fetch-bootstrap${{matrix.bootstrap_version}} - env: - GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - name: Build run: dart bin/sass.dart --quiet build/bootstrap/scss:build/bootstrap-output @@ -315,11 +400,10 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: dart-lang/setup-dart@v1 - run: dart pub get - run: dart run grinder fetch-bourbon - env: - GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - name: Test run: | dart bin/sass.dart --quiet -I build/bourbon -I build/bourbon/spec/fixtures \ @@ -332,11 +416,10 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: dart-lang/setup-dart@v1 - run: dart pub get - run: dart run grinder fetch-foundation - env: - GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} # TODO(nweiz): Foundation has proper Sass tests, but they're currently not # compatible with Dart Sass. Once they are, we should run those rather # than just building the CSS output. @@ -350,11 +433,10 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: dart-lang/setup-dart@v1 - run: dart pub get - run: dart run grinder fetch-bulma - env: - GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + env: {GITHUB_BEARER_TOKEN: "${{ secrets.GITHUB_TOKEN }}"} - name: Build run: dart bin/sass.dart --quiet build/bulma/bulma.sass build/bulma-output.css @@ -366,8 +448,11 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: bufbuild/buf-setup-action@v1.13.1 + with: {github_token: "${{ github.token }}"} + - uses: dart-lang/setup-dart@v1 - run: dart pub get + - run: dart run grinder protobuf - name: Deploy run: dart run grinder pkg-github-release pkg-github-linux-ia32 pkg-github-linux-x64 env: @@ -389,6 +474,11 @@ jobs: steps: - uses: actions/checkout@v3 + - uses: bufbuild/buf-setup-action@v1.13.1 + with: {github_token: "${{ github.token }}"} + - uses: dart-lang/setup-dart@v1 + - run: dart pub get + - run: dart run grinder protobuf - uses: docker/setup-qemu-action@v2 - name: Deploy run: | @@ -424,11 +514,14 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: dart-lang/setup-dart@v1.2 + - uses: bufbuild/buf-setup-action@v1.13.1 + with: {github_token: "${{ github.token }}"} + - uses: dart-lang/setup-dart@v1 # Workaround for dart-lang/setup-dart#59 with: architecture: ${{ matrix.architecture }} - run: dart pub get + - run: dart run grinder protobuf - name: Deploy run: dart run grinder pkg-github-${{ matrix.platform }} env: @@ -446,8 +539,7 @@ jobs: - uses: dart-lang/setup-dart@v1.2 - run: dart pub get - uses: actions/setup-node@v3 - with: - node-version: "${{ env.DEFAULT_NODE_VERSION }}" + with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} - name: Deploy run: dart run grinder pkg-npm-deploy env: @@ -464,8 +556,7 @@ jobs: - uses: dart-lang/setup-dart@v1.2 - run: dart pub get - uses: actions/setup-node@v3 - with: - node-version: "${{ env.DEFAULT_NODE_VERSION }}" + with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} - name: Deploy run: dart run grinder update-bazel env: @@ -482,13 +573,12 @@ jobs: - uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1.2 - run: dart pub get + - run: dart run grinder protobuf - uses: actions/setup-node@v3 - with: - node-version: "${{ env.DEFAULT_NODE_VERSION }}" + with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"} - name: Deploy run: dart run grinder pkg-pub-deploy - env: - PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}" + env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} deploy_sub_packages: name: "Deploy Sub-Packages" @@ -535,15 +625,13 @@ jobs: - run: dart pub get - name: Deploy run: dart run grinder pkg-chocolatey-deploy - env: - CHOCOLATEY_TOKEN: "${{ secrets.CHOCOLATEY_TOKEN }}" + env: {CHOCOLATEY_TOKEN: "${{ secrets.CHOCOLATEY_TOKEN }}"} deploy_website: name: "Deploy sass-lang.com" runs-on: ubuntu-latest needs: [bootstrap, bourbon, foundation, bulma] if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - steps: - uses: actions/checkout@v3 with: @@ -557,59 +645,46 @@ jobs: 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] - if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'" - + needs: [deploy_github_linux, deploy_github_linux_qemu, deploy_github] + if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass-embedded'" 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.2 - - uses: frenck/action-setup-yq@v1 - with: - version: v4.30.5 # frenck/action-setup-yq#35 - name: Get version id: version - run: | - echo "sass=${GITHUB_REF##*/}" | tee --append $GITHUB_OUTPUT - echo "sass_api=$(curl --fail --silent --show-error --location https://raw.githubusercontent.com/sass/dart-sass/${GITHUB_REF##*/}/pkg/sass_api/pubspec.yaml | yq .version)" | tee --append $GITHUB_OUTPUT + run: echo "::set-output name=version::${GITHUB_REF##*/}" - name: Update version run: | - sed -i 's/version: .*/version: ${{ steps.version.outputs.sass }}/' pubspec.yaml - dart pub remove sass_api - dart pub remove sass - dart pub add sass:${{ steps.version.outputs.sass }} - dart pub add sass_api:^${{ steps.version.outputs.sass_api }} - - # 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 - - curl --fail --silent --show-error --location --output CHANGELOG.md https://raw.githubusercontent.com/sass/dart-sass/${{ steps.version.outputs.sass }}/CHANGELOG.md + # 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 with: author_name: Sass Bot author_email: sass.bot.beep.boop@gmail.com message: Update Dart Sass version and release - tag: ${{ steps.version.outputs.sass }} + tag: ${{ steps.version.outputs.version }} 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/CHANGELOG.md b/CHANGELOG.md index 74dd0ba27..6c13c2681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 1.63.0 + +### 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. + ## 1.62.1 * Fix a bug where `:has(+ &)` and related constructs would drop the leading diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de99ee93c..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/ diff --git a/README.md b/README.md index c185dc71b..df46ad10e 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**. @@ -192,10 +192,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,12 +216,14 @@ commands: ```Dockerfile # Dart stage FROM dart:stable AS dart +FROM buildbuf/buf AS buf COPY --from=another_stage /app /app WORKDIR /dart-sass RUN git clone https://github.com/sass/dart-sass.git . && \ dart pub get && \ + dart run grinder protobuf && \ dart ./bin/sass.dart /app/sass/example.scss /app/public/css/example.css ``` @@ -299,6 +310,22 @@ 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-embedded-protocol/blob/master/README.md#readme + +### Usage + +* `sass --embedded` starts the embedded compiler and listens on stdin. +* `sass --embedded --version` prints `versionResponse` with `id = 0` in JSON and + exits. + +No other command-line flags are supported with `--embedded`. + ## Behavioral Differences from Ruby Sass There are a few intentional behavioral differences between Dart Sass and Ruby 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 9ca390a96..06c6b17d6 100644 --- a/bin/sass.dart +++ b/bin/sass.dart @@ -18,6 +18,10 @@ import 'package:sass/src/io.dart'; import 'package:sass/src/logger/deprecation_handling.dart'; import 'package:sass/src/stylesheet_graph.dart'; import 'package:sass/src/utils.dart'; +import 'package:sass/src/embedded/executable.dart' + // 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; @@ -37,6 +41,11 @@ Future main(List args) async { } } + if (args[0] == '--embedded') { + embedded.main(args.sublist(1)); + return; + } + ExecutableOptions? options; try { options = ExecutableOptions.parse(args); 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..0cc295bc9 --- /dev/null +++ b/buf.work.yaml @@ -0,0 +1,2 @@ +version: v1 +directories: [build/embedded-protocol] diff --git a/lib/sass.dart b/lib/sass.dart index 1c2acc016..a292fa212 100644 --- a/lib/sass.dart +++ b/lib/sass.dart @@ -326,8 +326,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, @@ -377,8 +376,7 @@ String compile( /// /// {@category Compile} @Deprecated("Use compileStringToResult() instead.") -String compileString( - String source, +String compileString(String source, {Syntax? syntax, bool color = false, Logger? logger, @@ -422,8 +420,7 @@ String compileString( /// /// {@category Compile} @Deprecated("Use compileToResultAsync() instead.") -Future compileAsync( - String path, +Future compileAsync(String path, {bool color = false, Logger? logger, Iterable? importers, @@ -457,8 +454,7 @@ Future compileAsync( /// /// {@category Compile} @Deprecated("Use compileStringToResultAsync() instead.") -Future compileStringAsync( - String source, +Future compileStringAsync(String source, {Syntax? syntax, bool color = false, Logger? logger, diff --git a/lib/src/embedded/dispatcher.dart b/lib/src/embedded/dispatcher.dart new file mode 100644 index 000000000..a895b9f03 --- /dev/null +++ b/lib/src/embedded/dispatcher.dart @@ -0,0 +1,252 @@ +// 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:io'; +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:stream_channel/stream_channel.dart'; + +import 'embedded_sass.pb.dart'; +import 'utils.dart'; + +/// A class that dispatches messages to and from the host. +class Dispatcher { + /// The channel of encoded protocol buffers, connected to the host. + final StreamChannel _channel; + + /// Completers awaiting responses to outbound requests. + /// + /// The completers are located at indexes in this list matching the request + /// IDs. `null` elements indicate IDs whose requests have been responded to, + /// and which are therefore free to re-use. + final _outstandingRequests = ?>[]; + + /// Creates a [Dispatcher] that sends and receives encoded protocol buffers + /// over [channel]. + Dispatcher(this._channel); + + /// Listens for incoming `CompileRequests` and passes them to [callback]. + /// + /// The callback must return a `CompileResponse` which is sent to the host. + /// The callback may throw [ProtocolError]s, which will be sent back to the + /// host. Neither `CompileResponse`s nor [ProtocolError]s need to set their + /// `id` fields; the [Dispatcher] will take care of that. + /// + /// This may only be called once. + void listen( + FutureOr callback( + InboundMessage_CompileRequest request)) { + _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(); + + InboundMessage? message; + try { + try { + message = InboundMessage.fromBuffer(binaryMessage); + } on InvalidProtocolBufferException catch (error) { + throw _parseError(error.message); + } + + switch (message.whichMessage()) { + case InboundMessage_Message.versionRequest: + var request = message.versionRequest; + var response = versionResponse(); + response.id = request.id; + _send(OutboundMessage()..versionResponse = response); + break; + + case InboundMessage_Message.compileRequest: + var request = message.compileRequest; + var response = await callback(request); + response.id = request.id; + _send(OutboundMessage()..compileResponse = response); + break; + + case InboundMessage_Message.canonicalizeResponse: + var response = message.canonicalizeResponse; + _dispatchResponse(response.id, response); + break; + + case InboundMessage_Message.importResponse: + var response = message.importResponse; + _dispatchResponse(response.id, response); + break; + + case InboundMessage_Message.fileImportResponse: + var response = message.fileImportResponse; + _dispatchResponse(response.id, response); + break; + + case InboundMessage_Message.functionCallResponse: + var response = message.functionCallResponse; + _dispatchResponse(response.id, response); + break; + + case InboundMessage_Message.notSet: + throw _parseError("InboundMessage.message is not set."); + + default: + throw _parseError( + "Unknown message type: ${message.toDebugString()}"); + } + } on ProtocolError catch (error) { + error.id = _inboundId(message) ?? errorId; + stderr.write("Host caused ${error.type.name.toLowerCase()} error"); + if (error.id != errorId) stderr.write(" with request ${error.id}"); + stderr.writeln(": ${error.message}"); + sendError(error); + // PROTOCOL error from https://bit.ly/2poTt90 + exitCode = 76; + _channel.sink.close(); + } catch (error, stackTrace) { + var errorMessage = "$error\n${Chain.forTrace(stackTrace)}"; + stderr.write("Internal compiler error: $errorMessage"); + sendError(ProtocolError() + ..type = ProtocolErrorType.INTERNAL + ..id = _inboundId(message) ?? errorId + ..message = errorMessage); + _channel.sink.close(); + } + }); + } + + /// 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 { + var id = _nextRequestId(); + _setOutboundId(request, id); + _send(request); + + var completer = Completer(); + _outstandingRequests[id] = completer; + return completer.future; + } + + /// Returns an available request ID, and guarantees that its slot is available + /// in [_outstandingRequests]. + int _nextRequestId() { + for (var i = 0; i < _outstandingRequests.length; i++) { + if (_outstandingRequests[i] == null) return i; + } + + // If there are no empty slots, add another one. + _outstandingRequests.add(null); + return _outstandingRequests.length - 1; + } + + /// 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) { + Completer? completer; + if (id < _outstandingRequests.length) { + completer = _outstandingRequests[id]; + _outstandingRequests[id] = null; + } + + if (completer == null) { + throw paramsError( + "Response ID $id doesn't match any outstanding requests."); + } else if (completer is! Completer) { + throw paramsError("Request ID $id doesn't match response type " + "${response.runtimeType}."); + } + + completer.complete(response); + } + + /// Sends [message] to the host. + void _send(OutboundMessage message) => + _channel.sink.add(message.writeToBuffer()); + + /// Returns a [ProtocolError] with type `PARSE` and the given [message]. + ProtocolError _parseError(String message) => ProtocolError() + ..type = ProtocolErrorType.PARSE + ..message = message; + + /// Returns the id for [message] if it it's a request, or `null` + /// otherwise. + int? _inboundId(InboundMessage? message) { + if (message == null) return null; + switch (message.whichMessage()) { + case InboundMessage_Message.compileRequest: + return message.compileRequest.id; + default: + return null; + } + } + + /// Sets the id for [message] to [id]. + /// + /// Throws an [ArgumentError] if [message] doesn't have an id field. + void _setOutboundId(OutboundMessage message, int id) { + switch (message.whichMessage()) { + case OutboundMessage_Message.compileResponse: + message.compileResponse.id = id; + break; + case OutboundMessage_Message.canonicalizeRequest: + message.canonicalizeRequest.id = id; + break; + case OutboundMessage_Message.importRequest: + message.importRequest.id = id; + break; + case OutboundMessage_Message.fileImportRequest: + message.fileImportRequest.id = id; + break; + case OutboundMessage_Message.functionCallRequest: + message.functionCallRequest.id = id; + break; + case OutboundMessage_Message.versionResponse: + message.versionResponse.id = id; + break; + default: + throw ArgumentError("Unknown message type: ${message.toDebugString()}"); + } + } + + /// 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"; + } +} diff --git a/lib/src/embedded/executable.dart b/lib/src/embedded/executable.dart new file mode 100644 index 000000000..3ee7eaf5a --- /dev/null +++ b/lib/src/embedded/executable.dart @@ -0,0 +1,159 @@ +// 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:path/path.dart' as p; +import 'package:stream_channel/stream_channel.dart'; + +import '../../sass.dart'; +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart' as proto; +import 'embedded_sass.pb.dart' hide OutputStyle; +import 'function_registry.dart'; +import 'host_callable.dart'; +import 'importer/file.dart'; +import 'importer/host.dart'; +import 'logger.dart'; +import 'util/length_delimited_transformer.dart'; +import 'utils.dart'; + +void main(List args) { + if (args.isNotEmpty) { + if (args.first == "--version") { + var response = Dispatcher.versionResponse(); + response.id = 0; + stdout.writeln( + JsonEncoder.withIndent(" ").convert(response.toProto3Json())); + return; + } + + 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; + } + + var dispatcher = Dispatcher( + StreamChannel.withGuarantees(stdin, stdout, allowSinkErrors: false) + .transform(lengthDelimited)); + + dispatcher.listen((request) async { + var functions = FunctionRegistry(); + + var style = request.style == proto.OutputStyle.COMPRESSED + ? OutputStyle.compressed + : OutputStyle.expanded; + var logger = EmbeddedLogger(dispatcher, request.id, + color: request.alertColor, ascii: request.alertAscii); + + try { + var importers = request.importers.map((importer) => + _decodeImporter(dispatcher, request, importer) ?? + (throw mandatoryError("Importer.importer"))); + + var globalFunctions = request.globalFunctions.map((signature) => + hostCallable(dispatcher, functions, request.id, signature)); + + late CompileResult result; + switch (request.whichInput()) { + case InboundMessage_CompileRequest_Input.string: + var input = request.string; + result = compileStringToResult(input.source, + color: request.alertColor, + logger: logger, + importers: importers, + importer: _decodeImporter(dispatcher, request, input.importer) ?? + (input.url.startsWith("file:") ? null : 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 = 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 + ..loadedUrls.addAll(result.loadedUrls.map((url) => url.toString())); + + var sourceMap = result.sourceMap; + if (sourceMap != null) { + success.sourceMap = json.encode(sourceMap.toJson( + includeSourceContents: request.sourceMapIncludeSources)); + } + return OutboundMessage_CompileResponse()..success = success; + } on 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); + } + }); +} + +/// Converts [importer] into a [Importer]. +Importer? _decodeImporter( + Dispatcher dispatcher, + InboundMessage_CompileRequest request, + InboundMessage_CompileRequest_Importer importer) { + switch (importer.whichImporter()) { + case InboundMessage_CompileRequest_Importer_Importer.path: + return FilesystemImporter(importer.path); + + case InboundMessage_CompileRequest_Importer_Importer.importerId: + return HostImporter(dispatcher, request.id, importer.importerId); + + case InboundMessage_CompileRequest_Importer_Importer.fileImporterId: + return FileImporter(dispatcher, request.id, importer.fileImporterId); + + case InboundMessage_CompileRequest_Importer_Importer.notSet: + return null; + } +} diff --git a/lib/src/embedded/function_registry.dart b/lib/src/embedded/function_registry.dart new file mode 100644 index 000000000..98cd2f6e0 --- /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. +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..e9f20036e --- /dev/null +++ b/lib/src/embedded/host_callable.dart @@ -0,0 +1,64 @@ +// 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 'dart:io'; + +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, + int compilationId, String signature, + {int? id}) { + late Callable callable; + callable = Callable.fromSignature(signature, (arguments) { + var protofier = Protofier(dispatcher, functions, compilationId); + var request = OutboundMessage_FunctionCallRequest() + ..compilationId = compilationId + ..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) { + error.id = errorId; + stderr.writeln("Host caused ${error.type.name.toLowerCase()} error: " + "${error.message}"); + dispatcher.sendError(error); + 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..0fac89ef9 --- /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 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..13de17f7a --- /dev/null +++ b/lib/src/embedded/importer/file.dart @@ -0,0 +1,64 @@ +// 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. +class FileImporter extends ImporterBase { + /// The ID of the compilation in which this importer is used. + final int _compilationId; + + /// The host-provided ID of the importer to invoke. + final int _importerId; + + FileImporter(Dispatcher dispatcher, this._compilationId, 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() + ..compilationId = _compilationId + ..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..705ed0258 --- /dev/null +++ b/lib/src/embedded/importer/host.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. + +// 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. +class HostImporter extends ImporterBase { + /// The ID of the compilation in which this importer is used. + final int _compilationId; + + /// The host-provided ID of the importer to invoke. + final int _importerId; + + HostImporter(Dispatcher dispatcher, this._compilationId, this._importerId) + : super(dispatcher); + + Uri? canonicalize(Uri url) { + // ignore: deprecated_member_use + return waitFor(() async { + var response = await dispatcher + .sendCanonicalizeRequest(OutboundMessage_CanonicalizeRequest() + ..compilationId = _compilationId + ..importerId = _importerId + ..url = url.toString() + ..fromImport = fromImport); + + switch (response.whichResult()) { + case InboundMessage_CanonicalizeResponse_Result.url: + return parseAbsoluteUrl("The importer", response.url); + + case InboundMessage_CanonicalizeResponse_Result.error: + throw response.error; + + case InboundMessage_CanonicalizeResponse_Result.notSet: + return null; + } + }()); + } + + ImporterResult? load(Uri url) { + // ignore: deprecated_member_use + return waitFor(() async { + var response = + await dispatcher.sendImportRequest(OutboundMessage_ImportRequest() + ..compilationId = _compilationId + ..importerId = _importerId + ..url = url.toString()); + + switch (response.whichResult()) { + case InboundMessage_ImportResponse_Result.success: + return ImporterResult(response.success.contents, + sourceMapUrl: response.success.sourceMapUrl.isEmpty + ? null + : parseAbsoluteUrl( + "The importer", response.success.sourceMapUrl), + syntax: syntaxToSyntax(response.success.syntax)); + + case InboundMessage_ImportResponse_Result.error: + throw response.error; + + case InboundMessage_ImportResponse_Result.notSet: + return null; + } + }()); + } + + String toString() => "HostImporter"; +} diff --git a/lib/src/embedded/logger.dart b/lib/src/embedded/logger.dart new file mode 100644 index 000000000..f84b31fb9 --- /dev/null +++ b/lib/src/embedded/logger.dart @@ -0,0 +1,85 @@ +// 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 '../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. +class EmbeddedLogger implements Logger { + /// The [Dispatcher] to which to send events. + final Dispatcher _dispatcher; + + /// The ID of the compilation to which this logger is passed. + final int _compilationId; + + /// 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, this._compilationId, + {bool color = false, bool ascii = false}) + : _color = color, + _ascii = ascii; + + void debug(String message, SourceSpan span) { + var url = + span.start.sourceUrl == null ? '-' : p.prettyUri(span.start.sourceUrl); + var buffer = StringBuffer() + ..write('$url:${span.start.line + 1} ') + ..write(_color ? '\u001b[1mDebug\u001b[0m' : 'DEBUG') + ..writeln(': $message'); + + _dispatcher.sendLog(OutboundMessage_LogEvent() + ..compilationId = _compilationId + ..type = LogEventType.DEBUG + ..message = message + ..span = protofySpan(span) + ..formatted = buffer.toString()); + } + + 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() + ..compilationId = _compilationId + ..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..796fb7f6f --- /dev/null +++ b/lib/src/embedded/protofier.dart @@ -0,0 +1,433 @@ +// 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.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. +class Protofier { + /// The dispatcher, for invoking deprotofied [Value_HostFunction]s. + final Dispatcher _dispatcher; + + /// The IDs of first-class functions. + final FunctionRegistry _functions; + + /// The ID of the current compilation. + final int _compilationId; + + /// 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, this._compilationId); + + /// Converts [value] to its protocol buffer representation. + proto.Value protofy(Value value) { + var result = proto.Value(); + if (value is SassString) { + result.string = Value_String() + ..text = value.text + ..quoted = value.hasQuotes; + } else if (value is SassNumber) { + result.number = _protofyNumber(value); + } else if (value is SassColor) { + if (value.hasCalculatedHsl) { + result.hslColor = Value_HslColor() + ..hue = value.hue * 1.0 + ..saturation = value.saturation * 1.0 + ..lightness = value.lightness * 1.0 + ..alpha = value.alpha * 1.0; + } else { + result.rgbColor = Value_RgbColor() + ..red = value.red + ..green = value.green + ..blue = value.blue + ..alpha = value.alpha * 1.0; + } + } else if (value is SassArgumentList) { + _argumentLists.add(value); + var argList = Value_ArgumentList() + ..id = _argumentLists.length + ..separator = _protofySeparator(value.separator) + ..contents.addAll([for (var element in value.asList) protofy(element)]); + value.keywordsWithoutMarking.forEach((key, value) { + argList.keywords[key] = protofy(value); + }); + + result.argumentList = argList; + } else if (value is SassList) { + result.list = Value_List() + ..separator = _protofySeparator(value.separator) + ..hasBrackets = value.hasBrackets + ..contents.addAll([for (var element in value.asList) protofy(element)]); + } else if (value is SassMap) { + var map = Value_Map(); + value.contents.forEach((key, value) { + map.entries.add(Value_Map_Entry() + ..key = protofy(key) + ..value = protofy(value)); + }); + result.map = map; + } else if (value is SassCalculation) { + result.calculation = _protofyCalculation(value); + } else if (value is SassFunction) { + result.compilerFunction = _functions.protofy(value); + } else if (value == sassTrue) { + result.singleton = SingletonValue.TRUE; + } else if (value == sassFalse) { + result.singleton = SingletonValue.FALSE; + } else if (value == sassNull) { + result.singleton = SingletonValue.NULL; + } else { + throw "Unknown Value $value"; + } + return result; + } + + /// Converts [number] to its protocol buffer representation. + Value_Number _protofyNumber(SassNumber number) { + var value = Value_Number()..value = number.value * 1.0; + value.numerators.addAll(number.numeratorUnits); + value.denominators.addAll(number.denominatorUnits); + return value; + } + + /// Converts [separator] to its protocol buffer representation. + proto.ListSeparator _protofySeparator(ListSeparator separator) { + switch (separator) { + case ListSeparator.comma: + return proto.ListSeparator.COMMA; + case ListSeparator.space: + return proto.ListSeparator.SPACE; + case ListSeparator.slash: + return proto.ListSeparator.SLASH; + case ListSeparator.undecided: + return proto.ListSeparator.UNDECIDED; + default: + throw "Unknown ListSeparator $separator"; + } + } + + /// Converts [calculation] to its protocol buffer representation. + Value_Calculation _protofyCalculation(SassCalculation calculation) => + Value_Calculation() + ..name = calculation.name + ..arguments.addAll([ + for (var argument in calculation.arguments) + _protofyCalculationValue(argument) + ]); + + /// 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(); + if (value is SassNumber) { + result.number = _protofyNumber(value); + } else if (value is SassCalculation) { + result.calculation = _protofyCalculation(value); + } else if (value is SassString) { + result.string = value.text; + } else if (value is CalculationOperation) { + result.operation = Value_Calculation_CalculationOperation() + ..operator = _protofyCalculationOperator(value.operator) + ..left = _protofyCalculationValue(value.left) + ..right = _protofyCalculationValue(value.right); + } else if (value is CalculationInterpolation) { + result.interpolation = value.value; + } else { + throw "Unknown calculation value $value"; + } + return result; + } + + /// Converts [operator] to its protocol buffer representation. + proto.CalculationOperator _protofyCalculationOperator( + CalculationOperator operator) { + switch (operator) { + case CalculationOperator.plus: + return proto.CalculationOperator.PLUS; + case CalculationOperator.minus: + return proto.CalculationOperator.MINUS; + case CalculationOperator.times: + return proto.CalculationOperator.TIMES; + case CalculationOperator.dividedBy: + return proto.CalculationOperator.DIVIDE; + default: + throw "Unknown CalculationOperator $operator"; + } + } + + /// 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([ + for (var element in value.argumentList.contents) _deprotofy(element) + ], { + for (var entry in value.argumentList.keywords.entries) + entry.key: _deprotofy(entry.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([ + for (var element in value.list.contents) _deprotofy(element) + ], separator, brackets: value.list.hasBrackets); + + case Value_Value.map: + return value.map.entries.isEmpty + ? const SassMap.empty() + : SassMap({ + for (var entry in value.map.entries) + _deprotofy(entry.key): _deprotofy(entry.value) + }); + + case Value_Value.compilerFunction: + var id = value.compilerFunction.id; + var function = _functions[id]; + if (function == null) { + throw paramsError( + "CompilerFunction.id $id doesn't match any known functions"); + } + + return function; + + case Value_Value.hostFunction: + return SassFunction(hostCallable(_dispatcher, _functions, + _compilationId, value.hostFunction.signature, + id: value.hostFunction.id)); + + case Value_Value.calculation: + return _deprotofyCalculation(value.calculation); + + case Value_Value.singleton: + switch (value.singleton) { + case SingletonValue.TRUE: + return sassTrue; + case SingletonValue.FALSE: + return sassFalse; + case SingletonValue.NULL: + return sassNull; + default: + 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) { + case proto.ListSeparator.COMMA: + return ListSeparator.comma; + case proto.ListSeparator.SPACE: + return ListSeparator.space; + case proto.ListSeparator.SLASH: + return ListSeparator.slash; + case proto.ListSeparator.UNDECIDED: + return ListSeparator.undecided; + default: + throw "Unknown separator $separator"; + } + } + + /// Converts [calculation] to its Sass representation. + Value _deprotofyCalculation(Value_Calculation calculation) { + if (calculation.name == "calc") { + if (calculation.arguments.length != 1) { + throw paramsError( + "Value.Calculation.arguments must have exactly one argument for " + "calc()."); + } + + return SassCalculation.calc( + _deprotofyCalculationValue(calculation.arguments[0])); + } else if (calculation.name == "clamp") { + if (calculation.arguments.length != 3) { + throw paramsError( + "Value.Calculation.arguments must have exactly 3 arguments for " + "clamp()."); + } + + return SassCalculation.clamp( + _deprotofyCalculationValue(calculation.arguments[0]), + _deprotofyCalculationValue(calculation.arguments[1]), + _deprotofyCalculationValue(calculation.arguments[2])); + } else if (calculation.name == "min") { + if (calculation.arguments.isEmpty) { + throw paramsError( + "Value.Calculation.arguments must have at least 1 argument for " + "min()."); + } + + return SassCalculation.min( + calculation.arguments.map(_deprotofyCalculationValue)); + } else if (calculation.name == "max") { + if (calculation.arguments.isEmpty) { + throw paramsError( + "Value.Calculation.arguments must have at least 1 argument for " + "max()."); + } + + return SassCalculation.max( + calculation.arguments.map(_deprotofyCalculationValue)); + } else { + 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()) { + case Value_Calculation_CalculationValue_Value.number: + return _deprotofyNumber(value.number); + + case Value_Calculation_CalculationValue_Value.calculation: + return _deprotofyCalculation(value.calculation); + + case Value_Calculation_CalculationValue_Value.string: + return SassString(value.string, quotes: false); + + case Value_Calculation_CalculationValue_Value.operation: + return SassCalculation.operate( + _deprotofyCalculationOperator(value.operation.operator), + _deprotofyCalculationValue(value.operation.left), + _deprotofyCalculationValue(value.operation.right)); + + case Value_Calculation_CalculationValue_Value.interpolation: + return CalculationInterpolation(value.interpolation); + + case Value_Calculation_CalculationValue_Value.notSet: + throw mandatoryError("Value.Calculation.value"); + } + } + + /// Converts [operator] to its Sass representation. + CalculationOperator _deprotofyCalculationOperator( + proto.CalculationOperator operator) { + switch (operator) { + case proto.CalculationOperator.PLUS: + return CalculationOperator.plus; + case proto.CalculationOperator.MINUS: + return CalculationOperator.minus; + case proto.CalculationOperator.TIMES: + return CalculationOperator.times; + case proto.CalculationOperator.DIVIDE: + return CalculationOperator.dividedBy; + default: + throw "Unknown CalculationOperator $operator"; + } + } +} diff --git a/lib/src/embedded/unavailable.dart b/lib/src/embedded/unavailable.dart new file mode 100644 index 000000000..063507227 --- /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 { + stderr.writeln('sass --embedded is unavailable in pure JS mode.'); + exitCode = 1; +} 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..20bc33c98 --- /dev/null +++ b/lib/src/embedded/util/length_delimited_transformer.dart @@ -0,0 +1,132 @@ +// 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 'package:typed_data/typed_data.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 number of bits we've consumed so far to fill out [nextMessageLength]. + var nextMessageLengthBits = 0; + + // The length of the next message, in bytes. + // + // This is built up from a [varint]. Once it's fully consumed, [buffer] is + // initialized. + // + // [varint]: https://developers.google.com/protocol-buffers/docs/encoding#varints + var nextMessageLength = 0; + + // 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 byte = chunk[i]; + + // Varints encode data in the 7 lower bits of each byte, which we access + // by masking with 0x7f = 0b01111111. + nextMessageLength += (byte & 0x7f) << nextMessageLengthBits; + nextMessageLengthBits += 7; + i++; + + // 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 message length. + if (byte > 0x7f) continue; + + // Otherwise, [nextMessageLength] is now finalized and we can allocate + // the data buffer. + buffer_ = buffer = Uint8List(nextMessageLength); + 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 < nextMessageLength) return; + + // Once we've filled the buffer, emit it and reset our state. + sink.add(buffer_); + nextMessageLength = 0; + nextMessageLengthBits = 0; + 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; + } + + // Write the length in varint format, 7 bits at a time from least to most + // significant. + var lengthBuffer = Uint8Buffer(); + while (length > 0) { + // The highest-order bit indicates whether more bytes are necessary to fully + // express the number. The lower 7 bits indicate the number's value. + lengthBuffer.add((length > 0x7f ? 0x80 : 0) | (length & 0x7f)); + length >>= 7; + } + + sink.add(Uint8List.view(lengthBuffer.buffer, 0, lengthBuffer.length)); + sink.add(message); +}); diff --git a/lib/src/embedded/utils.dart b/lib/src/embedded/utils.dart new file mode 100644 index 000000000..123c8d23c --- /dev/null +++ b/lib/src/embedded/utils.dart @@ -0,0 +1,70 @@ +// 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 '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; + +/// 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; + +/// 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) { + case proto.Syntax.SCSS: + return Syntax.scss; + case proto.Syntax.INDENTED: + return Syntax.sass; + case proto.Syntax.CSS: + return Syntax.css; + default: + 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; +} diff --git a/lib/src/embedded/value.dart b/lib/src/embedded/value.dart new file mode 100644 index 000000000..fcbe99d93 --- /dev/null +++ b/lib/src/embedded/value.dart @@ -0,0 +1,202 @@ +// 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.dart'; +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart' as proto; +import 'embedded_sass.pb.dart' hide Value, ListSeparator; +import 'function_registry.dart'; +import 'host_callable.dart'; +import 'utils.dart'; + +/// Converts [value] to its protocol buffer representation. +/// +/// The [functions] tracks the IDs of first-class functions so that the host can +/// pass them back to the compiler. +proto.Value protofyValue(FunctionRegistry functions, Value value) { + var result = proto.Value(); + if (value is SassString) { + result.string = Value_String() + ..text = value.text + ..quoted = value.hasQuotes; + } else if (value is SassNumber) { + var number = Value_Number()..value = value.value * 1.0; + number.numerators.addAll(value.numeratorUnits); + number.denominators.addAll(value.denominatorUnits); + result.number = number; + } else if (value is SassColor) { + // TODO(nweiz): If the color is represented as HSL internally, this coerces + // it to RGB. Is it worth providing some visibility into its internal + // representation so we can serialize without converting? + result.rgbColor = Value_RgbColor() + ..red = value.red + ..green = value.green + ..blue = value.blue + ..alpha = value.alpha * 1.0; + } else if (value is SassList) { + var list = Value_List() + ..separator = _protofySeparator(value.separator) + ..hasBrackets = value.hasBrackets + ..contents.addAll( + [for (var element in value.asList) protofyValue(functions, element)]); + result.list = list; + } else if (value is SassMap) { + var map = Value_Map(); + value.contents.forEach((key, value) { + map.entries.add(Value_Map_Entry() + ..key = protofyValue(functions, key) + ..value = protofyValue(functions, value)); + }); + result.map = map; + } else if (value is SassFunction) { + result.compilerFunction = functions.protofy(value); + } else if (value == sassTrue) { + result.singleton = SingletonValue.TRUE; + } else if (value == sassFalse) { + result.singleton = SingletonValue.FALSE; + } else if (value == sassNull) { + result.singleton = SingletonValue.NULL; + } else { + throw "Unknown Value $value"; + } + return result; +} + +/// Converts [separator] to its protocol buffer representation. +proto.ListSeparator _protofySeparator(ListSeparator separator) { + switch (separator) { + case ListSeparator.comma: + return proto.ListSeparator.COMMA; + case ListSeparator.space: + return proto.ListSeparator.SPACE; + case ListSeparator.slash: + return proto.ListSeparator.SLASH; + case ListSeparator.undecided: + return proto.ListSeparator.UNDECIDED; + default: + throw "Unknown ListSeparator $separator"; + } +} + +/// Converts [value] to its Sass representation. +/// +/// The [functions] tracks the IDs of first-class functions so that they can be +/// deserialized to their original references. +Value deprotofyValue(Dispatcher dispatcher, FunctionRegistry functions, + int compilationId, proto.Value value) { + // Curry recursive calls to this function so we don't have to keep repeating + // ourselves. + deprotofy(proto.Value value) => + deprotofyValue(dispatcher, functions, compilationId, 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 SassNumber.withUnits(value.number.value, + numeratorUnits: value.number.numerators, + denominatorUnits: value.number.denominators); + + 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.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([ + for (var element in value.list.contents) deprotofy(element) + ], separator, brackets: value.list.hasBrackets); + + case Value_Value.map: + return value.map.entries.isEmpty + ? const SassMap.empty() + : SassMap({ + for (var entry in value.map.entries) + deprotofy(entry.key): deprotofy(entry.value) + }); + + case Value_Value.compilerFunction: + var id = value.compilerFunction.id; + var function = functions[id]; + if (function == null) { + throw paramsError( + "CompilerFunction.id $id doesn't match any known functions"); + } + + return function; + + case Value_Value.hostFunction: + return SassFunction(hostCallable( + dispatcher, functions, compilationId, value.hostFunction.signature, + id: value.hostFunction.id)); + + case Value_Value.singleton: + switch (value.singleton) { + case SingletonValue.TRUE: + return sassTrue; + case SingletonValue.FALSE: + return sassFalse; + case SingletonValue.NULL: + return sassNull; + default: + throw "Unknown Value.singleton ${value.singleton}"; + } + + case Value_Value.notSet: + default: + 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 [separator] to its Sass representation. +ListSeparator _deprotofySeparator(proto.ListSeparator separator) { + switch (separator) { + case proto.ListSeparator.COMMA: + return ListSeparator.comma; + case proto.ListSeparator.SPACE: + return ListSeparator.space; + case proto.ListSeparator.SLASH: + return ListSeparator.slash; + case proto.ListSeparator.UNDECIDED: + return ListSeparator.undecided; + default: + throw "Unknown separator $separator"; + } +} diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 37a4c7981..bb249278e 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.0.1 + +* No user-visible changes. + ## 7.0.0 * Silent comments in SCSS that are separated by blank lines are now parsed as diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 07a5109dd..ac10b5e3b 100644 --- a/pkg/sass_api/pubspec.yaml +++ b/pkg/sass_api/pubspec.yaml @@ -2,7 +2,7 @@ name: sass_api # Note: Every time we add a new Sass AST node, we need to bump the *major* # version because it's a breaking change for anyone who's implementing the # visitor interface(s). -version: 7.0.0 +version: 7.0.1-dev description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=2.17.0 <3.0.0" dependencies: - sass: 1.62.1 + sass: 1.63.0 dev_dependencies: dartdoc: ^5.0.0 diff --git a/pubspec.yaml b/pubspec.yaml index 61ca84a8d..80ad2074e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.62.1 +version: 1.63.0-dev description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass @@ -16,21 +16,24 @@ dependencies: charcode: ^1.2.0 cli_repl: ^0.2.1 collection: ^1.16.0 + http: ^0.13.3 + 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 + protobuf: ^2.0.0 pub_semver: ^2.0.0 source_maps: ^0.10.10 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 @@ -42,9 +45,9 @@ dev_dependencies: grinder: ^0.9.0 node_preamble: ^2.0.2 lints: ^2.0.0 + protoc_plugin: ^20.0.0 pub_api_client: ^2.1.1 pubspec_parse: ^1.0.0 - stream_channel: ^2.1.0 test: ^1.16.7 test_descriptor: ^2.0.0 test_process: ^2.0.0 diff --git a/test/embedded/embedded_process.dart b/test/embedded/embedded_process.dart new file mode 100644 index 000000000..3d4a394f7 --- /dev/null +++ b/test/embedded/embedded_process.dart @@ -0,0 +1,186 @@ +// 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 '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/util/length_delimited_transformer.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. + StreamQueue get outbound => _outbound; + late StreamQueue _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 _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. + final Sink 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((message) => OutboundMessage.fromBuffer(message))), + _stderrSplitter = StreamSplitter(process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter())), + inbound = StreamSinkTransformer>.fromHandlers( + handleData: (message, sink) => + sink.add(message.writeToBuffer())).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((message) { + for (var line in message.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()); + } + + /// 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..2bc9ef47a --- /dev/null +++ b/test/embedded/file_importer_test.dart @@ -0,0 +1,289 @@ +// 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. + +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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + request = getFileImportRequest(await process.outbound.next); + }); + + test("for a response without a corresponding request ID", () async { + process.inbound.add(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id + 1)); + + await expectParamsError( + process, + errorId, + "Response ID ${request.id + 1} doesn't match any outstanding " + "requests."); + await process.kill(); + }); + + test("for a response that doesn't match the request type", () async { + process.inbound.add(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + + await expectParamsError( + process, + errorId, + "Request ID ${request.id} doesn't match response type " + "InboundMessage_CanonicalizeResponse."); + await process.kill(); + }); + }); + + group("emits a compile failure", () { + late OutboundMessage_FileImportRequest request; + + setUp(() async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + request = getFileImportRequest(await process.outbound.next); + }); + + group("for a FileImportResponse with a URL", () { + test("that's empty", () async { + process.inbound.add(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = "")); + + await _expectImportError( + process, 'The file importer must return an absolute URL, was ""'); + await process.kill(); + }); + + test("that's relative", () async { + process.inbound.add(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = "foo")); + + await _expectImportError(process, + 'The file importer must return an absolute URL, was "foo"'); + await process.kill(); + }); + + test("that's not file:", () async { + process.inbound.add(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.kill(); + }); + }); + }); + + group("includes in FileImportRequest", () { + var compilationId = 1234; + var importerId = 5679; + late OutboundMessage_FileImportRequest request; + setUp(() async { + process.inbound.add( + compileString("@import 'other'", id: compilationId, importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = importerId + ])); + request = getFileImportRequest(await process.outbound.next); + }); + + test("the same compilationId as the compilation", () async { + expect(request.compilationId, equals(compilationId)); + await process.kill(); + }); + + 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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + var request = getFileImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..error = "oh no")); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals('oh no')); + expect(failure.span.text, equals("'other'")); + expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + await process.kill(); + }); + + test("null results count as not found", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..fileImporterId = 1 + ])); + + var request = getFileImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.kill(); + }); + + group("attempts importers in order", () { + test("with multiple file importers", () async { + process.inbound.add(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 = getFileImportRequest(await process.outbound.next); + expect(request.importerId, equals(i)); + process.inbound.add(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } + + await process.kill(); + }); + + test("with a mixture of file and normal importers", () async { + process.inbound.add(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 = getFileImportRequest(await process.outbound.next); + expect(request.importerId, equals(i)); + process.inbound.add(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } else { + var request = getCanonicalizeRequest(await process.outbound.next); + expect(request.importerId, equals(i)); + process.inbound.add(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.inbound.add(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 = getFileImportRequest(await process.outbound.next); + expect(request.url, equals("midstream")); + expect(request.importerId, equals(i)); + process.inbound.add(InboundMessage() + ..fileImportResponse = + (InboundMessage_FileImportResponse()..id = request.id)); + } + + var request = getFileImportRequest(await process.outbound.next); + expect(request.importerId, equals(5)); + process.inbound.add(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = p.toUri(d.path("midstream")).toString())); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + 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.inbound.add(compileString("@import 'other'", + importer: InboundMessage_CompileRequest_Importer() + ..fileImporterId = 1)); + + var request = getFileImportRequest(await process.outbound.next); + expect(request.url, equals("other")); + + process.inbound.add(InboundMessage() + ..fileImportResponse = (InboundMessage_FileImportResponse() + ..id = request.id + ..fileUrl = p.toUri(d.path("other")).toString())); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + test("with a base URL", () async { + process.inbound.add(compileString("@import 'other'", + url: p.toUri(d.path("input")).toString(), + importer: InboundMessage_CompileRequest_Importer() + ..fileImporterId = 1)); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + }); +} + +/// 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 = getCompileFailure(await process.outbound.next); + 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..cdfc3b7e7 --- /dev/null +++ b/test/embedded/function_test.dart @@ -0,0 +1,1967 @@ +// 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: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.inbound.add(compileString("a {b: c}", functions: [r""])); + await _expectFunctionError( + _process, r'Invalid signature "": Expected identifier.'); + await _process.kill(); + }); + + test("that's just a name", () async { + _process.inbound.add(compileString("a {b: c}", functions: [r"foo"])); + await _expectFunctionError( + _process, r'Invalid signature "foo": expected "(".'); + await _process.kill(); + }); + + test("without a closing paren", () async { + _process.inbound.add(compileString("a {b: c}", functions: [r"foo($bar"])); + await _expectFunctionError( + _process, r'Invalid signature "foo($bar": expected ")".'); + await _process.kill(); + }); + + test("with text after the closing paren", () async { + _process.inbound.add(compileString("a {b: c}", functions: [r"foo() "])); + await _expectFunctionError( + _process, r'Invalid signature "foo() ": expected no more input.'); + await _process.kill(); + }); + + test("with invalid arguments", () async { + _process.inbound.add(compileString("a {b: c}", functions: [r"foo($)"])); + await _expectFunctionError( + _process, r'Invalid signature "foo($)": Expected identifier.'); + await _process.kill(); + }); + }); + + group("includes in FunctionCallRequest", () { + var compilationId = 1234; + late OutboundMessage_FunctionCallRequest request; + setUp(() async { + _process.inbound.add(compileString("a {b: foo()}", + id: compilationId, functions: ["foo()"])); + request = getFunctionCallRequest(await _process.outbound.next); + }); + + test("the same compilationId as the compilation", () async { + expect(request.compilationId, equals(compilationId)); + await _process.kill(); + }); + + test("the function name", () async { + expect(request.name, equals("foo")); + await _process.kill(); + }); + + group("arguments", () { + test("that are empty", () async { + _process.inbound + .add(compileString("a {b: foo()}", functions: ["foo()"])); + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + await _process.kill(); + }); + + test("by position", () async { + _process.inbound.add(compileString("a {b: foo(true, null, false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, equals([_true, _null, _false])); + await _process.kill(); + }); + + test("by name", () async { + _process.inbound.add(compileString( + r"a {b: foo($arg3: true, $arg1: null, $arg2: false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, equals([_null, _false, _true])); + await _process.kill(); + }); + + test("by position and name", () async { + _process.inbound.add(compileString( + r"a {b: foo(true, $arg3: null, $arg2: false)}", + functions: [r"foo($arg1, $arg2, $arg3)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, equals([_true, _false, _null])); + await _process.kill(); + }); + + test("from defaults", () async { + _process.inbound.add(compileString(r"a {b: foo(1, $arg3: 2)}", + functions: [r"foo($arg1: null, $arg2: true, $arg3: false)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + 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.inbound.add(compileString("a {b: foo(true, false, null)}", + functions: [r"foo($arg, $args...)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + + 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.inbound.add(compileString(r"a {b: foo(true, $arg: false)}", + functions: [r"foo($args...)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + + 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.inbound.add(compileString(r"a {b: foo($arg: false)}", + functions: [r"foo($args...)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _true)); + + var failure = getCompileFailure(await _process.outbound.next); + expect(failure.message, equals(r"No argument named $arg.")); + await _process.kill(); + }); + + test("doesn't throw if named arguments are used", () async { + _process.inbound.add(compileString(r"a {b: foo($arg: false)}", + functions: [r"foo($args...)"])); + var request = getFunctionCallRequest(await _process.outbound.next); + + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..accessedArgumentLists + .add(request.arguments.first.argumentList.id) + ..success = _true)); + + await expectLater(_process.outbound, + emits(isSuccess(equals("a {\n b: true;\n}")))); + await _process.kill(); + }); + }); + }); + }); + + test("returns the result as a SassScript value", () async { + _process.inbound + .add(compileString("a {b: foo() + 2px}", functions: [r"foo()"])); + var request = getFunctionCallRequest(await _process.outbound.next); + + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..number = (Value_Number() + ..value = 1 + ..numerators.add("px"))))); + + await expectLater( + _process.outbound, emits(isSuccess(equals("a {\n b: 3px;\n}")))); + await _process.kill(); + }); + + group("calls a first-class function", () { + test("defined in the compiler and passed to and from the host", () async { + _process.inbound.add(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 = getFunctionCallRequest(await _process.outbound.next); + var value = request.arguments.single; + expect(value.hasCompilerFunction(), isTrue); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + await expectLater( + _process.outbound, emits(isSuccess(equals("a {\n b: 1;\n}")))); + await _process.kill(); + }); + + test("defined in the host", () async { + var compilationId = 1234; + _process.inbound.add(compileString("a {b: call(foo(), true)}", + id: compilationId, functions: [r"foo()"])); + + var hostFunctionId = 5678; + var request = getFunctionCallRequest(await _process.outbound.next); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = hostFunctionId + ..signature = r"bar($arg)")))); + + request = getFunctionCallRequest(await _process.outbound.next); + expect(request.compilationId, equals(compilationId)); + expect(request.functionId, equals(hostFunctionId)); + expect(request.arguments, equals([_true])); + + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _false)); + + await expectLater( + _process.outbound, emits(isSuccess(equals("a {\n b: false;\n}")))); + await _process.kill(); + }); + + test("defined in the host and passed to and from the host", () async { + var compilationId = 1234; + _process.inbound.add(compileString( + r""" + $function: get-host-function(); + $function: round-trip($function); + a {b: call($function, true)} + """, + id: compilationId, + functions: [r"get-host-function()", r"round-trip($function)"])); + + var hostFunctionId = 5678; + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.name, equals("get-host-function")); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = hostFunctionId + ..signature = r"bar($arg)")))); + + request = getFunctionCallRequest(await _process.outbound.next); + expect(request.name, equals("round-trip")); + var value = request.arguments.single; + expect(value.hasCompilerFunction(), isTrue); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + request = getFunctionCallRequest(await _process.outbound.next); + expect(request.compilationId, equals(compilationId)); + expect(request.functionId, equals(hostFunctionId)); + expect(request.arguments, equals([_true])); + + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = _false)); + + await expectLater( + _process.outbound, emits(isSuccess(equals("a {\n b: false;\n}")))); + await _process.kill(); + }); + }); + + 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')); + }); + }); + + 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)), "#bf4040"); + }); + + test("360", () async { + expect(await _deprotofy(_hsl(360, 50, 50, 1.0)), "#bf4040"); + }); + + test("below 0", () async { + expect(await _deprotofy(_hsl(-100, 50, 50, 1.0)), "#6a40bf"); + }); + + test("between 0 and 360", () async { + expect(await _deprotofy(_hsl(100, 50, 50, 1.0)), "#6abf40"); + }); + + test("above 360", () async { + expect(await _deprotofy(_hsl(560, 50, 50, 1.0)), "#4095bf"); + }); + }); + + group("saturation", () { + test("0", () async { + expect(await _deprotofy(_hsl(0, 0, 50, 1.0)), "gray"); + }); + + test("100", () async { + expect(await _deprotofy(_hsl(0, 100, 50, 1.0)), "red"); + }); + + test("in the middle", () async { + expect(await _deprotofy(_hsl(0, 42, 50, 1.0)), "#b54a4a"); + }); + }); + + group("lightness", () { + test("0", () async { + expect(await _deprotofy(_hsl(0, 50, 0, 1.0)), "black"); + }); + + test("100", () async { + expect(await _deprotofy(_hsl(0, 50, 100, 1.0)), "white"); + }); + + test("in the middle", () async { + expect(await _deprotofy(_hsl(0, 50, 42, 1.0)), "#a13636"); + }); + }); + }); + + group("with alpha", () { + test("0", () async { + expect( + await _deprotofy(_hsl(10, 20, 30, 0.0)), "rgba(92, 66, 61, 0)"); + }); + + test("between 0 and 1", () async { + expect(await _deprotofy(_hsl(10, 20, 30, 0.123)), + "rgba(92, 66, 61, 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 red above 255", () async { + await _expectDeprotofyError(_rgb(256, 0, 0, 1.0), + "RgbColor.red must be between 0 and 255, was 256"); + }); + + test("with green above 255", () async { + await _expectDeprotofyError(_rgb(0, 256, 0, 1.0), + "RgbColor.green must be between 0 and 255, was 256"); + }); + + test("with blue above 255", () async { + await _expectDeprotofyError(_rgb(0, 0, 256, 1.0), + "RgbColor.blue must be between 0 and 255, was 256"); + }); + + test("with RGB alpha below 0", () async { + await _expectDeprotofyError(_rgb(0, 0, 0, -0.1), + "RgbColor.alpha must be between 0 and 1, was -0.1"); + }); + + 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 exactly 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" + ..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 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.inbound + .add(compileString("a {b: foo()}", functions: [r"foo()"])); + + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + _process.inbound.add(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 = getCompileFailure(await _process.outbound.next); + expect(failure.message, equals("1px and 2s are incompatible.")); + expect(_process.kill(), completes); + }); + }); + + group("reports a compilation error for a function with a signature", () { + Future expectSignatureError( + String signature, Object message) async { + _process.inbound.add( + compileString("a {b: inspect(foo())}", functions: [r"foo()"])); + + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = (Value() + ..hostFunction = (Value_HostFunction() + ..id = 1234 + ..signature = signature)))); + + var failure = getCompileFailure(await _process.outbound.next); + expect(failure.message, message); + expect(_process.kill(), 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.inbound.add(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 = getFunctionCallRequest(await _process.outbound.next); + 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.inbound.add(compileString( + inspect ? "a {b: inspect(foo())}" : "a {b: foo()}", + functions: [r"foo()"])); + + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + var success = getCompileSuccess(await _process.outbound.next); + expect(_process.kill(), 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.inbound.add(compileString("a {b: foo()}", functions: [r"foo()"])); + + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + _process.inbound.add(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.inbound.add(compileString(""" +\$_: outbound(inbound()); +""", functions: ["inbound()", r"outbound($arg)"])); + + var request = getFunctionCallRequest(await _process.outbound.next); + expect(request.arguments, isEmpty); + _process.inbound.add(InboundMessage() + ..functionCallResponse = (InboundMessage_FunctionCallResponse() + ..id = request.id + ..success = value)); + + request = getFunctionCallRequest(await _process.outbound.next); + 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 = getCompileFailure(await process.outbound.next); + 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..c08980f21 --- /dev/null +++ b/test/embedded/importer_test.dart @@ -0,0 +1,517 @@ +// 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_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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id + 1)); + + await expectParamsError( + process, + errorId, + "Response ID ${request.id + 1} doesn't match any outstanding " + "requests."); + await process.kill(); + }); + + test("for a response that doesn't match the request type", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse()..id = request.id)); + + await expectParamsError( + process, + errorId, + "Request ID ${request.id} doesn't match response type " + "InboundMessage_ImportResponse."); + await process.kill(); + }); + + test("for an unset importer", () async { + process.inbound.add(compileString("a {b: c}", + importers: [InboundMessage_CompileRequest_Importer()])); + await expectParamsError( + process, 0, "Missing mandatory field Importer.importer"); + await process.kill(); + }); + }); + + group("canonicalization", () { + group("emits a compile failure", () { + test("for a canonicalize response with an empty URL", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..url = "")); + + await _expectImportError( + process, 'The importer must return an absolute URL, was ""'); + await process.kill(); + }); + + test("for a canonicalize response with a relative URL", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..url = "relative")); + + await _expectImportError(process, + 'The importer must return an absolute URL, was "relative"'); + await process.kill(); + }); + }); + + group("includes in CanonicalizeRequest", () { + var compilationId = 1234; + var importerId = 5679; + late OutboundMessage_CanonicalizeRequest request; + setUp(() async { + process.inbound.add(compileString("@import 'other'", + id: compilationId, + importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + request = getCanonicalizeRequest(await process.outbound.next); + }); + + test("the same compilationId as the compilation", () async { + expect(request.compilationId, equals(compilationId)); + await process.kill(); + }); + + 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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = request.id + ..error = "oh no")); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals('oh no')); + expect(failure.span.text, equals("'other'")); + expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + await process.kill(); + }); + + test("null results count as not found", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var request = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.kill(); + }); + + test("attempts importers in order", () async { + process.inbound.add(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 = getCanonicalizeRequest(await process.outbound.next); + expect(request.importerId, equals(i)); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + } + + await process.kill(); + }); + + test("tries resolved URL using the original importer first", () async { + process.inbound.add(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 = getCanonicalizeRequest(await process.outbound.next); + expect(request.url, equals("midstream")); + expect(request.importerId, equals(i)); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = + (InboundMessage_CanonicalizeResponse()..id = request.id)); + } + + var canonicalize = getCanonicalizeRequest(await process.outbound.next); + expect(canonicalize.importerId, equals(5)); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalize.id + ..url = "custom:foo/bar")); + + var import = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = import.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "@import 'upstream'"))); + + canonicalize = getCanonicalizeRequest(await process.outbound.next); + 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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var import = getImportRequest(await process.outbound.next); + process.inbound.add(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.kill(); + }); + }); + + group("includes in ImportRequest", () { + var compilationId = 1234; + var importerId = 5678; + late OutboundMessage_ImportRequest request; + setUp(() async { + process.inbound.add(compileString("@import 'other'", + id: compilationId, + importers: [ + InboundMessage_CompileRequest_Importer()..importerId = importerId + ])); + + var canonicalize = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalize.id + ..url = "custom:foo")); + + request = getImportRequest(await process.outbound.next); + }); + + test("the same compilationId as the compilation", () async { + expect(request.compilationId, equals(compilationId)); + await process.kill(); + }); + + 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.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + var canonicalizeRequest = + getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..canonicalizeResponse = (InboundMessage_CanonicalizeResponse() + ..id = canonicalizeRequest.id + ..url = "o:other")); + + var importRequest = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = + (InboundMessage_ImportResponse()..id = importRequest.id)); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals("Can't find stylesheet to import.")); + expect(failure.span.text, equals("'other'")); + await process.kill(); + }); + + test("errors cause compilation to fail", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..error = "oh no")); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.message, equals('oh no')); + expect(failure.span.text, equals("'other'")); + expect(failure.stackTrace, equals('- 1:9 root stylesheet\n')); + await process.kill(); + }); + + test("can return an SCSS file", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: 1px + 2px}"))); + + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("can return an indented syntax file", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a\n b: 1px + 2px" + ..syntax = Syntax.INDENTED))); + + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("can return a plain CSS file", () async { + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..syntax = Syntax.CSS))); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + test("uses a data: URL rather than an empty source map URL", () async { + process.inbound.add(compileString("@import 'other'", + sourceMap: true, + importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..sourceMapUrl = ""))); + + await expectLater( + process.outbound, + emits(isSuccess("a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, [startsWith("data:")]); + }))); + await process.kill(); + }); + + test("uses a non-empty source map URL", () async { + process.inbound.add(compileString("@import 'other'", + sourceMap: true, + importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: c}" + ..sourceMapUrl = "file:///asdf"))); + + await expectLater( + process.outbound, + emits(isSuccess("a { b: c; }", sourceMap: (String map) { + var mapping = source_maps.parse(map) as source_maps.SingleMapping; + expect(mapping.urls, equals(["file:///asdf"])); + }))); + await process.kill(); + }); + }); + + test("handles an importer for a string compile request", () async { + process.inbound.add(compileString("@import 'other'", + importer: InboundMessage_CompileRequest_Importer()..importerId = 1)); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "a {b: 1px + 2px}"))); + + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + group("load paths", () { + test("are used to load imports", () async { + await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); + + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..path = d.path("dir") + ])); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + 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.inbound.add(compileString("@import 'other2'", importers: [ + for (var i = 0; i < 3; i++) + InboundMessage_CompileRequest_Importer()..path = d.path("dir$i") + ])); + + await expectLater(process.outbound, emits(isSuccess("a { b: 2; }"))); + await process.kill(); + }); + + test("take precedence over later importers", () async { + await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); + + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..path = d.path("dir"), + InboundMessage_CompileRequest_Importer()..importerId = 1 + ])); + + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + test("yield precedence to earlier importers", () async { + await d.dir("dir", [d.file("other.scss", "a {b: c}")]).create(); + + process.inbound.add(compileString("@import 'other'", importers: [ + InboundMessage_CompileRequest_Importer()..importerId = 1, + InboundMessage_CompileRequest_Importer()..path = d.path("dir") + ])); + await _canonicalize(process); + + var request = getImportRequest(await process.outbound.next); + process.inbound.add(InboundMessage() + ..importResponse = (InboundMessage_ImportResponse() + ..id = request.id + ..success = (InboundMessage_ImportResponse_ImportSuccess() + ..contents = "x {y: z}"))); + + await expectLater(process.outbound, emits(isSuccess("x { y: z; }"))); + await process.kill(); + }); + }); +} + +/// 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 = getCanonicalizeRequest(await process.outbound.next); + process.inbound.add(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 = getCompileFailure(await process.outbound.next); + 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..7132278d8 --- /dev/null +++ b/test/embedded/length_delimited_test.dart @@ -0,0 +1,127 @@ +// 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: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..77234e41a --- /dev/null +++ b/test/embedded/protocol_test.dart @@ -0,0 +1,481 @@ +// 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: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 '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.inbound.add(InboundMessage()); + await expectParseError(process, "InboundMessage.message is not set."); + expect(await process.exitCode, 76); + }); + + test("caused by an invalid message", () async { + process.stdin.add([1, 0]); + await expectParseError( + process, "Protocol message contained an invalid tag (zero)."); + expect(await process.exitCode, 76); + }); + }); + + test("a version response is valid", () async { + process.inbound.add(InboundMessage() + ..versionRequest = (InboundMessage_VersionRequest()..id = 123)); + var response = (await process.outbound.next).versionResponse; + 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.kill(); + }); + + group("compiles CSS from", () { + test("an SCSS string by default", () async { + process.inbound.add(compileString("a {b: 1px + 2px}")); + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("an SCSS string explicitly", () async { + process.inbound + .add(compileString("a {b: 1px + 2px}", syntax: Syntax.SCSS)); + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("an indented syntax string", () async { + process.inbound + .add(compileString("a\n b: 1px + 2px", syntax: Syntax.INDENTED)); + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("a plain CSS string", () async { + process.inbound.add(compileString("a {b: c}", syntax: Syntax.CSS)); + await expectLater(process.outbound, emits(isSuccess("a { b: c; }"))); + await process.kill(); + }); + + test("an absolute path", () async { + await d.file("test.scss", "a {b: 1px + 2px}").create(); + + process.inbound.add(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest() + ..path = p.absolute(d.path("test.scss")))); + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + + test("a relative path", () async { + await d.file("test.scss", "a {b: 1px + 2px}").create(); + + process.inbound.add(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest() + ..path = p.relative(d.path("test.scss")))); + await expectLater(process.outbound, emits(isSuccess("a { b: 3px; }"))); + await process.kill(); + }); + }); + + group("compiles CSS in", () { + test("expanded mode", () async { + process.inbound + .add(compileString("a {b: 1px + 2px}", style: OutputStyle.EXPANDED)); + await expectLater( + process.outbound, emits(isSuccess(equals("a {\n b: 3px;\n}")))); + await process.kill(); + }); + + test("compressed mode", () async { + process.inbound.add( + compileString("a {b: 1px + 2px}", style: OutputStyle.COMPRESSED)); + await expectLater(process.outbound, emits(isSuccess(equals("a{b:3px}")))); + await process.kill(); + }); + }); + + test("doesn't include a source map by default", () async { + process.inbound.add(compileString("a {b: 1px + 2px}")); + await expectLater(process.outbound, + emits(isSuccess("a { b: 3px; }", sourceMap: isEmpty))); + await process.kill(); + }); + + test("doesn't include a source map with source_map: false", () async { + process.inbound.add(compileString("a {b: 1px + 2px}", sourceMap: false)); + await expectLater(process.outbound, + emits(isSuccess("a { b: 3px; }", sourceMap: isEmpty))); + await process.kill(); + }); + + test("includes a source map if source_map is true", () async { + process.inbound.add(compileString("a {b: 1px + 2px}", sourceMap: true)); + await expectLater( + process.outbound, + emits(isSuccess("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.kill(); + }); + + test( + "includes a source map without content if source_map is true and source_map_include_sources is false", + () async { + process.inbound.add(compileString("a {b: 1px + 2px}", + sourceMap: true, sourceMapIncludeSources: false)); + await expectLater( + process.outbound, + emits(isSuccess("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.kill(); + }); + + test( + "includes a source map with content if source_map is true and source_map_include_sources is true", + () async { + process.inbound.add(compileString("a {b: 1px + 2px}", + sourceMap: true, sourceMapIncludeSources: true)); + await expectLater( + process.outbound, + emits(isSuccess("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.kill(); + }); + + group("emits a log event", () { + group("for a @debug rule", () { + test("with correct fields", () async { + process.inbound.add(compileString("a {@debug hello}")); + + var logEvent = getLogEvent(await process.outbound.next); + expect(logEvent.compilationId, equals(0)); + 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.inbound + .add(compileString("a {@debug hello}", alertColor: true)); + var logEvent = getLogEvent(await process.outbound.next); + 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.inbound.add(compileString("a {@warn hello}")); + + var logEvent = getLogEvent(await process.outbound.next); + expect(logEvent.compilationId, equals(0)); + 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.inbound.add(compileString("a {@warn hello}", alertColor: true)); + var logEvent = getLogEvent(await process.outbound.next); + 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.inbound + .add(compileString("a {@debug a && b}", alertAscii: true)); + var logEvent = getLogEvent(await process.outbound.next); + 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.inbound.add(compileString("@if true {} @elseif true {}")); + + var logEvent = getLogEvent(await process.outbound.next); + expect(logEvent.compilationId, equals(0)); + 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.inbound.add(compileString("a {\$var: value !global}")); + + var logEvent = getLogEvent(await process.outbound.next); + expect(logEvent.compilationId, equals(0)); + 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(); + }); + + test("with the same ID as the CompileRequest", () async { + process.inbound.add(compileString("@debug hello", id: 12345)); + + var logEvent = getLogEvent(await process.outbound.next); + expect(logEvent.compilationId, equals(12345)); + await process.kill(); + }); + }); + + group("gracefully handles an error", () { + test("from invalid syntax", () async { + process.inbound.add(compileString("a {b: }")); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("from the runtime", () async { + process.inbound.add(compileString("a {b: 1px + 1em}")); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("from a missing file", () async { + process.inbound.add(InboundMessage() + ..compileRequest = + (InboundMessage_CompileRequest()..path = d.path("test.scss"))); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("with a multi-line source span", () async { + process.inbound.add(compileString(""" +a { + b: 1px + + 1em; +} +""")); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("with multiple stack trace entries", () async { + process.inbound.add(compileString(""" +@function fail() { + @return 1px + 1em; +} + +a { + b: fail(); +} +""")); + + var failure = getCompileFailure(await process.outbound.next); + expect( + failure.stackTrace, + equals("- 2:11 fail()\n" + "- 6:6 root stylesheet\n")); + await process.kill(); + }); + + group("and includes the URL from", () { + test("a string input", () async { + process.inbound + .add(compileString("a {b: 1px + 1em}", url: "foo://bar/baz")); + + var failure = getCompileFailure(await process.outbound.next); + expect(failure.span.url, equals("foo://bar/baz")); + expect( + failure.stackTrace, equals("foo://bar/baz 1:7 root stylesheet\n")); + await process.kill(); + }); + + test("a path input", () async { + await d.file("test.scss", "a {b: 1px + 1em}").create(); + var path = d.path("test.scss"); + process.inbound.add(InboundMessage() + ..compileRequest = (InboundMessage_CompileRequest()..path = path)); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + }); + + test("caused by using Sass features in CSS", () async { + process.inbound + .add(compileString("a {b: 1px + 2px}", syntax: Syntax.CSS)); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + group("and provides a formatted", () { + test("message", () async { + process.inbound.add(compileString("a {b: 1px + 1em}")); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("message with terminal colors", () async { + process.inbound + .add(compileString("a {b: 1px + 1em}", alertColor: true)); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + + test("message with ASCII encoding", () async { + process.inbound + .add(compileString("a {b: 1px + 1em}", alertAscii: true)); + + var failure = getCompileFailure(await process.outbound.next); + 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.kill(); + }); + }); + }); +} diff --git a/test/embedded/utils.dart b/test/embedded/utils.dart new file mode 100644 index 000000000..35eb2220b --- /dev/null +++ b/test/embedded/utils.dart @@ -0,0 +1,201 @@ +// 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'; + +/// Returns a [InboundMessage] 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 (id != null) request.id = id; + 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) async { + await expectLater(process.outbound, + emits(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) async { + await expectLater(process.outbound, + emits(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 that [message] is an [OutboundMessage] with a +/// `CanonicalizeRequest` and returns it. +OutboundMessage_CanonicalizeRequest getCanonicalizeRequest(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasCanonicalizeRequest(), isTrue, + reason: "Expected $message to have a CanonicalizeRequest"); + return message.canonicalizeRequest; +} + +/// Asserts that [message] is an [OutboundMessage] with a `ImportRequest` and +/// returns it. +OutboundMessage_ImportRequest getImportRequest(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasImportRequest(), isTrue, + reason: "Expected $message to have a ImportRequest"); + return message.importRequest; +} + +/// Asserts that [message] is an [OutboundMessage] with a `FileImportRequest` +/// and returns it. +OutboundMessage_FileImportRequest getFileImportRequest(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasFileImportRequest(), isTrue, + reason: "Expected $message to have a FileImportRequest"); + return message.fileImportRequest; +} + +/// Asserts that [message] is an [OutboundMessage] with a +/// `FunctionCallRequest` and returns it. +OutboundMessage_FunctionCallRequest getFunctionCallRequest(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasFunctionCallRequest(), isTrue, + reason: "Expected $message to have a FunctionCallRequest"); + return message.functionCallRequest; +} + +/// Asserts that [message] is an [OutboundMessage] with a +/// `CompileResponse.Failure` and returns it. +OutboundMessage_CompileResponse_CompileFailure getCompileFailure( + Object? value) { + var response = getCompileResponse(value); + expect(response.hasFailure(), isTrue, + reason: "Expected $response to be a failure"); + return response.failure; +} + +/// Asserts that [message] is an [OutboundMessage] with a +/// `CompileResponse.Success` and returns it. +OutboundMessage_CompileResponse_CompileSuccess getCompileSuccess( + Object? value) { + var response = getCompileResponse(value); + expect(response.hasSuccess(), isTrue, + reason: "Expected $response to be a success"); + return response.success; +} + +/// Asserts that [message] is an [OutboundMessage] with a `CompileResponse` and +/// returns it. +OutboundMessage_CompileResponse getCompileResponse(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasCompileResponse(), isTrue, + reason: "Expected $message to have a CompileResponse"); + return message.compileResponse; +} + +/// Asserts that [message] is an [OutboundMessage] with a `LogEvent` and +/// returns it. +OutboundMessage_LogEvent getLogEvent(Object? value) { + expect(value, isA()); + var message = value as OutboundMessage; + expect(message.hasLogEvent(), isTrue, + reason: "Expected $message to have a LogEvent"); + return message.logEvent; +} + +/// Asserts that an [OutboundMessage] is 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`. +Matcher isSuccess(Object css, {Object? sourceMap}) => predicate((value) { + var success = getCompileSuccess(value); + 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); + } + return true; + }); + +/// 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/tool/grind.dart b/tool/grind.dart index 9cc3f0efc..04c917215 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -85,6 +85,20 @@ void main(List args) { "\n" "${pkg.githubReleaseNotes.defaultValue}"; + pkg.environmentConstants.fn = () { + if (!Directory('build/embedded-protocol').existsSync()) { + fail('Run `dart run grinder protobuf` before building Dart Sass ' + 'executables.'); + } + + return { + ...pkg.environmentConstants.defaultValue, + "protocol-version": + File('build/embedded-protocol/VERSION').readAsStringSync().trim(), + "compiler-version": pkg.pubspec.version!.toString(), + }; + }; + pkg.addAllTasks(); grind(args); } @@ -104,7 +118,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 => """ @@ -193,3 +208,36 @@ 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/embedded-protocol.git", "main"); + } + + await runAsync("buf", + arguments: ["generate"], + runOptions: RunOptions(environment: { + "PATH": 'build' + + (Platform.isWindows ? ";" : ":") + + Platform.environment["PATH"]! + })); +} 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; +}