From e98484afca6f695b3b64975341cfd7c5dee5e4d2 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Mon, 9 Aug 2021 16:11:51 -0700 Subject: [PATCH] Add support for argument lists --- CHANGELOG.md | 3 + lib/src/host_callable.dart | 11 +- lib/src/protofier.dart | 274 +++++++++++++++++++++++++++++++++++++ pubspec.yaml | 4 +- test/function_test.dart | 202 +++++++++++++++++++++++++-- 5 files changed, 471 insertions(+), 23 deletions(-) create mode 100644 lib/src/protofier.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb79cd8..afe916c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 1.0.0-beta.10 +* Support version 1.0.0-beta.12 of the Sass embedded protocol: + * Support `Value.ArgumentList`. + * Support slash-separated lists. ## 1.0.0-beta.9 diff --git a/lib/src/host_callable.dart b/lib/src/host_callable.dart index bb567d41..92411692 100644 --- a/lib/src/host_callable.dart +++ b/lib/src/host_callable.dart @@ -10,8 +10,8 @@ import 'package:sass/sass.dart' as sass; import 'dispatcher.dart'; import 'embedded_sass.pb.dart'; import 'function_registry.dart'; +import 'protofier.dart'; import 'utils.dart'; -import 'value.dart'; /// Returns a Sass callable that invokes a function defined on the host with the /// given [signature]. @@ -41,11 +41,11 @@ sass.Callable hostCallable(Dispatcher dispatcher, FunctionRegistry functions, return sass.Callable.function( name, signature.substring(openParen + 1, signature.length - 1), (arguments) { + var protofier = Protofier(dispatcher, functions, compilationId); var request = OutboundMessage_FunctionCallRequest() ..compilationId = compilationId - ..arguments.addAll([ - for (var argument in arguments) protofyValue(functions, argument) - ]); + ..arguments.addAll( + [for (var argument in arguments) protofier.protofy(argument)]); if (id != null) { request.functionId = id; @@ -57,8 +57,7 @@ sass.Callable hostCallable(Dispatcher dispatcher, FunctionRegistry functions, try { switch (response.whichResult()) { case InboundMessage_FunctionCallResponse_Result.success: - return deprotofyValue( - dispatcher, functions, compilationId, response.success); + return protofier.deprotofyResponse(response); case InboundMessage_FunctionCallResponse_Result.error: throw response.error; diff --git a/lib/src/protofier.dart b/lib/src/protofier.dart new file mode 100644 index 00000000..3c2ef53d --- /dev/null +++ b/lib/src/protofier.dart @@ -0,0 +1,274 @@ +// 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:sass/sass.dart' as sass; + +import 'dispatcher.dart'; +import 'embedded_sass.pb.dart'; +import 'function_registry.dart'; +import 'host_callable.dart'; +import 'utils.dart'; + +/// A class that converts Sass [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. + Value protofy(sass.Value value) { + var result = Value(); + if (value is sass.SassString) { + result.string = Value_String() + ..text = value.text + ..quoted = value.hasQuotes; + } else if (value is sass.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 sass.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 sass.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 sass.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 sass.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 sass.SassFunction) { + result.compilerFunction = _functions.protofy(value); + } else if (value == sass.sassTrue) { + result.singleton = SingletonValue.TRUE; + } else if (value == sass.sassFalse) { + result.singleton = SingletonValue.FALSE; + } else if (value == sass.sassNull) { + result.singleton = SingletonValue.NULL; + } else { + throw "Unknown Value $value"; + } + return result; + } + + /// Converts [separator] to its protocol buffer representation. + ListSeparator _protofySeparator(sass.ListSeparator separator) { + switch (separator) { + case sass.ListSeparator.comma: + return ListSeparator.COMMA; + case sass.ListSeparator.space: + return ListSeparator.SPACE; + case sass.ListSeparator.slash: + return ListSeparator.SLASH; + case sass.ListSeparator.undecided: + return ListSeparator.UNDECIDED; + default: + throw "Unknown ListSeparator $separator"; + } + } + + /// Converts [response]'s return value to its Sass representation. + sass.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. + sass.Value _deprotofy(Value value) { + try { + switch (value.whichValue()) { + case Value_Value.string: + return value.string.text.isEmpty + ? sass.SassString.empty(quotes: value.string.quoted) + : sass.SassString(value.string.text, quotes: value.string.quoted); + + case Value_Value.number: + return sass.SassNumber.withUnits(value.number.value, + numeratorUnits: value.number.numerators, + denominatorUnits: value.number.denominators); + + case Value_Value.rgbColor: + return sass.SassColor.rgb(value.rgbColor.red, value.rgbColor.green, + value.rgbColor.blue, value.rgbColor.alpha); + + case Value_Value.hslColor: + return sass.SassColor.hsl( + value.hslColor.hue, + value.hslColor.saturation, + value.hslColor.lightness, + value.hslColor.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 == sass.ListSeparator.undecided && length > 1) { + throw paramsError( + "List $value can't have an undecided separator because it has " + "$length elements"); + } + + return sass.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 sass.SassList.empty( + separator: separator, brackets: value.list.hasBrackets); + } + + var length = value.list.contents.length; + if (separator == sass.ListSeparator.undecided && length > 1) { + throw paramsError( + "List $value can't have an undecided separator because it has " + "$length elements"); + } + + return sass.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 sass.SassMap.empty() + : sass.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 sass.SassFunction(hostCallable(_dispatcher, _functions, + _compilationId, value.hostFunction.signature, + id: value.hostFunction.id)); + + case Value_Value.singleton: + switch (value.singleton) { + case SingletonValue.TRUE: + return sass.sassTrue; + case SingletonValue.FALSE: + return sass.sassFalse; + case SingletonValue.NULL: + return sass.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}'); + } + } + + /// Returns the argument list in [_argumentLists] that corresponds to [id]. + sass.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. + sass.ListSeparator _deprotofySeparator(ListSeparator separator) { + switch (separator) { + case ListSeparator.COMMA: + return sass.ListSeparator.comma; + case ListSeparator.SPACE: + return sass.ListSeparator.space; + case ListSeparator.SLASH: + return sass.ListSeparator.slash; + case ListSeparator.UNDECIDED: + return sass.ListSeparator.undecided; + default: + throw "Unknown separator $separator"; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c2b3662e..f6bd6ca2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass_embedded -version: 1.0.0-dev +version: 1.0.0-beta.10 description: An implementation of the Sass embedded protocol using Dart Sass. author: Sass Team homepage: https://github.com/sass/dart-sass-embedded @@ -14,7 +14,7 @@ dependencies: async: ">=1.13.0 <3.0.0" meta: ^1.1.0 protobuf: ^2.0.0 - sass: ^1.36.0 + sass: ^1.38.0 source_span: ^1.1.0 stack_trace: ^1.6.0 stream_channel: ">=1.6.0 <3.0.0" diff --git a/test/function_test.dart b/test/function_test.dart index e0ccb6a5..4a263039 100644 --- a/test/function_test.dart +++ b/test/function_test.dart @@ -133,22 +133,74 @@ void main() { await _process.kill(); }); - test("from argument lists", () async { - _process.inbound.add(compileString("a {b: foo(true, false, null)}", - functions: [r"foo($arg, $args...)"])); - var request = getFunctionCallRequest(await _process.outbound.next); + 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() - ..list = (Value_List() - ..separator = ListSeparator.COMMA - ..hasBrackets = false - ..contents.addAll([_false, _null])) - ])); - await _process.kill(); + 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(); + }); }); }); }); @@ -646,6 +698,48 @@ void main() { }); }); + 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); @@ -1159,6 +1253,71 @@ void main() { }); }); + 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(), "()", @@ -1289,6 +1448,13 @@ void main() { 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")); + }); }); }); } @@ -1300,6 +1466,12 @@ Future _protofy(String sassScript) async { @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)"]));