diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7b5d513..8ddedb98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ ### JavaScript API +* Add a new `SassCalculation` type that represents the calculation objects added + in Dart Sass 1.40.0. + +* Add `Value.assertCalculation()`, which returns the value if it's a + `SassCalculation` and throws an error otherwise. + * Produce a better error message when an environment that supports some Node.js APIs loads the browser entrypoint but attempts to access the filesystem. diff --git a/lib/src/node.dart b/lib/src/node.dart index 41513da75..31df5acc8 100644 --- a/lib/src/node.dart +++ b/lib/src/node.dart @@ -27,6 +27,9 @@ void main() { exports.Value = valueClass; exports.SassBoolean = booleanClass; exports.SassArgumentList = argumentListClass; + exports.SassCalculation = calculationClass; + exports.CalculationOperation = calculationOperationClass; + exports.CalculationInterpolation = calculationInterpolationClass; exports.SassColor = colorClass; exports.SassFunction = functionClass; exports.SassList = listClass; diff --git a/lib/src/node/compile.dart b/lib/src/node/compile.dart index 820f19119..1c2178741 100644 --- a/lib/src/node/compile.dart +++ b/lib/src/node/compile.dart @@ -225,6 +225,40 @@ Importer _parseImporter(Object? importer) { } } +/// Implements the simplification algorithm for custom function return `Value`s. +/// {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue} +Value _simplifyValue(Value value) => switch (value) { + SassCalculation() => switch (( + // Match against... + value.name, // ...the calculation name + value.arguments // ...and simplified arguments + .map(_simplifyCalcArg) + .toList() + )) { + ('calc', [var first]) => first as Value, + ('calc', _) => + throw ArgumentError('calc() requires exactly one argument.'), + ('clamp', [var min, var value, var max]) => + SassCalculation.clamp(min, value, max), + ('clamp', _) => + throw ArgumentError('clamp() requires exactly 3 arguments.'), + ('min', var args) => SassCalculation.min(args), + ('max', var args) => SassCalculation.max(args), + (var name, _) => throw ArgumentError( + '"$name" is not a recognized calculation type.'), + }, + _ => value, + }; + +/// Handles simplifying calculation arguments, which are not guaranteed to be +/// Value instances. +Object _simplifyCalcArg(Object value) => switch (value) { + SassCalculation() => _simplifyValue(value), + CalculationOperation() => SassCalculation.operate(value.operator, + _simplifyCalcArg(value.left), _simplifyCalcArg(value.right)), + _ => value, + }; + /// Parses `functions` from [record] into a list of [Callable]s or /// [AsyncCallable]s. /// @@ -239,7 +273,7 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { late Callable callable; callable = Callable.fromSignature(signature, (arguments) { var result = (callback as Function)(toJSArray(arguments)); - if (result is Value) return result; + if (result is Value) return _simplifyValue(result); if (isPromise(result)) { throw 'Invalid return value for custom function ' '"${callable.name}":\n' @@ -259,7 +293,7 @@ List _parseFunctions(Object? functions, {bool asynch = false}) { result = await promiseToFuture(result as Promise); } - if (result is Value) return result; + if (result is Value) return _simplifyValue(result); throw 'Invalid return value for custom function ' '"${callable.name}": $result is not a sass.Value.'; }); diff --git a/lib/src/node/exports.dart b/lib/src/node/exports.dart index c301e270e..936b63e2a 100644 --- a/lib/src/node/exports.dart +++ b/lib/src/node/exports.dart @@ -26,6 +26,9 @@ class Exports { // Value APIs external set Value(JSClass function); external set SassArgumentList(JSClass function); + external set SassCalculation(JSClass function); + external set CalculationOperation(JSClass function); + external set CalculationInterpolation(JSClass function); external set SassBoolean(JSClass function); external set SassColor(JSClass function); external set SassFunction(JSClass function); diff --git a/lib/src/node/reflection.dart b/lib/src/node/reflection.dart index c3a5d3c54..5c4efa33e 100644 --- a/lib/src/node/reflection.dart +++ b/lib/src/node/reflection.dart @@ -76,6 +76,16 @@ extension JSClassExtension on JSClass { allowInteropCaptureThis((Object self, _, __, [___]) => inspect(self))); } + /// Defines a static method with the given [name] and [body]. + void defineStaticMethod(String name, Function body) { + setProperty(this, name, allowInteropNamed(name, body)); + } + + /// A shorthand for calling [defineStaticMethod] multiple times. + void defineStaticMethods(Map methods) { + methods.forEach(defineStaticMethod); + } + /// Defines a method with the given [name] and [body]. /// /// The [body] should take an initial `self` parameter, representing the diff --git a/lib/src/node/value.dart b/lib/src/node/value.dart index 5fcc26b12..f8697efea 100644 --- a/lib/src/node/value.dart +++ b/lib/src/node/value.dart @@ -10,6 +10,7 @@ import 'reflection.dart'; export 'value/argument_list.dart'; export 'value/boolean.dart'; +export 'value/calculation.dart'; export 'value/color.dart'; export 'value/function.dart'; export 'value/list.dart'; @@ -36,6 +37,8 @@ final JSClass valueClass = () { 'get': (Value self, num index) => index < 1 && index >= -1 ? self : undefined, 'assertBoolean': (Value self, [String? name]) => self.assertBoolean(name), + 'assertCalculation': (Value self, [String? name]) => + self.assertCalculation(name), 'assertColor': (Value self, [String? name]) => self.assertColor(name), 'assertFunction': (Value self, [String? name]) => self.assertFunction(name), 'assertMap': (Value self, [String? name]) => self.assertMap(name), diff --git a/lib/src/node/value/calculation.dart b/lib/src/node/value/calculation.dart new file mode 100644 index 000000000..ae3a8bd88 --- /dev/null +++ b/lib/src/node/value/calculation.dart @@ -0,0 +1,133 @@ +// Copyright 2023 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import 'package:collection/collection.dart'; +import 'package:node_interop/js.dart'; +import 'package:sass/src/node/immutable.dart'; +import 'package:sass/src/node/utils.dart'; + +import '../../value.dart'; +import '../reflection.dart'; + +/// Check that [arg] is a valid argument to a calculation function. +void _assertCalculationValue(Object arg) => switch (arg) { + SassNumber() || + SassString(hasQuotes: false) || + SassCalculation() || + CalculationOperation() || + CalculationInterpolation() => + null, + _ => jsThrow(JsError( + 'Argument `$arg` must be one of SassNumber, unquoted SassString, ' + 'SassCalculation, CalculationOperation, CalculationInterpolation')), + }; + +/// Check that [arg] is an unquoted string or interpolation. +bool _isValidClampArg(Object? arg) => switch (arg) { + CalculationInterpolation() || SassString(hasQuotes: false) => true, + _ => false, + }; + +/// The JavaScript `SassCalculation` class. +final JSClass calculationClass = () { + var jsClass = + createJSClass('sass.SassCalculation', (Object self, [Object? _]) { + jsThrow(JsError("new sass.SassCalculation() isn't allowed")); + }); + + jsClass.defineStaticMethods({ + 'calc': (Object argument) { + _assertCalculationValue(argument); + return SassCalculation.unsimplified('calc', [argument]); + }, + 'min': (Object arguments) { + var argList = jsToDartList(arguments).cast(); + argList.forEach(_assertCalculationValue); + return SassCalculation.unsimplified('min', argList); + }, + 'max': (Object arguments) { + var argList = jsToDartList(arguments).cast(); + argList.forEach(_assertCalculationValue); + return SassCalculation.unsimplified('max', argList); + }, + 'clamp': (Object min, [Object? value, Object? max]) { + if ((value == null && !_isValidClampArg(min)) || + (max == null && ![min, value].any(_isValidClampArg))) { + jsThrow(JsError('Expected at least one SassString or ' + 'CalculationInterpolation in `${[ + min, + value, + max + ].whereNotNull()}`')); + } + [min, value, max].whereNotNull().forEach(_assertCalculationValue); + return SassCalculation.unsimplified( + 'clamp', [min, value, max].whereNotNull()); + } + }); + + jsClass.defineMethods({ + 'assertCalculation': (SassCalculation self, [String? name]) => self, + }); + + jsClass.defineGetters({ + // The `name` getter is included by default by `createJSClass` + 'arguments': (SassCalculation self) => ImmutableList(self.arguments), + }); + + getJSClass(SassCalculation.unsimplified('calc', [SassNumber(1)])) + .injectSuperclass(jsClass); + return jsClass; +}(); + +/// The JavaScript `CalculationOperation` class. +final JSClass calculationOperationClass = () { + var jsClass = createJSClass('sass.CalculationOperation', + (Object self, String strOperator, Object left, Object right) { + var operator = CalculationOperator.values + .firstWhereOrNull((value) => value.operator == strOperator); + if (operator == null) { + jsThrow(JsError('Invalid operator: $strOperator')); + } + _assertCalculationValue(left); + _assertCalculationValue(right); + return SassCalculation.operateInternal(operator, left, right, + inMinMax: false, simplify: false); + }); + + jsClass.defineMethods({ + 'equals': (CalculationOperation self, Object other) => self == other, + 'hashCode': (CalculationOperation self) => self.hashCode, + }); + + jsClass.defineGetters({ + 'operator': (CalculationOperation self) => self.operator.operator, + 'left': (CalculationOperation self) => self.left, + 'right': (CalculationOperation self) => self.right, + }); + + getJSClass(SassCalculation.operateInternal( + CalculationOperator.plus, SassNumber(1), SassNumber(1), + inMinMax: false, simplify: false)) + .injectSuperclass(jsClass); + return jsClass; +}(); + +/// The JavaScript `CalculationInterpolation` class. +final JSClass calculationInterpolationClass = () { + var jsClass = createJSClass('sass.CalculationInterpolation', + (Object self, String value) => CalculationInterpolation(value)); + + jsClass.defineMethods({ + 'equals': (CalculationInterpolation self, Object other) => self == other, + 'hashCode': (CalculationInterpolation self) => self.hashCode, + }); + + jsClass.defineGetters({ + 'value': (CalculationInterpolation self) => self.value, + }); + + getJSClass(CalculationInterpolation('')).injectSuperclass(jsClass); + return jsClass; +}(); diff --git a/lib/src/value/calculation.dart b/lib/src/value/calculation.dart index 74375b280..12a53e947 100644 --- a/lib/src/value/calculation.dart +++ b/lib/src/value/calculation.dart @@ -328,22 +328,28 @@ class SassCalculation extends Value { /// {@category Value} @sealed class CalculationOperation { + /// We use a getters to allow overriding the logic in the JS API + /// implementation. + /// The operator. - final CalculationOperator operator; + CalculationOperator get operator => _operator; + final CalculationOperator _operator; /// The left-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. - final Object left; + Object get left => _left; + final Object _left; /// The right-hand operand. /// /// This is either a [SassNumber], a [SassCalculation], an unquoted /// [SassString], a [CalculationOperation], or a [CalculationInterpolation]. - final Object right; + Object get right => _right; + final Object _right; - CalculationOperation._(this.operator, this.left, this.right); + CalculationOperation._(this._operator, this._left, this._right); bool operator ==(Object other) => other is CalculationOperation && @@ -403,9 +409,13 @@ enum CalculationOperator { /// {@category Value} @sealed class CalculationInterpolation { - final String value; + /// We use a getters to allow overriding the logic in the JS API + /// implementation. + + String get value => _value; + final String _value; - CalculationInterpolation(this.value); + CalculationInterpolation(this._value); bool operator ==(Object other) => other is CalculationInterpolation && value == other.value; diff --git a/pkg/sass_api/CHANGELOG.md b/pkg/sass_api/CHANGELOG.md index 8d5b9eeff..f2fb16980 100644 --- a/pkg/sass_api/CHANGELOG.md +++ b/pkg/sass_api/CHANGELOG.md @@ -1,3 +1,7 @@ +## 7.2.0 + +* No user-visible changes. + ## 7.1.6 * No user-visible changes. diff --git a/pkg/sass_api/pubspec.yaml b/pkg/sass_api/pubspec.yaml index 960cba9a5..fdbdd5c25 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.1.6 +version: 7.2.0 description: Additional APIs for Dart Sass. homepage: https://github.com/sass/dart-sass @@ -10,7 +10,7 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: - sass: 1.63.6 + sass: 1.64.0 dev_dependencies: dartdoc: ^5.0.0 diff --git a/pubspec.yaml b/pubspec.yaml index 689e345f3..d386e6beb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sass -version: 1.64.0-dev +version: 1.64.0 description: A Sass implementation in Dart. homepage: https://github.com/sass/dart-sass diff --git a/test/dart_api/value/boolean_test.dart b/test/dart_api/value/boolean_test.dart index a416e1b8f..8a0579707 100644 --- a/test/dart_api/value/boolean_test.dart +++ b/test/dart_api/value/boolean_test.dart @@ -28,6 +28,7 @@ void main() { }); test("isn't any other type", () { + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); @@ -54,6 +55,7 @@ void main() { }); test("isn't any other type", () { + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/calculation_test.dart b/test/dart_api/value/calculation_test.dart new file mode 100644 index 000000000..594842cee --- /dev/null +++ b/test/dart_api/value/calculation_test.dart @@ -0,0 +1,73 @@ +// Copyright 2018 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +@TestOn('vm') + +import 'package:test/test.dart'; + +import 'package:sass/sass.dart'; + +import 'utils.dart'; + +void main() { + group("new SassCalculation", () { + late Value value; + setUp(() => value = SassCalculation.unsimplified('calc', [SassNumber(1)])); + + test("is a calculation", () { + expect(value.assertCalculation(), equals(value)); + }); + + test("isn't any other type", () { + expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertColor, throwsSassScriptException); + expect(value.assertFunction, throwsSassScriptException); + expect(value.assertMap, throwsSassScriptException); + expect(value.tryMap(), isNull); + expect(value.assertNumber, throwsSassScriptException); + expect(value.assertString, throwsSassScriptException); + }); + }); + + group('SassCalculation simplifies', () { + test('calc()', () { + expect(SassCalculation.calc(SassNumber(1)).assertNumber(), + equals(SassNumber(1))); + }); + + test('min()', () { + expect(SassCalculation.min([SassNumber(1), SassNumber(2)]).assertNumber(), + equals(SassNumber(1))); + }); + + test('max()', () { + expect(SassCalculation.max([SassNumber(1), SassNumber(2)]).assertNumber(), + equals(SassNumber(2))); + }); + + test('clamp()', () { + expect( + SassCalculation.clamp(SassNumber(1), SassNumber(2), SassNumber(3)) + .assertNumber(), + equals(SassNumber(2))); + }); + + test('operations', () { + expect( + SassCalculation.calc(SassCalculation.operate( + CalculationOperator.plus, + SassCalculation.operate( + CalculationOperator.minus, + SassCalculation.operate( + CalculationOperator.times, + SassCalculation.operate(CalculationOperator.dividedBy, + SassNumber(5), SassNumber(2)), + SassNumber(3)), + SassNumber(4)), + SassNumber(5))) + .assertNumber(), + equals(SassNumber(8.5))); + }); + }); +} diff --git a/test/dart_api/value/color_test.dart b/test/dart_api/value/color_test.dart index 7d9521beb..6535c8a0c 100644 --- a/test/dart_api/value/color_test.dart +++ b/test/dart_api/value/color_test.dart @@ -181,6 +181,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); expect(value.tryMap(), isNull); diff --git a/test/dart_api/value/function_test.dart b/test/dart_api/value/function_test.dart index 8fe26dfc2..03776d07c 100644 --- a/test/dart_api/value/function_test.dart +++ b/test/dart_api/value/function_test.dart @@ -29,6 +29,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); expect(value.tryMap(), isNull); diff --git a/test/dart_api/value/list_test.dart b/test/dart_api/value/list_test.dart index f94bb361c..e49605ce5 100644 --- a/test/dart_api/value/list_test.dart +++ b/test/dart_api/value/list_test.dart @@ -107,6 +107,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/map_test.dart b/test/dart_api/value/map_test.dart index 00a57aa2b..e82ef7f17 100644 --- a/test/dart_api/value/map_test.dart +++ b/test/dart_api/value/map_test.dart @@ -133,6 +133,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertNumber, throwsSassScriptException); diff --git a/test/dart_api/value/null_test.dart b/test/dart_api/value/null_test.dart index b02649db0..4badc075a 100644 --- a/test/dart_api/value/null_test.dart +++ b/test/dart_api/value/null_test.dart @@ -24,6 +24,7 @@ void main() { test("isn't any type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/number_test.dart b/test/dart_api/value/number_test.dart index f0e9d8872..42741fdf9 100644 --- a/test/dart_api/value/number_test.dart +++ b/test/dart_api/value/number_test.dart @@ -130,6 +130,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/test/dart_api/value/string_test.dart b/test/dart_api/value/string_test.dart index d2330af64..61d8023b2 100644 --- a/test/dart_api/value/string_test.dart +++ b/test/dart_api/value/string_test.dart @@ -34,6 +34,7 @@ void main() { test("isn't any other type", () { expect(value.assertBoolean, throwsSassScriptException); + expect(value.assertCalculation, throwsSassScriptException); expect(value.assertColor, throwsSassScriptException); expect(value.assertFunction, throwsSassScriptException); expect(value.assertMap, throwsSassScriptException); diff --git a/tool/grind.dart b/tool/grind.dart index 3cbed31bb..76f0b66e1 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -54,6 +54,9 @@ void main(List args) { 'Logger', 'SassArgumentList', 'SassBoolean', + 'SassCalculation', + 'CalculationOperation', + 'CalculationInterpolation', 'SassColor', 'SassFunction', 'SassList',