From c2ea732649e4b6c75e1ac6e698a68fd2e964aa27 Mon Sep 17 00:00:00 2001 From: Mark Skelton Date: Thu, 13 Apr 2023 22:25:07 -0500 Subject: [PATCH 1/2] feat: Add `string-unions` rule to sort string unions Tests Finish rule --- README.md | 1 + docs/rules/string-unions.md | 44 ++++++++++++++ docs/rules/type-properties.md | 4 +- src/__tests__/string-unions.spec.ts | 90 +++++++++++++++++++++++++++++ src/index.ts | 2 + src/rules/string-unions.ts | 88 ++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 docs/rules/string-unions.md create mode 100644 src/__tests__/string-unions.spec.ts create mode 100644 src/rules/string-unions.ts diff --git a/README.md b/README.md index d869c04..a42eb35 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,4 @@ recommended configuration. This will enable all available rules as warnings. | ✔ | 🔧 | [sort/import-members](docs/rules/import-members.md) | Sorts import members | | ✔ | 🔧 | [sort/object-properties](docs/rules/object-properties.md) | Sorts object properties | | | 🔧 | [sort/type-properties](docs/rules/type-properties.md) | Sorts TypeScript type properties | +| | 🔧 | [sort/string-unions](docs/rules/string-unions.md) | Sorts TypeScript string unions | diff --git a/docs/rules/string-unions.md b/docs/rules/string-unions.md new file mode 100644 index 0000000..4f84fb4 --- /dev/null +++ b/docs/rules/string-unions.md @@ -0,0 +1,44 @@ +# TypeScript String Union Sorting (sort/string-unions) + +🔧 The `--fix` option on the command line can automatically fix the problems +reported by this rule. + +Sorts TypeScript string unions alphabetically and case insensitive in ascending +order. This only applies to union types that are made up of entirely string +keys, so mixed type unions will be ignored. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```typescript +type Fruit = "orange" | "apple" | "grape" +``` + +Examples of **correct** code for this rule: + +```typescript +type Fruit = "apple" | "grape" | "orange" +``` + +## Options + +```json +{ + "sort/string-unions": ["error", { "caseSensitive": false, "natural": true }] +} +``` + +- `caseSensitive` (default `false`) - if `true`, enforce properties to be in + case-sensitive order. +- `natural` (default `true`) - if `true`, enforce properties to be in natural + order. Natural order compares strings containing combination of letters and + numbers in the way a human being would sort. It basically sorts numerically, + instead of sorting alphabetically. So the number 10 comes after the number 3 + in natural sorting. + +## When Not To Use It + +This rule is a formatting preference and not following it won't negatively +affect the quality of your code. If alphabetizing string unions isn't a part of +your coding standards, then you can leave this rule off. diff --git a/docs/rules/type-properties.md b/docs/rules/type-properties.md index 8027a6b..59031cd 100644 --- a/docs/rules/type-properties.md +++ b/docs/rules/type-properties.md @@ -10,7 +10,7 @@ ascending order. Examples of **incorrect** code for this rule: -```ts +```typescript interface A { B: number c: string @@ -28,7 +28,7 @@ type A = { Examples of **correct** code for this rule: -```ts +```typescript interface A { a: boolean B: number diff --git a/src/__tests__/string-unions.spec.ts b/src/__tests__/string-unions.spec.ts new file mode 100644 index 0000000..f819765 --- /dev/null +++ b/src/__tests__/string-unions.spec.ts @@ -0,0 +1,90 @@ +import { TSESLint } from "@typescript-eslint/experimental-utils" +import rule from "../rules/string-unions.js" +import { createTsRuleTester } from "../test-utils.js" + +const ruleTester = createTsRuleTester() + +const createValidCodeVariants = ( + code: string +): TSESLint.RunTests< + "unsorted", + [{ caseSensitive?: boolean; natural?: boolean }] +>["valid"] => [ + { code, options: [{ caseSensitive: false, natural: false }] }, + { code, options: [{ caseSensitive: true, natural: false }] }, + { code, options: [{ caseSensitive: false, natural: true }] }, + { code, options: [{ caseSensitive: true, natural: true }] }, +] + +ruleTester.run("sort/string-unions", rule, { + valid: [ + ...createValidCodeVariants("type A = 'a'"), + ...createValidCodeVariants("type A = 'a' | 'b'"), + ...createValidCodeVariants("type A = '_' | 'a' | 'b'"), + + // Ignores mixed types + ...createValidCodeVariants("type A = 'b' | 'a' | boolean"), + ...createValidCodeVariants("type A = 'b' | {type:string} | 'a'"), + + // Options + { + code: "type A = 'a1' | 'A1' | 'a12' | 'a2' | 'B2'", + options: [{ caseSensitive: false, natural: false }], + }, + { + code: "type A = 'a1' | 'A1' | 'a12' | 'a2' | 'B2'", + options: [{ caseSensitive: true, natural: false }], + }, + { + code: "type A = 'a1' | 'A1' | 'a2' | 'a12' | 'B2'", + options: [{ caseSensitive: false, natural: true }], + }, + { + code: "type A = 'A1' | 'B2' | 'a1' | 'a2' | 'a12'", + options: [{ caseSensitive: true, natural: true }], + }, + ], + invalid: [ + { + code: "type A = 'b' | 'a'", + output: "type A = 'a' | 'b'", + errors: [{ messageId: "unsorted" }], + }, + { + code: "type A = 'b' | 'a' | 'c'", + output: "type A = 'a' | 'b' | 'c'", + errors: [{ messageId: "unsorted" }], + }, + { + code: "type A = 'b' | '_' | 'c'", + output: "type A = '_' | 'b' | 'c'", + errors: [{ messageId: "unsorted" }], + }, + + // Options + { + code: "type A = 'a12' | 'B2' | 'a1' | 'a2'", + output: "type A = 'a1' | 'a12' | 'a2' | 'B2'", + options: [{ caseSensitive: false, natural: false }], + errors: [{ messageId: "unsorted" }], + }, + { + code: "type A = 'a1' | 'B2' | 'a2' | 'a12'", + output: "type A = 'a1' | 'a12' | 'a2' | 'B2'", + options: [{ caseSensitive: true, natural: false }], + errors: [{ messageId: "unsorted" }], + }, + { + code: "type A = 'a2' | 'a1' | 'a12' | 'B2'", + output: "type A = 'a1' | 'a2' | 'a12' | 'B2'", + options: [{ caseSensitive: false, natural: true }], + errors: [{ messageId: "unsorted" }], + }, + { + code: "type A = 'a12' | 'a2' | 'B2' | 'a1'", + output: "type A = 'B2' | 'a1' | 'a2' | 'a12'", + options: [{ caseSensitive: true, natural: true }], + errors: [{ messageId: "unsorted" }], + }, + ], +}) diff --git a/src/index.ts b/src/index.ts index 7f7428c..d554fbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import sortImports from "./rules/imports.js" import sortImportMembers from "./rules/import-members.js" import sortObjectProperties from "./rules/object-properties.js" import sortTypeProperties from "./rules/type-properties.js" +import sortStringUnions from "./rules/string-unions.js" const config = { configs: { @@ -49,6 +50,7 @@ const config = { "import-members": sortImportMembers, "object-properties": sortObjectProperties, "type-properties": sortTypeProperties, + "string-unions": sortStringUnions, }, } diff --git a/src/rules/string-unions.ts b/src/rules/string-unions.ts new file mode 100644 index 0000000..276a01d --- /dev/null +++ b/src/rules/string-unions.ts @@ -0,0 +1,88 @@ +import { + ESLintUtils, + TSESLint, + TSESTree, +} from "@typescript-eslint/experimental-utils" +import { getNodeText } from "../ts-utils.js" +import { docsURL, enumerate, getSorter, isUnsorted } from "../utils.js" + +function getSortValue(node: TSESTree.Node) { + return node.type === TSESTree.AST_NODE_TYPES.TSLiteralType && + node.literal.type === TSESTree.AST_NODE_TYPES.Literal && + typeof node.literal.value === "string" + ? node.literal.value + : null +} + +export default ESLintUtils.RuleCreator.withoutDocs< + [{ caseSensitive?: boolean; natural?: boolean }], + "unsorted" +>({ + create(context) { + const source = context.getSourceCode() + const options = context.options[0] + const sorter = getSorter({ + caseSensitive: options?.caseSensitive, + natural: options?.natural, + }) + + return { + TSUnionType(node) { + const nodes = node.types + + // If there are one or fewer properties, there is nothing to sort + if (nodes.length < 2) return + + // Ignore mixed type unions + if (nodes.map(getSortValue).some((value) => value === null)) return + + const sorted = nodes + .slice() + .sort((a, b) => sorter(getSortValue(a) ?? "", getSortValue(b) ?? "")) + + const firstUnsortedNode = isUnsorted(nodes, sorted) + if (firstUnsortedNode) { + context.report({ + node: firstUnsortedNode, + messageId: "unsorted", + *fix(fixer) { + for (const [node, complement] of enumerate(nodes, sorted)) { + yield fixer.replaceText(node, getNodeText(source, complement)) + } + }, + }) + } + }, + } + }, + meta: { + docs: { + recommended: false, + url: docsURL("string-unions"), + description: `Sorts TypeScript string unions alphabetically and case insensitive in ascending order.`, + }, + fixable: "code", + messages: { + unsorted: "String unions should be sorted alphabetically.", + }, + schema: [ + { + additionalProperties: false, + default: { caseSensitive: false, natural: true }, + properties: { + caseSensitive: { + type: "boolean", + default: false, + }, + natural: { + type: "boolean", + default: true, + }, + }, + type: "object", + }, + ], + type: "suggestion", + }, + defaultOptions: [{}], +}) as TSESLint.RuleModule From 219183188b3c4a534610cb121184cebf972fa0fc Mon Sep 17 00:00:00 2001 From: Mark Skelton Date: Thu, 13 Apr 2023 23:45:40 -0500 Subject: [PATCH 2/2] fix: Fix case sensitive sorting when not using natural compare --- src/__tests__/destructuring-properties.spec.ts | 2 +- src/__tests__/export-members.spec.ts | 2 +- src/__tests__/import-members.spec.ts | 2 +- src/__tests__/object-properties.spec.ts | 4 ++-- src/__tests__/string-unions.spec.ts | 4 ++-- src/__tests__/type-properties.spec.ts | 2 +- src/utils.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/__tests__/destructuring-properties.spec.ts b/src/__tests__/destructuring-properties.spec.ts index 34f992b..3dcc698 100644 --- a/src/__tests__/destructuring-properties.spec.ts +++ b/src/__tests__/destructuring-properties.spec.ts @@ -56,7 +56,7 @@ ruleTester.run("sort/destructuring-properties", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "let { a: A, B: b, c: C, C: c } = {}", + code: "let { B: b, C: c, a: A, c: C } = {}", options: [{ caseSensitive: true, natural: false }], }, { diff --git a/src/__tests__/export-members.spec.ts b/src/__tests__/export-members.spec.ts index f228c32..8c9931e 100644 --- a/src/__tests__/export-members.spec.ts +++ b/src/__tests__/export-members.spec.ts @@ -40,7 +40,7 @@ ruleTester.run("sort/export-members", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "export { a, B, c, C } from 'a'", + code: "export { B, C, a, c } from 'a'", options: [{ caseSensitive: true, natural: false }], }, { diff --git a/src/__tests__/import-members.spec.ts b/src/__tests__/import-members.spec.ts index 5e5363a..85afc9c 100644 --- a/src/__tests__/import-members.spec.ts +++ b/src/__tests__/import-members.spec.ts @@ -40,7 +40,7 @@ ruleTester.run("sort/import-members", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "import { a, B, c, C } from 'a'", + code: "import { B, C, a, c } from 'a'", options: [{ caseSensitive: true, natural: false }], }, { diff --git a/src/__tests__/object-properties.spec.ts b/src/__tests__/object-properties.spec.ts index 6763d81..dcfc2d8 100644 --- a/src/__tests__/object-properties.spec.ts +++ b/src/__tests__/object-properties.spec.ts @@ -102,7 +102,7 @@ ruleTester.run("sort/object-properties", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "var a = { a: 1, B: 2, c: 3, C: 4 }", + code: "var a = { B: 2, C: 4, a: 1, c: 3 }", options: [{ caseSensitive: true, natural: false }], }, { @@ -120,7 +120,7 @@ ruleTester.run("sort/object-properties", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "var a = { ['a']: 1, ['B']: 2, ['c']: 3, ['C']: 4, }", + code: "var a = { ['B']: 2, ['C']: 4, ['a']: 1, ['c']: 3 }", options: [{ caseSensitive: true, natural: false }], }, { diff --git a/src/__tests__/string-unions.spec.ts b/src/__tests__/string-unions.spec.ts index f819765..7acaf48 100644 --- a/src/__tests__/string-unions.spec.ts +++ b/src/__tests__/string-unions.spec.ts @@ -32,7 +32,7 @@ ruleTester.run("sort/string-unions", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "type A = 'a1' | 'A1' | 'a12' | 'a2' | 'B2'", + code: "type A = 'A1' | 'B1' | 'a1' | 'a12' | 'a2'", options: [{ caseSensitive: true, natural: false }], }, { @@ -70,7 +70,7 @@ ruleTester.run("sort/string-unions", rule, { }, { code: "type A = 'a1' | 'B2' | 'a2' | 'a12'", - output: "type A = 'a1' | 'a12' | 'a2' | 'B2'", + output: "type A = 'B2' | 'a1' | 'a12' | 'a2'", options: [{ caseSensitive: true, natural: false }], errors: [{ messageId: "unsorted" }], }, diff --git a/src/__tests__/type-properties.spec.ts b/src/__tests__/type-properties.spec.ts index b41fcc3..9d19382 100644 --- a/src/__tests__/type-properties.spec.ts +++ b/src/__tests__/type-properties.spec.ts @@ -80,7 +80,7 @@ ruleTester.run("sort/type-properties", rule, { options: [{ caseSensitive: false, natural: false }], }, { - code: "type A = { a: 1, B: 2, c: 3, C: 4 }", + code: "type A = { B: 2, C: 4, a: 1, c: 3 }", options: [{ caseSensitive: true, natural: false }], }, { diff --git a/src/utils.ts b/src/utils.ts index 18dd58a..c1c94fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -72,7 +72,7 @@ export const getSorter = ({ if (caseSensitive && natural) { return (a, b) => naturalCompare(a, b) } else if (caseSensitive) { - return (a, b) => a.localeCompare(b) + return (a, b) => (a < b ? -1 : a > b ? 1 : 0) } else if (natural) { return (a, b) => naturalCompare(a.toLowerCase(), b.toLowerCase()) }