UNPKG

11.9 kBJavaScriptView Raw
1/**
2 * @fileoverview Enforce props alphabetical sorting
3 * @author Ilya Volodin, Yannick Croissant
4 */
5
6'use strict';
7
8const propName = require('jsx-ast-utils/propName');
9const docsUrl = require('../util/docsUrl');
10const jsxUtil = require('../util/jsx');
11
12// ------------------------------------------------------------------------------
13// Rule Definition
14// ------------------------------------------------------------------------------
15
16function isCallbackPropName(name) {
17 return /^on[A-Z]/.test(name);
18}
19
20const RESERVED_PROPS_LIST = [
21 'children',
22 'dangerouslySetInnerHTML',
23 'key',
24 'ref'
25];
26
27function isReservedPropName(name, list) {
28 return list.indexOf(name) >= 0;
29}
30
31function contextCompare(a, b, options) {
32 let aProp = propName(a);
33 let bProp = propName(b);
34
35 if (options.reservedFirst) {
36 const aIsReserved = isReservedPropName(aProp, options.reservedList);
37 const bIsReserved = isReservedPropName(bProp, options.reservedList);
38 if (aIsReserved && !bIsReserved) {
39 return -1;
40 }
41 if (!aIsReserved && bIsReserved) {
42 return 1;
43 }
44 }
45
46 if (options.callbacksLast) {
47 const aIsCallback = isCallbackPropName(aProp);
48 const bIsCallback = isCallbackPropName(bProp);
49 if (aIsCallback && !bIsCallback) {
50 return 1;
51 }
52 if (!aIsCallback && bIsCallback) {
53 return -1;
54 }
55 }
56
57 if (options.shorthandFirst || options.shorthandLast) {
58 const shorthandSign = options.shorthandFirst ? -1 : 1;
59 if (!a.value && b.value) {
60 return shorthandSign;
61 }
62 if (a.value && !b.value) {
63 return -shorthandSign;
64 }
65 }
66
67 if (options.noSortAlphabetically) {
68 return 0;
69 }
70
71 if (options.ignoreCase) {
72 aProp = aProp.toLowerCase();
73 bProp = bProp.toLowerCase();
74 return aProp.localeCompare(bProp);
75 }
76 if (aProp === bProp) {
77 return 0;
78 }
79 return aProp < bProp ? -1 : 1;
80}
81
82/**
83 * Create an array of arrays where each subarray is composed of attributes
84 * that are considered sortable.
85 * @param {Array<JSXSpreadAttribute|JSXAttribute>} attributes
86 * @return {Array<Array<JSXAttribute>>}
87 */
88function getGroupsOfSortableAttributes(attributes) {
89 const sortableAttributeGroups = [];
90 let groupCount = 0;
91 for (let i = 0; i < attributes.length; i++) {
92 const lastAttr = attributes[i - 1];
93 // If we have no groups or if the last attribute was JSXSpreadAttribute
94 // then we start a new group. Append attributes to the group until we
95 // come across another JSXSpreadAttribute or exhaust the array.
96 if (
97 !lastAttr
98 || (lastAttr.type === 'JSXSpreadAttribute'
99 && attributes[i].type !== 'JSXSpreadAttribute')
100 ) {
101 groupCount++;
102 sortableAttributeGroups[groupCount - 1] = [];
103 }
104 if (attributes[i].type !== 'JSXSpreadAttribute') {
105 sortableAttributeGroups[groupCount - 1].push(attributes[i]);
106 }
107 }
108 return sortableAttributeGroups;
109}
110
111const generateFixerFunction = (node, context, reservedList) => {
112 const sourceCode = context.getSourceCode();
113 const attributes = node.attributes.slice(0);
114 const configuration = context.options[0] || {};
115 const ignoreCase = configuration.ignoreCase || false;
116 const callbacksLast = configuration.callbacksLast || false;
117 const shorthandFirst = configuration.shorthandFirst || false;
118 const shorthandLast = configuration.shorthandLast || false;
119 const noSortAlphabetically = configuration.noSortAlphabetically || false;
120 const reservedFirst = configuration.reservedFirst || false;
121
122 // Sort props according to the context. Only supports ignoreCase.
123 // Since we cannot safely move JSXSpreadAttribute (due to potential variable overrides),
124 // we only consider groups of sortable attributes.
125 const options = {
126 ignoreCase,
127 callbacksLast,
128 shorthandFirst,
129 shorthandLast,
130 noSortAlphabetically,
131 reservedFirst,
132 reservedList
133 };
134 const sortableAttributeGroups = getGroupsOfSortableAttributes(attributes);
135 const sortedAttributeGroups = sortableAttributeGroups
136 .slice(0)
137 .map((group) => group.slice(0).sort((a, b) => contextCompare(a, b, options)));
138
139 return function fixFunction(fixer) {
140 const fixers = [];
141 let source = sourceCode.getText();
142
143 // Replace each unsorted attribute with the sorted one.
144 sortableAttributeGroups.forEach((sortableGroup, ii) => {
145 sortableGroup.forEach((attr, jj) => {
146 const sortedAttr = sortedAttributeGroups[ii][jj];
147 const sortedAttrText = sourceCode.getText(sortedAttr);
148 fixers.push({
149 range: [attr.range[0], attr.range[1]],
150 text: sortedAttrText
151 });
152 });
153 });
154
155 fixers.sort((a, b) => b.range[0] - a.range[0]);
156
157 const rangeStart = fixers[fixers.length - 1].range[0];
158 const rangeEnd = fixers[0].range[1];
159
160 fixers.forEach((fix) => {
161 source = `${source.substr(0, fix.range[0])}${fix.text}${source.substr(fix.range[1])}`;
162 });
163
164 return fixer.replaceTextRange([rangeStart, rangeEnd], source.substr(rangeStart, rangeEnd - rangeStart));
165 };
166};
167
168/**
169 * Checks if the `reservedFirst` option is valid
170 * @param {Object} context The context of the rule
171 * @param {Boolean|Array<String>} reservedFirst The `reservedFirst` option
172 * @return {Function|undefined} If an error is detected, a function to generate the error message, otherwise, `undefined`
173 */
174// eslint-disable-next-line consistent-return
175function validateReservedFirstConfig(context, reservedFirst) {
176 if (reservedFirst) {
177 if (Array.isArray(reservedFirst)) {
178 // Only allow a subset of reserved words in customized lists
179 const nonReservedWords = reservedFirst.filter((word) => !isReservedPropName(
180 word,
181 RESERVED_PROPS_LIST
182 ));
183
184 if (reservedFirst.length === 0) {
185 return function report(decl) {
186 context.report({
187 node: decl,
188 message: 'A customized reserved first list must not be empty'
189 });
190 };
191 }
192 if (nonReservedWords.length > 0) {
193 return function report(decl) {
194 context.report({
195 node: decl,
196 message: 'A customized reserved first list must only contain a subset of React reserved props.'
197 + ' Remove: {{ nonReservedWords }}',
198 data: {
199 nonReservedWords: nonReservedWords.toString()
200 }
201 });
202 };
203 }
204 }
205 }
206}
207
208module.exports = {
209 meta: {
210 docs: {
211 description: 'Enforce props alphabetical sorting',
212 category: 'Stylistic Issues',
213 recommended: false,
214 url: docsUrl('jsx-sort-props')
215 },
216 fixable: 'code',
217 schema: [{
218 type: 'object',
219 properties: {
220 // Whether callbacks (prefixed with "on") should be listed at the very end,
221 // after all other props. Supersedes shorthandLast.
222 callbacksLast: {
223 type: 'boolean'
224 },
225 // Whether shorthand properties (without a value) should be listed first
226 shorthandFirst: {
227 type: 'boolean'
228 },
229 // Whether shorthand properties (without a value) should be listed last
230 shorthandLast: {
231 type: 'boolean'
232 },
233 ignoreCase: {
234 type: 'boolean'
235 },
236 // Whether alphabetical sorting should be enforced
237 noSortAlphabetically: {
238 type: 'boolean'
239 },
240 reservedFirst: {
241 type: ['array', 'boolean']
242 }
243 },
244 additionalProperties: false
245 }]
246 },
247
248 create(context) {
249 const configuration = context.options[0] || {};
250 const ignoreCase = configuration.ignoreCase || false;
251 const callbacksLast = configuration.callbacksLast || false;
252 const shorthandFirst = configuration.shorthandFirst || false;
253 const shorthandLast = configuration.shorthandLast || false;
254 const noSortAlphabetically = configuration.noSortAlphabetically || false;
255 const reservedFirst = configuration.reservedFirst || false;
256 const reservedFirstError = validateReservedFirstConfig(context, reservedFirst);
257 let reservedList = Array.isArray(reservedFirst) ? reservedFirst : RESERVED_PROPS_LIST;
258
259 return {
260 JSXOpeningElement(node) {
261 // `dangerouslySetInnerHTML` is only "reserved" on DOM components
262 if (reservedFirst && !jsxUtil.isDOMComponent(node)) {
263 reservedList = reservedList.filter((prop) => prop !== 'dangerouslySetInnerHTML');
264 }
265
266 node.attributes.reduce((memo, decl, idx, attrs) => {
267 if (decl.type === 'JSXSpreadAttribute') {
268 return attrs[idx + 1];
269 }
270
271 let previousPropName = propName(memo);
272 let currentPropName = propName(decl);
273 const previousValue = memo.value;
274 const currentValue = decl.value;
275 const previousIsCallback = isCallbackPropName(previousPropName);
276 const currentIsCallback = isCallbackPropName(currentPropName);
277
278 if (ignoreCase) {
279 previousPropName = previousPropName.toLowerCase();
280 currentPropName = currentPropName.toLowerCase();
281 }
282
283 if (reservedFirst) {
284 if (reservedFirstError) {
285 reservedFirstError(decl);
286 return memo;
287 }
288
289 const previousIsReserved = isReservedPropName(previousPropName, reservedList);
290 const currentIsReserved = isReservedPropName(currentPropName, reservedList);
291
292 if (previousIsReserved && !currentIsReserved) {
293 return decl;
294 }
295 if (!previousIsReserved && currentIsReserved) {
296 context.report({
297 node: decl.name,
298 message: 'Reserved props must be listed before all other props',
299 fix: generateFixerFunction(node, context, reservedList)
300 });
301 return memo;
302 }
303 }
304
305 if (callbacksLast) {
306 if (!previousIsCallback && currentIsCallback) {
307 // Entering the callback prop section
308 return decl;
309 }
310 if (previousIsCallback && !currentIsCallback) {
311 // Encountered a non-callback prop after a callback prop
312 context.report({
313 node: memo.name,
314 message: 'Callbacks must be listed after all other props',
315 fix: generateFixerFunction(node, context, reservedList)
316 });
317 return memo;
318 }
319 }
320
321 if (shorthandFirst) {
322 if (currentValue && !previousValue) {
323 return decl;
324 }
325 if (!currentValue && previousValue) {
326 context.report({
327 node: memo.name,
328 message: 'Shorthand props must be listed before all other props',
329 fix: generateFixerFunction(node, context, reservedList)
330 });
331 return memo;
332 }
333 }
334
335 if (shorthandLast) {
336 if (!currentValue && previousValue) {
337 return decl;
338 }
339 if (currentValue && !previousValue) {
340 context.report({
341 node: memo.name,
342 message: 'Shorthand props must be listed after all other props',
343 fix: generateFixerFunction(node, context, reservedList)
344 });
345 return memo;
346 }
347 }
348
349 if (
350 !noSortAlphabetically
351 && (
352 ignoreCase
353 ? previousPropName.localeCompare(currentPropName) > 0
354 : previousPropName > currentPropName
355 )
356 ) {
357 context.report({
358 node: decl.name,
359 message: 'Props should be sorted alphabetically',
360 fix: generateFixerFunction(node, context, reservedList)
361 });
362 return memo;
363 }
364
365 return decl;
366 }, node.attributes[0]);
367 }
368 };
369 }
370};