diff --git a/sass/ext/_assert.scss b/sass/ext/_assert.scss new file mode 100644 index 000000000..0ffbbd8d4 --- /dev/null +++ b/sass/ext/_assert.scss @@ -0,0 +1,82 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Utility assert functions that throw errors. + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'sass:meta'; +@use 'throw'; +@use 'type'; +// go/keep-sorted end + +/// Asserts that the argument is a specific type. If it is, the argument is +/// returned, otherwise an error is thrown. +/// +/// @example scss +/// @mixin multiply($a, $b) { +/// $a: assert.is-type($a, 'number'); +/// $b: assert.is-type($b, 'number'); +/// @return $a * $b; +/// } +/// +/// @function is-empty($value) { +/// $value: assert.is-type( +/// $value, +/// 'list|map|null', +/// $message: '$value must be a list, map, or null', +/// $source: 'is-empty' +/// ); +/// @return $value and list.length($value) == 0; +/// } +/// +/// @param {*} $arg - The argument to check. +/// @param {string} $type - The string type to assert the argument matches. +/// Multiple types may be separated by '|'. +/// @param {string} $message - Optional custom error message. +/// @param {string} $source - Optional source of the error message. +/// @return {*} The argument if it matches the type string. +/// @throw Error if the argument does not match the type string. +@function is-type( + $arg, + $type, + $message: 'Argument must be type #{meta.inspect($type)}. $arg: #{meta.inspect($arg)}', + $source: 'assert.is-type' +) { + @if type.matches($arg, $type) { + @return $arg; + } + @return throw.error($message, $source); +} + +/// Asserts that the argument is a specific type. If it is, the argument is +/// returned, otherwise an error is thrown. +/// +/// @example scss +/// @function get-or-throw($map, $key) { +/// @return assert.not-type( +/// map.get($map, $key), +/// 'null', +/// $message: 'Key must be in the map' +/// ); +/// } +/// +/// @param {*} $arg - The argument to check. +/// @param {string} $type - The string type to assert the argument does not +/// match. Multiple types may be separated by '|'. +/// @param {string} $message - Optional custom error message. +/// @param {string} $source - Optional source of the error message. +/// @return {*} The argument if it does not match the type string. +/// @throw Error if the argument matches the type string. +@function not-type( + $arg, + $type, + $message: 'Argument may not be type #{meta.inspect($type)}. $arg: #{meta.inspect($arg)}', + $source: 'assert.not-type' +) { + @if type.matches($arg, $type) { + @return throw.error($message, $source); + } + @return $arg; +} diff --git a/sass/ext/_assert_test.scss b/sass/ext/_assert_test.scss new file mode 100644 index 000000000..5f855a554 --- /dev/null +++ b/sass/ext/_assert_test.scss @@ -0,0 +1,121 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use 'true' as test; + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'sass:string'; +@use 'assert'; +@use 'throw'; +// go/keep-sorted end + +@include test.describe('assert') { + // Value types + $number: 1; + $string: 'a-string'; + $color: red; + $bool: true; + $null: null; + $list: ('list', 'of', 'values'); + $map: ( + 'map': 'value', + ); + + @include test.describe('is-type()') { + @include test.it('returns the argument when it matches a single type') { + @include test.assert-equal(assert.is-type($number, 'number'), $number); + @include test.assert-equal(assert.is-type($string, 'string'), $string); + @include test.assert-equal(assert.is-type($bool, 'bool'), $bool); + @include test.assert-equal(assert.is-type($null, 'null'), $null); + @include test.assert-equal(assert.is-type($list, 'list'), $list); + @include test.assert-equal(assert.is-type($map, 'map'), $map); + } + + @include test.it( + 'returns the argument when it matches one of multiple types' + ) { + @include test.assert-equal( + assert.is-type($number, 'number|string'), + $number + ); + @include test.assert-equal( + assert.is-type($string, 'number|string'), + $string + ); + @include test.assert-equal(assert.is-type($null, 'list|map|null'), $null); + @include test.assert-equal(assert.is-type($list, 'list|map|null'), $list); + @include test.assert-equal(assert.is-type($map, 'list|map|null'), $map); + } + + @include test.it('throws an error when it does not match the type') { + @include test.assert-true( + throw.get-error(assert.is-type($number, 'string')), + 'number should not match "string" type' + ); + @include test.assert-true( + throw.get-error(assert.is-type($string, 'number')), + 'string should not match "number" type' + ); + @include test.assert-true( + throw.get-error(assert.is-type($null, 'list|map')), + 'null should not match "list|map" type' + ); + } + } + + @include test.describe('not-type()') { + @include test.it( + 'returns the argument when it does not match a single type' + ) { + @include test.assert-equal(assert.not-type($number, 'string'), $number); + @include test.assert-equal(assert.not-type($string, 'number'), $string); + @include test.assert-equal(assert.not-type($bool, 'string'), $bool); + @include test.assert-equal(assert.not-type($null, 'string'), $null); + @include test.assert-equal(assert.not-type($list, 'string'), $list); + @include test.assert-equal(assert.not-type($map, 'string'), $map); + } + + @include test.it( + 'returns the argument when it does not match one of multiple types' + ) { + @include test.assert-equal( + assert.not-type($number, 'string|map'), + $number + ); + @include test.assert-equal( + assert.not-type($string, 'number|map'), + $string + ); + @include test.assert-equal(assert.not-type($null, 'list|map'), $null); + } + + @include test.it('throws an error when it matches the type') { + @include test.assert-true( + throw.get-error(assert.not-type($number, 'number')), + 'number should match "number" type and throw' + ); + @include test.assert-true( + throw.get-error(assert.not-type($string, 'string')), + 'string should match "string" type and throw' + ); + @include test.assert-true( + throw.get-error(assert.not-type($null, 'null')), + 'null should match "null" type and throw' + ); + @include test.assert-true( + throw.get-error(assert.not-type($number, 'number|string')), + 'number should match "number|string" type and throw' + ); + @include test.assert-true( + throw.get-error(assert.not-type($string, 'number|string')), + 'string should match "number|string" type and throw' + ); + @include test.assert-true( + throw.get-error(assert.not-type($null, 'list|map|null')), + 'null should match "list|map|null" type and throw' + ); + } + } +} diff --git a/sass/ext/_throw.scss b/sass/ext/_throw.scss new file mode 100644 index 000000000..cca14dfcf --- /dev/null +++ b/sass/ext/_throw.scss @@ -0,0 +1,83 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Utilities for `sass-true` errors, to support testing error behavior. + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'sass:meta'; +@use 'sass:string'; +// go/keep-sorted end + +@forward 'true' show error; + +/// Returns false if none of the given values are error strings, or returns an +/// error string if any value has an error. +/// +/// This is used to support testing error behavior with `sass-true`, since +/// `@error` messages cannot be caught at build time. +/// +/// @example scss +/// // A function that may return an "ERROR:" string in a test. +/// @function get-value($map, $key) { +/// @if meta.type-of($map) != 'map' { +/// // Identical to `@error 'ERROR: Arg is not a map'` outside of tests. +/// @return throw.error('Arg is not a map'); +/// } +/// @return map.get($map, $key); +/// } +/// +/// // A function that needs to handle potential errors from other functions. +/// @function mix-primary-on-surface($values) { +/// $primary: get-value($values, 'primary'); +/// $surface: get-value($values, 'surface'); +/// $error: throw.get-error($primary, $secondary); +/// @if $error { +/// // Return early to guard logic against additional errors since +/// // $primary or $secondary may be a string instead of a color. +/// @return $error; +/// } +/// +/// @return color.mix($primary, $surface, 10%); +/// } +/// +/// Note: `throw.error()` and `throw.get-error()` are only useful when testing +/// error behavior using `sass-true`. If you are not testing a function, use +/// `@error` instead. +/// +/// @example scss +/// // In a `sass-true` test, `throw.get-error()` can be used to assert that +/// // an error is thrown. +/// @use 'true' as test with ($catch-errors: true); +/// +/// @include test.describe('module.get-value()') { +/// @include test.it('throws an error if the value is not a map') { +/// $result: module.get-value('not a map', 'primary'); +/// @include test.assert-truthy(throw.get-error($result), '$result is an error'); +/// } +/// } +/// +/// @param {*} $error - The value to check. +/// @param {list} $errors - Additional values to check. Useful for checking +/// multiple errors at the same time. +/// @return {string|boolean} The error string if any value is an error, or false +/// otherwise. +@function get-error($error, $errors...) { + @if _is-error($error) { + @return $error; + } + + @each $additional-error in $errors { + @if _is-error($additional-error) { + @return $additional-error; + } + } + + @return false; +} + +@function _is-error($error) { + @return (meta.type-of($error) == 'string') and + (string.index($error, 'ERROR') == 1); +} diff --git a/sass/ext/_throw_test.scss b/sass/ext/_throw_test.scss new file mode 100644 index 000000000..ff9357658 --- /dev/null +++ b/sass/ext/_throw_test.scss @@ -0,0 +1,67 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use 'true' as test; + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'throw'; +// go/keep-sorted end + +@include test.describe('throw') { + @include test.describe('get-error()') { + @include test.it('returns the string if the value is an error string') { + $error: throw.error('test error message'); + @include test.assert-equal(throw.get-error($error), $error); + } + + @include test.it('returns null for non-error strings') { + @include test.assert-false( + throw.get-error('not an error'), + 'get-error("not an error") should return null for non-error strings' + ); + } + + @include test.it('returns null for other values') { + @include test.assert-false( + throw.get-error(1), + 'get-error(1) should return null' + ); + @include test.assert-false( + throw.get-error(true), + 'get-error(true) should return null' + ); + @include test.assert-false( + throw.get-error(null), + 'get-error(null) should return null' + ); + @include test.assert-false( + throw.get-error(()), + 'get-error(()) should return null' + ); + } + + @include test.it( + 'returns the first error if multiple values are provided' + ) { + $error: throw.error('test error message'); + @include test.assert-equal( + throw.get-error( + 'not an error', + 'still not an error', + $error, + 'not an error either' + ), + $error + ); + } + + @include test.it('returns null if multiple non-error values are provided') { + @include test.assert-false( + throw.get-error('not an error', 'still not an error'), + 'get-error("not an error", "still not an error") should return null' + ); + } + } +} diff --git a/sass/ext/_type.scss b/sass/ext/_type.scss new file mode 100644 index 000000000..9737f750f --- /dev/null +++ b/sass/ext/_type.scss @@ -0,0 +1,66 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Utilities for Sass type checking. + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'sass:meta'; +@use 'sass:string'; +@use 'throw'; +// go/keep-sorted end + +/// Returns true if the given value matches the provided type string. +/// +/// The type string supports multiple types separated by `|`, such as +/// `'string|null'`. Type options are any values returned by `meta.type-of()`. +/// +/// @example scss +/// @function is-empty($value) { +/// @if type.matches($value, 'list|map') { +/// @return list.length($value) == 0; +/// } +/// @if type.matches($value, 'string') { +/// @return $value == ''; +/// } +/// @return type.matches($value, 'null'); +/// } +/// +/// @param {*} $value - The value to check the type of. +/// @param {string} $type-string - The type to check. May be multiple types +/// separated by `|`. +/// @return {boolean} True if the value matches the type string. +@function matches($value, $type-string) { + @if meta.type-of($type-string) != 'string' or $type-string == '' { + @return throw.error( + '$type-string must be a non-empty string', + $source: 'type.matches' + ); + } + @if string.index($type-string, ' ') { + @return throw.error( + '$type-string may not contain spaces', + $source: 'type.matches' + ); + } + @if string.index($type-string, 'boolean') { + @return throw.error( + 'Use "bool" instead of "boolean"', + $source: 'type.matches' + ); + } + + $value-type: meta.type-of($value); + @if $value-type == $type-string { + @return true; + } + + @each $type in string.split($type-string, '|') { + @if $value-type == $type { + @return true; + } + } + + @return false; +} diff --git a/sass/ext/_type_test.scss b/sass/ext/_type_test.scss new file mode 100644 index 000000000..7aade6a27 --- /dev/null +++ b/sass/ext/_type_test.scss @@ -0,0 +1,115 @@ +// +// Copyright 2025 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +@use 'true' as test; + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'throw'; +@use 'type'; +// go/keep-sorted end + +@include test.describe('type') { + @include test.describe('matches()') { + @include test.it( + 'returns true when the value matches a single string type' + ) { + @include test.assert-true( + type.matches(1, 'number'), + '1 should match "number" type' + ); + @include test.assert-true( + type.matches('foo', 'string'), + '"foo" should match "string" type' + ); + @include test.assert-true( + type.matches(true, 'bool'), + 'true should match "bool" type' + ); + @include test.assert-true( + type.matches(null, 'null'), + 'null should match "null" type' + ); + @include test.assert-true( + type.matches((1, 2, 3), 'list'), + '(1, 2, 3) should match "list" type' + ); + $map: ( + 'key': 'value', + ); + @include test.assert-true( + type.matches($map, 'map'), + '("key": "value") should match "map" type' + ); + } + + @include test.it( + 'returns true when the value matches multiple string types' + ) { + @include test.assert-true( + type.matches(1, 'number|string'), + '1 should match "number|string" type' + ); + @include test.assert-true( + type.matches('foo', 'number|string'), + '"foo" should match "number|string" type' + ); + @include test.assert-true( + type.matches(null, 'number|string|null'), + 'null should match "number|string|null" type' + ); + @include test.assert-true( + type.matches((), 'list|map'), + '() should match "list|map" type' + ); + } + + @include test.it('returns false when the value does not match any types') { + @include test.assert-false( + type.matches(1, 'string'), + '1 should not match "string" type' + ); + @include test.assert-false( + type.matches('foo', 'number'), + '"foo" should not match "number" type' + ); + @include test.assert-false( + type.matches(1, 'list|map|null'), + '1 should not match "list|map|null" type' + ); + } + + @include test.it('throws an error when the type string is empty') { + @include test.assert-true( + throw.get-error(type.matches('foo', '')), + 'should throw error' + ); + } + + @include test.it('throws an error when the type string is not a string') { + @include test.assert-true( + throw.get-error(type.matches(1, 1)), + 'should throw error' + ); + } + + @include test.it('throws an error when the type string contains spaces') { + @include test.assert-true( + throw.get-error(type.matches('foo', 'number | string')), + 'should throw error' + ); + } + + @include test.it('throws an error if the type string uses "boolean" instead of "bool"') { + @include test.assert-true( + throw.get-error(type.matches(true, 'boolean')), + 'should throw error' + ); + @include test.assert-true( + throw.get-error(type.matches(1, 'number|boolean')), + 'should throw error' + ); + } + } +} diff --git a/sass/ext/tests.scss b/sass/ext/tests.scss index ea754dccf..33d0e0237 100644 --- a/sass/ext/tests.scss +++ b/sass/ext/tests.scss @@ -8,5 +8,8 @@ ); // go/keep-sorted start +@use 'assert_test'; @use 'string_ext_test'; +@use 'throw_test'; +@use 'type_test'; // go/keep-sorted end