From 1eaae09ecca359f366b94f6a04665403f48b05c7 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Mon, 24 Oct 2022 15:09:42 -0400 Subject: [PATCH] feat(eslint-plugin): [member-ordering] add natural sort order (#5662) * [WIP] feat(eslint-plugin): [member-ordering] add natural sort order * Fix yarn.lock and split option on case sensitivity * Document it too * Remove last todos * Move member-ordering sub-tests into sub-dirs --- .../docs/rules/member-ordering.md | 18 ++- packages/eslint-plugin/package.json | 2 + .../src/rules/member-ordering.ts | 53 +++++-- ...habetically-case-insensitive-order.test.ts | 6 +- ...mber-ordering-alphabetically-order.test.ts | 6 +- ...ing-natural-case-insensitive-order.test.ts | 135 ++++++++++++++++ .../member-ordering-natural-order.test.ts | 144 ++++++++++++++++++ yarn.lock | 10 ++ 8 files changed, 352 insertions(+), 22 deletions(-) rename packages/eslint-plugin/tests/rules/{ => member-ordering}/member-ordering-alphabetically-case-insensitive-order.test.ts (98%) rename packages/eslint-plugin/tests/rules/{ => member-ordering}/member-ordering-alphabetically-order.test.ts (99%) create mode 100644 packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-case-insensitive-order.test.ts create mode 100644 packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-order.test.ts diff --git a/packages/eslint-plugin/docs/rules/member-ordering.md b/packages/eslint-plugin/docs/rules/member-ordering.md index 9a6ca8d5dbc..7adde7ba9a6 100644 --- a/packages/eslint-plugin/docs/rules/member-ordering.md +++ b/packages/eslint-plugin/docs/rules/member-ordering.md @@ -24,7 +24,12 @@ type OrderConfig = MemberType[] | SortedOrderConfig | 'never'; interface SortedOrderConfig { memberTypes?: MemberType[] | 'never'; - order: 'alphabetically' | 'alphabetically-case-insensitive' | 'as-written'; + order: + | 'alphabetically' + | 'alphabetically-case-insensitive' + | 'as-written' + | 'natural' + | 'natural-case-insensitive'; } // See below for the more specific MemberType strings @@ -56,6 +61,17 @@ The supported member attributes are, in order: Member attributes may be joined with a `'-'` to combine into more specific groups. For example, `'public-field'` would come before `'private-field'`. +### Orders + +The `order` value specifies what order members should be within a group. +It defaults to `as-written`, meaning any order is fine. +Other allowed values are: + +- `alphabetically`: Sorted in a-z alphabetical order, directly using string `<` comparison (so `B` comes before `a`) +- `alphabetically-case-insensitive`: Sorted in a-z alphabetical order, ignoring case (so `a` comes before `B`) +- `natural`: Same as `alphabetically`, but using [`natural-compare-lite`](https://github.com/litejs/natural-compare-lite) for more friendly sorting of numbers +- `natural-case-insensitive`: Same as `alphabetically-case-insensitive`, but using [`natural-compare-lite`](https://github.com/litejs/natural-compare-lite) for more friendly sorting of numbers + ### Default configuration The default configuration looks as follows: diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index c977c64b8bc..eb1c777d115 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -49,6 +49,7 @@ "@typescript-eslint/utils": "5.41.0", "debug": "^4.3.4", "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", "regexpp": "^3.2.0", "semver": "^7.3.7", "tsutils": "^3.21.0" @@ -57,6 +58,7 @@ "@types/debug": "*", "@types/json-schema": "*", "@types/marked": "*", + "@types/natural-compare-lite": "^1.4.0", "@types/prettier": "*", "chalk": "^5.0.1", "json-schema": "*", diff --git a/packages/eslint-plugin/src/rules/member-ordering.ts b/packages/eslint-plugin/src/rules/member-ordering.ts index 66fe9b62334..3892c989bc9 100644 --- a/packages/eslint-plugin/src/rules/member-ordering.ts +++ b/packages/eslint-plugin/src/rules/member-ordering.ts @@ -1,5 +1,6 @@ import type { JSONSchema, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import naturalCompare from 'natural-compare-lite'; import * as util from '../util'; @@ -34,10 +35,13 @@ type BaseMemberType = type MemberType = BaseMemberType | BaseMemberType[]; -type Order = +type AlphabeticalOrder = | 'alphabetically' | 'alphabetically-case-insensitive' - | 'as-written'; + | 'natural' + | 'natural-case-insensitive'; + +type Order = AlphabeticalOrder | 'as-written'; interface SortedOrderConfig { memberTypes?: MemberType[] | 'never'; @@ -87,7 +91,13 @@ const objectConfig = (memberTypes: MemberType[]): JSONSchema.JSONSchema4 => ({ }, order: { type: 'string', - enum: ['alphabetically', 'alphabetically-case-insensitive', 'as-written'], + enum: [ + 'alphabetically', + 'alphabetically-case-insensitive', + 'as-written', + 'natural', + 'natural-case-insensitive', + ], }, }, additionalProperties: false, @@ -629,7 +639,7 @@ export default util.createRule({ */ function checkAlphaSort( members: Member[], - caseSensitive: boolean, + order: AlphabeticalOrder, ): boolean { let previousName = ''; let isCorrectlySorted = true; @@ -640,11 +650,7 @@ export default util.createRule({ // Note: Not all members have names if (name) { - if ( - caseSensitive - ? name < previousName - : name.toLowerCase() < previousName.toLowerCase() - ) { + if (naturalOutOfOrder(name, previousName, order)) { context.report({ node: member, messageId: 'incorrectOrder', @@ -664,6 +670,25 @@ export default util.createRule({ return isCorrectlySorted; } + function naturalOutOfOrder( + name: string, + previousName: string, + order: AlphabeticalOrder, + ): boolean { + switch (order) { + case 'alphabetically': + return name < previousName; + case 'alphabetically-case-insensitive': + return name.toLowerCase() < previousName.toLowerCase(); + case 'natural': + return naturalCompare(name, previousName) !== 1; + case 'natural-case-insensitive': + return ( + naturalCompare(name.toLowerCase(), previousName.toLowerCase()) !== 1 + ); + } + } + /** * Validates if all members are correctly sorted. * @@ -681,7 +706,7 @@ export default util.createRule({ } // Standardize config - let order: Order | null = null; + let order: Order | undefined; let memberTypes; if (Array.isArray(orderConfig)) { @@ -691,9 +716,7 @@ export default util.createRule({ memberTypes = orderConfig.memberTypes; } - const hasAlphaSort = order?.startsWith('alphabetically'); - const alphaSortIsCaseSensitive = - order !== 'alphabetically-case-insensitive'; + const hasAlphaSort = !!(order && order !== 'as-written'); // Check order if (Array.isArray(memberTypes)) { @@ -706,11 +729,11 @@ export default util.createRule({ if (hasAlphaSort) { grouped.some( groupMember => - !checkAlphaSort(groupMember, alphaSortIsCaseSensitive), + !checkAlphaSort(groupMember, order as AlphabeticalOrder), ); } } else if (hasAlphaSort) { - checkAlphaSort(members, alphaSortIsCaseSensitive); + checkAlphaSort(members, order as AlphabeticalOrder); } } diff --git a/packages/eslint-plugin/tests/rules/member-ordering-alphabetically-case-insensitive-order.test.ts b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-case-insensitive-order.test.ts similarity index 98% rename from packages/eslint-plugin/tests/rules/member-ordering-alphabetically-case-insensitive-order.test.ts rename to packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-case-insensitive-order.test.ts index d2600b17dc8..3eecfc999c9 100644 --- a/packages/eslint-plugin/tests/rules/member-ordering-alphabetically-case-insensitive-order.test.ts +++ b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-case-insensitive-order.test.ts @@ -1,8 +1,8 @@ import type { TSESLint } from '@typescript-eslint/utils'; -import type { MessageIds, Options } from '../../src/rules/member-ordering'; -import rule, { defaultOrder } from '../../src/rules/member-ordering'; -import { RuleTester } from '../RuleTester'; +import type { MessageIds, Options } from '../../../src/rules/member-ordering'; +import rule, { defaultOrder } from '../../../src/rules/member-ordering'; +import { RuleTester } from '../../RuleTester'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', diff --git a/packages/eslint-plugin/tests/rules/member-ordering-alphabetically-order.test.ts b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-order.test.ts similarity index 99% rename from packages/eslint-plugin/tests/rules/member-ordering-alphabetically-order.test.ts rename to packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-order.test.ts index 63ef55408e7..afa1a5df810 100644 --- a/packages/eslint-plugin/tests/rules/member-ordering-alphabetically-order.test.ts +++ b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-alphabetically-order.test.ts @@ -1,8 +1,8 @@ import type { TSESLint } from '@typescript-eslint/utils'; -import type { MessageIds, Options } from '../../src/rules/member-ordering'; -import rule, { defaultOrder } from '../../src/rules/member-ordering'; -import { RuleTester } from '../RuleTester'; +import type { MessageIds, Options } from '../../../src/rules/member-ordering'; +import rule, { defaultOrder } from '../../../src/rules/member-ordering'; +import { RuleTester } from '../../RuleTester'; const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', diff --git a/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-case-insensitive-order.test.ts b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-case-insensitive-order.test.ts new file mode 100644 index 00000000000..782fee826d5 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-case-insensitive-order.test.ts @@ -0,0 +1,135 @@ +import rule from '../../../src/rules/member-ordering'; +import { RuleTester } from '../../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('member-ordering-natural-order', rule, { + valid: [ + { + code: ` +interface Example { + 1: number; + 5: number; + 10: number; +} + `, + options: [ + { + default: { + order: 'natural-case-insensitive', + }, + }, + ], + }, + { + code: ` +interface Example { + new (): unknown; + + a1(): void; + a5(): void; + a10(): void; + B1(): void; + B5(): void; + B10(): void; + + a1: number; + a5: number; + a10: number; + B1: number; + B5: number; + B10: number; +} + `, + options: [ + { + default: { + memberTypes: ['constructor', 'method', 'field'], + order: 'natural-case-insensitive', + }, + }, + ], + }, + ], + invalid: [ + { + code: ` +interface Example { + 1: number; + 10: number; + 5: number; +} + `, + errors: [ + { + messageId: 'incorrectOrder', + data: { + beforeMember: 10, + member: 5, + }, + line: 5, + column: 3, + }, + ], + options: [ + { + default: { + order: 'natural-case-insensitive', + }, + }, + ], + }, + + { + code: ` +interface Example { + new (): unknown; + + a1(): void; + a10(): void; + a5(): void; + B5(): void; + B10(): void; + B1(): void; + + a5: number; + a10: number; + B1: number; + a1: number; + B5: number; + B10: number; +} + `, + errors: [ + { + column: 3, + data: { + beforeMember: 'a10', + member: 'a5', + }, + line: 7, + messageId: 'incorrectOrder', + }, + { + column: 3, + data: { + beforeMember: 'B10', + member: 'B1', + }, + line: 10, + messageId: 'incorrectOrder', + }, + ], + options: [ + { + default: { + memberTypes: ['constructor', 'method', 'field'], + order: 'natural-case-insensitive', + }, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-order.test.ts b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-order.test.ts new file mode 100644 index 00000000000..c34677a81c7 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/member-ordering/member-ordering-natural-order.test.ts @@ -0,0 +1,144 @@ +import rule from '../../../src/rules/member-ordering'; +import { RuleTester } from '../../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('member-ordering-natural-order', rule, { + valid: [ + { + code: ` +interface Example { + 1: number; + 5: number; + 10: number; +} + `, + options: [ + { + default: { + order: 'natural', + }, + }, + ], + }, + { + code: ` +interface Example { + new (): unknown; + + B1(): void; + B5(): void; + B10(): void; + a1(): void; + a5(): void; + a10(): void; + + B1: number; + B5: number; + B10: number; + a1: number; + a5: number; + a10: number; +} + `, + options: [ + { + default: { + memberTypes: ['constructor', 'method', 'field'], + order: 'natural', + }, + }, + ], + }, + ], + invalid: [ + { + code: ` +interface Example { + 1: number; + 10: number; + 5: number; +} + `, + errors: [ + { + messageId: 'incorrectOrder', + data: { + beforeMember: 10, + member: 5, + }, + line: 5, + column: 3, + }, + ], + options: [ + { + default: { + order: 'natural', + }, + }, + ], + }, + + { + code: ` +interface Example { + new (): unknown; + + a1(): void; + a10(): void; + a5(): void; + B5(): void; + B10(): void; + B1(): void; + + a5: number; + a10: number; + B1: number; + a1: number; + B5: number; + B10: number; +} + `, + errors: [ + { + column: 3, + data: { + beforeMember: 'a10', + member: 'a5', + }, + line: 7, + messageId: 'incorrectOrder', + }, + { + column: 3, + data: { + beforeMember: 'a5', + member: 'B5', + }, + line: 8, + messageId: 'incorrectOrder', + }, + { + column: 3, + data: { + beforeMember: 'B10', + member: 'B1', + }, + line: 10, + messageId: 'incorrectOrder', + }, + ], + options: [ + { + default: { + memberTypes: ['constructor', 'method', 'field'], + order: 'natural', + }, + }, + ], + }, + ], +}); diff --git a/yarn.lock b/yarn.lock index 2c88e05570a..1e719261af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4112,6 +4112,11 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== +"@types/natural-compare-lite@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@types/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#90724682da3c304dd8d643b4e9ba00f53f29d454" + integrity sha512-ZDcj/yWsRPacqKPpCExWWFq9X1JQwEOfHsu8deN1Qfa6M02z6tN4DK6AMf2IkM7709gp3QW6Bo7m2NFDhA485w== + "@types/ncp@^2.0.5": version "2.0.5" resolved "https://registry.yarnpkg.com/@types/ncp/-/ncp-2.0.5.tgz#5c53b229a321946102a188b603306162137f4fb9" @@ -10327,6 +10332,11 @@ nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +natural-compare-lite@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" + integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"