From 02684ec9f5a83acb0d23c023588c09ba66c8b520 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Fri, 7 Jul 2023 00:21:50 +0000 Subject: [PATCH 01/14] Add support for SassCalculation --- lib/src/value/calculations.ts | 157 ++++++++++++++++++++++++++++++++++ lib/src/value/index.ts | 11 +++ 2 files changed, 168 insertions(+) create mode 100644 lib/src/value/calculations.ts diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts new file mode 100644 index 00000000..728aeeea --- /dev/null +++ b/lib/src/value/calculations.ts @@ -0,0 +1,157 @@ +import {hash, List, ValueObject} from 'immutable'; + +import {Value} from './index'; +import {SassNumber} from './number'; +import {SassString} from './string'; + +export type CalculationValue = + | SassNumber + | SassCalculation + | SassString + | CalculationOperation + | CalculationInterpolation; + +type CalculationValueIterable = CalculationValue[] | List; + +function checkUnquotedString(value: CalculationValue): void { + if (value instanceof SassString && value.hasQuotes) { + throw new Error(`Expected ${value} to be an unquoted string.`); + } +} + +export class SassCalculation extends Value { + readonly name: string; + readonly arguments: List; + + private constructor(name: string, args: CalculationValueIterable) { + super(); + this.name = name; + this.arguments = List(args); + } + + static calc(argument: CalculationValue): SassCalculation { + checkUnquotedString(argument); + return new SassCalculation('calc', [argument]); + } + + static min(args: CalculationValueIterable): SassCalculation { + args.forEach(checkUnquotedString); + return new SassCalculation('min', args); + } + + static max(args: CalculationValueIterable): SassCalculation { + args.forEach(checkUnquotedString); + return new SassCalculation('max', args); + } + + static clamp( + min: CalculationValue, + value?: CalculationValue, + max?: CalculationValue + ): SassCalculation { + if (value === undefined && max === undefined) { + let minString: string; + if (min instanceof SassString) { + minString = min.text; + } else if (min instanceof CalculationInterpolation) { + minString = min.value; + } else { + throw new Error( + '`value` and `max` are both undefined, but `min` is not a SassString or CalculationInterpolation.' + ); + } + const values = minString.split(',').map(s => { + const parsed = parseFloat(s.trim()); + return isNaN(parsed) ? undefined : new SassNumber(parsed); + }); + const error = new Error( + `Expected \`min\` to be a comma-separated list of numbers, got \`${min}\`` + ); + if (values[0] === undefined) { + throw error; + } else if (values.length == 2) { + [min, value] = values; + } else if (values.length == 3) { + [min, value, max] = values; + } else { + throw error; + } + } else if (value === undefined && max !== undefined) { + throw new Error('`value` is undefined but `max` is defined.'); + } + const args = [min]; + if (value !== undefined) args.push(value); + if (max !== undefined) args.push(max); + args.forEach(checkUnquotedString); + return new SassCalculation('clamp', args); + } + + assertCalculation(): SassCalculation { + return this; + } + + equals(other: Value): boolean { + return ( + other instanceof SassCalculation && + this.name === other.name && + this.arguments.equals(other.arguments) + ); + } + + hashCode(): number { + return hash(this.name) ^ this.arguments.hashCode(); + } + + toString(): string { + return `${this.name}(${this.arguments.join(', ')})`; + } +} + +export type CalculationOperator = '+' | '-' | '*' | '/'; + +export class CalculationOperation implements ValueObject { + readonly operator: CalculationOperator; + readonly left: CalculationValue; + readonly right: CalculationValue; + + constructor( + operator: CalculationOperator, + left: CalculationValue, + right: CalculationValue + ) { + this.operator = operator; + this.left = left; + this.right = right; + } + + equals(other: Value): boolean { + return ( + other instanceof CalculationOperation && + this.operator === other.operator && + this.left === other.left && + this.right === other.right + ); + } + + hashCode(): number { + return hash(this.operator) ^ hash(this.left) ^ hash(this.right); + } +} + +export class CalculationInterpolation implements ValueObject { + readonly value: string; + + constructor(value: string) { + this.value = value; + } + + equals(other: Value): boolean { + return ( + other instanceof CalculationInterpolation && this.value === other.value + ); + } + + hashCode(): number { + return hash(this.value); + } +} diff --git a/lib/src/value/index.ts b/lib/src/value/index.ts index 536eab64..d7c95f8e 100644 --- a/lib/src/value/index.ts +++ b/lib/src/value/index.ts @@ -11,6 +11,7 @@ import {SassMap} from './map'; import {SassNumber} from './number'; import {SassString} from './string'; import {valueError} from '../utils'; +import {SassCalculation} from './calculations'; /** * A SassScript value. @@ -106,6 +107,16 @@ export abstract class Value implements ValueObject { throw valueError(`${this} is not a boolean`, name); } + /** + * Casts `this` to `SassCalculation`; throws if `this` isn't a calculation. + * + * If `this` came from a function argument, `name` is the argument name + * (without the `$`) and is used for error reporting. + */ + assertCalculation(name?: string): SassCalculation { + throw valueError(`${this} is not a calculation`, name); + } + /** * Casts `this` to `SassColor`; throws if `this` isn't a color. * From 473a9381de4f924272aa662cfb071d029df278a1 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Fri, 7 Jul 2023 00:34:58 +0000 Subject: [PATCH 02/14] Lint --- lib/src/value/calculations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index 728aeeea..280d192f 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -69,9 +69,9 @@ export class SassCalculation extends Value { ); if (values[0] === undefined) { throw error; - } else if (values.length == 2) { + } else if (values.length === 2) { [min, value] = values; - } else if (values.length == 3) { + } else if (values.length === 3) { [min, value, max] = values; } else { throw error; From 53de5e5076dc9b479746c5d288dd168a2524b854 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Fri, 7 Jul 2023 23:53:56 +0000 Subject: [PATCH 03/14] Add legal notice --- lib/src/value/calculations.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index 280d192f..8babbec9 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -1,3 +1,7 @@ +// 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 {hash, List, ValueObject} from 'immutable'; import {Value} from './index'; From a17f1e0454d33584d4739895eab4a1d0956c64f7 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Sat, 8 Jul 2023 00:38:09 +0000 Subject: [PATCH 04/14] Expose calculation types --- lib/index.mjs | 20 ++++++++++++++++++++ lib/index.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/lib/index.mjs b/lib/index.mjs index 31a57f8e..374e98e9 100644 --- a/lib/index.mjs +++ b/lib/index.mjs @@ -5,8 +5,12 @@ export const compileAsync = sass.compileAsync; export const compileString = sass.compileString; export const compileStringAsync = sass.compileStringAsync; export const Logger = sass.Logger; +export const CalculationInterpolation = sass.CalculationInterpolation +export const CalculationOperation = sass.CalculationOperation +export const CalculationOperator = sass.CalculationOperator export const SassArgumentList = sass.SassArgumentList; export const SassBoolean = sass.SassBoolean; +export const SassCalculation = sass.SassCalculation export const SassColor = sass.SassColor; export const SassFunction = sass.SassFunction; export const SassList = sass.SassList; @@ -59,6 +63,18 @@ export default { defaultExportDeprecation(); return sass.Logger; }, + get CalculationOperation() { + defaultExportDeprecation(); + return sass.CalculationOperation; + }, + get CalculationOperator() { + defaultExportDeprecation(); + return sass.CalculationOperator; + }, + get CalculationInterpolation() { + defaultExportDeprecation(); + return sass.CalculationInterpolation; + }, get SassArgumentList() { defaultExportDeprecation(); return sass.SassArgumentList; @@ -67,6 +83,10 @@ export default { defaultExportDeprecation(); return sass.SassBoolean; }, + get SassCalculation() { + defaultExportDeprecation(); + return sass.SassCalculation; + }, get SassColor() { defaultExportDeprecation(); return sass.SassColor; diff --git a/lib/index.ts b/lib/index.ts index 3d75ea05..c79ffec2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,6 +16,12 @@ export {SassNumber} from './src/value/number'; export {SassString} from './src/value/string'; export {Value} from './src/value'; export {sassNull} from './src/value/null'; +export { + CalculationOperation, + CalculationOperator, + CalculationInterpolation, + SassCalculation, +} from './src/value/calculations'; export * as types from './src/legacy/value'; export {Exception} from './src/exception'; From cd6a2b976aa5aa7ff3491e6266b49e2b049d2a6a Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Sat, 8 Jul 2023 00:38:21 +0000 Subject: [PATCH 05/14] Enforce operator validation --- lib/src/value/calculations.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index 8babbec9..6343ab86 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -111,7 +111,8 @@ export class SassCalculation extends Value { } } -export type CalculationOperator = '+' | '-' | '*' | '/'; +const operators = ['+', '-', '*', '/'] as const; +export type CalculationOperator = typeof operators[number]; export class CalculationOperation implements ValueObject { readonly operator: CalculationOperator; @@ -123,6 +124,9 @@ export class CalculationOperation implements ValueObject { left: CalculationValue, right: CalculationValue ) { + if (!operators.includes(operator)) { + throw new Error(`Unknown operator ${operator}`); + } this.operator = operator; this.left = left; this.right = right; From ae1c7f33259c5632ca2e0d15370f67c3298087ed Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Tue, 11 Jul 2023 21:23:27 +0000 Subject: [PATCH 06/14] Address review --- lib/src/value/calculations.ts | 90 ++++++++++++++--------------------- 1 file changed, 35 insertions(+), 55 deletions(-) diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index 6343ab86..d8f3927e 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -17,34 +17,49 @@ export type CalculationValue = type CalculationValueIterable = CalculationValue[] | List; -function checkUnquotedString(value: CalculationValue): void { +function assertUnquotedString(value: unknown): void { if (value instanceof SassString && value.hasQuotes) { throw new Error(`Expected ${value} to be an unquoted string.`); } + if (value instanceof CalculationOperation) { + assertUnquotedString(value.left); + assertUnquotedString(value.right); + } } +function assertUnquotedStringOrInterpolation(value: unknown): void { + if ( + (!(value instanceof SassString) && + !(value instanceof CalculationInterpolation)) || + (value instanceof SassString && value.hasQuotes) + ) { + throw new Error( + `Expected ${value} to be an unquoted SassString or CalculationInterpolation.` + ); + } +} + +/* A SassScript calculation */ export class SassCalculation extends Value { - readonly name: string; readonly arguments: List; - private constructor(name: string, args: CalculationValueIterable) { + private constructor(readonly name: string, args: CalculationValueIterable) { super(); - this.name = name; this.arguments = List(args); } static calc(argument: CalculationValue): SassCalculation { - checkUnquotedString(argument); + assertUnquotedString(argument); return new SassCalculation('calc', [argument]); } static min(args: CalculationValueIterable): SassCalculation { - args.forEach(checkUnquotedString); + args.forEach(assertUnquotedString); return new SassCalculation('min', args); } static max(args: CalculationValueIterable): SassCalculation { - args.forEach(checkUnquotedString); + args.forEach(assertUnquotedString); return new SassCalculation('max', args); } @@ -53,40 +68,16 @@ export class SassCalculation extends Value { value?: CalculationValue, max?: CalculationValue ): SassCalculation { - if (value === undefined && max === undefined) { - let minString: string; - if (min instanceof SassString) { - minString = min.text; - } else if (min instanceof CalculationInterpolation) { - minString = min.value; - } else { - throw new Error( - '`value` and `max` are both undefined, but `min` is not a SassString or CalculationInterpolation.' - ); - } - const values = minString.split(',').map(s => { - const parsed = parseFloat(s.trim()); - return isNaN(parsed) ? undefined : new SassNumber(parsed); - }); - const error = new Error( - `Expected \`min\` to be a comma-separated list of numbers, got \`${min}\`` - ); - if (values[0] === undefined) { - throw error; - } else if (values.length === 2) { - [min, value] = values; - } else if (values.length === 3) { - [min, value, max] = values; - } else { - throw error; - } - } else if (value === undefined && max !== undefined) { - throw new Error('`value` is undefined but `max` is defined.'); + if (value === undefined) { + assertUnquotedStringOrInterpolation(min); + } else if (max === undefined) { + assertUnquotedStringOrInterpolation(min); + assertUnquotedStringOrInterpolation(value); } const args = [min]; if (value !== undefined) args.push(value); if (max !== undefined) args.push(max); - args.forEach(checkUnquotedString); + args.forEach(assertUnquotedString); return new SassCalculation('clamp', args); } @@ -94,7 +85,7 @@ export class SassCalculation extends Value { return this; } - equals(other: Value): boolean { + equals(other: unknown): boolean { return ( other instanceof SassCalculation && this.name === other.name && @@ -115,24 +106,17 @@ const operators = ['+', '-', '*', '/'] as const; export type CalculationOperator = typeof operators[number]; export class CalculationOperation implements ValueObject { - readonly operator: CalculationOperator; - readonly left: CalculationValue; - readonly right: CalculationValue; - constructor( - operator: CalculationOperator, - left: CalculationValue, - right: CalculationValue + readonly operator: CalculationOperator, + readonly left: CalculationValue, + readonly right: CalculationValue ) { if (!operators.includes(operator)) { throw new Error(`Unknown operator ${operator}`); } - this.operator = operator; - this.left = left; - this.right = right; } - equals(other: Value): boolean { + equals(other: unknown): boolean { return ( other instanceof CalculationOperation && this.operator === other.operator && @@ -147,13 +131,9 @@ export class CalculationOperation implements ValueObject { } export class CalculationInterpolation implements ValueObject { - readonly value: string; - - constructor(value: string) { - this.value = value; - } + constructor(readonly value: string) {} - equals(other: Value): boolean { + equals(other: unknown): boolean { return ( other instanceof CalculationInterpolation && this.value === other.value ); From 4e19108c65caae9ee275cb37620285e2d4262842 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Thu, 13 Jul 2023 00:39:32 +0000 Subject: [PATCH 07/14] Update after spec changes --- lib/src/value/calculations.ts | 58 ++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index d8f3927e..4b3aa6f2 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -17,26 +17,30 @@ export type CalculationValue = type CalculationValueIterable = CalculationValue[] | List; -function assertUnquotedString(value: unknown): void { +function assertCalculationValue(value: unknown): void { + // Keep in sync with the CalculationValue type + const calculationValueClasses = [ + SassNumber, + SassCalculation, + SassString, + CalculationOperation, + CalculationInterpolation, + ]; + if (!calculationValueClasses.some(type => value instanceof type)) { + throw new Error( + `Expected ${value} to be one of SassNumber, SassString, SassCalculation, CalculationOperation, CalculationInterpolation` + ); + } if (value instanceof SassString && value.hasQuotes) { throw new Error(`Expected ${value} to be an unquoted string.`); } - if (value instanceof CalculationOperation) { - assertUnquotedString(value.left); - assertUnquotedString(value.right); - } } -function assertUnquotedStringOrInterpolation(value: unknown): void { - if ( - (!(value instanceof SassString) && - !(value instanceof CalculationInterpolation)) || - (value instanceof SassString && value.hasQuotes) - ) { - throw new Error( - `Expected ${value} to be an unquoted SassString or CalculationInterpolation.` - ); - } +function isValidClampArg(value: unknown): boolean { + return ( + value instanceof CalculationInterpolation || + (value instanceof SassString && !value.hasQuotes) + ); } /* A SassScript calculation */ @@ -49,17 +53,17 @@ export class SassCalculation extends Value { } static calc(argument: CalculationValue): SassCalculation { - assertUnquotedString(argument); + assertCalculationValue(argument); return new SassCalculation('calc', [argument]); } static min(args: CalculationValueIterable): SassCalculation { - args.forEach(assertUnquotedString); + args.forEach(assertCalculationValue); return new SassCalculation('min', args); } static max(args: CalculationValueIterable): SassCalculation { - args.forEach(assertUnquotedString); + args.forEach(assertCalculationValue); return new SassCalculation('max', args); } @@ -68,16 +72,18 @@ export class SassCalculation extends Value { value?: CalculationValue, max?: CalculationValue ): SassCalculation { - if (value === undefined) { - assertUnquotedStringOrInterpolation(min); - } else if (max === undefined) { - assertUnquotedStringOrInterpolation(min); - assertUnquotedStringOrInterpolation(value); + if ( + (value === undefined && !isValidClampArg(min)) || + (max === undefined && ![min, value].some(isValidClampArg)) + ) { + throw new Error( + 'Argument must be an unquoted SassString or CalculationInterpolation.' + ); } const args = [min]; if (value !== undefined) args.push(value); if (max !== undefined) args.push(max); - args.forEach(assertUnquotedString); + args.forEach(assertCalculationValue); return new SassCalculation('clamp', args); } @@ -112,8 +118,10 @@ export class CalculationOperation implements ValueObject { readonly right: CalculationValue ) { if (!operators.includes(operator)) { - throw new Error(`Unknown operator ${operator}`); + throw new Error(`Invalid operator: ${operator}`); } + assertCalculationValue(left); + assertCalculationValue(right); } equals(other: unknown): boolean { From 3cb331893aa84a11e701fad6d4a5333a52e57b8b Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Thu, 13 Jul 2023 01:24:44 +0000 Subject: [PATCH 08/14] WIP result simplification --- lib/src/function-registry.ts | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index d82acc4a..cd86a317 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -11,6 +11,12 @@ import * as proto from './vendor/embedded_sass_pb'; import {PromiseOr, catchOr, compilerError, thenOr} from './utils'; import {Protofier} from './protofier'; import {Value} from './value'; +import { + CalculationOperation, + CalculationValue, + SassCalculation, +} from './value/calculations'; +import {List} from 'immutable'; /** * The next ID to use for a function. The embedded protocol requires that @@ -66,6 +72,7 @@ export class FunctionRegistry { ) ), result => { + result = simplify(result) as any; if (!(result instanceof Value)) { const name = request.identifier.case === 'name' @@ -119,3 +126,46 @@ export class FunctionRegistry { } } } + +/** + * Implements the simplification algorithm for custom function return values. + * {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue} + */ +function simplify(value: unknown): unknown { + if (value instanceof SassCalculation) { + const simplifiedArgs = value.arguments.map( + simplify + ) as List; + if (value.name == 'calc') { + return simplifiedArgs.get(0); + } + if (value.name == 'clamp') { + if (simplifiedArgs.size != 3) { + throw new Error('clamp() requires exactly 3 arguments.'); + } + return SassCalculation.clamp( + simplifiedArgs.get(0) as CalculationValue, + simplifiedArgs.get(1), + simplifiedArgs.get(2) + ); + } + if (value.name == 'min') { + return SassCalculation.min(simplifiedArgs); + } + if (value.name == 'max') { + return SassCalculation.max(simplifiedArgs); + } + // @ts-expect-error: Constructor is private, but we need a new instance here + return new SassCalculation(value.name, simplifiedArgs); + } + if (value instanceof CalculationOperation) { + return simplify( + new CalculationOperation( + value.operator, + simplify(value.left) as CalculationValue, + simplify(value.right) as CalculationValue + ) + ); + } + return value; +} From 51e2f7b6d695e9091c4e25a4e75bf1e817981c26 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Thu, 13 Jul 2023 01:39:39 +0000 Subject: [PATCH 09/14] Lint --- lib/src/function-registry.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index cd86a317..26036b03 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -72,7 +72,7 @@ export class FunctionRegistry { ) ), result => { - result = simplify(result) as any; + result = simplify(result) as types.Value; if (!(result instanceof Value)) { const name = request.identifier.case === 'name' @@ -136,11 +136,11 @@ function simplify(value: unknown): unknown { const simplifiedArgs = value.arguments.map( simplify ) as List; - if (value.name == 'calc') { + if (value.name === 'calc') { return simplifiedArgs.get(0); } - if (value.name == 'clamp') { - if (simplifiedArgs.size != 3) { + if (value.name === 'clamp') { + if (simplifiedArgs.size !== 3) { throw new Error('clamp() requires exactly 3 arguments.'); } return SassCalculation.clamp( @@ -149,10 +149,10 @@ function simplify(value: unknown): unknown { simplifiedArgs.get(2) ); } - if (value.name == 'min') { + if (value.name === 'min') { return SassCalculation.min(simplifiedArgs); } - if (value.name == 'max') { + if (value.name === 'max') { return SassCalculation.max(simplifiedArgs); } // @ts-expect-error: Constructor is private, but we need a new instance here From ba077ee485993de0784735bbec9059c062e4f822 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Fri, 14 Jul 2023 21:56:52 +0000 Subject: [PATCH 10/14] Address review --- lib/src/function-registry.ts | 6 ++++-- lib/src/value/calculations.ts | 26 +++++--------------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index 26036b03..a7ae8de7 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -137,6 +137,9 @@ function simplify(value: unknown): unknown { simplify ) as List; if (value.name === 'calc') { + if (simplifiedArgs.size !== 1) { + throw new Error('calc() requires exactly 1 argument.'); + } return simplifiedArgs.get(0); } if (value.name === 'clamp') { @@ -155,8 +158,7 @@ function simplify(value: unknown): unknown { if (value.name === 'max') { return SassCalculation.max(simplifiedArgs); } - // @ts-expect-error: Constructor is private, but we need a new instance here - return new SassCalculation(value.name, simplifiedArgs); + throw new Error(`Unknown calculation function: ${value.name}`); } if (value instanceof CalculationOperation) { return simplify( diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts index 4b3aa6f2..aa160e67 100644 --- a/lib/src/value/calculations.ts +++ b/lib/src/value/calculations.ts @@ -17,31 +17,15 @@ export type CalculationValue = type CalculationValueIterable = CalculationValue[] | List; -function assertCalculationValue(value: unknown): void { - // Keep in sync with the CalculationValue type - const calculationValueClasses = [ - SassNumber, - SassCalculation, - SassString, - CalculationOperation, - CalculationInterpolation, - ]; - if (!calculationValueClasses.some(type => value instanceof type)) { - throw new Error( - `Expected ${value} to be one of SassNumber, SassString, SassCalculation, CalculationOperation, CalculationInterpolation` - ); - } +function assertCalculationValue(value: CalculationValue): void { if (value instanceof SassString && value.hasQuotes) { throw new Error(`Expected ${value} to be an unquoted string.`); } } -function isValidClampArg(value: unknown): boolean { - return ( - value instanceof CalculationInterpolation || - (value instanceof SassString && !value.hasQuotes) - ); -} +const isValidClampArg = (value: CalculationValue): boolean => + value instanceof CalculationInterpolation || + (value instanceof SassString && !value.hasQuotes); /* A SassScript calculation */ export class SassCalculation extends Value { @@ -74,7 +58,7 @@ export class SassCalculation extends Value { ): SassCalculation { if ( (value === undefined && !isValidClampArg(min)) || - (max === undefined && ![min, value].some(isValidClampArg)) + (max === undefined && ![min, value].some(x => x && isValidClampArg(x))) ) { throw new Error( 'Argument must be an unquoted SassString or CalculationInterpolation.' From ced9d565480a4d5e1dc3fdf5877912d4bb314519 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Mon, 17 Jul 2023 20:28:20 +0000 Subject: [PATCH 11/14] Add proto logic for calculations --- lib/src/function-registry.ts | 12 +-- lib/src/protofier.ts | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 7 deletions(-) diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index a7ae8de7..0932bfef 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -158,15 +158,13 @@ function simplify(value: unknown): unknown { if (value.name === 'max') { return SassCalculation.max(simplifiedArgs); } - throw new Error(`Unknown calculation function: ${value.name}`); + throw new Error(`Unknown calculation function: ${value.name}.`); } if (value instanceof CalculationOperation) { - return simplify( - new CalculationOperation( - value.operator, - simplify(value.left) as CalculationValue, - simplify(value.right) as CalculationValue - ) + return new CalculationOperation( + value.operator, + simplify(value.left) as CalculationValue, + simplify(value.right) as CalculationValue ); } return value; diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index f379f3c0..454eccf1 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -17,6 +17,13 @@ import {SassString} from './value/string'; import {Value} from './value'; import {sassNull} from './value/null'; import {sassTrue, sassFalse} from './value/boolean'; +import { + CalculationValue, + SassCalculation, + CalculationInterpolation, + CalculationOperation, + CalculationOperator, +} from './value/calculations'; /** * A class that converts [Value] objects into protobufs. @@ -116,6 +123,11 @@ export class Protofier { fn.signature = value.signature!; result.value = {case: 'hostFunction', value: fn}; } + } else if (value instanceof SassCalculation) { + result.value = { + case: 'calculation', + value: this.protofyCalculation(value), + }; } else if (value === sassTrue) { result.value = {case: 'singleton', value: proto.SingletonValue.TRUE}; } else if (value === sassFalse) { @@ -144,6 +156,69 @@ export class Protofier { } } + /** Converts `calculation` to its protocol buffer representation. */ + private protofyCalculation( + calculation: SassCalculation + ): proto.Value_Calculation { + return new proto.Value_Calculation({ + name: calculation.name, + arguments: calculation.arguments + .map(this.protofyCalculationValue.bind(this)) + .toArray(), + }); + } + + /** Converts a CalculationValue that appears within a `SassCalculation` to + * its protocol buffer representation. */ + private protofyCalculationValue( + value: Object + ): proto.Value_Calculation_CalculationValue { + var result = new proto.Value_Calculation_CalculationValue(); + if (value instanceof SassCalculation) { + result.value = { + case: 'calculation', + value: this.protofyCalculation(value), + }; + } else if (value instanceof CalculationOperation) { + result.value = { + case: 'operation', + value: new proto.Value_Calculation_CalculationOperation({ + operator: this.protofyCalculationOperator(value.operator), + left: this.protofyCalculationValue(value.left), + right: this.protofyCalculationValue(value.right), + }), + }; + } else if (value instanceof CalculationInterpolation) { + result.value = {case: 'interpolation', value: value.value}; + } else if (value instanceof SassString) { + result.value = {case: 'string', value: value.text}; + } else if (value instanceof SassNumber) { + // @ts-ignore + result.value = this.protofy(value).value; + } else { + throw utils.compilerError(`Unknown CalculationValue ${value}`); + } + return result; + } + + /** Converts `operator` to its protocol buffer representation. */ + private protofyCalculationOperator( + operator: CalculationOperator + ): proto.CalculationOperator { + switch (operator) { + case '+': + return proto.CalculationOperator.PLUS; + case '-': + return proto.CalculationOperator.MINUS; + case '*': + return proto.CalculationOperator.TIMES; + case '/': + return proto.CalculationOperator.DIVIDE; + default: + throw utils.compilerError(`Unknown CalculationOperator ${operator}`); + } + } + /** Converts `value` to its JS representation. */ deprotofy(value: proto.Value): Value { switch (value.value.case) { @@ -247,6 +322,9 @@ export class Protofier { 'The compiler may not send Value.host_function.' ); + case 'calculation': + return this.deprotofyCalculation(value.value.value); + case 'singleton': switch (value.value.value) { case proto.SingletonValue.TRUE: @@ -278,4 +356,101 @@ export class Protofier { throw utils.compilerError(`Unknown separator ${separator}`); } } + + /** Converts `calculation` to its Sass representation. */ + private deprotofyCalculation( + calculation: proto.Value_Calculation + ): SassCalculation { + switch (calculation.name) { + case 'calc': + if (calculation.arguments.length != 1) { + throw utils.compilerError( + 'Value.Calculation.arguments must have exactly one argument for calc().' + ); + } + return SassCalculation.calc( + this.deprotofyCalculationValue(calculation.arguments[0]) + ); + case 'clamp': + if (calculation.arguments.length != 3) { + throw utils.compilerError( + 'Value.Calculation.arguments must have exactly 3 arguments for clamp().' + ); + } + return SassCalculation.clamp( + this.deprotofyCalculationValue(calculation.arguments[0]), + this.deprotofyCalculationValue(calculation.arguments[1]), + this.deprotofyCalculationValue(calculation.arguments[2]) + ); + case 'min': + if (calculation.arguments.length === 0) { + throw utils.compilerError( + 'Value.Calculation.arguments must have at least 1 argument for min().' + ); + } + return SassCalculation.min( + calculation.arguments.map(this.deprotofyCalculationValue) + ); + case 'max': + if (calculation.arguments.length === 0) { + throw utils.compilerError( + 'Value.Calculation.arguments must have at least 1 argument for max().' + ); + } + return SassCalculation.max( + calculation.arguments.map(this.deprotofyCalculationValue) + ); + default: + throw utils.compilerError( + `Value.Calculation.name "${calculation.name}" is not a recognized calculation type.` + ); + } + } + + /** Converts `value` to its Sass representation. */ + private deprotofyCalculationValue( + value: proto.Value_Calculation_CalculationValue + ): CalculationValue { + switch (value.value.case) { + case 'number': + // @ts-ignore + return this.deprotofy(value) as SassNumber; + case 'calculation': + return this.deprotofyCalculation(value.value.value); + case 'string': + return new SassString(value.value.value, {quotes: false}); + case 'operation': + return new CalculationOperation( + this.deprotofyCalculationOperator(value.value.value.operator), + this.deprotofyCalculationValue( + value.value.value.left as proto.Value_Calculation_CalculationValue + ), + this.deprotofyCalculationValue( + value.value.value.right as proto.Value_Calculation_CalculationValue + ) + ); + case 'interpolation': + return new CalculationInterpolation(value.value.value); + default: + throw utils.mandatoryError('Calculation.CalculationValue.value'); + } + } + + /** Converts `operator` to its Sass representation. */ + private deprotofyCalculationOperator( + operator: proto.CalculationOperator + ): CalculationOperator { + switch (operator) { + case proto.CalculationOperator.PLUS: + return '+'; + case proto.CalculationOperator.MINUS: + return '-'; + case proto.CalculationOperator.TIMES: + return '*'; + case proto.CalculationOperator.DIVIDE: + return '/'; + default: + throw utils.compilerError(`Unknown CalculationOperator ${operator}`); + } + } } From 22af4223cfdca423d7bfa30bd444ccf610a054de Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Mon, 17 Jul 2023 20:31:45 +0000 Subject: [PATCH 12/14] Lint --- lib/src/protofier.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index 454eccf1..46eb5b31 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -173,7 +173,7 @@ export class Protofier { private protofyCalculationValue( value: Object ): proto.Value_Calculation_CalculationValue { - var result = new proto.Value_Calculation_CalculationValue(); + const result = new proto.Value_Calculation_CalculationValue(); if (value instanceof SassCalculation) { result.value = { case: 'calculation', @@ -363,7 +363,7 @@ export class Protofier { ): SassCalculation { switch (calculation.name) { case 'calc': - if (calculation.arguments.length != 1) { + if (calculation.arguments.length !== 1) { throw utils.compilerError( 'Value.Calculation.arguments must have exactly one argument for calc().' ); @@ -372,7 +372,7 @@ export class Protofier { this.deprotofyCalculationValue(calculation.arguments[0]) ); case 'clamp': - if (calculation.arguments.length != 3) { + if (calculation.arguments.length !== 3) { throw utils.compilerError( 'Value.Calculation.arguments must have exactly 3 arguments for clamp().' ); From 8d2d5a0193238d16c7c0bd4f7a4192ff8de4f1c9 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Tue, 18 Jul 2023 16:33:07 +0000 Subject: [PATCH 13/14] No need to simplify in the host --- lib/src/function-registry.ts | 50 ------------------------------------ lib/src/protofier.ts | 34 ++++++++++++++---------- 2 files changed, 20 insertions(+), 64 deletions(-) diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index 0932bfef..d82acc4a 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -11,12 +11,6 @@ import * as proto from './vendor/embedded_sass_pb'; import {PromiseOr, catchOr, compilerError, thenOr} from './utils'; import {Protofier} from './protofier'; import {Value} from './value'; -import { - CalculationOperation, - CalculationValue, - SassCalculation, -} from './value/calculations'; -import {List} from 'immutable'; /** * The next ID to use for a function. The embedded protocol requires that @@ -72,7 +66,6 @@ export class FunctionRegistry { ) ), result => { - result = simplify(result) as types.Value; if (!(result instanceof Value)) { const name = request.identifier.case === 'name' @@ -126,46 +119,3 @@ export class FunctionRegistry { } } } - -/** - * Implements the simplification algorithm for custom function return values. - * {@link https://github.com/sass/sass/blob/main/spec/types/calculation.md#simplifying-a-calculationvalue} - */ -function simplify(value: unknown): unknown { - if (value instanceof SassCalculation) { - const simplifiedArgs = value.arguments.map( - simplify - ) as List; - if (value.name === 'calc') { - if (simplifiedArgs.size !== 1) { - throw new Error('calc() requires exactly 1 argument.'); - } - return simplifiedArgs.get(0); - } - if (value.name === 'clamp') { - if (simplifiedArgs.size !== 3) { - throw new Error('clamp() requires exactly 3 arguments.'); - } - return SassCalculation.clamp( - simplifiedArgs.get(0) as CalculationValue, - simplifiedArgs.get(1), - simplifiedArgs.get(2) - ); - } - if (value.name === 'min') { - return SassCalculation.min(simplifiedArgs); - } - if (value.name === 'max') { - return SassCalculation.max(simplifiedArgs); - } - throw new Error(`Unknown calculation function: ${value.name}.`); - } - if (value instanceof CalculationOperation) { - return new CalculationOperation( - value.operator, - simplify(value.left) as CalculationValue, - simplify(value.right) as CalculationValue - ); - } - return value; -} diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index 46eb5b31..1ef2f180 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -63,11 +63,7 @@ export class Protofier { string.quoted = value.hasQuotes; result.value = {case: 'string', value: string}; } else if (value instanceof SassNumber) { - const number = new proto.Value_Number(); - number.value = value.value; - number.numerators = value.numeratorUnits.toArray(); - number.denominators = value.denominatorUnits.toArray(); - result.value = {case: 'number', value: number}; + result.value = {case: 'number', value: this.protofyNumber(value)}; } else if (value instanceof SassColor) { if (value.hasCalculatedHsl) { const color = new proto.Value_HslColor(); @@ -140,6 +136,15 @@ export class Protofier { return result; } + /** Converts `number` to its protocol buffer representation. */ + private protofyNumber(number: SassNumber): proto.Value_Number { + return new proto.Value_Number({ + value: number.value, + numerators: number.numeratorUnits.toArray(), + denominators: number.denominatorUnits.toArray(), + }); + } + /** Converts `separator` to its protocol buffer representation. */ private protofySeparator(separator: ListSeparator): proto.ListSeparator { switch (separator) { @@ -193,8 +198,7 @@ export class Protofier { } else if (value instanceof SassString) { result.value = {case: 'string', value: value.text}; } else if (value instanceof SassNumber) { - // @ts-ignore - result.value = this.protofy(value).value; + result.value = {case: 'number', value: this.protofyNumber(value)}; } else { throw utils.compilerError(`Unknown CalculationValue ${value}`); } @@ -230,11 +234,7 @@ export class Protofier { } case 'number': { - const number = value.value.value; - return new SassNumber(number.value, { - numeratorUnits: number.numerators, - denominatorUnits: number.denominators, - }); + return this.deprotofyNumber(value.value.value); } case 'rgbColor': { @@ -341,6 +341,13 @@ export class Protofier { } } + private deprotofyNumber(number: proto.Value_Number): SassNumber { + return new SassNumber(number.value, { + numeratorUnits: number.numerators, + denominatorUnits: number.denominators, + }); + } + /** Converts `separator` to its JS representation. */ private deprotofySeparator(separator: proto.ListSeparator): ListSeparator { switch (separator) { @@ -413,8 +420,7 @@ export class Protofier { ): CalculationValue { switch (value.value.case) { case 'number': - // @ts-ignore - return this.deprotofy(value) as SassNumber; + return this.deprotofyNumber(value.value.value); case 'calculation': return this.deprotofyCalculation(value.value.value); case 'string': From f6641d6f20f41634182962aaf8c23cb1525ff029 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Tue, 18 Jul 2023 23:26:22 +0000 Subject: [PATCH 14/14] Add missing comment --- lib/src/protofier.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index 1ef2f180..a4c4ac61 100644 --- a/lib/src/protofier.ts +++ b/lib/src/protofier.ts @@ -341,6 +341,7 @@ export class Protofier { } } + /** Converts `number` to its JS representation. */ private deprotofyNumber(number: proto.Value_Number): SassNumber { return new SassNumber(number.value, { numeratorUnits: number.numerators,