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 | url: "https://eslint.org/docs/rules/key-spacing"
|
136 | },
|
137 |
|
138 | fixable: "whitespace",
|
139 |
|
140 | schema: [{
|
141 | anyOf: [
|
142 | {
|
143 | type: "object",
|
144 | properties: {
|
145 | align: {
|
146 | anyOf: [
|
147 | {
|
148 | enum: ["colon", "value"]
|
149 | },
|
150 | {
|
151 | type: "object",
|
152 | properties: {
|
153 | mode: {
|
154 | enum: ["strict", "minimum"]
|
155 | },
|
156 | on: {
|
157 | enum: ["colon", "value"]
|
158 | },
|
159 | beforeColon: {
|
160 | type: "boolean"
|
161 | },
|
162 | afterColon: {
|
163 | type: "boolean"
|
164 | }
|
165 | },
|
166 | additionalProperties: false
|
167 | }
|
168 | ]
|
169 | },
|
170 | mode: {
|
171 | enum: ["strict", "minimum"]
|
172 | },
|
173 | beforeColon: {
|
174 | type: "boolean"
|
175 | },
|
176 | afterColon: {
|
177 | type: "boolean"
|
178 | }
|
179 | },
|
180 | additionalProperties: false
|
181 | },
|
182 | {
|
183 | type: "object",
|
184 | properties: {
|
185 | singleLine: {
|
186 | type: "object",
|
187 | properties: {
|
188 | mode: {
|
189 | enum: ["strict", "minimum"]
|
190 | },
|
191 | beforeColon: {
|
192 | type: "boolean"
|
193 | },
|
194 | afterColon: {
|
195 | type: "boolean"
|
196 | }
|
197 | },
|
198 | additionalProperties: false
|
199 | },
|
200 | multiLine: {
|
201 | type: "object",
|
202 | properties: {
|
203 | align: {
|
204 | anyOf: [
|
205 | {
|
206 | enum: ["colon", "value"]
|
207 | },
|
208 | {
|
209 | type: "object",
|
210 | properties: {
|
211 | mode: {
|
212 | enum: ["strict", "minimum"]
|
213 | },
|
214 | on: {
|
215 | enum: ["colon", "value"]
|
216 | },
|
217 | beforeColon: {
|
218 | type: "boolean"
|
219 | },
|
220 | afterColon: {
|
221 | type: "boolean"
|
222 | }
|
223 | },
|
224 | additionalProperties: false
|
225 | }
|
226 | ]
|
227 | },
|
228 | mode: {
|
229 | enum: ["strict", "minimum"]
|
230 | },
|
231 | beforeColon: {
|
232 | type: "boolean"
|
233 | },
|
234 | afterColon: {
|
235 | type: "boolean"
|
236 | }
|
237 | },
|
238 | additionalProperties: false
|
239 | }
|
240 | },
|
241 | additionalProperties: false
|
242 | },
|
243 | {
|
244 | type: "object",
|
245 | properties: {
|
246 | singleLine: {
|
247 | type: "object",
|
248 | properties: {
|
249 | mode: {
|
250 | enum: ["strict", "minimum"]
|
251 | },
|
252 | beforeColon: {
|
253 | type: "boolean"
|
254 | },
|
255 | afterColon: {
|
256 | type: "boolean"
|
257 | }
|
258 | },
|
259 | additionalProperties: false
|
260 | },
|
261 | multiLine: {
|
262 | type: "object",
|
263 | properties: {
|
264 | mode: {
|
265 | enum: ["strict", "minimum"]
|
266 | },
|
267 | beforeColon: {
|
268 | type: "boolean"
|
269 | },
|
270 | afterColon: {
|
271 | type: "boolean"
|
272 | }
|
273 | },
|
274 | additionalProperties: false
|
275 | },
|
276 | align: {
|
277 | type: "object",
|
278 | properties: {
|
279 | mode: {
|
280 | enum: ["strict", "minimum"]
|
281 | },
|
282 | on: {
|
283 | enum: ["colon", "value"]
|
284 | },
|
285 | beforeColon: {
|
286 | type: "boolean"
|
287 | },
|
288 | afterColon: {
|
289 | type: "boolean"
|
290 | }
|
291 | },
|
292 | additionalProperties: false
|
293 | }
|
294 | },
|
295 | additionalProperties: false
|
296 | }
|
297 | ]
|
298 | }]
|
299 | },
|
300 |
|
301 | create(context) {
|
302 |
|
303 | /**
|
304 | * OPTIONS
|
305 | * "key-spacing": [2, {
|
306 | * beforeColon: false,
|
307 | * afterColon: true,
|
308 | * align: "colon" // Optional, or "value"
|
309 | * }
|
310 | */
|
311 | const options = context.options[0] || {},
|
312 | ruleOptions = initOptions({}, options),
|
313 | multiLineOptions = ruleOptions.multiLine,
|
314 | singleLineOptions = ruleOptions.singleLine,
|
315 | alignmentOptions = ruleOptions.align || null;
|
316 |
|
317 | const sourceCode = context.getSourceCode();
|
318 |
|
319 | /**
|
320 | * Checks whether a property is a member of the property group it follows.
|
321 | * @param {ASTNode} lastMember The last Property known to be in the group.
|
322 | * @param {ASTNode} candidate The next Property that might be in the group.
|
323 | * @returns {boolean} True if the candidate property is part of the group.
|
324 | */
|
325 | function continuesPropertyGroup(lastMember, candidate) {
|
326 | const groupEndLine = lastMember.loc.start.line,
|
327 | candidateStartLine = candidate.loc.start.line;
|
328 |
|
329 | if (candidateStartLine - groupEndLine <= 1) {
|
330 | return true;
|
331 | }
|
332 |
|
333 | /*
|
334 | * Check that the first comment is adjacent to the end of the group, the
|
335 | * last comment is adjacent to the candidate property, and that successive
|
336 | * comments are adjacent to each other.
|
337 | */
|
338 | const leadingComments = sourceCode.getCommentsBefore(candidate);
|
339 |
|
340 | if (
|
341 | leadingComments.length &&
|
342 | leadingComments[0].loc.start.line - groupEndLine <= 1 &&
|
343 | candidateStartLine - last(leadingComments).loc.end.line <= 1
|
344 | ) {
|
345 | for (let i = 1; i < leadingComments.length; i++) {
|
346 | if (leadingComments[i].loc.start.line - leadingComments[i - 1].loc.end.line > 1) {
|
347 | return false;
|
348 | }
|
349 | }
|
350 | return true;
|
351 | }
|
352 |
|
353 | return false;
|
354 | }
|
355 |
|
356 | /**
|
357 | * Determines if the given property is key-value property.
|
358 | * @param {ASTNode} property Property node to check.
|
359 | * @returns {boolean} Whether the property is a key-value property.
|
360 | */
|
361 | function isKeyValueProperty(property) {
|
362 | return !(
|
363 | property.method ||
|
364 | property.shorthand ||
|
365 | property.kind !== "init" ||
|
366 | property.type !== "Property" // Could be "ExperimentalSpreadProperty" or "SpreadElement"
|
367 | );
|
368 | }
|
369 |
|
370 | /**
|
371 | * Starting from the given a node (a property.key node here) looks forward
|
372 | * until it finds the last token before a colon punctuator and returns it.
|
373 | * @param {ASTNode} node The node to start looking from.
|
374 | * @returns {ASTNode} The last token before a colon punctuator.
|
375 | */
|
376 | function getLastTokenBeforeColon(node) {
|
377 | const colonToken = sourceCode.getTokenAfter(node, astUtils.isColonToken);
|
378 |
|
379 | return sourceCode.getTokenBefore(colonToken);
|
380 | }
|
381 |
|
382 | /**
|
383 | * Starting from the given a node (a property.key node here) looks forward
|
384 | * until it finds the colon punctuator and returns it.
|
385 | * @param {ASTNode} node The node to start looking from.
|
386 | * @returns {ASTNode} The colon punctuator.
|
387 | */
|
388 | function getNextColon(node) {
|
389 | return sourceCode.getTokenAfter(node, astUtils.isColonToken);
|
390 | }
|
391 |
|
392 | /**
|
393 | * Gets an object literal property's key as the identifier name or string value.
|
394 | * @param {ASTNode} property Property node whose key to retrieve.
|
395 | * @returns {string} The property's key.
|
396 | */
|
397 | function getKey(property) {
|
398 | const key = property.key;
|
399 |
|
400 | if (property.computed) {
|
401 | return sourceCode.getText().slice(key.range[0], key.range[1]);
|
402 | }
|
403 |
|
404 | return property.key.name || property.key.value;
|
405 | }
|
406 |
|
407 | /**
|
408 | * Reports an appropriately-formatted error if spacing is incorrect on one
|
409 | * side of the colon.
|
410 | * @param {ASTNode} property Key-value pair in an object literal.
|
411 | * @param {string} side Side being verified - either "key" or "value".
|
412 | * @param {string} whitespace Actual whitespace string.
|
413 | * @param {int} expected Expected whitespace length.
|
414 | * @param {string} mode Value of the mode as "strict" or "minimum"
|
415 | * @returns {void}
|
416 | */
|
417 | function report(property, side, whitespace, expected, mode) {
|
418 | const diff = whitespace.length - expected,
|
419 | nextColon = getNextColon(property.key),
|
420 | tokenBeforeColon = sourceCode.getTokenBefore(nextColon, { includeComments: true }),
|
421 | tokenAfterColon = sourceCode.getTokenAfter(nextColon, { includeComments: true }),
|
422 | isKeySide = side === "key",
|
423 | locStart = isKeySide ? tokenBeforeColon.loc.start : tokenAfterColon.loc.start,
|
424 | isExtra = diff > 0,
|
425 | diffAbs = Math.abs(diff),
|
426 | spaces = Array(diffAbs + 1).join(" ");
|
427 |
|
428 | if ((
|
429 | diff && mode === "strict" ||
|
430 | diff < 0 && mode === "minimum" ||
|
431 | diff > 0 && !expected && mode === "minimum") &&
|
432 | !(expected && containsLineTerminator(whitespace))
|
433 | ) {
|
434 | let fix;
|
435 |
|
436 | if (isExtra) {
|
437 | let range;
|
438 |
|
439 | // Remove whitespace
|
440 | if (isKeySide) {
|
441 | range = [tokenBeforeColon.range[1], tokenBeforeColon.range[1] + diffAbs];
|
442 | } else {
|
443 | range = [tokenAfterColon.range[0] - diffAbs, tokenAfterColon.range[0]];
|
444 | }
|
445 | fix = function(fixer) {
|
446 | return fixer.removeRange(range);
|
447 | };
|
448 | } else {
|
449 |
|
450 | // Add whitespace
|
451 | if (isKeySide) {
|
452 | fix = function(fixer) {
|
453 | return fixer.insertTextAfter(tokenBeforeColon, spaces);
|
454 | };
|
455 | } else {
|
456 | fix = function(fixer) {
|
457 | return fixer.insertTextBefore(tokenAfterColon, spaces);
|
458 | };
|
459 | }
|
460 | }
|
461 |
|
462 | context.report({
|
463 | node: property[side],
|
464 | loc: locStart,
|
465 | message: messages[side],
|
466 | data: {
|
467 | error: isExtra ? "Extra" : "Missing",
|
468 | computed: property.computed ? "computed " : "",
|
469 | key: getKey(property)
|
470 | },
|
471 | fix
|
472 | });
|
473 | }
|
474 | }
|
475 |
|
476 | /**
|
477 | * Gets the number of characters in a key, including quotes around string
|
478 | * keys and braces around computed property keys.
|
479 | * @param {ASTNode} property Property of on object literal.
|
480 | * @returns {int} Width of the key.
|
481 | */
|
482 | function getKeyWidth(property) {
|
483 | const startToken = sourceCode.getFirstToken(property);
|
484 | const endToken = getLastTokenBeforeColon(property.key);
|
485 |
|
486 | return endToken.range[1] - startToken.range[0];
|
487 | }
|
488 |
|
489 | /**
|
490 | * Gets the whitespace around the colon in an object literal property.
|
491 | * @param {ASTNode} property Property node from an object literal.
|
492 | * @returns {Object} Whitespace before and after the property's colon.
|
493 | */
|
494 | function getPropertyWhitespace(property) {
|
495 | const whitespace = /(\s*):(\s*)/.exec(sourceCode.getText().slice(
|
496 | property.key.range[1], property.value.range[0]
|
497 | ));
|
498 |
|
499 | if (whitespace) {
|
500 | return {
|
501 | beforeColon: whitespace[1],
|
502 | afterColon: whitespace[2]
|
503 | };
|
504 | }
|
505 | return null;
|
506 | }
|
507 |
|
508 | /**
|
509 | * Creates groups of properties.
|
510 | * @param {ASTNode} node ObjectExpression node being evaluated.
|
511 | * @returns {Array.<ASTNode[]>} Groups of property AST node lists.
|
512 | */
|
513 | function createGroups(node) {
|
514 | if (node.properties.length === 1) {
|
515 | return [node.properties];
|
516 | }
|
517 |
|
518 | return node.properties.reduce((groups, property) => {
|
519 | const currentGroup = last(groups),
|
520 | prev = last(currentGroup);
|
521 |
|
522 | if (!prev || continuesPropertyGroup(prev, property)) {
|
523 | currentGroup.push(property);
|
524 | } else {
|
525 | groups.push([property]);
|
526 | }
|
527 |
|
528 | return groups;
|
529 | }, [
|
530 | []
|
531 | ]);
|
532 | }
|
533 |
|
534 | /**
|
535 | * Verifies correct vertical alignment of a group of properties.
|
536 | * @param {ASTNode[]} properties List of Property AST nodes.
|
537 | * @returns {void}
|
538 | */
|
539 | function verifyGroupAlignment(properties) {
|
540 | const length = properties.length,
|
541 | widths = properties.map(getKeyWidth), // Width of keys, including quotes
|
542 | align = alignmentOptions.on; // "value" or "colon"
|
543 | let targetWidth = Math.max.apply(null, widths),
|
544 | beforeColon, afterColon, mode;
|
545 |
|
546 | if (alignmentOptions && length > 1) { // When aligning values within a group, use the alignment configuration.
|
547 | beforeColon = alignmentOptions.beforeColon;
|
548 | afterColon = alignmentOptions.afterColon;
|
549 | mode = alignmentOptions.mode;
|
550 | } else {
|
551 | beforeColon = multiLineOptions.beforeColon;
|
552 | afterColon = multiLineOptions.afterColon;
|
553 | mode = alignmentOptions.mode;
|
554 | }
|
555 |
|
556 | // Conditionally include one space before or after colon
|
557 | targetWidth += (align === "colon" ? beforeColon : afterColon);
|
558 |
|
559 | for (let i = 0; i < length; i++) {
|
560 | const property = properties[i];
|
561 | const whitespace = getPropertyWhitespace(property);
|
562 |
|
563 | if (whitespace) { // Object literal getters/setters lack a colon
|
564 | const width = widths[i];
|
565 |
|
566 | if (align === "value") {
|
567 | report(property, "key", whitespace.beforeColon, beforeColon, mode);
|
568 | report(property, "value", whitespace.afterColon, targetWidth - width, mode);
|
569 | } else { // align = "colon"
|
570 | report(property, "key", whitespace.beforeColon, targetWidth - width, mode);
|
571 | report(property, "value", whitespace.afterColon, afterColon, mode);
|
572 | }
|
573 | }
|
574 | }
|
575 | }
|
576 |
|
577 | /**
|
578 | * Verifies vertical alignment, taking into account groups of properties.
|
579 | * @param {ASTNode} node ObjectExpression node being evaluated.
|
580 | * @returns {void}
|
581 | */
|
582 | function verifyAlignment(node) {
|
583 | createGroups(node).forEach(group => {
|
584 | verifyGroupAlignment(group.filter(isKeyValueProperty));
|
585 | });
|
586 | }
|
587 |
|
588 | /**
|
589 | * Verifies spacing of property conforms to specified options.
|
590 | * @param {ASTNode} node Property node being evaluated.
|
591 | * @param {Object} lineOptions Configured singleLine or multiLine options
|
592 | * @returns {void}
|
593 | */
|
594 | function verifySpacing(node, lineOptions) {
|
595 | const actual = getPropertyWhitespace(node);
|
596 |
|
597 | if (actual) { // Object literal getters/setters lack colons
|
598 | report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode);
|
599 | report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode);
|
600 | }
|
601 | }
|
602 |
|
603 | /**
|
604 | * Verifies spacing of each property in a list.
|
605 | * @param {ASTNode[]} properties List of Property AST nodes.
|
606 | * @returns {void}
|
607 | */
|
608 | function verifyListSpacing(properties) {
|
609 | const length = properties.length;
|
610 |
|
611 | for (let i = 0; i < length; i++) {
|
612 | verifySpacing(properties[i], singleLineOptions);
|
613 | }
|
614 | }
|
615 |
|
616 | //--------------------------------------------------------------------------
|
617 | // Public API
|
618 | //--------------------------------------------------------------------------
|
619 |
|
620 | if (alignmentOptions) { // Verify vertical alignment
|
621 |
|
622 | return {
|
623 | ObjectExpression(node) {
|
624 | if (isSingleLine(node)) {
|
625 | verifyListSpacing(node.properties.filter(isKeyValueProperty));
|
626 | } else {
|
627 | verifyAlignment(node);
|
628 | }
|
629 | }
|
630 | };
|
631 |
|
632 | }
|
633 |
|
634 | // Obey beforeColon and afterColon in each property as configured
|
635 | return {
|
636 | Property(node) {
|
637 | verifySpacing(node, isSingleLine(node.parent) ? singleLineOptions : multiLineOptions);
|
638 | }
|
639 | };
|
640 |
|
641 |
|
642 | }
|
643 | };
|