]>
Commit | Line | Data |
---|---|---|
eb39fafa DC |
1 | /** |
2 | * @fileoverview Rule to require sorting of import declarations | |
3 | * @author Christian Schuller | |
4 | */ | |
5 | ||
6 | "use strict"; | |
7 | ||
8 | //------------------------------------------------------------------------------ | |
9 | // Rule Definition | |
10 | //------------------------------------------------------------------------------ | |
11 | ||
34eeec05 | 12 | /** @type {import('../shared/types').Rule} */ |
eb39fafa DC |
13 | module.exports = { |
14 | meta: { | |
15 | type: "suggestion", | |
16 | ||
17 | docs: { | |
8f9d1d4d | 18 | description: "Enforce sorted import declarations within modules", |
eb39fafa DC |
19 | recommended: false, |
20 | url: "https://eslint.org/docs/rules/sort-imports" | |
21 | }, | |
22 | ||
23 | schema: [ | |
24 | { | |
25 | type: "object", | |
26 | properties: { | |
27 | ignoreCase: { | |
28 | type: "boolean", | |
29 | default: false | |
30 | }, | |
31 | memberSyntaxSortOrder: { | |
32 | type: "array", | |
33 | items: { | |
34 | enum: ["none", "all", "multiple", "single"] | |
35 | }, | |
36 | uniqueItems: true, | |
37 | minItems: 4, | |
38 | maxItems: 4 | |
39 | }, | |
40 | ignoreDeclarationSort: { | |
41 | type: "boolean", | |
42 | default: false | |
43 | }, | |
44 | ignoreMemberSort: { | |
45 | type: "boolean", | |
46 | default: false | |
6f036462 TL |
47 | }, |
48 | allowSeparatedGroups: { | |
49 | type: "boolean", | |
50 | default: false | |
eb39fafa DC |
51 | } |
52 | }, | |
53 | additionalProperties: false | |
54 | } | |
55 | ], | |
56 | ||
57 | fixable: "code", | |
58 | ||
59 | messages: { | |
60 | sortImportsAlphabetically: "Imports should be sorted alphabetically.", | |
61 | sortMembersAlphabetically: "Member '{{memberName}}' of the import declaration should be sorted alphabetically.", | |
62 | unexpectedSyntaxOrder: "Expected '{{syntaxA}}' syntax before '{{syntaxB}}' syntax." | |
63 | } | |
64 | }, | |
65 | ||
66 | create(context) { | |
67 | ||
68 | const configuration = context.options[0] || {}, | |
69 | ignoreCase = configuration.ignoreCase || false, | |
70 | ignoreDeclarationSort = configuration.ignoreDeclarationSort || false, | |
71 | ignoreMemberSort = configuration.ignoreMemberSort || false, | |
72 | memberSyntaxSortOrder = configuration.memberSyntaxSortOrder || ["none", "all", "multiple", "single"], | |
6f036462 | 73 | allowSeparatedGroups = configuration.allowSeparatedGroups || false, |
eb39fafa DC |
74 | sourceCode = context.getSourceCode(); |
75 | let previousDeclaration = null; | |
76 | ||
77 | /** | |
78 | * Gets the used member syntax style. | |
79 | * | |
80 | * import "my-module.js" --> none | |
81 | * import * as myModule from "my-module.js" --> all | |
82 | * import {myMember} from "my-module.js" --> single | |
83 | * import {foo, bar} from "my-module.js" --> multiple | |
84 | * @param {ASTNode} node the ImportDeclaration node. | |
85 | * @returns {string} used member parameter style, ["all", "multiple", "single"] | |
86 | */ | |
87 | function usedMemberSyntax(node) { | |
88 | if (node.specifiers.length === 0) { | |
89 | return "none"; | |
90 | } | |
91 | if (node.specifiers[0].type === "ImportNamespaceSpecifier") { | |
92 | return "all"; | |
93 | } | |
94 | if (node.specifiers.length === 1) { | |
95 | return "single"; | |
96 | } | |
97 | return "multiple"; | |
98 | ||
99 | } | |
100 | ||
101 | /** | |
102 | * Gets the group by member parameter index for given declaration. | |
103 | * @param {ASTNode} node the ImportDeclaration node. | |
104 | * @returns {number} the declaration group by member index. | |
105 | */ | |
106 | function getMemberParameterGroupIndex(node) { | |
107 | return memberSyntaxSortOrder.indexOf(usedMemberSyntax(node)); | |
108 | } | |
109 | ||
110 | /** | |
111 | * Gets the local name of the first imported module. | |
112 | * @param {ASTNode} node the ImportDeclaration node. | |
113 | * @returns {?string} the local name of the first imported module. | |
114 | */ | |
115 | function getFirstLocalMemberName(node) { | |
116 | if (node.specifiers[0]) { | |
117 | return node.specifiers[0].local.name; | |
118 | } | |
119 | return null; | |
120 | ||
121 | } | |
122 | ||
6f036462 TL |
123 | /** |
124 | * Calculates number of lines between two nodes. It is assumed that the given `left` node appears before | |
125 | * the given `right` node in the source code. Lines are counted from the end of the `left` node till the | |
126 | * start of the `right` node. If the given nodes are on the same line, it returns `0`, same as if they were | |
127 | * on two consecutive lines. | |
128 | * @param {ASTNode} left node that appears before the given `right` node. | |
129 | * @param {ASTNode} right node that appears after the given `left` node. | |
130 | * @returns {number} number of lines between nodes. | |
131 | */ | |
132 | function getNumberOfLinesBetween(left, right) { | |
133 | return Math.max(right.loc.start.line - left.loc.end.line - 1, 0); | |
134 | } | |
135 | ||
eb39fafa DC |
136 | return { |
137 | ImportDeclaration(node) { | |
138 | if (!ignoreDeclarationSort) { | |
6f036462 TL |
139 | if ( |
140 | previousDeclaration && | |
141 | allowSeparatedGroups && | |
142 | getNumberOfLinesBetween(previousDeclaration, node) > 0 | |
143 | ) { | |
144 | ||
145 | // reset declaration sort | |
146 | previousDeclaration = null; | |
147 | } | |
148 | ||
eb39fafa DC |
149 | if (previousDeclaration) { |
150 | const currentMemberSyntaxGroupIndex = getMemberParameterGroupIndex(node), | |
151 | previousMemberSyntaxGroupIndex = getMemberParameterGroupIndex(previousDeclaration); | |
152 | let currentLocalMemberName = getFirstLocalMemberName(node), | |
153 | previousLocalMemberName = getFirstLocalMemberName(previousDeclaration); | |
154 | ||
155 | if (ignoreCase) { | |
156 | previousLocalMemberName = previousLocalMemberName && previousLocalMemberName.toLowerCase(); | |
157 | currentLocalMemberName = currentLocalMemberName && currentLocalMemberName.toLowerCase(); | |
158 | } | |
159 | ||
160 | /* | |
161 | * When the current declaration uses a different member syntax, | |
162 | * then check if the ordering is correct. | |
163 | * Otherwise, make a default string compare (like rule sort-vars to be consistent) of the first used local member name. | |
164 | */ | |
165 | if (currentMemberSyntaxGroupIndex !== previousMemberSyntaxGroupIndex) { | |
166 | if (currentMemberSyntaxGroupIndex < previousMemberSyntaxGroupIndex) { | |
167 | context.report({ | |
168 | node, | |
169 | messageId: "unexpectedSyntaxOrder", | |
170 | data: { | |
171 | syntaxA: memberSyntaxSortOrder[currentMemberSyntaxGroupIndex], | |
172 | syntaxB: memberSyntaxSortOrder[previousMemberSyntaxGroupIndex] | |
173 | } | |
174 | }); | |
175 | } | |
176 | } else { | |
177 | if (previousLocalMemberName && | |
178 | currentLocalMemberName && | |
179 | currentLocalMemberName < previousLocalMemberName | |
180 | ) { | |
181 | context.report({ | |
182 | node, | |
183 | messageId: "sortImportsAlphabetically" | |
184 | }); | |
185 | } | |
186 | } | |
187 | } | |
188 | ||
189 | previousDeclaration = node; | |
190 | } | |
191 | ||
192 | if (!ignoreMemberSort) { | |
193 | const importSpecifiers = node.specifiers.filter(specifier => specifier.type === "ImportSpecifier"); | |
194 | const getSortableName = ignoreCase ? specifier => specifier.local.name.toLowerCase() : specifier => specifier.local.name; | |
195 | const firstUnsortedIndex = importSpecifiers.map(getSortableName).findIndex((name, index, array) => array[index - 1] > name); | |
196 | ||
197 | if (firstUnsortedIndex !== -1) { | |
198 | context.report({ | |
199 | node: importSpecifiers[firstUnsortedIndex], | |
200 | messageId: "sortMembersAlphabetically", | |
201 | data: { memberName: importSpecifiers[firstUnsortedIndex].local.name }, | |
202 | fix(fixer) { | |
203 | if (importSpecifiers.some(specifier => | |
204 | sourceCode.getCommentsBefore(specifier).length || sourceCode.getCommentsAfter(specifier).length)) { | |
205 | ||
206 | // If there are comments in the ImportSpecifier list, don't rearrange the specifiers. | |
207 | return null; | |
208 | } | |
209 | ||
210 | return fixer.replaceTextRange( | |
211 | [importSpecifiers[0].range[0], importSpecifiers[importSpecifiers.length - 1].range[1]], | |
212 | importSpecifiers | |
213 | ||
214 | // Clone the importSpecifiers array to avoid mutating it | |
215 | .slice() | |
216 | ||
217 | // Sort the array into the desired order | |
218 | .sort((specifierA, specifierB) => { | |
219 | const aName = getSortableName(specifierA); | |
220 | const bName = getSortableName(specifierB); | |
221 | ||
222 | return aName > bName ? 1 : -1; | |
223 | }) | |
224 | ||
225 | // Build a string out of the sorted list of import specifiers and the text between the originals | |
226 | .reduce((sourceText, specifier, index) => { | |
227 | const textAfterSpecifier = index === importSpecifiers.length - 1 | |
228 | ? "" | |
229 | : sourceCode.getText().slice(importSpecifiers[index].range[1], importSpecifiers[index + 1].range[0]); | |
230 | ||
231 | return sourceText + sourceCode.getText(specifier) + textAfterSpecifier; | |
232 | }, "") | |
233 | ); | |
234 | } | |
235 | }); | |
236 | } | |
237 | } | |
238 | } | |
239 | }; | |
240 | } | |
241 | }; |