jsx-curly-brace-presence.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. /**
  2. * @fileoverview Enforce curly braces or disallow unnecessary curly brace in JSX
  3. * @author Jacky Ho
  4. * @author Simon Lydell
  5. */
  6. 'use strict';
  7. const arrayIncludes = require('array-includes');
  8. const docsUrl = require('../util/docsUrl');
  9. const jsxUtil = require('../util/jsx');
  10. const report = require('../util/report');
  11. const eslintUtil = require('../util/eslint');
  12. const getSourceCode = eslintUtil.getSourceCode;
  13. const getText = eslintUtil.getText;
  14. // ------------------------------------------------------------------------------
  15. // Constants
  16. // ------------------------------------------------------------------------------
  17. const OPTION_ALWAYS = 'always';
  18. const OPTION_NEVER = 'never';
  19. const OPTION_IGNORE = 'ignore';
  20. const OPTION_VALUES = [
  21. OPTION_ALWAYS,
  22. OPTION_NEVER,
  23. OPTION_IGNORE,
  24. ];
  25. const DEFAULT_CONFIG = { props: OPTION_NEVER, children: OPTION_NEVER, propElementValues: OPTION_IGNORE };
  26. const HTML_ENTITY_REGEX = () => /&[A-Za-z\d#]+;/g;
  27. function containsLineTerminators(rawStringValue) {
  28. return /[\n\r\u2028\u2029]/.test(rawStringValue);
  29. }
  30. function containsBackslash(rawStringValue) {
  31. return arrayIncludes(rawStringValue, '\\');
  32. }
  33. function containsHTMLEntity(rawStringValue) {
  34. return HTML_ENTITY_REGEX().test(rawStringValue);
  35. }
  36. function containsOnlyHtmlEntities(rawStringValue) {
  37. return rawStringValue.replace(HTML_ENTITY_REGEX(), '').trim() === '';
  38. }
  39. function containsDisallowedJSXTextChars(rawStringValue) {
  40. return /[{<>}]/.test(rawStringValue);
  41. }
  42. function containsQuoteCharacters(value) {
  43. return /['"]/.test(value);
  44. }
  45. function containsMultilineComment(value) {
  46. return /\/\*/.test(value);
  47. }
  48. function escapeDoubleQuotes(rawStringValue) {
  49. return rawStringValue.replace(/\\"/g, '"').replace(/"/g, '\\"');
  50. }
  51. function escapeBackslashes(rawStringValue) {
  52. return rawStringValue.replace(/\\/g, '\\\\');
  53. }
  54. function needToEscapeCharacterForJSX(raw, node) {
  55. return (
  56. containsBackslash(raw)
  57. || containsHTMLEntity(raw)
  58. || (node.parent.type !== 'JSXAttribute' && containsDisallowedJSXTextChars(raw))
  59. );
  60. }
  61. function containsWhitespaceExpression(child) {
  62. if (child.type === 'JSXExpressionContainer') {
  63. const value = child.expression.value;
  64. return value ? jsxUtil.isWhiteSpaces(value) : false;
  65. }
  66. return false;
  67. }
  68. function isLineBreak(text) {
  69. return containsLineTerminators(text) && text.trim() === '';
  70. }
  71. function wrapNonHTMLEntities(text) {
  72. const HTML_ENTITY = '<HTML_ENTITY>';
  73. const withCurlyBraces = text.split(HTML_ENTITY_REGEX()).map((word) => (
  74. word === '' ? '' : `{${JSON.stringify(word)}}`
  75. )).join(HTML_ENTITY);
  76. const htmlEntities = text.match(HTML_ENTITY_REGEX());
  77. return htmlEntities.reduce((acc, htmlEntity) => (
  78. acc.replace(HTML_ENTITY, htmlEntity)
  79. ), withCurlyBraces);
  80. }
  81. function wrapWithCurlyBraces(rawText) {
  82. if (!containsLineTerminators(rawText)) {
  83. return `{${JSON.stringify(rawText)}}`;
  84. }
  85. return rawText.split('\n').map((line) => {
  86. if (line.trim() === '') {
  87. return line;
  88. }
  89. const firstCharIndex = line.search(/[^\s]/);
  90. const leftWhitespace = line.slice(0, firstCharIndex);
  91. const text = line.slice(firstCharIndex);
  92. if (containsHTMLEntity(line)) {
  93. return `${leftWhitespace}${wrapNonHTMLEntities(text)}`;
  94. }
  95. return `${leftWhitespace}{${JSON.stringify(text)}}`;
  96. }).join('\n');
  97. }
  98. function isWhiteSpaceLiteral(node) {
  99. return node.type && node.type === 'Literal' && node.value && jsxUtil.isWhiteSpaces(node.value);
  100. }
  101. function isStringWithTrailingWhiteSpaces(value) {
  102. return /^\s|\s$/.test(value);
  103. }
  104. function isLiteralWithTrailingWhiteSpaces(node) {
  105. return node.type && node.type === 'Literal' && node.value && isStringWithTrailingWhiteSpaces(node.value);
  106. }
  107. // ------------------------------------------------------------------------------
  108. // Rule Definition
  109. // ------------------------------------------------------------------------------
  110. const messages = {
  111. unnecessaryCurly: 'Curly braces are unnecessary here.',
  112. missingCurly: 'Need to wrap this literal in a JSX expression.',
  113. };
  114. /** @type {import('eslint').Rule.RuleModule} */
  115. module.exports = {
  116. meta: {
  117. docs: {
  118. description: 'Disallow unnecessary JSX expressions when literals alone are sufficient or enforce JSX expressions on literals in JSX children or attributes',
  119. category: 'Stylistic Issues',
  120. recommended: false,
  121. url: docsUrl('jsx-curly-brace-presence'),
  122. },
  123. fixable: 'code',
  124. messages,
  125. schema: [
  126. {
  127. anyOf: [
  128. {
  129. type: 'object',
  130. properties: {
  131. props: { enum: OPTION_VALUES },
  132. children: { enum: OPTION_VALUES },
  133. propElementValues: { enum: OPTION_VALUES },
  134. },
  135. additionalProperties: false,
  136. },
  137. {
  138. enum: OPTION_VALUES,
  139. },
  140. ],
  141. },
  142. ],
  143. },
  144. create(context) {
  145. const ruleOptions = context.options[0];
  146. const userConfig = typeof ruleOptions === 'string'
  147. ? { props: ruleOptions, children: ruleOptions, propElementValues: OPTION_IGNORE }
  148. : Object.assign({}, DEFAULT_CONFIG, ruleOptions);
  149. /**
  150. * Report and fix an unnecessary curly brace violation on a node
  151. * @param {ASTNode} JSXExpressionNode - The AST node with an unnecessary JSX expression
  152. */
  153. function reportUnnecessaryCurly(JSXExpressionNode) {
  154. report(context, messages.unnecessaryCurly, 'unnecessaryCurly', {
  155. node: JSXExpressionNode,
  156. fix(fixer) {
  157. const expression = JSXExpressionNode.expression;
  158. let textToReplace;
  159. if (jsxUtil.isJSX(expression)) {
  160. textToReplace = getText(context, expression);
  161. } else {
  162. const expressionType = expression && expression.type;
  163. const parentType = JSXExpressionNode.parent.type;
  164. if (parentType === 'JSXAttribute') {
  165. if (expressionType !== 'TemplateLiteral' && /["]/.test(expression.raw.slice(1, -1))) {
  166. textToReplace = expression.raw;
  167. } else {
  168. textToReplace = `"${expressionType === 'TemplateLiteral'
  169. ? expression.quasis[0].value.raw
  170. : expression.raw.slice(1, -1)
  171. }"`;
  172. }
  173. } else if (jsxUtil.isJSX(expression)) {
  174. textToReplace = getText(context, expression);
  175. } else {
  176. textToReplace = expressionType === 'TemplateLiteral'
  177. ? expression.quasis[0].value.cooked : expression.value;
  178. }
  179. }
  180. return fixer.replaceText(JSXExpressionNode, textToReplace);
  181. },
  182. });
  183. }
  184. function reportMissingCurly(literalNode) {
  185. report(context, messages.missingCurly, 'missingCurly', {
  186. node: literalNode,
  187. fix(fixer) {
  188. if (jsxUtil.isJSX(literalNode)) {
  189. return fixer.replaceText(literalNode, `{${getText(context, literalNode)}}`);
  190. }
  191. // If a HTML entity name is found, bail out because it can be fixed
  192. // by either using the real character or the unicode equivalent.
  193. // If it contains any line terminator character, bail out as well.
  194. if (
  195. containsOnlyHtmlEntities(literalNode.raw)
  196. || (literalNode.parent.type === 'JSXAttribute' && containsLineTerminators(literalNode.raw))
  197. || isLineBreak(literalNode.raw)
  198. ) {
  199. return null;
  200. }
  201. const expression = literalNode.parent.type === 'JSXAttribute'
  202. ? `{"${escapeDoubleQuotes(escapeBackslashes(
  203. literalNode.raw.slice(1, -1)
  204. ))}"}`
  205. : wrapWithCurlyBraces(literalNode.raw);
  206. return fixer.replaceText(literalNode, expression);
  207. },
  208. });
  209. }
  210. // Bail out if there is any character that needs to be escaped in JSX
  211. // because escaping decreases readability and the original code may be more
  212. // readable anyway or intentional for other specific reasons
  213. function lintUnnecessaryCurly(JSXExpressionNode) {
  214. const expression = JSXExpressionNode.expression;
  215. const expressionType = expression.type;
  216. const sourceCode = getSourceCode(context);
  217. // Curly braces containing comments are necessary
  218. if (sourceCode.getCommentsInside && sourceCode.getCommentsInside(JSXExpressionNode).length > 0) {
  219. return;
  220. }
  221. if (
  222. (expressionType === 'Literal' || expressionType === 'JSXText')
  223. && typeof expression.value === 'string'
  224. && (
  225. (JSXExpressionNode.parent.type === 'JSXAttribute' && !isWhiteSpaceLiteral(expression))
  226. || !isLiteralWithTrailingWhiteSpaces(expression)
  227. )
  228. && !containsMultilineComment(expression.value)
  229. && !needToEscapeCharacterForJSX(expression.raw, JSXExpressionNode) && (
  230. jsxUtil.isJSX(JSXExpressionNode.parent)
  231. || (!containsQuoteCharacters(expression.value) || typeof expression.value === 'string')
  232. )
  233. ) {
  234. reportUnnecessaryCurly(JSXExpressionNode);
  235. } else if (
  236. expressionType === 'TemplateLiteral'
  237. && expression.expressions.length === 0
  238. && expression.quasis[0].value.raw.indexOf('\n') === -1
  239. && !isStringWithTrailingWhiteSpaces(expression.quasis[0].value.raw)
  240. && !needToEscapeCharacterForJSX(expression.quasis[0].value.raw, JSXExpressionNode)
  241. && !containsQuoteCharacters(expression.quasis[0].value.cooked)
  242. ) {
  243. reportUnnecessaryCurly(JSXExpressionNode);
  244. } else if (jsxUtil.isJSX(expression)) {
  245. reportUnnecessaryCurly(JSXExpressionNode);
  246. }
  247. }
  248. function areRuleConditionsSatisfied(parent, config, ruleCondition) {
  249. return (
  250. parent.type === 'JSXAttribute'
  251. && typeof config.props === 'string'
  252. && config.props === ruleCondition
  253. ) || (
  254. jsxUtil.isJSX(parent)
  255. && typeof config.children === 'string'
  256. && config.children === ruleCondition
  257. );
  258. }
  259. function getAdjacentSiblings(node, children) {
  260. for (let i = 1; i < children.length - 1; i++) {
  261. const child = children[i];
  262. if (node === child) {
  263. return [children[i - 1], children[i + 1]];
  264. }
  265. }
  266. if (node === children[0] && children[1]) {
  267. return [children[1]];
  268. }
  269. if (node === children[children.length - 1] && children[children.length - 2]) {
  270. return [children[children.length - 2]];
  271. }
  272. return [];
  273. }
  274. function hasAdjacentJsxExpressionContainers(node, children) {
  275. if (!children) {
  276. return false;
  277. }
  278. const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
  279. const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);
  280. return adjSiblings.some((x) => x.type && x.type === 'JSXExpressionContainer');
  281. }
  282. function hasAdjacentJsx(node, children) {
  283. if (!children) {
  284. return false;
  285. }
  286. const childrenExcludingWhitespaceLiteral = children.filter((child) => !isWhiteSpaceLiteral(child));
  287. const adjSiblings = getAdjacentSiblings(node, childrenExcludingWhitespaceLiteral);
  288. return adjSiblings.some((x) => x.type && arrayIncludes(['JSXExpressionContainer', 'JSXElement'], x.type));
  289. }
  290. function shouldCheckForUnnecessaryCurly(node, config) {
  291. const parent = node.parent;
  292. // Bail out if the parent is a JSXAttribute & its contents aren't
  293. // StringLiteral or TemplateLiteral since e.g
  294. // <App prop1={<CustomEl />} prop2={<CustomEl>...</CustomEl>} />
  295. if (
  296. parent.type && parent.type === 'JSXAttribute'
  297. && (node.expression && node.expression.type
  298. && node.expression.type !== 'Literal'
  299. && node.expression.type !== 'StringLiteral'
  300. && node.expression.type !== 'TemplateLiteral')
  301. ) {
  302. return false;
  303. }
  304. // If there are adjacent `JsxExpressionContainer` then there is no need,
  305. // to check for unnecessary curly braces.
  306. if (jsxUtil.isJSX(parent) && hasAdjacentJsxExpressionContainers(node, parent.children)) {
  307. return false;
  308. }
  309. if (containsWhitespaceExpression(node) && hasAdjacentJsx(node, parent.children)) {
  310. return false;
  311. }
  312. if (
  313. parent.children
  314. && parent.children.length === 1
  315. && containsWhitespaceExpression(node)
  316. ) {
  317. return false;
  318. }
  319. return areRuleConditionsSatisfied(parent, config, OPTION_NEVER);
  320. }
  321. function shouldCheckForMissingCurly(node, config) {
  322. if (jsxUtil.isJSX(node)) {
  323. return config.propElementValues !== OPTION_IGNORE;
  324. }
  325. if (
  326. isLineBreak(node.raw)
  327. || containsOnlyHtmlEntities(node.raw)
  328. ) {
  329. return false;
  330. }
  331. const parent = node.parent;
  332. if (
  333. parent.children
  334. && parent.children.length === 1
  335. && containsWhitespaceExpression(parent.children[0])
  336. ) {
  337. return false;
  338. }
  339. return areRuleConditionsSatisfied(parent, config, OPTION_ALWAYS);
  340. }
  341. // --------------------------------------------------------------------------
  342. // Public
  343. // --------------------------------------------------------------------------
  344. return {
  345. 'JSXAttribute > JSXExpressionContainer > JSXElement'(node) {
  346. if (userConfig.propElementValues === OPTION_NEVER) {
  347. reportUnnecessaryCurly(node.parent);
  348. }
  349. },
  350. JSXExpressionContainer(node) {
  351. if (shouldCheckForUnnecessaryCurly(node, userConfig)) {
  352. lintUnnecessaryCurly(node);
  353. }
  354. },
  355. 'JSXAttribute > JSXElement, Literal, JSXText'(node) {
  356. if (shouldCheckForMissingCurly(node, userConfig)) {
  357. reportMissingCurly(node);
  358. }
  359. },
  360. };
  361. },
  362. };