jsx-curly-brace-presence.js 13 KB

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