key-spacing.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  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 properies 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 property.key.name || property.key.value;
  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. locStart = isKeySide ? tokenBeforeColon.loc.start : tokenAfterColon.loc.start,
  398. isExtra = diff > 0,
  399. diffAbs = Math.abs(diff),
  400. spaces = Array(diffAbs + 1).join(" ");
  401. if ((
  402. diff && mode === "strict" ||
  403. diff < 0 && mode === "minimum" ||
  404. diff > 0 && !expected && mode === "minimum") &&
  405. !(expected && containsLineTerminator(whitespace))
  406. ) {
  407. let fix;
  408. if (isExtra) {
  409. let range;
  410. // Remove whitespace
  411. if (isKeySide) {
  412. range = [tokenBeforeColon.range[1], tokenBeforeColon.range[1] + diffAbs];
  413. } else {
  414. range = [tokenAfterColon.range[0] - diffAbs, tokenAfterColon.range[0]];
  415. }
  416. fix = function(fixer) {
  417. return fixer.removeRange(range);
  418. };
  419. } else {
  420. // Add whitespace
  421. if (isKeySide) {
  422. fix = function(fixer) {
  423. return fixer.insertTextAfter(tokenBeforeColon, spaces);
  424. };
  425. } else {
  426. fix = function(fixer) {
  427. return fixer.insertTextBefore(tokenAfterColon, spaces);
  428. };
  429. }
  430. }
  431. let messageId = "";
  432. if (isExtra) {
  433. messageId = side === "key" ? "extraKey" : "extraValue";
  434. } else {
  435. messageId = side === "key" ? "missingKey" : "missingValue";
  436. }
  437. context.report({
  438. node: property[side],
  439. loc: locStart,
  440. messageId,
  441. data: {
  442. computed: property.computed ? "computed " : "",
  443. key: getKey(property)
  444. },
  445. fix
  446. });
  447. }
  448. }
  449. /**
  450. * Gets the number of characters in a key, including quotes around string
  451. * keys and braces around computed property keys.
  452. * @param {ASTNode} property Property of on object literal.
  453. * @returns {int} Width of the key.
  454. */
  455. function getKeyWidth(property) {
  456. const startToken = sourceCode.getFirstToken(property);
  457. const endToken = getLastTokenBeforeColon(property.key);
  458. return endToken.range[1] - startToken.range[0];
  459. }
  460. /**
  461. * Gets the whitespace around the colon in an object literal property.
  462. * @param {ASTNode} property Property node from an object literal.
  463. * @returns {Object} Whitespace before and after the property's colon.
  464. */
  465. function getPropertyWhitespace(property) {
  466. const whitespace = /(\s*):(\s*)/u.exec(sourceCode.getText().slice(
  467. property.key.range[1], property.value.range[0]
  468. ));
  469. if (whitespace) {
  470. return {
  471. beforeColon: whitespace[1],
  472. afterColon: whitespace[2]
  473. };
  474. }
  475. return null;
  476. }
  477. /**
  478. * Creates groups of properties.
  479. * @param {ASTNode} node ObjectExpression node being evaluated.
  480. * @returns {Array.<ASTNode[]>} Groups of property AST node lists.
  481. */
  482. function createGroups(node) {
  483. if (node.properties.length === 1) {
  484. return [node.properties];
  485. }
  486. return node.properties.reduce((groups, property) => {
  487. const currentGroup = last(groups),
  488. prev = last(currentGroup);
  489. if (!prev || continuesPropertyGroup(prev, property)) {
  490. currentGroup.push(property);
  491. } else {
  492. groups.push([property]);
  493. }
  494. return groups;
  495. }, [
  496. []
  497. ]);
  498. }
  499. /**
  500. * Verifies correct vertical alignment of a group of properties.
  501. * @param {ASTNode[]} properties List of Property AST nodes.
  502. * @returns {void}
  503. */
  504. function verifyGroupAlignment(properties) {
  505. const length = properties.length,
  506. widths = properties.map(getKeyWidth), // Width of keys, including quotes
  507. align = alignmentOptions.on; // "value" or "colon"
  508. let targetWidth = Math.max(...widths),
  509. beforeColon, afterColon, mode;
  510. if (alignmentOptions && length > 1) { // When aligning values within a group, use the alignment configuration.
  511. beforeColon = alignmentOptions.beforeColon;
  512. afterColon = alignmentOptions.afterColon;
  513. mode = alignmentOptions.mode;
  514. } else {
  515. beforeColon = multiLineOptions.beforeColon;
  516. afterColon = multiLineOptions.afterColon;
  517. mode = alignmentOptions.mode;
  518. }
  519. // Conditionally include one space before or after colon
  520. targetWidth += (align === "colon" ? beforeColon : afterColon);
  521. for (let i = 0; i < length; i++) {
  522. const property = properties[i];
  523. const whitespace = getPropertyWhitespace(property);
  524. if (whitespace) { // Object literal getters/setters lack a colon
  525. const width = widths[i];
  526. if (align === "value") {
  527. report(property, "key", whitespace.beforeColon, beforeColon, mode);
  528. report(property, "value", whitespace.afterColon, targetWidth - width, mode);
  529. } else { // align = "colon"
  530. report(property, "key", whitespace.beforeColon, targetWidth - width, mode);
  531. report(property, "value", whitespace.afterColon, afterColon, mode);
  532. }
  533. }
  534. }
  535. }
  536. /**
  537. * Verifies spacing of property conforms to specified options.
  538. * @param {ASTNode} node Property node being evaluated.
  539. * @param {Object} lineOptions Configured singleLine or multiLine options
  540. * @returns {void}
  541. */
  542. function verifySpacing(node, lineOptions) {
  543. const actual = getPropertyWhitespace(node);
  544. if (actual) { // Object literal getters/setters lack colons
  545. report(node, "key", actual.beforeColon, lineOptions.beforeColon, lineOptions.mode);
  546. report(node, "value", actual.afterColon, lineOptions.afterColon, lineOptions.mode);
  547. }
  548. }
  549. /**
  550. * Verifies spacing of each property in a list.
  551. * @param {ASTNode[]} properties List of Property AST nodes.
  552. * @param {Object} lineOptions Configured singleLine or multiLine options
  553. * @returns {void}
  554. */
  555. function verifyListSpacing(properties, lineOptions) {
  556. const length = properties.length;
  557. for (let i = 0; i < length; i++) {
  558. verifySpacing(properties[i], lineOptions);
  559. }
  560. }
  561. /**
  562. * Verifies vertical alignment, taking into account groups of properties.
  563. * @param {ASTNode} node ObjectExpression node being evaluated.
  564. * @returns {void}
  565. */
  566. function verifyAlignment(node) {
  567. createGroups(node).forEach(group => {
  568. const properties = group.filter(isKeyValueProperty);
  569. if (properties.length > 0 && isSingleLineProperties(properties)) {
  570. verifyListSpacing(properties, multiLineOptions);
  571. } else {
  572. verifyGroupAlignment(properties);
  573. }
  574. });
  575. }
  576. //--------------------------------------------------------------------------
  577. // Public API
  578. //--------------------------------------------------------------------------
  579. if (alignmentOptions) { // Verify vertical alignment
  580. return {
  581. ObjectExpression(node) {
  582. if (isSingleLine(node)) {
  583. verifyListSpacing(node.properties.filter(isKeyValueProperty), singleLineOptions);
  584. } else {
  585. verifyAlignment(node);
  586. }
  587. }
  588. };
  589. }
  590. // Obey beforeColon and afterColon in each property as configured
  591. return {
  592. Property(node) {
  593. verifySpacing(node, isSingleLine(node.parent) ? singleLineOptions : multiLineOptions);
  594. }
  595. };
  596. }
  597. };