Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SassCalculation #237

Merged
merged 14 commits into from
Jul 19, 2023
20 changes: 20 additions & 0 deletions lib/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -67,6 +83,10 @@ export default {
defaultExportDeprecation();
return sass.SassBoolean;
},
get SassCalculation() {
defaultExportDeprecation();
return sass.SassCalculation;
},
get SassColor() {
defaultExportDeprecation();
return sass.SassColor;
Expand Down
6 changes: 6 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions lib/src/function-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,6 +72,7 @@ export class FunctionRegistry<sync extends 'sync' | 'async'> {
)
),
result => {
result = simplify(result) as types.Value;
if (!(result instanceof Value)) {
const name =
request.identifier.case === 'name'
Expand Down Expand Up @@ -119,3 +126,46 @@ export class FunctionRegistry<sync extends 'sync' | 'async'> {
}
}
}

/**
* 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<CalculationValue>;
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);
jerivas marked this conversation as resolved.
Show resolved Hide resolved
}
if (value instanceof CalculationOperation) {
return simplify(
new CalculationOperation(
value.operator,
simplify(value.left) as CalculationValue,
simplify(value.right) as CalculationValue
)
);
}
return value;
}
153 changes: 153 additions & 0 deletions lib/src/value/calculations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// 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<CalculationValue>;

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.`);
}
}

function isValidClampArg(value: unknown): boolean {
return (
value instanceof CalculationInterpolation ||
(value instanceof SassString && !value.hasQuotes)
);
}

/* A SassScript calculation */
export class SassCalculation extends Value {
readonly arguments: List<CalculationValue>;

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(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(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);
}
}
11 changes: 11 additions & 0 deletions lib/src/value/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
Loading