UNPKG

24.9 kBJavaScriptView Raw
1/**
2 * @fileoverview Rule to specify spacing of object literal keys and values
3 * @author Brandon Mills
4 */
5"use strict";
6
7//------------------------------------------------------------------------------
8// Requirements
9//------------------------------------------------------------------------------
10
11const astUtils = require("../ast-utils");
12
13//------------------------------------------------------------------------------
14// Helpers
15//------------------------------------------------------------------------------
16
17/**
18 * Checks whether a string contains a line terminator as defined in
19 * http://www.ecma-international.org/ecma-262/5.1/#sec-7.3
20 * @param {string} str String to test.
21 * @returns {boolean} True if str contains a line terminator.
22 */
23function containsLineTerminator(str) {
24 return astUtils.LINEBREAK_MATCHER.test(str);
25}
26
27/**
28 * Gets the last element of an array.
29 * @param {Array} arr An array.
30 * @returns {any} Last element of arr.
31 */
32function last(arr) {
33 return arr[arr.length - 1];
34}
35
36/**
37 * Checks whether a node is contained on a single line.
38 * @param {ASTNode} node AST Node being evaluated.
39 * @returns {boolean} True if the node is a single line.
40 */
41function isSingleLine(node) {
42 return (node.loc.end.line === node.loc.start.line);
43}
44
45/**
46 * Initializes a single option property from the configuration with defaults for undefined values
47 * @param {Object} toOptions Object to be initialized
48 * @param {Object} fromOptions Object to be initialized from
49 * @returns {Object} The object with correctly initialized options and values
50 */
51function initOptionProperty(toOptions, fromOptions) {
52 toOptions.mode = fromOptions.mode || "strict";
53
54 // Set value of beforeColon
55 if (typeof fromOptions.beforeColon !== "undefined") {
56 toOptions.beforeColon = +fromOptions.beforeColon;
57 } else {
58 toOptions.beforeColon = 0;
59 }
60
61 // Set value of afterColon
62 if (typeof fromOptions.afterColon !== "undefined") {
63 toOptions.afterColon = +fromOptions.afterColon;
64 } else {
65 toOptions.afterColon = 1;
66 }
67
68 // Set align if exists
69 if (typeof fromOptions.align !== "undefined") {
70 if (typeof fromOptions.align === "object") {
71 toOptions.align = fromOptions.align;
72 } else { // "string"
73 toOptions.align = {
74 on: fromOptions.align,
75 mode: toOptions.mode,
76 beforeColon: toOptions.beforeColon,
77 afterColon: toOptions.afterColon
78 };
79 }
80 }
81
82 return toOptions;
83}
84
85/**
86 * Initializes all the option values (singleLine, multiLine and align) from the configuration with defaults for undefined values
87 * @param {Object} toOptions Object to be initialized
88 * @param {Object} fromOptions Object to be initialized from
89 * @returns {Object} The object with correctly initialized options and values
90 */
91function initOptions(toOptions, fromOptions) {
92 if (typeof fromOptions.align === "object") {
93
94 // Initialize the alignment configuration
95 toOptions.align = initOptionProperty({}, fromOptions.align);
96 toOptions.align.on = fromOptions.align.on || "colon";
97 toOptions.align.mode = fromOptions.align.mode || "strict";
98
99 toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions));
100 toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions));
101
102 } else { // string or undefined
103 toOptions.multiLine = initOptionProperty({}, (fromOptions.multiLine || fromOptions));
104 toOptions.singleLine = initOptionProperty({}, (fromOptions.singleLine || fromOptions));
105
106 // If alignment options are defined in multiLine, pull them out into the general align configuration
107 if (toOptions.multiLine.align) {
108 toOptions.align = {
109 on: toOptions.multiLine.align.on,
110 mode: toOptions.multiLine.align.mode || toOptions.multiLine.mode,
111 beforeColon: toOptions.multiLine.align.beforeColon,
112 afterColon: toOptions.multiLine.align.afterColon
113 };
114 }
115 }
116
117 return toOptions;
118}
119
120//------------------------------------------------------------------------------
121// Rule Definition
122//------------------------------------------------------------------------------
123
124const messages = {
125 key: "{{error}} space after {{computed}}key '{{key}}'.",
126 value: "{{error}} space before value for {{computed}}key '{{key}}'."
127};
128
129module.exports = {
130 meta: {
131 docs: {
132 description: "enforce consistent spacing between keys and values in object literal properties",
133 category: "Stylistic Issues",
134 recommended: false
135 },
136
137 fixable: "whitespace",
138
139 schema: [{
140 anyOf: [
141 {
142 type: "object",
143 properties: {
144 align: {
145 anyOf: [
146 {
147 enum: ["colon", "value"]
148 },
149 {
150 type: "object",
151 properties: {
152 mode: {
153 enum: ["strict", "minimum"]
154 },
155 on: {
156 enum: ["colon", "value"]
157 },
158 beforeColon: {
159 type: "boolean"
160 },
161 afterColon: {
162 type: "boolean"
163 }
164 },
165 additionalProperties: false
166 }
167 ]
168 },
169 mode: {
170 enum: ["strict", "minimum"]
171 },
172 beforeColon: {
173 type: "boolean"
174 },
175 afterColon: {
176 type: "boolean"
177 }
178 },
179 additionalProperties: false
180 },
181 {
182 type: "object",
183 properties: {
184 singleLine: {
185 type: "object",
186 properties: {
187 mode: {
188 enum: ["strict", "minimum"]
189 },
190 beforeColon: {
191 type: "boolean"
192 },
193 afterColon: {
194 type: "boolean"
195 }
196 },
197 additionalProperties: false
198 },
199 multiLine: {
200 type: "object",
201 properties: {
202 align: {
203 anyOf: [
204 {
205 enum: ["colon", "value"]
206 },
207 {
208 type: "object",
209 properties: {
210 mode: {
211 enum: ["strict", "minimum"]
212 },
213 on: {
214 enum: ["colon", "value"]
215 },
216 beforeColon: {
217 type: "boolean"
218 },
219 afterColon: {
220 type: "boolean"
221 }
222 },
223 additionalProperties: false
224 }
225 ]
226 },
227 mode: {
228 enum: ["strict", "minimum"]
229 },
230 beforeColon: {
231 type: "boolean"
232 },
233 afterColon: {
234 type: "boolean"
235 }
236 },
237 additionalProperties: false
238 }
239 },
240 additionalProperties: false
241 },
242 {
243 type: "object",
244 properties: {
245 singleLine: {
246 type: "object",
247 properties: {
248 mode: {
249 enum: ["strict", "minimum"]
250 },
251 beforeColon: {
252 type: "boolean"
253 },
254 afterColon: {
255 type: "boolean"
256 }
257 },
258 additionalProperties: false
259 },
260 multiLine: {
261 type: "object",
262 properties: {
263 mode: {
264 enum: ["strict", "minimum"]
265 },
266 beforeColon: {
267 type: "boolean"
268 },
269 afterColon: {
270 type: "boolean"
271 }
272 },
273 additionalProperties: false
274 },
275 align: {
276 type: "object",
277 properties: {
278 mode: {
279 enum: ["strict", "minimum"]
280 },
281 on: {
282 enum: ["colon", "value"]
283 },
284 beforeColon: {
285 type: "boolean"
286 },
287 afterColon: {
288 type: "boolean"
289 }
290 },
291 additionalProperties: false
292 }
293 },
294 additionalProperties: false
295 }
296 ]
297 }]
298 },
299
300 create(context) {
301
302 /**
303 * OPTIONS
304 * "key-spacing": [2, {
305 * beforeColon: false,
306 * afterColon: true,
307 * align: "colon" // Optional, or "value"
308 * }
309 */
310 const options = context.options[0] || {},
311 ruleOptions = initOptions({}, options),
312 multiLineOptions = ruleOptions.multiLine,
313 singleLineOptions = ruleOptions.singleLine,
314 alignmentOptions = ruleOptions.align || null;
315
316 const sourceCode = context.getSourceCode();
317
318 /**
319 * Checks whether a property is a member of the property group it follows.
320 * @param {ASTNode} lastMember The last Property known to be in the group.
321 * @param {ASTNode} candidate The next Property that might be in the group.
322 * @returns {boolean} True if the candidate property is part of the group.
323 */
324 function continuesPropertyGroup(lastMember, candidate) {
325 const groupEndLine = lastMember.loc.start.line,
326 candidateStartLine = candidate.loc.start.line;
327
328 if (candidateStartLine - groupEndLine <= 1) {
329 return true;
330 }
331
332 // Check that the first comment is adjacent to the end of the group, the
333 // last comment is adjacent to the candidate property, and that successive
334 // comments are adjacent to each other.
335 const leadingComments = sourceCode.getCommentsBefore(candidate);
336
337 if (
338 leadingComments.length &&
339 leadingComments[0].loc.start.line - groupEndLine <= 1 &&
340 candidateStartLine - last(leadingComments).loc.end.line <= 1
341 ) {
342 for (let i = 1; i < leadingComments.length; i++) {
343 if (leadingComments[i].loc.start.line - leadingComments[i - 1].loc.end.line > 1) {
344 return false;
345 }
346 }
347 return true;
348 }
349
350 return false;
351 }
352
353 /**
354 * Determines if the given property is key-value property.
355 * @param {ASTNode} property Property node to check.
356 * @returns {boolean} Whether the property is a key-value property.
357 */
358 function isKeyValueProperty(property) {
359 return !(
360 (property.method ||
361 property.shorthand ||
362 property.kind !== "init" || property.type !== "Property") // Could be "ExperimentalSpreadProperty" or "SpreadProperty"
363 );
364 }
365
366 /**
367 * Starting from the given a node (a property.key node here) looks forward
368 * until it finds the last token before a colon punctuator and returns it.
369 * @param {ASTNode} node The node to start looking from.
370 * @returns {ASTNode} The last token before a colon punctuator.
371 */
372 function getLastTokenBeforeColon(node) {
373 const colonToken = sourceCode.getTokenAfter(node, astUtils.isColonToken);
374
375 return sourceCode.getTokenBefore(colonToken);
376 }
377
378 /**
379 * Starting from the given a node (a property.key node here) looks forward
380 * until it finds the colon punctuator and returns it.
381 * @param {ASTNode} node The node to start looking from.
382 * @returns {ASTNode} The colon punctuator.
383 */
384 function getNextColon(node) {
385 return sourceCode.getTokenAfter(node, astUtils.isColonToken);
386 }
387
388 /**
389 * Gets an object literal property's key as the identifier name or string value.
390 * @param {ASTNode} property Property node whose key to retrieve.
391 * @returns {string} The property's key.
392 */
393 function getKey(property) {
394 const key = property.key;
395
396 if (property.computed) {
397 return sourceCode.getText().slice(key.range[0], key.range[1]);
398 }
399
400 return property.key.name || property.key.value;
401 }
402
403 /**
404 * Reports an appropriately-formatted error if spacing is incorrect on one
405 * side of the colon.
406 * @param {ASTNode} property Key-value pair in an object literal.
407 * @param {string} side Side being verified - either "key" or "value".
408 * @param {string} whitespace Actual whitespace string.
409 * @param {int} expected Expected whitespace length.
410 * @param {string} mode Value of the mode as "strict" or "minimum"
411 * @returns {void}
412 */
413 function report(property, side, whitespace, expected, mode) {
414 const diff = whitespace.length - expected,
415 nextColon = getNextColon(property.key),
416 tokenBeforeColon = sourceCode.getTokenBefore(nextColon, { includeComments: true }),
417 tokenAfterColon = sourceCode.getTokenAfter(nextColon, { includeComments: true }),
418 isKeySide = side === "key",
419 locStart = isKeySide ? tokenBeforeColon.loc.start : tokenAfterColon.loc.start,
420 isExtra = diff > 0,
421 diffAbs = Math.abs(diff),
422 spaces = Array(diffAbs + 1).join(" ");
423 let fix;
424
425 if ((
426 diff && mode === "strict" ||
427 diff < 0 && mode === "minimum" ||
428 diff > 0 && !expected && mode === "minimum") &&
429 !(expected && containsLineTerminator(whitespace))
430 ) {
431 if (isExtra) {
432 let range;
433
434 // Remove whitespace
435 if (isKeySide) {
436 range = [tokenBeforeColon.range[1], tokenBeforeColon.range[1] + diffAbs];
437 } else {
438 range = [tokenAfterColon.range[0] - diffAbs, tokenAfterColon.range[0]];
439 }
440 fix = function(fixer) {
441 return fixer.removeRange(range);
442 };
443 } else {
444
445 // Add whitespace
446 if (isKeySide) {
447 fix = function(fixer) {
448 return fixer.insertTextAfter(tokenBeforeColon, spaces);
449 };
450 } else {
451 fix = function(fixer) {
452 return fixer.insertTextBefore(tokenAfterColon, spaces);
453 };
454 }
455 }
456
457 context.report({
458 node: property[side],
459 loc: locStart,
460 message: messages[side],
461 data: {
462 error: isExtra ? "Extra" : "Missing",
463 computed: property.computed ? "computed " : "",
464 key: getKey(property)
465 },
466 fix
467 });
468 }
469 }
470
471 /**
472 * Gets the number of characters in a key, including quotes around string
473 * keys and braces around computed property keys.
474 * @param {ASTNode} property Property of on object literal.
475 * @returns {int} Width of the key.
476 */
477 function getKeyWidth(property) {
478 const startToken = sourceCode.getFirstToken(property);
479 const endToken = getLastTokenBeforeColon(property.key);
480
481 return endToken.range[1] - startToken.range[0];
482 }
483
484 /**
485 * Gets the whitespace around the colon in an object literal property.
486 * @param {ASTNode} property Property node from an object literal.
487 * @returns {Object} Whitespace before and after the property's colon.
488 */
489 function getPropertyWhitespace(property) {
490 const whitespace = /(\s*):(\s*)/.exec(sourceCode.getText().slice(
491 property.key.range[1], property.value.range[0]
492 ));
493
494 if (whitespace) {
495 return {
496 beforeColon: whitespace[1],
497 afterColon: whitespace[2]
498 };
499 }
500 return null;
501 }
502
503 /**
504 * Creates groups of properties.
505 * @param {ASTNode} node ObjectExpression node being evaluated.
506 * @returns {Array.<ASTNode[]>} Groups of property AST node lists.
507 */
508 function createGroups(node) {
509 if (node.properties.length === 1) {
510 return [node.properties];
511 }
512
513 return node.properties.reduce((groups, property) => {
514 const currentGroup = last(groups),
515 prev = last(currentGroup);
516
517 if (!prev || continuesPropertyGroup(prev, property)) {
518 currentGroup.push(property);
519 } else {
520 groups.push([property]);
521 }
522
523 return groups;
524 }, [
525 []
526 ]);
527 }
528
529 /**
530 * Verifies correct vertical alignment of a group of properties.
531 * @param {ASTNode[]} properties List of Property AST nodes.
532 * @returns {void}
533 */
534 function verifyGroupAlignment(properties) {
535 const length = properties.length,
536 widths = properties.map(getKeyWidth), // Width of keys, including quotes
537 align = alignmentOptions.on; // "value" or "colon"
538 let targetWidth = Math.max.apply(null, widths),
539 beforeColon, afterColon, mode;
540
541 if (alignmentOptions && length > 1) { // When aligning values within a group, use the alignment configuration.
542 beforeColon = alignmentOptions.beforeColon;
543 afterColon = alignmentOptions.afterColon;
544 mode = alignmentOptions.mode;
545 } else {
546 beforeColon = multiLineOptions.beforeColon;
547 afterColon = multiLineOptions.afterColon;
548 mode = alignmentOptions.mode;
549 }
550
551 // Conditionally include one space before or after colon
552 targetWidth += (align === "colon" ? beforeColon : afterColon);
553
554 for (let i = 0; i < length; i++) {
555 const property = properties[i];
556 const whitespace = getPropertyWhitespace(property);
557
558 if (whitespace) { // Object literal getters/setters lack a colon
559 const width = widths[i];
560
561 if (align === "value") {
562 report(property, "key", whitespace.beforeColon, beforeColon, mode);
563 report(property, "value", whitespace.afterColon, targetWidth - width, mode);
564 } else { // align = "colon"
565 report(property, "key", whitespace.beforeColon, targetWidth - width, mode);
566 report(property, "value", whitespace.afterColon, afterColon, mode);
567 }
568 }
569 }
570 }
571
572 /**
573 * Verifies vertical alignment, taking into account groups of properties.
574 * @param {ASTNode} node ObjectExpression node being evaluated.
575 * @returns {void}
576 */
577 function verifyAlignment(node) {
578 createGroups(node).forEach(group => {
579 verifyGroupAlignment(group.filter(isKeyValueProperty));
580 });
581 }
582
583 /**
584 * Verifies spacing of property conforms to specified options.
585 * @param {ASTNode} node Property node being evaluated.
586 * @param {Object} lineOptions Configured singleLine or multiLine options
587 * @returns {void}
588 */
589 function verifySpacing(node, lineOptions) {
590 const actual = getPropertyWhitespace(node);
591
592 if (actual) { // Object literal getters/setters lack colons
593 report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode);
594 report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode);
595 }
596 }
597
598 /**
599 * Verifies spacing of each property in a list.
600 * @param {ASTNode[]} properties List of Property AST nodes.
601 * @returns {void}
602 */
603 function verifyListSpacing(properties) {
604 const length = properties.length;
605
606 for (let i = 0; i < length; i++) {
607 verifySpacing(properties[i], singleLineOptions);
608 }
609 }
610
611 //--------------------------------------------------------------------------
612 // Public API
613 //--------------------------------------------------------------------------
614
615 if (alignmentOptions) { // Verify vertical alignment
616
617 return {
618 ObjectExpression(node) {
619 if (isSingleLine(node)) {
620 verifyListSpacing(node.properties.filter(isKeyValueProperty));
621 } else {
622 verifyAlignment(node);
623 }
624 }
625 };
626
627 }
628
629 // Obey beforeColon and afterColon in each property as configured
630 return {
631 Property(node) {
632 verifySpacing(node, isSingleLine(node.parent) ? singleLineOptions : multiLineOptions);
633 }
634 };
635
636
637 }
638};