prefer-template.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. /**
  2. * @fileoverview A rule to suggest using template literals instead of string concatenation.
  3. * @author Toru Nagashima
  4. */
  5. "use strict";
  6. //------------------------------------------------------------------------------
  7. // Requirements
  8. //------------------------------------------------------------------------------
  9. const astUtils = require("./utils/ast-utils");
  10. //------------------------------------------------------------------------------
  11. // Helpers
  12. //------------------------------------------------------------------------------
  13. /**
  14. * Checks whether or not a given node is a concatenation.
  15. * @param {ASTNode} node A node to check.
  16. * @returns {boolean} `true` if the node is a concatenation.
  17. */
  18. function isConcatenation(node) {
  19. return node.type === "BinaryExpression" && node.operator === "+";
  20. }
  21. /**
  22. * Gets the top binary expression node for concatenation in parents of a given node.
  23. * @param {ASTNode} node A node to get.
  24. * @returns {ASTNode} the top binary expression node in parents of a given node.
  25. */
  26. function getTopConcatBinaryExpression(node) {
  27. let currentNode = node;
  28. while (isConcatenation(currentNode.parent)) {
  29. currentNode = currentNode.parent;
  30. }
  31. return currentNode;
  32. }
  33. /**
  34. * Checks whether or not a node contains a string literal with an octal or non-octal decimal escape sequence
  35. * @param {ASTNode} node A node to check
  36. * @returns {boolean} `true` if at least one string literal within the node contains
  37. * an octal or non-octal decimal escape sequence
  38. */
  39. function hasOctalOrNonOctalDecimalEscapeSequence(node) {
  40. if (isConcatenation(node)) {
  41. return (
  42. hasOctalOrNonOctalDecimalEscapeSequence(node.left) ||
  43. hasOctalOrNonOctalDecimalEscapeSequence(node.right)
  44. );
  45. }
  46. // No need to check TemplateLiterals – would throw parsing error
  47. if (node.type === "Literal" && typeof node.value === "string") {
  48. return astUtils.hasOctalOrNonOctalDecimalEscapeSequence(node.raw);
  49. }
  50. return false;
  51. }
  52. /**
  53. * Checks whether or not a given binary expression has string literals.
  54. * @param {ASTNode} node A node to check.
  55. * @returns {boolean} `true` if the node has string literals.
  56. */
  57. function hasStringLiteral(node) {
  58. if (isConcatenation(node)) {
  59. // `left` is deeper than `right` normally.
  60. return hasStringLiteral(node.right) || hasStringLiteral(node.left);
  61. }
  62. return astUtils.isStringLiteral(node);
  63. }
  64. /**
  65. * Checks whether or not a given binary expression has non string literals.
  66. * @param {ASTNode} node A node to check.
  67. * @returns {boolean} `true` if the node has non string literals.
  68. */
  69. function hasNonStringLiteral(node) {
  70. if (isConcatenation(node)) {
  71. // `left` is deeper than `right` normally.
  72. return (
  73. hasNonStringLiteral(node.right) || hasNonStringLiteral(node.left)
  74. );
  75. }
  76. return !astUtils.isStringLiteral(node);
  77. }
  78. /**
  79. * Determines whether a given node will start with a template curly expression (`${}`) when being converted to a template literal.
  80. * @param {ASTNode} node The node that will be fixed to a template literal
  81. * @returns {boolean} `true` if the node will start with a template curly.
  82. */
  83. function startsWithTemplateCurly(node) {
  84. if (node.type === "BinaryExpression") {
  85. return startsWithTemplateCurly(node.left);
  86. }
  87. if (node.type === "TemplateLiteral") {
  88. return (
  89. node.expressions.length &&
  90. node.quasis.length &&
  91. node.quasis[0].range[0] === node.quasis[0].range[1]
  92. );
  93. }
  94. return node.type !== "Literal" || typeof node.value !== "string";
  95. }
  96. /**
  97. * Determines whether a given node end with a template curly expression (`${}`) when being converted to a template literal.
  98. * @param {ASTNode} node The node that will be fixed to a template literal
  99. * @returns {boolean} `true` if the node will end with a template curly.
  100. */
  101. function endsWithTemplateCurly(node) {
  102. if (node.type === "BinaryExpression") {
  103. return startsWithTemplateCurly(node.right);
  104. }
  105. if (node.type === "TemplateLiteral") {
  106. return (
  107. node.expressions.length &&
  108. node.quasis.length &&
  109. node.quasis.at(-1).range[0] === node.quasis.at(-1).range[1]
  110. );
  111. }
  112. return node.type !== "Literal" || typeof node.value !== "string";
  113. }
  114. //------------------------------------------------------------------------------
  115. // Rule Definition
  116. //------------------------------------------------------------------------------
  117. /** @type {import('../types').Rule.RuleModule} */
  118. module.exports = {
  119. meta: {
  120. type: "suggestion",
  121. docs: {
  122. description:
  123. "Require template literals instead of string concatenation",
  124. recommended: false,
  125. frozen: true,
  126. url: "https://eslint.org/docs/latest/rules/prefer-template",
  127. },
  128. schema: [],
  129. fixable: "code",
  130. messages: {
  131. unexpectedStringConcatenation: "Unexpected string concatenation.",
  132. },
  133. },
  134. create(context) {
  135. const sourceCode = context.sourceCode;
  136. let done = Object.create(null);
  137. /**
  138. * Gets the non-token text between two nodes, ignoring any other tokens that appear between the two tokens.
  139. * @param {ASTNode} node1 The first node
  140. * @param {ASTNode} node2 The second node
  141. * @returns {string} The text between the nodes, excluding other tokens
  142. */
  143. function getTextBetween(node1, node2) {
  144. const allTokens = [node1]
  145. .concat(sourceCode.getTokensBetween(node1, node2))
  146. .concat(node2);
  147. const sourceText = sourceCode.getText();
  148. return allTokens
  149. .slice(0, -1)
  150. .reduce(
  151. (accumulator, token, index) =>
  152. accumulator +
  153. sourceText.slice(
  154. token.range[1],
  155. allTokens[index + 1].range[0],
  156. ),
  157. "",
  158. );
  159. }
  160. /**
  161. * Returns a template literal form of the given node.
  162. * @param {ASTNode} currentNode A node that should be converted to a template literal
  163. * @param {string} textBeforeNode Text that should appear before the node
  164. * @param {string} textAfterNode Text that should appear after the node
  165. * @returns {string} A string form of this node, represented as a template literal
  166. */
  167. function getTemplateLiteral(
  168. currentNode,
  169. textBeforeNode,
  170. textAfterNode,
  171. ) {
  172. if (
  173. currentNode.type === "Literal" &&
  174. typeof currentNode.value === "string"
  175. ) {
  176. /*
  177. * If the current node is a string literal, escape any instances of ${ or ` to prevent them from being interpreted
  178. * as a template placeholder. However, if the code already contains a backslash before the ${ or `
  179. * for some reason, don't add another backslash, because that would change the meaning of the code (it would cause
  180. * an actual backslash character to appear before the dollar sign).
  181. */
  182. return `\`${currentNode.raw
  183. .slice(1, -1)
  184. .replace(/\\*(\$\{|`)/gu, matched => {
  185. if (matched.lastIndexOf("\\") % 2) {
  186. return `\\${matched}`;
  187. }
  188. return matched;
  189. // Unescape any quotes that appear in the original Literal that no longer need to be escaped.
  190. })
  191. .replace(
  192. new RegExp(`\\\\${currentNode.raw[0]}`, "gu"),
  193. currentNode.raw[0],
  194. )}\``;
  195. }
  196. if (currentNode.type === "TemplateLiteral") {
  197. return sourceCode.getText(currentNode);
  198. }
  199. if (isConcatenation(currentNode) && hasStringLiteral(currentNode)) {
  200. const plusSign = sourceCode.getFirstTokenBetween(
  201. currentNode.left,
  202. currentNode.right,
  203. token => token.value === "+",
  204. );
  205. const textBeforePlus = getTextBetween(
  206. currentNode.left,
  207. plusSign,
  208. );
  209. const textAfterPlus = getTextBetween(
  210. plusSign,
  211. currentNode.right,
  212. );
  213. const leftEndsWithCurly = endsWithTemplateCurly(
  214. currentNode.left,
  215. );
  216. const rightStartsWithCurly = startsWithTemplateCurly(
  217. currentNode.right,
  218. );
  219. if (leftEndsWithCurly) {
  220. // If the left side of the expression ends with a template curly, add the extra text to the end of the curly bracket.
  221. // `foo${bar}` /* comment */ + 'baz' --> `foo${bar /* comment */ }${baz}`
  222. return (
  223. getTemplateLiteral(
  224. currentNode.left,
  225. textBeforeNode,
  226. textBeforePlus + textAfterPlus,
  227. ).slice(0, -1) +
  228. getTemplateLiteral(
  229. currentNode.right,
  230. null,
  231. textAfterNode,
  232. ).slice(1)
  233. );
  234. }
  235. if (rightStartsWithCurly) {
  236. // Otherwise, if the right side of the expression starts with a template curly, add the text there.
  237. // 'foo' /* comment */ + `${bar}baz` --> `foo${ /* comment */ bar}baz`
  238. return (
  239. getTemplateLiteral(
  240. currentNode.left,
  241. textBeforeNode,
  242. null,
  243. ).slice(0, -1) +
  244. getTemplateLiteral(
  245. currentNode.right,
  246. textBeforePlus + textAfterPlus,
  247. textAfterNode,
  248. ).slice(1)
  249. );
  250. }
  251. /*
  252. * Otherwise, these nodes should not be combined into a template curly, since there is nowhere to put
  253. * the text between them.
  254. */
  255. return `${getTemplateLiteral(currentNode.left, textBeforeNode, null)}${textBeforePlus}+${textAfterPlus}${getTemplateLiteral(currentNode.right, textAfterNode, null)}`;
  256. }
  257. return `\`\${${textBeforeNode || ""}${sourceCode.getText(currentNode)}${textAfterNode || ""}}\``;
  258. }
  259. /**
  260. * Returns a fixer object that converts a non-string binary expression to a template literal
  261. * @param {SourceCodeFixer} fixer The fixer object
  262. * @param {ASTNode} node A node that should be converted to a template literal
  263. * @returns {Object} A fix for this binary expression
  264. */
  265. function fixNonStringBinaryExpression(fixer, node) {
  266. const topBinaryExpr = getTopConcatBinaryExpression(node.parent);
  267. if (hasOctalOrNonOctalDecimalEscapeSequence(topBinaryExpr)) {
  268. return null;
  269. }
  270. return fixer.replaceText(
  271. topBinaryExpr,
  272. getTemplateLiteral(topBinaryExpr, null, null),
  273. );
  274. }
  275. /**
  276. * Reports if a given node is string concatenation with non string literals.
  277. * @param {ASTNode} node A node to check.
  278. * @returns {void}
  279. */
  280. function checkForStringConcat(node) {
  281. if (
  282. !astUtils.isStringLiteral(node) ||
  283. !isConcatenation(node.parent)
  284. ) {
  285. return;
  286. }
  287. const topBinaryExpr = getTopConcatBinaryExpression(node.parent);
  288. // Checks whether or not this node had been checked already.
  289. if (done[topBinaryExpr.range[0]]) {
  290. return;
  291. }
  292. done[topBinaryExpr.range[0]] = true;
  293. if (hasNonStringLiteral(topBinaryExpr)) {
  294. context.report({
  295. node: topBinaryExpr,
  296. messageId: "unexpectedStringConcatenation",
  297. fix: fixer => fixNonStringBinaryExpression(fixer, node),
  298. });
  299. }
  300. }
  301. return {
  302. Program() {
  303. done = Object.create(null);
  304. },
  305. Literal: checkForStringConcat,
  306. TemplateLiteral: checkForStringConcat,
  307. };
  308. },
  309. };