From 656c86b66ae881ecaca64a7526397de497dd0686 Mon Sep 17 00:00:00 2001 From: Azat S Date: Mon, 8 May 2023 11:54:48 +0300 Subject: [PATCH] feat: add sort-jsx-props rule --- docs/.vitepress/config.ts | 4 + docs/rules/index.md | 1 + docs/rules/sort-jsx-props.md | 83 +++++++++++++++ index.ts | 8 +- readme.md | 1 + rules/sort-jsx-props.ts | 83 +++++++++++++++ test/sort-jsx-props.test.ts | 198 +++++++++++++++++++++++++++++++++++ 7 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 docs/rules/sort-jsx-props.md create mode 100644 rules/sort-jsx-props.ts create mode 100644 test/sort-jsx-props.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0603ed20..269e3e14 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -75,6 +75,10 @@ export default defineConfig({ text: 'sort-interfaces', link: '/rules/sort-interfaces', }, + { + text: 'sort-jsx-props', + link: '/rules/sort-jsx-props', + }, { text: 'sort-named-imports', link: '/rules/sort-named-imports', diff --git a/docs/rules/index.md b/docs/rules/index.md index 58630fda..b0128927 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -9,4 +9,5 @@ title: Rules | Name | Description | 💼 | 🛠 | | :---------------------------------------------- | :---------------------------------- | :-- | :-- | | [sort-interfaces](/rules/sort-interfaces) | Enforce sorted interface properties | ✅ | 🔧 | +| [sort-jsx-props](/rules/sort-jsx-props) | Enforce sorted JSX props | ✅ | 🔧 | | [sort-named-imports](/rules/sort-named-imports) | Enforce sorted named imports | ✅ | 🔧 | diff --git a/docs/rules/sort-jsx-props.md b/docs/rules/sort-jsx-props.md new file mode 100644 index 00000000..d4057abf --- /dev/null +++ b/docs/rules/sort-jsx-props.md @@ -0,0 +1,83 @@ +--- +title: sort-jsx-props +--- + +# sort-jsx-props + +> Enforce sorted JSX props. + +## Rule details + +This rule verifies that JSX props are sorted sorted in order of string length. + +### Incorrect + +```tsx +let Container = () => ( + +) +``` + +### Correct + +```tsx +let Container = () => ( + +) +``` + +## Options + +This rule is not configurable. + +## Usage + +### Legacy config + +```json +// .eslintrc +{ + "rules": { + "perfectionist/sort-jsx-props": "error" + } +} +``` + +### Flat config + +```js +// eslint.config.js +import perfectionist from 'eslint-plugin-perfectionist' + +export default { + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-jsx-props': 'error', + }, +} +``` + +## Resources + +- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-jsx-props.ts) +- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-jsx-props.test.ts) diff --git a/index.ts b/index.ts index bc9f64c0..8d20716c 100644 --- a/index.ts +++ b/index.ts @@ -1,11 +1,13 @@ -import sortInterfaces, { - RULE_NAME as sortInterfacesName, -} from '~/rules/sort-interfaces' +import sortInterfaces, { RULE_NAME as sortInterfacesName } from '~/rules/sort-interfaces' +import sortJsxProps, { RULE_NAME as sortJsxPropsName } from '~/rules/sort-jsx-props' +import sortNamedImports, { RULE_NAME as sortNamedImportsName } from '~/rules/sort-named-imports' import { name } from '~/package.json' export default { name, rules: { [sortInterfacesName]: sortInterfaces, + [sortJsxPropsName]: sortJsxProps, + [sortNamedImportsName]: sortNamedImports, }, } diff --git a/readme.md b/readme.md index 30cc76ba..67206e17 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,7 @@ npm install --save-dev eslint-plugin-perfectionist | Name | Description | 💼 | 🛠 | | :----------------------------------------------------------------------------------------- | :---------------------------------- | :-- | :-- | | [sort-interfaces](https://eslint-plugin-perfectionist.azat.io/rules/sort-interfaces) | Enforce sorted interface properties | ✅ | 🔧 | +| [sort-jsx-props](https://eslint-plugin-perfectionist.azat.io/rules/sort-jsx-props) | Enforce sorted JSX props | ✅ | 🔧 | | [sort-named-imports](https://eslint-plugin-perfectionist.azat.io/rules/sort-named-imports) | Enforce sorted named imports | ✅ | 🔧 | ## See also diff --git a/rules/sort-jsx-props.ts b/rules/sort-jsx-props.ts new file mode 100644 index 00000000..c1297c23 --- /dev/null +++ b/rules/sort-jsx-props.ts @@ -0,0 +1,83 @@ +import type { + JSXSpreadAttribute, + JSXAttribute, +} from '@typescript-eslint/types/dist/generated/ast-spec' + +import { AST_NODE_TYPES } from '@typescript-eslint/types' + +import { createEslintRule } from '~/utils/create-eslint-rule' +import { rangeToDiff } from '~/utils/range-to-diff' +import { sortNodes } from '~/utils/sort-nodes' +import type { SortingNode } from '~/typings' + +type MESSAGE_ID = 'unexpectedJSXPropsOrder' + +type Options = [] + +export const RULE_NAME = 'sort-jsx-props' + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce sorted interface properties', + recommended: false, + }, + messages: { + unexpectedJSXPropsOrder: 'Expected "{{second}}" to come before "{{first}}"', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create: context => ({ + JSXElement: node => { + let parts: JSXAttribute[][] = node.openingElement.attributes.reduce( + (accumulator: JSXAttribute[][], attribute: JSXSpreadAttribute | JSXAttribute) => { + if (attribute.type === 'JSXAttribute') { + accumulator.at(-1)!.push(attribute) + } else { + accumulator.push([]) + } + return accumulator + }, + [[]], + ) + + parts.forEach(part => { + let values: SortingNode[] = part.map(attribute => ({ + name: + attribute.name.type === AST_NODE_TYPES.JSXNamespacedName + ? `${attribute.name.namespace.name}:${attribute.name.name.name}` + : attribute.name.name, + size: rangeToDiff(attribute.range), + node: attribute, + })) + + for (let i = 1; i < values.length; i++) { + let firstIndex = i - 1 + let secondIndex = i + let first = values.at(firstIndex)! + let second = values.at(secondIndex)! + + if (first.size < second.size) { + context.report({ + messageId: 'unexpectedJSXPropsOrder', + data: { + first: first.name, + second: second.name, + }, + node: second.node, + fix: fixer => { + let sourceCode = context.getSourceCode() + let { text } = sourceCode + return sortNodes(fixer, text, values) + }, + }) + } + } + }) + }, + }), +}) diff --git a/test/sort-jsx-props.test.ts b/test/sort-jsx-props.test.ts new file mode 100644 index 00000000..49a39217 --- /dev/null +++ b/test/sort-jsx-props.test.ts @@ -0,0 +1,198 @@ +import { RuleTester } from '@typescript-eslint/utils/dist/ts-eslint/index.js' +import { describe, it } from 'vitest' + +import rule, { RULE_NAME } from '~/rules/sort-jsx-props' + +describe(RULE_NAME, () => { + let ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }) + + it(`${RULE_NAME}: sorts jsx props`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + ` + let Container = () => ( + + ) + `, + ], + invalid: [ + { + code: ` + let Container = () => ( + + ) + `, + output: ` + let Container = () => ( + + ) + `, + errors: [ + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'type', + second: 'variant', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}: sorts jsx props with namespaced names`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + ` + let Container = () => ( + + ) + `, + ], + invalid: [ + { + code: ` + let Container = () => ( + + ) + `, + output: ` + let Container = () => ( + + ) + `, + errors: [ + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'name', + second: 'foo:bar', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}: does not break the property list`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + ` + let Container = () => ( + + ) + `, + ], + invalid: [ + { + code: ` + let Container = () => ( + + ) + `, + output: ` + let Container = () => ( + + ) + `, + errors: [ + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'full', + second: 'value', + }, + }, + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'error', + second: 'name', + }, + }, + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'name', + second: 'type', + }, + }, + { + messageId: 'unexpectedJSXPropsOrder', + data: { + first: 'autoFocus', + second: 'className', + }, + }, + ], + }, + ], + }) + }) +})