UNPKG

25 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 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};