From df802881ef16d9169f302206aa605cd8cd7709de Mon Sep 17 00:00:00 2001 From: adamhamlin Date: Sun, 5 Feb 2023 02:03:36 -0500 Subject: [PATCH] feat: Add `opt-in-sort` rule (#5) --- README.md | 5 +- __tests__/misc-utils.test.ts | 15 + __tests__/rules/opt-in-sort.test.ts | 803 ++++++++++++++++++++++++++++ docs/rules/opt-in-sort.md | 139 +++++ index.ts | 2 + package.json | 13 +- src/rules/opt-in-sort.ts | 89 +++ src/utils/annotations.ts | 36 ++ src/utils/fix.ts | 24 + src/utils/misc-utils.ts | 17 + src/utils/sorting.ts | 170 ++++++ src/utils/sorting.types.ts | 47 ++ 12 files changed, 1357 insertions(+), 3 deletions(-) create mode 100644 __tests__/misc-utils.test.ts create mode 100644 __tests__/rules/opt-in-sort.test.ts create mode 100644 docs/rules/opt-in-sort.md create mode 100644 src/rules/opt-in-sort.ts create mode 100644 src/utils/annotations.ts create mode 100644 src/utils/fix.ts create mode 100644 src/utils/misc-utils.ts create mode 100644 src/utils/sorting.ts create mode 100644 src/utils/sorting.types.ts diff --git a/README.md b/README.md index c89f6a0..4d78c3c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # ESLint Plugin +[![npm version](https://badge.fury.io/js/eslint-plugin-adamhamlin.svg)](https://badge.fury.io/js/eslint-plugin-adamhamlin) [![CI Status Badge](https://github.com/adamhamlin/eslint-plugin/actions/workflows/ci.yaml/badge.svg)](https://github.com/adamhamlin/eslint-plugin/actions/workflows/ci.yaml) My collection of miscellaneous custom ESLint rules! @@ -26,6 +27,7 @@ Then configure the rules you want to use under the rules section. { "rules": { "adamhamlin/no-empty-block-comment": "error", + "adamhamlin/opt-in-sort": "error", "adamhamlin/forbid-pattern-everywhere": [ "error", { @@ -40,8 +42,9 @@ Then configure the rules you want to use under the rules section. This plugin makes the following lint rules available: -- [forbid-pattern-everywhere](./docs/rules/forbid-pattern-everywhere.md) - Specified pattern(s) are disallowed **everywhere**--in variables, functions, literals, property names, classes, types, interfaces, etc. - [no-emppty-block-comment](./docs/rules/no-empty-block-comment.md) - Block comments must have non-empty content. +- [opt-in-sort](./docs/rules/opt-in-sort.md) - Enforce sorting of object keys, array values, or TS enums/interfaces/types by adding the `@sort` annotation. +- [forbid-pattern-everywhere](./docs/rules/forbid-pattern-everywhere.md) - Specified pattern(s) are disallowed **everywhere**--in variables, functions, literals, property names, classes, types, interfaces, etc. diff --git a/__tests__/misc-utils.test.ts b/__tests__/misc-utils.test.ts new file mode 100644 index 0000000..4d29a73 --- /dev/null +++ b/__tests__/misc-utils.test.ts @@ -0,0 +1,15 @@ +import * as MiscUtils from '../src/utils/misc-utils'; + +describe('Misc Utils', () => { + describe('assertIsDefined', () => { + it('No error when value is defined', () => { + expect(() => MiscUtils.assertDefined(5)).not.toThrow(); + }); + + it('Assertion error thrown when value is nullish', () => { + const expectedError = new MiscUtils.AssertionError('Value is null or undefined.'); + expect(() => MiscUtils.assertDefined(null)).toThrow(expectedError); + expect(() => MiscUtils.assertDefined(undefined)).toThrow(expectedError); + }); + }); +}); diff --git a/__tests__/rules/opt-in-sort.test.ts b/__tests__/rules/opt-in-sort.test.ts new file mode 100644 index 0000000..9e811f1 --- /dev/null +++ b/__tests__/rules/opt-in-sort.test.ts @@ -0,0 +1,803 @@ +import { TestCaseError } from '@typescript-eslint/utils/dist/ts-eslint'; +import dedent from 'dedent'; + +import { name, rule } from '../../src/rules/opt-in-sort'; +import { ruleTester } from '../rule-tester'; + +describe('Rule Tests', () => { + // NOTE: Pattern-matching to make testing in isolation easier. Add specific regexes + // below as desired for troubleshooting + const testPatternsToMatch: RegExp[] = []; + + function filterTests(testCases: T[]): T[] { + return testCases.filter((test) => { + return testPatternsToMatch.length === 0 || testPatternsToMatch.some((pattern) => pattern.test(test.code)); + }); + } + + function getErrors(entityType: string, expected: string, actual: string): TestCaseError<'unsortedKeysOrValues'>[] { + return [{ messageId: 'unsortedKeysOrValues', data: { entityType, expected, actual } }]; + } + + // Simple helper to build code block from list of lines. + // This is needed to get proper parsing of template literals w/ expressions + function buildLines(lines: string[]): string { + return lines.join('\n'); + } + + ruleTester.run(name, rule, { + valid: filterTests([ + { + code: dedent` + // Basic coverage for no annotations + const myObj = { + b: '', + a: '', + c: '', + }; + `, + }, + { + code: dedent` + // Basic keys and values + // @sort + const myObj = { + a: '', + b: '', + c: '', + d: [ + 'apple', + 'banana', + 'cherry', + ], + }; + `, + }, + { + code: dedent` + // Nested objects, with nested overrides (using option 'reverse') + // @sort + const myObj = { + a: '', + b: '', + c: '', + d: { + e: '', + f: '', + g: '', + // @sort:reverse + h: { + k: '', + j: '', + i: '', + } + } + }; + `, + }, + { + code: dedent` + // Using option 'none' + // @sort + const myObj = { + a: '', + b: '', + c: '', + // @sort:none + d: { + f: '', + g: '', + e: '', + } + }; + `, + }, + { + code: dedent` + // Using option 'shallow' + // @sort:shallow + const myObj = { + a: '', + b: '', + c: '', + d: { + f: '', + e: '', + g: '', + } + }; + `, + }, + { + code: dedent` + // Using option 'none' and 'shallow' + // @sort + const myObj = { + a: '', + b: '', + c: '', + // @sort:none:shallow + d: { + f: '', + g: '', + e: '', + h: { + i: '', + j: '', + k: '' + } + } + }; + `, + }, + { + code: dedent` + // Case insensitive, but uppercase first, numeric order respected + // @sort + const myObj = { + 1: '', + 2: '', + 10: '', + A: '', + a: '', + B: '', + b: '', + }; + `, + }, + { + code: dedent` + // Using option 'keys' + // @sort:keys + const myObj = { + a: '', + b: '', + c: '', + d: [ + 'banana', + 'apple', + 'cherry', + ], + }; + `, + }, + { + code: dedent` + // Using option 'values' + // @sort:values + const myObj = { + b: '', + a: '', + c: '', + d: [ + 'apple', + 'banana', + 'cherry', + ], + }; + `, + }, + { + code: dedent` + // Using programmatic keys, non-literal array values + const keyA = 'a'; + const keyB = 'b'; + const APPLE = 'zzz_apple'; + const BANANA = 'aaa_banana' + // @sort + const myObj = { + [keyA]: '', + [keyB]: [ + APPLE, + BANANA, + ], + }; + `, + }, + { + code: dedent` + // Using long member expressions + const ref = { + a1: { + b1: { + c1: { + key: 'myKey', + val: 'myValue' + }, + c2: { + key: 'myOtherKey', + } + }, + b2: { + val: 'myValue' + } + } + }; + // @sort + const myObj = { + [ref.a1.b1.c1.key]: '', + [ref.a1.b1.c2.key]: [ + ref.a1.b1.c1.val, + ref.a1.b2.val, + ], + }; + `, + }, + { + code: dedent` + // Sort enum + // @sort + enum MyEnum { + A = 'a', + B = 'b', + C = 'c', + } + `, + }, + { + code: dedent` + // Sort interface + // @sort + interface MyInterface { + a: string; + b: string; + c: string; + } + `, + }, + { + code: dedent` + // Sort type literal + // @sort + type MyTypeLiteral = { + a: string; + b: string; + c: string; + }; + `, + }, + { + code: dedent` + // Sort keys constructed from template literals + // @sort + const a = 'a'; + const b = 'b'; + const myObj = { + [\`cool-dude-\${a}\`]: '', + [\`cool-dude-\${b}\`]: '', + [\`not-cool-dude-\${a}-first\`]: '', + [\`not-cool-dude-\${a}-second\`]: '', + }; + `, + }, + { + code: dedent` + // Sort values including primitives and "unsortable" types + // @sort + const myArray = [ + /^hi/g, 1, 2, '3', false, null, undefined, {}, [] + ]; + `, + }, + ]), + + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////// + + invalid: filterTests([ + { + code: dedent` + // Basic keys + // @sort + const myObj = { + a: '', + c: '', + b: '', + }; + `, + errors: getErrors('Object keys', 'b', 'c'), + output: dedent` + // Basic keys + // @sort + const myObj = { + a: '', + b: '', + c: '', + }; + `, + }, + { + code: dedent` + // Basic values + // @sort + const myValues = [ + 'banana', + 'apple', + 'cherry', + ], + `, + errors: getErrors('Array values', 'apple', 'banana'), + output: dedent` + // Basic values + // @sort + const myValues = [ + 'apple', + 'banana', + 'cherry', + ], + `, + }, + { + code: dedent` + // Nested objects + // @sort + const myObj = { + a: '', + b: '', + c: '', + d: { + f: '', + e: '', + g: '', + } + }; + `, + errors: getErrors('Object keys', 'e', 'f'), + output: dedent` + // Nested objects + // @sort + const myObj = { + a: '', + b: '', + c: '', + d: { + e: '', + f: '', + g: '', + } + }; + `, + }, + { + code: dedent` + // Using option 'none', then overriding + // @sort:none + const myObj = { + b: '', + a: '', + c: '', + // @sort + d: { + f: '', + g: '', + e: '', + } + }; + `, + errors: getErrors('Object keys', 'e', 'f'), + output: dedent` + // Using option 'none', then overriding + // @sort:none + const myObj = { + b: '', + a: '', + c: '', + // @sort + d: { + e: '', + f: '', + g: '', + } + }; + `, + }, + { + code: dedent` + // Case insensitive, but uppercase first, numeric order respected + // @sort + const myObj = { + 1: '', + 10: '', + 2: '', + A: '', + a: '', + B: '', + b: '', + }; + `, + errors: getErrors('Object keys', '2', '10'), + output: dedent` + // Case insensitive, but uppercase first, numeric order respected + // @sort + const myObj = { + 1: '', + 2: '', + 10: '', + A: '', + a: '', + B: '', + b: '', + }; + `, + }, + { + code: dedent` + // Using option 'keys' + // @sort:keys + const myObj = { + c: '', + b: '', + a: '', + }; + `, + errors: getErrors('Object keys', 'a', 'c'), + output: dedent` + // Using option 'keys' + // @sort:keys + const myObj = { + a: '', + b: '', + c: '', + }; + `, + }, + { + code: dedent` + // Using option 'values' + // @sort:values + const myObj = { + b: '', + a: '', + c: '', + d: [ + 'cherry', + 'apple', + 'banana', + ], + }; + `, + errors: getErrors('Array values', 'apple', 'cherry'), + output: dedent` + // Using option 'values' + // @sort:values + const myObj = { + b: '', + a: '', + c: '', + d: [ + 'apple', + 'banana', + 'cherry', + ], + }; + `, + }, + { + code: dedent` + // Using programmatic keys + const keyA = 'a'; + const keyB = 'b'; + // @sort + const myObj = { + [keyB]: '', + [keyA]: '', + }; + `, + errors: getErrors('Object keys', 'keyA', 'keyB'), + output: dedent` + // Using programmatic keys + const keyA = 'a'; + const keyB = 'b'; + // @sort + const myObj = { + [keyA]: '', + [keyB]: '', + }; + `, + }, + { + code: dedent` + // Using non-literal array values + const APPLE = 'zzz_apple'; + const BANANA = 'aaa_banana' + // @sort + const myArray = [ + BANANA, + APPLE, + ]; + `, + errors: getErrors('Array values', 'APPLE', 'BANANA'), + output: dedent` + // Using non-literal array values + const APPLE = 'zzz_apple'; + const BANANA = 'aaa_banana' + // @sort + const myArray = [ + APPLE, + BANANA, + ]; + `, + }, + { + code: dedent` + // Using long member expressions + const ref = { + a1: { + b1: { + c1: { + key: 'myKey', + val: 'myValue' + }, + c2: { + key: 'myOtherKey', + } + }, + b2: { + val: 'myValue' + } + } + }; + // @sort + const myObj = { + [ref.a1.b1.c2.key]: [ + ref.a1.b2.val, + ref.a1.b1.c1.val, + ], + [ref.a1.b1.c1.key]: '', + }; + `, + errors: [ + ...getErrors('Object keys', 'ref.a1.b1.c1.key', 'ref.a1.b1.c2.key'), + ...getErrors('Array values', 'ref.a1.b1.c1.val', 'ref.a1.b2.val'), + ], + // NOTE: Only the first error will be fixed + output: dedent` + // Using long member expressions + const ref = { + a1: { + b1: { + c1: { + key: 'myKey', + val: 'myValue' + }, + c2: { + key: 'myOtherKey', + } + }, + b2: { + val: 'myValue' + } + } + }; + // @sort + const myObj = { + [ref.a1.b1.c1.key]: '', + [ref.a1.b1.c2.key]: [ + ref.a1.b2.val, + ref.a1.b1.c1.val, + ], + }; + `, + }, + { + code: dedent` + // Sort enum + // @sort + enum MyEnum { + B = 'b', + A = 'a', + C = 'c', + } + `, + errors: getErrors('Enum values', 'A', 'B'), + output: dedent` + // Sort enum + // @sort + enum MyEnum { + A = 'a', + B = 'b', + C = 'c', + } + `, + }, + { + code: dedent` + // Sort interface + // @sort + interface MyInterface { + b: string; + a: string; + c: string; + } + `, + errors: getErrors('Interface keys', 'a', 'b'), + output: dedent` + // Sort interface + // @sort + interface MyInterface { + a: string; + b: string; + c: string; + } + `, + }, + { + code: dedent` + // Sort type literal + // @sort + type MyTypeLiteral = { + b: string; + a: string; + c: string; + }; + `, + errors: getErrors('Type literal keys', 'a', 'b'), + output: dedent` + // Sort type literal + // @sort + type MyTypeLiteral = { + a: string; + b: string; + c: string; + }; + `, + }, + { + code: buildLines([ + '// Sort keys constructed from template literals', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myObj = {', + ' [`not-cool-dude-${a}-first`]: 1,', + ' [`cool-dude-${b}`]: 1,', + ' [`not-cool-dude-${a}-second`]: 1,', + ' [`cool-dude-${a}`]: 1,', + '};', + ]), + errors: getErrors('Object keys', 'cool-dude-${a}', 'not-cool-dude-${a}-first'), + output: buildLines([ + '// Sort keys constructed from template literals', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myObj = {', + ' [`cool-dude-${a}`]: 1,', + ' [`cool-dude-${b}`]: 1,', + ' [`not-cool-dude-${a}-first`]: 1,', + ' [`not-cool-dude-${a}-second`]: 1,', + '};', + ]), + }, + { + code: buildLines([ + '// Combination of template literals and member expressions', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myArr = [', + ' `my.template.literal.${b}`,', + ' `my.template.literal.${a}`,', + ' some.other.member.expression,', + ' my.member.expression,', + '];', + ]), + errors: getErrors('Array values', 'my.member.expression', 'my.template.literal.${b}'), + output: buildLines([ + '// Combination of template literals and member expressions', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myArr = [', + ' my.member.expression,', + ' `my.template.literal.${a}`,', + ' `my.template.literal.${b}`,', + ' some.other.member.expression,', + '];', + ]), + }, + { + code: buildLines([ + '// Template literals with unsortable expressions', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myArr = [', + ' `my.template.literal.${a}`,', + ' `my.template.literal.${b ? b : a}`,', + '];', + ]), + errors: getErrors( + 'Array values', + 'my.template.literal.${}', + 'my.template.literal.${a}' + ), + output: buildLines([ + '// Template literals with unsortable expressions', + 'const a = 1;', + 'const b = 1;', + '// @sort', + 'const myArr = [', + ' `my.template.literal.${b ? b : a}`,', + ' `my.template.literal.${a}`,', + '];', + ]), + }, + { + code: dedent` + // Sort values including primitives and "unsortable" types + // @sort + const myArray = [ + {}, 2, '3', null, 1, undefined, false, [], /^hi/g + ]; + `, + errors: getErrors('Array values', '/^hi/g', ''), + output: dedent` + // Sort values including primitives and "unsortable" types + // @sort + const myArray = [ + /^hi/g, 1, 2, '3', false, null, undefined, {}, [] + ]; + `, + }, + { + code: dedent` + // Sparse arrays OK + // @sort + const myArray = [ + 4,,1,2,,3 + ]; + `, + errors: getErrors('Array values', '1', '4'), + output: dedent` + // Sparse arrays OK + // @sort + const myArray = [ + 1,,2,3,,4 + ]; + `, + }, + { + code: dedent` + // Comment content after the annotation ok + // @sort (blah blah sort blah) + const myArray = [ + 1,3,2 + ]; + `, + errors: getErrors('Array values', '2', '3'), + output: dedent` + // Comment content after the annotation ok + // @sort (blah blah sort blah) + const myArray = [ + 1,2,3 + ]; + `, + }, + { + code: dedent` + /** + * Annotation can appear on any line in block comment + * @sort + * More comment + */ + const myArray = [ + 1,3,2 + ]; + `, + errors: getErrors('Array values', '2', '3'), + output: dedent` + /** + * Annotation can appear on any line in block comment + * @sort + * More comment + */ + const myArray = [ + 1,2,3 + ]; + `, + }, + ]), + }); +}); diff --git a/docs/rules/opt-in-sort.md b/docs/rules/opt-in-sort.md new file mode 100644 index 0000000..40fec1f --- /dev/null +++ b/docs/rules/opt-in-sort.md @@ -0,0 +1,139 @@ +# `opt-in-sort` + +Enforce (and fix!) sorting of object keys, array values, or TS enums/interfaces/types by adding the `@sort` annotation where desired. + +Most sorting lint rules require you to make an "all or nothing" sorting decision--sort ALL structures across your codebase or sort nothing. Frequently, sorting arrays is incorrect/would change behavior, but even sorting some objects can hurt readability. But of course there are times where sorting is highly desired, like in large constant objects/lists. Thus, being able to selectively "opt-in" to sorting behavior is ideal. + +## Rule Details + +This rule will alarm when any of the following structures are both (1) preceded by the `@sort` annotation in a comment, and (2) have unsorted keys/values/elements/etc: + +- Object literal +- Array literal +- TS Enum +- TS Interface +- TS Type Literal + +> _Note that sorting will also be enforced on any nested structures. However, this behavior can be configured--see the [Options](#options) section._ + +Examples of **incorrect** code for this rule: + +```ts +// @sort +const myObj = { + b: 2, + a: 1, + c: 3, +}; + +// @sort +const myArray = ['b', 'a', 'c']; + +// @sort +enum MyEnum { + B = 'b', + A = 'a', + C = 'c', +} + +// @sort +interface MyInterface { + b: string; + a: string; + c: string; +} + +// @sort +type MyTypeLiteral = { + b: string; + a: string; + c: string; +}; +``` + +Examples of **correct** code for this rule: + +```ts +// @sort +const myObj = { + a: 1, + b: 2, + c: 3, +}; + +/* + * Works in block comment, too + * @sort + */ +const myArray = ['a', 'b', 'c']; +``` + +### Sorting Behavior + +The default sorting behavior is as follows: + +- Ordering: Normalize a structure's "elements" to strings, then compare using [String.prototype.localeCompare](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare) with the options `{ numeric: true, caseFirst: 'upper' }` +- "Deeply" sort all applicable nested structures +- Consider special tokens like `null`/`undefined`/`false`/`true` to be non-special (i.e., null starts with "n", false starts with "f", etc.) +- Push unknown/unsortable array values to the end of the list (e.g., an array that contains the element `{some: 'value'}`) +- Sorting is not limited to traditional keys and literal values, we can also sort some expression-based elements: + ```ts + // @sort + const myObj1 = { + [MyEnum.A]: 1, + [MyEnum.B]: 2, + [MyEnum.C]: 3, + }; + // @sort + const myArr = [ + my.member.expression, + `my.template.literal.${a}`, + `my.template.literal.${b}`, + some.other.member.expression, + ]; + ``` + +### Options + +This rule does NOT accept traditional options via an `.eslintrc` file, but every `@sort` annotation accepts the following _colon-delimited_ options: + +- `keys`: (default: true) If prescribed _and_ `values` is omitted, only sort keys, not values. The structures considered to have "keys" are: + - Object Literal + - TS Interface + - TS Type Literal +- `values`: (default: true) If prescribed _and_ `keys` is omitted, only sort values, not keys. The structures considered to have "values" are: + - Array Literal + - TS Enum +- `reverse`: (default: false) If true, reverse the sorting order. +- `none`: (default: false) If true, do not sort this structure. This is used to "skip" the sorting of specific nested structures. +- `shallow`: (default: false) If true, only apply sorting to this structure and not nested structures. + +```ts +/* OPTIONS EXAMPLE */ + +// First annotation says: deep sort everything +// @sort +const myObj = { + a: 1, + b: 2, + c: 3, + // Second annotation says: sort keys-only, reversed, but don't apply that sorting any deeper + // @sort:keys:reverse:shallow + nest1: { + nest2: [ + // Since previous annotation was shallow, now we're back to the top-level + // annotation, which includes sorting of array values + 'apple', + 'banana', + 'cherry', + ], + g: 6, + f: 5, + e: 4, + }, +}; +``` + +## When Not To Use It + +If you don't have any structures you want to sort. diff --git a/index.ts b/index.ts index d13010c..e087447 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,9 @@ import excludePatternEverywhere from './src/rules/forbid-pattern-everywhere'; import noEmptyBlockComment from './src/rules/no-empty-block-comment'; +import optInSort from './src/rules/opt-in-sort'; export const rules = { [excludePatternEverywhere.name]: excludePatternEverywhere.rule, [noEmptyBlockComment.name]: noEmptyBlockComment.rule, + [optInSort.name]: optInSort.rule, }; diff --git a/package.json b/package.json index 8a03ec9..ec50749 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,15 @@ "keywords": [ "eslint", "eslintplugin", - "eslint-plugin" + "eslint-plugin", + "sort", + "sorting", + "forbid", + "pattern", + "everywhere", + "empty", + "block", + "comment" ], "author": "Adam C. Hamlin", "main": "dist/index.js", @@ -27,7 +35,8 @@ "watch": "npm run compile -- -watch", "test": "jest", "pretest:ci": "npm run check", - "test:ci": "npm test" + "test:ci": "npm test", + "view-coverage": "open coverage/lcov-report/index.html" }, "lint-staged": { "*.ts": "npm run _lint -- --cache", diff --git a/src/rules/opt-in-sort.ts b/src/rules/opt-in-sort.ts new file mode 100644 index 0000000..de0d08b --- /dev/null +++ b/src/rules/opt-in-sort.ts @@ -0,0 +1,89 @@ +import { Node } from '@typescript-eslint/types/dist/generated/ast-spec'; + +import { getAnnotationMap } from '../utils/annotations'; +import { createRule } from '../utils/create-rule'; +import { assertDefined } from '../utils/misc-utils'; +import { enforceSorting } from '../utils/sorting'; +import { SortableNode, SortingConfig, SortingState } from '../utils/sorting.types'; + +export const name = 'opt-in-sort'; +export const rule = createRule({ + name, + meta: { + type: 'layout', + messages: { + unsortedKeysOrValues: `{{entityType}} are not sorted: '{{expected}}' should appear before '{{actual}}'.`, + }, + docs: { + description: 'sorts object keys, array values, TS interfaces/enums/etc via an annotation', + recommended: false, + requiresTypeChecking: false, + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + + create(context) { + const annotatedLinesMap = getAnnotationMap(context, '@sort', (opts): SortingConfig => { + return { + keys: opts.has('keys') || !opts.has('values'), + values: opts.has('values') || !opts.has('keys'), + reverse: opts.has('reverse'), + none: opts.has('none'), + shallow: opts.has('shallow'), + }; + }); + + // Short-circuit if no @sort annotations in this file + if (Object.keys(annotatedLinesMap).length === 0) { + return {}; + } + + let sortingState: SortingState | undefined; + + function processExpressionEnter(node: SortableNode): void { + const annotationConfig = getSortingConfigFromAnnotation(node); + if (annotationConfig && !annotationConfig.shallow) { + // Store this config for deep/nested usage + sortingState = { + config: annotationConfig, + prev: sortingState, + }; + } + const sortingConfig = annotationConfig ?? sortingState?.config; + if (sortingConfig) { + enforceSorting(node, sortingConfig, context); + } + } + + function processExpressionExit(node: SortableNode): void { + const annotationConfig = getSortingConfigFromAnnotation(node); + if (annotationConfig && !annotationConfig.shallow) { + // NOTE: Can't reach here before sortingState exists, but make types/coverage happy + assertDefined(sortingState); + // Revert to previous sorting configuration + sortingState = sortingState.prev; + } + } + + function getSortingConfigFromAnnotation(node: Node): SortingConfig | undefined { + return annotatedLinesMap[node.loc.start.line - 1]; + } + + return { + ObjectExpression: processExpressionEnter, + 'ObjectExpression:exit': processExpressionExit, + ArrayExpression: processExpressionEnter, + 'ArrayExpression:exit': processExpressionExit, + TSInterfaceDeclaration: processExpressionEnter, + 'TSInterfaceDeclaration:exit': processExpressionExit, + TSTypeLiteral: processExpressionEnter, + 'TSTypeLiteral:exit': processExpressionExit, + TSEnumDeclaration: processExpressionEnter, + 'TSEnumDeclaration:exit': processExpressionExit, + }; + }, +}); + +export default { name, rule }; diff --git a/src/utils/annotations.ts b/src/utils/annotations.ts new file mode 100644 index 0000000..fbf986d --- /dev/null +++ b/src/utils/annotations.ts @@ -0,0 +1,36 @@ +import { RuleContext } from '@typescript-eslint/utils/dist/ts-eslint'; + +interface AnnotationMap { + [lineNum: number]: T; +} + +/** + * Returns a map who keys are line numbers corresponding to annotations and whose values are + * the result of passing the colon-delimited options thru the `processOptions` function. + * + * Assumptions: + * - An annotation must appear by itself on a single line + * - Any options should follow the annotation and be colon-delimited, no whitespace + */ +export function getAnnotationMap( + context: RuleContext, + annotation: `@${string}`, + processOptions: (opts: Set) => T +): AnnotationMap { + const regexStr = `^\\s*(?:\\*\\s*)?(${annotation}(?::[\\w-]+)*)`; + return context + .getSourceCode() + .getAllComments() + .reduce((accumulator, comment) => { + // Find the first line that matches + const annotationMatch = comment.value + .split('\n') + .find((line) => line.match(regexStr)) + ?.match(regexStr)?.[1]; // just want the first capture group, which includes the annotation itself + if (annotationMatch) { + const optionsTokens = new Set(annotationMatch.split(':').slice(1)); + accumulator[comment.loc.end.line] = processOptions(optionsTokens); + } + return accumulator; + }, {} as AnnotationMap); +} diff --git a/src/utils/fix.ts b/src/utils/fix.ts new file mode 100644 index 0000000..672ad40 --- /dev/null +++ b/src/utils/fix.ts @@ -0,0 +1,24 @@ +import { Node } from '@typescript-eslint/types/dist/generated/ast-spec'; +import { SourceCode, RuleFixer, ReportFixFunction } from '@typescript-eslint/utils/dist/ts-eslint'; + +import { NodePair } from './sorting.types'; + +export class FixHelper { + constructor(private sourceCode: SourceCode) {} + + /** + * For each pair, replace text of left node with the right node. + */ + swapNodes(pairs: NodePair[]): ReportFixFunction { + return (fixer: RuleFixer) => { + return pairs.map(([node, replacementNode]) => { + const replacementText = this.getText(replacementNode); + return fixer.replaceText(node, replacementText); + }); + }; + } + + private getText(node: Node): string { + return this.sourceCode.getText(node); + } +} diff --git a/src/utils/misc-utils.ts b/src/utils/misc-utils.ts new file mode 100644 index 0000000..a44492a --- /dev/null +++ b/src/utils/misc-utils.ts @@ -0,0 +1,17 @@ +/** + * Zip arr1 alongside arr2 + */ +export function zip(arr1: L[], arr2: R[]): Array<[L, R]> { + return arr1.map((el, idx) => [el, arr2[idx]]); +} + +/** + * Assertion type guard for undefined values + */ +export function assertDefined(value: T): asserts value is NonNullable { + if (value === undefined || value === null) { + throw new AssertionError('Value is null or undefined.'); + } +} + +export class AssertionError extends Error {} diff --git a/src/utils/sorting.ts b/src/utils/sorting.ts new file mode 100644 index 0000000..765442a --- /dev/null +++ b/src/utils/sorting.ts @@ -0,0 +1,170 @@ +import { AST_NODE_TYPES, Expression, Node } from '@typescript-eslint/types/dist/generated/ast-spec'; +import { RuleContext } from '@typescript-eslint/utils/dist/ts-eslint'; + +import { FixHelper } from './fix'; +import { zip } from './misc-utils'; +import { NodePair, SortableNode, SortingConfig } from './sorting.types'; + +/** + * Enforce sorting for the elements of the given node type + */ +export function enforceSorting( + node: SortableNode, + sortingConfig: SortingConfig, + context: RuleContext +): void { + if (sortingConfig.none) { + // Short-circuit + return; + } + + switch (node.type) { + case AST_NODE_TYPES.ObjectExpression: + if (sortingConfig.keys) { + enforceSortingHelper(node, node.properties, sortingConfig, context); + } + break; + case AST_NODE_TYPES.ArrayExpression: + if (sortingConfig.values) { + // A null element only occurs in sparse array; we'll just ignore + const elements = node.elements.filter((el): el is Expression => el !== null); + enforceSortingHelper(node, elements, sortingConfig, context); + } + break; + case AST_NODE_TYPES.TSInterfaceDeclaration: + if (sortingConfig.keys) { + enforceSortingHelper(node, node.body.body, sortingConfig, context); + } + break; + case AST_NODE_TYPES.TSTypeLiteral: + if (sortingConfig.keys) { + enforceSortingHelper(node, node.members, sortingConfig, context); + } + break; + case AST_NODE_TYPES.TSEnumDeclaration: + if (sortingConfig.values) { + enforceSortingHelper(node, node.members, sortingConfig, context); + } + break; + /* istanbul ignore next */ + default: + // Unexpected node type -- do nothing + break; + } +} + +/////////////////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// + +function enforceSortingHelper( + parentNode: SortableNode, + nodes: Node[], + sortingConfig: SortingConfig, + context: RuleContext +): void { + const sortedNodes = [...nodes].sort(getCompareFn(sortingConfig)); + const nodePairs: NodePair[] = zip(nodes, sortedNodes); + + for (const [node, expectedNode] of nodePairs) { + if (node !== expectedNode) { + const fixHelper = new FixHelper(context.getSourceCode()); + context.report({ + loc: parentNode.loc, + messageId: 'unsortedKeysOrValues', + data: { + entityType: getEntityTypeForDisplay(parentNode), + expected: getStringFromNode(expectedNode, true), + actual: getStringFromNode(node, true), + }, + fix: fixHelper.swapNodes(nodePairs), + }); + break; + } + } +} + +function getCompareFn(sortingConfig: SortingConfig) { + return (a: Node, b: Node) => { + const aStr = getStringFromNode(a); + const bStr = getStringFromNode(b); + if (aStr === undefined && bStr === undefined) { + return 0; + } else if (aStr === undefined) { + // Non-sortable nodes to end + return 1; + } else if (bStr === undefined) { + // Non-sortable nodes to end + return -1; + } else { + const compareRes = aStr.localeCompare(bStr, undefined, { numeric: true, caseFirst: 'upper' }); + const multiplier = sortingConfig.reverse ? -1 : 1; + return compareRes * multiplier; + } + }; +} + +/** + * Do our best to extract text from a given node type. + */ +function getStringFromNode(node: Node, forDisplay = false): string | undefined { + switch (node.type) { + case AST_NODE_TYPES.Identifier: + // NOTE: This includes literal undefined + return node.name; + case AST_NODE_TYPES.Literal: + // NOTE: No special treatment of null, true, false, etc + return node.value?.toString() ?? node.raw; + case AST_NODE_TYPES.TemplateElement: + return node.value.cooked; + case AST_NODE_TYPES.TemplateLiteral: { + // Order the consituent parts from left to right + const children = [...node.quasis, ...node.expressions].sort((a, b) => { + // NOTE: `range` is of form [start, end] + return a.range[0] - b.range[0]; + }); + return children + .map((child) => { + const str = getStringFromNode(child, forDisplay); + const expressions = new Set(node.expressions); + if (forDisplay && expressions.has(child)) { + return `\${${str}}`; + } else { + return str; + } + }) + .join(''); + } + case AST_NODE_TYPES.Property: + return getStringFromNode(node.key, forDisplay); + case AST_NODE_TYPES.MemberExpression: { + // Get member expression path, e.g., `someObj.a.b.c` + const leftSide = getStringFromNode(node.object, forDisplay); + const rightSide = getStringFromNode(node.property, forDisplay); + return `${leftSide}.${rightSide}`; + } + case AST_NODE_TYPES.TSPropertySignature: + // NOTE: This generally covers 'TypeElement' types + return getStringFromNode(node.key, forDisplay); + case AST_NODE_TYPES.TSEnumMember: + return getStringFromNode(node.id, forDisplay); + default: + // "Unsortable" node type + return forDisplay ? `` : undefined; + } +} + +function getEntityTypeForDisplay(node: SortableNode): string { + switch (node.type) { + case AST_NODE_TYPES.ObjectExpression: + return 'Object keys'; + case AST_NODE_TYPES.ArrayExpression: + return 'Array values'; + case AST_NODE_TYPES.TSInterfaceDeclaration: + return 'Interface keys'; + case AST_NODE_TYPES.TSTypeLiteral: + return 'Type literal keys'; + case AST_NODE_TYPES.TSEnumDeclaration: + return 'Enum values'; + } +} diff --git a/src/utils/sorting.types.ts b/src/utils/sorting.types.ts new file mode 100644 index 0000000..932ab0f --- /dev/null +++ b/src/utils/sorting.types.ts @@ -0,0 +1,47 @@ +import { + ArrayExpression, + Node, + ObjectExpression, + TSEnumDeclaration, + TSInterfaceDeclaration, + TSTypeLiteral, +} from '@typescript-eslint/types/dist/generated/ast-spec'; + +export type SortableNode = + | ObjectExpression + | ArrayExpression + | TSEnumDeclaration + | TSInterfaceDeclaration + | TSTypeLiteral; + +export interface SortingState { + config: SortingConfig; + prev?: SortingState; +} + +export interface SortingConfig { + /** + * If true, sort object keys (default: true) + */ + keys: boolean; + /** + * If true, sort array values (default: true) + */ + values: boolean; + /** + * If true, reverse the sorting order (default: false) + */ + reverse: boolean; + /** + * If true, don't sort at all (default: false) + * + * This is needed to "skip" sorting of a specific nested object, for example + */ + none: boolean; + /** + * If true, don't sort nested objects/arrays (default: false) + */ + shallow: boolean; +} + +export type NodePair = [Node, Node];