Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
`prefix` option + `modifier` + `arbitrary variant`: extra prefix is generated for classes inside variant
`prefix: "tw-",` + `hover:[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000]`
generates:
```CSS
.hover\:\[\&\.header-is-scrolled_\.menu-list_a\]\:tw-text-\[\#FF0000\].tw-header-is-scrolled .tw-menu-list a:hover {
  --tw-text-opacity: 1;
  color: rgb(255 0 0 / var(--tw-text-opacity));
}
```
expected result:
```CSS
.hover\:\[\&\.header-is-scrolled_\.menu-list_a\]\:tw-text-\[\#FF0000\].header-is-scrolled .menu-list a:hover {
  --tw-text-opacity: 1;
  color: rgb(255 0 0 / var(--tw-text-opacity));
}
```

# [Tailwind Play Link](https://play.tailwindcss.com/kuo2fGhMiZ)
![image](https://user-images.githubusercontent.com/5171262/208573193-a916f1a3-0efc-4064-99a9-f9b33fa8de3f.png)
```HTML
<div class="[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] header-is-scrolled">
  <div class="menu-list">
    <a href="#">abc<-</a>
  </div>
</div>
<div class="[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] header-is-scrolled">
  <div class="tw-menu-list">
    <a href="#">abc</a>
  </div>
</div>
<div class="[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] tw-header-is-scrolled">
  <div class="menu-list">
    <a href="#">abc</a>
  </div>
</div>
<div class="[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] tw-header-is-scrolled">
  <div class="tw-menu-list">
    <a href="#">abc</a>
  </div>
</div>

<div class="hover:[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] header-is-scrolled">
  <div class="menu-list">
    <a href="#">abc</a>
  </div>
</div>
<div class="hover:[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] header-is-scrolled">
  <div class="tw-menu-list">
    <a href="#">abc</a>
  </div>
</div>
<div class="hover:[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] tw-header-is-scrolled">
  <div class="menu-list">
    <a href="#">abc</a>
  </div>
</div>
<div class="hover:[&.header-is-scrolled_.menu-list_a]:tw-text-[#FF0000] tw-header-is-scrolled">
  <div class="tw-menu-list">
    <a href="#">abc<-</a>
  </div>
</div>
```


Hints:
I think I'm having a similar issue with group

This is not working:

```html
<div class="tw-group is-published">
  <div class="tw-hidden group-[.is-published]:block">
    Published
  </div>
</div>
```

But found this is working:

```html
<div class="tw-group tw-is-published">
  <div class="tw-hidden group-[.is-published]:block">
    Published
  </div>
</div>
```

Is this the same issue?

Patch:
diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js
--- a/src/lib/generateRules.js
+++ b/src/lib/generateRules.js
@@ -201,6 +201,7 @@ function applyVariant(variant, matches, context) {
   }
 
   if (context.variantMap.has(variant)) {
+    let isArbitraryVariant = isArbitraryValue(variant)
     let variantFunctionTuples = context.variantMap.get(variant).slice()
     let result = []
 
@@ -262,7 +263,10 @@ function applyVariant(variant, matches, context) {
             clone.append(wrapper)
           },
           format(selectorFormat) {
-            collectedFormats.push(selectorFormat)
+            collectedFormats.push({
+              format: selectorFormat,
+              isArbitraryVariant,
+            })
           },
           args,
         })
@@ -288,7 +292,10 @@ function applyVariant(variant, matches, context) {
         }
 
         if (typeof ruleWithVariant === 'string') {
-          collectedFormats.push(ruleWithVariant)
+          collectedFormats.push({
+            format: ruleWithVariant,
+            isArbitraryVariant,
+          })
         }
 
         if (ruleWithVariant === null) {
@@ -329,7 +336,10 @@ function applyVariant(variant, matches, context) {
             //      modified (by plugin): .foo .foo\\:markdown > p
             //    rebuiltBase (internal): .foo\\:markdown > p
             //                    format: .foo &
-            collectedFormats.push(modified.replace(rebuiltBase, '&'))
+            collectedFormats.push({
+              format: modified.replace(rebuiltBase, '&'),
+              isArbitraryVariant,
+            })
             rule.selector = before
           })
         }
@@ -349,7 +359,6 @@ function applyVariant(variant, matches, context) {
               Object.assign(args, context.variantOptions.get(variant))
             ),
             collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats),
-            isArbitraryVariant: isArbitraryValue(variant),
           },
           clone.nodes[0],
         ]
@@ -733,48 +742,15 @@ function* resolveMatches(candidate, context, original = candidate) {
     }
 
     for (let match of matches) {
-      let isValid = true
-
       match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate }
 
       // Apply final format selector
