jsx-curly-brace-presence.js 14 KB

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