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

Issue:
[BUG] `no-custom-classname` false negative on arrays/objects passed to callee
**Describe the bug**
`classnames` and similar allow:
- objects where the keys are classes and values are boolean representing whether the class is enabled
- arbitrarily nested arrays to group classes organizationally or with conditionals

For example:
```js
classnames(
  ["asdf foo"],
  myFlag && [
    "bar",
    someBoolean ? ["baz"] : { "qwerty": someOtherFlag },
  ]
);
```

`no-custom-classname` does not identify invalid classes within arrays and objects passed to a callee.

**To Reproduce**
Steps to reproduce the behavior:
1. Ensure `classnames` is configured as a callee
2. Paste the above code block

**Expected behavior**
All five classes (`asdf`, `foo`, `bar`, `baz`, `qwerty`) are identified as invalid

**Environment (please complete the following information):**
 - OS: macOS
 - Softwares + version used:
     - node 16.3.0
     - yarn 1.22.10
     - eslint 7.32.0
     - eslint-plugin-tailwindcss 3.2.0
     - tailwindcss v3.0.15

**Additional context**
I'd be happy to make a PR if this change makes sense.

Pull request:
fix: crawl arrays and objects passed to callees
# fix: crawl arrays and objects passed to callees

## Description
Added cases for crawling `ArrayExpression` elements and `ObjectExpression` keys to each recursive parse function.

Fixes #99

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Added unit tests for each rule covering class names in arrays and objects.

