Repository: francoismassart/eslint-plugin-tailwindcss (js)

Issue:
[BUG] Non-ASCII whitespace should not be treated as class separator
**Describe the bug**
It is a common mistake among Japanese developers to mistakenly insert an Ideographic Space (U+3000) in place of a normal space character (U+0020).
Example:

```jsx
<div className="px-1　py-2">notice that the space between px-1 and py-2 is a U+3000</div>
```

According to the relevant spec, `px-1[U+3000]py-2` is treated as one class name, not two separated by the space. Only ASCII whitespaces are treated as separators. Therefore, `px-1[U+3000]py-2` is not recognized as a Tailwind class by browsers and it does not have any effect. I would like this plugin to catch this kind of mistake.

However, `eslint-plugin-tailwindcss` considers `px-1[U+3000]py-2` as two classes (`px-1` and `py-2`) so it cannot catch this mistake.

**To Reproduce**
Steps to reproduce the behavior:

1. Enable the `no-custom-classname` rule
2. Run the rule against `<div className="px-1　py-2">notice that the space between px-1 and py-2 is a U+3000</div>`
3. Currently you get no error.

**Expected behavior**
The `no-custom-classname` rule should complain that `px-1　py-2` is not a valid Tailwind classname.

**Screenshots**

**Environment (please complete the following information):**
 - OS: macOS
 - Softwares + version used:
     - `tailwind@3.3.2`
     - `eslint-plugin-tailwindcss@3.12.0`

**Additional context**
- `prettier-plugin-tailwindcss` seems to have the same issue.
  - https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/166

**eslint config file or live demo**

Pull request:
fix: do not handle non-ASCII whitespace as separator
# fix: do not handle non-ASCII whitespace as separator

## Description

Fixes #245

This PR improves interpretation of space-separated class names to match the web standards.

Currently any space character `\s` was treated as class separators. However, according to the [relevant spec](https://dom.spec.whatwg.org/#concept-getelementsbyclassname) only [ASCII whitespace](https://infra.spec.whatwg.org/#ascii-whitespace) should be treated as separators.

 See a concrete example and the motivation in the issue.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [x] Added failing tests and then updated code to solve the failure

**Test Configuration**:
* OS + version: macOS
* NPM version: 9.5.1
* Node version: 19.8.1

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] ~I have made corresponding changes to the documentation~
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] ~Any dependent changes have been merged and published in downstream modules~
- [x] I have checked my code and corrected any misspellings


Patch:
diff --git a/lib/rules/no-custom-classname.js b/lib/rules/no-custom-classname.js
index e38cfa4..aceb171 100644
--- a/lib/rules/no-custom-classname.js
+++ b/lib/rules/no-custom-classname.js
@@ -14,6 +14,7 @@ const parserUtil = require('../util/parser');
 const getClassnamesFromCSS = require('../util/cssFiles');
 const createContextFallback = require('tailwindcss/lib/lib/setupContextUtils').createContext;
 const generated = require('../util/generated');
