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'; diff --git a/lib/src/protofier.ts b/lib/src/protofier.ts index f379f3c0..a4c4ac61 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. @@ -56,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(); @@ -116,6 +119,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) { @@ -128,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) { @@ -144,6 +161,68 @@ 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 { + const 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) { + result.value = {case: 'number', value: this.protofyNumber(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) { @@ -155,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': { @@ -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: @@ -263,6 +341,14 @@ export class Protofier { } } + /** Converts `number` to its JS representation. */ + 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) { @@ -278,4 +364,100 @@ 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': + return this.deprotofyNumber(value.value.value); + 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}`); + } + } } diff --git a/lib/src/value/calculations.ts b/lib/src/value/calculations.ts new file mode 100644 index 00000000..aa160e67 --- /dev/null +++ b/lib/src/value/calculations.ts @@ -0,0 +1,137 @@ +// 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'; +import {SassNumber} from './number'; +import {SassString} from './string'; + +export type CalculationValue = + | SassNumber + | SassCalculation + | SassString + | CalculationOperation + | CalculationInterpolation; + +type CalculationValueIterable = CalculationValue[] | List; + +function assertCalculationValue(value: CalculationValue): void { + if (value instanceof SassString && value.hasQuotes) { + throw new Error(`Expected ${value} to be an unquoted string.`); + } +} + +const isValidClampArg = (value: CalculationValue): boolean => + value instanceof CalculationInterpolation || + (value instanceof SassString && !value.hasQuotes); + +/* A SassScript calculation */ +export class SassCalculation extends Value { + readonly arguments: List; + + private constructor(readonly name: string, args: CalculationValueIterable) { + super(); + this.arguments = List(args); + } + + static calc(argument: CalculationValue): SassCalculation { + assertCalculationValue(argument); + return new SassCalculation('calc', [argument]); + } + + static min(args: CalculationValueIterable): SassCalculation { + args.forEach(assertCalculationValue); + return new SassCalculation('min', args); + } + + static max(args: CalculationValueIterable): SassCalculation { + args.forEach(assertCalculationValue); + return new SassCalculation('max', args); + } + + static clamp( + min: CalculationValue, + value?: CalculationValue, + max?: CalculationValue + ): SassCalculation { + if ( + (value === undefined && !isValidClampArg(min)) || + (max === undefined && ![min, value].some(x => x && isValidClampArg(x))) + ) { + 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(assertCalculationValue); + return new SassCalculation('clamp', args); + } + + assertCalculation(): SassCalculation { + return this; + } + + equals(other: unknown): 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(', ')})`; + } +} + +const operators = ['+', '-', '*', '/'] as const; +export type CalculationOperator = typeof operators[number]; + +export class CalculationOperation implements ValueObject { + constructor( + readonly operator: CalculationOperator, + readonly left: CalculationValue, + readonly right: CalculationValue + ) { + if (!operators.includes(operator)) { + throw new Error(`Invalid operator: ${operator}`); + } + assertCalculationValue(left); + assertCalculationValue(right); + } + + equals(other: unknown): 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 { + constructor(readonly value: string) {} + + equals(other: unknown): 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. *