diff --git a/docs/api/ast-utils.md b/docs/api/ast-utils.md index 5e43046..745ac43 100644 --- a/docs/api/ast-utils.md +++ b/docs/api/ast-utils.md @@ -260,7 +260,7 @@ module.exports = { create(context) { return { MemberExpression(node) { - const name = getPropertyName(node) + const name = getPropertyName(node, context.getScope()) }, } }, @@ -269,42 +269,81 @@ module.exports = { ---- -## getStringIfConstant +## getStaticValue ```js -const str1 = utils.getStringIfConstant(node) -const str2 = utils.getStringIfConstant(node, initialScope) +const ret1 = utils.getStaticValue(node) +const ret2 = utils.getStaticValue(node, initialScope) ``` -Get the string value of a given literal node. +Get the value of a given node if it can decide the value statically. + +If the 2nd parameter `initialScope` was given, this function tries to resolve identifier references which are in the given node as much as possible. +In the resolving way, it does on the assumption that built-in global objects have not been modified. +For example, it considers `Symbol.iterator`, ``String.raw`hello` ``, and `Object.freeze({a: 1}).a` as static. + +For another complex example, this function can evaluate the following cases on AST: + +```js{6} +const eventName = "click" +const aMap = Object.freeze({ + click: 777 +}) + +;`on${eventName} : ${aMap[eventName]}` // evaluated to "onclick : 777" +``` ### Parameters Name | Type | Description :-----|:-----|:------------ -node | Node | The node to get that string value. -initialScope | Scope or undefined | Optional. The scope object to find variables. If this scope was given and the node is an Identifier node, it finds the variable of the identifier, and if the variable is a `const` variable, it returns the value of the `const` variable. +node | Node | The node to get that the value. +initialScope | Scope or undefined | Optional. The scope object to find variables. ### Return value -The string value of the node. -If the node is not constant then it returns `null`. +The `{ value: any }` shaped object. The `value` property is the static value. + +If it couldn't compute the static value of the node, it returns `null`. ### Example -```js{9} -const { getStringIfConstant } = require("eslint-utils") +```js{8} +const { getStaticValue } = require("eslint-utils") module.exports = { meta: {}, create(context) { return { - MemberExpression(node) { - const name = node.computed - ? getStringIfConstant(node.property) - : node.property.name + ExpressionStatement(node) { + const evaluated = getStaticValue(node, context.getScope()) + if (evaluated) { + const staticValue = evaluated.value + // ... + } }, } }, } ``` + +---- + +## getStringIfConstant + +```js +const str1 = utils.getStringIfConstant(node) +const str2 = utils.getStringIfConstant(node, initialScope) +``` + +Get the string value of a given node. + +This function is a tiny wrapper of the [getStaticValue](#getstaticvalue) function. +I.e., this is the same as below: + +```js +function getStringIfConstant(node, initialScope) { + const evaluated = getStaticValue(node, initialScope) + return evaluated && String(evaluated.value) +} +``` diff --git a/src/get-static-value.js b/src/get-static-value.js new file mode 100644 index 0000000..8f4a782 --- /dev/null +++ b/src/get-static-value.js @@ -0,0 +1,414 @@ +import { findVariable } from "./find-variable" + +const builtinNames = Object.freeze( + new Set([ + "Array", + "ArrayBuffer", + "Boolean", + "DataView", + "Date", + "decodeURI", + "decodeURIComponent", + "encodeURI", + "encodeURIComponent", + "Error", + "escape", + "EvalError", + "Float32Array", + "Float64Array", + "Function", + "Infinity", + "Int16Array", + "Int32Array", + "Int8Array", + "isFinite", + "isNaN", + "isPrototypeOf", + "JSON", + "Map", + "Math", + "NaN", + "Number", + "Object", + "parseFloat", + "parseInt", + "Promise", + "Proxy", + "RangeError", + "ReferenceError", + "Reflect", + "RegExp", + "Set", + "String", + "Symbol", + "SyntaxError", + "TypeError", + "Uint16Array", + "Uint32Array", + "Uint8Array", + "Uint8ClampedArray", + "undefined", + "unescape", + "URIError", + "WeakMap", + "WeakSet", + ]) +) + +/** + * Get the element values of a given node list. + * @param {Node[]} nodeList The node list to get values. + * @param {Scope|undefined} initialScope The initial scope to find variables. + * @returns {any[]|null} The value list if all nodes are constant. Otherwise, null. + */ +function getElementValues(nodeList, initialScope) { + const valueList = [] + + for (let i = 0; i < nodeList.length; ++i) { + const elementNode = nodeList[i] + + if (elementNode == null) { + valueList.length = i + 1 + } else if (elementNode.type === "SpreadElement") { + const argument = getStaticValueR(elementNode.argument, initialScope) + if (argument == null) { + return null + } + valueList.push(...argument.value) + } else { + const element = getStaticValueR(elementNode, initialScope) + if (element == null) { + return null + } + valueList.push(element.value) + } + } + + return valueList +} + +const operations = Object.freeze({ + ArrayExpression(node, initialScope) { + const elements = getElementValues(node.elements, initialScope) + return elements != null ? { value: elements } : null + }, + + AssignmentExpression(node, initialScope) { + if (node.operator === "=") { + return getStaticValueR(node.right, initialScope) + } + return null + }, + + //eslint-disable-next-line complexity + BinaryExpression(node, initialScope) { + if (node.operator === "in" || node.operator === "instanceof") { + // Not supported. + return null + } + + const left = getStaticValueR(node.left, initialScope) + const right = getStaticValueR(node.right, initialScope) + if (left != null && right != null) { + switch (node.operator) { + case "==": + return { value: left.value == right.value } //eslint-disable-line eqeqeq + case "!=": + return { value: left.value != right.value } //eslint-disable-line eqeqeq + case "===": + return { value: left.value === right.value } + case "!==": + return { value: left.value !== right.value } + case "<": + return { value: left.value < right.value } + case "<=": + return { value: left.value <= right.value } + case ">": + return { value: left.value > right.value } + case ">=": + return { value: left.value >= right.value } + case "<<": + return { value: left.value << right.value } + case ">>": + return { value: left.value >> right.value } + case ">>>": + return { value: left.value >>> right.value } + case "+": + return { value: left.value + right.value } + case "-": + return { value: left.value - right.value } + case "*": + return { value: left.value * right.value } + case "/": + return { value: left.value / right.value } + case "%": + return { value: left.value % right.value } + case "**": + return { value: Math.pow(left.value, right.value) } + case "|": + return { value: left.value | right.value } + case "^": + return { value: left.value ^ right.value } + case "&": + return { value: left.value & right.value } + + // no default + } + } + + return null + }, + + CallExpression(node, initialScope) { + const calleeNode = node.callee + const args = getElementValues(node.arguments, initialScope) + + if (args != null) { + if (calleeNode.type === "MemberExpression") { + const object = getStaticValueR(calleeNode.object, initialScope) + const property = calleeNode.computed + ? getStaticValueR(calleeNode.property, initialScope) + : { value: calleeNode.property.name } + + if (object != null && property != null) { + const receiver = object.value + const methodName = property.value + return { value: receiver[methodName](...args) } + } + } else { + const callee = getStaticValueR(calleeNode, initialScope) + if (callee != null) { + const func = callee.value + return { value: func(...args) } + } + } + } + + return null + }, + + ConditionalExpression(node, initialScope) { + const test = getStaticValueR(node.test, initialScope) + if (test != null) { + return test.value + ? getStaticValueR(node.consequent, initialScope) + : getStaticValueR(node.alternate, initialScope) + } + return null + }, + + ExpressionStatement(node, initialScope) { + return getStaticValueR(node.expression, initialScope) + }, + + Identifier(node, initialScope) { + if (initialScope != null) { + const variable = findVariable(initialScope, node) + + // Built-in globals. + if ( + variable != null && + variable.defs.length === 0 && + builtinNames.has(variable.name) && + variable.name in global + ) { + return { value: global[variable.name] } + } + + // Constants. + if (variable != null && variable.defs.length === 1) { + const def = variable.defs[0] + if ( + def.parent && + def.parent.kind === "const" && + // TODO(mysticatea): don't support destructuring here. + def.node.id.type === "Identifier" + ) { + return getStaticValueR(def.node.init, initialScope) + } + } + } + return null + }, + + Literal(node) { + //istanbul ignore if : this is implementation-specific behavior. + if (node.regex != null && node.value == null) { + // It was a RegExp literal, but Node.js didn't support it. + return null + } + return node + }, + + LogicalExpression(node, initialScope) { + const left = getStaticValueR(node.left, initialScope) + if (left != null) { + if ( + (node.operator === "||" && Boolean(left.value) === true) || + (node.operator === "&&" && Boolean(left.value) === false) + ) { + return left + } + + const right = getStaticValueR(node.right, initialScope) + if (right != null) { + return right + } + } + + return null + }, + + MemberExpression(node, initialScope) { + const object = getStaticValueR(node.object, initialScope) + const property = node.computed + ? getStaticValueR(node.property, initialScope) + : { value: node.property.name } + + if (object != null && property != null) { + return { value: object.value[property.value] } + } + return null + }, + + NewExpression(node, initialScope) { + const callee = getStaticValueR(node.callee, initialScope) + const args = getElementValues(node.arguments, initialScope) + + if (callee != null && args != null) { + const Func = callee.value + return { value: new Func(...args) } + } + + return null + }, + + ObjectExpression(node, initialScope) { + const object = {} + + for (const propertyNode of node.properties) { + if (propertyNode.type === "Property") { + if (propertyNode.kind !== "init") { + return null + } + const key = propertyNode.computed + ? getStaticValueR(propertyNode.key, initialScope) + : { value: propertyNode.key.name } + const value = getStaticValueR(propertyNode.value, initialScope) + if (key == null || value == null) { + return null + } + object[key.value] = value.value + } else if ( + propertyNode.type === "SpreadElement" || + propertyNode.type === "ExperimentalSpreadProperty" + ) { + const argument = getStaticValueR( + propertyNode.argument, + initialScope + ) + if (argument == null) { + return null + } + Object.assign(object, argument.value) + } else { + return null + } + } + + return { value: object } + }, + + SequenceExpression(node, initialScope) { + const last = node.expressions[node.expressions.length - 1] + return getStaticValueR(last, initialScope) + }, + + TaggedTemplateExpression(node, initialScope) { + const tag = getStaticValueR(node.tag, initialScope) + const expressions = getElementValues( + node.quasi.expressions, + initialScope + ) + + if (tag != null && expressions != null) { + const func = tag.value + const strings = node.quasi.quasis.map(q => q.value.cooked) + strings.raw = node.quasi.quasis.map(q => q.value.raw) + + return { value: func(strings, ...expressions) } + } + + return null + }, + + TemplateLiteral(node, initialScope) { + const expressions = getElementValues(node.expressions, initialScope) + if (expressions != null) { + let value = node.quasis[0].value.cooked + for (let i = 0; i < expressions.length; ++i) { + value += expressions[i] + value += node.quasis[i + 1].value.cooked + } + return { value } + } + return null + }, + + UnaryExpression(node, initialScope) { + if (node.operator === "delete") { + // Not supported. + return null + } + if (node.operator === "void") { + return { value: undefined } + } + + const arg = getStaticValueR(node.argument, initialScope) + if (arg != null) { + switch (node.operator) { + case "-": + return { value: -arg.value } + case "+": + return { value: +arg.value } //eslint-disable-line no-implicit-coercion + case "!": + return { value: !arg.value } + case "~": + return { value: ~arg.value } + case "typeof": + return { value: typeof arg.value } + + // no default + } + } + + return null + }, +}) + +/** + * Get the value of a given node if it's a static value. + * @param {Node} node The node to get. + * @param {Scope|undefined} initialScope The scope to start finding variable. + * @returns {{value:any}|null} The static value of the node, or `null`. + */ +function getStaticValueR(node, initialScope) { + if (node != null && Object.hasOwnProperty.call(operations, node.type)) { + return operations[node.type](node, initialScope) + } + return null +} + +/** + * Get the value of a given node if it's a static value. + * @param {Node} node The node to get. + * @param {Scope} [initialScope] The scope to start finding variable. Optional. If this scope was given, this tries to resolve identifier references which are in the given node as much as possible. + * @returns {{value:any}|null} The static value of the node, or `null`. + */ +export function getStaticValue(node, initialScope = null) { + try { + return getStaticValueR(node, initialScope) + } catch (_error) { + return null + } +} diff --git a/src/get-string-if-constant.js b/src/get-string-if-constant.js index 668d067..61c5370 100644 --- a/src/get-string-if-constant.js +++ b/src/get-string-if-constant.js @@ -1,4 +1,4 @@ -import { findVariable } from "./find-variable" +import { getStaticValue } from "./get-static-value" /** * Get the value of a given node if it's a literal or a template literal. @@ -7,38 +7,6 @@ import { findVariable } from "./find-variable" * @returns {string|null} The value of the node, or `null`. */ export function getStringIfConstant(node, initialScope = null) { - if (!node) { - return null - } - - switch (node.type) { - case "Literal": - if (node.regex) { - return `/${node.regex.pattern}/${node.regex.flags}` - } - return String(node.value) - - case "TemplateLiteral": - if (node.expressions.length === 0) { - const value = node.quasis[0].value - if (typeof value.cooked === "string") { - return value.cooked - } - } - break - - case "Identifier": - if (initialScope != null) { - const variable = findVariable(initialScope, node) - const def = variable && variable.defs[0] - if (def != null && def.parent.kind === "const") { - return getStringIfConstant(def.node.init) - } - } - break - - // no default - } - - return null + const evaluated = getStaticValue(node, initialScope) + return evaluated && String(evaluated.value) } diff --git a/src/index.js b/src/index.js index 19b1094..e82aab8 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import { getFunctionHeadLocation } from "./get-function-head-location" import { getFunctionNameWithKind } from "./get-function-name-with-kind" import { getInnermostScope } from "./get-innermost-scope" import { getPropertyName } from "./get-property-name" +import { getStaticValue } from "./get-static-value" import { getStringIfConstant } from "./get-string-if-constant" import { CALL, @@ -45,6 +46,7 @@ export default { getFunctionNameWithKind, getInnermostScope, getPropertyName, + getStaticValue, getStringIfConstant, isArrowToken, isClosingBraceToken, @@ -80,6 +82,7 @@ export { getFunctionNameWithKind, getInnermostScope, getPropertyName, + getStaticValue, getStringIfConstant, isArrowToken, isClosingBraceToken, diff --git a/test/get-property-name.js b/test/get-property-name.js index 098b9c1..012c400 100644 --- a/test/get-property-name.js +++ b/test/get-property-name.js @@ -9,7 +9,7 @@ describe("The 'getPropertyName' function", () => { { code: "a[`b`]", expected: "b" }, { code: "a[100]", expected: "100" }, { code: "a[b]", expected: null }, - { code: "a['a' + 'b']", expected: null }, + { code: "a['a' + 'b']", expected: "ab" }, { code: "a[tag`b`]", expected: null }, { code: "a[`${b}`]", expected: null }, //eslint-disable-line no-template-curly-in-string { code: "({b: 1})", expected: "b" }, @@ -22,7 +22,7 @@ describe("The 'getPropertyName' function", () => { { code: "({[`b`]: 1})", expected: "b" }, { code: "({[100]: 1})", expected: "100" }, { code: "({[b]: 1})", expected: null }, - { code: "({['a' + 'b']: 1})", expected: null }, + { code: "({['a' + 'b']: 1})", expected: "ab" }, { code: "({[tag`b`]: 1})", expected: null }, { code: "({[`${b}`]: 1})", expected: null }, //eslint-disable-line no-template-curly-in-string { code: "(class {b() {}})", expected: "b" }, @@ -30,7 +30,7 @@ describe("The 'getPropertyName' function", () => { { code: "(class {['b']() {}})", expected: "b" }, { code: "(class {[100]() {}})", expected: "100" }, { code: "(class {[b]() {}})", expected: null }, - { code: "(class {['a' + 'b']() {}})", expected: null }, + { code: "(class {['a' + 'b']() {}})", expected: "ab" }, { code: "(class {[tag`b`]() {}})", expected: null }, { code: "(class {[`${b}`]() {}})", expected: null }, //eslint-disable-line no-template-curly-in-string ]) { diff --git a/test/get-static-value.js b/test/get-static-value.js new file mode 100644 index 0000000..af34f91 --- /dev/null +++ b/test/get-static-value.js @@ -0,0 +1,145 @@ +import assert from "assert" +import eslint from "eslint" +import { getStaticValue } from "../src/" + +describe("The 'getStaticValue' function", () => { + for (const { code, expected, noScope = false } of [ + { code: "[]", expected: { value: [] } }, + { code: "[1, 2, 3]", expected: { value: [1, 2, 3] } }, + { code: "[,, 3]", expected: { value: [, , 3] } }, //eslint-disable-line no-sparse-arrays + { code: "[1, ...[2, 3]]", expected: { value: [1, 2, 3] } }, + { code: "[0, a]", expected: null }, + { code: "[0, ...a]", expected: null }, + { code: "a = 1 + 2", expected: { value: 3 } }, + { code: "a += 1 + 2", expected: null }, + { code: "a in obj", expected: null }, + { code: "obj instanceof Object", expected: null }, + { code: "1 == '1'", expected: { value: true } }, + { code: "1 != '1'", expected: { value: false } }, + { code: "1 === '1'", expected: { value: false } }, + { code: "1 !== '1'", expected: { value: true } }, + { code: "1 < '1'", expected: { value: false } }, + { code: "1 <= '1'", expected: { value: true } }, + { code: "1 > '1'", expected: { value: false } }, + { code: "1 >= '1'", expected: { value: true } }, + { code: "1 << '1'", expected: { value: 2 } }, + { code: "1 >> '1'", expected: { value: 0 } }, + { code: "1 >>> '1'", expected: { value: 0 } }, + { code: "1 + '1'", expected: { value: "11" } }, + { code: "1 + 2", expected: { value: 3 } }, + { code: "1 - 2", expected: { value: -1 } }, + { code: "1 * 2", expected: { value: 2 } }, + { code: "1 / 2", expected: { value: 0.5 } }, + { code: "1 % 2", expected: { value: 1 } }, + { code: "2 ** 2", expected: { value: 4 } }, + { code: "1 | 2", expected: { value: 3 } }, + { code: "1 ^ 15", expected: { value: 14 } }, + { code: "3 & 2", expected: { value: 2 } }, + { code: "a + 1", expected: null }, + { code: "String(7)", expected: { value: "7" } }, + { code: "Math.round(0.7)", expected: { value: 1 } }, + { code: "Math['round'](0.4)", expected: { value: 0 } }, + { code: "foo(7)", expected: null }, + { code: "obj.foo(7)", expected: null }, + { code: "Math.round(a)", expected: null }, + { code: "true ? 1 : c", expected: { value: 1 } }, + { code: "false ? b : 2", expected: { value: 2 } }, + { code: "a ? 1 : 2", expected: null }, + { code: "true ? b : 2", expected: null }, + { code: "false ? 1 : c", expected: null }, + { code: "undefined", expected: { value: undefined } }, + { code: "var undefined; undefined", expected: null }, + { code: "const undefined = 1; undefined", expected: { value: 1 } }, + { code: "const a = 2; a", expected: { value: 2 } }, + { code: "let a = 2; a", expected: null }, + { code: "const a = 2; a", expected: null, noScope: true }, + { code: "const a = { b: 7 }; a.b", expected: { value: 7 } }, + { code: "null", expected: { value: null } }, + { code: "true", expected: { value: true } }, + { code: "false", expected: { value: false } }, + { code: "1", expected: { value: 1 } }, + { code: "'hello'", expected: { value: "hello" } }, + { code: "/foo/g", expected: { value: /foo/g } }, + { code: "true && 1", expected: { value: 1 } }, + { code: "false && a", expected: { value: false } }, + { code: "true || a", expected: { value: true } }, + { code: "false || 2", expected: { value: 2 } }, + { code: "true && a", expected: null }, + { code: "false || a", expected: null }, + { code: "a && 1", expected: null }, + { code: "Symbol.iterator", expected: { value: Symbol.iterator } }, + { + code: "Symbol['iter' + 'ator']", + expected: { value: Symbol.iterator }, + }, + { code: "Symbol[iterator]", expected: null }, + { code: "Object.freeze", expected: { value: Object.freeze } }, + { code: "Object.xxx", expected: { value: undefined } }, + { code: "new Array(2)", expected: { value: new Array(2) } }, + { code: "new Array(len)", expected: null }, + { code: "({})", expected: { value: {} } }, + { + code: "({a: 1, b: 2, c: 3})", + expected: { value: { a: 1, b: 2, c: 3 } }, + }, + { + code: "const obj = {b: 2}; ({a: 1, ...obj})", + expected: { value: { a: 1, b: 2 } }, + }, + { code: "var obj = {b: 2}; ({a: 1, ...obj})", expected: null }, + { code: "({ get a() {} })", expected: null }, + { code: "({ a })", expected: null }, + { code: "({ a: b })", expected: null }, + { code: "({ [a]: 1 })", expected: null }, + { code: "(a, b, 3)", expected: { value: 3 } }, + { code: "(1, b)", expected: null }, + { code: "`hello`", expected: { value: "hello" } }, + { code: "const ll = 'll'; `he${ll}o`", expected: { value: "hello" } }, //eslint-disable-line no-template-curly-in-string + { code: "String.raw`\\unicode`", expected: { value: "\\unicode" } }, + { code: "`he${a}o`", expected: null }, //eslint-disable-line no-template-curly-in-string + { code: "x`hello`", expected: null }, + { code: "-1", expected: { value: -1 } }, + { code: "+'1'", expected: { value: 1 } }, + { code: "!0", expected: { value: true } }, + { code: "~-1", expected: { value: 0 } }, + { code: "typeof 0", expected: { value: "number" } }, + { code: "void a.b", expected: { value: undefined } }, + { code: "+a", expected: null }, + { code: "delete a.b", expected: null }, + { code: "!function(){ return true }", expected: null }, + { code: "'' + Symbol()", expected: null }, + { + code: `const eventName = "click" +const aMap = Object.freeze({ + click: 777 +}) +;\`on\${eventName} : \${aMap[eventName]}\``, + expected: { value: "onclick : 777" }, + }, + ]) { + it(`should return ${JSON.stringify(expected)} from ${code}`, () => { + const linter = new eslint.Linter() + + let actual = null + linter.defineRule("test", context => ({ + ExpressionStatement(node) { + actual = getStaticValue( + node, + noScope ? null : context.getScope() + ) + }, + })) + linter.verify(code, { + env: { es6: true }, + parserOptions: { ecmaVersion: 2018 }, + rules: { test: "error" }, + }) + + if (actual == null) { + assert.strictEqual(actual, expected) + } else { + assert.deepStrictEqual(actual.value, expected.value) + } + }) + } +}) diff --git a/test/get-string-if-constant.js b/test/get-string-if-constant.js index b50410e..2761d25 100644 --- a/test/get-string-if-constant.js +++ b/test/get-string-if-constant.js @@ -17,8 +17,8 @@ describe("The 'getStringIfConstant' function", () => { { code: "id", expected: null }, { code: "tag`foo`", expected: null }, { code: "`aaa${id}bbb`", expected: null }, //eslint-disable-line no-template-curly-in-string - { code: "1 + 2", expected: null }, - { code: "'a' + 'b'", expected: null }, + { code: "1 + 2", expected: "3" }, + { code: "'a' + 'b'", expected: "ab" }, ]) { it(`should return ${JSON.stringify(expected)} from ${code}`, () => { const linter = new eslint.Linter()