1 | /**
|
2 | * @fileoverview Rule to specify spacing of object literal keys and values
|
3 | * @author Brandon Mills
|
4 | */
|
5 | ;
|
6 |
|
7 | //------------------------------------------------------------------------------
|
8 | // Requirements
|
9 | //------------------------------------------------------------------------------
|
10 |
|
11 | const 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 | */
|
23 | function 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 | */
|
32 | function 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 | */
|
41 | function 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 | */
|
51 | function 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 | */
|
91 | function 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 |
|
124 | const messages = {
|
125 | key: "{{error}} space after {{computed}}key '{{key}}'.",
|
126 | value: "{{error}} space before value for {{computed}}key '{{key}}'."
|
127 | };
|
128 |
|
129 | module.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 | };
|