diff --git a/README.md b/README.md
index 58efd46f..a9a1b37b 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ While you can use the official plugin [`prettier-plugin-tailwindcss`](https://ww
Learn more about each supported rules by reading their documentation:
- [`classnames-order`](docs/rules/classnames-order.md): order classnames for consistency and it makes merge conflict a bit easier to resolve
+- [`enforces-arbitrary-value-syntax`](docs/rules/enforces-arbitrary-value-syntax.md): enforce correct arbitrary value syntax using square brackets (e.g. `p-10px` should be `p-[10px]`)
- [`enforces-negative-arbitrary-values`](docs/rules/enforces-negative-arbitrary-values.md): make sure to use negative arbitrary values classname without the negative classname e.g. `-top-[5px]` should become `top-[-5px]`
- [`enforces-shorthand`](docs/rules/enforces-shorthand.md): merge multiple classnames into shorthand if possible e.g. `mx-5 my-5` should become `m-5`
- [`migration-from-tailwind-2`](docs/rules/migration-from-tailwind-2.md) for easy upgrade from Tailwind CSS `v2` to `v3`.
diff --git a/docs/rules/enforces-arbitrary-value-syntax.md b/docs/rules/enforces-arbitrary-value-syntax.md
new file mode 100644
index 00000000..647fcd8f
--- /dev/null
+++ b/docs/rules/enforces-arbitrary-value-syntax.md
@@ -0,0 +1,84 @@
+# Enforces correct Tailwind CSS arbitrary value syntax (enforces-arbitrary-value-syntax)
+
+This rule enforces the correct Tailwind CSS arbitrary value syntax, which is using square brackets `[]` to wrap arbitrary values.
+
+## Rule Details
+
+In Tailwind CSS, when you need to use a custom value that is not in the preset, you should use the arbitrary value syntax `prefix-[value]` instead of directly appending the value after the prefix.
+
+### Incorrect Examples
+
+```html
+
+ These class names use the incorrect syntax
+
+```
+
+```js
+classnames("p-10px", "mx-15px", "leading-20px", "text-60px", "w-800px");
+```
+
+### Correct Examples
+
+```html
+
+ These class names use the correct syntax
+
+```
+
+```js
+classnames(
+ "p-[10px]",
+ "mx-[15px]",
+ "leading-[20px]",
+ "text-[60px]",
+ "w-[800px]"
+);
+```
+
+## Options
+
+```js
+...
+"tailwindcss/enforces-arbitrary-value-syntax": [, {
+ "callees": Array,
+ "config": |,
+ "skipClassAttribute": ,
+ "tags": Array,
+}]
+...
+```
+
+### `callees` (default: `["classnames", "clsx", "ctl", "cva", "tv"]`)
+
+If you use tool libraries like [@netlify/classnames-template-literals](https://github.com/netlify/classnames-template-literals), you can add their names to the list to ensure that the rule can parse them correctly.
+
+### `ignoredKeys` (default: `["compoundVariants", "defaultVariants"]`)
+
+When using libraries like `cva`, some object keys are not used to contain class names.
+You can specify which keys will not be parsed by the plugin through this setting.
+For example, `cva` has `compoundVariants` and `defaultVariants`.
+Note: Since `compoundVariants` can contain class names in its `class` attribute, you can also use callee to ensure that this inner section is parsed while its parent is ignored.
+
+### `config` (default: generated by `tailwindcss/lib/lib/load-config`)
+
+By default, the plugin will try to load the file returned by the official `loadConfig()` tool.
+
+This allows the plugin to use your customized `colors`, `spacing`, `screens`, etc.
+
+You can provide another path or file name for the Tailwind CSS configuration file, such as `"config/tailwind.js"`.
+
+If the external file cannot be loaded (e.g. the path is incorrect or the file has been deleted), an empty object `{}` will be used.
+
+You can also directly inject a pure `object` configuration, such as `{ prefix: "tw-", theme: { ... } }`.
+
+Finally, the plugin will [merge the provided configuration](https://tailwindcss.com/docs/configuration#referencing-in-java-script) with [Tailwind CSS's default configuration](https://github.com/tailwindlabs/tailwindcss/blob/master/stubs/defaultConfig.stub.js).
+
+### `skipClassAttribute` (default: `false`)
+
+Set `skipClassAttribute` to `true` if you only want to check class names inside functions in `callees`.
+This will avoid checking `class` and `className` attributes, but will still check `callees` inside these attributes.
+
+### `tags` (default: `[]`)
+
+Optional, if you use tag templates, you should provide them in this array.
diff --git a/lib/config/rules.js b/lib/config/rules.js
index 2399d016..9c922fe1 100644
--- a/lib/config/rules.js
+++ b/lib/config/rules.js
@@ -5,6 +5,7 @@
module.exports = {
'tailwindcss/classnames-order': 'warn',
+ 'tailwindcss/enforces-arbitrary-value-syntax': 'warn',
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
diff --git a/lib/index.js b/lib/index.js
index 53824e76..b6e630de 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -13,6 +13,7 @@ var base = __dirname + '/rules/';
module.exports = {
rules: {
'classnames-order': require(base + 'classnames-order'),
+ 'enforces-arbitrary-value-syntax': require(base + 'enforces-arbitrary-value-syntax'),
'enforces-negative-arbitrary-values': require(base + 'enforces-negative-arbitrary-values'),
'enforces-shorthand': require(base + 'enforces-shorthand'),
'migration-from-tailwind-2': require(base + 'migration-from-tailwind-2'),
diff --git a/lib/rules/enforces-arbitrary-value-syntax.js b/lib/rules/enforces-arbitrary-value-syntax.js
new file mode 100644
index 00000000..16d61480
--- /dev/null
+++ b/lib/rules/enforces-arbitrary-value-syntax.js
@@ -0,0 +1,330 @@
+/**
+ * @fileoverview Enforces correct arbitrary value syntax (using square brackets)
+ * @author Sweet
+ */
+'use strict';
+
+const docsUrl = require('../util/docsUrl');
+const customConfig = require('../util/customConfig');
+const astUtil = require('../util/ast');
+const groupUtil = require('../util/groupMethods');
+const getOption = require('../util/settings');
+const parserUtil = require('../util/parser');
+const createContextFallback = require('tailwindcss/lib/lib/setupContextUtils').createContext;
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+// Predefine message for use in context.report conditional.
+// messageId will still be usable in tests.
+const INVALID_ARBITRARY_VALUE_MSG = `Classname '{{classname}}' uses invalid arbitrary value syntax, should be '{{suggestion}}'`;
+
+// Regular expression to match class names with incorrect value usage
+// Modified to only match values with units, avoiding matching pure numbers and color values
+const INVALID_ARBITRARY_VALUE_REGEX = /^([a-z][\w-]*?)-([-]?\d+(?:\.\d+)?(?:px|rem|em|vh|vw|%|s|ms|deg|turn))$/i;
+
+// Regular expression to exclude color classes (e.g. bg-blue-500, text-red-600, etc.)
+const COLOR_CLASSNAME_REGEX = /-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$/i;
+
+// List of supported CSS units
+const CSS_UNITS = ['px', 'rem', 'em', 'vh', 'vw', 'vmin', 'vmax', 's', 'ms', 'deg', 'turn', 'rad', 'fr', 'ch', 'ex', 'rpx'];
+
+// Regular expression to match values with units
+const CSS_UNIT_REGEX = new RegExp(`^([a-z][\\w-]*?)-([-]?\\d+(?:\\.\\d+)?(?:${CSS_UNITS.join('|')}))$`, 'i');
+
+// Regular expression to exclude classes with percentage units
+const PERCENTAGE_REGEX = /^([a-z][\w-]*?)-\d+(?:\.\d+)?%$/i;
+
+const contextFallbackCache = new WeakMap();
+
+module.exports = {
+ meta: {
+ docs: {
+ description: 'Enforces correct arbitrary value syntax (using square brackets)',
+ category: 'Best Practices',
+ recommended: false,
+ url: docsUrl('enforces-arbitrary-value-syntax'),
+ },
+ messages: {
+ invalidArbitraryValueSyntax: INVALID_ARBITRARY_VALUE_MSG,
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ callees: {
+ type: 'array',
+ items: { type: 'string', minLength: 0 },
+ uniqueItems: true,
+ },
+ ignoredKeys: {
+ type: 'array',
+ items: { type: 'string', minLength: 0 },
+ uniqueItems: true,
+ },
+ config: {
+ // returned from `loadConfig()` utility
+ type: ['string', 'object'],
+ },
+ tags: {
+ type: 'array',
+ items: { type: 'string', minLength: 0 },
+ uniqueItems: true,
+ },
+ },
+ },
+ ],
+ },
+
+ create: function (context) {
+ const callees = getOption(context, 'callees');
+ const ignoredKeys = getOption(context, 'ignoredKeys');
+ const skipClassAttribute = getOption(context, 'skipClassAttribute');
+ const tags = getOption(context, 'tags');
+ const twConfig = getOption(context, 'config');
+ const classRegex = getOption(context, 'classRegex');
+
+ const mergedConfig = customConfig.resolve(twConfig);
+ const contextFallback = // Set the created contextFallback in the cache if it does not exist yet.
+ (
+ contextFallbackCache.has(mergedConfig)
+ ? contextFallbackCache
+ : contextFallbackCache.set(mergedConfig, createContextFallback(mergedConfig))
+ ).get(mergedConfig);
+
+ //----------------------------------------------------------------------
+ // Helpers
+ //----------------------------------------------------------------------
+
+ /**
+ * Check if a class name uses incorrect arbitrary value syntax
+ * @param {string} className The class name to check
+ * @returns {object|null} An object containing the correction suggestion, or null if no correction is needed
+ */
+ const checkArbitraryValueSyntax = (className) => {
+ // Skip if the class name matches the color class pattern (e.g. bg-blue-500)
+ if (COLOR_CLASSNAME_REGEX.test(className)) {
+ return null;
+ }
+
+ // Skip if the class name contains a percentage unit
+ if (PERCENTAGE_REGEX.test(className)) {
+ return null;
+ }
+
+ // Check if it matches a value with a unit
+ const match = className.match(CSS_UNIT_REGEX);
+ if (match) {
+ const [, prefix, value] = match;
+ // Build the corrected class name
+ const correctedClassName = `${prefix}-[${value}]`;
+ return {
+ original: className,
+ suggestion: correctedClassName
+ };
+ }
+ return null;
+ };
+
+ /**
+ * Parse class names and report any syntax errors found
+ * @param {Array} classNames Array of class names
+ * @param {ASTNode} node AST node
+ * @param {Function} fixerFn Fix function
+ */
+ const parseForInvalidArbitraryValueSyntax = (classNames, node, fixerFn) => {
+ // Collect all class names that need to be fixed
+ const invalidClassnames = classNames
+ .map(className => {
+ const result = checkArbitraryValueSyntax(className);
+ return result ? {
+ original: result.original,
+ suggestion: result.suggestion
+ } : null;
+ })
+ .filter(result => result !== null);
+
+ // If there are class names that need to be fixed, report errors
+ if (invalidClassnames.length > 0) {
+ invalidClassnames.forEach(result => {
+ context.report({
+ node,
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: result.original,
+ suggestion: result.suggestion,
+ },
+ fix: fixerFn ? (fixer) => fixerFn(fixer, invalidClassnames) : null,
+ });
+ });
+ }
+ };
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ const attributeVisitor = function (node) {
+ if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) {
+ return;
+ }
+
+ // Handle text attribute
+ if (node.type === 'TextAttribute' && node.name === 'class') {
+ const originalValue = node.value;
+ if (typeof originalValue === 'string') {
+ const { classNames } = astUtil.extractClassnamesFromValue(originalValue);
+
+ const fixerFn = (fixer, invalidClassnames) => {
+ let updatedValue = originalValue;
+
+ // Replace all invalid class names
+ invalidClassnames.forEach(item => {
+ const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g');
+ updatedValue = updatedValue.replace(classNameRegex, item.suggestion);
+ });
+
+ return fixer.replaceText(node, `class="${updatedValue}"`);
+ };
+
+ parseForInvalidArbitraryValueSyntax(classNames, node, fixerFn);
+ }
+ } else if (astUtil.isLiteralAttributeValue(node)) {
+ const originalClassNamesValue = astUtil.extractValueFromNode(node);
+
+ if (node.type === 'JSXAttribute') {
+ let start, end;
+
+ if (node.value.type === 'Literal') {
+ start = node.value.range[0] + 1;
+ end = node.value.range[1] - 1;
+ } else if (node.value.type === 'JSXExpressionContainer' && node.value.expression.type === 'Literal') {
+ start = node.value.expression.range[0] + 1;
+ end = node.value.expression.range[1] - 1;
+ }
+
+ const fixerFn = (fixer, invalidClassnames) => {
+ // Get the full class name string
+ const sourceCode = context.getSourceCode();
+ const text = sourceCode.getText().slice(start, end);
+
+ let updatedText = text;
+
+ // Replace all invalid class names
+ invalidClassnames.forEach(item => {
+ const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g');
+ updatedText = updatedText.replace(classNameRegex, item.suggestion);
+ });
+
+ return fixer.replaceTextRange([start, end], updatedText);
+ };
+
+ const { classNames } = astUtil.extractClassnamesFromValue(originalClassNamesValue);
+ parseForInvalidArbitraryValueSyntax(classNames, node, fixerFn);
+ }
+ }
+ };
+
+ const callExpressionVisitor = function (node) {
+ const calleeStr = astUtil.calleeToString(node.callee);
+ if (callees.findIndex((name) => calleeStr === name) === -1) {
+ return;
+ }
+
+ node.arguments.forEach((arg) => {
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
+ const { classNames } = astUtil.extractClassnamesFromValue(arg.value);
+
+ const fixerFn = (fixer, invalidClassnames) => {
+ const sourceCode = context.getSourceCode();
+ const text = sourceCode.getText(arg);
+ const innerText = text.slice(1, -1); // Remove quotes
+
+ let updatedText = innerText;
+
+ // Replace all invalid class names
+ invalidClassnames.forEach(item => {
+ const classNameRegex = new RegExp(`\\b${item.original}\\b`, 'g');
+ updatedText = updatedText.replace(classNameRegex, item.suggestion);
+ });
+
+ // Use the same type of quote
+ const quote = text[0];
+ return fixer.replaceText(arg, `${quote}${updatedText}${quote}`);
+ };
+
+ parseForInvalidArbitraryValueSyntax(classNames, arg, fixerFn);
+ } else {
+ astUtil.parseNodeRecursive(node, arg, (classNames, nestedNode) => {
+ parseForInvalidArbitraryValueSyntax(classNames, nestedNode, null);
+ }, false, false, ignoredKeys);
+ }
+ });
+ };
+
+ const scriptVisitor = {
+ JSXAttribute: attributeVisitor,
+ TextAttribute: attributeVisitor,
+ CallExpression: callExpressionVisitor,
+ TaggedTemplateExpression: function (node) {
+ if (!tags.includes(node.tag.name ?? node.tag.object?.name ?? node.tag.callee?.name)) {
+ return;
+ }
+ astUtil.parseNodeRecursive(node, node.quasi, (classNames, nestedNode) => {
+ parseForInvalidArbitraryValueSyntax(classNames, nestedNode, null);
+ }, false, false, ignoredKeys);
+ },
+ };
+
+ // With the vue-eslint-parser
+ if (parserUtil.defineTemplateBodyVisitor) {
+ return parserUtil.defineTemplateBodyVisitor(
+ context,
+ {
+ VAttribute: function (node) {
+ if (!astUtil.isValidVueAttribute(node, classRegex)) {
+ return;
+ }
+
+ if (astUtil.isVLiteralValue(node)) {
+ const { classNames } = astUtil.extractClassnamesFromValue(node.value.value);
+ parseForInvalidArbitraryValueSyntax(classNames, node, null);
+ } else if (astUtil.isArrayExpression(node)) {
+ node.value.expression.elements.forEach((element) => {
+ if (element.type === 'Literal' && typeof element.value === 'string') {
+ const { classNames } = astUtil.extractClassnamesFromValue(element.value);
+ parseForInvalidArbitraryValueSyntax(classNames, element, null);
+ }
+ });
+ } else if (astUtil.isObjectExpression(node)) {
+ node.value.expression.properties.forEach((property) => {
+ if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
+ const { classNames } = astUtil.extractClassnamesFromValue(property.key.value);
+ parseForInvalidArbitraryValueSyntax(classNames, property.key, null);
+ }
+ });
+ }
+ },
+ VElement: function (node) {
+ // Look for
+ // the "class" attribute is attached to the VElement
+ // (no dynamic value)
+ if (node.startTag.attributes) {
+ node.startTag.attributes.forEach((attr) => {
+ if (attr.key && attr.key.name === 'class' && attr.value) {
+ const { classNames } = astUtil.extractClassnamesFromValue(attr.value.value);
+ parseForInvalidArbitraryValueSyntax(classNames, attr, null);
+ }
+ });
+ }
+ },
+ },
+ scriptVisitor
+ );
+ }
+
+ return scriptVisitor;
+ },
+};
\ No newline at end of file
diff --git a/tests/lib/rules/enforces-arbitrary-value-syntax.js b/tests/lib/rules/enforces-arbitrary-value-syntax.js
new file mode 100644
index 00000000..27a4011d
--- /dev/null
+++ b/tests/lib/rules/enforces-arbitrary-value-syntax.js
@@ -0,0 +1,186 @@
+/**
+ * @fileoverview Enforces correct arbitrary value syntax (using square brackets)
+ * @author Sweet
+ */
+"use strict";
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+var rule = require("../../../lib/rules/enforces-arbitrary-value-syntax");
+var RuleTester = require("eslint").RuleTester;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+var parserOptions = {
+ ecmaVersion: 2019,
+ sourceType: "module",
+ ecmaFeatures: {
+ jsx: true,
+ },
+};
+
+var defaultOptions = [
+ {
+ config: {
+ theme: {},
+ plugins: [],
+ separator: ':',
+ },
+ },
+];
+
+var invalidClassnames = [
+ 'p-10px',
+ 'mx-15px',
+ 'leading-20px',
+ 'text-60px',
+ 'w-800px',
+ 'h-100vh',
+ 'gap-5rem',
+ 'm-1em',
+ 'rotate-10deg',
+ 'delay-500ms',
+ 'h-0.5px',
+ 'w-3.7rem',
+ 'mt-20rpx',
+ 'pt-1.25vmin',
+ 'width-5fr',
+];
+
+var invalidClassnamesFixed = [
+ 'p-[10px]',
+ 'mx-[15px]',
+ 'leading-[20px]',
+ 'text-[60px]',
+ 'w-[800px]',
+ 'h-[100vh]',
+ 'gap-[5rem]',
+ 'm-[1em]',
+ 'rotate-[10deg]',
+ 'delay-[500ms]',
+ 'h-[0.5px]',
+ 'w-[3.7rem]',
+ 'mt-[20rpx]',
+ 'pt-[1.25vmin]',
+ 'width-[5fr]',
+];
+
+var validClassnames = [
+ 'p-[10px]',
+ 'mx-[15px]',
+ 'leading-[20px]',
+ 'text-[60px]',
+ 'w-[800px]',
+ 'p-2',
+ 'mx-4',
+ 'text-lg',
+ 'rounded-md',
+ 'bg-blue-500',
+ 'w-1/2',
+ 'w-full',
+ 'w-100%',
+ 'scale-100',
+ 'opacity-50',
+];
+
+var ruleTester = new RuleTester({ parserOptions });
+ruleTester.run("enforces-arbitrary-value-syntax", rule, {
+ valid: [
+ {
+ code: `
`,
+ options: defaultOptions,
+ },
+ {
+ code: `
`,
+ options: defaultOptions,
+ },
+ {
+ code: `classnames('${validClassnames.join("', '")}')`,
+ options: defaultOptions,
+ },
+ {
+ code: `clsx('${validClassnames.join("', '")}')`,
+ options: defaultOptions,
+ },
+ {
+ code: `
`,
+ options: defaultOptions,
+ },
+ ],
+
+ invalid: [
+ {
+ code: `
`,
+ output: `
`,
+ options: defaultOptions,
+ errors: invalidClassnames.map(className => ({
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: className,
+ suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)],
+ },
+ })),
+ },
+ {
+ code: `
`,
+ output: `
`,
+ options: defaultOptions,
+ errors: invalidClassnames.map(className => ({
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: className,
+ suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)],
+ },
+ })),
+ },
+ {
+ code: `classnames('${invalidClassnames.join("', '")}')`,
+ output: `classnames('${invalidClassnamesFixed.join("', '")}')`,
+ options: defaultOptions,
+ errors: invalidClassnames.map(className => ({
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: className,
+ suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)],
+ },
+ })),
+ },
+ {
+ code: `clsx('${invalidClassnames.join("', '")}')`,
+ output: `clsx('${invalidClassnamesFixed.join("', '")}')`,
+ options: defaultOptions,
+ errors: invalidClassnames.map(className => ({
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: className,
+ suggestion: invalidClassnamesFixed[invalidClassnames.indexOf(className)],
+ },
+ })),
+ },
+ {
+ code: `
`,
+ output: `
`,
+ options: defaultOptions,
+ errors: [
+ {
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: invalidClassnames[0],
+ suggestion: invalidClassnamesFixed[0],
+ },
+ },
+ {
+ messageId: 'invalidArbitraryValueSyntax',
+ data: {
+ classname: invalidClassnames[1],
+ suggestion: invalidClassnamesFixed[1],
+ },
+ },
+ ],
+ },
+ ],
+});
\ No newline at end of file