Skip to content

Commit

Permalink
Improve data type analyses for arbitrary values (#9320)
Browse files Browse the repository at this point in the history
* improve split logic by delimiter

The original RegEx did mostly what we want, the idea is that we wanted
to split by a `,` but one that was not within `()`. This is useful when
you define multiple background colors for example:
```html
<div class="bg-[rgb(0,0,0),rgb(255,255,255)]"></div>
```

In this case splitting by the regex would result in the proper result:
```js
let result = [
  'rgb(0,0,0)',
  'rgb(255,255,255)'
]
```

Visually, you can think of it like:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgb(0,0,0),rgb(255,255,255)]"></div>
    ·                       ──┬── ┬    ─────┬─────
    ·                         │   │         ╰─────── Guarded by parens
    ·                         │   ╰───────────────── We will split here
    ·                         ╰───────────────────── Guarded by parens
    │
    └─
```

We properly split by `,` not inside a `()`. However, this RegEx fails
the moment you have deeply nested RegEx values.

Visually, this is what's happening:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgba(0,0,0,var(--alpha))]"></div>
    ·                         ┬ ┬ ┬
    ·                         ╰─┴─┴── We accidentally split here
    │
    └─
```
This is because on the right of the `,`, the first paren is an opening
paren `(` instead of a closing one `)`.

I'm not 100% sure how we can improve the RegEx to handle that case as
well, instead I wrote a small `splitBy` function that allows you to
split the string by a character (just like you could do before) but
ignores the ones inside the given exceptions. This keeps track of a
stack to know whether we are within parens or not.

Visually, the fix looks like this:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgba(0,0,0,var(--alpha)),rgb(255,255,255,var(--alpha))]"></div>
    ·                         ┬ ┬ ┬             ┬       ┬   ┬   ┬
    ·                         │ │ │             │       ╰───┴───┴── Guarded by parens
    ·                         │ │ │             ╰────────────────── We will split here
    ·                         ╰─┴─┴──────────────────────────────── Guarded by parens
    │
    └─
