Repository: francoismassart/eslint-plugin-tailwindcss
Pull request #167 (2022-09-15 07:28:38)

Arbitrary properties & attribute variants
# Arbitrary properties & attribute variants

## Description

Released in beta for testing:

`npm i eslint-plugin-tailwindcss@3.6.2-beta.0`

- FIX: [prefix parsing when using attribute variants](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/164)
- FIX: [add support for contradicting arbitrary properties](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/163)
- FIX: `npm audit fix`

## Type of change

- [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

## 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
- [x] 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/README.md b/README.md
index c4b614f..fc6eb5b 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,8 @@ If you enjoy my work you can:
 
 ## Latest changelog
 
+- FIX: [prefix parsing when using attribute variants](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/164)
+- FIX: [add support for contradicting arbitrary properties](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/163)
 - FIX: [conflicting rules with ambiguous arbitrary values](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/152)(by [acewf](https://github.com/acewf) 🙏)
 - FIX: [`parseNodeRecursive`: Correctly recurse into TemplateLiteral expressions](https://github.com/francoismassart/eslint-plugin-tailwindcss/pull/138) (by [mpsijm](https://github.com/mpsijm) 🙏)
 - FIX: [Prevent rule crash on class/className attributes with no value](https://github.com/francoismassart/eslint-plugin-tailwindcss/issues/157) (by [threehams](https://github.com/threehams) 🙏)
diff --git a/lib/rules/no-contradicting-classname.js b/lib/rules/no-contradicting-classname.js
index 8810f89..6763a46 100644
--- a/lib/rules/no-contradicting-classname.js
+++ b/lib/rules/no-contradicting-classname.js
@@ -86,36 +86,55 @@ module.exports = {
         }
       });
 
-      classNames = sorted.filter((slot) => slot.length > 1);
+      // Only multiple classNames
+      const sortedGroups = sorted.filter((slot) => slot.length > 1);
+      const arbitraryPropsGroupIndex = sortedGroups.findIndex((slot) => {
+        const suffix = groupUtil.getSuffix(slot[0], mergedConfig.separator);
+        return groupUtil.getArbitraryProperty(suffix, mergedConfig.separator) !== '';
+      });
 
       // Sorts each groups' classnames
-      const ambiguousArbitraryValues = String.raw`(\[(.*${mergedConfig.separator}))`
-      const ambiguousArbitraryValuesOrClasses = String.raw`(\[(.*${mergedConfig.separator}))|(^((?!:).)*$)`
-      const arbitraryRegex = new RegExp(ambiguousArbitraryValues);
+      const ambiguousArbitraryValuesOrClasses = String.raw`(\[(.*${mergedConfig.separator}))|(^((?!:).)*$)`;
 
-      classNames.forEach((slot) => {
+      sortedGroups.forEach((group, groupIndex) => {
         const variants = [];
-        slot.forEach((cls) => {
-          const arbiratySeparator = arbitraryRegex.test(cls);
-          const start = arbiratySeparator? 0 : cls.lastIndexOf(mergedConfig.separator) + 1;
-          const prefix = cls.substr(0, start);
-          const name = cls.substr(start);
-          const rePrefix = prefix === '' ? ambiguousArbitraryValuesOrClasses : '^' + prefix;
-          const idx = variants.findIndex((v) => v.prefix === rePrefix);
-          if (idx === -1) {
-            variants.push({
-              prefix: rePrefix,
-              name: [name],
-            });
+        group.forEach((cls) => {
+          const prefix = groupUtil.getPrefix(cls, mergedConfig.separator);
+          const name = cls.substr(prefix.length);
+          if (groupIndex === arbitraryPropsGroupIndex) {
+            // Arbitrary Props
+            const arbitraryProp = groupUtil.getArbitraryProperty(name, mergedConfig.separator);
+            const identifier = prefix + arbitraryProp;
+            const idx = variants.findIndex((v) => identifier === v.prefix);
+            if (idx === -1) {
+              variants.push({
+                prefix: identifier,
+                name: [name],
+              });
+            } else {
+              variants[idx].name.push(name);
+            }
           } else {
-            variants[idx].name.push(name);
+            // "Regular classNames"
+            const rePrefix = prefix === '' ? ambiguousArbitraryValuesOrClasses : '^' + prefix;
+            const idx = variants.findIndex((v) => v.prefix === rePrefix);
+            if (idx === -1) {
+              variants.push({
+                prefix: rePrefix,
+                name: [name],
+              });
+            } else {
+              variants[idx].name.push(name);
+            }
           }
         });
-        const troubles = variants.filter((v) => v.name.length > 1);
-        if (troubles.length) {
-          troubles.forEach((issue) => {
-            const re = new RegExp(issue.prefix);
-            const conflicting = slot.filter((c) => re.test(c));
+
+        // Several classNames with the same prefix
+        const potentialTroubles = variants.filter((v) => v.name.length > 1);
+        if (potentialTroubles.length) {
+          potentialTroubles.forEach((variantGroup) => {
+            const re = new RegExp(variantGroup.prefix);
+            const conflicting = group.filter((c) => re.test(c));
             context.report({
               node: node,
               messageId: 'conflictingClassnames',
diff --git a/lib/util/groupMethods.js b/lib/util/groupMethods.js
index 00babfb..7627a53 100644
--- a/lib/util/groupMethods.js
+++ b/lib/util/groupMethods.js
@@ -424,38 +424,54 @@ function getGroupIndex(name, arr, separator = ':') {
 }
 
 /**
- * Get the prefixes of the full classname
+ * Returns the prefix (variants) of a className including the separator or an empty string if none
  *
- * @param {String} className The target classname
- * @param {String} separator The delimiter to be used between variants
- * @returns {String} The variants
+ * @param {String} name Classname to be parsed
+ * @param {String} separator The separator character as in config
+ * @returns {String} The prefix
+ */
+function getPrefix(name, separator) {
+  const rootSeparator = String.raw`(?<!\[[a-z0-9\-]*)(${separator})(?![a-z0-9\-]*\])`;
+  const rootSeparatorRegex = new RegExp(rootSeparator);
+  let classname = name;
+  let index = 0;
+  let results;
+  while ((results = rootSeparatorRegex.exec(classname)) !== null) {
+    const newIndex = results.index + separator.length;
+    index += newIndex;
+    classname = classname.substring(newIndex);
+  }
+
+  return index ? name.substring(0, index) : '';
+}
+
+/**
+ * Returns the arbitrary property of className without the separator or an empty string if none
+ * e.g. "[mask-type:luminance]" => "mask-type"
+ *
+ * @see https://tailwindcss.com/docs/adding-custom-styles#arbitrary-properties
+ * @param {String} name Classname suffix (without it variants) to be parsed
+ * @param {String} separator The separator character as in config
+ * @returns {String} The arbitrary property
  */
-function getPrefixes(className, separator = ':') {
-  return className.split(separator).slice(0, -1).join(separator);
+function getArbitraryProperty(name, separator) {
+  const arbitraryPropPattern = String.raw`^\[([a-z\-]*)${separator}\.*`;
+  const arbitraryPropRegExp = new RegExp(arbitraryPropPattern);
+  const results = arbitraryPropRegExp.exec(name);
+  return results === null ? '' : results[1];
 }
 
 /**
  * Get the last part of the full classname
+ * e.g. "lg:w-[100px]" => "w-[100px]"
  *
  * @param {String} className The target classname
  * @param {String} separator The delimiter to be used between variants
  * @returns {String} The classname without its variants
  */
 function getSuffix(className, separator = ':') {
-  const arbitraryPropertiesRe = new RegExp(
-    `^\\!?([a-z]*${escapeSpecialChars(separator)})*(?<value>\\[(.{1,})\\])$`,
-    'i'
-  );
-  let res = arbitraryPropertiesRe.exec(className);
-  if (res && res.groups && res.groups.value) {
-    // arbitraryProperties detected !
-    return res.groups.value;
-  }
-  const arbitraryArr = className.split('-[');
-  if (arbitraryArr.length === 1) {
-    return className.split(separator).pop();
-  }
-  return arbitraryArr[0].split(separator).pop() + '-[' + arbitraryArr[1];
+  const prefix = getPrefix(className, separator);
+  return className.substring(prefix.length);
 }
 
 /**
@@ -524,7 +540,7 @@ function parseClassname(name, arr, config, index = null) {
     trailing = trailingRes.groups.trailing || '';
   }
   core = name.substring(leading.length, name.length - trailing.length);
-  const classPrefixes = getPrefixes(core, config.separator);
+  const variants = getPrefix(core, config.separator);
   const classSuffix = getSuffix(core, config.separator);
   let slot = null;
   arr.forEach((group) => {
@@ -535,7 +551,6 @@ function parseClassname(name, arr, config, index = null) {
       }
     }
   });
-  const variants = classPrefixes ? classPrefixes + config.separator : '';
   const value = slot ? slot.value : '';
   const isNegative = value[0] === '-';
   const off = isNegative ? 1 : 0;
@@ -547,7 +562,7 @@ function parseClassname(name, arr, config, index = null) {
     parentType: slot ? slot.group : '',
     body: body,
     value: value,
-    shorthand: slot ? slot.shorthand : '',
+    shorthand: slot && slot.shorthand ? slot.shorthand : '',
     leading: leading,
     trailing: trailing,
     important: body.substr(0, 1) === '!',
@@ -556,9 +571,10 @@ function parseClassname(name, arr, config, index = null) {
 
 module.exports = {
   initGroupSlots,
+  getArbitraryProperty,
   getGroups,
   getGroupIndex,
-  getPrefixes,
+  getPrefix,
   getSuffix,
   parseClassname,
 };
diff --git a/package-lock.json b/package-lock.json
index 51ff291..dd44ec4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -367,9 +367,9 @@
       }
     },
     "node_modules/ansi-regex": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
-      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
       "dev": true,
       "engines": {
         "node": ">=8"
@@ -674,9 +674,9 @@
       }
     },
     "node_modules/cliui/node_modules/ansi-regex": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+      "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
       "dev": true,
       "engines": {
         "node": ">=6"
@@ -3297,9 +3297,9 @@
       }
     },
     "node_modules/wide-align/node_modules/ansi-regex": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-      "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
+      "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
       "dev": true,
       "engines": {
         "node": ">=4"
@@ -3363,9 +3363,9 @@
       }
     },
     "node_modules/wrap-ansi/node_modules/ansi-regex": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+      "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
       "dev": true,
       "engines": {
         "node": ">=6"
@@ -3489,9 +3489,9 @@
       }
     },
     "node_modules/yargs/node_modules/ansi-regex": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-      "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+      "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
       "dev": true,
       "engines": {
         "node": ">=6"
@@ -3779,9 +3779,9 @@
       "dev": true
     },
     "ansi-regex": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
-      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
       "dev": true
     },
     "ansi-styles": {
@@ -4002,9 +4002,9 @@
       },
       "dependencies": {
         "ansi-regex": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+          "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
           "dev": true
         },
         "emoji-regex": {
@@ -5898,9 +5898,9 @@
       },
       "dependencies": {
         "ansi-regex": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
-          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
+          "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
           "dev": true
         },
         "is-fullwidth-code-point": {
@@ -5948,9 +5948,9 @@
       },
       "dependencies": {
         "ansi-regex": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+          "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
           "dev": true
         },
         "emoji-regex": {
@@ -6034,9 +6034,9 @@
       },
       "dependencies": {
         "ansi-regex": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
-          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz",
+          "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==",
           "dev": true
         },
         "emoji-regex": {


Test patch:
diff --git a/tests/lib/rules/no-contradicting-classname.js b/tests/lib/rules/no-contradicting-classname.js
index 60ab11a..d9a0e8f 100644
--- a/tests/lib/rules/no-contradicting-classname.js
+++ b/tests/lib/rules/no-contradicting-classname.js
@@ -244,6 +244,37 @@ ruleTester.run("no-contradicting-classname", rule, {
     {
       code: "<div class>No errors while typing</div>",
     },
+    {
+      code: '<div class="opacity-100 [&[aria-hidden=true]]:opacity-0">No errors while typing</div>',
+    },
+    {
+      code: `
+      <div
+        className={clsx(
+          "[clip-path:circle(100%_at_center)]",
+          "[mask-image:radial-gradient(circle_at_center,transparent_51%,black_54.8%)]",
+          "bg-[conic-gradient(transparent,#9e9ab1)]",
+          "pointer-events-none h-6 w-6 animate-spin rounded-full"
+        )}
+      />`,
+    },
+    {
+      code: `
+      <div
+        className={ctl(\`
+          [clip-path:circle(100%_at_center)]
+          [mask-image:radial-gradient(circle_at_center,transparent_51%,black_54.8%)]
+          lg:[mask-image:radial-gradient(circle_at_center,transparent_10%,black_10%)]
+          bg-[conic-gradient(transparent,#ff00ff)]
+          lg:bg-[conic-gradient(transparent,#9e9ab1)]
+          pointer-events-none
+          h-6
+          w-6
+          animate-spin
+          rounded-full
+        \`)}
+      />`,
+    },
   ],
 
   invalid: [
@@ -543,6 +574,19 @@ ruleTester.run("no-contradicting-classname", rule, {
       </div>`,
       errors: generateErrors("border-spacing-y-px border-spacing-y-0"),
     },
+    {
+      code: `
+      <div
+        className={ctl(\`
+          [clip-path:circle(90%_at_center)]
+          [clip-path:circle(100%_at_center)]
+          [mask-image:radial-gradient(circle_at_center,transparent_10%,black_10%)]
+          bg-[conic-gradient(transparent,#ff00ff)]
+          lg:bg-[conic-gradient(transparent,#9e9ab1)]
+        \`)}
+      />`,
+      errors: generateErrors("[clip-path:circle(90%_at_center)] [clip-path:circle(100%_at_center)]"),
+    },
     // {
     //   code: `
     //   <div class="scale-75 transform-none">
diff --git a/tests/lib/util/groupMethods.js b/tests/lib/util/groupMethods.js
index 51bca22..0f37f6a 100644
--- a/tests/lib/util/groupMethods.js
+++ b/tests/lib/util/groupMethods.js
@@ -10,6 +10,26 @@ var customConfig = require("../../../lib/util/customConfig");
 var groupUtil = require("../../../lib/util/groupMethods");
 var mergedConfig = customConfig.resolve({});
 
+describe("getPrefix", function () {
+  it("should retrieve the correct prefix", function () {
+    let prefix = "";
+    prefix = groupUtil.getPrefix("dark:[hidden]:lg:text-[color:var(--my-var,#ccc)]", ":");
+    assert.equal(prefix, "dark:[hidden]:lg:");
+    prefix = groupUtil.getPrefix("text-[color:var(--my-var,#ccc)]", ":");
+    assert.equal(prefix, "");
+  });
+});
+
+describe("getSuffix", function () {
+  it("should retrieve the correct suffix", function () {
+    let prefix = "";
+    prefix = groupUtil.getSuffix("dark:[hidden]:lg:text-[color:var(--my-var,#ccc)]", ":");
+    assert.equal(prefix, "text-[color:var(--my-var,#ccc)]");
+    prefix = groupUtil.getSuffix("text-[color:var(--my-var,#ccc)]", ":");
+    assert.equal(prefix, "text-[color:var(--my-var,#ccc)]");
+  });
+});
+
 describe("parseClassname", function () {
   const targetProperties = {
     Layout: ["Overflow", "Overscroll Behavior", "Top / Right / Bottom / Left"],