jsx-closing-bracket-location.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * @fileoverview Validate closing bracket location in JSX
  3. * @author Yannick Croissant
  4. */
  5. 'use strict';
  6. const has = require('has');
  7. const docsUrl = require('../util/docsUrl');
  8. // ------------------------------------------------------------------------------
  9. // Rule Definition
  10. // ------------------------------------------------------------------------------
  11. module.exports = {
  12. meta: {
  13. docs: {
  14. description: 'Validate closing bracket location in JSX',
  15. category: 'Stylistic Issues',
  16. recommended: false,
  17. url: docsUrl('jsx-closing-bracket-location')
  18. },
  19. fixable: 'code',
  20. schema: [{
  21. oneOf: [
  22. {
  23. enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned']
  24. },
  25. {
  26. type: 'object',
  27. properties: {
  28. location: {
  29. enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned']
  30. }
  31. },
  32. additionalProperties: false
  33. }, {
  34. type: 'object',
  35. properties: {
  36. nonEmpty: {
  37. enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false]
  38. },
  39. selfClosing: {
  40. enum: ['after-props', 'props-aligned', 'tag-aligned', 'line-aligned', false]
  41. }
  42. },
  43. additionalProperties: false
  44. }
  45. ]
  46. }]
  47. },
  48. create: function(context) {
  49. const MESSAGE = 'The closing bracket must be {{location}}{{details}}';
  50. const MESSAGE_LOCATION = {
  51. 'after-props': 'placed after the last prop',
  52. 'after-tag': 'placed after the opening tag',
  53. 'props-aligned': 'aligned with the last prop',
  54. 'tag-aligned': 'aligned with the opening tag',
  55. 'line-aligned': 'aligned with the line containing the opening tag'
  56. };
  57. const DEFAULT_LOCATION = 'tag-aligned';
  58. const sourceCode = context.getSourceCode();
  59. const config = context.options[0];
  60. const options = {
  61. nonEmpty: DEFAULT_LOCATION,
  62. selfClosing: DEFAULT_LOCATION
  63. };
  64. if (typeof config === 'string') {
  65. // simple shorthand [1, 'something']
  66. options.nonEmpty = config;
  67. options.selfClosing = config;
  68. } else if (typeof config === 'object') {
  69. // [1, {location: 'something'}] (back-compat)
  70. if (has(config, 'location')) {
  71. options.nonEmpty = config.location;
  72. options.selfClosing = config.location;
  73. }
  74. // [1, {nonEmpty: 'something'}]
  75. if (has(config, 'nonEmpty')) {
  76. options.nonEmpty = config.nonEmpty;
  77. }
  78. // [1, {selfClosing: 'something'}]
  79. if (has(config, 'selfClosing')) {
  80. options.selfClosing = config.selfClosing;
  81. }
  82. }
  83. /**
  84. * Get expected location for the closing bracket
  85. * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
  86. * @return {String} Expected location for the closing bracket
  87. */
  88. function getExpectedLocation(tokens) {
  89. let location;
  90. // Is always after the opening tag if there is no props
  91. if (typeof tokens.lastProp === 'undefined') {
  92. location = 'after-tag';
  93. // Is always after the last prop if this one is on the same line as the opening bracket
  94. } else if (tokens.opening.line === tokens.lastProp.lastLine) {
  95. location = 'after-props';
  96. // Else use configuration dependent on selfClosing property
  97. } else {
  98. location = tokens.selfClosing ? options.selfClosing : options.nonEmpty;
  99. }
  100. return location;
  101. }
  102. /**
  103. * Get the correct 0-indexed column for the closing bracket, given the
  104. * expected location.
  105. * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
  106. * @param {String} expectedLocation Expected location for the closing bracket
  107. * @return {?Number} The correct column for the closing bracket, or null
  108. */
  109. function getCorrectColumn(tokens, expectedLocation) {
  110. switch (expectedLocation) {
  111. case 'props-aligned':
  112. return tokens.lastProp.column;
  113. case 'tag-aligned':
  114. return tokens.opening.column;
  115. case 'line-aligned':
  116. return tokens.openingStartOfLine.column;
  117. default:
  118. return null;
  119. }
  120. }
  121. /**
  122. * Check if the closing bracket is correctly located
  123. * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
  124. * @param {String} expectedLocation Expected location for the closing bracket
  125. * @return {Boolean} True if the closing bracket is correctly located, false if not
  126. */
  127. function hasCorrectLocation(tokens, expectedLocation) {
  128. switch (expectedLocation) {
  129. case 'after-tag':
  130. return tokens.tag.line === tokens.closing.line;
  131. case 'after-props':
  132. return tokens.lastProp.lastLine === tokens.closing.line;
  133. case 'props-aligned':
  134. case 'tag-aligned':
  135. case 'line-aligned':
  136. const correctColumn = getCorrectColumn(tokens, expectedLocation);
  137. return correctColumn === tokens.closing.column;
  138. default:
  139. return true;
  140. }
  141. }
  142. /**
  143. * Get the characters used for indentation on the line to be matched
  144. * @param {Object} tokens Locations of the opening bracket, closing bracket and last prop
  145. * @param {String} expectedLocation Expected location for the closing bracket
  146. * @param {Number} correctColumn Expected column for the closing bracket
  147. * @return {String} The characters used for indentation
  148. */
  149. function getIndentation(tokens, expectedLocation, correctColumn) {
  150. let indentation, spaces = [];
  151. switch (expectedLocation) {
  152. case 'props-aligned':
  153. indentation = /^\s*/.exec(sourceCode.lines[tokens.lastProp.firstLine - 1])[0];
  154. break;
  155. case 'tag-aligned':
  156. case 'line-aligned':
  157. indentation = /^\s*/.exec(sourceCode.lines[tokens.opening.line - 1])[0];
  158. break;
  159. default:
  160. indentation = '';
  161. }
  162. if (indentation.length + 1 < correctColumn) {
  163. // Non-whitespace characters were included in the column offset
  164. spaces = new Array(+correctColumn + 1 - indentation.length);
  165. }
  166. return indentation + spaces.join(' ');
  167. }
  168. /**
  169. * Get the locations of the opening bracket, closing bracket, last prop, and
  170. * start of opening line.
  171. * @param {ASTNode} node The node to check
  172. * @return {Object} Locations of the opening bracket, closing bracket, last
  173. * prop and start of opening line.
  174. */
  175. function getTokensLocations(node) {
  176. const opening = sourceCode.getFirstToken(node).loc.start;
  177. const closing = sourceCode.getLastTokens(node, node.selfClosing ? 2 : 1)[0].loc.start;
  178. const tag = sourceCode.getFirstToken(node.name).loc.start;
  179. let lastProp;
  180. if (node.attributes.length) {
  181. lastProp = node.attributes[node.attributes.length - 1];
  182. lastProp = {
  183. column: sourceCode.getFirstToken(lastProp).loc.start.column,
  184. firstLine: sourceCode.getFirstToken(lastProp).loc.start.line,
  185. lastLine: sourceCode.getLastToken(lastProp).loc.end.line
  186. };
  187. }
  188. const openingLine = sourceCode.lines[opening.line - 1];
  189. const openingStartOfLine = {
  190. column: /^\s*/.exec(openingLine)[0].length,
  191. line: opening.line
  192. };
  193. return {
  194. tag: tag,
  195. opening: opening,
  196. closing: closing,
  197. lastProp: lastProp,
  198. selfClosing: node.selfClosing,
  199. openingStartOfLine: openingStartOfLine
  200. };
  201. }
  202. /**
  203. * Get an unique ID for a given JSXOpeningElement
  204. *
  205. * @param {ASTNode} node The AST node being checked.
  206. * @returns {String} Unique ID (based on its range)
  207. */
  208. function getOpeningElementId(node) {
  209. return node.range.join(':');
  210. }
  211. const lastAttributeNode = {};
  212. return {
  213. JSXAttribute: function(node) {
  214. lastAttributeNode[getOpeningElementId(node.parent)] = node;
  215. },
  216. JSXSpreadAttribute: function(node) {
  217. lastAttributeNode[getOpeningElementId(node.parent)] = node;
  218. },
  219. 'JSXOpeningElement:exit': function(node) {
  220. const attributeNode = lastAttributeNode[getOpeningElementId(node)];
  221. const cachedLastAttributeEndPos = attributeNode ? attributeNode.range[1] : null;
  222. let expectedNextLine;
  223. const tokens = getTokensLocations(node);
  224. const expectedLocation = getExpectedLocation(tokens);
  225. if (hasCorrectLocation(tokens, expectedLocation)) {
  226. return;
  227. }
  228. const data = {location: MESSAGE_LOCATION[expectedLocation], details: ''};
  229. const correctColumn = getCorrectColumn(tokens, expectedLocation);
  230. if (correctColumn !== null) {
  231. expectedNextLine = tokens.lastProp &&
  232. (tokens.lastProp.lastLine === tokens.closing.line);
  233. data.details = ` (expected column ${correctColumn + 1}${expectedNextLine ? ' on the next line)' : ')'}`;
  234. }
  235. context.report({
  236. node: node,
  237. loc: tokens.closing,
  238. message: MESSAGE,
  239. data: data,
  240. fix: function(fixer) {
  241. const closingTag = tokens.selfClosing ? '/>' : '>';
  242. switch (expectedLocation) {
  243. case 'after-tag':
  244. if (cachedLastAttributeEndPos) {
  245. return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
  246. (expectedNextLine ? '\n' : '') + closingTag);
  247. }
  248. return fixer.replaceTextRange([node.name.range[1], node.range[1]],
  249. (expectedNextLine ? '\n' : ' ') + closingTag);
  250. case 'after-props':
  251. return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
  252. (expectedNextLine ? '\n' : '') + closingTag);
  253. case 'props-aligned':
  254. case 'tag-aligned':
  255. case 'line-aligned':
  256. return fixer.replaceTextRange([cachedLastAttributeEndPos, node.range[1]],
  257. `\n${getIndentation(tokens, expectedLocation, correctColumn)}${closingTag}`);
  258. default:
  259. return true;
  260. }
  261. }
  262. });
  263. }
  264. };
  265. }
  266. };