diff --git a/.gitignore b/.gitignore index b83202d..76eda3a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ logs results npm-debug.log node_modules +lib +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..738837c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "bracketSpacing": false, + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 5e0f19c..0000000 --- a/index.js +++ /dev/null @@ -1,129 +0,0 @@ -'use strict' - -var walk = require('babylon-walk'); -var getExpression = require('is-expression-babylon').getExpression; -var t = require('babel-types'); - -var lastSRC = '(null)'; -var lastRes = true; -var lastConstants = undefined; - -var STATEMENT_WHITE_LIST = { - 'EmptyStatement': true, - 'ExpressionStatement': true, -}; -// See require('babel-types').EXPRESSION_TYPES -var EXPRESSION_WHITE_LIST = { - ArrayExpression: true, - // AssignmentExpression: false, - BinaryExpression: true, - CallExpression: true, - ConditionalExpression: true, - // FunctionExpression: false, - Identifier: true, - StringLiteral: true, - NumericLiteral: true, - NullLiteral: true, - BooleanLiteral: true, - RegExpLiteral: true, - LogicalExpression: true, - MemberExpression: true, - NewExpression: true, - ObjectExpression: true, - SequenceExpression: true, - // ThisExpression: false, - UnaryExpression: true, - // UpdateExpression: false, - // ArrowFunctionExpression: false, - // ClassExpression: false, - // MetaProperty: false, - // Super: false, - TaggedTemplateExpression: true, - TemplateLiteral: true, - // YieldExpression: false, - TypeCastExpression: true, - JSXElement: true, - JSXEmptyExpression: true, - JSXIdentifier: true, - JSXMemberExpression: true, - ParenthesizedExpression: true, - // AwaitExpression: false, - BindExpression: true, - // DoExpression: false, -}; -var visitors = { - Statement: function (node, state) { - if (!state.stop && !STATEMENT_WHITE_LIST[node.type]) { - state.stop = true; - } - }, - Expression: function (node, state) { - if (!state.stop && !EXPRESSION_WHITE_LIST[node.type]) { - state.stop = true; - } - }, - 'MemberExpression|JSXMemberExpression': function (node, state) { - if (state.stop) return; - if (node.computed) return state.stop = true; - else if (node.property.name[0] === '_') return state.stop = true; - }, - 'Identifier|JSXIdentifier': function (node, state, parents) { - if (state.stop) return; - var lastParent = parents[parents.length - 2]; - if (lastParent && !isReferenced(node, lastParent)) return; - if (!(state.constants && node.name in state.constants)) { - state.stop = true; - } - }, -}; -module.exports = isConstant; -function isConstant(src, constants, options) { - if (lastSRC === src && lastConstants === constants) return lastRes; - lastSRC = src; - lastConstants = constants; - var ast; - try { - ast = getExpression(src, options); - } catch (ex) { - return lastRes = false; - } - var state = { - constants: constants, - stop: false - }; - walk.ancestor(ast, visitors, state); - - return lastRes = !state.stop; -} -isConstant.isConstant = isConstant; - -isConstant.toConstant = toConstant; -function toConstant(src, constants, options) { - if (!isConstant(src, constants, options)) throw new Error(JSON.stringify(src) + ' is not constant.'); - return Function(Object.keys(constants || {}).join(','), 'return (' + src + ')').apply(null, Object.keys(constants || {}).map(function (key) { - return constants[key]; - })); -} - -function isReferenced(node, parent) { - switch (parent.type) { - // yes: { [NODE]: '' } - // yes: { NODE } - // no: { NODE: '' } - case 'ObjectProperty': - return parent.value === node || parent.computed; - - // no: break NODE; - // no: continue NODE; - case 'BreakStatement': - case 'ContinueStatement': - return false; - - // yes: left = NODE; - // yes: NODE = right; - case 'AssignmentExpression': - return true; - } - - return t.isReferenced(node, parent); -} diff --git a/package.json b/package.json index 143c921..4b633bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "name": "constantinople", "version": "3.1.0", + "main": "lib/index.js", + "types": "lib/index.d.ts", "description": "Determine whether a JavaScript expression evaluates to a constant (using acorn)", "keywords": [ "acorn", @@ -8,14 +10,20 @@ "tooling" ], "dependencies": { + "@types/babylon": "^6.16.2", "babel-types": "^6.16.0", - "babylon-walk": "^1.0.2", - "is-expression-babylon": "^1.1.0" + "babylon": "^6.18.0", + "babylon-walk": "^1.0.2" }, "devDependencies": { - "mocha": "*" + "@types/node": "^9.4.4", + "mocha": "*", + "typescript": "^2.7.1" }, "scripts": { + "prepublish": "npm run build", + "build": "tsc", + "pretest": "npm run build", "test": "mocha -R spec" }, "repository": { diff --git a/src/binaryOperation.ts b/src/binaryOperation.ts new file mode 100644 index 0000000..99244d5 --- /dev/null +++ b/src/binaryOperation.ts @@ -0,0 +1,76 @@ +export type Operator = + | '+' + | '-' + | '/' + | '%' + | '*' + | '**' + | '&' + | '|' + | '>>' + | '>>>' + | '<<' + | '^' + | '==' + | '===' + | '!=' + | '!==' + | 'in' + | 'instanceof' + | '>' + | '<' + | '>=' + | '<='; + +export default function binaryOperation( + operator: Operator, + left: any, + right: any, +): any { + switch (operator) { + case '+': + return left + right; + case '-': + return left - right; + case '/': + return left / right; + case '%': + return left % right; + case '*': + return left * right; + case '**': + return left ** right; + case '&': + return left & right; + case '|': + return left | right; + case '>>': + return left >> right; + case '>>>': + return left >>> right; + case '<<': + return left << right; + case '^': + return left ^ right; + case '==': + return left == right; + case '===': + return left === right; + case '!=': + return left != right; + case '!==': + return left !== right; + case 'in': + return left in right; + case 'instanceof': + return left instanceof right; + case '>': + return left > right; + case '<': + return left < right; + case '>=': + return left >= right; + case '<=': + return left <= right; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..31dd4d9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,357 @@ +import {parseExpression, BabylonOptions} from 'babylon'; +import * as b from 'babel-types'; +import binaryOperation from './binaryOperation'; + +export {BabylonOptions}; + +export interface ExpressionToConstantOptions { + constants?: any; +} + +export interface Options extends ExpressionToConstantOptions { + babylon?: BabylonOptions; +} +export function expressionToConstant( + expression: b.Expression, + options: ExpressionToConstantOptions = {}, +): {constant: true; result: any} | {constant: false; result?: void} { + let constant = true; + function toConstant(expression: b.Expression): any { + if (!constant) return; + if (b.isArrayExpression(expression)) { + const result = []; + for (let i = 0; constant && i < expression.elements.length; i++) { + const element = expression.elements[i]; + if (b.isSpreadElement(element)) { + const spread = toConstant(element.argument); + if (!(isSpreadable(spread) && constant)) { + constant = false; + } else { + result.push(...spread); + } + } else { + result.push(toConstant(element)); + } + } + return result; + } + if (b.isBinaryExpression(expression)) { + const left = toConstant(expression.left); + const right = toConstant(expression.right); + return constant && binaryOperation(expression.operator, left, right); + } + if (b.isBooleanLiteral(expression)) { + return expression.value; + } + if (b.isCallExpression(expression)) { + const args = []; + for (let i = 0; constant && i < expression.arguments.length; i++) { + const arg = expression.arguments[i]; + if (b.isSpreadElement(arg)) { + const spread = toConstant(arg.argument); + if (!(isSpreadable(spread) && constant)) { + constant = false; + } else { + args.push(...spread); + } + } else { + args.push(toConstant(arg)); + } + } + if (!constant) return; + if (b.isMemberExpression(expression.callee)) { + const object = toConstant(expression.callee.object); + if (!object || !constant) { + constant = false; + return; + } + const member = expression.callee.computed + ? toConstant(expression.callee.property) + : b.isIdentifier(expression.callee.property) + ? expression.callee.property.name + : undefined; + if (member === undefined && !expression.callee.computed) { + constant = false; + } + if (!constant) return; + if (canCallMethod(object, '' + member)) { + return object[member].apply(object, args); + } + } else { + const callee = toConstant(expression.callee); + if (!constant) return; + return callee.apply(null, args); + } + } + if (b.isConditionalExpression(expression)) { + const test = toConstant(expression.test); + return test + ? toConstant(expression.consequent) + : toConstant(expression.alternate); + } + if (b.isIdentifier(expression)) { + if ( + options.constants && + {}.hasOwnProperty.call(options.constants, expression.name) + ) { + return options.constants[expression.name]; + } + } + if (b.isLogicalExpression(expression)) { + const left = toConstant(expression.left); + const right = toConstant(expression.right); + if (constant && expression.operator === '&&') { + return left && right; + } + if (constant && expression.operator === '||') { + return left || right; + } + } + if (b.isMemberExpression(expression)) { + const object = toConstant(expression.object); + if (!object || !constant) { + constant = false; + return; + } + const member = expression.computed + ? toConstant(expression.property) + : b.isIdentifier(expression.property) + ? expression.property.name + : undefined; + if (member === undefined && !expression.computed) { + constant = false; + } + if (!constant) return; + if ({}.hasOwnProperty.call(object, '' + member) && member[0] !== '_') { + return object[member]; + } + } + if (b.isNullLiteral(expression)) { + return null; + } + if (b.isNumericLiteral(expression)) { + return expression.value; + } + if (b.isObjectExpression(expression)) { + const result: any = {}; + for (let i = 0; constant && i < expression.properties.length; i++) { + const property = expression.properties[i]; + if (b.isObjectProperty(property)) { + if (property.shorthand) { + constant = false; + return; + } + const key = property.computed + ? toConstant(property.key) + : b.isIdentifier(property.key) + ? property.key.name + : b.isStringLiteral(property.key) + ? property.key.value + : undefined; + if (!key || key[0] === '_') { + constant = false; + } + if (!constant) return; + const value = toConstant(property.value); + if (!constant) return; + result[key] = value; + } else if (b.isObjectMethod(property)) { + constant = false; + } else if (b.isSpreadProperty(property)) { + const argument = toConstant(property.argument); + if (!argument) constant = false; + if (!constant) return; + Object.assign(result, argument); + } + } + return result; + } + if (b.isParenthesizedExpression(expression)) { + return toConstant(expression.expression); + } + if (b.isRegExpLiteral(expression)) { + return new RegExp(expression.pattern, expression.flags); + } + if (b.isSequenceExpression(expression)) { + for (let i = 0; i < expression.expressions.length - 1 && constant; i++) { + toConstant(expression.expressions[i]); + } + return toConstant( + expression.expressions[expression.expressions.length - 1], + ); + } + if (b.isStringLiteral(expression)) { + return expression.value; + } + // TODO: TaggedTemplateExpression + if (b.isTemplateLiteral(expression)) { + let result = ''; + for (let i = 0; i < expression.quasis.length; i++) { + const quasi = expression.quasis[i]; + result += quasi.value.cooked; + if (i < expression.expressions.length) { + result += '' + toConstant(expression.expressions[i]); + } + } + return result; + } + if (b.isUnaryExpression(expression)) { + const argument = toConstant(expression.argument); + if (!constant) { + return; + } + switch (expression.operator) { + case '-': + return -argument; + case '+': + return +argument; + case '!': + return !argument; + case '~': + return ~argument; + case 'typeof': + return typeof argument; + case 'void': + return void argument; + } + } + constant = false; + } + const result = toConstant(expression); + return constant ? {constant: true, result} : {constant: false}; +} +function isSpreadable(value: any): boolean { + return ( + typeof value === 'string' || + Array.isArray(value) || + (typeof Set !== 'undefined' && value instanceof Set) || + (typeof Map !== 'undefined' && value instanceof Map) + ); +} +function shallowEqual(a: any, b: any) { + if (a === b) return true; + if (a && b && typeof a === 'object' && typeof b === 'object') { + for (let key in a) { + if (a[key] !== b[key]) { + return false; + } + } + for (let key in b) { + if (a[key] !== b[key]) { + return false; + } + } + return true; + } + return false; +} +function canCallMethod(object: any, member: string): boolean { + switch (typeof object) { + case 'boolean': + switch (member) { + case 'toString': + return true; + default: + return false; + } + case 'number': + switch (member) { + case 'toExponential': + case 'toFixed': + case 'toPrecision': + case 'toString': + return true; + default: + return false; + } + case 'string': + switch (member) { + case 'charAt': + case 'charCodeAt': + case 'codePointAt': + case 'concat': + case 'endsWith': + case 'includes': + case 'indexOf': + case 'lastIndexOf': + case 'match': + case 'normalize': + case 'padEnd': + case 'padStart': + case 'repeat': + case 'replace': + case 'search': + case 'slice': + case 'split': + case 'startsWith': + case 'substr': + case 'substring': + case 'toLowerCase': + case 'toUpperCase': + case 'trim': + return true; + default: + return false; + } + default: + if (object instanceof RegExp) { + switch (member) { + case 'test': + case 'exec': + return true; + default: + return false; + } + } + return {}.hasOwnProperty.call(object, member) && member[0] !== '_'; + } +} + +const EMPTY_OBJECT = {}; +let lastSrc = ''; +let lastConstants = EMPTY_OBJECT; +let lastOptions = EMPTY_OBJECT; +let lastResult: any = null; +let lastWasConstant = false; +export function isConstant( + src: string, + constants: any = EMPTY_OBJECT, + options: BabylonOptions = EMPTY_OBJECT, +) { + if ( + lastSrc === src && + shallowEqual(lastConstants, constants) && + shallowEqual(lastOptions, options) + ) { + return lastWasConstant; + } + lastSrc = src; + lastConstants = constants; + let ast: b.Expression | void; + try { + ast = parseExpression(src, options); + } catch (ex) { + return (lastWasConstant = false); + } + const {result, constant} = expressionToConstant(ast, {constants}); + lastResult = result; + return (lastWasConstant = constant); +} +export function toConstant( + src: string, + constants: any = EMPTY_OBJECT, + options: BabylonOptions = EMPTY_OBJECT, +) { + if (!isConstant(src, constants, options)) { + throw new Error(JSON.stringify(src) + ' is not constant.'); + } + return lastResult; +} + +export default isConstant; + +module.exports = isConstant; +module.exports.default = isConstant; +module.exports.expressionToConstant = expressionToConstant; +module.exports.isConstant = isConstant; +module.exports.toConstant = toConstant; diff --git a/test/index.js b/test/index.js index 6fd614b..4a5101b 100644 --- a/test/index.js +++ b/test/index.js @@ -1,71 +1,86 @@ -'use strict' +'use strict'; -var assert = require('assert') -var constaninople = require('../') +var assert = require('assert'); +var constaninople = require('../'); -describe('isConstant(src)', function () { - it('handles "[5 + 3 + 10]"', function () { - assert(constaninople.isConstant('[5 + 3 + 10]') === true) - }) - it('handles "/[a-z]/.test(\'a\')"', function () { - assert(constaninople.isConstant('/[a-z]/.test(\'a\')') === true) - }) - it('handles "{\'class\': [(\'data\')]}"', function () { - assert(constaninople.isConstant('{\'class\': [(\'data\')]}') === true) - }) - it('handles "Math.random()"', function () { - assert(constaninople.isConstant('Math.random()') === false) - }) - it('handles "Math.random("', function () { - assert(constaninople.isConstant('Math.random(') === false) - }) - it('handles "Math.floor(10.5)" with {Math: Math} as constants', function () { - assert(constaninople.isConstant('Math.floor(10.5)', {Math: Math}) === true) - }) - it('handles "this.myVar"', function () { - assert(constaninople.isConstant('this.myVar') === false) - }) - it('handles "(function () { while (true); return 10; }())"', function () { - assert(constaninople.isConstant('(function () { while (true); return 10; }())') === false) - }) -}) +describe('isConstant(src)', function() { + it('handles "[5 + 3 + 10]"', function() { + assert(constaninople.isConstant('[5 + 3 + 10]') === true); + }); + it('handles "/[a-z]/i.test(\'a\')"', function() { + assert(constaninople.isConstant("/[a-z]/i.test('a')") === true); + }); + it("handles \"{'class': [('data')]}\"", function() { + assert(constaninople.isConstant("{'class': [('data')]}") === true); + }); + it('handles "Math.random()"', function() { + assert(constaninople.isConstant('Math.random()') === false); + }); + it('handles "Math.random("', function() { + assert(constaninople.isConstant('Math.random(') === false); + }); + it('handles "Math.floor(10.5)" with {Math: Math} as constants', function() { + assert(constaninople.isConstant('Math.floor(10.5)', {Math: Math}) === true); + }); + it('handles "this.myVar"', function() { + assert(constaninople.isConstant('this.myVar') === false); + }); + it('handles "(function () { while (true); return 10; }())"', function() { + assert( + constaninople.isConstant( + '(function () { while (true); return 10; }())', + ) === false, + ); + }); + it('handles "({}).toString.constructor("console.log(1)")()"', function() { + assert( + constaninople.isConstant( + '({}).toString.constructor("console.log(1)")()', + ) === false, + ); + }); +}); - -describe('toConstant(src)', function () { - it('handles "[5 + 3 + 10]"', function () { - assert.deepEqual(constaninople.toConstant('[5 + 3 + 10]'), [5 + 3 + 10]) - }) - it('handles "/[a-z]/.test(\'a\')"', function () { - assert(constaninople.toConstant('/[a-z]/.test(\'a\')') === true) - }) - it('handles "{\'class\': [(\'data\')]}"', function () { - assert.deepEqual(constaninople.toConstant('{\'class\': [(\'data\')]}'), {'class': ['data']}) - }) - it('handles "Math.random()"', function () { +describe('toConstant(src)', function() { + it('handles "[5 + 3 + 10]"', function() { + assert.deepEqual(constaninople.toConstant('[5 + 3 + 10]'), [5 + 3 + 10]); + }); + it('handles "/[a-z]/i.test(\'a\')"', function() { + assert(constaninople.toConstant("/[a-z]/i.test('a')") === true); + }); + it("handles \"{'class': [('data')]}\"", function() { + assert.deepEqual(constaninople.toConstant("{'class': [('data')]}"), { + class: ['data'], + }); + }); + it('handles "Math.random()"', function() { try { - constaninople.toConstant('Math.random()') + constaninople.toConstant('Math.random()'); } catch (ex) { - return + return; } - assert(false, 'Math.random() should result in an error') - }) - it('handles "Math.random("', function () { + assert(false, 'Math.random() should result in an error'); + }); + it('handles "Math.random("', function() { try { - constaninople.toConstant('Math.random(') + constaninople.toConstant('Math.random('); } catch (ex) { - return + return; } - assert(false, 'Math.random( should result in an error') - }) - it('handles "Math.floor(10.5)" with {Math: Math} as constants', function () { - assert(constaninople.toConstant('Math.floor(10.5)', {Math: Math}) === 10) - }) - it('handles "(function () { while (true); return 10; }())"', function () { + assert(false, 'Math.random( should result in an error'); + }); + it('handles "Math.floor(10.5)" with {Math: Math} as constants', function() { + assert(constaninople.toConstant('Math.floor(10.5)', {Math: Math}) === 10); + }); + it('handles "(function () { while (true); return 10; }())"', function() { try { - constaninople.toConstant('(function () { while (true); return 10; }())') + constaninople.toConstant('(function () { while (true); return 10; }())'); } catch (ex) { - return + return; } - assert(false, '(function () { while (true); return 10; }()) should result in an error') - }) -}) + assert( + false, + '(function () { while (true); return 10; }()) should result in an error', + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5364a5c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "declaration": true, + "outDir": "lib", + "strict": true, + "lib": ["es2017"] + } +}