jsx-closing-bracket-location.js 11 KB

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