Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Safelisting negative values with a prefix not working
<!-- Please provide all of the information requested below. We're a small team and without all of this information it's not possible for us to help and your bug report will be closed. -->

**What version of Tailwind CSS are you using?**

v3.0.9

**What build tool (or framework if it abstracts the build tool) are you using?**

postcss-cli 9.1.0
tailwindcss-cli

**What version of Node.js are you using?**

v14.16.1

**What browser are you using?**

Chrome

**What operating system are you using?**

macOS

**Reproduction URL**

https://play.tailwindcss.com/lNapHkRIzf?file=config

**Describe your issue**
Safelisting negative values with a prefix isn't working.

Reproduction URL: https://play.tailwindcss.com/lNapHkRIzf?file=config

Config:
```js
{
  prefix: 'tw-',
  safelist: [
  {
    pattern: /-skew-x-2/,
    variants: ['hover','focus','md','sm','md:hover','sm:hover','md:focus','sm:focus'],
  },
  ],
  theme: {
    extend: {
      // ...
    },
  },
  plugins: [],
}
```
Generated safelisted CSS ( no negative value utilities get generated ):
```css
.tw-skew-x-2, .hover\:tw-skew-x-2, .focus\:tw-skew-x-2, .sm\:tw-skew-x-2, .sm\:hover\:tw-skew-x-2, .sm\:focus\:tw-skew-x-2, .md\:tw-skew-x-2, .md\:hover\:tw-skew-x-2, .md\:focus\:tw-skew-x-2 {
  --tw-translate-x: 0;
  --tw-translate-y: 0;
  --tw-rotate: 0;
  --tw-skew-x: 0;
  --tw-skew-y: 0;
  --tw-scale-x: 1;
  --tw-scale-y: 1;
  --tw-transform: translateX(var(--tw-translate-x)) translateY(var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.tw-skew-x-2 {
  --tw-skew-x: 2deg;
  transform: var(--tw-transform);
}
.hover\:tw-skew-x-2:hover {
  --tw-skew-x: 2deg;
  transform: var(--tw-transform);
}
.focus\:tw-skew-x-2:focus {
  --tw-skew-x: 2deg;
  transform: var(--tw-transform);
}
@media (min-width: 640px) {

  .sm\:tw-skew-x-2 {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }

  .sm\:hover\:tw-skew-x-2:hover {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }

  .sm\:focus\:tw-skew-x-2:focus {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }
}
@media (min-width: 768px) {

  .md\:tw-skew-x-2 {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }

  .md\:hover\:tw-skew-x-2:hover {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }

  .md\:focus\:tw-skew-x-2:focus {
    --tw-skew-x: 2deg;
    transform: var(--tw-transform);
  }
}
```


Hints:
+1 experiencing a similar issue on the newest update

```
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/line-clamp": "^0.3.0",
"@tailwindcss/typography": "^0.5.0",
"@types/tailwindcss": "^3.0.1",
```

Patch:
diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js
--- a/src/lib/generateRules.js
+++ b/src/lib/generateRules.js
@@ -63,9 +63,23 @@ function applyPrefix(matches, context) {
     let [meta] = match
     if (meta.options.respectPrefix) {
       let container = postcss.root({ nodes: [match[1].clone()] })
+      let classCandidate = match[1].raws.tailwind.classCandidate
+
       container.walkRules((r) => {
-        r.selector = prefixSelector(context.tailwindConfig.prefix, r.selector)
+        // If this is a negative utility with a dash *before* the prefix we
+        // have to ensure that the generated selector matches the candidate
+
+        // Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1`
+        // The disconnect between candidate <-> class can cause @apply to hard crash.
+        let shouldPrependNegative = classCandidate.startsWith('-')
+
+        r.selector = prefixSelector(
+          context.tailwindConfig.prefix,
+          r.selector,
+          shouldPrependNegative
+        )
       })
+
       match[1] = container.nodes[0]
     }
   }
@@ -371,6 +385,14 @@ function splitWithSeparator(input, separator) {
   return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g'))
 }
 
