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