From 6dcb4258ca8e47894a4130c04dfc2f3a0556a6c9 Mon Sep 17 00:00:00 2001 From: Azat S Date: Tue, 23 May 2023 16:58:38 +0300 Subject: [PATCH] feat: add sort-object-keys rule --- docs/.vitepress/config.ts | 4 + docs/rules/index.md | 1 + docs/rules/sort-object-keys.md | 119 +++++++ index.ts | 3 + readme.md | 1 + rules/sort-object-keys.ts | 123 +++++++ test/sort-object-keys.test.ts | 625 +++++++++++++++++++++++++++++++++ 7 files changed, 876 insertions(+) create mode 100644 docs/rules/sort-object-keys.md create mode 100644 rules/sort-object-keys.ts create mode 100644 test/sort-object-keys.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c036b72f..14165598 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -222,6 +222,10 @@ export default defineConfig({ text: 'sort-named-imports', link: '/rules/sort-named-imports', }, + { + text: 'sort-object-keys', + link: '/rules/sort-object-keys', + }, { text: 'sort-union-types', link: '/rules/sort-union-types', diff --git a/docs/rules/index.md b/docs/rules/index.md index 6e93335f..fa857b89 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -14,4 +14,5 @@ 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-named-object-keys](/rules/sort-object-keys) | Enforce sorted object keys | ✅ | 🔧 | | [sort-named-union-types](/rules/sort-union-types) | Enforce sorted union types | ✅ | 🔧 | diff --git a/docs/rules/sort-object-keys.md b/docs/rules/sort-object-keys.md new file mode 100644 index 00000000..ef3963c5 --- /dev/null +++ b/docs/rules/sort-object-keys.md @@ -0,0 +1,119 @@ +--- +title: sort-object-keys +--- + +# sort-object-keys + +> Enforce sorted object keys. + +## 💡 Examples + +### Natural sorting + + +```ts +// Incorrect +let family = { + dad: 'Loid Forger', + mom: 'Yor Forger', + daughter: 'Anya Forger', +} + +// Correct +let family = { + dad: 'Loid Forger', + daughter: 'Anya Forger', + mom: 'Yor Forger', +} +``` + +### Sorting by line length + + +```ts +// Incorrect +let family = { + dad: 'Loid Forger', + mom: 'Yor Forger', + daughter: 'Anya Forger', +} + +// Correct +let family = { + daughter: 'Anya Forger', + dad: 'Loid Forger', + mom: 'Yor Forger', +} +``` + +## 🔧 Options + +### `type` + +- `enum` (default: `natural`): + - `natural` - sorting, which is similar to alphabetical order. + - `line-length` - sort by code line length. + +### `order` + +- `enum` (default: `asc`): + - `asc` - enforce properties to be in ascending order. + - `desc` - enforce properties to be in descending order. + +## ⚙️ Usage + +:::tip +If you use the `sort-keys` rule, you should disable it, as it may conflict with the current rule. +::: + +### Legacy config + +```json +// .eslintrc +{ + "rules": { + "perfectionist/sort-object-keys": [ + "error", + { + "type": "line-length", + "order": "desc", + "spreadLast": true + } + ] + } +} +``` + +### Flat config + +```js +// eslint.config.js +import perfectionist from 'eslint-plugin-perfectionist' + +export default [ + { + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-object-keys': [ + 'error', + { + type: 'line-length', + order: 'desc', + spreadLast: true, + }, + ], + }, + }, +] +``` + +## 🚀 Version + +Coming soon. + +## 📚 Resources + +- [Rule source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/rules/sort-object-keys.ts) +- [Test source](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/test/sort-object-keys.test.ts) diff --git a/index.ts b/index.ts index e24e8172..ea59c3e1 100644 --- a/index.ts +++ b/index.ts @@ -4,6 +4,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 sortObjectKeys, { RULE_NAME as sortObjectKeysName } from '~/rules/sort-object-keys' import sortUnionTypes, { RULE_NAME as sortUnionTypesName } from '~/rules/sort-union-types' import { SortType, SortOrder } from '~/typings' import { name } from '~/package.json' @@ -22,6 +23,7 @@ let getRulesWithOptions = (options: { [sortMapElementsName]: ['error'], [sortNamedExportsName]: ['error'], [sortNamedImportsName]: ['error'], + [sortObjectKeysName]: ['error'], [sortUnionTypesName]: ['error'], } return Object.fromEntries( @@ -41,6 +43,7 @@ export default { [sortMapElementsName]: sortMapElements, [sortNamedExportsName]: sortNamedExports, [sortNamedImportsName]: sortNamedImports, + [sortObjectKeysName]: sortObjectKeys, [sortUnionTypesName]: sortUnionTypes, }, configs: { diff --git a/readme.md b/readme.md index 3cfd6089..1a8127de 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,7 @@ npm install --save-dev eslint-plugin-perfectionist | [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-keys](https://eslint-plugin-perfectionist.azat.io/rules/sort-object-keys) | Enforce sorted object keys | ✅ | 🔧 | | [sort-union-types](https://eslint-plugin-perfectionist.azat.io/rules/sort-union-types) | Enforce sorted union types | ✅ | 🔧 | ## See also diff --git a/rules/sort-object-keys.ts b/rules/sort-object-keys.ts new file mode 100644 index 00000000..588e85d6 --- /dev/null +++ b/rules/sort-object-keys.ts @@ -0,0 +1,123 @@ +import type { TSESTree } from '@typescript-eslint/types' + +import { AST_NODE_TYPES } from '@typescript-eslint/types' + +import { createEslintRule } from '~/utils/create-eslint-rule' +import { rangeToDiff } from '~/utils/range-to-diff' +import { SortType, SortOrder } from '~/typings' +import { sortNodes } from '~/utils/sort-nodes' +import type { SortingNode } from '~/typings' +import { complete } from '~/utils/complete' +import { compare } from '~/utils/compare' + +type MESSAGE_ID = 'unexpectedObjectKeysOrder' + +type Options = [ + Partial<{ + order: SortOrder + type: SortType + }>, +] + +export const RULE_NAME = 'sort-object-keys' + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce sorted object keys', + recommended: false, + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + type: { + enum: [SortType.natural, SortType['line-length']], + default: SortType.natural, + }, + order: { + enum: [SortOrder.asc, SortOrder.desc], + default: SortOrder.asc, + }, + }, + additionalProperties: false, + }, + ], + messages: { + unexpectedObjectKeysOrder: 'Expected "{{second}}" to come before "{{first}}"', + }, + }, + defaultOptions: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + create: context => ({ + ObjectExpression: node => { + if (node.properties.length > 1) { + let options = complete(context.options.at(0), { + type: SortType.natural, + order: SortOrder.asc, + }) + + let source = context.getSourceCode().text + + let formatProperties = (props: TSESTree.ObjectLiteralElement[]): SortingNode[][] => + props.reduce( + (accumulator: SortingNode[][], prop) => { + if (prop.type === AST_NODE_TYPES.SpreadElement) { + accumulator.push([]) + return accumulator + } + + let name: string + + if (prop.key.type === AST_NODE_TYPES.Identifier) { + ;({ name } = prop.key) + } else if (prop.key.type === AST_NODE_TYPES.Literal) { + name = `${prop.key.value}` + } else { + name = source.slice(...prop.key.range) + } + + let value = { + size: rangeToDiff(prop.range), + node: prop, + name, + } + + accumulator.at(-1)!.push(value) + + return accumulator + }, + [[]], + ) + + formatProperties(node.properties).forEach(values => { + if (values.length > 1) { + for (let i = 1; i < values.length; i++) { + let first = values.at(i - 1)! + let second = values.at(i)! + + if (compare(first, second, options)) { + context.report({ + messageId: 'unexpectedObjectKeysOrder', + data: { + first: first.name, + second: second.name, + }, + node: second.node, + fix: fixer => sortNodes(fixer, source, values, options), + }) + } + } + } + }) + } + }, + }), +}) diff --git a/test/sort-object-keys.test.ts b/test/sort-object-keys.test.ts new file mode 100644 index 00000000..329b8393 --- /dev/null +++ b/test/sort-object-keys.test.ts @@ -0,0 +1,625 @@ +import { ESLintUtils } from '@typescript-eslint/utils' +import { describe, it } from 'vitest' +import { dedent } from 'ts-dedent' + +import rule, { RULE_NAME } from '~/rules/sort-object-keys' +import { SortType, SortOrder } from '~/typings' + +describe(RULE_NAME, () => { + let ruleTester = new ESLintUtils.RuleTester({ + parser: '@typescript-eslint/parser', + }) + + describe(`${RULE_NAME}: sorting by natural order`, () => { + let type = 'natural-order' + + it(`${RULE_NAME}(${type}): sorts object with identifier and literal keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let wisewolf = { + age: undefined, + 'eye-color': '#f00', + [hometown]: 'Yoitsu', + name: 'Holo', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let wisewolf = { + age: undefined, + [hometown]: 'Yoitsu', + 'eye-color': '#f00', + name: 'Holo', + } + `, + output: dedent` + let wisewolf = { + age: undefined, + 'eye-color': '#f00', + [hometown]: 'Yoitsu', + name: 'Holo', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'hometown', + second: 'eye-color', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorting does not break object`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let bebop = { + dog: 'Ein', + hunter: 'Spike Spiegel', + ...teamMembers, + hacker: 'Ed', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let bebop = { + hunter: 'Spike Spiegel', + dog: 'Ein', + ...teamMembers, + hacker: 'Ed', + } + `, + output: dedent` + let bebop = { + dog: 'Ein', + hunter: 'Spike Spiegel', + ...teamMembers, + hacker: 'Ed', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'hunter', + second: 'dog', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts objects in objects`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let enforcers = { + 'akane-tsunemori': { + age: 20, + 'crime-coefficient': 28, + }, + 'nobuchika-ginoza': { + age: 28, + 'crime-coefficient': 86.3, + }, + 'shinya-kogami': { + age: 28, + 'crime-coefficient': 282.6, + }, + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let enforcers = { + 'akane-tsunemori': { + 'crime-coefficient': 28, + age: 20, + }, + 'shinya-kogami': { + 'crime-coefficient': 282.6, + age: 28, + }, + 'nobuchika-ginoza': { + 'crime-coefficient': 86.3, + age: 28, + }, + } + `, + output: dedent` + let enforcers = { + 'akane-tsunemori': { + 'crime-coefficient': 28, + age: 20, + }, + 'nobuchika-ginoza': { + 'crime-coefficient': 86.3, + age: 28, + }, + 'shinya-kogami': { + 'crime-coefficient': 282.6, + age: 28, + }, + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'crime-coefficient', + second: 'age', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'crime-coefficient', + second: 'age', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'shinya-kogami', + second: 'nobuchika-ginoza', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'crime-coefficient', + second: 'age', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts objects computed keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let robots = { + 'eva-02': 'Asuka Langley Sohryu', + [getTestEva()]: 'Yui Ikari', + [robots[1]]: 'Rei Ayanami', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let robots = { + [robots[1]]: 'Rei Ayanami', + [getTestEva()]: 'Yui Ikari', + 'eva-02': 'Asuka Langley Sohryu', + } + `, + output: dedent` + let robots = { + 'eva-02': 'Asuka Langley Sohryu', + [getTestEva()]: 'Yui Ikari', + [robots[1]]: 'Rei Ayanami', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'robots[1]', + second: 'getTestEva()', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'getTestEva()', + second: 'eva-02', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: sorting by line length`, () => { + let type = 'line-length-order' + + it(`${RULE_NAME}(${type}): sorts object with identifier and literal keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let wisewolf = { + [hometown]: 'Yoitsu', + 'eye-color': '#f00', + age: undefined, + name: 'Holo', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let wisewolf = { + age: undefined, + [hometown]: 'Yoitsu', + 'eye-color': '#f00', + name: 'Holo', + } + `, + output: dedent` + let wisewolf = { + [hometown]: 'Yoitsu', + 'eye-color': '#f00', + age: undefined, + name: 'Holo', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'age', + second: 'hometown', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorting does not break object`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let bebop = { + hunter: 'Spike Spiegel', + dog: 'Ein', + ...teamMembers, + hacker: 'Ed', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let bebop = { + dog: 'Ein', + hunter: 'Spike Spiegel', + ...teamMembers, + hacker: 'Ed', + } + `, + output: dedent` + let bebop = { + hunter: 'Spike Spiegel', + dog: 'Ein', + ...teamMembers, + hacker: 'Ed', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'dog', + second: 'hunter', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts objects in objects`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let enforcers = { + 'nobuchika-ginoza': { + 'crime-coefficient': 86.3, + age: 28, + }, + 'shinya-kogami': { + 'crime-coefficient': 282.6, + age: 28, + }, + 'akane-tsunemori': { + 'crime-coefficient': 28, + age: 20, + }, + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let enforcers = { + 'shinya-kogami': { + age: 28, + 'crime-coefficient': 282.6, + }, + 'akane-tsunemori': { + age: 20, + 'crime-coefficient': 28, + }, + 'nobuchika-ginoza': { + age: 28, + 'crime-coefficient': 86.3, + }, + } + `, + output: dedent` + let enforcers = { + 'nobuchika-ginoza': { + age: 28, + 'crime-coefficient': 86.3, + }, + 'shinya-kogami': { + age: 28, + 'crime-coefficient': 282.6, + }, + 'akane-tsunemori': { + age: 20, + 'crime-coefficient': 28, + }, + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'age', + second: 'crime-coefficient', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'age', + second: 'crime-coefficient', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'akane-tsunemori', + second: 'nobuchika-ginoza', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'age', + second: 'crime-coefficient', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): sorts objects computed keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let robots = { + 'eva-02': 'Asuka Langley Sohryu', + [getTestEva()]: 'Yui Ikari', + [robots[1]]: 'Rei Ayanami', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let robots = { + [robots[1]]: 'Rei Ayanami', + [getTestEva()]: 'Yui Ikari', + 'eva-02': 'Asuka Langley Sohryu', + } + `, + output: dedent` + let robots = { + 'eva-02': 'Asuka Langley Sohryu', + [getTestEva()]: 'Yui Ikari', + [robots[1]]: 'Rei Ayanami', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'robots[1]', + second: 'getTestEva()', + }, + }, + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'getTestEva()', + second: 'eva-02', + }, + }, + ], + }, + ], + }) + }) + }) + + describe(`${RULE_NAME}: misc`, () => { + it(`${RULE_NAME}: sets natural asc sorting as default`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + dedent` + let family = { + dad: 'Loid Forger', + daughter: 'Anya Forger', + mom: 'Yor Forger', + } + `, + ], + invalid: [ + { + code: dedent` + let family = { + dad: 'Loid Forger', + mom: 'Yor Forger', + daughter: 'Anya Forger', + } + `, + output: dedent` + let family = { + dad: 'Loid Forger', + daughter: 'Anya Forger', + mom: 'Yor Forger', + } + `, + errors: [ + { + messageId: 'unexpectedObjectKeysOrder', + data: { + first: 'mom', + second: 'daughter', + }, + }, + ], + }, + ], + }) + }) + }) +})