381 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			381 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @fileoverview A rule to disallow the type conversions with shorter notations.
 | |
|  * @author Toru Nagashima
 | |
|  */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| const astUtils = require("./utils/ast-utils");
 | |
| 
 | |
| //------------------------------------------------------------------------------
 | |
| // Helpers
 | |
| //------------------------------------------------------------------------------
 | |
| 
 | |
| const INDEX_OF_PATTERN = /^(?:i|lastI)ndexOf$/u;
 | |
| const ALLOWABLE_OPERATORS = ["~", "!!", "+", "*"];
 | |
| 
 | |
| /**
 | |
|  * Parses and normalizes an option object.
 | |
|  * @param {Object} options An option object to parse.
 | |
|  * @returns {Object} The parsed and normalized option object.
 | |
|  */
 | |
| function parseOptions(options) {
 | |
|     return {
 | |
|         boolean: "boolean" in options ? options.boolean : true,
 | |
|         number: "number" in options ? options.number : true,
 | |
|         string: "string" in options ? options.string : true,
 | |
|         disallowTemplateShorthand: "disallowTemplateShorthand" in options ? options.disallowTemplateShorthand : false,
 | |
|         allow: options.allow || []
 | |
|     };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether or not a node is a double logical negating.
 | |
|  * @param {ASTNode} node An UnaryExpression node to check.
 | |
|  * @returns {boolean} Whether or not the node is a double logical negating.
 | |
|  */
 | |
| function isDoubleLogicalNegating(node) {
 | |
|     return (
 | |
|         node.operator === "!" &&
 | |
|         node.argument.type === "UnaryExpression" &&
 | |
|         node.argument.operator === "!"
 | |
|     );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether or not a node is a binary negating of `.indexOf()` method calling.
 | |
|  * @param {ASTNode} node An UnaryExpression node to check.
 | |
|  * @returns {boolean} Whether or not the node is a binary negating of `.indexOf()` method calling.
 | |
|  */
 | |
| function isBinaryNegatingOfIndexOf(node) {
 | |
|     if (node.operator !== "~") {
 | |
|         return false;
 | |
|     }
 | |
|     const callNode = astUtils.skipChainExpression(node.argument);
 | |
| 
 | |
|     return (
 | |
|         callNode.type === "CallExpression" &&
 | |
|         astUtils.isSpecificMemberAccess(callNode.callee, null, INDEX_OF_PATTERN)
 | |
|     );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether or not a node is a multiplying by one.
 | |
|  * @param {BinaryExpression} node A BinaryExpression node to check.
 | |
|  * @returns {boolean} Whether or not the node is a multiplying by one.
 | |
|  */
 | |
| function isMultiplyByOne(node) {
 | |
|     return node.operator === "*" && (
 | |
|         node.left.type === "Literal" && node.left.value === 1 ||
 | |
|         node.right.type === "Literal" && node.right.value === 1
 | |
|     );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether the given node logically represents multiplication by a fraction of `1`.
 | |
|  * For example, `a * 1` in `a * 1 / b` is technically multiplication by `1`, but the
 | |
|  * whole expression can be logically interpreted as `a * (1 / b)` rather than `(a * 1) / b`.
 | |
|  * @param {BinaryExpression} node A BinaryExpression node to check.
 | |
|  * @param {SourceCode} sourceCode The source code object.
 | |
|  * @returns {boolean} Whether or not the node is a multiplying by a fraction of `1`.
 | |
|  */
 | |
| function isMultiplyByFractionOfOne(node, sourceCode) {
 | |
|     return node.type === "BinaryExpression" &&
 | |
|         node.operator === "*" &&
 | |
|         (node.right.type === "Literal" && node.right.value === 1) &&
 | |
|         node.parent.type === "BinaryExpression" &&
 | |
|         node.parent.operator === "/" &&
 | |
|         node.parent.left === node &&
 | |
|         !astUtils.isParenthesised(sourceCode, node);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether the result of a node is numeric or not
 | |
|  * @param {ASTNode} node The node to test
 | |
|  * @returns {boolean} true if the node is a number literal or a `Number()`, `parseInt` or `parseFloat` call
 | |
|  */
 | |
| function isNumeric(node) {
 | |
|     return (
 | |
|         node.type === "Literal" && typeof node.value === "number" ||
 | |
|         node.type === "CallExpression" && (
 | |
|             node.callee.name === "Number" ||
 | |
|             node.callee.name === "parseInt" ||
 | |
|             node.callee.name === "parseFloat"
 | |
|         )
 | |
|     );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Returns the first non-numeric operand in a BinaryExpression. Designed to be
 | |
|  * used from bottom to up since it walks up the BinaryExpression trees using
 | |
|  * node.parent to find the result.
 | |
|  * @param {BinaryExpression} node The BinaryExpression node to be walked up on
 | |
|  * @returns {ASTNode|null} The first non-numeric item in the BinaryExpression tree or null
 | |
|  */
 | |
| function getNonNumericOperand(node) {
 | |
|     const left = node.left,
 | |
|         right = node.right;
 | |
| 
 | |
|     if (right.type !== "BinaryExpression" && !isNumeric(right)) {
 | |
|         return right;
 | |
|     }
 | |
| 
 | |
|     if (left.type !== "BinaryExpression" && !isNumeric(left)) {
 | |
|         return left;
 | |
|     }
 | |
| 
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether an expression evaluates to a string.
 | |
|  * @param {ASTNode} node node that represents the expression to check.
 | |
|  * @returns {boolean} Whether or not the expression evaluates to a string.
 | |
|  */
 | |
| function isStringType(node) {
 | |
|     return astUtils.isStringLiteral(node) ||
 | |
|         (
 | |
|             node.type === "CallExpression" &&
 | |
|             node.callee.type === "Identifier" &&
 | |
|             node.callee.name === "String"
 | |
|         );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether a node is an empty string literal or not.
 | |
|  * @param {ASTNode} node The node to check.
 | |
|  * @returns {boolean} Whether or not the passed in node is an
 | |
|  * empty string literal or not.
 | |
|  */
 | |
| function isEmptyString(node) {
 | |
|     return astUtils.isStringLiteral(node) && (node.value === "" || (node.type === "TemplateLiteral" && node.quasis.length === 1 && node.quasis[0].value.cooked === ""));
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether or not a node is a concatenating with an empty string.
 | |
|  * @param {ASTNode} node A BinaryExpression node to check.
 | |
|  * @returns {boolean} Whether or not the node is a concatenating with an empty string.
 | |
|  */
 | |
| function isConcatWithEmptyString(node) {
 | |
|     return node.operator === "+" && (
 | |
|         (isEmptyString(node.left) && !isStringType(node.right)) ||
 | |
|         (isEmptyString(node.right) && !isStringType(node.left))
 | |
|     );
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Checks whether or not a node is appended with an empty string.
 | |
|  * @param {ASTNode} node An AssignmentExpression node to check.
 | |
|  * @returns {boolean} Whether or not the node is appended with an empty string.
 | |
|  */
 | |
| function isAppendEmptyString(node) {
 | |
|     return node.operator === "+=" && isEmptyString(node.right);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Returns the operand that is not an empty string from a flagged BinaryExpression.
 | |
|  * @param {ASTNode} node The flagged BinaryExpression node to check.
 | |
|  * @returns {ASTNode} The operand that is not an empty string from a flagged BinaryExpression.
 | |
|  */
 | |
| function getNonEmptyOperand(node) {
 | |
|     return isEmptyString(node.left) ? node.right : node.left;
 | |
| }
 | |
| 
 | |
| //------------------------------------------------------------------------------
 | |
| // Rule Definition
 | |
| //------------------------------------------------------------------------------
 | |
| 
 | |
| /** @type {import('../shared/types').Rule} */
 | |
| module.exports = {
 | |
|     meta: {
 | |
|         type: "suggestion",
 | |
| 
 | |
|         docs: {
 | |
|             description: "Disallow shorthand type conversions",
 | |
|             recommended: false,
 | |
|             url: "https://eslint.org/docs/latest/rules/no-implicit-coercion"
 | |
|         },
 | |
| 
 | |
|         fixable: "code",
 | |
| 
 | |
|         schema: [{
 | |
|             type: "object",
 | |
|             properties: {
 | |
|                 boolean: {
 | |
|                     type: "boolean",
 | |
|                     default: true
 | |
|                 },
 | |
|                 number: {
 | |
|                     type: "boolean",
 | |
|                     default: true
 | |
|                 },
 | |
|                 string: {
 | |
|                     type: "boolean",
 | |
|                     default: true
 | |
|                 },
 | |
|                 disallowTemplateShorthand: {
 | |
|                     type: "boolean",
 | |
|                     default: false
 | |
|                 },
 | |
|                 allow: {
 | |
|                     type: "array",
 | |
|                     items: {
 | |
|                         enum: ALLOWABLE_OPERATORS
 | |
|                     },
 | |
|                     uniqueItems: true
 | |
|                 }
 | |
|             },
 | |
|             additionalProperties: false
 | |
|         }],
 | |
| 
 | |
|         messages: {
 | |
|             useRecommendation: "use `{{recommendation}}` instead."
 | |
|         }
 | |
|     },
 | |
| 
 | |
|     create(context) {
 | |
|         const options = parseOptions(context.options[0] || {});
 | |
|         const sourceCode = context.sourceCode;
 | |
| 
 | |
|         /**
 | |
|          * Reports an error and autofixes the node
 | |
|          * @param {ASTNode} node An ast node to report the error on.
 | |
|          * @param {string} recommendation The recommended code for the issue
 | |
|          * @param {bool} shouldFix Whether this report should fix the node
 | |
|          * @returns {void}
 | |
|          */
 | |
|         function report(node, recommendation, shouldFix) {
 | |
|             context.report({
 | |
|                 node,
 | |
|                 messageId: "useRecommendation",
 | |
|                 data: {
 | |
|                     recommendation
 | |
|                 },
 | |
|                 fix(fixer) {
 | |
|                     if (!shouldFix) {
 | |
|                         return null;
 | |
|                     }
 | |
| 
 | |
|                     const tokenBefore = sourceCode.getTokenBefore(node);
 | |
| 
 | |
|                     if (
 | |
|                         tokenBefore &&
 | |
|                         tokenBefore.range[1] === node.range[0] &&
 | |
|                         !astUtils.canTokensBeAdjacent(tokenBefore, recommendation)
 | |
|                     ) {
 | |
|                         return fixer.replaceText(node, ` ${recommendation}`);
 | |
|                     }
 | |
|                     return fixer.replaceText(node, recommendation);
 | |
|                 }
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         return {
 | |
|             UnaryExpression(node) {
 | |
|                 let operatorAllowed;
 | |
| 
 | |
|                 // !!foo
 | |
|                 operatorAllowed = options.allow.includes("!!");
 | |
|                 if (!operatorAllowed && options.boolean && isDoubleLogicalNegating(node)) {
 | |
|                     const recommendation = `Boolean(${sourceCode.getText(node.argument.argument)})`;
 | |
| 
 | |
|                     report(node, recommendation, true);
 | |
|                 }
 | |
| 
 | |
|                 // ~foo.indexOf(bar)
 | |
|                 operatorAllowed = options.allow.includes("~");
 | |
|                 if (!operatorAllowed && options.boolean && isBinaryNegatingOfIndexOf(node)) {
 | |
| 
 | |
|                     // `foo?.indexOf(bar) !== -1` will be true (== found) if the `foo` is nullish. So use `>= 0` in that case.
 | |
|                     const comparison = node.argument.type === "ChainExpression" ? ">= 0" : "!== -1";
 | |
|                     const recommendation = `${sourceCode.getText(node.argument)} ${comparison}`;
 | |
| 
 | |
|                     report(node, recommendation, false);
 | |
|                 }
 | |
| 
 | |
|                 // +foo
 | |
|                 operatorAllowed = options.allow.includes("+");
 | |
|                 if (!operatorAllowed && options.number && node.operator === "+" && !isNumeric(node.argument)) {
 | |
|                     const recommendation = `Number(${sourceCode.getText(node.argument)})`;
 | |
| 
 | |
|                     report(node, recommendation, true);
 | |
|                 }
 | |
|             },
 | |
| 
 | |
|             // Use `:exit` to prevent double reporting
 | |
|             "BinaryExpression:exit"(node) {
 | |
|                 let operatorAllowed;
 | |
| 
 | |
|                 // 1 * foo
 | |
|                 operatorAllowed = options.allow.includes("*");
 | |
|                 const nonNumericOperand = !operatorAllowed && options.number && isMultiplyByOne(node) && !isMultiplyByFractionOfOne(node, sourceCode) &&
 | |
|                     getNonNumericOperand(node);
 | |
| 
 | |
|                 if (nonNumericOperand) {
 | |
|                     const recommendation = `Number(${sourceCode.getText(nonNumericOperand)})`;
 | |
| 
 | |
|                     report(node, recommendation, true);
 | |
|                 }
 | |
| 
 | |
|                 // "" + foo
 | |
|                 operatorAllowed = options.allow.includes("+");
 | |
|                 if (!operatorAllowed && options.string && isConcatWithEmptyString(node)) {
 | |
|                     const recommendation = `String(${sourceCode.getText(getNonEmptyOperand(node))})`;
 | |
| 
 | |
|                     report(node, recommendation, true);
 | |
|                 }
 | |
|             },
 | |
| 
 | |
|             AssignmentExpression(node) {
 | |
| 
 | |
|                 // foo += ""
 | |
|                 const operatorAllowed = options.allow.includes("+");
 | |
| 
 | |
|                 if (!operatorAllowed && options.string && isAppendEmptyString(node)) {
 | |
|                     const code = sourceCode.getText(getNonEmptyOperand(node));
 | |
|                     const recommendation = `${code} = String(${code})`;
 | |
| 
 | |
|                     report(node, recommendation, true);
 | |
|                 }
 | |
|             },
 | |
| 
 | |
|             TemplateLiteral(node) {
 | |
|                 if (!options.disallowTemplateShorthand) {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 // tag`${foo}`
 | |
|                 if (node.parent.type === "TaggedTemplateExpression") {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 // `` or `${foo}${bar}`
 | |
|                 if (node.expressions.length !== 1) {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
| 
 | |
|                 //  `prefix${foo}`
 | |
|                 if (node.quasis[0].value.cooked !== "") {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 //  `${foo}postfix`
 | |
|                 if (node.quasis[1].value.cooked !== "") {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 // if the expression is already a string, then this isn't a coercion
 | |
|                 if (isStringType(node.expressions[0])) {
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 const code = sourceCode.getText(node.expressions[0]);
 | |
|                 const recommendation = `String(${code})`;
 | |
| 
 | |
|                 report(node, recommendation, true);
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| };
 |