diff --git a/docs/rules/index.md b/docs/rules/index.md index 1d7c806e..19f4b12b 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -9,20 +9,21 @@ description: ESLint Plugin Perfectionist list of rules 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name | Description | 🔧 | -| :------------------------------------------------ | :------------------------------------------ | :- | -| [sort-array-includes](/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | -| [sort-classes](/rules/sort-classes) | enforce sorted classes | 🔧 | -| [sort-enums](/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 | -| [sort-exports](/rules/sort-exports) | enforce sorted exports | 🔧 | -| [sort-imports](/rules/sort-imports) | enforce sorted imports | 🔧 | -| [sort-interfaces](/rules/sort-interfaces) | enforce sorted interface properties | 🔧 | -| [sort-jsx-props](/rules/sort-jsx-props) | enforce sorted JSX props | 🔧 | -| [sort-maps](/rules/sort-maps) | enforce sorted Map elements | 🔧 | -| [sort-named-exports](/rules/sort-named-exports) | enforce sorted named exports | 🔧 | -| [sort-named-imports](/rules/sort-named-imports) | enforce sorted named imports | 🔧 | -| [sort-object-types](/rules/sort-object-types) | enforce sorted object types | 🔧 | -| [sort-objects](/rules/sort-objects) | enforce sorted objects | 🔧 | -| [sort-union-types](/rules/sort-union-types) | enforce sorted union types | 🔧 | +| Name | Description | 🔧 | +| :------------------------------------------------------ | :------------------------------------------ | :- | +| [sort-array-includes](/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | +| [sort-classes](/rules/sort-classes) | enforce sorted classes | 🔧 | +| [sort-enums](/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 | +| [sort-exports](/rules/sort-exports) | enforce sorted exports | 🔧 | +| [sort-imports](/rules/sort-imports) | enforce sorted imports | 🔧 | +| [sort-interfaces](/rules/sort-interfaces) | enforce sorted interface properties | 🔧 | +| [sort-jsx-props](/rules/sort-jsx-props) | enforce sorted JSX props | 🔧 | +| [sort-maps](/rules/sort-maps) | enforce sorted Map elements | 🔧 | +| [sort-named-exports](/rules/sort-named-exports) | enforce sorted named exports | 🔧 | +| [sort-named-imports](/rules/sort-named-imports) | enforce sorted named imports | 🔧 | +| [sort-object-types](/rules/sort-object-types) | enforce sorted object types | 🔧 | +| [sort-objects](/rules/sort-objects) | enforce sorted objects | 🔧 | +| [sort-svelte-attributes](/rules/sort-svelte-attributes) | enforce sorted union types | 🔧 | +| [sort-union-types](/rules/sort-union-types) | enforce sorted union types | 🔧 | diff --git a/docs/rules/sort-svelte-attributes.md b/docs/rules/sort-svelte-attributes.md new file mode 100644 index 00000000..e527552a --- /dev/null +++ b/docs/rules/sort-svelte-attributes.md @@ -0,0 +1,214 @@ +--- +title: sort-svelte-attributes +description: ESLint Plugin Perfectionist rule which enforce sorted attributes in Svelte elements +--- + +# sort-svelte-attributes + +💼 This rule is enabled in the following [configs](/configs/): `recommended-alphabetical`, `recommended-line-length`, `recommended-natural`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +## 📖 Rule Details + +Enforce sorted attributes in Svelte elements. + +It's **safe**. The rule considers spread elements in an attributes list and does not break component functionality. + +:::info Important +If you use the [`sort-attributes`](https://sveltejs.github.io/eslint-plugin-svelte/rules/sort-attributes/) rule from the [`eslint-plugin-svelte`](https://sveltejs.github.io/eslint-plugin-svelte) plugin, it is highly recommended to [disable it](https://eslint.org/docs/latest/use/configure/rules#using-configuration-files-1) to avoid conflicts. +::: + +## 💡 Examples + +::: code-group + + +```svelte [Alphabetical and Natural Sorting] +// ❌ Incorrect + + +// ✅ Correct + +``` + +```svelte [Sorting by Line Length] +// ❌ Incorrect + + +// ✅ Correct + +``` + +::: + +## 🔧 Options + +This rule accepts an options object with the following properties: + +```ts +type Group = + | 'multiline' + | 'shorthand' + | 'svelte-shorthand' + | 'unknown' + +interface Options { + type?: 'alphabetical' | 'natural' | 'line-length' + order?: 'asc' | 'desc' + 'ignore-case'?: boolean + groups?: (Group | Group[])[] + 'custom-groups': { [key in T[number]]: string[] | string } +} +``` + +### type + +(default: `'alphabetical'`) + +- `alphabetical` - sort alphabetically. +- `natural` - sort in natural order. +- `line-length` - sort by code line length. + +### order + +(default: `'asc'`) + +- `asc` - enforce properties to be in ascending order. +- `desc` - enforce properties to be in descending order. + +### ignore-case + +(default: `false`) + +Only affects alphabetical and natural sorting. When `true` the rule ignores the case-sensitivity of the order. + +### groups + +(default: `[]`) + +You can set up a list of Svelte attribute groups for sorting. Groups can be combined. There are predefined groups: `'multiline'`, `'shorthand'`, `'svelte-shorthand'`. + +### custom-groups + +(default: `{}`) + +You can define your own groups for Svelte attributes. The [minimatch](https://github.com/isaacs/minimatch) library is used for pattern matching. + +Example: + +``` +{ + "custom-groups": { + "callback": "on*" + } +} +``` + +## ⚙️ Usage + +:::info Important +In order to start using this rule, you need to install additional dependencies: + +- `svelte` +- `svelte-eslint-parser` +::: + +::: code-group + +```json [Legacy Config] +// .eslintrc +{ + "plugins": ["perfectionist"], + "rules": { + "perfectionist/sort-svelte-attributes": [ + "error", + { + "type": "natural", + "order": "asc", + "groups": [ + "multiline", + "unknown", + ["shorthand", "svelte-shorthand"] + ] + } + ] + } +} +``` + +```js [Flat Config] +// eslint.config.js +import perfectionist from 'eslint-plugin-perfectionist' + +export default [ + { + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-svelte-attributes': [ + 'error', + { + type: 'natural', + order: 'asc', + groups: [ + 'multiline', + 'unknown', + ['shorthand', 'svelte-shorthand'], + ], + }, + ], + }, + }, +] +``` + +::: + +## 🚀 Version + +Coming soon. + +## 📚 Resources + +- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-svelte-attributes.ts) +- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-svelte-attributes.test.ts) diff --git a/index.ts b/index.ts index f10a4354..1cae5941 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,4 @@ +import sortSvelteAttributes, { RULE_NAME as sortSvelteAttributesName } from './rules/sort-svelte-attributes' import sortArrayIncludes, { RULE_NAME as sortArrayIncludesName } from './rules/sort-array-includes' import sortNamedImports, { RULE_NAME as sortNamedImportsName } from './rules/sort-named-imports' import sortNamedExports, { RULE_NAME as sortNamedExportsName } from './rules/sort-named-exports' @@ -90,8 +91,9 @@ let createConfigWithOptions = (options: { 'spread-last': true, }, ], - [sortNamedImportsName]: ['error'], + [sortSvelteAttributesName]: ['error'], [sortNamedExportsName]: ['error'], + [sortNamedImportsName]: ['error'], [sortObjectTypesName]: ['error'], [sortUnionTypesName]: ['error'], [sortInterfacesName]: ['error'], @@ -125,6 +127,7 @@ export default { [sortNamedImportsName]: sortNamedImports, [sortObjectTypesName]: sortObjectTypes, [sortObjectsName]: sortObjects, + [sortSvelteAttributesName]: sortSvelteAttributes, [sortUnionTypesName]: sortUnionTypes, }, configs: { diff --git a/package.json b/package.json index 764eee4c..d8d303de 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,18 @@ }, "./package.json": "./package.json" }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "svelte-eslint-parser": { + "optional": true + } + }, "peerDependencies": { - "eslint": ">=8.0.0" + "eslint": ">=8.0.0", + "svelte": ">=3.0.0", + "svelte-eslint-parser": "^0.32.0" }, "dependencies": { "@typescript-eslint/types": "^5.62.0", @@ -87,6 +97,8 @@ "eslint-plugin-vitest": "^0.2.6", "simple-git-hooks": "^2.8.1", "sitemap": "^7.1.1", + "svelte": "^4.1.1", + "svelte-eslint-parser": "^0.32.2", "ts-dedent": "^2.2.0", "typescript": "^5.1.6", "vite": "^4.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 530e9b95..d65b29fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,12 @@ devDependencies: sitemap: specifier: ^7.1.1 version: 7.1.1 + svelte: + specifier: ^4.1.1 + version: 4.1.1 + svelte-eslint-parser: + specifier: ^0.32.2 + version: 0.32.2(svelte@4.1.1) ts-dedent: specifier: ^2.2.0 version: 2.2.0 @@ -993,6 +999,10 @@ packages: resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} dev: true + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + /@types/is-core-module@2.2.0: resolution: {integrity: sha512-4jdbEoadP1B6v5/6YoVFPKUr2NC+knEWMSllpX3cL4FAZktVmveeWTcUHQY7bU6Vx8TEt/ARbQyQapkZvGgFog==} dev: true @@ -1513,6 +1523,12 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -1573,6 +1589,12 @@ packages: engines: {node: '>= 0.4'} dev: true + /axobject-query@3.2.1: + resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} + dependencies: + dequal: 2.0.3 + dev: true + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1807,6 +1829,16 @@ packages: wrap-ansi: 7.0.0 dev: true + /code-red@1.0.3: + resolution: {integrity: sha512-kVwJELqiILQyG5aeuyKFbdsI1fmQy1Cmf7dQ8eGmVuJoaRVdwey7WaMknr2ZFeVSYSKT0rExsa8EGw0aoI/1QQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.1 + acorn: 8.10.0 + estree-walker: 3.0.3 + periscopic: 3.1.0 + dev: true + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -1925,6 +1957,14 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + dev: true + /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} dev: true @@ -2019,6 +2059,11 @@ packages: resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} dev: true + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dev: true + /destr@2.0.0: resolution: {integrity: sha512-FJ9RDpf3GicEBvzI3jxc2XhHzbqD8p4ANw/1kPsFBfTvP1b7Gn/Lg1vO7R9J4IVgoMbyUmFrFGZafJ1hPZpvlg==} dev: true @@ -2595,6 +2640,12 @@ packages: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: true + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.1 + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3138,6 +3189,12 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-reference@3.0.1: + resolution: {integrity: sha512-baJJdQLiYaJdvFbJqXrcGv3WU3QCzBlUcI5QhbesIm6/xPsvmO+2CDoi/GMOFBQEQm+PXkwOPrp9KK5ozZsp2w==} + dependencies: + '@types/estree': 1.0.1 + dev: true + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -3442,6 +3499,10 @@ packages: engines: {node: '>=14'} dev: true + /locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + dev: true + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3552,6 +3613,10 @@ packages: resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} dev: true + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + dev: true + /meow@8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -3910,6 +3975,14 @@ packages: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} dev: true + /periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + dependencies: + '@types/estree': 1.0.1 + estree-walker: 3.0.3 + is-reference: 3.0.1 + dev: true + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -3931,6 +4004,15 @@ packages: engines: {node: '>=4'} dev: true + /postcss-scss@4.0.6(postcss@8.4.26): + resolution: {integrity: sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.19 + dependencies: + postcss: 8.4.26 + dev: true + /postcss@8.4.25: resolution: {integrity: sha512-7taJ/8t2av0Z+sQEvNzCkpDynl0tX3uJMCODi6nT3PfASC7dYCWV9aQ+uiCf+KBD4SEFcu+GvJdGdwzQ6OSjCw==} engines: {node: ^10 || ^12 || >=14} @@ -4392,6 +4474,42 @@ packages: engines: {node: '>= 0.4'} dev: true + /svelte-eslint-parser@0.32.2(svelte@4.1.1): + resolution: {integrity: sha512-Ok9D3A4b23iLQsONrjqtXtYDu5ZZ/826Blaw2LeFZVTg1pwofKDG4mz3/GYTax8fQ0plRGHI6j+d9VQYy5Lo/A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + dependencies: + eslint-scope: 7.2.1 + eslint-visitor-keys: 3.4.1 + espree: 9.6.1 + postcss: 8.4.26 + postcss-scss: 4.0.6(postcss@8.4.26) + svelte: 4.1.1 + dev: true + + /svelte@4.1.1: + resolution: {integrity: sha512-Enick5fPFISLoVy0MFK45cG+YlQt6upw8skEK9zzTpJnH1DqEv8xOZwizCGSo3Q6HZ7KrZTM0J18poF7aQg5zw==} + engines: {node: '>=16'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.18 + acorn: 8.10.0 + aria-query: 5.3.0 + axobject-query: 3.2.1 + code-red: 1.0.3 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.1 + locate-character: 3.0.0 + magic-string: 0.30.1 + periscopic: 3.1.0 + dev: true + /synckit@0.8.5: resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} engines: {node: ^14.18.0 || >=16.0.0} diff --git a/readme.md b/readme.md index 346a5349..beda31ca 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ ESLint plugin that sets rules to format your code and make it consistent. -This plugin defines rules for sorting various data, such as objects, imports, TypeScript types, enums, JSX props, etc. alphabetically, naturally, or by line length +This plugin defines rules for sorting various data, such as objects, imports, TypeScript types, enums, JSX props, Svelte attributes, etc. alphabetically, naturally, or by line length All rules are automatically fixable. It's safe! @@ -138,21 +138,22 @@ export default [ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name | Description | 🔧 | -| :------------------------------------------------------------------------------------------- | :------------------------------------------ | :- | -| [sort-array-includes](https://eslint-plugin-perfectionist.azat.io/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | -| [sort-classes](https://eslint-plugin-perfectionist.azat.io/rules/sort-classes) | enforce sorted classes | 🔧 | -| [sort-enums](https://eslint-plugin-perfectionist.azat.io/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 | -| [sort-exports](https://eslint-plugin-perfectionist.azat.io/rules/sort-exports) | enforce sorted exports | 🔧 | -| [sort-imports](https://eslint-plugin-perfectionist.azat.io/rules/sort-imports) | enforce sorted imports | 🔧 | -| [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-maps](https://eslint-plugin-perfectionist.azat.io/rules/sort-maps) | enforce sorted Map elements | 🔧 | -| [sort-named-exports](https://eslint-plugin-perfectionist.azat.io/rules/sort-named-exports) | enforce sorted named exports | 🔧 | -| [sort-named-imports](https://eslint-plugin-perfectionist.azat.io/rules/sort-named-imports) | enforce sorted named imports | 🔧 | -| [sort-object-types](https://eslint-plugin-perfectionist.azat.io/rules/sort-object-types) | enforce sorted object types | 🔧 | -| [sort-objects](https://eslint-plugin-perfectionist.azat.io/rules/sort-objects) | enforce sorted objects | 🔧 | -| [sort-union-types](https://eslint-plugin-perfectionist.azat.io/rules/sort-union-types) | enforce sorted union types | 🔧 | +| Name | Description | 🔧 | +| :------------------------------------------------------------------------------------------------- | :------------------------------------------ | :- | +| [sort-array-includes](https://eslint-plugin-perfectionist.azat.io/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | +| [sort-classes](https://eslint-plugin-perfectionist.azat.io/rules/sort-classes) | enforce sorted classes | 🔧 | +| [sort-enums](https://eslint-plugin-perfectionist.azat.io/rules/sort-enums) | enforce sorted TypeScript enums | 🔧 | +| [sort-exports](https://eslint-plugin-perfectionist.azat.io/rules/sort-exports) | enforce sorted exports | 🔧 | +| [sort-imports](https://eslint-plugin-perfectionist.azat.io/rules/sort-imports) | enforce sorted imports | 🔧 | +| [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-maps](https://eslint-plugin-perfectionist.azat.io/rules/sort-maps) | enforce sorted Map elements | 🔧 | +| [sort-named-exports](https://eslint-plugin-perfectionist.azat.io/rules/sort-named-exports) | enforce sorted named exports | 🔧 | +| [sort-named-imports](https://eslint-plugin-perfectionist.azat.io/rules/sort-named-imports) | enforce sorted named imports | 🔧 | +| [sort-object-types](https://eslint-plugin-perfectionist.azat.io/rules/sort-object-types) | enforce sorted object types | 🔧 | +| [sort-objects](https://eslint-plugin-perfectionist.azat.io/rules/sort-objects) | enforce sorted objects | 🔧 | +| [sort-svelte-attributes](https://eslint-plugin-perfectionist.azat.io/rules/sort-svelte-attributes) | enforce sorted union types | 🔧 | +| [sort-union-types](https://eslint-plugin-perfectionist.azat.io/rules/sort-union-types) | enforce sorted union types | 🔧 | diff --git a/rules/sort-svelte-attributes.ts b/rules/sort-svelte-attributes.ts new file mode 100644 index 00000000..c15d11ff --- /dev/null +++ b/rules/sort-svelte-attributes.ts @@ -0,0 +1,234 @@ +import type { TSESTree } from '@typescript-eslint/types' +import type { AST } from 'svelte-eslint-parser' + +import path from 'path' + +import type { SortingNode } from '../typings' + +import { createEslintRule } from '../utils/create-eslint-rule' +import { rangeToDiff } from '../utils/range-to-diff' +import { SortOrder, SortType } from '../typings' +import { sortNodes } from '../utils/sort-nodes' +import { makeFixes } from '../utils/make-fixes' +import { complete } from '../utils/complete' +import { pairwise } from '../utils/pairwise' +import { compare } from '../utils/compare' + +type MESSAGE_ID = 'unexpectedSvelteAttributesOrder' + +type Group = + | 'svelte-shorthand' + | 'multiline' + | 'shorthand' + | 'unknown' + | T[number] + +type SortingNodeWithGroup = SortingNode & { + group: Group +} + +type Options = [ + Partial<{ + 'custom-groups': { [key in T[number]]: string[] | string } + groups: (Group[] | Group)[] + 'ignore-case': boolean + order: SortOrder + type: SortType + }>, +] + +export const RULE_NAME = 'sort-svelte-attributes' + +export default createEslintRule, MESSAGE_ID>({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'enforce sorted union types', + recommended: false, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + 'custom-groups': { + type: 'object', + }, + type: { + enum: [ + SortType.alphabetical, + SortType.natural, + SortType['line-length'], + ], + default: SortType.natural, + }, + order: { + enum: [SortOrder.asc, SortOrder.desc], + default: SortOrder.asc, + }, + 'ignore-case': { + type: 'boolean', + default: false, + }, + groups: { + type: 'array', + default: [], + }, + }, + additionalProperties: false, + }, + ], + messages: { + unexpectedSvelteAttributesOrder: + 'Expected "{{right}}" to come before "{{left}}"', + }, + }, + defaultOptions: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + create: context => { + if (path.extname(context.getFilename()) !== '.svelte') { + return {} + } + + return { + SvelteStartTag: (node: AST.SvelteStartTag) => { + if (node.attributes.length > 1) { + let options = complete(context.options.at(0), { + type: SortType.alphabetical, + order: SortOrder.asc, + 'ignore-case': false, + 'custom-groups': {}, + groups: [], + }) + + let source = context.getSourceCode() + + let parts: SortingNodeWithGroup[][] = + node.attributes.reduce( + (accumulator: SortingNodeWithGroup[][], attribute) => { + if (attribute.type === 'SvelteSpreadAttribute') { + accumulator.push([]) + return accumulator + } + + let name: string + + let group: Group | undefined + + let defineGroup = (nodeGroup: Group) => { + if (!group && options.groups.flat().includes(nodeGroup)) { + group = nodeGroup + } + } + + if (attribute.key.type === 'SvelteSpecialDirectiveKey') { + name = source.text.slice(...attribute.key.range) + } else { + if (typeof attribute.key.name === 'string') { + ;({ name } = attribute.key) + } else { + name = source.text.slice(...attribute.key.range!) + } + } + + if (attribute.type === 'SvelteShorthandAttribute') { + defineGroup('svelte-shorthand') + defineGroup('shorthand') + } + + if ( + !('value' in attribute) || + (Array.isArray(attribute.value) && !attribute.value.at(0)) + ) { + defineGroup('shorthand') + } + + if (attribute.loc.start.line !== attribute.loc.end.line) { + defineGroup('multiline') + } + + accumulator.at(-1)!.push({ + size: rangeToDiff(attribute.range), + node: attribute as unknown as TSESTree.Node, + group: group ?? 'unknown', + name, + }) + + return accumulator + }, + [[]], + ) + + let getGroupNumber = ( + nodeWithGroup: SortingNodeWithGroup, + ): number => { + for (let i = 0, max = options.groups.length; i < max; i++) { + let currentGroup = options.groups[i] + + if ( + nodeWithGroup.group === currentGroup || + (Array.isArray(currentGroup) && + currentGroup.includes(nodeWithGroup.group)) + ) { + return i + } + } + return options.groups.length + } + + for (let nodes of parts) { + pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(left) + let rightNum = getGroupNumber(right) + + if ( + leftNum > rightNum || + (leftNum === rightNum && compare(left, right, options)) + ) { + context.report({ + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: fixer => { + let grouped: { + [key: string]: SortingNodeWithGroup[] + } = {} + + for (let currentNode of nodes) { + let groupNum = getGroupNumber(currentNode) + + if (!(groupNum in grouped)) { + grouped[groupNum] = [currentNode] + } else { + grouped[groupNum] = sortNodes( + [...grouped[groupNum], currentNode], + options, + ) + } + } + + let sortedNodes: SortingNode[] = [] + + for (let group of Object.keys(grouped).sort()) { + sortedNodes.push(...sortNodes(grouped[group], options)) + } + + return makeFixes(fixer, nodes, sortedNodes, source) + }, + }) + } + }) + } + } + }, + } + }, +}) diff --git a/test/sort-svelte-attributes.test.ts b/test/sort-svelte-attributes.test.ts new file mode 100644 index 00000000..2295a8dc --- /dev/null +++ b/test/sort-svelte-attributes.test.ts @@ -0,0 +1,1213 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { describe, it } from 'vitest' +import { dedent } from 'ts-dedent' + +import rule, { RULE_NAME } from '../rules/sort-svelte-attributes' +import { SortOrder, SortType } from '../typings' + +describe(RULE_NAME, () => { + let ruleTester = new ESLintUtils.RuleTester({ + // @ts-ignore + parser: require.resolve('svelte-eslint-parser'), + parserOptions: { + parser: { + ts: '@typescript-eslint/parser', + }, + }, + }) + + describe(`${RULE_NAME}: sorting by alphabetical order`, () => { + let type = 'alphabetical-order' + + it(`${RULE_NAME}(${type}): sorts props in svelte components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'partner', + right: 'name', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'name', + right: 'age', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with spread attributes`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'lastName', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with directives`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} /> + {/if} + `, + output: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'on:click', + right: 'id', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'use:clickOutside', + right: 'on:outclick', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set shorthand attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + groups: ['unknown', ['svelte-shorthand', 'shorthand']], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + groups: ['unknown', ['svelte-shorthand', 'shorthand']], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'reincarnated', + right: 'isAlive', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'isAlive', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set multiline attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + frags={frags} + name="One-Punch Man" + realName="Saitama" + /> + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + groups: ['multiline', 'unknown'], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + name="One-Punch Man" + realName="Saitama" + /> + `, + output: dedent` + + + { + frags += 1 + }} + frags={frags} + name="One-Punch Man" + realName="Saitama" + /> + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + groups: ['multiline', 'unknown'], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'frags', + right: 'onAttack', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by natural order`, () => { + let type = 'natural-order' + + it(`${RULE_NAME}(${type}): sorts props in svelte components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'partner', + right: 'name', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'name', + right: 'age', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with spread attributes`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'lastName', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with directives`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} /> + {/if} + `, + output: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'on:click', + right: 'id', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'use:clickOutside', + right: 'on:outclick', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set shorthand attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + groups: ['unknown', 'shorthand'], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + groups: ['unknown', 'shorthand'], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'reincarnated', + right: 'isAlive', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'isAlive', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set multiline attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + frags={frags} + name="One-Punch Man" + realName="Saitama" + /> + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + groups: ['multiline', 'unknown'], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + name="One-Punch Man" + realName="Saitama" + /> + `, + output: dedent` + + + { + frags += 1 + }} + frags={frags} + name="One-Punch Man" + realName="Saitama" + /> + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + groups: ['multiline', 'unknown'], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'frags', + right: 'onAttack', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by line length`, () => { + let type = 'line-length-order' + + it(`${RULE_NAME}(${type}): sorts props in svelte components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'age', + right: 'name', + }, + }, + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'name', + right: 'partner', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with spread attributes`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'lastName', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): works with directives`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + {#if showParasite} + (showParasite = false)} /> + {/if} + `, + output: dedent` + + + + {#if showParasite} + (showParasite = false)} use:clickOutside /> + {/if} + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'use:clickOutside', + right: 'on:outclick', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set shorthand attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + groups: ['unknown', 'shorthand'], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + + `, + output: dedent` + + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + groups: ['unknown', ['svelte-shorthand', 'shorthand']], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'isAlive', + right: 'firstName', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to set multiline attributes position`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + name="One-Punch Man" + realName="Saitama" + frags={frags} + /> + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + groups: ['multiline', 'unknown'], + }, + ], + }, + ], + invalid: [ + { + filename: 'component.svelte', + code: dedent` + + + { + frags += 1 + }} + name="One-Punch Man" + realName="Saitama" + /> + `, + output: dedent` + + + { + frags += 1 + }} + name="One-Punch Man" + realName="Saitama" + frags={frags} + /> + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + groups: ['multiline', 'unknown'], + }, + ], + errors: [ + { + messageId: 'unexpectedSvelteAttributesOrder', + data: { + left: 'frags', + right: 'onAttack', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: misc`, () => { + it(`${RULE_NAME}: works only with .svelte files`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.ts', + code: dedent` + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [], + }) + }) + + it(`${RULE_NAME}: works with special directive keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.svelte', + code: dedent` + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [], + }) + }) + }) +})