key-spacing.js 24 KB

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