Skip to content

Commit b355222

Browse files
authored
Merge pull request #49 from mskelton/string-unions
feat: Add `string-unions` rule to sort string unions
2 parents 117ba89 + 2191831 commit b355222

12 files changed

+234
-9
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ recommended configuration. This will enable all available rules as warnings.
5252
|| 🔧 | [sort/import-members](docs/rules/import-members.md) | Sorts import members |
5353
|| 🔧 | [sort/object-properties](docs/rules/object-properties.md) | Sorts object properties |
5454
| | 🔧 | [sort/type-properties](docs/rules/type-properties.md) | Sorts TypeScript type properties |
55+
| | 🔧 | [sort/string-unions](docs/rules/string-unions.md) | Sorts TypeScript string unions |

docs/rules/string-unions.md

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# TypeScript String Union Sorting (sort/string-unions)
2+
3+
🔧 The `--fix` option on the command line can automatically fix the problems
4+
reported by this rule.
5+
6+
Sorts TypeScript string unions alphabetically and case insensitive in ascending
7+
order. This only applies to union types that are made up of entirely string
8+
keys, so mixed type unions will be ignored.
9+
10+
## Rule Details
11+
12+
Examples of **incorrect** code for this rule:
13+
14+
```typescript
15+
type Fruit = "orange" | "apple" | "grape"
16+
```
17+
18+
Examples of **correct** code for this rule:
19+
20+
```typescript
21+
type Fruit = "apple" | "grape" | "orange"
22+
```
23+
24+
## Options
25+
26+
```json
27+
{
28+
"sort/string-unions": ["error", { "caseSensitive": false, "natural": true }]
29+
}
30+
```
31+
32+
- `caseSensitive` (default `false`) - if `true`, enforce properties to be in
33+
case-sensitive order.
34+
- `natural` (default `true`) - if `true`, enforce properties to be in natural
35+
order. Natural order compares strings containing combination of letters and
36+
numbers in the way a human being would sort. It basically sorts numerically,
37+
instead of sorting alphabetically. So the number 10 comes after the number 3
38+
in natural sorting.
39+
40+
## When Not To Use It
41+
42+
This rule is a formatting preference and not following it won't negatively
43+
affect the quality of your code. If alphabetizing string unions isn't a part of
44+
your coding standards, then you can leave this rule off.

docs/rules/type-properties.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ ascending order.
1010

1111
Examples of **incorrect** code for this rule:
1212

