sass/accepted/calc-functions.md
2024-06-17 17:19:23 -07:00

32 KiB

Calculation Functions: Draft 3.2

(Issue, Changelog)

Table of Contents

Background

This section is non-normative.

Sass added support for first-class calculation objects recently, but this initial support only included the calc(), min(), max(), and clamp() expressions since these were the only ones supported in browsers at the time. Since then, between Firefox and Safari browser support has landed for the rest of the expressions listed in Values and Units 4.

Summary

This section is non-normative.

This proposal parses the full range of functions defined in Values and Units 4 as calculation values in Sass: round(), mod(), rem(), sin(), cos(), tan(), asin(), acos(), atan(), atan2(), pow(), sqrt(), hypot(), log(), exp(), abs(), and sign().

Since Sass already defines top-level functions named round() and abs(), these will fall back to Sass function calls in a similar way to how min() and max() already work.

This poses a small compatibility problem: in Sass, abs(10%) will always return 10% because the number is positive, but in plain CSS it could return the equivalent of -10% since percentages are resolved before calculations. To handle this, we'll deprecate the global abs() function with a percentage and recommend users explicitly write math.abs() or abs(#{}) instead.

This also expands calculation parsing to allow constructs like calc(1 var(--plus-two)) (where for example --plus-two: + 2) which are valid CSS but weren't supported by the old first-class calculation parsing.

Design Decisions

Merged Syntax

This proposal substantially changes the way calculations are parsed, merging the syntax with the standard Sass expression syntax. Now the only difference between a calculation and a normal Sass function is how it's evaluated. This has the notable benefit of allowing calculations to coexist with user-defined Sass functions of the same name, preserving backwards-compatibility.

Because this overlap is always going to be somewhat confusing for readers, we considered simply disallowing Sass functions whose names matched CSS calculations after a suitable deprecation period. However, in addition to the intrinsic value of avoiding breaking changes, the function name rem() in particular is widely used in Sass libraries as a means of converting pixel widths to relative ems, so this is a fairly substantial breaking change in practice.

