Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Bug: box shadow with color method can be compiled with missing parentheses
**What version of Tailwind CSS are you using?**

3.0.16

**What build tool (or framework if it abstracts the build tool) are you using?**

just Tailwind

**What version of Node.js are you using?**

For example: v16.13.0

**What browser are you using?**

N/A

**What operating system are you using?**

macOS

**Reproduction URL**

minimal reproduction: https://github.com/matthewcc/tailwind3-boxshadow-error

**Describe your issue**

We are using a 3rd party theme switching plugin that can output the following configuration (minimal reproduction doesn't require the plugin):

```
boxShadow: {
   'lg': 'var(--some-variable-name, 0 35px 60px -15px rgba(0, 0, 0))'
}
```
This is valid CSS, and Tailwind 2 handled it fine. But how Tailwind compiles box-shadow has been updated in v3, and with the above configuration the new `tw-shadow-colored` attribute is created missing a closing parentheses:

`  --tw-shadow-colored: var(--some-variable-name, 0 35px 60px -15px var(--tw-shadow-color);`

If we do not use color functions like `rgb(...)` or `hsla(...)`, it compiles fine. It also compiles fine if we throw in random CSS functions like `calc()`.

The issue seems to only happen when the config value is a CSS var that contains a CSS color method, and only with box-shadow (we tried to recreate this with other theme-able values for Tailwind, but only saw this happen with box-shadow), so it seems to specifically be about how Tailwind is transforming those color functions for box-shadow.






Hints:
Yeah, this is something which has been bugging me for ages. Tailwind internally converts the formats of all colors to `color: rgba(...)` values which keeps us from being able to specify colors in other systems or use color profiles in CSS Color Module Level 4 colors. I have a discussion at #2218 which illustrates the issue in more depth.

I'd really like to see this get fixed in Tailwind.

Patch:
diff --git a/src/util/parseBoxShadowValue.js b/src/util/parseBoxShadowValue.js
--- a/src/util/parseBoxShadowValue.js
+++ b/src/util/parseBoxShadowValue.js
@@ -1,10 +1,58 @@
 let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
-let COMMA = /\,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
 let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
 let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
 
+let SPECIALS = /[(),]/g
+
+/**
+ * This splits a string on top-level commas.
+ *
+ * Regex doesn't support recursion (at least not the JS-flavored version).
+ * So we have to use a tiny state machine to keep track of paren vs comma
+ * placement. Before we'd only exclude commas from the inner-most nested
+ * set of parens rather than any commas that were not contained in parens
+ * at all which is the intended behavior here.
+ *
+ * Expected behavior:
+ * var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
+ *       ─┬─             ┬  ┬    ┬
+ *        x              x  x    ╰──────── Split because top-level
+ *        ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
+ *
+ * @param {string} input
+ */
+function* splitByTopLevelCommas(input) {
+  SPECIALS.lastIndex = -1
+
+  let depth = 0
+  let lastIndex = 0
+  let found = false
+
+  // Find all parens & commas
+  // And only split on commas if they're top-level
+  for (let match of input.matchAll(SPECIALS)) {
+    if (match[0] === '(') depth++
+    if (match[0] === ')') depth--
+    if (match[0] === ',' && depth === 0) {
+      found = true
+
+      yield input.substring(lastIndex, match.index)
+      lastIndex = match.index + match[0].length
+    }
+  }
+
+  // Provide the last segment of the string if available
+  // Otherwise the whole string since no commas were found
+  // This mirrors the behavior of string.split()
+  if (found) {
+    yield input.substring(lastIndex)
+  } else {
+    yield input
+  }
+}
+
 export function parseBoxShadowValue(input) {
-  let shadows = input.split(COMMA)
+  let shadows = Array.from(splitByTopLevelCommas(input))
   return shadows.map((shadow) => {
     let value = shadow.trim()
     let result = { raw: value }


Test patch:
diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js
--- a/tests/basic-usage.test.js
+++ b/tests/basic-usage.test.js
@@ -346,3 +346,31 @@ it('does not produce duplicate output when seeing variants preceding a wildcard
     `)
   })
 })
+
+it('it can parse box shadows with variables', () => {
+  let config = {
+    content: [{ raw: html`<div class="shadow-lg"></div>` }],
+    theme: {
+      boxShadow: {
+        lg: 'var(-a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0)',
+      },
+    },
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .shadow-lg {
+        --tw-shadow: var(-a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0);
+        --tw-shadow-colored: 0 35px 60px -15px var(--tw-shadow-color),
+          0 0 1px var(--tw-shadow-color);
+        box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000),
+          var(--tw-shadow);
+      }
+    `)
+  })
+})