jsx-closing-bracket-location.js 11 KB

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