diff --git a/docs/rules/index.md b/docs/rules/index.md index 19f4b12b..1cc264b8 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -12,6 +12,7 @@ description: ESLint Plugin Perfectionist list of rules | Name | Description | 🔧 | | :------------------------------------------------------ | :------------------------------------------ | :- | | [sort-array-includes](/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | +| [sort-astro-attributes](/rules/sort-astro-attributes) | enforce sorted union types | 🔧 | | [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 | 🔧 | diff --git a/docs/rules/sort-astro-attributes.md b/docs/rules/sort-astro-attributes.md new file mode 100644 index 00000000..9e466652 --- /dev/null +++ b/docs/rules/sort-astro-attributes.md @@ -0,0 +1,108 @@ +--- +title: sort-astro-attributes +description: ESLint Plugin Perfectionist rule which enforce sorted ES class members +--- + +# sort-astro-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 Astro elements. + +It's **safe**. The rule considers spread elements in an attributes list and does not break component functionality. + +## 🔧 Options + +This rule accepts an options object with the following properties: + +```ts +interface Options { + type?: 'alphabetical' | 'natural' | 'line-length' + order?: 'asc' | 'desc' + 'ignore-case'?: boolean +} +``` + +### 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. + +## ⚙️ Usage + +In order to start using this rule, you need to install additional dependency: + +- `astro-eslint-parser` + +::: code-group + +```json [Legacy Config] +// .eslintrc +{ + "plugins": ["perfectionist"], + "rules": { + "perfectionist/sort-astro-attributes": [ + "error", + { + "type": "natural", + "order": "asc" + } + ] + } +} +``` + +```js [Flat Config] +// eslint.config.js +import perfectionist from 'eslint-plugin-perfectionist' + +export default [ + { + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-astro-attributes': [ + 'error', + { + type: 'natural', + order: 'asc', + }, + ], + }, + }, +] +``` + +::: + +## 🚀 Version + +Coming soon. + +## 📚 Resources + +- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-astro-attributes.ts) +- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-astro-attributes.test.ts) diff --git a/index.ts b/index.ts index 1cae5941..9ff0adc8 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,5 @@ import sortSvelteAttributes, { RULE_NAME as sortSvelteAttributesName } from './rules/sort-svelte-attributes' +import sortAstroAttributes, { RULE_NAME as sortAstroAttributesName } from './rules/sort-astro-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' @@ -92,6 +93,7 @@ let createConfigWithOptions = (options: { }, ], [sortSvelteAttributesName]: ['error'], + [sortAstroAttributesName]: ['error'], [sortNamedExportsName]: ['error'], [sortNamedImportsName]: ['error'], [sortObjectTypesName]: ['error'], @@ -116,6 +118,7 @@ let createConfigWithOptions = (options: { export default { rules: { [sortArrayIncludesName]: sortArrayIncludes, + [sortAstroAttributesName]: sortAstroAttributes, [sortClassesName]: sortClasses, [sortEnumsName]: sortEnums, [sortExportsName]: sortExports, diff --git a/package.json b/package.json index be262e3f..ae0e7e17 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,9 @@ "./package.json": "./package.json" }, "peerDependenciesMeta": { + "astro-eslint-parser": { + "optional": true + }, "svelte": { "optional": true }, @@ -59,7 +62,9 @@ "optional": true } }, + "peerDependencies": { + "astro-eslint-parser": "^0.14.0", "eslint": ">=8.0.0", "svelte": ">=3.0.0", "svelte-eslint-parser": "^0.32.0" @@ -81,6 +86,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitest/coverage-v8": "^0.33.0", + "astro-eslint-parser": "^0.14.0", "changelogen": "^0.5.4", "clean-publish": "^4.2.0", "eslint": "^8.45.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af217f1d..eeef8845 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ devDependencies: '@vitest/coverage-v8': specifier: ^0.33.0 version: 0.33.0(vitest@0.33.0) + astro-eslint-parser: + specifier: ^0.14.0 + version: 0.14.0 changelogen: specifier: ^0.5.4 version: 0.5.4 @@ -273,6 +276,10 @@ packages: '@jridgewell/trace-mapping': 0.3.18 dev: true + /@astrojs/compiler@1.6.1: + resolution: {integrity: sha512-QZZhV2kOhvyXEfpaGLgHWNPUOlkMVx3Zw+5IlXXU2p448NwtyYt3C6MuS7baVsTlluH3EedAoDBmelt9hPhfiQ==} + dev: true + /@azat-io/eslint-config-typescript@1.1.1(@typescript-eslint/eslint-plugin@5.62.0)(@typescript-eslint/parser@5.62.0)(eslint-plugin-import@2.27.5)(eslint-plugin-n@16.0.1)(eslint-plugin-perfectionist@1.5.0)(eslint-plugin-prefer-arrow@1.2.3)(eslint-plugin-prefer-let@3.0.1)(eslint-plugin-promise@6.1.1)(eslint-plugin-sonarjs@0.19.0)(eslint-plugin-unicorn@47.0.0)(eslint-plugin-vitest@0.2.6)(eslint@8.45.0): resolution: {integrity: sha512-OvN3/IgvxZfWwo7LibT3L+9WEUq1NGi5QVKcKj4x9EyNdOJz6T8CfiYHLsDZXBajHaE1X4/7HC/c9H/E3buKBg==} peerDependencies: @@ -1561,6 +1568,32 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /astro-eslint-parser@0.14.0: + resolution: {integrity: sha512-3F8l1h7+5MNxzDg1cSQxEloalG7fj64K6vOERChUVG7RLnAzSoafADnPQlU8DpMM3WRNfRHSC4NwUCORk/aPrA==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@astrojs/compiler': 1.6.1 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + astrojs-compiler-sync: 0.3.3(@astrojs/compiler@1.6.1) + debug: 4.3.4 + eslint-visitor-keys: 3.4.1 + espree: 9.6.1 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + dev: true + + /astrojs-compiler-sync@0.3.3(@astrojs/compiler@1.6.1): + resolution: {integrity: sha512-LbhchWgsvjvRBb5n5ez8/Q/f9ZKViuox27VxMDOdTUm8MRv9U7phzOiLue5KluqTmC0z1LId4gY2SekvoDrkuw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@astrojs/compiler': '>=0.27.0' + dependencies: + '@astrojs/compiler': 1.6.1 + synckit: 0.8.5 + dev: true + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} diff --git a/readme.md b/readme.md index beda31ca..427746c9 100644 --- a/readme.md +++ b/readme.md @@ -141,6 +141,7 @@ export default [ | Name | Description | 🔧 | | :------------------------------------------------------------------------------------------------- | :------------------------------------------ | :- | | [sort-array-includes](https://eslint-plugin-perfectionist.azat.io/rules/sort-array-includes) | enforce sorted arrays before include method | 🔧 | +| [sort-astro-attributes](https://eslint-plugin-perfectionist.azat.io/rules/sort-astro-attributes) | enforce sorted union types | 🔧 | | [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 | 🔧 | diff --git a/rules/sort-astro-attributes.ts b/rules/sort-astro-attributes.ts new file mode 100644 index 00000000..85f589fd --- /dev/null +++ b/rules/sort-astro-attributes.ts @@ -0,0 +1,133 @@ +import type { TSESTree } from '@typescript-eslint/types' +import type { AST } from 'astro-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 { makeFixes } from '../utils/make-fixes' +import { sortNodes } from '../utils/sort-nodes' +import { pairwise } from '../utils/pairwise' +import { complete } from '../utils/complete' +import { compare } from '../utils/compare' + +export const RULE_NAME = 'sort-astro-attributes' + +type MESSAGE_ID = 'unexpectedAstroAttributesOrder' + +type Options = [ + Partial<{ + 'ignore-case': boolean + order: SortOrder + type: SortType + }>, +] + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'enforce sorted union types', + recommended: false, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + 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, + }, + }, + additionalProperties: false, + }, + ], + messages: { + unexpectedAstroAttributesOrder: + 'Expected "{{right}}" to come before "{{left}}"', + }, + }, + defaultOptions: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + // @ts-ignore + create: context => { + if (path.extname(context.getFilename()) !== '.astro') { + return {} + } + + return { + JSXElement: (node: AST.JSXElement) => { + let { attributes } = node.openingElement + + if (attributes.length > 1) { + let options = complete(context.options.at(0), { + type: SortType.alphabetical, + order: SortOrder.asc, + 'ignore-case': false, + }) + + let source = context.getSourceCode() + + let parts: SortingNode[][] = attributes.reduce( + (accumulator: SortingNode[][], attribute) => { + if (attribute.type === 'JSXSpreadAttribute') { + accumulator.push([]) + return accumulator + } + + accumulator.at(-1)!.push({ + size: rangeToDiff(attribute.range), + node: attribute as unknown as TSESTree.Node, + name: + typeof attribute.name.name === 'string' + ? attribute.name.name + : source.text.slice(...attribute.name.range), + }) + + return accumulator + }, + [[]], + ) + + for (let nodes of parts) { + pairwise(nodes, (left, right) => { + if (compare(left, right, options)) { + context.report({ + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: left.name, + right: right.name, + }, + node: right.node, + fix: fixer => + makeFixes(fixer, nodes, sortNodes(nodes, options), source), + }) + } + }) + } + } + }, + } + }, +}) diff --git a/test/sort-astro-attributes.test.ts b/test/sort-astro-attributes.test.ts new file mode 100644 index 00000000..4bba147f --- /dev/null +++ b/test/sort-astro-attributes.test.ts @@ -0,0 +1,389 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { describe, it } from 'vitest' +import { dedent } from 'ts-dedent' + +import rule, { RULE_NAME } from '../rules/sort-astro-attributes' +import { SortOrder, SortType } from '../typings' + +describe(RULE_NAME, () => { + let ruleTester = new ESLintUtils.RuleTester({ + // @ts-ignore + parser: require.resolve('astro-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 astro components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'partner', + right: 'age', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'status', + right: 'occupation', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by natural order`, () => { + let type = 'natural-order' + + it(`${RULE_NAME}(${type}): sorts props in astro components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'partner', + right: 'age', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'status', + right: 'occupation', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by line length`, () => { + let type = 'line-length-order' + + it(`${RULE_NAME}(${type}): sorts props in astro components`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'name', + right: 'partner', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): split props intro groups if there is spreaded props`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + filename: 'component.astro', + code: dedent` + + + `, + output: dedent` + + + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedAstroAttributesOrder', + data: { + left: 'status', + right: 'occupation', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: misc`, () => { + it(`${RULE_NAME}: works only for .astro files`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + dedent` + + `, + ], + invalid: [], + }) + }) + }) +})