Skip to content

Commit

Permalink
implement "calc()" reduction for css (#1731)
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Oct 31, 2021
1 parent 2959332 commit b32360d
Show file tree
Hide file tree
Showing 6 changed files with 681 additions and 8 deletions.
34 changes: 34 additions & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,40 @@

## Unreleased

* Implement initial support for simplifying `calc()` expressions in CSS ([#1607](https://github.com/evanw/esbuild/issues/1607))

This release includes basic simplification of `calc()` expressions in CSS when minification is enabled. The approach mainly follows the official CSS specification, which means it should behave the way browsers behave: https://www.w3.org/TR/css-values-4/#calc-func. This is a basic implementation so there are probably some `calc()` expressions that can be reduced by other tools but not by esbuild. This release mainly focuses on setting up the parsing infrastructure for `calc()` expressions to make it straightforward to implement additional simplifications in the future. Here's an example of this new functionality:

```css
/* Input CSS */
div {
width: calc(60px * 4 - 5px * 2);
height: calc(100% / 4);
}

/* Output CSS (with --minify-syntax) */
div {
width: 230px;
height: 25%;
}
```

Expressions that can't be fully simplified will still be partially simplified into a reduced `calc()` expression:

```css
/* Input CSS */
div {
width: calc(100% / 5 - 2 * 1em - 2 * 1px);
}

/* Output CSS (with --minify-syntax) */
div {
width: calc(20% - 2em - 2px);
}
```

Note that this transformation doesn't attempt to modify any expression containing a `var()` CSS variable reference. These variable references can contain any number of tokens so it's not safe to move forward with a simplification assuming that `var()` is a single token. For example, `calc(2px * var(--x) * 3)` is not transformed into `calc(6px * var(--x))` in case `var(--x)` contains something like `4 + 5px` (`calc(2px * 4 + 5px * 3)` evaluates to `23px` while `calc(6px * 4 + 5px)` evaluates to `29px`).

* Fix a crash with a legal comment followed by an import ([#1730](https://github.com/evanw/esbuild/issues/1730))

Version 0.13.10 introduced parsing for CSS legal comments but caused a regression in the code that checks whether there are any rules that come before `@import`. This is not desired because browsers ignore `@import` rules after other non-`@import` rules, so esbuild warns you when you do this. However, legal comments are modeled as rules in esbuild's internal AST even though they aren't actual CSS rules, and the code that performs this check wasn't updated. This release fixes the crash.
Expand Down
4 changes: 3 additions & 1 deletion internal/css_lexer/css_lexer.go
Expand Up @@ -39,6 +39,7 @@ const (
TDelimEquals
TDelimExclamation
TDelimGreaterThan
TDelimMinus
TDelimPlus
TDelimSlash
TDelimTilde
Expand Down Expand Up @@ -79,6 +80,7 @@ var tokenToString = []string{
"\"=\"",
"\"!\"",
"\">\"",
"\"-\"",
"\"+\"",
"\"/\"",
"\"~\"",
Expand Down Expand Up @@ -363,7 +365,7 @@ func (lexer *lexer) next() {
lexer.Token.Kind = lexer.consumeIdentLike()
} else {
lexer.step()
lexer.Token.Kind = TDelim
lexer.Token.Kind = TDelimMinus
}

case '<':
Expand Down
10 changes: 5 additions & 5 deletions internal/css_parser/css_decls_color.go
Expand Up @@ -230,7 +230,7 @@ func hexG(v uint32) int { return int((v >> 16) & 255) }
func hexB(v uint32) int { return int((v >> 8) & 255) }
func hexA(v uint32) int { return int(v & 255) }

func floatToString(a float64) string {
func floatToStringForColor(a float64) string {
text := fmt.Sprintf("%.03f", a)
for text[len(text)-1] == '0' {
text = text[:len(text)-1]
Expand Down Expand Up @@ -269,7 +269,7 @@ func lowerAlphaPercentageToNumber(token css_ast.Token) css_ast.Token {
if token.Kind == css_lexer.TPercentage {
if value, err := strconv.ParseFloat(token.Text[:len(token.Text)-1], 64); err == nil {
token.Kind = css_lexer.TNumber
token.Text = floatToString(value / 100.0)
token.Text = floatToStringForColor(value / 100.0)
}
}
return token
Expand All @@ -294,7 +294,7 @@ func (p *parser) lowerColor(token css_ast.Token) css_ast.Token {
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexR(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexG(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexB(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: floatToString(float64(hexA(hex)) / 255)},
{Kind: css_lexer.TNumber, Text: floatToStringForColor(float64(hexA(hex)) / 255)},
}
}

Expand All @@ -308,7 +308,7 @@ func (p *parser) lowerColor(token css_ast.Token) css_ast.Token {
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexR(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexG(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: strconv.Itoa(hexB(hex))}, commaToken,
{Kind: css_lexer.TNumber, Text: floatToString(float64(hexA(hex)) / 255)},
{Kind: css_lexer.TNumber, Text: floatToStringForColor(float64(hexA(hex)) / 255)},
}
}
}
Expand All @@ -332,7 +332,7 @@ func (p *parser) lowerColor(token css_ast.Token) css_ast.Token {
if (text == "hsl" || text == "hsla") && len(args) > 0 {
if degrees, ok := degreesForAngle(args[0]); ok {
args[0].Kind = css_lexer.TNumber
args[0].Text = floatToString(degrees)
args[0].Text = floatToStringForColor(degrees)
}
}

Expand Down
35 changes: 33 additions & 2 deletions internal/css_parser/css_parser.go
Expand Up @@ -681,8 +681,9 @@ func (p *parser) convertTokens(tokens []css_lexer.Token) []css_ast.Token {
}

type convertTokensOpts struct {
allowImports bool
verbatimWhitespace bool
allowImports bool
verbatimWhitespace bool
isInsideCalcFunction bool
}

func (p *parser) convertTokensHelper(tokens []css_lexer.Token, close css_lexer.T, opts convertTokensOpts) ([]css_ast.Token, []css_lexer.Token) {
Expand All @@ -703,6 +704,14 @@ loop:
}
nextWhitespace = 0

// Warn about invalid "+" and "-" operators that break the containing "calc()"
if opts.isInsideCalcFunction && t.Kind.IsNumeric() && len(result) > 0 && result[len(result)-1].Kind.IsNumeric() &&
(strings.HasPrefix(token.Text, "+") || strings.HasPrefix(token.Text, "-")) {
// "calc(1+2)" and "calc(1-2)" are invalid
p.log.AddRangeWarning(&p.tracker, logger.Range{Loc: t.Range.Loc, Len: 1},
fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text[:1]))
}

switch t.Kind {
case css_lexer.TWhitespace:
if last := len(result) - 1; last >= 0 {
Expand All @@ -711,6 +720,20 @@ loop:
nextWhitespace = css_ast.WhitespaceBefore
continue

case css_lexer.TDelimPlus, css_lexer.TDelimMinus:
// Warn about invalid "+" and "-" operators that break the containing "calc()"
if opts.isInsideCalcFunction && len(tokens) > 0 {
if len(result) == 0 || result[len(result)-1].Kind == css_lexer.TComma {
// "calc(-(1 + 2))" is invalid
p.log.AddRangeWarning(&p.tracker, t.Range,
fmt.Sprintf("%q can only be used as an infix operator, not a prefix operator", token.Text))
} else if token.Whitespace != css_ast.WhitespaceBefore || tokens[0].Kind != css_lexer.TWhitespace {
// "calc(1- 2)" and "calc(1 -(2))" are invalid
p.log.AddRangeWarning(&p.tracker, t.Range,
fmt.Sprintf("The %q operator only works if there is whitespace on both sides", token.Text))
}
}

case css_lexer.TNumber:
if p.options.MangleSyntax {
if text, ok := mangleNumber(token.Text); ok {
Expand Down Expand Up @@ -758,9 +781,17 @@ loop:
// CSS variables require verbatim whitespace for correctness
nestedOpts.verbatimWhitespace = true
}
if token.Text == "calc" {
nestedOpts.isInsideCalcFunction = true
}
nested, tokens = p.convertTokensHelper(tokens, css_lexer.TCloseParen, nestedOpts)
token.Children = &nested

// Apply "calc" simplification rules when minifying
if p.options.MangleSyntax && token.Text == "calc" {
token = p.tryToReduceCalcExpression(token)
}

// Treat a URL function call with a string just like a URL token
if token.Text == "url" && len(nested) == 1 && nested[0].Kind == css_lexer.TString {
token.Kind = css_lexer.TURL
Expand Down
87 changes: 87 additions & 0 deletions internal/css_parser/css_parser_test.go
Expand Up @@ -1178,6 +1178,93 @@ func TestMangleTime(t *testing.T) {
expectPrintedMangle(t, "a { animation: b 1E3ms }", "a {\n animation: b 1E3ms;\n}\n")
}

func TestCalc(t *testing.T) {
expectParseError(t, "a { b: calc(+(2)) }", "<stdin>: warning: \"+\" can only be used as an infix operator, not a prefix operator\n")
expectParseError(t, "a { b: calc(-(2)) }", "<stdin>: warning: \"-\" can only be used as an infix operator, not a prefix operator\n")
expectParseError(t, "a { b: calc(*(2)) }", "")
expectParseError(t, "a { b: calc(/(2)) }", "")

expectParseError(t, "a { b: calc(1 + 2) }", "")
expectParseError(t, "a { b: calc(1 - 2) }", "")
expectParseError(t, "a { b: calc(1 * 2) }", "")
expectParseError(t, "a { b: calc(1 / 2) }", "")

expectParseError(t, "a { b: calc(1+ 2) }", "<stdin>: warning: The \"+\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1- 2) }", "<stdin>: warning: The \"-\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1* 2) }", "")
expectParseError(t, "a { b: calc(1/ 2) }", "")

expectParseError(t, "a { b: calc(1 +2) }", "<stdin>: warning: The \"+\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1 -2) }", "<stdin>: warning: The \"-\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1 *2) }", "")
expectParseError(t, "a { b: calc(1 /2) }", "")

expectParseError(t, "a { b: calc(1 +(2)) }", "<stdin>: warning: The \"+\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1 -(2)) }", "<stdin>: warning: The \"-\" operator only works if there is whitespace on both sides\n")
expectParseError(t, "a { b: calc(1 *(2)) }", "")
expectParseError(t, "a { b: calc(1 /(2)) }", "")
}

func TestMinifyCalc(t *testing.T) {
expectPrintedMangleMinify(t, "a { b: calc(x + y) }", "a{b:calc(x + y)}")
expectPrintedMangleMinify(t, "a { b: calc(x - y) }", "a{b:calc(x - y)}")
expectPrintedMangleMinify(t, "a { b: calc(x * y) }", "a{b:calc(x*y)}")
expectPrintedMangleMinify(t, "a { b: calc(x / y) }", "a{b:calc(x/y)}")
}

func TestMangleCalc(t *testing.T) {
expectPrintedMangle(t, "a { b: calc(1) }", "a {\n b: 1;\n}\n")
expectPrintedMangle(t, "a { b: calc((1)) }", "a {\n b: 1;\n}\n")
expectPrintedMangle(t, "a { b: calc(calc(1)) }", "a {\n b: 1;\n}\n")
expectPrintedMangle(t, "a { b: calc(x + y * z) }", "a {\n b: calc(x + y * z);\n}\n")
expectPrintedMangle(t, "a { b: calc(x * y + z) }", "a {\n b: calc(x * y + z);\n}\n")

// Test sum
expectPrintedMangle(t, "a { b: calc(2 + 3) }", "a {\n b: 5;\n}\n")
expectPrintedMangle(t, "a { b: calc(6 - 2) }", "a {\n b: 4;\n}\n")

// Test product
expectPrintedMangle(t, "a { b: calc(2 * 3) }", "a {\n b: 6;\n}\n")
expectPrintedMangle(t, "a { b: calc(6 / 2) }", "a {\n b: 3;\n}\n")
expectPrintedMangle(t, "a { b: calc(2px * 3 + 4px * 5) }", "a {\n b: 26px;\n}\n")
expectPrintedMangle(t, "a { b: calc(2 * 3px + 4 * 5px) }", "a {\n b: 26px;\n}\n")
expectPrintedMangle(t, "a { b: calc(2px * 3 - 4px * 5) }", "a {\n b: -14px;\n}\n")
expectPrintedMangle(t, "a { b: calc(2 * 3px - 4 * 5px) }", "a {\n b: -14px;\n}\n")

// Test negation
expectPrintedMangle(t, "a { b: calc(x + 1) }", "a {\n b: calc(x + 1);\n}\n")
expectPrintedMangle(t, "a { b: calc(x - 1) }", "a {\n b: calc(x - 1);\n}\n")
expectPrintedMangle(t, "a { b: calc(x + -1) }", "a {\n b: calc(x - 1);\n}\n")
expectPrintedMangle(t, "a { b: calc(x - -1) }", "a {\n b: calc(x + 1);\n}\n")
expectPrintedMangle(t, "a { b: calc(1 + x) }", "a {\n b: calc(1 + x);\n}\n")
expectPrintedMangle(t, "a { b: calc(1 - x) }", "a {\n b: calc(1 - x);\n}\n")
expectPrintedMangle(t, "a { b: calc(-1 + x) }", "a {\n b: calc(-1 + x);\n}\n")
expectPrintedMangle(t, "a { b: calc(-1 - x) }", "a {\n b: calc(-1 - x);\n}\n")

// Test inversion
expectPrintedMangle(t, "a { b: calc(x * 4) }", "a {\n b: calc(x * 4);\n}\n")
expectPrintedMangle(t, "a { b: calc(x / 4) }", "a {\n b: calc(x / 4);\n}\n")
expectPrintedMangle(t, "a { b: calc(x * 0.25) }", "a {\n b: calc(x / 4);\n}\n")
expectPrintedMangle(t, "a { b: calc(x / 0.25) }", "a {\n b: calc(x * 4);\n}\n")

// Test operator precedence
expectPrintedMangle(t, "a { b: calc((a + b) + c) }", "a {\n b: calc(a + b + c);\n}\n")
expectPrintedMangle(t, "a { b: calc(a + (b + c)) }", "a {\n b: calc(a + b + c);\n}\n")
expectPrintedMangle(t, "a { b: calc((a - b) - c) }", "a {\n b: calc(a - b - c);\n}\n")
expectPrintedMangle(t, "a { b: calc(a - (b - c)) }", "a {\n b: calc(a - (b - c));\n}\n")
expectPrintedMangle(t, "a { b: calc((a * b) * c) }", "a {\n b: calc(a * b * c);\n}\n")
expectPrintedMangle(t, "a { b: calc(a * (b * c)) }", "a {\n b: calc(a * b * c);\n}\n")
expectPrintedMangle(t, "a { b: calc((a / b) / c) }", "a {\n b: calc(a / b / c);\n}\n")
expectPrintedMangle(t, "a { b: calc(a / (b / c)) }", "a {\n b: calc(a / (b / c));\n}\n")
expectPrintedMangle(t, "a { b: calc(a + b * c / d - e) }", "a {\n b: calc(a + b * c / d - e);\n}\n")
expectPrintedMangle(t, "a { b: calc((a + ((b * c) / d)) - e) }", "a {\n b: calc(a + b * c / d - e);\n}\n")
expectPrintedMangle(t, "a { b: calc((a + b) * c / (d - e)) }", "a {\n b: calc((a + b) * c / (d - e));\n}\n")

// Using "var()" should bail because it can expand to any number of tokens
expectPrintedMangle(t, "a { b: calc(1px - x + 2px) }", "a {\n b: calc(3px - x);\n}\n")
expectPrintedMangle(t, "a { b: calc(1px - var(x) + 2px) }", "a {\n b: calc(1px - var(x) + 2px);\n}\n")
}

func TestTransform(t *testing.T) {
expectPrintedMangle(t, "a { transform: matrix(1, 0, 0, 1, 0, 0) }", "a {\n transform: scale(1);\n}\n")
expectPrintedMangle(t, "a { transform: matrix(2, 0, 0, 1, 0, 0) }", "a {\n transform: scaleX(2);\n}\n")
Expand Down

0 comments on commit b32360d

Please sign in to comment.