+function* recordCandidates(matches, classCandidate) {
+  for (const match of matches) {
+    match[1].raws.tailwind = { classCandidate }
+
+    yield match
+  }
+}
+
 function* resolveMatches(candidate, context) {
   let separator = context.tailwindConfig.separator
   let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
@@ -482,7 +504,9 @@ function* resolveMatches(candidate, context) {
       continue
     }
 
-    matches = applyPrefix(matches.flat(), context)
+    matches = matches.flat()
+    matches = Array.from(recordCandidates(matches, classCandidate))
+    matches = applyPrefix(matches, context)
 
     if (important) {
       matches = applyImportant(matches, context)
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -666,17 +666,30 @@ function registerPlugins(plugins, context) {
 
     if (checks.length > 0) {
       let patternMatchingCount = new Map()
+      let prefixLength = context.tailwindConfig.prefix.length
 
       for (let util of classList) {
         let utils = Array.isArray(util)
           ? (() => {
               let [utilName, options] = util
-              let classes = Object.keys(options?.values ?? {}).map((value) =>
-                formatClass(utilName, value)
-              )
+              let values = Object.keys(options?.values ?? {})
+              let classes = values.map((value) => formatClass(utilName, value))
 
               if (options?.supportsNegativeValues) {
+                // This is the normal negated version
+                // e.g. `-inset-1` or `-tw-inset-1`
                 classes = [...classes, ...classes.map((cls) => '-' + cls)]
+
+                // This is the negated version *after* the prefix
+                // e.g. `tw--inset-1`
+                // The prefix is already attached to util name
+                // So we add the negative after the prefix
+                classes = [
+                  ...classes,
+                  ...classes.map(
+                    (cls) => cls.slice(0, prefixLength) + '-' + cls.slice(prefixLength)
+                  ),
+                ]
               }
 
               return classes
diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js
--- a/src/util/prefixSelector.js
+++ b/src/util/prefixSelector.js
@@ -1,12 +1,14 @@
 import parser from 'postcss-selector-parser'
-import { tap } from './tap'
 
-export default function (prefix, selector) {
+export default function (prefix, selector, prependNegative = false) {
   return parser((selectors) => {
     selectors.walkClasses((classSelector) => {
-      tap(classSelector.value, (baseClass) => {
-        classSelector.value = `${prefix}${baseClass}`
-      })
+      let baseClass = classSelector.value
+      let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-')
+
+      classSelector.value = shouldPlaceNegativeBeforePrefix
+        ? `-${prefix}${baseClass.slice(1)}`
+        : `${prefix}${baseClass}`
     })
   }).processSync(selector)
 }


Test patch:
diff --git a/tests/getClassList.test.js b/tests/getClassList.test.js
--- a/tests/getClassList.test.js
+++ b/tests/getClassList.test.js
@@ -5,22 +5,57 @@ it('should generate every possible class, without variants', () => {
   let config = {}
 
   let context = createContext(resolveConfig(config))
-  expect(context.getClassList()).toBeInstanceOf(Array)
+  let classes = context.getClassList()
+  expect(classes).toBeInstanceOf(Array)
 
   // Verify we have a `container` for the 'components' section.
-  expect(context.getClassList()).toContain('container')
+  expect(classes).toContain('container')
 
   // Verify we handle the DEFAULT case correctly
-  expect(context.getClassList()).toContain('border')
+  expect(classes).toContain('border')
 
   // Verify we handle negative values correctly
-  expect(context.getClassList()).toContain('-inset-1/4')
-  expect(context.getClassList()).toContain('-m-0')
-  expect(context.getClassList()).not.toContain('-uppercase')
-  expect(context.getClassList()).not.toContain('-opacity-50')
-  expect(
-    createContext(
-      resolveConfig({ theme: { extend: { margin: { DEFAULT: '5px' } } } })
-    ).getClassList()
-  ).not.toContain('-m-DEFAULT')
+  expect(classes).toContain('-inset-1/4')
+  expect(classes).toContain('-m-0')
+  expect(classes).not.toContain('-uppercase')
+  expect(classes).not.toContain('-opacity-50')
+
+  config = { theme: { extend: { margin: { DEFAULT: '5px' } } } }
+  context = createContext(resolveConfig(config))
+  classes = context.getClassList()
+
+  expect(classes).not.toContain('-m-DEFAULT')
+})
+
+it('should generate every possible class while handling negatives and prefixes', () => {
+  let config = { prefix: 'tw-' }
+  let context = createContext(resolveConfig(config))
+  let classes = context.getClassList()
+  expect(classes).toBeInstanceOf(Array)
+
+  // Verify we have a `container` for the 'components' section.
+  expect(classes).toContain('tw-container')
+
+  // Verify we handle the DEFAULT case correctly
+  expect(classes).toContain('tw-border')
+
+  // Verify we handle negative values correctly
+  expect(classes).toContain('-tw-inset-1/4')
+  expect(classes).toContain('-tw-m-0')
+  expect(classes).not.toContain('-tw-uppercase')
+  expect(classes).not.toContain('-tw-opacity-50')
+
+  // These utilities do work but there's no reason to generate
+  // them alongside the `-{prefix}-{utility}` versions
+  expect(classes).not.toContain('tw--inset-1/4')
+  expect(classes).not.toContain('tw--m-0')
+
+  config = {
+    prefix: 'tw-',
+    theme: { extend: { margin: { DEFAULT: '5px' } } },
+  }
+  context = createContext(resolveConfig(config))
+  classes = context.getClassList()
+
+  expect(classes).not.toContain('-tw-m-DEFAULT')
 })
diff --git a/tests/prefix.test.js b/tests/prefix.test.js
--- a/tests/prefix.test.js
+++ b/tests/prefix.test.js
@@ -1,7 +1,7 @@
 import fs from 'fs'
 import path from 'path'
 
-import { run, css } from './util/run'
+import { run, html, css } from './util/run'
 
 test('prefix', () => {
   let config = {
@@ -73,3 +73,288 @@ test('prefix', () => {
     expect(result.css).toMatchFormattedCss(expected)
   })
 })
+
+it('negative values: marker before prefix', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="-tw-top-1"></div>` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .-tw-top-1 {
+      top: -0.25rem;
+    }
+  `)
+})
+
+it('negative values: marker after prefix', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="tw--top-1"></div>` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .tw--top-1 {
+      top: -0.25rem;
+    }
+  `)
+})
+
+it('negative values: marker before prefix and arbitrary value', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="-tw-top-[1px]"></div>` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .-tw-top-\[1px\] {
+      top: -1px;
+    }
+  `)
+})
+
+it('negative values: marker after prefix and arbitrary value', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="tw--top-[1px]"></div>` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .tw--top-\[1px\] {
+      top: -1px;
+    }
+  `)
+})
+
+it('negative values: no marker and arbitrary value', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="tw-top-[-1px]"></div>` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .tw-top-\[-1px\] {
+      top: -1px;
+    }
+  `)
+})
+
+it('negative values: variant versions', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [
+      {
+        raw: html`
+          <div class="hover:-tw-top-1 hover:tw--top-1"></div>
+          <div class="hover:-tw-top-[1px] hover:tw--top-[1px]"></div>
+          <div class="hover:tw-top-[-1px]"></div>
+
+          <!-- this one should not generate anything -->
+          <div class="-hover:tw-top-1"></div>
+        `,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .hover\:-tw-top-1:hover {
+      top: -0.25rem;
+    }
+    .hover\:tw--top-1:hover {
+      top: -0.25rem;
+    }
+    .hover\:-tw-top-\[1px\]:hover {
+      top: -1px;
+    }
+    .hover\:tw--top-\[1px\]:hover {
+      top: -1px;
+    }
+    .hover\:tw-top-\[-1px\]:hover {
+      top: -1px;
+    }
+  `)
+})
+
+it('negative values: prefix and apply', async () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+
+    .a {
+      @apply hover:tw--top-1;
+    }
+    .b {
+      @apply hover:-tw-top-1;
+    }
+    .c {
+      @apply hover:-tw-top-[1px];
+    }
+    .d {
+      @apply hover:tw--top-[1px];
+    }
+    .e {
+      @apply hover:tw-top-[-1px];
+    }
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .a:hover {
+      top: -0.25rem;
+    }
+    .b:hover {
+      top: -0.25rem;
+    }
+    .c:hover {
+      top: -1px;
+    }
+    .d:hover {
+      top: -1px;
+    }
+    .e:hover {
+      top: -1px;
+    }
+  `)
+})
+
+it('negative values: prefix in the safelist', async () => {
+  let config = {
+    prefix: 'tw-',
+    safelist: [{ pattern: /-tw-top-1/g }, { pattern: /tw--top-1/g }],
+    theme: {
+      inset: {
+        1: '0.25rem',
+      },
+    },
+    content: [{ raw: html`` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .-tw-top-1 {
+      top: -0.25rem;
+    }
+    .tw--top-1 {
+      top: -0.25rem;
+    }
+  `)
+})
+
+it('prefix with negative values and variants in the safelist', async () => {
+  let config = {
+    prefix: 'tw-',
+    safelist: [
+      { pattern: /-tw-top-1/, variants: ['hover', 'sm:hover'] },
+      { pattern: /tw--top-1/, variants: ['hover', 'sm:hover'] },
+    ],
+    theme: {
+      inset: {
+        1: '0.25rem',
+      },
+    },
+    content: [{ raw: html`` }],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  await run(input, config)
+
+  const result = await run(input, config)
+
+  expect(result.css).toMatchFormattedCss(css`
+    .-tw-top-1 {
+      top: -0.25rem;
+    }
+    .tw--top-1 {
+      top: -0.25rem;
+    }
+    .hover\:-tw-top-1:hover {
+      top: -0.25rem;
+    }
+
+    .hover\:tw--top-1:hover {
+      top: -0.25rem;
+    }
+    @media (min-width: 640px) {
+      .sm\:hover\:-tw-top-1:hover {
+        top: -0.25rem;
+      }
+      .sm\:hover\:tw--top-1:hover {
+        top: -0.25rem;
+      }
+    }
+  `)
+})