diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a9800953..8a40909d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -258,6 +258,10 @@ export default defineConfig({ text: 'sort-named-imports', link: '/rules/sort-named-imports', }, + { + text: 'sort-object-types', + link: '/rules/sort-object-types', + }, { text: 'sort-objects', link: '/rules/sort-objects', diff --git a/docs/rules/index.md b/docs/rules/index.md index feaf6b99..96175817 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -18,6 +18,7 @@ title: Rules | [sort-map-elements](/rules/sort-map-elements) | 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 | 🔧 | diff --git a/docs/rules/sort-object-types.md b/docs/rules/sort-object-types.md new file mode 100644 index 00000000..9a92561b --- /dev/null +++ b/docs/rules/sort-object-types.md @@ -0,0 +1,146 @@ +--- +title: sort-object-types +--- + +# sort-object-types + +💼 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 object types. + +This rule standardizes the order of members of an object type in a TypeScript. The order in which the members are defined within an object type does not affect the type system or the behavior of the code. + +## 💡 Examples + +### Alphabetical and Natural Sorting + + +```ts +// Incorrect +type User = { + name: string + email: string + role: Role + isAdmin: boolean +} + +// Correct +type User = { + email: string + isAdmin: boolean + name: string + role: Role +} +``` + +### Sorting by Line Length + + +```ts +// Incorrect +type User = { + name: string + email: string + role: Role + isAdmin: boolean +} + +// Correct +type User = { + isAdmin: boolean + email: string + name: string + role: Role +} +``` + +## 🔧 Options + +This rule accepts an options object with the following properties: + +```ts +interface Options { + type?: 'alphabetical' | 'natural' | 'natural' + 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 + +### Legacy Config + +```json +// .eslintrc +{ + "rules": { + "perfectionist/sort-object-types": [ + "error", + { + "type": "line-length", + "order": "desc" + } + ] + } +} +``` + +### Flat Config + +```js +// eslint.config.js +import perfectionist from 'eslint-plugin-perfectionist' + +export default [ + { + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-object-types': [ + 'error', + { + type: 'line-length', + order: 'desc', + }, + ], + }, + }, +] +``` + +## 🚀 Version + +Coming soon. + +## 📚 Resources + +- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-object-types.ts) +- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-object-types.test.ts) diff --git a/index.ts b/index.ts index 4bcd2246..54b46d50 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,7 @@ import sortJsxProps, { RULE_NAME as sortJsxPropsName } from './rules/sort-jsx-pr import sortMapElements, { RULE_NAME as sortMapElementsName } from './rules/sort-map-elements' import sortNamedExports, { RULE_NAME as sortNamedExportsName } from './rules/sort-named-exports' import sortNamedImports, { RULE_NAME as sortNamedImportsName } from './rules/sort-named-imports' +import sortObjectTypes, { RULE_NAME as sortObjectTypesName } from './rules/sort-object-types' import sortObjects, { RULE_NAME as sortObjectsName } from './rules/sort-objects' import sortUnionTypes, { RULE_NAME as sortUnionTypesName } from './rules/sort-union-types' import { SortType, SortOrder } from './typings' @@ -66,6 +67,7 @@ let createConfigWithOptions = (options: { [sortMapElementsName]: ['error'], [sortNamedExportsName]: ['error'], [sortNamedImportsName]: ['error'], + [sortObjectTypesName]: ['error'], [sortObjectsName]: [ 'error', { @@ -96,6 +98,7 @@ export default { [sortMapElementsName]: sortMapElements, [sortNamedExportsName]: sortNamedExports, [sortNamedImportsName]: sortNamedImports, + [sortObjectTypesName]: sortObjectTypes, [sortObjectsName]: sortObjects, [sortUnionTypesName]: sortUnionTypes, }, diff --git a/readme.md b/readme.md index 7b16f5ec..3a316b02 100644 --- a/readme.md +++ b/readme.md @@ -135,6 +135,7 @@ export default [perfectionistPluginRecommendedLineLength] | [sort-map-elements](https://eslint-plugin-perfectionist.azat.io/rules/sort-map-elements) | 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 | 🔧 | diff --git a/rules/sort-object-types.ts b/rules/sort-object-types.ts new file mode 100644 index 00000000..903ba706 --- /dev/null +++ b/rules/sort-object-types.ts @@ -0,0 +1,129 @@ +import type { SortingNode } from '../typings' + +import { AST_NODE_TYPES } from '@typescript-eslint/types' + +import { createEslintRule } from '../utils/create-eslint-rule' +import { toSingleLine } from '../utils/to-single-line' +import { rangeToDiff } from '../utils/range-to-diff' +import { SortType, SortOrder } 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 = 'unexpectedObjectTypesOrder' + +type Options = [ + Partial<{ + 'ignore-case': boolean + order: SortOrder + type: SortType + }>, +] + +export const RULE_NAME = 'sort-object-types' + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'enforce sorted object 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: { + unexpectedObjectTypesOrder: + 'Expected "{{second}}" to come before "{{first}}"', + }, + }, + defaultOptions: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + create: context => ({ + TSTypeLiteral: node => { + if (node.members.length > 1) { + let options = complete(context.options.at(0), { + type: SortType.alphabetical, + 'ignore-case': false, + order: SortOrder.asc, + }) + + let source = context.getSourceCode() + + let nodes: SortingNode[] = node.members.map(member => { + let name: string + + if (member.type === AST_NODE_TYPES.TSPropertySignature) { + if (member.key.type === AST_NODE_TYPES.Identifier) { + ;({ name } = member.key) + } else if (member.key.type === AST_NODE_TYPES.Literal) { + name = `${member.key.value}` + } else { + name = source.text.slice( + member.range.at(0), + member.typeAnnotation?.range.at(0), + ) + } + } else if (member.type === AST_NODE_TYPES.TSIndexSignature) { + let endIndex: number = + member.typeAnnotation?.range.at(0) ?? member.range.at(1)! + + name = source.text.slice(member.range.at(0), endIndex) + } else { + name = source.text.slice(member.range.at(0), member.range.at(1)) + } + + return { + size: rangeToDiff(member.range), + node: member, + name, + } + }) + + pairwise(nodes, (first, second) => { + if (compare(first, second, options)) { + context.report({ + messageId: 'unexpectedObjectTypesOrder', + data: { + first: toSingleLine(first.name), + second: toSingleLine(second.name), + }, + node: second.node, + fix: fixer => + makeFixes(fixer, nodes, sortNodes(nodes, options), source), + }) + } + }) + } + }, + }), +}) diff --git a/test/sort-object-types.test.ts b/test/sort-object-types.test.ts new file mode 100644 index 00000000..49af446b --- /dev/null +++ b/test/sort-object-types.test.ts @@ -0,0 +1,812 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { describe, it } from 'vitest' +import { dedent } from 'ts-dedent' + +import rule, { RULE_NAME } from '../rules/sort-object-types' +import { SortType, SortOrder } from '../typings' + +describe(RULE_NAME, () => { + let ruleTester = new ESLintUtils.RuleTester({ + parser: '@typescript-eslint/parser', + }) + + describe(`${RULE_NAME}: sorting by alphabetical order`, () => { + let type = 'alphabetical-order' + + it(`${RULE_NAME}(${type}): sorts type members`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type Mushishi = { + birthname: 'Yoki' + name: 'Ginko' + status: 'wanderer' + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Mushishi = { + name: 'Ginko' + birthname: 'Yoki' + status: 'wanderer' + } + `, + output: dedent` + type Mushishi = { + birthname: 'Yoki' + name: 'Ginko' + status: 'wanderer' + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'name', + second: 'birthname', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members in function args`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + demon: string + slayerName: string + }) => { + // ... + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + slayerName: string + attackType: string + demon: string + }) => { + // ... + } + `, + output: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + demon: string + slayerName: string + }) => { + // ... + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'slayerName', + second: 'attackType', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with computed keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type SquadMember = { + [key: string]: string + age?: 30 + name: 'Levi Ackermann' + occupation: 'soldier' + rank: 'captain' + [residence]: 'Wall Rose' + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type SquadMember = { + age?: 30 + [key: string]: string + occupation: 'soldier' + name: 'Levi Ackermann' + [residence]: 'Wall Rose' + rank: 'captain' + } + `, + output: dedent` + type SquadMember = { + [key: string]: string + age?: 30 + name: 'Levi Ackermann' + occupation: 'soldier' + rank: 'captain' + [residence]: 'Wall Rose' + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'age', + second: '[key: string]', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'occupation', + second: 'name', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'residence', + second: 'rank', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with any key types`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + [name in victims]? + [8]: Victim + goldenBatAttack(): void + hide?: () => void + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + goldenBatAttack(): void + [8]: Victim + hide?: () => void + } + `, + output: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + [8]: Victim + goldenBatAttack(): void + hide?: () => void + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'goldenBatAttack(): void', + second: '8', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by natural order`, () => { + let type = 'natural-order' + + it(`${RULE_NAME}(${type}): sorts type members`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type Mushishi = { + birthname: 'Yoki' + name: 'Ginko' + status: 'wanderer' + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Mushishi = { + name: 'Ginko' + birthname: 'Yoki' + status: 'wanderer' + } + `, + output: dedent` + type Mushishi = { + birthname: 'Yoki' + name: 'Ginko' + status: 'wanderer' + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'name', + second: 'birthname', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members in function args`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + demon: string + slayerName: string + }) => { + // ... + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + slayerName: string + attackType: string + demon: string + }) => { + // ... + } + `, + output: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + demon: string + slayerName: string + }) => { + // ... + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'slayerName', + second: 'attackType', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with computed keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type SquadMember = { + [key: string]: string + age?: 30 + name: 'Levi Ackermann' + occupation: 'soldier' + rank: 'captain' + [residence]: 'Wall Rose' + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type SquadMember = { + age?: 30 + [key: string]: string + occupation: 'soldier' + name: 'Levi Ackermann' + [residence]: 'Wall Rose' + rank: 'captain' + } + `, + output: dedent` + type SquadMember = { + [key: string]: string + age?: 30 + name: 'Levi Ackermann' + occupation: 'soldier' + rank: 'captain' + [residence]: 'Wall Rose' + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'age', + second: '[key: string]', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'occupation', + second: 'name', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'residence', + second: 'rank', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with any key types`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + [name in victims]? + [8]: Victim + goldenBatAttack(): void + hide?: () => void + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + goldenBatAttack(): void + [8]: Victim + hide?: () => void + } + `, + output: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + [8]: Victim + goldenBatAttack(): void + hide?: () => void + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'goldenBatAttack(): void', + second: '8', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by line length`, () => { + let type = 'line-length-order' + + it(`${RULE_NAME}(${type}): sorts type members`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type Mushishi = { + status: 'wanderer' + birthname: 'Yoki' + name: 'Ginko' + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type Mushishi = { + name: 'Ginko' + birthname: 'Yoki' + status: 'wanderer' + } + `, + output: dedent` + type Mushishi = { + status: 'wanderer' + birthname: 'Yoki' + name: 'Ginko' + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'name', + second: 'birthname', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'birthname', + second: 'status', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members in function args`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + slayerName: string + demon: string + }) => { + // ... + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let handleDemonSlayerAttack = (attack: { + slayerName: string + demon: string + attackType: string + }) => { + // ... + } + `, + output: dedent` + let handleDemonSlayerAttack = (attack: { + attackType: string + slayerName: string + demon: string + }) => { + // ... + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'demon', + second: 'attackType', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with computed keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type SquadMember = { + [residence]: 'Wall Rose' + name: 'Levi Ackermann' + [key: string]: string + occupation: 'soldier' + rank: 'captain' + age?: 30 + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type SquadMember = { + age?: 30 + [key: string]: string + occupation: 'soldier' + name: 'Levi Ackermann' + [residence]: 'Wall Rose' + rank: 'captain' + } + `, + output: dedent` + type SquadMember = { + [residence]: 'Wall Rose' + name: 'Levi Ackermann' + occupation: 'soldier' + [key: string]: string + rank: 'captain' + age?: 30 + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'age', + second: '[key: string]', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'occupation', + second: 'name', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: 'name', + second: 'residence', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts type members with any key types`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + type ParanoiaAgent = { + goldenBatAttack(): void + hide?: () => void + [[data]]: string + [8]: Victim + [...kills] + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + type ParanoiaAgent = { + [...kills] + [[data]]: string + goldenBatAttack(): void + [8]: Victim + hide?: () => void + } + `, + output: dedent` + type ParanoiaAgent = { + goldenBatAttack(): void + hide?: () => void + [[data]]: string + [8]: Victim + [...kills] + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: '[...kills]', + second: '[[data]]', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: '[[data]]', + second: 'goldenBatAttack(): void', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + first: '8', + second: 'hide', + }, + }, + ], + }, + ], + }) + }) + }) +})