```

* use already existing `splitAtTopLevelOnly` function

* add faster implemetation for `splitAtTopLevelOnly`

However, the faster version can't handle separators with multiple
characters right now. So instead of using buggy code or only using the
"slower" code, we've added a fast path where we use the faster code
wherever we can.

* use `splitAtTopLevelOnly` directly

* make split go brrrrrrr

* update changelog

* remove unncessary array.from call

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
  • Loading branch information
RobinMalfait and thecrypticace committed Sep 14, 2022
1 parent 3e6b8ac commit 527031d
Show file tree
Hide file tree
Showing 7 changed files with 37 additions and 60 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't mutate shared config objects ([#9294](https://github.com/tailwindlabs/tailwindcss/pull/9294))
- Fix ordering of parallel variants ([#9282](https://github.com/tailwindlabs/tailwindcss/pull/9282))
- Handle variants in utility selectors using `:where()` and `:has()` ([#9309](https://github.com/tailwindlabs/tailwindcss/pull/9309))
- Improve data type analyses for arbitrary values ([#9320](https://github.com/tailwindlabs/tailwindcss/pull/9320))

## [3.1.8] - 2022-08-05

Expand Down
2 changes: 1 addition & 1 deletion src/lib/generateRules.js
Expand Up @@ -461,7 +461,7 @@ function splitWithSeparator(input, separator) {
return [sharedState.NOT_ON_DEMAND]
}

return Array.from(splitAtTopLevelOnly(input, separator))
return splitAtTopLevelOnly(input, separator)
}

function* recordCandidates(matches, classCandidate) {
Expand Down
16 changes: 7 additions & 9 deletions src/util/dataTypes.js
@@ -1,13 +1,11 @@
import { parseColor } from './color'
import { parseBoxShadowValue } from './parseBoxShadowValue'
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'

let cssFunctions = ['min', 'max', 'clamp', 'calc']

// Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types

let COMMA = /,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
let UNDERSCORE = /_(?![^(]*\))/g // Underscore separator that is not located between brackets. E.g.: `rgba(255,_255,_255)_black` these don't count.

// This is not a data type, but rather a function that can normalize the
// correct values.
export function normalize(value, isRoot = true) {
Expand Down Expand Up @@ -61,7 +59,7 @@ export function number(value) {
}

export function percentage(value) {
return value.split(UNDERSCORE).every((part) => {
return splitAtTopLevelOnly(value, '_').every((part) => {
return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part))
})
}
Expand All @@ -86,7 +84,7 @@ let lengthUnits = [
]
let lengthUnitsPattern = `(?:${lengthUnits.join('|')})`
export function length(value) {
return value.split(UNDERSCORE).every((part) => {
return splitAtTopLevelOnly(value, '_').every((part) => {
return (
part === '0' ||
new RegExp(`${lengthUnitsPattern}$`).test(part) ||
Expand Down Expand Up @@ -115,7 +113,7 @@ export function shadow(value) {
export function color(value) {
let colors = 0

let result = value.split(UNDERSCORE).every((part) => {
let result = splitAtTopLevelOnly(value, '_').every((part) => {
part = normalize(part)

if (part.startsWith('var(')) return true
Expand All @@ -130,7 +128,7 @@ export function color(value) {

export function image(value) {
let images = 0
let result = value.split(COMMA).every((part) => {
let result = splitAtTopLevelOnly(value, ',').every((part) => {
part = normalize(part)

if (part.startsWith('var(')) return true
Expand Down Expand Up @@ -171,7 +169,7 @@ export function gradient(value) {
let validPositions = new Set(['center', 'top', 'right', 'bottom', 'left'])
export function position(value) {
let positions = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = splitAtTopLevelOnly(value, '_').every((part) => {
part = normalize(part)

if (part.startsWith('var(')) return true
Expand All @@ -189,7 +187,7 @@ export function position(value) {

export function familyName(value) {
let fonts = 0
let result = value.split(COMMA).every((part) => {
let result = splitAtTopLevelOnly(value, ',').every((part) => {
part = normalize(part)

if (part.startsWith('var(')) return true
Expand Down
2 changes: 1 addition & 1 deletion src/util/parseBoxShadowValue.js
Expand Up @@ -5,7 +5,7 @@ let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces inste
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g

export function parseBoxShadowValue(input) {
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
let shadows = splitAtTopLevelOnly(input, ',')
return shadows.map((shadow) => {
let value = shadow.trim()
let result = { raw: value }
Expand Down
72 changes: 23 additions & 49 deletions src/util/splitAtTopLevelOnly.js
@@ -1,5 +1,3 @@
import * as regex from '../lib/regex'

/**
* This splits a string on a top-level character.
*
Expand All @@ -15,57 +13,33 @@ import * as regex from '../lib/regex'
* @param {string} input
* @param {string} separator
*/
export function* splitAtTopLevelOnly(input, separator) {
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')

let depth = 0
let lastIndex = 0
let found = false
let separatorIndex = 0
let separatorStart = 0
let separatorLength = separator.length

// Find all paren-like things & character
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
let matchesSeparator = match[0] === separator[separatorIndex]
let atEndOfSeparator = separatorIndex === separatorLength - 1
let matchesFullSeparator = matchesSeparator && atEndOfSeparator

if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === '[') depth++
if (match[0] === ']') depth--
if (match[0] === '{') depth++
if (match[0] === '}') depth--

if (matchesSeparator && depth === 0) {
if (separatorStart === 0) {
separatorStart = match.index
export function splitAtTopLevelOnly(input, separator) {
let stack = []
let parts = []
let lastPos = 0

for (let idx = 0; idx < input.length; idx++) {
let char = input[idx]

if (stack.length === 0 && char === separator[0]) {
if (separator.length === 1 || input.slice(idx, idx + separator.length) === separator) {
parts.push(input.slice(lastPos, idx))
lastPos = idx + separator.length
}

separatorIndex++
}

if (matchesFullSeparator && depth === 0) {
found = true

yield input.substring(lastIndex, separatorStart)
lastIndex = separatorStart + separatorLength
}

if (separatorIndex === separatorLength) {
separatorIndex = 0
separatorStart = 0
if (char === '(' || char === '[' || char === '{') {
stack.push(char)
} else if (
(char === ')' && stack[stack.length - 1] === '(') ||
(char === ']' && stack[stack.length - 1] === '[') ||
(char === '}' && stack[stack.length - 1] === '{')
) {
stack.pop()
}
}

// Provide the last segment of the string if available
// Otherwise the whole string since no `char`s were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
parts.push(input.slice(lastPos))

return parts
}
3 changes: 3 additions & 0 deletions tests/arbitrary-values.test.css
Expand Up @@ -661,6 +661,9 @@
.bg-opacity-\[var\(--value\)\] {
--tw-bg-opacity: var(--value);
}
.bg-\[linear-gradient\(to_left\2c rgb\(var\(--green\)\)\2c blue\)\] {
background-image: linear-gradient(to left, rgb(var(--green)), blue);
}
.bg-\[url\(\'\/path-to-image\.png\'\)\] {
background-image: url('/path-to-image.png');
}
Expand Down
1 change: 1 addition & 0 deletions tests/arbitrary-values.test.html
Expand Up @@ -220,6 +220,7 @@
<div class="bg-[#0f0_var(--value)]"></div>
<div class="bg-[var(--value1)_var(--value2)]"></div>
<div class="bg-[color:var(--value1)_var(--value2)]"></div>
<div class="bg-[linear-gradient(to_left,rgb(var(--green)),blue)]"></div>

<div class="bg-[url('/path-to-image.png')] bg-[url:var(--url)]"></div>
<div class="bg-[linear-gradient(#eee,#fff)]"></div>
Expand Down

0 comments on commit 527031d

Please sign in to comment.