Home Reference Source

src/compiler.js

/**
 * @desc Compiler for most ceallog function api calls.
 * @since 0.1.0
 */
'use strict';

// Requirements
/**
 * @ignore
 */
const CompilerError = require('./classes/CompilerError'),
	esprima = require('esprima'),
	walk = require('esprima-walk');

// Constants
/**
 * @desc An object acting like a map where key represents the type of message
 * and the value is a string with the message itself.
 * @since 0.1.0
 */
const messages = {
	ILLEGAL_DIRNAME: 'Use of `__dirname` is illegal.',
	ILLEGAL_FILENAME: 'Use of `__filename` is illegal.',
	ILLEGAL_CLEARIMMEDIATE: 'Use of `clearImmediate()` is illegal.',
	ILLEGAL_CLEARINTERVAL: 'Use of `clearInterval()` is illegal.',
	ILLEGAL_CLEARTIMEOUT: 'Use of `clearTimeout()` is illegal.',
	ILLEGAL_CONSOLE: 'Use of `console` is illegal.',
	ILLEGAL_EXPORTS: 'Use of `exports` is illegal.',
	ILLEGAL_EVAL: 'Use of `eval()` is illegal.',
	ILLEGAL_GLOBAL: 'Use of `global` is illegal.',
	ILLEGAL_MODULE: 'Use of `module` is illegal.',
	ILLEGAL_PROCESS: 'Use of `process` is illegal.',
	ILLEGAL_REQUIRE: 'Use of `require()` is illegal.',
	ILLEGAL_SETIMMEDIATE: 'Use of `setImmediate()` is illegal.',
	ILLEGAL_SETINTERVAL: 'Use of `setInterval()` is illegal.',
	ILLEGAL_SETTIMEOUT: 'Use of `setTimeout()` is illegal.',
	ILLEGAL_THIS: 'Use of `this` keyword is illegal.',
	MULTIPLE_TOP_LEVEL_STATEMENTS:
		'Only one top-level statement permitted, and it must be an arrow function expression (eg. "() => { return \'Hello world\'; }").',
	NOT_A_FUNCTION: 'Statement is not a function.',
	NOT_AN_ARROW_FUNCTION:
		'Top level statement must be an arrow function (eg. "() => { return \'Hello world\'; }").',
	PARSER_FAILURE: 'Failed to parse statement.'
};

// Functions
/**
 * @param {string} code Code to compile.
 * @return {function} A closure function that takes a `Ceallog` instance as a
 * param. When this function is executed, it applies a `null` scope to the
 * compiled function and passes the `Ceallog` instance as its sole argument.
 * @since 0.1.0
 */
const compile = code => {
	let compiled = require('./safe-eval')(code);

	/* istanbul ignore if */
	if (typeof compiled != 'function') { // Unreachable...
		throw new Error(messages.NOT_A_FUNCTION);
	}

	let closure = function(ceallog) {
		return compiled.apply(null, [ceallog]);
	};

	return closure;
};

/**
 * @desc An object containg compiler fields and functions to be exported.
 * @since 0.1.0
 */
const compiler = {
	/**
	 * @see #messages
	 */
	_messages: messages,
	/**
	 * @desc Attempts to compile from string contained in `req.code`.  Adds
	 * results to `req.compiler.err` and `req.compiler.compiled` respectively.
	 * @param {Object} req Express request object
	 * @param {Object} res Express response object
	 * @param {function} next Function to be called by Express next.
	 * @since 0.1.0
	 */
	compileRequest: (req, res, next) => {
		req.compiler = {
			compiled: null,
			error: null
		};
		if (req.code) {
			compiler.compileString(req.code, (err, compiled) => {
				if (err) {
					req.compiler.error = err;
				} else {
					req.compiler.compiled = compiled;
				}
			});
		}
		next();
	},
	/**
	 * @param {string} code String containing code to be compiled
	 * @param {function} callback Function to which to pass the compilation
	 * results.  Function expected to include params `err` and `compiled`.
	 * @since 0.1.0
	 */
	compileString: (code, callback) => {
		let compiled, err;

		try {
			let ast = parse(code);
			validate(ast);
			compiled = compile(code);
		} catch (e) {
			err =
				e instanceof CompilerError ? e : new CompilerError(e.message, null, e);
		} finally {
			callback(err, compiled);
		}
	},
	compileStringSync: code => {
		/**
		 * @param {string} code String containing code to be compiled
		 * @since 0.2.0
		 */
		let ast = parse(code);
		validate(ast);
		return compile(code);
	}
};

/**
 * @param {string} code String containing code to be parsed.
 * @return {Object} AST from `esprima` module.
 * @since 0.1.0
 */
const parse = code => {
	let ast = esprima.parse(code, {loc: true});
	return ast;
};

/**
 * @desc A shorthand function to throw a `CompilerError`
 * @param {string} message Message to include in `CompileError`.
 * @param {Object} node (Optional) node object from esprima where the failure
 * occurred.
 * @throws {CompilerError} Error with line location info if applicable.
 * @since 0.1.0
 */
const throwError = (message, node) => {
	let err = new CompilerError(message, node);
	throw err;
};

/**
 * @desc A void function that validates a ceallog function syntax tree based on
 * a series of rules, and throwing an error if not.
 * @param {Object} ast ceallog function abstract syntax tree to be validated.
 * @since 0.1.0
 */
const validate = ast => {
	if (ast) {
		if (ast.body.length > 1) {
			throw new Error(messages.MULTIPLE_TOP_LEVEL_STATEMENTS);
		} else if (
			0 in ast.body &&
			(!ast.body[0].expression ||
				ast.body[0].expression.type != 'ArrowFunctionExpression')
		) {
			throw new Error(messages.NOT_AN_ARROW_FUNCTION);
		}

		walk(ast, node => {
			let isIdentifier = node.type == 'Identifier';

			// Handle illegal identifiers
			if (isIdentifier) {
				switch (node.name) {
					case '__dirname':
						throwError(messages.ILLEGAL_DIRNAME, node);
					case '__filename':
						throwError(messages.ILLEGAL_FILENAME, node);
					case 'clearImmediate':
						throwError(messages.ILLEGAL_CLEARIMMEDIATE, node);
					case 'clearInterval':
						throwError(messages.ILLEGAL_CLEARINTERVAL, node);
					case 'clearTimeout':
						throwError(messages.ILLEGAL_CLEARTIMEOUT, node);
					case 'console':
						throwError(messages.ILLEGAL_CONSOLE, node);
					case 'exports':
						throwError(messages.ILLEGAL_EXPORTS, node);
					case 'global':
						throwError(messages.ILLEGAL_GLOBAL, node);
					case 'eval':
						throwError(messages.ILLEGAL_EVAL, node);
					case 'module':
						throwError(messages.ILLEGAL_MODULE, node);
					case 'process':
						throwError(messages.ILLEGAL_PROCESS, node);
					case 'require':
						throwError(messages.ILLEGAL_REQUIRE, node);
					case 'setImmediate':
						throwError(messages.ILLEGAL_SETIMMEDIATE, node);
					case 'setInterval':
						throwError(messages.ILLEGAL_SETINTERVAL, node);
					case 'setTimeout':
						throwError(messages.ILLEGAL_SETTIMEOUT, node);
				}

				if (node.name in process) {
					throwError(`Use of \`process.${node.name}\` is illegal.`, node);
				} else if (node.name in global) {
					throwError(`Use of identifier \`${node.name}\` is illegal.`, node);
				}
			} else if (node.type == 'ThisExpression') {
				throwError(messages.ILLEGAL_THIS, node);
			}
		});
	}
};

module.exports = compiler;