chore: add errors and type assertions to sass-ext

PiperOrigin-RevId: 826549583
This commit is contained in:
Elizabeth Mitchell 2025-10-31 10:51:15 -07:00 committed by Copybara-Service
parent 0a1f511ed8
commit be012462c8
7 changed files with 537 additions and 0 deletions

82
sass/ext/_assert.scss Normal file
View File

@ -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;
}

121
sass/ext/_assert_test.scss Normal file
View File

@ -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'
);
}
}
}

83
sass/ext/_throw.scss Normal file
View File

@ -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);
}

67
sass/ext/_throw_test.scss Normal file
View File

@ -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'
);
}
}
}

66
sass/ext/_type.scss Normal file
View File

@ -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;
}

115
sass/ext/_type_test.scss Normal file
View File

@ -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'
);
}
}
}

View File

@ -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