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 6960423e7..e69661d41 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,6 +43,11 @@ jobs:
with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"}
- run: npm install
working-directory: sass-spec
+
+ - name: Build protobuf
+ 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
@@ -52,7 +57,7 @@ jobs:
# They next need to be rotated April 2021. See
# https://github.com/nodejs/Release.
sass_spec_js:
- name: "JS API Tests | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node_version }} | ${{ matrix.os }}"
+ name: "JS API Tests | Pure JS | Dart ${{ matrix.dart_channel }} | Node ${{ matrix.node_version }} | ${{ matrix.os }}"
runs-on: "${{ matrix.os }}"
strategy:
@@ -82,6 +87,10 @@ jobs:
with: {node-version: "${{ matrix.node_version }}"}
- run: npm install
+ - name: Build protobuf
+ 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}
@@ -103,6 +112,73 @@ 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 October 2021. See
+ # https://github.com/nodejs/Release.
+ sass_spec_js_embedded:
+ name: 'JS API Tests | Embedded | Node ${{ matrix.node_version }} | ${{ matrix.os }}'
+ runs-on: ${{ matrix.os }}-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu, windows, macos]
+ node_version: [18]
+ include:
+ # Include LTS versions on Ubuntu
+ - os: ubuntu
+ node_version: 16
+ - os: ubuntu
+ node_version: 14
+
+ steps:
+ - uses: actions/checkout@v3
+ - uses: dart-lang/setup-dart@v1
+ with: {sdk: stable}
+ - run: dart pub get
+
+ - name: Build protobuf
+ 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
+
dart_tests:
name: "Dart tests | Dart ${{ matrix.dart_channel }} | ${{ matrix.os }}"
runs-on: "${{ matrix.os }}"
@@ -119,9 +195,14 @@ jobs:
- uses: dart-lang/setup-dart@v1
with: {sdk: "${{ matrix.dart_channel }}"}
- run: dart pub get
+
+ - name: Build protobuf
+ uses: ./.github/util/build-protobuf
+ with: {github-token: "${{ github.token }}"}
+
- run: dart run grinder pkg-standalone-dev
- name: Run tests
- run: dart run test -p vm -x node -r expanded
+ run: dart run test -p vm -x node
# Unit tests that use Node.js, defined in test/.
#
@@ -158,9 +239,14 @@ jobs:
- uses: actions/setup-node@v3
with: {node-version: "${{ matrix.node_version }}"}
- run: npm install
+
+ - name: Build protobuf
+ uses: ./.github/util/build-protobuf
+ with: {github-token: "${{ github.token }}"}
+
- run: dart run grinder before-test
- name: Run tests
- run: dart run test -j 2 -t node -r expanded
+ run: dart run test -j 2 -t node
static_analysis:
name: Static analysis
@@ -170,8 +256,13 @@ jobs:
- uses: actions/checkout@v3
- uses: dart-lang/setup-dart@v1
- run: dart pub get
+
+ - name: Build protobuf
+ uses: ./.github/util/build-protobuf
+ with: {github-token: "${{ github.token }}"}
+
- name: Analyze Dart
- run: dart analyze --fatal-warnings --fatal-infos .
+ run: dart analyze --fatal-warnings ./
dartdoc:
name: Dartdoc
@@ -200,6 +291,7 @@ jobs:
- node_tests
- static_analysis
- dartdoc
+ - format
if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'"
steps:
@@ -275,6 +367,16 @@ jobs:
- name: Build
run: dart bin/sass.dart --quiet build/bulma/bulma.sass build/bulma-output.css
+ 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
+
deploy_github_linux:
name: "Deploy Github: linux-ia32, linux-x64"
runs-on: ubuntu-latest
@@ -283,8 +385,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
- name: Deploy
run: dart run grinder pkg-github-release pkg-github-linux-ia32 pkg-github-linux-x64
env:
@@ -306,6 +411,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: |
@@ -341,11 +451,14 @@ 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
# 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:
@@ -397,6 +510,7 @@ jobs:
- uses: actions/checkout@v3
- uses: dart-lang/setup-dart@v1
- run: dart pub get
+ - run: dart run grinder protobuf
- uses: actions/setup-node@v3
with: {node-version: "${{ env.DEFAULT_NODE_VERSION }}"}
- name: Deploy
@@ -455,7 +569,6 @@ jobs:
runs-on: ubuntu-latest
needs: [bootstrap, bourbon, foundation, bulma]
if: "startsWith(github.ref, 'refs/tags/') && github.repository == 'sass/dart-sass'"
-
steps:
- uses: actions/checkout@v3
with:
@@ -469,58 +582,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
- - 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/lib/analysis_options.yaml b/analysis/lib/analysis_options.yaml
index 4ab629352..2a2921abc 100644
--- a/analysis/lib/analysis_options.yaml
+++ b/analysis/lib/analysis_options.yaml
@@ -1,8 +1,7 @@
analyzer:
exclude: [build/**]
- strong-mode:
- implicit-casts: false
language:
+ strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
diff --git a/analysis_options.yaml b/analysis_options.yaml
index fdd023b43..36aac758f 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -4,3 +4,5 @@
# out-of-date (because they cause "pub run" to modify the lockfile before it
# runs the executable).
include: analysis/lib/analysis_options.yaml
+analyzer:
+ exclude: ['**/*.pb*.dart']
diff --git a/bin/sass.dart b/bin/sass.dart
index 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/lib/src/node/compile.dart b/lib/src/node/compile.dart
index a5a5e50f2..1233f2954 100644
--- a/lib/src/node/compile.dart
+++ b/lib/src/node/compile.dart
@@ -2,7 +2,6 @@
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
-import 'package:js/js.dart';
import 'package:node_interop/js.dart';
import 'package:node_interop/util.dart' hide futureToPromise;
import 'package:term_glyph/term_glyph.dart' as glyph;
diff --git a/lib/src/node/legacy.dart b/lib/src/node/legacy.dart
index 3cda764a5..a06fed967 100644
--- a/lib/src/node/legacy.dart
+++ b/lib/src/node/legacy.dart
@@ -7,7 +7,6 @@ import 'dart:convert';
import 'dart:js_util';
import 'dart:typed_data';
-import 'package:js/js.dart';
import 'package:node_interop/js.dart';
import 'package:path/path.dart' as p;
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.lock b/pubspec.lock
new file mode 100644
index 000000000..3d655cc0a
--- /dev/null
+++ b/pubspec.lock
@@ -0,0 +1,629 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ _fe_analyzer_shared:
+ dependency: transitive
+ description:
+ name: _fe_analyzer_shared
+ sha256: "4897882604d919befd350648c7f91926a9d5de99e67b455bf0917cc2362f4bb8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "47.0.0"
+ analyzer:
+ dependency: "direct dev"
+ description:
+ name: analyzer
+ sha256: "690e335554a8385bc9d787117d9eb52c0c03ee207a607e593de3c9d71b1cfe80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.7.0"
+ archive:
+ dependency: "direct dev"
+ description:
+ name: archive
+ sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.3.7"
+ args:
+ dependency: "direct main"
+ description:
+ name: args
+ sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
+ async:
+ dependency: "direct main"
+ description:
+ name: async
+ sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.11.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ charcode:
+ dependency: "direct main"
+ description:
+ name: charcode
+ sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.1"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "3d1505d91afa809d177efd4eed5bb0eb65805097a1463abdd2add076effae311"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.2"
+ cli_pkg:
+ dependency: "direct dev"
+ description:
+ name: cli_pkg
+ sha256: "0f76b0ea3f158e9c68e3ae132e90435cfd094c507ae6aaeccb05bbc2ef758517"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.4"
+ cli_repl:
+ dependency: "direct main"
+ description:
+ name: cli_repl
+ sha256: a2ee06d98f211cb960c777519cb3d14e882acd90fe5e078668e3ab4baab0ddd4
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.3"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: "66f86e916d285c1a93d3b79587d94bd71984a66aac4ff74e524cfa7877f1395c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5"
+ collection:
+ dependency: "direct main"
+ description:
+ name: collection
+ sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.17.1"
+ convert:
+ dependency: transitive
+ description:
+ name: convert
+ sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
+ coverage:
+ dependency: transitive
+ description:
+ name: coverage
+ sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.3"
+ crypto:
+ dependency: "direct dev"
+ description:
+ name: crypto
+ sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ csslib:
+ dependency: transitive
+ description:
+ name: csslib
+ sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.17.2"
+ dart_style:
+ dependency: "direct dev"
+ description:
+ name: dart_style
+ sha256: "7a03456c3490394c8e7665890333e91ae8a49be43542b616e414449ac358acd4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.4"
+ dartdoc:
+ dependency: "direct dev"
+ description:
+ name: dartdoc
+ sha256: f236297ea9d0908e1510cfabbf9cfc318c9834067c1bbddbea0ad9d670cd0b1a
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.1"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.4"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ frontend_server_client:
+ dependency: transitive
+ description:
+ name: frontend_server_client
+ sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.0"
+ glob:
+ dependency: transitive
+ description:
+ name: glob
+ sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ grinder:
+ dependency: "direct dev"
+ description:
+ name: grinder
+ sha256: "1dabcd70f0d3975f9143d0cebf48a09cf56d4f5e0922f1d1931781e1047c8d00"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.3"
+ html:
+ dependency: transitive
+ description:
+ name: html
+ sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.15.3"
+ http:
+ dependency: "direct main"
+ description:
+ name: http
+ sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.5"
+ http_multi_server:
+ dependency: transitive
+ description:
+ name: http_multi_server
+ sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.1"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.2"
+ io:
+ dependency: transitive
+ description:
+ name: io
+ sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.4"
+ js:
+ dependency: "direct main"
+ description:
+ name: js
+ sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.7"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
+ lints:
+ dependency: "direct dev"
+ description:
+ name: lints
+ sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ markdown:
+ dependency: transitive
+ description:
+ name: markdown
+ sha256: c2b81e184067b41d0264d514f7cdaa2c02d38511e39d6521a1ccc238f6d7b3f2
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.1"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.15"
+ meta:
+ dependency: "direct main"
+ description:
+ name: meta
+ sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ mime:
+ dependency: transitive
+ description:
+ name: mime
+ sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.4"
+ node_interop:
+ dependency: "direct main"
+ description:
+ name: node_interop
+ sha256: "3af2420c728173806f4378cf89c53ba9f27f7f67792b898561bff9d390deb98e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ node_preamble:
+ dependency: "direct dev"
+ description:
+ name: node_preamble
+ sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.2"
+ oauth2:
+ dependency: transitive
+ description:
+ name: oauth2
+ sha256: "1e8376c222651904caf7785fd2fa01b1e2be608c94bec842a94e116deca88f13"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ package_config:
+ dependency: "direct main"
+ description:
+ name: package_config
+ sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ path:
+ dependency: "direct main"
+ description:
+ name: path
+ sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.8.3"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.4.0"
+ pointycastle:
+ dependency: transitive
+ description:
+ name: pointycastle
+ sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.7.3"
+ pool:
+ dependency: transitive
+ description:
+ name: pool
+ sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.5.1"
+ protobuf:
+ dependency: "direct main"
+ description:
+ name: protobuf
+ sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ protoc_plugin:
+ dependency: "direct dev"
+ description:
+ name: protoc_plugin
+ sha256: e2be5014ba145dc0f8de20ac425afa2a513aff64fe350d338e481d40de0573df
+ url: "https://pub.dev"
+ source: hosted
+ version: "20.0.1"
+ pub_api_client:
+ dependency: "direct dev"
+ description:
+ name: pub_api_client
+ sha256: d4bc6c9ec778da1a79675eab41bde456b392973216acd783156afaee69230e22
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
+ pub_semver:
+ dependency: "direct main"
+ description:
+ name: pub_semver
+ sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.3"
+ pubspec:
+ dependency: transitive
+ description:
+ name: pubspec
+ sha256: f534a50a2b4d48dc3bc0ec147c8bd7c304280fff23b153f3f11803c4d49d927e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ pubspec_parse:
+ dependency: "direct dev"
+ description:
+ name: pubspec_parse
+ sha256: ec85d7d55339d85f44ec2b682a82fea340071e8978257e5a43e69f79e98ef50c
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ quiver:
+ dependency: transitive
+ description:
+ name: quiver
+ sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.1"
+ retry:
+ dependency: transitive
+ description:
+ name: retry
+ sha256: a8a1e475a100a0bdc73f529ca8ea1e9c9c76bec8ad86a1f47780139a34ce7aea
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
+ shelf:
+ dependency: transitive
+ description:
+ name: shelf
+ sha256: c24a96135a2ccd62c64b69315a14adc5c3419df63b4d7c05832a346fdb73682c
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ shelf_packages_handler:
+ dependency: transitive
+ description:
+ name: shelf_packages_handler
+ sha256: aef74dc9195746a384843102142ab65b6a4735bb3beea791e63527b88cc83306
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.1"
+ shelf_static:
+ dependency: transitive
+ description:
+ name: shelf_static
+ sha256: e792b76b96a36d4a41b819da593aff4bdd413576b3ba6150df5d8d9996d2e74c
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ shelf_web_socket:
+ dependency: transitive
+ description:
+ name: shelf_web_socket
+ sha256: a988c0e8d8ffbdb8a28aa7ec8e449c260f3deb808781fe1284d22c5bba7156e8
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.3"
+ source_map_stack_trace:
+ dependency: transitive
+ description:
+ name: source_map_stack_trace
+ sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ source_maps:
+ dependency: "direct main"
+ description:
+ name: source_maps
+ sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.12"
+ source_span:
+ dependency: "direct main"
+ description:
+ name: source_span
+ sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.0"
+ stack_trace:
+ dependency: "direct main"
+ description:
+ name: stack_trace
+ sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.11.0"
+ stream_channel:
+ dependency: "direct dev"
+ description:
+ name: stream_channel
+ sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ stream_transform:
+ dependency: "direct main"
+ description:
+ name: stream_transform
+ sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ string_scanner:
+ dependency: "direct main"
+ description:
+ name: string_scanner
+ sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
+ term_glyph:
+ dependency: "direct main"
+ description:
+ name: term_glyph
+ sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ test:
+ dependency: "direct dev"
+ description:
+ name: test
+ sha256: "4f92f103ef63b1bbac6f4bd1930624fca81b2574464482512c4f0896319be575"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.24.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: daadc9baabec998b062c9091525aa95786508b1c48e9c30f1f891b8bf6ff2e64
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.5.2"
+ test_core:
+ dependency: transitive
+ description:
+ name: test_core
+ sha256: "3642b184882f79e76ca57a9230fb971e494c3c1fd09c21ae3083ce891bcc0aa1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.5.2"
+ test_descriptor:
+ dependency: "direct dev"
+ description:
+ name: test_descriptor
+ sha256: abe245e8b0d61245684127fe32343542c25dc2a1ce8f405531637241d98d07e4
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ test_process:
+ dependency: "direct dev"
+ description:
+ name: test_process
+ sha256: b0e6702f58272d459d5b80b88696483f86eac230dab05ecf73d0662e305d005e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.3"
+ tuple:
+ dependency: "direct main"
+ description:
+ name: tuple
+ sha256: "0ea99cd2f9352b2586583ab2ce6489d1f95a5f6de6fb9492faaf97ae2060f0aa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ typed_data:
+ dependency: "direct main"
+ description:
+ name: typed_data
+ sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.1"
+ uri:
+ dependency: transitive
+ description:
+ name: uri
+ sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "518254c0d3ee20667a1feef39eefe037df87439851e4b3cb277e5b3f37afa2f0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.4.0"
+ watcher:
+ dependency: "direct main"
+ description:
+ name: watcher
+ sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
+ web_socket_channel:
+ dependency: transitive
+ description:
+ name: web_socket_channel
+ sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
+ webkit_inspection_protocol:
+ dependency: transitive
+ description:
+ name: webkit_inspection_protocol
+ sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "80d494c09849dc3f899d227a78c30c5b949b985ededf884cb3f3bcd39f4b447a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.4.1"
+ yaml:
+ dependency: "direct dev"
+ description:
+ name: yaml
+ sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
+sdks:
+ dart: ">=2.19.0 <3.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 91d03cf7a..23242f780 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.0
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 70e1f7081..2c02f8dc1 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -57,6 +57,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);
}
@@ -76,7 +90,8 @@ void npmInstall() =>
run(Platform.isWindows ? "npm.cmd" : "npm", arguments: ["install"]);
@Task('Runs the tasks that are required for running tests.')
-@Depends(format, synchronize, "pkg-npm-dev", npmInstall, "pkg-standalone-dev")
+@Depends(format, synchronize, protobuf, "pkg-npm-dev", npmInstall,
+ "pkg-standalone-dev")
void beforeTest() {}
String get _nuspec => """
@@ -165,3 +180,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;
+}