Skip to content

Commit

Permalink
Merge pull request #49 from mskelton/string-unions
Browse files Browse the repository at this point in the history
feat: Add `string-unions` rule to sort string unions
  • Loading branch information
mskelton authored Apr 14, 2023
2 parents 117ba89 + 2191831 commit b355222
Show file tree
Hide file tree
Showing 12 changed files with 234 additions and 9 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
44 changes: 44 additions & 0 deletions docs/rules/string-unions.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/rules/type-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ascending order.

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

```ts
```typescript
interface A {
B: number
c: string
Expand All @@ -28,7 +28,7 @@ type A = {
Examples of **correct** code for this rule:
```ts
```typescript
interface A {
a: boolean
B: number
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/destructuring-properties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/export-members.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/import-members.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
{
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/object-properties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
{
Expand All @@ -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 }],
},
{
Expand Down
90 changes: 90 additions & 0 deletions src/__tests__/string-unions.spec.ts
Original file line number Diff line number Diff line change
@@ -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' | 'B1' | 'a1' | 'a12' | 'a2'",
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 = 'B2' | 'a1' | 'a12' | 'a2'",
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" }],
},
],
})
2 changes: 1 addition & 1 deletion src/__tests__/type-properties.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -49,6 +50,7 @@ const config = {
"import-members": sortImportMembers,
"object-properties": sortObjectProperties,
"type-properties": sortTypeProperties,
"string-unions": sortStringUnions,
},
}

Expand Down
88 changes: 88 additions & 0 deletions src/rules/string-unions.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]>
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down

0 comments on commit b355222

Please sign in to comment.