**Test Configuration**:
* OS + version: macOS Catalina
* NPM version: 8.1.0
* Node version: 16.13.0

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] ~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/classnames-order.js b/lib/rules/classnames-order.js
index 57a072b..7ae48b3 100644
--- a/lib/rules/classnames-order.js
+++ b/lib/rules/classnames-order.js
@@ -245,6 +245,16 @@ module.exports = {
           case 'LogicalExpression':
             sortNodeArgumentValue(node, arg.right);
             return;
+          case 'ArrayExpression':
+            arg.elements.forEach((el) => {
+              sortNodeArgumentValue(node, el);
+            });
+            return;
+          case 'ObjectExpression':
+            arg.properties.forEach((prop) => {
+              sortNodeArgumentValue(node, prop.key);
+            });
+            return;
           case 'Literal':
             trim = true;
             originalClassNamesValue = arg.value;
diff --git a/lib/rules/enforces-shorthand.js b/lib/rules/enforces-shorthand.js
index 404d34b..72168f9 100644
--- a/lib/rules/enforces-shorthand.js
+++ b/lib/rules/enforces-shorthand.js
@@ -141,6 +141,16 @@ module.exports = {
           case 'LogicalExpression':
             parseForShorthandCandidates(node, arg.right);
             return;
+          case 'ArrayExpression':
+            arg.elements.forEach((el) => {
+              parseForShorthandCandidates(node, el);
+            });
+            return;
+          case 'ObjectExpression':
+            arg.properties.forEach((prop) => {
+              parseForShorthandCandidates(node, prop.key);
+            });
+            return;
           case 'Literal':
             trim = true;
             originalClassNamesValue = arg.value;
diff --git a/lib/rules/migration-from-tailwind-2.js b/lib/rules/migration-from-tailwind-2.js
index 40b47d5..d0d5ff9 100644
--- a/lib/rules/migration-from-tailwind-2.js
+++ b/lib/rules/migration-from-tailwind-2.js
@@ -107,6 +107,16 @@ module.exports = {
           case 'LogicalExpression':
             parseForObsoleteClassNames(node, arg.right);
             return;
+          case 'ArrayExpression':
+            arg.elements.forEach((el) => {
+              parseForObsoleteClassNames(node, el);
+            });
+            return;
+          case 'ObjectExpression':
+            arg.properties.forEach((prop) => {
+              parseForObsoleteClassNames(node, prop.key);
+            });
+            return;
           case 'Literal':
             trim = true;
             originalClassNamesValue = arg.value;
diff --git a/lib/rules/no-arbitrary-value.js b/lib/rules/no-arbitrary-value.js
index a53d200..5721431 100644
--- a/lib/rules/no-arbitrary-value.js
+++ b/lib/rules/no-arbitrary-value.js
@@ -94,6 +94,16 @@ module.exports = {
           case 'LogicalExpression':
             parseForArbitraryValues(node, arg.right);
             return;
+          case 'ArrayExpression':
+            arg.elements.forEach((el) => {
+              parseForArbitraryValues(node, el);
+            });
+            return;
+          case 'ObjectExpression':
+            arg.properties.forEach((prop) => {
+              parseForArbitraryValues(node, prop.key);
+            });
+            return;
           case 'Literal':
             trim = true;
             originalClassNamesValue = arg.value;
diff --git a/lib/util/ast.js b/lib/util/ast.js
index b692e08..21f5595 100644
--- a/lib/util/ast.js
+++ b/lib/util/ast.js
@@ -158,6 +158,16 @@ function parseNodeRecursive(node, arg, cb, skipConditional = false, isolate = fa
       case 'LogicalExpression':
         parseNodeRecursive(node, arg.right, cb, skipConditional, forceIsolation);
         return;
+      case 'ArrayExpression':
+        arg.elements.forEach((el) => {
+          parseNodeRecursive(node, el, cb, skipConditional, forceIsolation);
+        });
+        return;
+      case 'ObjectExpression':
+        arg.properties.forEach((prop) => {
+          parseNodeRecursive(node, prop.key, cb, skipConditional, forceIsolation);
+        });
+        return;
       case 'Literal':
         trim = true;
         originalClassNamesValue = arg.value;


Test patch:
diff --git a/tests/lib/rules/classnames-order.js b/tests/lib/rules/classnames-order.js
index 46f105e..8a73b5a 100644
--- a/tests/lib/rules/classnames-order.js
+++ b/tests/lib/rules/classnames-order.js
@@ -25,11 +25,19 @@ var parserOptions = {
 
 var ruleTester = new RuleTester({ parserOptions });
 
-var errors = [
-  {
-    messageId: "invalidOrder",
-  },
-];
+const generateErrors = (count) => {
+  const errors = [];
+
+  for (let i = 0; i < count; i++) {
+    errors.push({
+      messageId: "invalidOrder",
+    });
+  }
+
+  return errors;
+};
+
+const errors = generateErrors(1);
 
 ruleTester.run("classnames-order", rule, {
   valid: [
@@ -372,14 +380,7 @@ ruleTester.run("classnames-order", rule, {
         lg:py-4
         \${hasError && "bg-red"}
       \`);`,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: generateErrors(2),
     },
     {
       code: `<div class="sm:w-12 w-[320px]">Allowed arbitrary value but incorrect order</div>`,
@@ -464,44 +465,22 @@ ruleTester.run("classnames-order", rule, {
         }
       \`)
       `,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-        {
-          messageId: "invalidOrder",
-        },
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: generateErrors(3),
     },
     {
       code: `<div className="px-2 flex">...</div>`,
       output: `<div className="flex px-2">...</div>`,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `ctl(\`\${enabled && "px-2 flex"}\`)`,
       output: `ctl(\`\${enabled && "flex px-2"}\`)`,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `ctl(\`px-2 flex\`)`,
       output: `ctl(\`flex px-2\`)`,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `
@@ -516,11 +495,7 @@ ruleTester.run("classnames-order", rule, {
         px-2
       \`)
       `,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `
@@ -553,11 +528,7 @@ ruleTester.run("classnames-order", rule, {
         #19
       </div>
       `,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `
@@ -580,11 +551,7 @@ ruleTester.run("classnames-order", rule, {
         )}
       />
       `,
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: errors,
     },
     {
       code: `
@@ -664,14 +631,35 @@ ruleTester.run("classnames-order", rule, {
           tags: ["myTag"],
         },
       ],
-      errors: [
-        {
-          messageId: "invalidOrder",
-        },
-        {
-          messageId: "invalidOrder",
-        },
-      ],
+      errors: generateErrors(2),
+    },
+    {
+      code: `
+      classnames([
+        'invalid lg:w-4 sm:w-6',
+        ['w-12 flex'],
+      ])`,
+      output: `
+      classnames([
+        'sm:w-6 lg:w-4 invalid',
+        ['flex w-12'],
+      ])`,
+      errors: generateErrors(2),
+    },
+    {
+      code: `
+      classnames({
+        invalid,
+        flex: myFlag,
+        'lg:w-4 sm:w-6': resize
+      })`,
+      output: `
+      classnames({
+        invalid,
+        flex: myFlag,
+        'sm:w-6 lg:w-4': resize
+      })`,
+      errors: errors,
     },
   ],
 });
diff --git a/tests/lib/rules/enforces-shorthand.js b/tests/lib/rules/enforces-shorthand.js
index e08bd23..3f4d963 100644
--- a/tests/lib/rules/enforces-shorthand.js
+++ b/tests/lib/rules/enforces-shorthand.js
@@ -363,5 +363,15 @@ ruleTester.run("shorthands", rule, {
       </div>`,
       errors: [generateError(["py-8", "px-8"], "p-8")],
     },
+    {
+      code: `classnames(['py-8 px-8 w-48 h-48 text-white'])`,
+      output: `classnames(['p-8 w-48 h-48 text-white'])`,
+      errors: [generateError(["py-8", "px-8"], "p-8")],
+    },
+    {
+      code: `classnames({'py-8 px-8 w-48 h-48 text-white': true})`,
+      output: `classnames({'p-8 w-48 h-48 text-white': true})`,
+      errors: [generateError(["py-8", "px-8"], "p-8")],
+    },
   ],
 });
diff --git a/tests/lib/rules/migration-from-tailwind-2.js b/tests/lib/rules/migration-from-tailwind-2.js
index 809ab5d..cb61c75 100644
--- a/tests/lib/rules/migration-from-tailwind-2.js
+++ b/tests/lib/rules/migration-from-tailwind-2.js
@@ -177,5 +177,31 @@ ruleTester.run("migration-from-tailwind-2", rule, {
         },
       ],
     },
+    {
+      code: `classnames(["placeholder-red-900"])`,
+      output: `classnames(["placeholder:text-red-900"])`,
+      errors: [
+        {
+          messageId: "classnameChanged",
+          data: {
+            deprecated: "placeholder-red-900",
+            updated: "placeholder:text-red-900",
+          },
+        },
+      ],
+    },
+    {
+      code: `classnames({"placeholder-red-900": true})`,
+      output: `classnames({"placeholder:text-red-900": true})`,
+      errors: [
+        {
+          messageId: "classnameChanged",
+          data: {
+            deprecated: "placeholder-red-900",
+            updated: "placeholder:text-red-900",
+          },
+        },
+      ],
+    },
   ],
 });
diff --git a/tests/lib/rules/no-arbitrary-value.js b/tests/lib/rules/no-arbitrary-value.js
index e809efd..fd519d4 100644
--- a/tests/lib/rules/no-arbitrary-value.js
+++ b/tests/lib/rules/no-arbitrary-value.js
@@ -69,5 +69,31 @@ ruleTester.run("no-arbitrary-value", rule, {
       \`)`,
       errors: generateErrors("[mask-type:luminance] bg-[rgba(10,20,30,0.5)]"),
     },
+    {
+      code: `
+      <nav
+        className={cns("flex relative flex-row rounded-lg select-none", {
+          "bg-gray-200 p-1": !size,
+          "bg-gray-100 p-[3px]": size === "sm",
+        })}
+      />`,
+      options: [
+        {
+          callees: ["cns"],
+        },
+      ],
+      errors: generateErrors("p-[3px]"),
+    },
+    {
+      code: `
+      classnames(
+        ["flex text-[length:var(--font-size)]"],
+        myFlag && [
+          "rounded-[2em]",
+          someBoolean ? ["p-[4vw]"] : { "leading-[1]": someOtherFlag },
+        ]
+      );`,
+      errors: generateErrors("text-[length:var(--font-size)] rounded-[2em] p-[4vw] leading-[1]"),
+    },
   ],
 });
diff --git a/tests/lib/rules/no-contradicting-classname.js b/tests/lib/rules/no-contradicting-classname.js
index 25d79e3..920255d 100644
--- a/tests/lib/rules/no-contradicting-classname.js
+++ b/tests/lib/rules/no-contradicting-classname.js
@@ -298,7 +298,17 @@ ruleTester.run("no-contradicting-classname", rule, {
       code: `ctl(\`\${enabled && "px-2 px-0"}\`)`,
       errors: generateErrors("px-2 px-0"),
     },
-
+    {
+      code: `
+      classnames(
+        ["p-2 p-4"],
+        myFlag && [
+          "w-1 w-2",
+          someBoolean ? ["py-1 py-2"] : { "px-2 px-4": someOtherFlag },
+        ]
+      );`,
+      errors: generateErrors(["p-2 p-4", "w-1 w-2", "py-1 py-2", "px-2 px-4"]),
+    },
     {
       code: `
       myTag\`
diff --git a/tests/lib/rules/no-custom-classname.js b/tests/lib/rules/no-custom-classname.js
index c2e38a0..86c0fcb 100644
--- a/tests/lib/rules/no-custom-classname.js
+++ b/tests/lib/rules/no-custom-classname.js
@@ -506,6 +506,16 @@ ruleTester.run("no-custom-classname", rule, {
       )}
       />`,
     },
+    {
+      code: `
+      <button
+      type="button"
+      className={classnames(
+        ["p-2 font-medium"],
+        [{"text-black": boolVal}]
+      )}
+      />`,
+    },
     {
       code: `
       <div className="-mt-4">Negative value with custom config</div>`,
@@ -671,6 +681,28 @@ ruleTester.run("no-custom-classname", rule, {
       `,
       errors: generateErrors("custom-2 custom-3 custom-1"),
     },
+    {
+      code: `
+      <button
+      type="button"
+      className={classnames(
+        ["asdf"],
+        [{"qwerty": boolVal}]
+      )}
+      />`,
+      errors: generateErrors("asdf qwerty"),
+    },
+    {
+      code: `
+      classnames(
+        ["asdf foo"],
+        myFlag && [
+          "bar",
+          someBoolean ? ["baz"] : { "qwerty": someOtherFlag },
+        ]
+      );`,
+      errors: generateErrors("asdf foo bar baz qwerty"),
+    },
     {
       code: `<div className="flex skin-summer custom-2 custom-not-whitelisted">incomplete whitelist</div>`,
       options: [