Skip to content

Commit fa4207d

Browse files
author
Geraint White
authored
feat: add new rule sort-type-union-intersection-members (#501)
1 parent 4265b27 commit fa4207d

File tree

6 files changed

+436
-0
lines changed

6 files changed

+436
-0
lines changed

.README/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ When `true`, only checks files with a [`@flow` annotation](http://flowtype.org/d
193193
{"gitdown": "include", "file": "./rules/require-variable-type.md"}
194194
{"gitdown": "include", "file": "./rules/semi.md"}
195195
{"gitdown": "include", "file": "./rules/sort-keys.md"}
196+
{"gitdown": "include", "file": "./rules/sort-type-union-intersection-members.md"}
196197
{"gitdown": "include", "file": "./rules/space-after-type-colon.md"}
197198
{"gitdown": "include", "file": "./rules/space-before-generic-bracket.md"}
198199
{"gitdown": "include", "file": "./rules/space-before-type-colon.md"}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
### `sort-type-union-intersection-members`
2+
3+
_The `--fix` option on the command line automatically fixes problems reported by this rule._
4+
5+
Enforces that members of a type union/intersection are sorted alphabetically.
6+
7+
#### Options
8+
9+
You can specify the sort order using `order`.
10+
11+
* `"asc"` (default) - enforce ascending sort order.
12+
* `"desc"` - enforce descending sort order.
13+
14+
```js
15+
{
16+
"rules": {
17+
"flowtype/sort-type-union-intersection-members": [
18+
2,
19+
{
20+
"order": "asc"
21+
}
22+
]
23+
}
24+
}
25+
```
26+
27+
You can disable checking intersection types using `checkIntersections`.
28+
29+
* `true` (default) - enforce sort order of intersection members.
30+
* `false` - do not enforce sort order of intersection members.
31+
32+
```js
33+
{
34+
"rules": {
35+
"flowtype/sort-type-union-intersection-members": [
36+
2,
37+
{
38+
"checkIntersections": true
39+
}
40+
]
41+
}
42+
}
43+
```
44+
45+
You can disable checking union types using `checkUnions`.
46+
47+
* `true` (default) - enforce sort order of union members.
48+
* `false` - do not enforce sort order of union members.
49+
50+
```js
51+
{
52+
"rules": {
53+
"flowtype/sort-type-union-intersection-members": [
54+
2,
55+
{
56+
"checkUnions": true
57+
}
58+
]
59+
}
60+
}
61+
```
62+
63+
You can specify the ordering of groups using `groupOrder`.
64+
65+
Each member of the type is placed into a group, and then the rule sorts alphabetically within each group.
66+
The ordering of groups is determined by this option.
67+
68+
* `keyword` - Keyword types (`any`, `string`, etc)
69+
* `named` - Named types (`A`, `A['prop']`, `B[]`, `Array<C>`)
70+
* `literal` - Literal types (`1`, `'b'`, `true`, etc)
71+
* `function` - Function types (`() => void`)
72+
* `object` - Object types (`{ a: string }`, `{ [key: string]: number }`)
73+
* `tuple` - Tuple types (`[A, B, C]`)
74+
* `intersection` - Intersection types (`A & B`)
75+
* `union` - Union types (`A | B`)
76+
* `nullish` - `null` and `undefined`
77+
78+
```js
79+
{
80+
"rules": {
81+
"flowtype/sort-type-union-intersection-members": [
82+
2,
83+
{
84+
"groupOrder": [
85+
'keyword',
86+
'named',
87+
'literal',
88+
'function',
89+
'object',
90+
'tuple',
91+
'intersection',
92+
'union',
93+
'nullish',
94+
]
95+
}
96+
]
97+
}
98+
}
99+
```
100+
101+
<!-- assertions sortTypeUnionIntersectionMembers -->

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import requireValidFileAnnotation from './rules/requireValidFileAnnotation';
3535
import requireVariableType from './rules/requireVariableType';
3636
import semi from './rules/semi';
3737
import sortKeys from './rules/sortKeys';
38+
import sortTypeUnionIntersectionMembers from './rules/sortTypeUnionIntersectionMembers';
3839
import spaceAfterTypeColon from './rules/spaceAfterTypeColon';
3940
import spaceBeforeGenericBracket from './rules/spaceBeforeGenericBracket';
4041
import spaceBeforeTypeColon from './rules/spaceBeforeTypeColon';
@@ -84,6 +85,7 @@ const rules = {
8485
'require-variable-type': requireVariableType,
8586
semi,
8687
'sort-keys': sortKeys,
88+
'sort-type-union-intersection-members': sortTypeUnionIntersectionMembers,
8789
'space-after-type-colon': spaceAfterTypeColon,
8890
'space-before-generic-bracket': spaceBeforeGenericBracket,
8991
'space-before-type-colon': spaceBeforeTypeColon,
@@ -133,6 +135,7 @@ export default {
133135
'require-variable-type': 0,
134136
semi: 0,
135137
'sort-keys': 0,
138+
'sort-type-union-intersection-members': 0,
136139
'space-after-type-colon': 0,
137140
'space-before-generic-bracket': 0,
138141
'space-before-type-colon': 0,
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
const groups = {
2+
function: 'function',
3+
intersection: 'intersection',
4+
keyword: 'keyword',
5+
literal: 'literal',
6+
named: 'named',
7+
nullish: 'nullish',
8+
object: 'object',
9+
tuple: 'tuple',
10+
union: 'union',
11+
unknown: 'unknown',
12+
};
13+
14+
// eslint-disable-next-line complexity
15+
const getGroup = (node) => {
16+
switch (node.type) {
17+
case 'FunctionTypeAnnotation':
18+
return groups.function;
19+
20+
case 'IntersectionTypeAnnotation':
21+
return groups.intersection;
22+
23+
case 'AnyTypeAnnotation':
24+
case 'BooleanTypeAnnotation':
25+
case 'NumberTypeAnnotation':
26+
case 'StringTypeAnnotation':
27+
case 'SymbolTypeAnnotation':
28+
case 'ThisTypeAnnotation':
29+
return groups.keyword;
30+
31+
case 'NullLiteralTypeAnnotation':
32+
case 'NullableTypeAnnotation':
33+
case 'VoidTypeAnnotation':
34+
return groups.nullish;
35+
36+
case 'BooleanLiteralTypeAnnotation':
37+
case 'NumberLiteralTypeAnnotation':
38+
case 'StringLiteralTypeAnnotation':
39+
return groups.literal;
40+
41+
case 'ArrayTypeAnnotation':
42+
case 'IndexedAccessType':
43+
case 'GenericTypeAnnotation':
44+
case 'OptionalIndexedAccessType':
45+
return groups.named;
46+
47+
case 'ObjectTypeAnnotation':
48+
return groups.object;
49+
50+
case 'TupleTypeAnnotation':
51+
return groups.tuple;
52+
53+
case 'UnionTypeAnnotation':
54+
return groups.union;
55+
}
56+
57+
return groups.unknown;
58+
};
59+
60+
const fallbackSort = (a, b) => {
61+
if (a < b) {
62+
return -1;
63+
}
64+
if (a > b) {
65+
return 1;
66+
}
67+
68+
return 0;
69+
};
70+
71+
const sorters = {
72+
asc: (collator, a, b) => {
73+
return collator.compare(a, b) || fallbackSort(a, b);
74+
},
75+
desc: (collator, a, b) => {
76+
return collator.compare(b, a) || fallbackSort(b, a);
77+
},
78+
};
79+
80+
const create = (context) => {
81+
const sourceCode = context.getSourceCode();
82+
83+
const {
84+
checkIntersections = true,
85+
checkUnions = true,
86+
groupOrder = [
87+
groups.keyword,
88+
groups.named,
89+
groups.literal,
90+
groups.function,
91+
groups.object,
92+
groups.tuple,
93+
groups.intersection,
94+
groups.union,
95+
groups.nullish,
96+
],
97+
order = 'asc',
98+
} = context.options[1] || {};
99+
100+
const sort = sorters[order];
101+
102+
const collator = new Intl.Collator('en', {
103+
numeric: true,
104+
sensitivity: 'base',
105+
});
106+
107+
const checkSorting = (node) => {
108+
const sourceOrder = node.types.map((type) => {
109+
const group = groupOrder?.indexOf(getGroup(type)) ?? -1;
110+
111+
return {
112+
group: group === -1 ? Number.MAX_SAFE_INTEGER : group,
113+
node: type,
114+
text: sourceCode.getText(type),
115+
};
116+
});
117+
118+
const expectedOrder = [...sourceOrder].sort((a, b) => {
119+
if (a.group !== b.group) {
120+
return a.group - b.group;
121+
}
122+
123+
return sort(collator, a.text, b.text);
124+
});
125+
126+
const hasComments = node.types.some((type) => {
127+
const count =
128+
sourceCode.getCommentsBefore(type).length +
129+
sourceCode.getCommentsAfter(type).length;
130+
131+
return count > 0;
132+
});
133+
134+
let prev = null;
135+
136+
for (let i = 0; i < expectedOrder.length; i += 1) {
137+
const type = node.type === 'UnionTypeAnnotation' ? 'union' : 'intersection';
138+
const current = sourceOrder[i].text;
139+
const last = prev;
140+
141+
// keep track of the last token
142+
prev = current || last;
143+
144+
if (!last || !current) {
145+
continue;
146+
}
147+
148+
if (expectedOrder[i].node !== sourceOrder[i].node) {
149+
const data = {
150+
current,
151+
last,
152+
order,
153+
type,
154+
};
155+
156+
const fix = (fixer) => {
157+
const sorted = expectedOrder
158+
.map((t) => {
159+
return t.text;
160+
})
161+
.join(
162+
node.type === 'UnionTypeAnnotation' ? ' | ' : ' & ',
163+
);
164+
165+
return fixer.replaceText(node, sorted);
166+
};
167+
168+
context.report({
169+
data,
170+
messageId: 'notSorted',
171+
node,
172+
173+
// don't autofix if any of the types have leading/trailing comments
174+
// the logic for preserving them correctly is a pain - we may implement this later
175+
...hasComments ?
176+
{
177+
suggest: [
178+
{
179+
fix,
180+
messageId: 'suggestFix',
181+
},
182+
],
183+
} :
184+
{fix},
185+
});
186+
}
187+
}
188+
};
189+
190+
return {
191+
IntersectionTypeAnnotation (node) {
192+
if (checkIntersections === true) {
193+
checkSorting(node);
194+
}
195+
},
196+
UnionTypeAnnotation (node) {
197+
if (checkUnions === true) {
198+
checkSorting(node);
199+
}
200+
},
201+
};
202+
};
203+
204+
export default {
205+
create,
206+
meta: {
207+
fixable: 'code',
208+
messages: {
209+
notSorted: 'Expected {{type}} members to be in {{order}}ending order. "{{current}}" should be before "{{last}}".',
210+
suggestFix: 'Sort members of type (removes all comments).',
211+
},
212+
schema: [
213+
{
214+
properties: {
215+
checkIntersections: {
216+
type: 'boolean',
217+
},
218+
checkUnions: {
219+
type: 'boolean',
220+
},
221+
groupOrder: {
222+
items: {
223+
enum: Object.keys(groups),
224+
type: 'string',
225+
},
226+
type: 'array',
227+
},
228+
order: {
229+
enum: ['asc', 'desc'],
230+
type: 'string',
231+
},
232+
},
233+
type: 'object',
234+
},
235+
],
236+
},
237+
};

0 commit comments

Comments
 (0)