diff --git a/.README/README.md b/.README/README.md index be062c4..ffe9a24 100644 --- a/.README/README.md +++ b/.README/README.md @@ -193,6 +193,7 @@ When `true`, only checks files with a [`@flow` annotation](http://flowtype.org/d {"gitdown": "include", "file": "./rules/require-variable-type.md"} {"gitdown": "include", "file": "./rules/semi.md"} {"gitdown": "include", "file": "./rules/sort-keys.md"} +{"gitdown": "include", "file": "./rules/sort-type-union-intersection-members.md"} {"gitdown": "include", "file": "./rules/space-after-type-colon.md"} {"gitdown": "include", "file": "./rules/space-before-generic-bracket.md"} {"gitdown": "include", "file": "./rules/space-before-type-colon.md"} diff --git a/.README/rules/sort-type-union-intersection-members.md b/.README/rules/sort-type-union-intersection-members.md new file mode 100644 index 0000000..fc7ad10 --- /dev/null +++ b/.README/rules/sort-type-union-intersection-members.md @@ -0,0 +1,101 @@ +### `sort-type-union-intersection-members` + +_The `--fix` option on the command line automatically fixes problems reported by this rule._ + +Enforces that members of a type union/intersection are sorted alphabetically. + +#### Options + +You can specify the sort order using `order`. + +* `"asc"` (default) - enforce ascending sort order. +* `"desc"` - enforce descending sort order. + +```js +{ + "rules": { + "flowtype/sort-type-union-intersection-members": [ + 2, + { + "order": "asc" + } + ] + } +} +``` + +You can disable checking intersection types using `checkIntersections`. + +* `true` (default) - enforce sort order of intersection members. +* `false` - do not enforce sort order of intersection members. + +```js +{ + "rules": { + "flowtype/sort-type-union-intersection-members": [ + 2, + { + "checkIntersections": true + } + ] + } +} +``` + +You can disable checking union types using `checkUnions`. + +* `true` (default) - enforce sort order of union members. +* `false` - do not enforce sort order of union members. + +```js +{ + "rules": { + "flowtype/sort-type-union-intersection-members": [ + 2, + { + "checkUnions": true + } + ] + } +} +``` + +You can specify the ordering of groups using `groupOrder`. + +Each member of the type is placed into a group, and then the rule sorts alphabetically within each group. +The ordering of groups is determined by this option. + +* `keyword` - Keyword types (`any`, `string`, etc) +* `named` - Named types (`A`, `A['prop']`, `B[]`, `Array`) +* `literal` - Literal types (`1`, `'b'`, `true`, etc) +* `function` - Function types (`() => void`) +* `object` - Object types (`{ a: string }`, `{ [key: string]: number }`) +* `tuple` - Tuple types (`[A, B, C]`) +* `intersection` - Intersection types (`A & B`) +* `union` - Union types (`A | B`) +* `nullish` - `null` and `undefined` + +```js +{ + "rules": { + "flowtype/sort-type-union-intersection-members": [ + 2, + { + "groupOrder": [ + 'keyword', + 'named', + 'literal', + 'function', + 'object', + 'tuple', + 'intersection', + 'union', + 'nullish', + ] + } + ] + } +} +``` + + diff --git a/src/index.js b/src/index.js index 66bc783..96b8b57 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ import requireValidFileAnnotation from './rules/requireValidFileAnnotation'; import requireVariableType from './rules/requireVariableType'; import semi from './rules/semi'; import sortKeys from './rules/sortKeys'; +import sortTypeUnionIntersectionMembers from './rules/sortTypeUnionIntersectionMembers'; import spaceAfterTypeColon from './rules/spaceAfterTypeColon'; import spaceBeforeGenericBracket from './rules/spaceBeforeGenericBracket'; import spaceBeforeTypeColon from './rules/spaceBeforeTypeColon'; @@ -84,6 +85,7 @@ const rules = { 'require-variable-type': requireVariableType, semi, 'sort-keys': sortKeys, + 'sort-type-union-intersection-members': sortTypeUnionIntersectionMembers, 'space-after-type-colon': spaceAfterTypeColon, 'space-before-generic-bracket': spaceBeforeGenericBracket, 'space-before-type-colon': spaceBeforeTypeColon, @@ -133,6 +135,7 @@ export default { 'require-variable-type': 0, semi: 0, 'sort-keys': 0, + 'sort-type-union-intersection-members': 0, 'space-after-type-colon': 0, 'space-before-generic-bracket': 0, 'space-before-type-colon': 0, diff --git a/src/rules/sortTypeUnionIntersectionMembers.js b/src/rules/sortTypeUnionIntersectionMembers.js new file mode 100644 index 0000000..4d212d7 --- /dev/null +++ b/src/rules/sortTypeUnionIntersectionMembers.js @@ -0,0 +1,237 @@ +const groups = { + function: 'function', + intersection: 'intersection', + keyword: 'keyword', + literal: 'literal', + named: 'named', + nullish: 'nullish', + object: 'object', + tuple: 'tuple', + union: 'union', + unknown: 'unknown', +}; + +// eslint-disable-next-line complexity +const getGroup = (node) => { + switch (node.type) { + case 'FunctionTypeAnnotation': + return groups.function; + + case 'IntersectionTypeAnnotation': + return groups.intersection; + + case 'AnyTypeAnnotation': + case 'BooleanTypeAnnotation': + case 'NumberTypeAnnotation': + case 'StringTypeAnnotation': + case 'SymbolTypeAnnotation': + case 'ThisTypeAnnotation': + return groups.keyword; + + case 'NullLiteralTypeAnnotation': + case 'NullableTypeAnnotation': + case 'VoidTypeAnnotation': + return groups.nullish; + + case 'BooleanLiteralTypeAnnotation': + case 'NumberLiteralTypeAnnotation': + case 'StringLiteralTypeAnnotation': + return groups.literal; + + case 'ArrayTypeAnnotation': + case 'IndexedAccessType': + case 'GenericTypeAnnotation': + case 'OptionalIndexedAccessType': + return groups.named; + + case 'ObjectTypeAnnotation': + return groups.object; + + case 'TupleTypeAnnotation': + return groups.tuple; + + case 'UnionTypeAnnotation': + return groups.union; + } + + return groups.unknown; +}; + +const fallbackSort = (a, b) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + + return 0; +}; + +const sorters = { + asc: (collator, a, b) => { + return collator.compare(a, b) || fallbackSort(a, b); + }, + desc: (collator, a, b) => { + return collator.compare(b, a) || fallbackSort(b, a); + }, +}; + +const create = (context) => { + const sourceCode = context.getSourceCode(); + + const { + checkIntersections = true, + checkUnions = true, + groupOrder = [ + groups.keyword, + groups.named, + groups.literal, + groups.function, + groups.object, + groups.tuple, + groups.intersection, + groups.union, + groups.nullish, + ], + order = 'asc', + } = context.options[1] || {}; + + const sort = sorters[order]; + + const collator = new Intl.Collator('en', { + numeric: true, + sensitivity: 'base', + }); + + const checkSorting = (node) => { + const sourceOrder = node.types.map((type) => { + const group = groupOrder?.indexOf(getGroup(type)) ?? -1; + + return { + group: group === -1 ? Number.MAX_SAFE_INTEGER : group, + node: type, + text: sourceCode.getText(type), + }; + }); + + const expectedOrder = [...sourceOrder].sort((a, b) => { + if (a.group !== b.group) { + return a.group - b.group; + } + + return sort(collator, a.text, b.text); + }); + + const hasComments = node.types.some((type) => { + const count = + sourceCode.getCommentsBefore(type).length + + sourceCode.getCommentsAfter(type).length; + + return count > 0; + }); + + let prev = null; + + for (let i = 0; i < expectedOrder.length; i += 1) { + const type = node.type === 'UnionTypeAnnotation' ? 'union' : 'intersection'; + const current = sourceOrder[i].text; + const last = prev; + + // keep track of the last token + prev = current || last; + + if (!last || !current) { + continue; + } + + if (expectedOrder[i].node !== sourceOrder[i].node) { + const data = { + current, + last, + order, + type, + }; + + const fix = (fixer) => { + const sorted = expectedOrder + .map((t) => { + return t.text; + }) + .join( + node.type === 'UnionTypeAnnotation' ? ' | ' : ' & ', + ); + + return fixer.replaceText(node, sorted); + }; + + context.report({ + data, + messageId: 'notSorted', + node, + + // don't autofix if any of the types have leading/trailing comments + // the logic for preserving them correctly is a pain - we may implement this later + ...hasComments ? + { + suggest: [ + { + fix, + messageId: 'suggestFix', + }, + ], + } : + {fix}, + }); + } + } + }; + + return { + IntersectionTypeAnnotation (node) { + if (checkIntersections === true) { + checkSorting(node); + } + }, + UnionTypeAnnotation (node) { + if (checkUnions === true) { + checkSorting(node); + } + }, + }; +}; + +export default { + create, + meta: { + fixable: 'code', + messages: { + notSorted: 'Expected {{type}} members to be in {{order}}ending order. "{{current}}" should be before "{{last}}".', + suggestFix: 'Sort members of type (removes all comments).', + }, + schema: [ + { + properties: { + checkIntersections: { + type: 'boolean', + }, + checkUnions: { + type: 'boolean', + }, + groupOrder: { + items: { + enum: Object.keys(groups), + type: 'string', + }, + type: 'array', + }, + order: { + enum: ['asc', 'desc'], + type: 'string', + }, + }, + type: 'object', + }, + ], + }, +}; diff --git a/tests/rules/assertions/sortTypeUnionIntersectionMembers.js b/tests/rules/assertions/sortTypeUnionIntersectionMembers.js new file mode 100644 index 0000000..4a43fa9 --- /dev/null +++ b/tests/rules/assertions/sortTypeUnionIntersectionMembers.js @@ -0,0 +1,93 @@ +export default { + invalid: [ + { + code: 'type T1 = B | A;', + errors: [{message: 'Expected union members to be in ascending order. "A" should be before "B".'}], + output: 'type T1 = A | B;', + }, + { + code: 'type T2 = { b: string } & { a: string };', + errors: [{message: 'Expected intersection members to be in ascending order. "{ a: string }" should be before "{ b: string }".'}], + output: 'type T2 = { a: string } & { b: string };', + }, + { + code: 'type T3 = [1, 2, 4] & [1, 2, 3];', + errors: [{message: 'Expected intersection members to be in ascending order. "[1, 2, 3]" should be before "[1, 2, 4]".'}], + output: 'type T3 = [1, 2, 3] & [1, 2, 4];', + }, + { + code: ` + type T4 = + | [1, 2, 4] + | [1, 2, 3] + | { b: string } + | { a: string } + | (() => void) + | (() => string) + | 'b' + | 'a' + | 'b' + | 'a' + | string[] + | number[] + | B + | A + | string + | any; + `, + errors: [ + {message: 'Expected union members to be in ascending order. "[1, 2, 3]" should be before "[1, 2, 4]".'}, + {message: 'Expected union members to be in ascending order. "{ b: string }" should be before "[1, 2, 3]".'}, + {message: 'Expected union members to be in ascending order. "{ a: string }" should be before "{ b: string }".'}, + {message: 'Expected union members to be in ascending order. "() => void" should be before "{ a: string }".'}, + {message: 'Expected union members to be in ascending order. "() => string" should be before "() => void".'}, + {message: 'Expected union members to be in ascending order. "\'b\'" should be before "() => string".'}, + {message: 'Expected union members to be in ascending order. "\'a\'" should be before "\'b\'".'}, + {message: 'Expected union members to be in ascending order. "\'b\'" should be before "\'a\'".'}, + {message: 'Expected union members to be in ascending order. "\'a\'" should be before "\'b\'".'}, + {message: 'Expected union members to be in ascending order. "string[]" should be before "\'a\'".'}, + {message: 'Expected union members to be in ascending order. "number[]" should be before "string[]".'}, + {message: 'Expected union members to be in ascending order. "B" should be before "number[]".'}, + {message: 'Expected union members to be in ascending order. "A" should be before "B".'}, + {message: 'Expected union members to be in ascending order. "string" should be before "A".'}, + {message: 'Expected union members to be in ascending order. "any" should be before "string".'}, + ], + output: ` + type T4 = + any | string | A | B | number[] | string[] | 'a' | 'a' | 'b' | 'b' | () => string | () => void | { a: string } | { b: string } | [1, 2, 3] | [1, 2, 4]; + `, + }, + ], + valid: [ + { + code: 'type T1 = A | B;', + }, + { + code: 'type T2 = { a: string } & { b: string };', + }, + { + code: 'type T3 = [1, 2, 3] & [1, 2, 4];', + }, + { + code: ` + type T4 = + | any + | string + | A + | B + | number[] + | string[] + | 'a' + | 'a' + | 'b' + | 'b' + | (() => string) + | (() => void) + | { a: string } + | { b: string } + | [1, 2, 3] + | [1, 2, 4]; + `, + }, + ], +}; diff --git a/tests/rules/index.js b/tests/rules/index.js index 619b6a0..069e54f 100644 --- a/tests/rules/index.js +++ b/tests/rules/index.js @@ -46,6 +46,7 @@ const reportingRules = [ 'require-variable-type', 'semi', 'sort-keys', + 'sort-type-union-intersection-members', 'space-after-type-colon', 'space-before-generic-bracket', 'space-before-type-colon',