+const escapeRegex = require('../util/regex').escapeRegex;
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -26,7 +27,8 @@ const CUSTOM_CLASSNAME_DETECTED_MSG = `Classname '{{classname}}' is not a Tailwi
 // Group/peer names can be arbitrarily named and are not
 // generated by generateRules. Using a custom regexp to
 // validate these avoids false reports.
-const GROUP_NAME_REGEXP = /^(group|peer)\/[\w\$\#\@\%\^\&\*\_\-]+$/i;
+const getGroupNameRegex = (prefix = '') =>
+  new RegExp(`^${escapeRegex(prefix)}(group|peer)\/[\\w\\$\\#\\@\\%\\^\\&\\*\\_\\-]+$`, 'i');
 
 const contextFallbackCache = new WeakMap();
 
@@ -110,6 +112,7 @@ module.exports = {
     // Init assets before sorting
     const groups = groupUtil.getGroups(defaultGroups, mergedConfig);
     const classnamesFromFiles = getClassnamesFromCSS(cssFiles, cssFilesRefreshRate);
+    const groupNameRegex = getGroupNameRegex(mergedConfig.prefix);
 
     /**
      * Parse the classnames and report found conflicts
@@ -134,7 +137,7 @@ module.exports = {
         if (fromFilesIdx >= 0) {
           return; // Lazier is faster... processing next className!
         }
-        if (GROUP_NAME_REGEXP.test(className)) {
+        if (groupNameRegex.test(className)) {
           return; // Lazier is faster... processing next className!
         }
 
diff --git a/lib/util/ast.js b/lib/util/ast.js
index 21c025e..fea7cb0 100644
--- a/lib/util/ast.js
+++ b/lib/util/ast.js
@@ -217,7 +217,7 @@ function extractClassnamesFromValue(classStr) {
   if (typeof classStr !== 'string') {
     return { classNames: [], whitespaces: [], headSpace: false, tailSpace: false };
   }
-  const separatorRegEx = /(\s+)/;
+  const separatorRegEx = /([\t\n\f\r ]+)/;
   let parts = classStr.split(separatorRegEx);
   if (parts[0] === '') {
     parts.shift();
diff --git a/lib/util/prettier/order.js b/lib/util/prettier/order.js
index c3ec785..a385305 100644
--- a/lib/util/prettier/order.js
+++ b/lib/util/prettier/order.js
@@ -45,7 +45,7 @@ function sortClasses(classStr, { env, ignoreFirst = false, ignoreLast = false })
   }
 
   let result = '';
-  let parts = classStr.split(/(\s+)/);
+  let parts = classStr.split(/([\t\n\f\r ]+)/);
   let classes = parts.filter((_, i) => i % 2 === 0);
   let whitespace = parts.filter((_, i) => i % 2 !== 0);
 
diff --git a/lib/util/regex.js b/lib/util/regex.js
new file mode 100644
index 0000000..823efd8
--- /dev/null
+++ b/lib/util/regex.js
@@ -0,0 +1,13 @@
+/**
+ * Escapes a string to be used in a regular expression
+ * Copied from https://stackoverflow.com/a/3561711.
+ * @param {string} input
+ * @returns {string}
+ */
+function escapeRegex(input) {
+  return input.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
+}
+
+module.exports = {
+  escapeRegex,
+};


Test patch:
diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js
index 286ddf0..894367c 100644
--- a/tests/lib/rules/classnames-order.js
+++ b/tests/lib/rules/classnames-order.js
@@ -278,6 +278,9 @@ ruleTester.run("classnames-order", rule, {
       `,
       options: skipClassAttributeOptions,
     },
+    {
+      code: `<div class="py-1\u3000px-2 block">Do not treat full width space as class separator</div>`,
+    },
   ],
   invalid: [
     {
@@ -913,7 +916,7 @@ ruleTester.run("classnames-order", rule, {
       code: `
       <template>
         <div v-bind="data" :class="[
-          'py-1.5 font-semibold transition', 
+          'py-1.5 font-semibold transition',
           {
             'text-white': variant === 'white',
             'text-blue-500 hover:text-blue-400 border-blue-500': variant === 'primary',
@@ -924,7 +927,7 @@ ruleTester.run("classnames-order", rule, {
       output: `
       <template>
         <div v-bind="data" :class="[
-          'py-1.5 font-semibold transition', 
+          'py-1.5 font-semibold transition',
           {
             'text-white': variant === 'white',
             'border-blue-500 text-blue-500 hover:text-blue-400': variant === 'primary',
diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js
index f20988c..534c0a0 100644
--- a/tests/lib/rules/no-custom-classname.js
+++ b/tests/lib/rules/no-custom-classname.js
@@ -877,7 +877,23 @@ ruleTester.run("no-custom-classname", rule, {
       ],
     },
     { code: `<div className="group/edit">Custom group name</div>` },
+    {
+      code: `<div className="tw-group/edit">Prefix and custom group name</div>`,
+      options: [
+        {
+          config: { prefix: "tw-" },
+        },
+      ],
+    },
     { code: `<div className="group-hover/edit:hidden">Custom group name variant</div>` },
+    {
+      code: `<div className="group-hover/edit:tw-hidden">Prefix and custom group name variant</div>`,
+      options: [
+        {
+          config: { prefix: "tw-" },
+        },
+      ],
+    },
     {
       code: `
       <div class="group/nav123$#@%^&*_-">
@@ -885,6 +901,18 @@ ruleTester.run("no-custom-classname", rule, {
       </div>
       `,
     },
+    {
+      code: `
+      <div class="tw-group/nav123$#@%^&*_-">
+        <div class="group-hover/nav123$#@%^&*_-:tw-h-0">group/nav123$#@%^&*_-</div>
+      </div>
+      `,
+      options: [
+        {
+          config: { prefix: "tw-" },
+        },
+      ],
+    },
     {
       code: `<div className="group-hover/edit:custom-class">Custom group name variant</div>`,
       options: [
@@ -894,7 +922,23 @@ ruleTester.run("no-custom-classname", rule, {
       ],
     },
     { code: `<div className="peer/draft">Custom peer name</div>` },
+    {
+      code: `<div className="tw-peer/draft">Prefix and custom peer name</div>`,
+      options: [
+        {
+          config: { prefix: "tw-" },
+        },
+      ],
+    },
     { code: `<div className="peer-checked/draft:hidden">Custom peer name variant</div>` },
+    {
+      code: `<div className="peer-checked/draft:tw-hidden">Prefix and custom peer name variant</div>`,
+      options: [
+        {
+          config: { prefix: "tw-" },
+        },
+      ],
+    },
     {
       code: `<div className="peer-checked/draft:custom-class">Custom peer name variant</div>`,
       options: [
@@ -1354,5 +1398,13 @@ ruleTester.run("no-custom-classname", rule, {
       ],
       errors: generateErrors("yolo custom"),
     },
+    {
+      code: `<div className="px-1\u3000py-2">Full-width space between classes</div>`,
+      errors: generateErrors("px-1\u3000py-2"),
+    },
+    {
+      code: `<div className="\u3000px-1 py-2\u3000">Full-width space before and after classes</div>`,
+      errors: generateErrors("\u3000px-1 py-2\u3000"),
+    },
   ],
 });