-      if (match[0].collectedFormats) {
-        let finalFormat = formatVariantSelector('&', ...match[0].collectedFormats)
-        let container = postcss.root({ nodes: [match[1].clone()] })
-        container.walkRules((rule) => {
-          if (inKeyframes(rule)) return
-
-          let selectorOptions = {
-            selector: rule.selector,
-            candidate: original,
-            base: candidate
-              .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
-              .pop(),
-            isArbitraryVariant: match[0].isArbitraryVariant,
-
-            context,
-          }
-
-          try {
-            rule.selector = finalizeSelector(finalFormat, selectorOptions)
-          } catch {
-            // The selector we produced is invalid
-            // This could be because:
-            // - A bug exists
-            // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`)
-            // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`)
-            // Either way the build will fail because of this
-            // We would rather that the build pass "silently" given that this could
-            // happen because of picking up invalid things when scanning content
-            // So we'll throw out the candidate instead
-            isValid = false
-            return false
-          }
-        })
-        match[1] = container.nodes[0]
-      }
+      match = applyFinalFormat(match, { context, candidate, original })
 
-      if (!isValid) {
+      // Skip rules with invalid selectors
+      // This will cause the candidate to be added to the "not class"
+      // cache skipping it entirely for future builds
+      if (match === null) {
         continue
       }
 
@@ -783,6 +759,62 @@ function* resolveMatches(candidate, context, original = candidate) {
   }
 }
 
+function applyFinalFormat(match, { context, candidate, original }) {
+  if (!match[0].collectedFormats) {
+    return match
+  }
+
+  let isValid = true
+  let finalFormat
+
+  try {
+    finalFormat = formatVariantSelector(match[0].collectedFormats, {
+      context,
+      candidate,
+    })
+  } catch {
+    // The format selector we produced is invalid
+    // This could be because:
+    // - A bug exists
+    // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`)
+    // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`)
+    // Either way the build will fail because of this
+    // We would rather that the build pass "silently" given that this could
+    // happen because of picking up invalid things when scanning content
+    // So we'll throw out the candidate instead
+
+    return null
+  }
+
+  let container = postcss.root({ nodes: [match[1].clone()] })
+
+  container.walkRules((rule) => {
+    if (inKeyframes(rule)) {
+      return
+    }
+
+    try {
+      rule.selector = finalizeSelector(rule.selector, finalFormat, {
+        candidate: original,
+        context,
+      })
+    } catch {
+      // If this selector is invalid we also want to skip it
+      // But it's likely that being invalid here means there's a bug in a plugin rather than too loosely matching content
+      isValid = false
+      return false
+    }
+  })
+
+  if (!isValid) {
+    return null
+  }
+
+  match[1] = container.nodes[0]
+
+  return match
+}
+
 function inKeyframes(rule) {
   return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes'
 }
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -1080,20 +1080,38 @@ function registerPlugins(plugins, context) {
             })
           }
 
