key-spacing.js 26 KB

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