jsx-closing-bracket-location.js 10 KB

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