13-
```ts
13+
```typescript
1414
interface A {
1515
B: number
1616
c: string
@@ -28,7 +28,7 @@ type A = {
2828
2929
Examples of **correct** code for this rule:
3030
31-
```ts
31+
```typescript
3232
interface A {
3333
a: boolean
3434
B: number

src/__tests__/destructuring-properties.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ ruleTester.run("sort/destructuring-properties", rule, {
5656
options: [{ caseSensitive: false, natural: false }],
5757
},
5858
{
59-
code: "let { a: A, B: b, c: C, C: c } = {}",
59+
code: "let { B: b, C: c, a: A, c: C } = {}",
6060
options: [{ caseSensitive: true, natural: false }],
6161
},
6262
{

src/__tests__/export-members.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ ruleTester.run("sort/export-members", rule, {
4040
options: [{ caseSensitive: false, natural: false }],
4141
},
4242
{
43-
code: "export { a, B, c, C } from 'a'",
43+
code: "export { B, C, a, c } from 'a'",
4444
options: [{ caseSensitive: true, natural: false }],
4545
},
4646
{

src/__tests__/import-members.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ ruleTester.run("sort/import-members", rule, {
4040
options: [{ caseSensitive: false, natural: false }],
4141
},
4242
{
43-
code: "import { a, B, c, C } from 'a'",
43+
code: "import { B, C, a, c } from 'a'",
4444
options: [{ caseSensitive: true, natural: false }],
4545
},
4646
{

src/__tests__/object-properties.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ ruleTester.run("sort/object-properties", rule, {
102102
options: [{ caseSensitive: false, natural: false }],
103103
},
104104
{
105-
code: "var a = { a: 1, B: 2, c: 3, C: 4 }",
105+
code: "var a = { B: 2, C: 4, a: 1, c: 3 }",
106106
options: [{ caseSensitive: true, natural: false }],
107107
},
108108
{
@@ -120,7 +120,7 @@ ruleTester.run("sort/object-properties", rule, {
120120
options: [{ caseSensitive: false, natural: false }],
121121
},
122122
{
123-
code: "var a = { ['a']: 1, ['B']: 2, ['c']: 3, ['C']: 4, }",
123+
code: "var a = { ['B']: 2, ['C']: 4, ['a']: 1, ['c']: 3 }",
124124
options: [{ caseSensitive: true, natural: false }],
125125
},
126126
{

src/__tests__/string-unions.spec.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { TSESLint } from "@typescript-eslint/experimental-utils"
2+
import rule from "../rules/string-unions.js"
3+
import { createTsRuleTester } from "../test-utils.js"
4+
5+
const ruleTester = createTsRuleTester()
6+
7+
const createValidCodeVariants = (
8+
code: string
9+
): TSESLint.RunTests<
10+
"unsorted",
11+
[{ caseSensitive?: boolean; natural?: boolean }]
12+
>["valid"] => [
13+
{ code, options: [{ caseSensitive: false, natural: false }] },
14+
{ code, options: [{ caseSensitive: true, natural: false }] },
15+
{ code, options: [{ caseSensitive: false, natural: true }] },
16+
{ code, options: [{ caseSensitive: true, natural: true }] },
17+
]
18+
19+
ruleTester.run("sort/string-unions", rule, {
20+
valid: [
21+
...createValidCodeVariants("type A = 'a'"),
22+
...createValidCodeVariants("type A = 'a' | 'b'"),
23+
...createValidCodeVariants("type A = '_' | 'a' | 'b'"),
24+
25+
// Ignores mixed types
26+
...createValidCodeVariants("type A = 'b' | 'a' | boolean"),
27+
...createValidCodeVariants("type A = 'b' | {type:string} | 'a'"),
28+
29+
// Options
30+
{
31+
code: "type A = 'a1' | 'A1' | 'a12' | 'a2' | 'B2'",
32+
options: [{ caseSensitive: false, natural: false }],
33+
},
34+
{
35+
code: "type A = 'A1' | 'B1' | 'a1' | 'a12' | 'a2'",
36+
options: [{ caseSensitive: true, natural: false }],
37+
},
38+
{
39+
code: "type A = 'a1' | 'A1' | 'a2' | 'a12' | 'B2'",
40+
options: [{ caseSensitive: false, natural: true }],
41+
},
42+
{
43+
code: "type A = 'A1' | 'B2' | 'a1' | 'a2' | 'a12'",
44+
options: [{ caseSensitive: true, natural: true }],
45+
},
46+
],
47+
invalid: [
48+
{
49+
code: "type A = 'b' | 'a'",
50+
output: "type A = 'a' | 'b'",
51+
errors: [{ messageId: "unsorted" }],
52+
},
53+
{
54+
code: "type A = 'b' | 'a' | 'c'",
55+
output: "type A = 'a' | 'b' | 'c'",
56+
errors: [{ messageId: "unsorted" }],
57+
},
58+
{
59+
code: "type A = 'b' | '_' | 'c'",
60+
output: "type A = '_' | 'b' | 'c'",
61+
errors: [{ messageId: "unsorted" }],
62+
},
63+
64+
// Options
65+
{
66+
code: "type A = 'a12' | 'B2' | 'a1' | 'a2'",
67+
output: "type A = 'a1' | 'a12' | 'a2' | 'B2'",
68+
options: [{ caseSensitive: false, natural: false }],
69+
errors: [{ messageId: "unsorted" }],
70+
},
71+
{
72+
code: "type A = 'a1' | 'B2' | 'a2' | 'a12'",
73+
output: "type A = 'B2' | 'a1' | 'a12' | 'a2'",
74+
options: [{ caseSensitive: true, natural: false }],
75+
errors: [{ messageId: "unsorted" }],
76+
},
77+
{
78+
code: "type A = 'a2' | 'a1' | 'a12' | 'B2'",
79+
output: "type A = 'a1' | 'a2' | 'a12' | 'B2'",
80+
options: [{ caseSensitive: false, natural: true }],
81+
errors: [{ messageId: "unsorted" }],
82+
},
83+
{
84+
code: "type A = 'a12' | 'a2' | 'B2' | 'a1'",
85+
output: "type A = 'B2' | 'a1' | 'a2' | 'a12'",
86+
options: [{ caseSensitive: true, natural: true }],
87+
errors: [{ messageId: "unsorted" }],
88+
},
89+
],
90+
})

src/__tests__/type-properties.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ ruleTester.run("sort/type-properties", rule, {
8080
options: [{ caseSensitive: false, natural: false }],
8181
},
8282
{
83-
code: "type A = { a: 1, B: 2, c: 3, C: 4 }",
83+
code: "type A = { B: 2, C: 4, a: 1, c: 3 }",
8484
options: [{ caseSensitive: true, natural: false }],
8585
},
8686
{

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import sortImports from "./rules/imports.js"
55
import sortImportMembers from "./rules/import-members.js"
66
import sortObjectProperties from "./rules/object-properties.js"
77
import sortTypeProperties from "./rules/type-properties.js"
8+
import sortStringUnions from "./rules/string-unions.js"
89

910
const config = {
1011
configs: {
@@ -49,6 +50,7 @@ const config = {
4950
"import-members": sortImportMembers,
5051
"object-properties": sortObjectProperties,
5152
"type-properties": sortTypeProperties,
53+
"string-unions": sortStringUnions,
5254
},
5355
}
5456

src/rules/string-unions.ts

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
ESLintUtils,
3+
TSESLint,
4+
TSESTree,
5+
} from "@typescript-eslint/experimental-utils"
6+
import { getNodeText } from "../ts-utils.js"
7+
import { docsURL, enumerate, getSorter, isUnsorted } from "../utils.js"
8+
9+
function getSortValue(node: TSESTree.Node) {
10+
return node.type === TSESTree.AST_NODE_TYPES.TSLiteralType &&
11+
node.literal.type === TSESTree.AST_NODE_TYPES.Literal &&
12+
typeof node.literal.value === "string"
13+
? node.literal.value
14+
: null
15+
}
16+
17+
export default ESLintUtils.RuleCreator.withoutDocs<
18+
[{ caseSensitive?: boolean; natural?: boolean }],
19+
"unsorted"
20+
>({
21+
create(context) {
22+
const source = context.getSourceCode()
23+
const options = context.options[0]
24+
const sorter = getSorter({
25+
caseSensitive: options?.caseSensitive,
26+
natural: options?.natural,
27+
})
28+
29+
return {
30+
TSUnionType(node) {
31+
const nodes = node.types
32+
33+
// If there are one or fewer properties, there is nothing to sort
34+
if (nodes.length < 2) return
35+
36+
// Ignore mixed type unions
37+
if (nodes.map(getSortValue).some((value) => value === null)) return
38+
39+
const sorted = nodes
40+
.slice()
41+
.sort((a, b) => sorter(getSortValue(a) ?? "", getSortValue(b) ?? ""))
42+
43+
const firstUnsortedNode = isUnsorted(nodes, sorted)
44+
if (firstUnsortedNode) {
45+
context.report({
46+
node: firstUnsortedNode,
47+
messageId: "unsorted",
48+
*fix(fixer) {
49+
for (const [node, complement] of enumerate(nodes, sorted)) {
50+
yield fixer.replaceText(node, getNodeText(source, complement))
51+
}
52+
},
53+
})
54+
}
55+
},
56+
}
57+
},
58+
meta: {
59+
docs: {
60+
recommended: false,
61+
url: docsURL("string-unions"),
62+
description: `Sorts TypeScript string unions alphabetically and case insensitive in ascending order.`,
63+
},
64+
fixable: "code",
65+
messages: {
66+
unsorted: "String unions should be sorted alphabetically.",
67+
},
68+
schema: [
69+
{
70+
additionalProperties: false,
71+
default: { caseSensitive: false, natural: true },
72+
properties: {
73+
caseSensitive: {
74+
type: "boolean",
75+
default: false,
76+
},
77+
natural: {
78+
type: "boolean",
79+
default: true,
80+
},
81+
},
82+
type: "object",
83+
},
84+
],
85+
type: "suggestion",
86+
},
87+
defaultOptions: [{}],
88+
}) as TSESLint.RuleModule<string, unknown[]>

src/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const getSorter = ({
7272
if (caseSensitive && natural) {
7373
return (a, b) => naturalCompare(a, b)
7474
} else if (caseSensitive) {
75-
return (a, b) => a.localeCompare(b)
75+
return (a, b) => (a < b ? -1 : a > b ? 1 : 0)
7676
} else if (natural) {
7777
return (a, b) => naturalCompare(a.toLowerCase(), b.toLowerCase())
7878
}

0 commit comments

Comments
 (0)