This does also require its own breaking change to the way interpolation is handled in calculations—calc(#{"1px +"} 1%) was formerly valid but is no longer. However, this is likely to break many fewer users in practice, and is relatively easy to continue supporting in a deprecated state in the short term.

Changing Mod Infinity Behavior

This proposal changes the behavior of the % operation when the right-hand side is infinite and has a different sign than the left-hand side. Sass used to return the right-hand side in accordance with the floating point specification, but it now returns NaN to match CSS's mod() function.

Although this is technically a breaking change, we think it's highly unlikely that it will break anyone in practice, so we're not going to do a deprecation process for it.

Definitions

Calculation-Safe Expression

An expression is "calculation-safe" if it is one of:

  • A FunctionExpression.
  • A ParenthesizedExpression whose contents is calculation-safe.
  • A SumExpression whose operands are calculation-safe.
  • A ProductExpression whose operator is * or / and whose operands are calculation-safe.
  • A Number.
  • A Variable.
  • An InterpolatedIdentifier.
  • An unbracketed SpaceListExpression with more than one element, whose elements are all calculation-safe.

Because calculations have special syntax in CSS, only a subset of SassScript expressions are valid (and these are interpreted differently than elsewhere).

Exact Equality

Two doubles are said to be exactly equal if they are equal according to the compareQuietEqual predicate as defined by IEEE 754 2019, §5.11.

This is as opposed to fuzzy equality.

Known Units

A number has known units unless it has unit %.

This is relevant for calculations, because in plain CSS they resolve percentages before doing their operations. This means that any non-linear operations involving percentages must be passed through to plain CSS rather than handled by Sass.

More complex units involving percentages are allowed because any non-linear function will throw for complex units anyway.

Potentially Slash-Separated Number

Replace the definition of Potentially Slash-Separated Number with the following:

A Sass number may be potentially slash-separated. If it is, it is associated with two additional Sass numbers, the original numerator and the original denominator. A number that is not potentially slash-separated is known as slash-free.

A potentially slash-separated number is created when a ProductExpression with a / operator is evaluated and each operand is syntactically one of the following:

  • a Number,
  • a FunctionCall, or
  • a ProductExpression that can itself create potentially slash-separated numbers.

If the result of evaluating the ProductExpression is a number, that number is potentially slash-separated if all of the following are true:

  • the results of evaluating both operands were numbers, and
  • if either operand was a FunctionCall, it was evaluated as a calculation and its name was not "abs", "max", "min", or "round".

If both of these are true, the first operand is the original numerator of the potentially slash-separated number returned by the / operator, and the second is the original denominator.

Syntax

Calculations are no longer parsed differently than other Sass productions. Instead, they're evaluated differently at runtime. This allows them to coexist with user-defined Sass functions even when their names overlap.

FunctionExpression

Remove CssMinMax and CalculationExpression from the definition of FunctionExpression.

CssMinMax

Remove the CssMinMax production.

CalculationExpression

Remove the CalculationExpression production.

Types

Calculation

Delete the CalculationInterpolation type and remove all references to it.

This type only existed to track where we needed to defensively insert parentheses. Now that we track parentheses as part of the calculation AST, this is no longer necessary

Operations

Modulo

Replace the definition of modulo for numbers with the following:

Differences are highlighted in bold.

Let n1 and n2 be two numbers. To determine n1 % n2:

  • Let c1 and c2 be the result of matching units for n1 and n2 allowing unitless.

  • If c2 is infinity and has a different sign than c1 (including oppositely-signed zero), return NaN with the same units as c1.

    This matches the behavior of CSS's mod() function.

  • Let remainder be a number whose value is the result of remainder(c1.value, c2.value) as defined by IEEE 754 2019, §5.3.1; and whose units are the same as c1's.

  • If c2's value is less than 0 and remainder's value isn't exactly equal to 0, return remainder - c2.

    This is known as floored division. It differs from the standard IEEE 754 specification, but matches the behavior of CSS's mod() function.

    Note: These comparisons are not the same as c2 < 0 or remainder == 0, because they don't do fuzzy equality.

  • Otherwise, return remainder.

Procedures

Evaluating a FunctionCall as a Calculation

This algorithm takes a FunctionCall call whose name is a plain identifier and returns a number or a calculation.

  • If call's ArgumentInvocation contains one or more KeywordArguments or one or more RestArguments, throw an error.

  • Let calc be a calculation whose name is the lower-case value of call's name and whose arguments are the result of evaluating each Expression in call's ArgumentInvocation as a calculation value.

  • Return the result of simplifying calc.

Evaluating an Expression as a Calculation Value

This algorithm takes an expression expression and returns a CalculationValue.

  • If expression isn't calculation-safe, throw an error.

  • Otherwise, evaluate expression using the semantics defined in the Calculations specification if there are any, or the standard semantics otherwise.

Simplifying a Calculation

Replace the definition of "Simplifying a Calculation" with the following:

This algorithm takes a calculation calc and returns a number or a calculation.

This algorithm is intended to return a value that's CSS-semantically identical to the input.

  • If calc was parsed from an expression within a SupportsDeclaration's Expression, but outside any interpolation, return a calc as-is.

  • Let arguments be the result of simplifying each of calc's arguments.

  • If calc's name is "calc" and arguments contains exactly a single number or calculation, return it.

  • If calc's name is "mod", "rem", "atan2", or "pow"; arguments has fewer than two elements; and none of those are unquoted strings, throw an error.

    It's valid to write pow(var(--two-args)) or pow(#{"2, 3"}), but otherwise calculations' arguments must match the expected number.

  • If calc's name is "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "log", or "round" and arguments contains exactly a single number, return the result of passing that number to the function in sass:math whose name matches calc's.

    The sass:math functions will check units here for the functions that require specific or no units.

  • If calc's name is "abs" and arguments contains exactly a single number with known units, return the result of passing that number to the function in sass:math whose name matches calc's.

  • If calc's name is "exp" and arguments contains exactly a single number number, return the result of calling math.pow(math.$e, number).

    This will throw an error if the argument has units.

  • If calc's name is "sign" and arguments contains exactly a single number number with known units:

    • If number's value is positive, return 1.

    • If number's value is negative, return -1.

    • Otherwise, return a unitless number with the same value as number.

      In this case, number is either +0, -0, or NaN.

    To match CSS's behavior, these computations don't use fuzzy comparisons.

  • If calc's name is "log":

    • If any argument is a number with units, throw an error.

    • Otherwise, if arguments contains exactly two numbers, return the result of passing its arguments to the log() function in sass:math.

  • If calc's name is "pow":

    • If any argument is a number with units, throw an error.

    • Otherwise, if arguments contains exactly two numbers, return the result of passing those numbers to the pow() function in sass:math.

  • If calc's name is "atan2" and arguments contains two numbers which both have known units, return the result of passing those numbers to the atan2() function in sass:math.

    This will throw an error if either argument has units.

    atan2() passes percentages along to the browser because they may resolve to negative values, and atan2(-x, -y) != atan2(x, y).

  • If calc's name is "mod" or "rem":

    • If arguments has only one element and it's not an unquoted string, throw an error.

    • Otherwise, if arguments contains exactly two numbers dividend and modulus:

      • If dividend and modulus are definitely-incompatible, throw an error.

      • If dividend and modulus are mutually compatible:

        • Let result be the result of dividend % modulus.

        • If calc's name is "rem", and if dividend is positive and modulus is negative or vice versa:

          • If modulus is infinite, return dividend.
          • If result exactly equals 0, return -result.
          • Otherwise, return result - modulus.
        • Otherwise, return result.

  • If calc's name is "round":

    • If arguments has exactly three elements, set strategy, number, and step to those arguments respectively.

    • Otherwise, if arguments has exactly two elements:

      • If the first element is an unquoted string or interpolation with value "nearest", "up", "down", or "to-zero", and the second argument isn't an unquoted string, throw an error.

        Normally we allow unquoted strings anywhere in a calculation, but this helps catch the likely error of a user accidentally writing round(up, 10px) without realizing that it needs a third argument.

      • Otherwise, set number and step to the two arguments respectively and strategy to an unquoted string with value "nearest".

    • Otherwise, if the single argument isn't an unquoted string, throw an error.

    • If strategy, number, and step are set:

      • If strategy isn't a special variable string, nor is it an unquoted string or interpolation with value "nearest", "up", "down", or "to-zero", throw an error.

      • If strategy is an unquoted string or interpolation and both number and step are numbers:

        • If number and step are definitely-incompatible, throw an error.

        • If number and step are mutually compatible:

          • If number's and step's values are both infinite, if step is exactly equal to 0, or if either number's or step's values are NaN, return NaN with the same units as number.

          • If number's value is infinite, return number.

          • If step's value is infinite:

            • If strategy's value is "nearest" or "to-zero", return +0 if number's value is positive or +0, and -0 otherwise.

            • If strategy's value is "up", return positive infinity if number's value is positive, +0 if number's value is +0, and -0 otherwise.

            • If strategy's value is "down", return negative infinity if number's value is negative, -0 if number's value is -0, and +0 otherwise.

          • Set number and step to the result of matching units for number and step.

          • If number's value is exactly equal to step's, return number.

          • Let upper and lower be the two integer multiples of step which are closest to number such that upper is greater than lower. If upper would be 0, it's specifically -0; if lower would be zero, it's specifically -0.

          • If strategy's value is "nearest", return whichever of upper and lower has the smallest absolute distance from number. If both have an equal difference, return upper.

          • If strategy's value is "up", return upper.

          • If strategy's value is "down", return lower.

          • If strategy's value is "to-zero", return whichever of upper and lower has the smallest absolute difference from 0.

  • If calc's name is "clamp":

    • If arguments has fewer than three elements, and none of those are unquoted strings, throw an error.

    • Otherwise, if any two elements of arguments are definitely-incompatible numbers, throw an error.

    • Otherwise, if arguments are all mutually compatible numbers, return the result of calling math.clamp() with those arguments.

  • If calc's name is "hypot":

    • If any two elements of arguments are definitely-incompatible numbers, throw an error.

    • Otherwise, if all arguments are all numbers with known units that are mutually compatible, return the result of calling math.hypot() with those arguments.

      hypot() has an exemption for percentages because it squares its inputs, so hypot(-x, -y) != -hypot(x, y).

  • If calc's name is "min" or "max" and arguments are all numbers:

    • If the arguments with units are all mutually compatible, call math.min() or math.max() (respectively) with those arguments. If this doesn't throw an error, return its result.

      min() and max() allow unitless numbers to be mixed with units because they need to be backwards-compatible with Sass's old global min() and max() functions.

    • Otherwise, if any two of those arguments are definitely-incompatible, throw an error.

  • Otherwise, return a calculation with the same name as calc and arguments as its arguments.

Simplifying a CalculationValue

Replace the block "If value is a calculation" in the procedure for simplifying a CalculationValue with the following:

  • If value is a calculation:

    • Let result be the result of simplifying value.

    • If result isn't a calculation whose name is "calc", return result.

    • If result's argument isn't an unquoted string, return result.

    • If result's argument begins case-insensitively with "var("; or if it contains whitespace, "/", or "*"; return "(" + result's argument + ")" as an unquoted string.

      This is ensures that values that could resolve to operations end up parenthesized if used in other operations. It's potentially a little overzealous, but that's unlikely to be a major problem given that the output is still smaller than including the full calc() and we don't want to encourage users to inject calculations with interpolation anyway.

Semantics

FunctionCall

Add the following to the semantics for FunctionCall before checking for a global function:

  • If function is null; name is case-insensitively equal to "min", "max", "round", or "abs"; call's ArgumentInvocation doesn't have any KeywordArguments or RestArguments; and all arguments in call's ArgumentInvocation are calculation-safe, return the result of evaluating call as a calculation.

    For calculation functions that overlap with global Sass function names, we want anything Sass-specific like this to end up calling the Sass function. For all other calculation functions, we want those constructs to throw an error (which they do when evaluating call as a calculation).

  • If function is null and name is case-insensitively equal to "calc", "clamp", "hypot", "sin", "cos", "tan", "asin", "acos", "atan", "sqrt", "exp", "sign", "mod", "rem", "atan2", "pow", or "log", return the result of evaluating call as a calculation.

Calculations

Remove all prior semantics for Calculations. The following semantics apply only when evaluating expressions as calculation values.

FunctionExpression and Variable

To evaluate a FunctionExpression or a Variable as a calculation value, evaluate it using the standard semantics. If the result is a number, an unquoted string, or a calculation, return it. Otherwise, throw an error.

Allowing variables to return unquoted strings here supports referential transparency, so that $var: fn(); calc($var) works the same as calc(fn()).

SumExpression and ProductExpression

To evaluate a SumExpresssion or a ProductExpression as a calculation value:

  • Let left be the result of evaluating the first operand as a calculation value.

  • For each remaining "+", "-", "*", or "/" token operator and operand operand:

    • Let right be the result of evaluating operand as a calculation value.

    • Set left to a CalcOperation with operator, left, and right.

  • Return left.

SpaceListExpression

To evaluate a SpaceListExpresssion as a calculation value:

  • Let elements be the results of evaluating each element as a calculation value.

  • If elements has two adjacent elements that aren't unquoted strings, throw an error.

    This ensures that valid CSS constructs like calc(1 var(--plus-two)) and similar Sass constructs like calc(1 #{"+ 2"}) work while preventing clear errors like calc(1 2).

    This does allow errors like calc(a b), but the complexity of verifying that the unquoted strings could actually be a partial operation isn't worth the benefit of eagerly producing an error in this edge case.

  • Let serialized be an empty list.

  • For each element of elements:

    • Let css be the result of serializing element.

    • If element is a CalcOperation that was produced by evaluating a ParenthesizedExpression, set css to "(" + css + ")".

    • Append css to serialized.

  • Return an unquoted strings whose contents are the elements of serialized separated by " ".

ParenthesizedExpression

If a var() or an interpolation is written directly within parentheses, it's necessary to preserve those parentheses. CSS resolves var() by literally replacing the function with the value of the variable and then parsing the surrounding context.

For example, if --ratio: 2/3, calc(1 / (var(--ratio))) is parsed as calc(1 / (2/3)) = calc(3/2) but calc(1 / var(--ratio)) is parsed as calc(1 / 2/3) = calc(1/6).

To evaluate a ParenthesizedExpression with contents expression as a calculation value:

  • Let result be the result of evaluating expression as a calculation value.

  • If result is an unquoted string, return "(" + result + ")" as an unquoted string.

  • Otherwise, return result.

InterpolatedIdentifier

To evaluate an InterpolatedIdentifier ident as a calculation value:

  • If ident is case-insensitively equal to pi, return 3.141592653589793.

    This is the closest double approximation of the mathematical constant π.

  • If ident is case-insensitively equal to e, return 2.718281828459045.

    This is the closest double approximation of the mathematical constant e.

  • If ident is case-insensitively equal to infinity, return the double Infinity.

  • If ident is case-insensitively equal to -infinity, return the double -Infinity.

  • If ident is case-insensitively equal to nan, return the double NaN.

  • Otherwise, return the result of evaluating ident using standard semantics.

    This will be an UnquotedString.

Interaction with Forward Slash as a Separator

Although the Forward Slash as a Separator proposal has not yet been integrated into the canonical spec, it will affect some of the constructs modified by this proposal. This section defines additional modifications to the spec as it will exist when that proposal is integrated.

Remove "or /" from the definition of a calculation-safe ProductExpression. Add "An unbracketed SlashListExpression with more than one element, all of which are calculation-safe" to the list of calculation-safe expressions.

Replace "evaluating each Expression" with "adjusting slash precedence in and then evaluating each Expression" in evaluting a FunctionCall as a calculation.

Adjusting Slash Precedence

This algorithm takes a calculation-safe expression expression and returns another calculation-safe expression with the precedence of SlashListExpressions adjusted to match division precedence.

  • Return a copy of expression except, for each SlashListExpression:

    • Let left be the first element of the list.

    • For each remaining element right:

      • If left and right are both SumExpressions:

        • Let last-left be the last operand of left and first-right the first operand of right.

        • Set left to a SumExpression that begins with all operands and operators of left except last-left, followed by a SlashListExpression with elements last-left and first-right, followed by all operators and operands of right except first-right.

          For example, slash-list(1 + 2, 3 + 4) becomes 1 + (2 / 3) + 4.

      • Otherwise, if left is a SumExpression:

        • Let last-left be the last operand of left.

        • Set left to a SumExpression that begins with all operands and operators of left except last-left, followed by a SlashListExpression with elements last-left and right.

          For example, slash-list(1 + 2, 3) becomes 1 + (2 / 3).

      • Otherwise, if right is a SumExpression or a ProductExpression:

        • Let first-right be the first operand of right.

        • Set left to an expression of the same type as right that begins a SlashListExpression with elements left and first-right, followed by operators and operands of right except first-right.

          For example, slash-list(1, 2 * 3) becomes (1 / 2) * 3.

      • Otherwise, if left is a slash-separated list, add right to the end.

      • Otherwise, set left to a slash-separated list containing left and right.

    • Replace each element in left with the result of adjusting slash precedence in that element.

    • Replace the SlashListExpression with left in the returned expression.

SlashListExpression

To evaluate a SlashListExpression as a calculation value:

  • Let left be the result of evaluating the first element of the list as a calculation value.

  • For each remaining element element:

    • Let right be the result of evaluating element as a calculation value.

    • Set left to a CalcOperation with operator "/", left, and right.

  • Return left.

API

Types

CalculationInterpolation

Replace the definition of this class, other than its TypeScript API, with the following:

A deprecated alternative JS API representation of an unquoted Sass string that's always surrounded by parentheses. It's never returned by the Sass compiler, but for backwards-compatibility users may still construct it and pass it to the Sass compiler.

CalculationInterpolations are no longer generated by the Sass compiler, because it can now tell at evaluation time whether an interpolation was originally surrounded by parentheses. However, until we make a breaking revision of the JS API, users may continue to pass CalculationInterpolations

internal

A private property like Value.internal that refers to a Sass string.

Constructor

Creates a CalculationInterpolation with its internal set to an unquoted Sass string with text "(" + value + ")" and returns it.

value

Returns internal's value field's text, without the leading and trailing parentheses.

equals

Whether other is a CalculationInterpolation and internal is equal to other.internal in Sass.

hashCode

Returns the same number for any two CalculationInterpolations that are equal according to equals.

Embedded Protocol

CalculationValue.value.interpolation

Add the following to this field's documentation:

The compiler must treat this as identical to a string option whose value is "(" + interpolation + ")".

This field is deprecated and hosts should avoid using it.

Deprecation Process

This proposal causes two breaking changes, each of which will be mitigated by supporting something very close to the old behavior with a deprecation warning until the next major version release.

abs-percent

Under this proposal, if a number with unit % is passed to the global abs() function, it will be emitted as a plain CSS abs() rather than returning the absolute value of the percentage itself.

During the deprecation period, when simplifying a calculation named "abs" whose sole argument is a number without known units, return the result of calling math.abs() with that number and emit a deprecation warning named abs-percent.