20 KiB
First-Class calc()
: Draft 2
Table of Contents
Background
This section is non-normative.
CSS's calc()
syntax for mathematical expressions has existed for a long
time, and it's always represented a high-friction point in its interactions with
Sass. Sass currently treats calc()
expressions as fully opaque, allowing
almost any sequence of tokens within the parentheses and evaluating it to an
unquoted string. Interpolation is required to use Sass variables in calc()
expressions, and once an expression is created it can't be inspected or
manipulated in any way other than using Sass's string functions.
As calc()
and related mathematical expression functions become more widely
used in CSS, this friction is becoming more and more annoying. In addition, the
move towards using /
as a separator makes it desirable to use calc()
syntax as a way to write expressions using mathematical syntax that can be
resolved at compile-time.
Summary
This section is non-normative.
This proposal changes calc()
(and other supported mathematical functions) from
being parsed as unquoted strings to being parsed in-depth, and sometimes
(although not always) producing a new data type known as a "calculation". This
data type represents mathematical expressions that can't be resolved at
compile-time, such as calc(10% + 5px)
, and allows those expressions to be
combined gracefully within further mathematical functions.
To be more specific: a calc()
expression will be parsed according to the CSS
syntax, with additional support for Sass variables, functions, and (for
backwards compatibility) interpolation. Sass will perform as much math as is
possible at compile-time, and if the result is a single number it will return
that number. Otherwise, it will return a calculation that represents the
(simplified) expression that can be resolved in the browser.
For example:
-
calc(1px + 10px)
will return the number11px
. -
Similarly, if
$length
is10px
,calc(1px + $length)
will return11px
. -
However,
calc(1px + 10%)
will return the calccalc(1px + 10%)
. -
If
$length
iscalc(1px + 10%)
,calc(1px + $length)
will returncalc(2px + 10%)
. -
Sass functions can be used directly in
calc()
, socalc(1% + math.round(15.3px))
returnscalc(1% + 15px)
.
Note that calculations cannot generally be used in place of numbers. For
example, 1px + calc(1px + 10%)
will produce an error, as will
math.round(calc(1px + 10%))
.
For backwards compatibility, calc()
expressions that contain interpolation
will continue to be parsed using the old highly-permissive syntax, although this
behavior will eventually be deprecated and removed. These expressions will still
return calculation values, but they'll never be simplified or resolve to plain
numbers.
Design Decisions
"Contagious" Calculations
In this proposal, calculation objects throw errors if they're used with normal
SassScript level math operations (+
, -
, *
, /
, and %
). Another option
would have been to make calculations "contagious", so that performing these
operations with at least one calculation operand would produce another
calculation as a result. For example, instead of throwing an error 1px + calc(100px + 10%)
would produce calc(101px + 10%)
(or possibly just calc(1 + 100px + 10%)
).
We chose not to do this because calculations aren't always interchangeable with plain numbers, so making them contagious in this way could lead to situations where a calculation entered a set of functions that only expected numbers and ended up producing an error far away in space or time from the actual source of the issue. For example:
-
Miriam publishes a Sass library with a function,
frobnicate()
, which does a bunch of arithmetic on its argument and returns a result. -
Jina tries calling
frobnicate(calc(100px + 10%))
. This works, so she commits it and ships to production. -
Miriam updates the implementation of
frobnicate()
to callmath.log()
, which does not support calculations. She doesn't realize this is a breaking change, since she was only ever expecting numbers to be passed. -
Jina updates to the newest version of Miriam's library and is unexpectedly broken.
To avoid this issue, we've made it so that the only operations that support
calculations are those within calc()
expressions. This follows Sass's broad
principle of "don't design for users using upstream stylesheets in ways they
weren't intended to be used".
Going back to the example above, if Miriam did want to support calculations,
she could simply wrap calc()
around any mathematical expressions she writes.
This will still return plain numbers when given compatible numbers as inputs,
but it will also make it clear that calc()
s are supported and that Miriam
expects to support them on into the future.
Interpolation in calc()
Historically, interpolation has been the only means of injecting SassScript
values into calc()
expressions, so for backwards compatibility, we must
continue to support it to some degree. Exactly to what degree and how it
integrates with first-class calculation is a question with multiple possible
answers, though.
The answer we settled on was to handle interpolation in a similar way to how we
handled backwards-compatibility with Sass's min()
and max()
functions: by
parsing calc()
expressions using the old logic if they contain any
interpolation and continuing to treat those values as opaque strings, and only
using the new parsing logic for calculations that contain no interpolation. This
is maximally backwards-compatible and it doesn't require interpolated
calculations to be reparsed after interpolation.
Here are some alternatives we considered:
-
Re-parsing a calculation that contains interpolation once the interpolation has been resolved, and using the result as a calculation object rather than an unquoted string. For example,
calc(#{"1px + 2px"})
would return3px
rather thancalc(1px + 2px)
. However, doing another parse at evaluation-time would add substantial complexity and some amount of runtime overhead. The return-on-investment would also be inherently limited, since we're planning on gradually transitioning users away from interpolation incalc()
anyway. -
Treating interpolation another type of
CalcValue
that participates in the normal parsing flow of aCalcArgument
. This is a simpler and more efficient method since it doesn't require parser lookahead, and it supports common cases likecalc(#{$var} + 10%)
well. However, it doesn't support cases likecalc(1px #{$op} 10%)
which are currently supported. This backwards-incompatibility is likely to cause real user pain for a feature as widely-used ascalc()
.
Vendor Prefixed calc()
Although calc()
is now widely supported in all modern browsers, older versions
of Firefox, Chrome, and Safari supported it only with a vendor prefix. Sass in
turn supported those browsers by handling calc()
's special function parsing
with arbitrary vendor prefixes as well. However, time has passed, those browser
versions have essentially no usage any more, and we don't anticipate anyone is
looking to write new stylesheets that target them.
As such, this proposal only adds first-class calculation support for the
calc()
function without any prefixes. For backwards-compatibility,
vendor-prefixed calc()
expressions will continue to be parsed as opaque
special functions the way they always have, but they will not be interoperable
with any of the new calculation features this proposal adds.
Syntax
SpecialFunctionExpression
This proposal replaces the definition of SpecialFunctionName
with the
following:
SpecialFunctionName¹ ::= VendorPrefix? ('element(' | 'expression(') | VendorPrefix 'calc('
1: The string calc(
is matched case-insensitively.
CalcExpression
This proposal defines a new production CalcExpression
. This expression is
parsed in a SassScript context when an expression is expected and the input
stream starts with an identifier with value calc
(ignoring case) followed
immediately by (
.
The grammar for this production is:
CalcExpression ::= 'calc('¹ CalcArgument ')' ClampExpression ::= 'clamp('¹ CalcArgument ',' CalcArgument ',' CalcArgument ')' CalcArgument² ::= InterpolatedDeclarationValue | CalcSum CalcSum ::= CalcProduct (('+' | '-')³ CalcProduct)* CalcProduct ::= CalcValue (('*' | '/') CalcValue)* CalcValue ::= '(' CalcArgument ')' | CalcExpression | ClampExpression | CssMinMax | FunctionExpression⁴ | Number | Variable
1: The strings calc(
and clamp(
are matched case-insensitively.
2: A CalcArgument
is only parsed as an InterpolatedDeclarationValue
if it
includes interpolation, unless that interpolation is within a region bounded by
parentheses (a FunctionExpression
counts as parentheses).
3: Whitespace is required around these "+"
and "-"
tokens.
4: This FunctionExpression
cannot begin with min(
, max(
, or clamp(
,
case-insensitively.
The
CalcArgument
production provides backwards-compatibility with the historical use of interpolation to inject SassScript values intocalc()
expressions. Because interpolation could inject any part of acalc()
expression regardless of syntax, for full compatibility it's necessary to parse it very expansively.Note that the interpolation in the definition of
CalcValue
is only reachable from aCssMinMax
production, not fromCalcExpression
. This is intentional, for backwards-compatibility with the existingCssMinMax
syntax.
CssMinMax
This proposal replaces the reference to CalcValue
in the definition of
CssMinMax
with CalcArgument
.
Note that this increases the number of cases where a
MinMaxExpression
will be parsed as aCssMinMax
rather than aFunctionExpression
(for example,min($foo, $bar)
is now a validCssMinMax
where it wasn't before). Fortunately, this is backwards-compatible, since all suchMinMaxExpression
s that were already valid will be simplified down into the same number they returned before.
Types
This proposal introduces a new value type known as a "calculation", with the following structure:
interface Calculation {
name: string;
arguments: CalculationValue[];
}
type CalculationValue =
| Number
| UnquotedString
| CalculationInterpolation
| CalculationOperation
| Calculation;
interface CalculationInterpolation {
value: string;
}
interface CalculationOperation {
operator: '+' | '-' | '*' | '/';
left: CalculationValue;
right: CalculationValue;
}
Unless otherwise specified, when this specification creates a calculation, its name is "calc".
Operations
A calculation follows the default behavior of all SassScript operations, except
that it throws an error if used as an operand of a unary or binary +
or -
operation.
This helps ensure that if a user expects a number and receives a calculation instead, it will throw an error quickly rather than propagating as an unquoted string.
Serialization
Calculation
To serialize a calculation, emit its name followed by "(", then each of its arguments separated by ",", then ")".
CalculationOperation
To serialize a CalculationOperation
:
-
Let
left
andright
be the result of serializing the left and right values, respectively. -
If either:
- the left value is a
CalculationInterpolation
, or - the operator is
"*"
or"/"
and the left value is aCalculationOperation
with operator"+"
or"-"
,
emit
"("
followed byleft
followed by")"
. Otherwise, emitleft
. - the left value is a
-
Emit
" "
, then the operator, then" "
. -
If either:
- the right value is a
CalculationInterpolation
, or - the operator is
"*"
or"/"
and the right value is aCalculationOperation
with operator"+"
or"-"
,
emit
"("
followed byright
followed by")"
. Otherwise, emitright
. - the right value is a
CalculationInterpolation
To serialize a CalculationInterpolation
, emit its value
.
Procedures
Simplifying a Calculation
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.
-
Let
arguments
be the result of simplifying each ofcalc
's arguments. -
If
calc
's name is"calc"
, the syntax guarantees thatarguments
contain only a single argument. If that argument is a number or calculation, return it. -
If
calc
's name is"min"
,"max"
, or"clamp"
andarguments
are all numbers whose units are mutually compatible, return the result of callingmath.min()
,math.max()
, ormath.clamp()
(respectively) with those arguments. -
Otherwise, return a calculation with the same name as
calc
andarguments
as its arguments.
Simplifying a CalculationValue
This algorithm takes a CalculationValue
value
and returns a
CalculationValue
.
This algorithm is intended to return a value that's CSS-semantically identical to the input.
-
If
value
is a number, unquoted string, orCalculationInterpolation
, return it as-is. -
If
value
is a calculation:-
Let
result
be the result of simplifyingvalue
. -
If
result
is a calculation whose name is"calc"
, returnresult
's single argument. -
Otherwise, return
result
.
-
-
Otherwise,
value
must be aCalculationOperation
. Letleft
andright
be the result of simplifyingvalue.left
andvalue.right
, respectively. -
If
value.operator
is"+"
or"-"
:-
If
left
andright
are both numbers with compatible units, returnleft + right
orleft - right
, respectively.TODO: Should we throw an error here for units we can prove are actively incompatible? For example, unitless numbers are always incompatible with numbers of any units, and numbers across different known types (length + time) are also invalid.
TODO: Should we try to simplify
calc(1px + 1% + 1px)
? -
Otherwise, return a
CalculationOperation
withvalue.operator
,left
, andright
.
-
-
If
value.operator
is"*"
or"/"
:-
If
left
andright
are both numbers, returnleft * right
ormath.div(left, right)
, respectively.TODO: Should we try to simplify
calc(2 * var(--foo) * 2)
? What aboutcalc(-1 * (var(--foo) + 1px)
? -
Otherwise, return a
CalculationOperation
withvalue.operator
,left
, andright
.
-
Semantics
CalcExpression
To evaluate a CalcExpression
:
-
Let
calc
be a calculation whose name is"calc"
and whose only argument is the result of evaluating the expression'sCalcArgument
. -
Return the result of simplifying
calc
.
ClampExpression
To evaluate a ClampExpression
:
-
Let
clamp
be a calculation whose name is"clamp"
and whose arguments are the results of evaluating the expression'sCalcArgument
s. -
Return the result of simplifying
clamp
.
CssMinMax
To evaluate a CssMinMax
:
-
Let
calc
be a calculation whose name is"min"
or"max"
according to theCssMinMax
's first token, and whose arguments are the results of evaluating the expression'sCalcArgument
s. -
Return the result of simplifying
calc
.
CalcArgument
To evaluate a CalcArgument
production argument
into a CalculationValue
object:
-
If
argument
is anInterpolatedDeclarationValue
, evaluate it and aCalculationInterpolation
whosevalue
is the resulting string. -
Otherwise, return the result of evaluating
argument
'sCalcValue
.
CalcSum
To evaluate a CalcSum
production sum
into a CalculationValue
object:
-
Left
left
be the result of evaluating the firstCalcProduct
. -
For each remaining "+" or "-" token
operator
andCalcProduct
product
:-
Let
right
be the result of evaluatingproduct
. -
Set
left
to aCalcOperation
withoperator
,left
, andright
.
-
-
Return
left
.
CalcProduct
To evaluate a CalcProduct
production product
into a CalculationValue
object:
-
Left
left
be the result of evaluating the firstCalcValue
. -
For each remaining "*" or "/" token
operator
andCalcValue
value
:-
Let
right
be the result of evaluatingvalue
. -
Set
left
to aCalcOperation
withoperator
,left
, andright
as its values.
-
-
Return
left
.
CalcValue
To evaluate a CalcValue
production value
into a CalculationValue
object:
-
If
value
is aCalcArgument
,CssMinMax
, orNumber
, return the result of evaluating it. -
If
value
is aFunctionExpression
orVariable
, evaluate it. 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 ascalc(fn())
.
Functions
meta.type-of()
Add the following clause to the meta.type-of()
function and the top-level
type-of()
function:
- If
$value
is a calculation, return"calc"
.
meta.calc-name()
This is a new function in the sass:meta
module.
meta.calc-name($calc)
-
If
$calc
is not a calculation, throw an error. -
Return
$calc
's name as a quoted string.
meta.calc-args()
This is a new function in the sass:meta
module.
meta.calc-args($calc)
-
If
$calc
is not a calculation, throw an error. -
Let
args
be an empty list. -
For each argument
arg
in$calc
's arguments:-
If
arg
is a number, add it toargs
. -
Otherwise, serialize
arg
and add the result toargs
as an unquoted string.
-
-
Return
args
as an unbracketed comma-separated list.