1 | /**
|
2 | * @fileoverview Rule to specify spacing of object literal keys and values
|
3 | * @author Brandon Mills
|
4 | * @copyright 2014 Brandon Mills. All rights reserved.
|
5 | */
|
6 | ;
|
7 |
|
8 | //------------------------------------------------------------------------------
|
9 | // Helpers
|
10 | //------------------------------------------------------------------------------
|
11 |
|
12 | /**
|
13 | * Checks whether a string contains a line terminator as defined in
|
14 | * http://www.ecma-international.org/ecma-262/5.1/#sec-7.3
|
15 | * @param {string} str String to test.
|
16 | * @returns {boolean} True if str contains a line terminator.
|
17 | */
|
18 | function containsLineTerminator(str) {
|
19 | return /[\n\r\u2028\u2029]/.test(str);
|
20 | }
|
21 |
|
22 | /**
|
23 | * Gets the last element of an array.
|
24 | * @param {Array} arr An array.
|
25 | * @returns {any} Last element of arr.
|
26 | */
|
27 | function last(arr) {
|
28 | return arr[arr.length - 1];
|
29 | }
|
30 |
|
31 | /**
|
32 | * Checks whether a property is a member of the property group it follows.
|
33 | * @param {ASTNode} lastMember The last Property known to be in the group.
|
34 | * @param {ASTNode} candidate The next Property that might be in the group.
|
35 | * @returns {boolean} True if the candidate property is part of the group.
|
36 | */
|
37 | function continuesPropertyGroup(lastMember, candidate) {
|
38 | var groupEndLine = lastMember.loc.start.line,
|
39 | candidateStartLine = candidate.loc.start.line,
|
40 | comments, i;
|
41 |
|
42 | if (candidateStartLine - groupEndLine <= 1) {
|
43 | return true;
|
44 | }
|
45 |
|
46 | // Check that the first comment is adjacent to the end of the group, the
|
47 | // last comment is adjacent to the candidate property, and that successive
|
48 | // comments are adjacent to each other.
|
49 | comments = candidate.leadingComments;
|
50 | if (
|
51 | comments &&
|
52 | comments[0].loc.start.line - groupEndLine <= 1 &&
|
53 | candidateStartLine - last(comments).loc.end.line <= 1
|
54 | ) {
|
55 | for (i = 1; i < comments.length; i++) {
|
56 | if (comments[i].loc.start.line - comments[i - 1].loc.end.line > 1) {
|
57 | return false;
|
58 | }
|
59 | }
|
60 | return true;
|
61 | }
|
62 |
|
63 | return false;
|
64 | }
|
65 |
|
66 | /**
|
67 | * Checks whether a node is contained on a single line.
|
68 | * @param {ASTNode} node AST Node being evaluated.
|
69 | * @returns {boolean} True if the node is a single line.
|
70 | */
|
71 | function isSingleLine(node) {
|
72 | return (node.loc.end.line === node.loc.start.line);
|
73 | }
|
74 |
|
75 | /** Sets option values from the configured options with defaults
|
76 | * @param {Object} toOptions Object to be initialized
|
77 | * @param {Object} fromOptions Object to be initialized from
|
78 | * @returns {Object} The object with correctly initialized options and values
|
79 | */
|
80 | function initOptions(toOptions, fromOptions) {
|
81 | toOptions.mode = fromOptions.mode || "strict";
|
82 |
|
83 | // Set align if exists - multiLine case
|
84 | if (typeof fromOptions.align !== "undefined") {
|
85 | toOptions.align = fromOptions.align;
|
86 | }
|
87 |
|
88 | // Set value of beforeColon
|
89 | if (typeof fromOptions.beforeColon !== "undefined") {
|
90 | toOptions.beforeColon = +fromOptions.beforeColon;
|
91 | } else {
|
92 | toOptions.beforeColon = 0;
|
93 | }
|
94 |
|
95 | // Set value of afterColon
|
96 | if (typeof fromOptions.afterColon !== "undefined") {
|
97 | toOptions.afterColon = +fromOptions.afterColon;
|
98 | } else {
|
99 | toOptions.afterColon = 1;
|
100 | }
|
101 |
|
102 | return toOptions;
|
103 | }
|
104 |
|
105 | //------------------------------------------------------------------------------
|
106 | // Rule Definition
|
107 | //------------------------------------------------------------------------------
|
108 |
|
109 | var messages = {
|
110 | key: "{{error}} space after {{computed}}key '{{key}}'.",
|
111 | value: "{{error}} space before value for {{computed}}key '{{key}}'."
|
112 | };
|
113 |
|
114 | module.exports = function(context) {
|
115 |
|
116 | /**
|
117 | * OPTIONS
|
118 | * "key-spacing": [2, {
|
119 | * beforeColon: false,
|
120 | * afterColon: true,
|
121 | * align: "colon" // Optional, or "value"
|
122 | * }
|
123 | */
|
124 |
|
125 | var options = context.options[0] || {},
|
126 | multiLineOptions = initOptions({}, (options.multiLine || options)),
|
127 | singleLineOptions = initOptions({}, (options.singleLine || options));
|
128 |
|
129 | /**
|
130 | * Determines if the given property is key-value property.
|
131 | * @param {ASTNode} property Property node to check.
|
132 | * @returns {Boolean} Whether the property is a key-value property.
|
133 | */
|
134 | function isKeyValueProperty(property) {
|
135 | return !(
|
136 | property.method ||
|
137 | property.shorthand ||
|
138 | property.kind !== "init" ||
|
139 | property.type !== "Property" // Could be "ExperimentalSpreadProperty" or "SpreadProperty"
|
140 | );
|
141 | }
|
142 |
|
143 | /**
|
144 | * Starting from the given a node (a property.key node here) looks forward
|
145 | * until it finds the last token before a colon punctuator and returns it.
|
146 | * @param {ASTNode} node The node to start looking from.
|
147 | * @returns {ASTNode} The last token before a colon punctuator.
|
148 | */
|
149 | function getLastTokenBeforeColon(node) {
|
150 | var prevNode;
|
151 |
|
152 | while (node && (node.type !== "Punctuator" || node.value !== ":")) {
|
153 | prevNode = node;
|
154 | node = context.getTokenAfter(node);
|
155 | }
|
156 |
|
157 | return prevNode;
|
158 | }
|
159 |
|
160 | /**
|
161 | * Starting from the given a node (a property.key node here) looks forward
|
162 | * until it finds the colon punctuator and returns it.
|
163 | * @param {ASTNode} node The node to start looking from.
|
164 | * @returns {ASTNode} The colon punctuator.
|
165 | */
|
166 | function getNextColon(node) {
|
167 |
|
168 | while (node && (node.type !== "Punctuator" || node.value !== ":")) {
|
169 | node = context.getTokenAfter(node);
|
170 | }
|
171 |
|
172 | return node;
|
173 | }
|
174 |
|
175 | /**
|
176 | * Gets an object literal property's key as the identifier name or string value.
|
177 | * @param {ASTNode} property Property node whose key to retrieve.
|
178 | * @returns {string} The property's key.
|
179 | */
|
180 | function getKey(property) {
|
181 | var key = property.key;
|
182 |
|
183 | if (property.computed) {
|
184 | return context.getSource().slice(key.range[0], key.range[1]);
|
185 | }
|
186 |
|
187 | return property.key.name || property.key.value;
|
188 | }
|
189 |
|
190 | /**
|
191 | * Reports an appropriately-formatted error if spacing is incorrect on one
|
192 | * side of the colon.
|
193 | * @param {ASTNode} property Key-value pair in an object literal.
|
194 | * @param {string} side Side being verified - either "key" or "value".
|
195 | * @param {string} whitespace Actual whitespace string.
|
196 | * @param {int} expected Expected whitespace length.
|
197 | * @param {string} mode Value of the mode as "strict" or "minimum"
|
198 | * @returns {void}
|
199 | */
|
200 | function report(property, side, whitespace, expected, mode) {
|
201 | var diff = whitespace.length - expected,
|
202 | key = property.key,
|
203 | firstTokenAfterColon = context.getTokenAfter(getNextColon(key)),
|
204 | location = side === "key" ? key.loc.start : firstTokenAfterColon.loc.start;
|
205 |
|
206 | if ((diff && mode === "strict" || diff < 0 && mode === "minimum") &&
|
207 | !(expected && containsLineTerminator(whitespace))
|
208 | ) {
|
209 | context.report(property[side], location, messages[side], {
|
210 | error: diff > 0 ? "Extra" : "Missing",
|
211 | computed: property.computed ? "computed " : "",
|
212 | key: getKey(property)
|
213 | });
|
214 | }
|
215 | }
|
216 |
|
217 | /**
|
218 | * Gets the number of characters in a key, including quotes around string
|
219 | * keys and braces around computed property keys.
|
220 | * @param {ASTNode} property Property of on object literal.
|
221 | * @returns {int} Width of the key.
|
222 | */
|
223 | function getKeyWidth(property) {
|
224 | var startToken, endToken;
|
225 |
|
226 | startToken = context.getFirstToken(property);
|
227 | endToken = getLastTokenBeforeColon(property.key);
|
228 |
|
229 | return endToken.range[1] - startToken.range[0];
|
230 | }
|
231 |
|
232 | /**
|
233 | * Gets the whitespace around the colon in an object literal property.
|
234 | * @param {ASTNode} property Property node from an object literal.
|
235 | * @returns {Object} Whitespace before and after the property's colon.
|
236 | */
|
237 | function getPropertyWhitespace(property) {
|
238 | var whitespace = /(\s*):(\s*)/.exec(context.getSource().slice(
|
239 | property.key.range[1], property.value.range[0]
|
240 | ));
|
241 |
|
242 | if (whitespace) {
|
243 | return {
|
244 | beforeColon: whitespace[1],
|
245 | afterColon: whitespace[2]
|
246 | };
|
247 | }
|
248 | return null;
|
249 | }
|
250 |
|
251 | /**
|
252 | * Creates groups of properties.
|
253 | * @param {ASTNode} node ObjectExpression node being evaluated.
|
254 | * @returns {Array.<ASTNode[]>} Groups of property AST node lists.
|
255 | */
|
256 | function createGroups(node) {
|
257 | if (node.properties.length === 1) {
|
258 | return [node.properties];
|
259 | }
|
260 |
|
261 | return node.properties.reduce(function(groups, property) {
|
262 | var currentGroup = last(groups),
|
263 | prev = last(currentGroup);
|
264 |
|
265 | if (!prev || continuesPropertyGroup(prev, property)) {
|
266 | currentGroup.push(property);
|
267 | } else {
|
268 | groups.push([property]);
|
269 | }
|
270 |
|
271 | return groups;
|
272 | }, [
|
273 | []
|
274 | ]);
|
275 | }
|
276 |
|
277 | /**
|
278 | * Verifies correct vertical alignment of a group of properties.
|
279 | * @param {ASTNode[]} properties List of Property AST nodes.
|
280 | * @returns {void}
|
281 | */
|
282 | function verifyGroupAlignment(properties) {
|
283 | var length = properties.length,
|
284 | widths = properties.map(getKeyWidth), // Width of keys, including quotes
|
285 | targetWidth = Math.max.apply(null, widths),
|
286 | i, property, whitespace, width,
|
287 | align = multiLineOptions.align,
|
288 | beforeColon = multiLineOptions.beforeColon,
|
289 | afterColon = multiLineOptions.afterColon,
|
290 | mode = multiLineOptions.mode;
|
291 |
|
292 | // Conditionally include one space before or after colon
|
293 | targetWidth += (align === "colon" ? beforeColon : afterColon);
|
294 |
|
295 | for (i = 0; i < length; i++) {
|
296 | property = properties[i];
|
297 | whitespace = getPropertyWhitespace(property);
|
298 | if (whitespace) { // Object literal getters/setters lack a colon
|
299 | width = widths[i];
|
300 |
|
301 | if (align === "value") {
|
302 | report(property, "key", whitespace.beforeColon, beforeColon, mode);
|
303 | report(property, "value", whitespace.afterColon, targetWidth - width, mode);
|
304 | } else { // align = "colon"
|
305 | report(property, "key", whitespace.beforeColon, targetWidth - width, mode);
|
306 | report(property, "value", whitespace.afterColon, afterColon, mode);
|
307 | }
|
308 | }
|
309 | }
|
310 | }
|
311 |
|
312 | /**
|
313 | * Verifies vertical alignment, taking into account groups of properties.
|
314 | * @param {ASTNode} node ObjectExpression node being evaluated.
|
315 | * @returns {void}
|
316 | */
|
317 | function verifyAlignment(node) {
|
318 | createGroups(node).forEach(function(group) {
|
319 | verifyGroupAlignment(group.filter(isKeyValueProperty));
|
320 | });
|
321 | }
|
322 |
|
323 | /**
|
324 | * Verifies spacing of property conforms to specified options.
|
325 | * @param {ASTNode} node Property node being evaluated.
|
326 | * @param {Object} lineOptions Configured singleLine or multiLine options
|
327 | * @returns {void}
|
328 | */
|
329 | function verifySpacing(node, lineOptions) {
|
330 | var actual = getPropertyWhitespace(node);
|
331 | if (actual) { // Object literal getters/setters lack colons
|
332 | report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode);
|
333 | report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode);
|
334 | }
|
335 | }
|
336 |
|
337 | /**
|
338 | * Verifies spacing of each property in a list.
|
339 | * @param {ASTNode[]} properties List of Property AST nodes.
|
340 | * @returns {void}
|
341 | */
|
342 | function verifyListSpacing(properties) {
|
343 | var length = properties.length;
|
344 |
|
345 | for (var i = 0; i < length; i++) {
|
346 | verifySpacing(properties[i], singleLineOptions);
|
347 | }
|
348 | }
|
349 |
|
350 | //--------------------------------------------------------------------------
|
351 | // Public API
|
352 | //--------------------------------------------------------------------------
|
353 |
|
354 | if (multiLineOptions.align) { // Verify vertical alignment
|
355 |
|
356 | return {
|
357 | "ObjectExpression": function(node) {
|
358 | if (isSingleLine(node)) {
|
359 | verifyListSpacing(node.properties);
|
360 | } else {
|
361 | verifyAlignment(node);
|
362 | }
|
363 | }
|
364 | };
|
365 |
|
366 | } else { // Obey beforeColon and afterColon in each property as configured
|
367 |
|
368 | return {
|
369 | "Property": function(node) {
|
370 | verifySpacing(node, isSingleLine(node) ? singleLineOptions : multiLineOptions);
|
371 | }
|
372 | };
|
373 |
|
374 | }
|
375 |
|
376 | };
|
377 |
|
378 | module.exports.schema = [{
|
379 | "anyOf": [
|
380 | {
|
381 | "type": "object",
|
382 | "properties": {
|
383 | "align": {
|
384 | "enum": ["colon", "value"]
|
385 | },
|
386 | "mode": {
|
387 | "enum": ["strict", "minimum"]
|
388 | },
|
389 | "beforeColon": {
|
390 | "type": "boolean"
|
391 | },
|
392 | "afterColon": {
|
393 | "type": "boolean"
|
394 | }
|
395 | },
|
396 | "additionalProperties": false
|
397 | },
|
398 | {
|
399 | "type": "object",
|
400 | "properties": {
|
401 | "singleLine": {
|
402 | "type": "object",
|
403 | "properties": {
|
404 | "mode": {
|
405 | "enum": ["strict", "minimum"]
|
406 | },
|
407 | "beforeColon": {
|
408 | "type": "boolean"
|
409 | },
|
410 | "afterColon": {
|
411 | "type": "boolean"
|
412 | }
|
413 | },
|
414 | "additionalProperties": false
|
415 | },
|
416 | "multiLine": {
|
417 | "type": "object",
|
418 | "properties": {
|
419 | "align": {
|
420 | "enum": ["colon", "value"]
|
421 | },
|
422 | "mode": {
|
423 | "enum": ["strict", "minimum"]
|
424 | },
|
425 | "beforeColon": {
|
426 | "type": "boolean"
|
427 | },
|
428 | "afterColon": {
|
429 | "type": "boolean"
|
430 | }
|
431 | },
|
432 | "additionalProperties": false
|
433 | }
|
434 | },
|
435 | "additionalProperties": false
|
436 | }
|
437 | ]
|
438 | }];
|