-          let result = formatStrings.map((formatString) =>
-            finalizeSelector(formatVariantSelector('&', ...formatString), {
-              selector: `.${candidate}`,
-              candidate,
-              context,
-              isArbitraryVariant: !(value in (options.values ?? {})),
-            })
+          let isArbitraryVariant = !(value in (options.values ?? {}))
+
+          formatStrings = formatStrings.map((format) =>
+            format.map((str) => ({
+              format: str,
+              isArbitraryVariant,
+            }))
+          )
+
+          manualFormatStrings = manualFormatStrings.map((format) => ({
+            format,
+            isArbitraryVariant,
+          }))
+
+          let opts = {
+            candidate,
+            context,
+          }
+
+          let result = formatStrings.map((formats) =>
+            finalizeSelector(`.${candidate}`, formatVariantSelector(formats, opts), opts)
               .replace(`.${candidate}`, '&')
               .replace('{ & }', '')
               .trim()
           )
 
           if (manualFormatStrings.length > 0) {
-            result.push(formatVariantSelector('&', ...manualFormatStrings))
+            result.push(
+              formatVariantSelector(manualFormatStrings, opts)
+                .toString()
+                .replace(`.${candidate}`, '&')
+            )
           }
 
           return result
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -3,30 +3,57 @@ import unescape from 'postcss-selector-parser/dist/util/unesc'
 import escapeClassName from '../util/escapeClassName'
 import prefixSelector from '../util/prefixSelector'
 
+/** @typedef {import('postcss-selector-parser').Root} Root */
+/** @typedef {import('postcss-selector-parser').Selector} Selector */
+/** @typedef {import('postcss-selector-parser').Pseudo} Pseudo */
+/** @typedef {import('postcss-selector-parser').Node} Node */
+
+/** @typedef {{format: string, isArbitraryVariant: boolean}[]} RawFormats */
+/** @typedef {import('postcss-selector-parser').Root} ParsedFormats */
+/** @typedef {RawFormats | ParsedFormats} AcceptedFormats */
+
 let MERGE = ':merge'
-let PARENT = '&'
-
-export let selectorFunctions = new Set([MERGE])
-
-export function formatVariantSelector(current, ...others) {
-  for (let other of others) {
-    let incomingValue = resolveFunctionArgument(other, MERGE)
-    if (incomingValue !== null) {
-      let existingValue = resolveFunctionArgument(current, MERGE, incomingValue)
-      if (existingValue !== null) {
-        let existingTarget = `${MERGE}(${incomingValue})`
-        let splitIdx = other.indexOf(existingTarget)
-        let addition = other.slice(splitIdx + existingTarget.length).split(' ')[0]
-
-        current = current.replace(existingTarget, existingTarget + addition)
-        continue
-      }
+
+/**
+ * @param {RawFormats} formats
+ * @param {{context: any, candidate: string, base: string | null}} options
+ * @returns {ParsedFormats | null}
+ */
+export function formatVariantSelector(formats, { context, candidate }) {
+  let prefix = context?.tailwindConfig.prefix ?? ''
+
+  // Parse the format selector into an AST
+  let parsedFormats = formats.map((format) => {
+    let ast = selectorParser().astSync(format.format)
+
+    return {
+      ...format,
+      ast: format.isArbitraryVariant ? ast : prefixSelector(prefix, ast),
     }
+  })
 
-    current = other.replace(PARENT, current)
+  // We start with the candidate selector
+  let formatAst = selectorParser.root({
+    nodes: [
+      selectorParser.selector({
+        nodes: [selectorParser.className({ value: escapeClassName(candidate) })],
+      }),
+    ],
+  })
+
+  // And iteratively merge each format selector into the candidate selector
+  for (let { ast } of parsedFormats) {
+    // 1. Handle :merge() special pseudo-class
+    ;[formatAst, ast] = handleMergePseudo(formatAst, ast)
+
+    // 2. Merge the format selector into the current selector AST
+    ast.walkNesting((nesting) => nesting.replaceWith(...formatAst.nodes[0].nodes))
+
+    // 3. Keep going!
+    formatAst = ast
   }
 
-  return current
+  return formatAst
 }
 
 /**
@@ -35,11 +62,11 @@ export function formatVariantSelector(current, ...others) {
  * Technically :is(), :not(), :has(), etc… can have combinators but those are nested
  * inside the relevant node and won't be picked up so they're fine to ignore
  *
- * @param {import('postcss-selector-parser').Node} node
- * @returns {import('postcss-selector-parser').Node[]}
+ * @param {Node} node
+ * @returns {Node[]}
  **/
 function simpleSelectorForNode(node) {
-  /** @type {import('postcss-selector-parser').Node[]} */
+  /** @type {Node[]} */
   let nodes = []
 
   // Walk backwards until we hit a combinator node (or the start)
@@ -60,8 +87,8 @@ function simpleSelectorForNode(node) {
  * Resorts the nodes in a selector to ensure they're in the correct order
  * Tags go before classes, and pseudo classes go after classes
  *
- * @param {import('postcss-selector-parser').Selector} sel
- * @returns {import('postcss-selector-parser').Selector}
+ * @param {Selector} sel
+ * @returns {Selector}
  **/
 function resortSelector(sel) {
   sel.sort((a, b) => {
@@ -81,6 +108,18 @@ function resortSelector(sel) {
   return sel
 }
 
+/**
+ * Remove extraneous selectors that do not include the base class/candidate
+ *
+ * Example:
+ * Given the utility `.a, .b { color: red}`
+ * Given the candidate `sm:b`
+ *
+ * The final selector should be `.sm\:b` and not `.a, .sm\:b`
+ *
+ * @param {Selector} ast
+ * @param {string} base
+ */
 function eliminateIrrelevantSelectors(sel, base) {
   let hasClassesMatchingCandidate = false
 
@@ -104,41 +143,26 @@ function eliminateIrrelevantSelectors(sel, base) {
   // TODO: Can we do this for :matches, :is, and :where?
 }
 
-export function finalizeSelector(
-  format,
-  {
-    selector,
-    candidate,
-    context,
-    isArbitraryVariant,
-
-    // Split by the separator, but ignore the separator inside square brackets:
-    //
-    // E.g.: dark:lg:hover:[paint-order:markers]
-    //           ┬  ┬     ┬            ┬
-    //           │  │     │            ╰── We will not split here
-    //           ╰──┴─────┴─────────────── We will split here
-    //
-    base = candidate
-      .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
-      .pop(),
-  }
-) {
-  let ast = selectorParser().astSync(selector)
-
-  // We explicitly DO NOT prefix classes in arbitrary variants
-  if (context?.tailwindConfig?.prefix && !isArbitraryVariant) {
-    format = prefixSelector(context.tailwindConfig.prefix, format)
-  }
-
-  format = format.replace(PARENT, `.${escapeClassName(candidate)}`)
-
-  let formatAst = selectorParser().astSync(format)
+/**
+ * @param {string} current
+ * @param {AcceptedFormats} formats
+ * @param {{context: any, candidate: string, base: string | null}} options
+ * @returns {string}
+ */
+export function finalizeSelector(current, formats, { context, candidate, base }) {
+  let separator = context?.tailwindConfig?.separator ?? ':'
+
+  // Split by the separator, but ignore the separator inside square brackets:
+  //
+  // E.g.: dark:lg:hover:[paint-order:markers]
+  //           ┬  ┬     ┬            ┬
+  //           │  │     │            ╰── We will not split here
+  //           ╰──┴─────┴─────────────── We will split here
+  //
+  base = base ?? candidate.split(new RegExp(`\\${separator}(?![^[]*\\])`)).pop()
 
-  // Remove extraneous selectors that do not include the base class/candidate being matched against
-  // For example if we have a utility defined `.a, .b { color: red}`
-  // And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b`
-  ast.each((sel) => eliminateIrrelevantSelectors(sel, base))
+  // Parse the selector into an AST
+  let selector = selectorParser().astSync(current)
 
   // Normalize escaped classes, e.g.:
   //
@@ -151,18 +175,31 @@ export function finalizeSelector(
   //   base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\]
   //       escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\]
   //
-  ast.walkClasses((node) => {
+  selector.walkClasses((node) => {
     if (node.raws && node.value.includes(base)) {
       node.raws.value = escapeClassName(unescape(node.raws.value))
     }
   })
 
+  // Remove extraneous selectors that do not include the base candidate
+  selector.each((sel) => eliminateIrrelevantSelectors(sel, base))
+
+  // If there are no formats that means there were no variants added to the candidate
+  // so we can just return the selector as-is
+  let formatAst = Array.isArray(formats)
+    ? formatVariantSelector(formats, { context, candidate })
+    : formats
+
+  if (formatAst === null) {
+    return selector.toString()
+  }
+
   let simpleStart = selectorParser.comment({ value: '/*__simple__*/' })
   let simpleEnd = selectorParser.comment({ value: '/*__simple__*/' })
 
   // We can safely replace the escaped base now, since the `base` section is
   // now in a normalized escaped value.
-  ast.walkClasses((node) => {
+  selector.walkClasses((node) => {
     if (node.value !== base) {
       return
     }
@@ -200,47 +237,86 @@ export function finalizeSelector(
     simpleEnd.remove()
   })
 
-  // This will make sure to move pseudo's to the correct spot (the end for
-  // pseudo elements) because otherwise the selector will never work
-  // anyway.
-  //
-  // E.g.:
-  //  - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
-  //  - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
-  //
-  // `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
-  function collectPseudoElements(selector) {
-    let nodes = []
-
-    for (let node of selector.nodes) {
-      if (isPseudoElement(node)) {
-        nodes.push(node)
-        selector.removeChild(node)
-      }
-
-      if (node?.nodes) {
-        nodes.push(...collectPseudoElements(node))
-      }
+  // Remove unnecessary pseudo selectors that we used as placeholders
+  selector.walkPseudos((p) => {
+    if (p.value === MERGE) {
+      p.replaceWith(p.nodes)
     }
+  })
 
-    return nodes
-  }
-
-  // Remove unnecessary pseudo selectors that we used as placeholders
-  ast.each((selector) => {
-    selector.walkPseudos((p) => {
-      if (selectorFunctions.has(p.value)) {
-        p.replaceWith(p.nodes)
-      }
-    })
-
-    let pseudoElements = collectPseudoElements(selector)
+  // Move pseudo elements to the end of the selector (if necessary)
+  selector.each((sel) => {
+    let pseudoElements = collectPseudoElements(sel)
     if (pseudoElements.length > 0) {
-      selector.nodes.push(pseudoElements.sort(sortSelector))
+      sel.nodes.push(pseudoElements.sort(sortSelector))
+    }
+  })
+
+  return selector.toString()
+}
+
+/**
+ *
+ * @param {Selector} selector
+ * @param {Selector} format
+ */
+export function handleMergePseudo(selector, format) {
+  /** @type {{pseudo: Pseudo, value: string}[]} */
+  let merges = []
+
+  // Find all :merge() pseudo-classes in `selector`
+  selector.walkPseudos((pseudo) => {
+    if (pseudo.value === MERGE) {
+      merges.push({
+        pseudo,
+        value: pseudo.nodes[0].toString(),
+      })
     }
   })
 
-  return ast.toString()
+  // Find all :merge() "attachments" in `format` and attach them to the matching selector in `selector`
+  format.walkPseudos((pseudo) => {
+    if (pseudo.value !== MERGE) {
+      return
+    }
+
+    let value = pseudo.nodes[0].toString()
+
+    // Does `selector` contain a :merge() pseudo-class with the same value?
+    let existing = merges.find((merge) => merge.value === value)
+
+    // Nope so there's nothing to do
+    if (!existing) {
+      return
+    }
+
+    // Everything after `:merge()` up to the next combinator is what is attached to the merged selector
+    let attachments = []
+    let next = pseudo.next()
+    while (next && next.type !== 'combinator') {
+      attachments.push(next)
+      next = next.next()
+    }
+
+    let combinator = next
+
+    existing.pseudo.parent.insertAfter(
+      existing.pseudo,
+      selectorParser.selector({ nodes: attachments.map((node) => node.clone()) })
+    )
+
+    pseudo.remove()
+    attachments.forEach((node) => node.remove())
+
+    // What about this case:
+    // :merge(.group):focus > &
+    // :merge(.group):hover &
+    if (combinator && combinator.type === 'combinator') {
+      combinator.remove()
+    }
+  })
+
+  return [selector, format]
 }
 
 // Note: As a rule, double colons (::) should be used instead of a single colon
@@ -263,6 +339,37 @@ let pseudoElementExceptions = [
   '::-webkit-resizer',
 ]
 
+/**
+ * This will make sure to move pseudo's to the correct spot (the end for
+ * pseudo elements) because otherwise the selector will never work
+ * anyway.
+ *
+ * E.g.:
+ *  - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
+ *  - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
+ *
+ * `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
+ *
+ * @param {Selector} selector
+ **/
+function collectPseudoElements(selector) {
+  /** @type {Node[]} */
+  let nodes = []
+
+  for (let node of selector.nodes) {
+    if (isPseudoElement(node)) {
+      nodes.push(node)
+      selector.removeChild(node)
+    }
+
+    if (node?.nodes) {
+      nodes.push(...collectPseudoElements(node))
+    }
+  }
+
+  return nodes
+}
+
 // This will make sure to move pseudo's to the correct spot (the end for
 // pseudo elements) because otherwise the selector will never work
 // anyway.
@@ -303,28 +410,3 @@ function isPseudoElement(node) {
 
   return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
 }
-
-function resolveFunctionArgument(haystack, needle, arg) {
-  let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
-  if (startIdx === -1) return null
-
-  // Start inside the `(`
-  startIdx += needle.length + 1
-
-  let target = ''
-  let count = 0
-
-  for (let char of haystack.slice(startIdx)) {
-    if (char !== '(' && char !== ')') {
-      target += char
-    } else if (char === '(') {
-      target += char
-      count++
-    } else if (char === ')') {
-      if (--count < 0) break // unbalanced
-      target += char
-    }
-  }
-
-  return target
-}
diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js
--- a/src/util/prefixSelector.js
+++ b/src/util/prefixSelector.js
@@ -1,14 +1,32 @@
 import parser from 'postcss-selector-parser'
 
+/**
+ * @template {string | import('postcss-selector-parser').Root} T
+ *
+ * Prefix all classes in the selector with the given prefix
+ *
+ * It can take either a string or a selector AST and will return the same type
+ *
+ * @param {string} prefix
+ * @param {T} selector
+ * @param {boolean} prependNegative
+ * @returns {T}
+ */
 export default function (prefix, selector, prependNegative = false) {
-  return parser((selectors) => {
-    selectors.walkClasses((classSelector) => {
-      let baseClass = classSelector.value
-      let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-')
-
-      classSelector.value = shouldPlaceNegativeBeforePrefix
-        ? `-${prefix}${baseClass.slice(1)}`
-        : `${prefix}${baseClass}`
-    })
-  }).processSync(selector)
+  if (prefix === '') {
+    return selector
+  }
+
+  let ast = typeof selector === 'string' ? parser().astSync(selector) : selector
+
+  ast.walkClasses((classSelector) => {
+    let baseClass = classSelector.value
+    let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-')
+
+    classSelector.value = shouldPlaceNegativeBeforePrefix
+      ? `-${prefix}${baseClass.slice(1)}`
+      : `${prefix}${baseClass}`
+  })
+
+  return typeof selector === 'string' ? ast.toString() : ast
 }


Test patch:
diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js
--- a/tests/arbitrary-variants.test.js
+++ b/tests/arbitrary-variants.test.js
@@ -542,6 +542,14 @@ test('classes in arbitrary variants should not be prefixed', () => {
             <div>should not be red</div>
             <div class="foo">should be red</div>
           </div>
+          <div class="hover:[&_.foo]:tw-text-red-400">
+            <div>should not be red</div>
+            <div class="foo">should be red</div>
+          </div>
+          <div class="[&_.foo]:hover:tw-text-red-400">
+            <div>should not be red</div>
+            <div class="foo">should be red</div>
+          </div>
         `,
       },
     ],
@@ -558,7 +566,14 @@ test('classes in arbitrary variants should not be prefixed', () => {
         --tw-text-opacity: 1;
         color: rgb(248 113 113 / var(--tw-text-opacity));
       }
-
+      .hover\:\[\&_\.foo\]\:tw-text-red-400 .foo:hover {
+        --tw-text-opacity: 1;
+        color: rgb(248 113 113 / var(--tw-text-opacity));
+      }
+      .\[\&_\.foo\]\:hover\:tw-text-red-400:hover .foo {
+        --tw-text-opacity: 1;
+        color: rgb(248 113 113 / var(--tw-text-opacity));
+      }
       .foo .\[\.foo_\&\]\:tw-text-red-400 {
         --tw-text-opacity: 1;
         color: rgb(248 113 113 / var(--tw-text-opacity));
diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js
--- a/tests/format-variant-selector.test.js
+++ b/tests/format-variant-selector.test.js
@@ -1,23 +1,24 @@
-import { formatVariantSelector, finalizeSelector } from '../src/util/formatVariantSelector'
+import { finalizeSelector } from '../src/util/formatVariantSelector'
 
 it('should be possible to add a simple variant to a simple selector', () => {
   let selector = '.text-center'
   let candidate = 'hover:text-center'
 
-  let variants = ['&:hover']
+  let formats = [{ format: '&:hover', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
-    '.hover\\:text-center:hover'
-  )
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual('.hover\\:text-center:hover')
 })
 
 it('should be possible to add a multiple simple variants to a simple selector', () => {
   let selector = '.text-center'
   let candidate = 'focus:hover:text-center'
 
-  let variants = ['&:hover', '&:focus']
+  let formats = [
+    { format: '&:hover', isArbitraryVariant: false },
+    { format: '&:focus', isArbitraryVariant: false },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.focus\\:hover\\:text-center:hover:focus'
   )
 })
@@ -26,9 +27,9 @@ it('should be possible to add a simple variant to a selector containing escaped
   let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]'
   let candidate = 'hover:bg-[rgba(0,0,0)]'
 
-  let variants = ['&:hover']
+  let formats = [{ format: '&:hover', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover'
   )
 })
@@ -37,9 +38,9 @@ it('should be possible to add a simple variant to a selector containing escaped
   let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]'
   let candidate = 'hover:bg-[rgba(0,0,0)]'
 
-  let variants = ['&:hover']
+  let formats = [{ format: '&:hover', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover'
   )
 })
@@ -48,9 +49,9 @@ it('should be possible to add a simple variant to a more complex selector', () =
   let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
   let candidate = 'hover:space-x-4'
 
-  let variants = ['&:hover']
+  let formats = [{ format: '&:hover', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])'
   )
 })
@@ -59,9 +60,13 @@ it('should be possible to add multiple simple variants to a more complex selecto
   let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
   let candidate = 'disabled:focus:hover:space-x-4'
 
-  let variants = ['&:hover', '&:focus', '&:disabled']
+  let formats = [
+    { format: '&:hover', isArbitraryVariant: false },
+    { format: '&:focus', isArbitraryVariant: false },
+    { format: '&:disabled', isArbitraryVariant: false },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.disabled\\:focus\\:hover\\:space-x-4:hover:focus:disabled > :not([hidden]) ~ :not([hidden])'
   )
 })
@@ -70,9 +75,9 @@ it('should be possible to add a single merge variant to a simple selector', () =
   let selector = '.text-center'
   let candidate = 'group-hover:text-center'
 
-  let variants = [':merge(.group):hover &']
+  let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:hover .group-hover\\:text-center'
   )
 })
@@ -81,9 +86,12 @@ it('should be possible to add multiple merge variants to a simple selector', ()
   let selector = '.text-center'
   let candidate = 'group-focus:group-hover:text-center'
 
-  let variants = [':merge(.group):hover &', ':merge(.group):focus &']
+  let formats = [
+    { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    { format: ':merge(.group):focus &', isArbitraryVariant: false },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:focus:hover .group-focus\\:group-hover\\:text-center'
   )
 })
@@ -92,9 +100,9 @@ it('should be possible to add a single merge variant to a more complex selector'
   let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])'
   let candidate = 'group-hover:space-x-4'
 
-  let variants = [':merge(.group):hover &']
+  let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:hover .group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])'
   )
 })
@@ -103,9 +111,12 @@ it('should be possible to add multiple merge variants to a more complex selector
   let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])'
   let candidate = 'group-focus:group-hover:space-x-4'
 
-  let variants = [':merge(.group):hover &', ':merge(.group):focus &']
+  let formats = [
+    { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    { format: ':merge(.group):focus &', isArbitraryVariant: false },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:focus:hover .group-focus\\:group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])'
   )
 })
@@ -114,9 +125,12 @@ it('should be possible to add multiple unique merge variants to a simple selecto
   let selector = '.text-center'
   let candidate = 'peer-focus:group-hover:text-center'
 
-  let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &']
+  let formats = [
+    { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    { format: ':merge(.peer):focus ~ &' },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.peer:focus ~ .group:hover .peer-focus\\:group-hover\\:text-center'
   )
 })
@@ -125,37 +139,41 @@ it('should be possible to add multiple unique merge variants to a simple selecto
   let selector = '.text-center'
   let candidate = 'group-hover:peer-focus:text-center'
 
-  let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &']
+  let formats = [
+    { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false },
+    { format: ':merge(.group):hover &', isArbitraryVariant: false },
+  ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:hover .peer:focus ~ .group-hover\\:peer-focus\\:text-center'
   )
 })
 
 it('should be possible to use multiple :merge() calls with different "arguments"', () => {
-  let result = '&'
-  result = formatVariantSelector(result, ':merge(.group):hover &')
-  expect(result).toEqual(':merge(.group):hover &')
-
-  result = formatVariantSelector(result, ':merge(.peer):hover ~ &')
-  expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):hover &')
-
-  result = formatVariantSelector(result, ':merge(.group):focus &')
-  expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):focus:hover &')
+  let selector = '.foo'
+  let candidate = 'peer-focus:group-focus:peer-hover:group-hover:foo'
+
+  let formats = [
+    { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    { format: ':merge(.peer):hover ~ &', isArbitraryVariant: false },
+    { format: ':merge(.group):focus &', isArbitraryVariant: false },
+    { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false },
+  ]
 
-  result = formatVariantSelector(result, ':merge(.peer):focus ~ &')
-  expect(result).toEqual(':merge(.peer):focus:hover ~ :merge(.group):focus:hover &')
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
+    '.peer:focus:hover ~ .group:focus:hover .peer-focus\\:group-focus\\:peer-hover\\:group-hover\\:foo'
+  )
 })
 
 it('group hover and prose headings combination', () => {
   let selector = '.text-center'
   let candidate = 'group-hover:prose-headings:text-center'
-  let variants = [
-    ':where(&) :is(h1, h2, h3, h4)', // Prose Headings
-    ':merge(.group):hover &', // Group Hover
+  let formats = [
+    { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings
+    { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover
   ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.group:hover :where(.group-hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4)'
   )
 })
@@ -163,12 +181,12 @@ it('group hover and prose headings combination', () => {
 it('group hover and prose headings combination flipped', () => {
   let selector = '.text-center'
   let candidate = 'prose-headings:group-hover:text-center'
-  let variants = [
-    ':merge(.group):hover &', // Group Hover
-    ':where(&) :is(h1, h2, h3, h4)', // Prose Headings
+  let formats = [
+    { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover
+    { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings
   ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     ':where(.group:hover .prose-headings\\:group-hover\\:text-center) :is(h1, h2, h3, h4)'
   )
 })
@@ -176,28 +194,74 @@ it('group hover and prose headings combination flipped', () => {
 it('should be possible to handle a complex utility', () => {
   let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
   let candidate = 'peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4'
-  let variants = [
-    '&:hover', // Hover
-    '&:focus', // Focus
-    ':merge(.group):focus &', // Group focus
-    ':merge(.group):hover &', // Group hover
-    ':merge(.peer):first-child ~ &', // Peer first-child
-    ':merge(.peer):disabled ~ &', // Peer disabled
+  let formats = [
+    { format: '&:hover', isArbitraryVariant: false }, // Hover
+    { format: '&:focus', isArbitraryVariant: false }, // Focus
+    { format: ':merge(.group):focus &', isArbitraryVariant: false }, // Group focus
+    { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group hover
+    { format: ':merge(.peer):first-child ~ &', isArbitraryVariant: false }, // Peer first-child
+    { format: ':merge(.peer):disabled ~ &', isArbitraryVariant: false }, // Peer disabled
   ]
 
-  expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
     '.peer:disabled:first-child ~ .group:hover:focus .peer-disabled\\:peer-first-child\\:group-hover\\:group-focus\\:focus\\:hover\\:space-x-4:hover:focus > :not([hidden]) ~ :not([hidden])'
   )
 })
 
+it('should match base utilities that are prefixed', () => {
+  let context = { tailwindConfig: { prefix: 'tw-' } }
+  let selector = '.tw-text-center'
+  let candidate = 'tw-text-center'
+  let formats = []
+
+  expect(finalizeSelector(selector, formats, { candidate, context })).toEqual('.tw-text-center')
+})
+
+it('should prefix classes from variants', () => {
+  let context = { tailwindConfig: { prefix: 'tw-' } }
+  let selector = '.tw-text-center'
+  let candidate = 'foo:tw-text-center'
+  let formats = [{ format: '.foo &', isArbitraryVariant: false }]
+
+  expect(finalizeSelector(selector, formats, { candidate, context })).toEqual(
+    '.tw-foo .foo\\:tw-text-center'
+  )
+})
+
+it('should not prefix classes from arbitrary variants', () => {
+  let context = { tailwindConfig: { prefix: 'tw-' } }
+  let selector = '.tw-text-center'
+  let candidate = '[.foo_&]:tw-text-center'
+  let formats = [{ format: '.foo &', isArbitraryVariant: true }]
+
+  expect(finalizeSelector(selector, formats, { candidate, context })).toEqual(
+    '.foo .\\[\\.foo_\\&\\]\\:tw-text-center'
+  )
+})
+
+it('Merged selectors with mixed combinators uses the first one', () => {
+  // This isn't explicitly specced behavior but it is how it works today
+
+  let selector = '.text-center'
+  let candidate = 'text-center'
+  let formats = [
+    { format: ':merge(.group):focus > &', isArbitraryVariant: true },
+    { format: ':merge(.group):hover &', isArbitraryVariant: true },
+  ]
+
+  expect(finalizeSelector(selector, formats, { candidate })).toEqual(
+    '.group:hover:focus > .text-center'
+  )
+})
+
 describe('real examples', () => {
   it('example a', () => {
     let selector = '.placeholder-red-500::placeholder'
     let candidate = 'hover:placeholder-red-500'
 
-    let variants = ['&:hover']
+    let formats = [{ format: '&:hover', isArbitraryVariant: false }]
 
-    expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+    expect(finalizeSelector(selector, formats, { candidate })).toEqual(
       '.hover\\:placeholder-red-500:hover::placeholder'
     )
   })
@@ -206,9 +270,12 @@ describe('real examples', () => {
     let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
     let candidate = 'group-hover:hover:space-x-4'
 
-    let variants = ['&:hover', ':merge(.group):hover &']
+    let formats = [
+      { format: '&:hover', isArbitraryVariant: false },
+      { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    ]
 
-    expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+    expect(finalizeSelector(selector, formats, { candidate })).toEqual(
       '.group:hover .group-hover\\:hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])'
     )
   })
@@ -217,9 +284,12 @@ describe('real examples', () => {
     let selector = '.text-center'
     let candidate = 'dark:group-hover:text-center'
 
-    let variants = [':merge(.group):hover &', '.dark &']
+    let formats = [
+      { format: ':merge(.group):hover &', isArbitraryVariant: false },
+      { format: '.dark &', isArbitraryVariant: false },
+    ]
 
-    expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+    expect(finalizeSelector(selector, formats, { candidate })).toEqual(
       '.dark .group:hover .dark\\:group-hover\\:text-center'
     )
   })
@@ -228,9 +298,12 @@ describe('real examples', () => {
     let selector = '.text-center'
     let candidate = 'group-hover:dark:text-center'
 
-    let variants = ['.dark &', ':merge(.group):hover &']
+    let formats = [
+      { format: '.dark &' },
+      { format: ':merge(.group):hover &', isArbitraryVariant: false },
+    ]
 
-    expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+    expect(finalizeSelector(selector, formats, { candidate })).toEqual(
       '.group:hover .dark .group-hover\\:dark\\:text-center'
     )
   })
@@ -240,9 +313,9 @@ describe('real examples', () => {
       let selector = '.text-center'
       let candidate = 'hover:prose-headings:text-center'
 
-      let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover']
+      let formats = [{ format: ':where(&) :is(h1, h2, h3, h4)' }, { format: '&:hover' }]
 
-      expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+      expect(finalizeSelector(selector, formats, { candidate })).toEqual(
         ':where(.hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4):hover'
       )
     })
@@ -251,9 +324,9 @@ describe('real examples', () => {
       let selector = '.text-center'
       let candidate = 'prose-headings:hover:text-center'
 
-      let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)']
+      let formats = [{ format: '&:hover' }, { format: ':where(&) :is(h1, h2, h3, h4)' }]
 
-      expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual(
+      expect(finalizeSelector(selector, formats, { candidate })).toEqual(
         ':where(.prose-headings\\:hover\\:text-center:hover) :is(h1, h2, h3, h4)'
       )
     })
@@ -274,8 +347,7 @@ describe('pseudo elements', () => {
     ${':where(&::before) :is(h1, h2, h3, h4)'}               | ${':where(&) :is(h1, h2, h3, h4)::before'}
     ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'}
   `('should translate "$before" into "$after"', ({ before, after }) => {
-    let result = finalizeSelector(formatVariantSelector('&', before), {
-      selector: '.a',
+    let result = finalizeSelector('.a', [{ format: before, isArbitraryVariant: false }], {
       candidate: 'a',
     })