Source: runtime.js

// Licensed under the MIT License
// https://github.com/craigahobbs/bare-script/blob/main/LICENSE

/** @module lib/runtime */

import {ValueArgsError, valueBoolean, valueCompare, valueObjectSet, valueString} from './value.js';
import {expressionFunctions, intrinsics, scriptFunctions} from './library.js';
import {systemIncludes} from './includeSource.js';


// The default maximum statements for executeScript
export const defaultMaxStatements = 1e9;


// Coverage configuration object global variable name
export const systemGlobalCoverageName = '__barescriptCoverage';


// System includes object global variable name
export const systemGlobalIncludesName = '__barescriptIncludes';


// The AsyncFunction constructor, cached for fast async-function detection
export const AsyncFunction = (async () => { /* c8 ignore next */ }).constructor;


/**
 * Execute a BareScript model
 *
 * @param {Object} script - The [BareScript model](model/#var.vName='BareScript')
 * @param {Object} [options = {}] - The [script execution options]{@link module:lib/options~ExecuteScriptOptions}
 * @returns The script result
 * @throws [BareScriptRuntimeError]{@link module:lib/runtime.BareScriptRuntimeError}
 */
export function executeScript(script, options = {}) {
    // Create the global variable object, if necessary
    let {globals = null} = options;
    if (globals === null) {
        globals = {};
        options.globals = globals;
    }

    // Set the script function globals variables
    for (const scriptFuncName of Object.keys(scriptFunctions)) {
        if (!(scriptFuncName in globals)) {
            globals[scriptFuncName] = scriptFunctions[scriptFuncName];
        }
    }

    // Execute the script
    options.statementCount = 0;
    return executeScriptHelper(script, script.statements, options, null, computeLabelIndexes(script.statements));
}


// Compute a statements array's map of label name to statement index
export function computeLabelIndexes(statements) {
    const labelIndexes = Object.create(null);
    const statementsLength = statements.length;
    for (let ixStatement = 0; ixStatement < statementsLength; ixStatement++) {
        const statement = statements[ixStatement];
        if ('label' in statement) {
            labelIndexes[statement.label.name] = ixStatement;
        }
    }
    return labelIndexes;
}


function executeScriptHelper(script, statements, options, locals, labelIndexes) {
    const {globals} = options;
    const maxStatements = options.maxStatements ?? defaultMaxStatements;
    options.statementCount ??= 0;

    // Coverage configuration is invariant across this helper invocation
    const coverageGlobal = globals[systemGlobalCoverageName] ?? null;
    const hasCoverage = coverageGlobal !== null && typeof coverageGlobal === 'object' && coverageGlobal.enabled && !script.system;

    // Iterate each script statement
    const statementsLength = statements.length;
    for (let ixStatement = 0; ixStatement < statementsLength; ixStatement++) {
        const statement = statements[ixStatement];
        const [statementKey] = Object.keys(statement);

        // Increment the statement counter
        const statementCount = options.statementCount + 1;
        options.statementCount = statementCount;
        if (statementCount > maxStatements && maxStatements > 0) {
            throw new BareScriptRuntimeError(script, statement, `Exceeded maximum script statements (${maxStatements})`);
        }

        // Record the statement coverage
        if (hasCoverage) {
            recordStatementCoverage(script, statement, statementKey, coverageGlobal);
        }

        // Expression?
        if (statementKey === 'expr') {
            const stmtExpr = statement.expr;
            const exprValue = evaluateExpressionHelper(stmtExpr.expr, options, globals, locals, false, script, statement);
            if ('name' in stmtExpr) {
                if (locals !== null) {
                    locals[stmtExpr.name] = exprValue;
                } else {
                    valueObjectSet(globals, stmtExpr.name, exprValue);
                }
            }

        // Jump?
        } else if (statementKey === 'jump') {
            const stmtJump = statement.jump;
            // Evaluate the expression (if any)
            if (!('expr' in stmtJump) ||
                valueBoolean(evaluateExpressionHelper(stmtJump.expr, options, globals, locals, false, script, statement))) {
                // Jump to the label
                const jumpLabel = stmtJump.label;
                const ixLabel = labelIndexes[jumpLabel];
                if (ixLabel === undefined) {
                    throw new BareScriptRuntimeError(script, statement, `Unknown jump label "${jumpLabel}"`);
                }
                ixStatement = ixLabel;

                // Record the label statement coverage
                if (hasCoverage) {
                    const labelStatement = statements[ixStatement];
                    const [labelStatementKey] = Object.keys(labelStatement);
                    recordStatementCoverage(script, labelStatement, labelStatementKey, coverageGlobal);
                }
            }

        // Return?
        } else if (statementKey === 'return') {
            const stmtReturn = statement.return;
            if ('expr' in stmtReturn) {
                return evaluateExpressionHelper(stmtReturn.expr, options, globals, locals, false, script, statement);
            }
            return null;

        // Function?
        } else if (statementKey === 'function') {
            const stmtFunction = statement.function;
            const fnLabelIndexes = computeLabelIndexes(stmtFunction.statements);
            valueObjectSet(globals, stmtFunction.name,
                (args, fnOptions) => scriptFunction(script, stmtFunction, fnLabelIndexes, args, fnOptions));

        // Include?
        } else if (statementKey === 'include') {
            for (const {url, system = false} of statement.include.includes) {
                // Non-system includes cannot execute within a non-async scope
                if (!system) {
                    throw new BareScriptRuntimeError(script, statement, `Include of "${url}" within non-async scope`);
                }

                // Already included? System include keys are bracketed so they can't collide with local include URLs.
                const includeKey = `<${url}>`;
                let globalIncludes = globals[systemGlobalIncludesName] ?? null;
                if (globalIncludes === null || typeof globalIncludes !== 'object') {
                    globalIncludes = {};
                    globals[systemGlobalIncludesName] = globalIncludes;
                }
                if (globalIncludes[includeKey]) {
                    continue;
                }
                globalIncludes[includeKey] = true;

                // Get the system include script text
                const includeText = (Object.hasOwn(systemIncludes, url) ? systemIncludes[url] : null);
                if (includeText === null) {
                    throw new BareScriptRuntimeError(script, statement, `Include of "${url}" failed`);
                }

                // Parse the include script. A system include starting with "{" is the
                // parser-compiled JSON script model (all system includes are embedded pre-compiled).
                const includeScript = (includeText.charCodeAt(0) === 0x7B
                    ? JSON.parse(includeText) : barescriptParseScript(includeText, 1, url));
                includeScript.system = true;

                // Execute the include script
                executeScriptHelper(includeScript, includeScript.statements, options, null, computeLabelIndexes(includeScript.statements));

                // Run the bare-script linter?
                if ('logFn' in options && options.debug) {
                    const warnings = barescriptLintScript(includeScript, globals);
                    const warningPrefix = `BareScript: Include "${url}" static analysis...`;
                    if (warnings.length) {
                        options.logFn(`${warningPrefix} ${warnings.length} warning${warnings.length > 1 ? 's' : ''}:`);
                        for (const warning of warnings) {
                            options.logFn(`BareScript: ${warning}`);
                        }
                    }
                }
            }
        }
    }

    return null;
}


// The barescriptParser.bare include library script globals (lazily initialized)
let parserGlobals = null;


// Helper function to execute the barescriptParser.bare include library script, if necessary
function parserGlobalsInit() {
    if (parserGlobals === null) {
        parserGlobals = {};
        executeScript(
            {'statements': [{'include': {'includes': [{'url': 'barescriptParser.bare', 'system': true}]}}]},
            {'globals': parserGlobals}
        );
    }
}


/**
 * Parse a BareScript script
 *
 * @param {string|string[]} scriptText - The [script text](https://craigahobbs.github.io/bare-script/language/)
 * @param {number} [startLineNumber = 1] - The script's starting line number
 * @param {?string} [scriptName = null] - The script name
 * @returns {Object} The [BareScript model](https://craigahobbs.github.io/bare-script/model/#var.vName='BareScript')
 * @throws [BareScriptParserError]{@link module:lib/runtime.BareScriptParserError}
 */
export function barescriptParseScript(scriptText, startLineNumber = 1, scriptName = null) {
    parserGlobalsInit();
    const result = parserGlobals.barescriptParseScriptEx([scriptText, startLineNumber, scriptName], {'globals': parserGlobals});
    if ('error' in result) {
        const {error} = result;
        throw new BareScriptParserError(error.error, error.line, error.columnNumber, error.lineNumber, error.scriptName);
    }
    return result.result;
}


/**
 * Parse a BareScript expression
 *
 * @param {string} exprText - The [expression text](https://craigahobbs.github.io/bare-script/language/#expressions)
 * @param {?number} [lineNumber = null] - The script line number
 * @param {?string} [scriptName = null] - The script name
 * @param {boolean} [arrayLiterals = false] - If True, allow parsing of array literals
 * @returns {Object} The [expression model](https://craigahobbs.github.io/bare-script/model/#var.vName='Expression')
 * @throws [BareScriptParserError]{@link module:lib/runtime.BareScriptParserError}
 */
export function barescriptParseExpression(exprText, lineNumber = null, scriptName = null, arrayLiterals = false) {
    parserGlobalsInit();
    const result = parserGlobals.barescriptParseExpressionEx([exprText, lineNumber, scriptName, arrayLiterals], {'globals': parserGlobals});
    if ('error' in result) {
        const {error} = result;
        throw new BareScriptParserError(error.error, error.line, error.columnNumber, error.lineNumber, error.scriptName);
    }
    return result.result;
}


// The barescriptLint.bare include library script globals (lazily initialized)
let lintGlobals = null;


/**
 * Lint a BareScript model
 *
 * @param {Object} script - The [BareScript model](https://craigahobbs.github.io/bare-script/model/#var.vName='BareScript')
 * @param {?Object} [globals = null] - The script global variables
 * @returns {string[]} The array of lint warning strings
 */
export function barescriptLintScript(script, globals = null) {
    // Execute the barescriptLint.bare include library script, if necessary
    if (lintGlobals === null) {
        lintGlobals = {};
        executeScript(
            {'statements': [{'include': {'includes': [{'url': 'barescriptLint.bare', 'system': true}]}}]},
            {'globals': lintGlobals}
        );
    }

    // Compute the async global function names
    let asyncFunctions = null;
    if (globals !== null) {
        asyncFunctions = {};
        for (const [funcName, funcValue] of Object.entries(globals)) {
            if (typeof funcValue === 'function' && funcValue.constructor === AsyncFunction) {
                asyncFunctions[funcName] = true;
            }
        }
    }

    // Call the barescriptLint.bare lint function
    return lintGlobals.barescriptLintScript([script, globals, asyncFunctions], {'globals': lintGlobals});
}


// Helper function to record statement coverage
export function recordStatementCoverage(script, statement, statementKey, coverageGlobal) {
    // Get the script name and statement line number
    const scriptName = script.scriptName ?? null;
    const lineno = statement[statementKey].lineNumber ?? null;
    if (scriptName === null || lineno === null) {
        return;
    }

    // Record the statement/lineno coverage
    let scripts = coverageGlobal.scripts ?? null;
    if (scripts === null) {
        scripts = {};
        coverageGlobal.scripts = scripts;
    }
    let scriptCoverage = scripts[scriptName] ?? null;
    if (scriptCoverage === null) {
        scriptCoverage = {'script': script, 'covered': {}};
        scripts[scriptName] = scriptCoverage;
    }

    // Increment the statement coverage count
    const linenoStr = String(lineno);
    const coveredStatements = scriptCoverage.covered;
    let coveredStatement = coveredStatements[linenoStr] ?? null;
    if (coveredStatement === null) {
        coveredStatement = {'statement': statement, 'count': 0};
        coveredStatements[linenoStr] = coveredStatement;
    }
    coveredStatement.count += 1;
}


// Runtime script function implementation
export function scriptFunction(script, function_, labelIndexes, args, options) {
    // Null-prototype object so variable names like "__proto__" are own keys, matching the Python runtime
    const funcLocals = Object.create(null);
    const funcArgs = function_.args ?? null;
    if (funcArgs !== null) {
        const argsLength = args.length;
        const funcArgsLength = funcArgs.length;
        if (function_.lastArgArray) {
            const ixArgLast = funcArgsLength - 1;
            for (let ixArg = 0; ixArg < funcArgsLength; ixArg++) {
                const argName = funcArgs[ixArg];
                if (ixArg < argsLength) {
                    funcLocals[argName] = (ixArg === ixArgLast ? args.slice(ixArg) : args[ixArg]);
                } else {
                    funcLocals[argName] = (ixArg === ixArgLast ? [] : null);
                }
            }
        } else {
            for (let ixArg = 0; ixArg < funcArgsLength; ixArg++) {
                funcLocals[funcArgs[ixArg]] = (ixArg < argsLength ? args[ixArg] : null);
            }
        }
    }
    return executeScriptHelper(script, function_.statements, options, funcLocals, labelIndexes);
}


/**
 * Evaluate an expression model
 *
 * @param {Object} expr - The [expression model](./model/#var.vName='Expression')
 * @param {?Object} [options = null] - The [script execution options]{@link module:lib/options~ExecuteScriptOptions}
 * @param {?Object} [locals = null] - The local variables
 * @param {boolean} [builtins = true] - If true, include the [built-in expression functions](./library/expression.html)
 * @returns The expression result
 * @throws [BareScriptRuntimeError]{@link module:lib/runtime.BareScriptRuntimeError}
 */
export function evaluateExpression(expr, options = null, locals = null, builtins = true, script = null, statement = null) {
    const globals = (options !== null ? (options.globals ?? null) : null);
    return evaluateExpressionHelper(expr, options, globals, locals, builtins, script, statement);
}


// Expression evaluation helper - threads the globals object to avoid a per-call options lookup
function evaluateExpressionHelper(expr, options, globals, locals, builtins, script, statement) {
    const [exprKey] = Object.keys(expr);

    // Number
    if (exprKey === 'number') {
        return expr.number;
    }

    // String
    if (exprKey === 'string') {
        return expr.string;
    }

    // Variable
    if (exprKey === 'variable') {
        const {variable} = expr;

        // Keywords
        if (variable === 'null') {
            return null;
        } else if (variable === 'false') {
            return false;
        } else if (variable === 'true') {
            return true;
        }

        // Get the local or global variable value or null if undefined. Locals are a null-prototype
        // object; globals are checked for an own property so inherited names (e.g. "__proto__",
        // "constructor") do not resolve, matching the Python runtime.
        let varValue = (locals !== null ? locals[variable] : undefined);
        if (typeof varValue === 'undefined') {
            varValue = (globals !== null && Object.hasOwn(globals, variable) ? (globals[variable] ?? null) : null);
        }
        return varValue;
    }

    // Function
    if (exprKey === 'function') {
        const {function: func} = expr;

        // "if" built-in function?
        const funcName = func.name;
        if (funcName === 'if') {
            const [valueExpr = null, trueExpr = null, falseExpr = null] = func.args ?? [];
            const value =
                (valueExpr !== null ? evaluateExpressionHelper(valueExpr, options, globals, locals, builtins, script, statement) : false);
            const resultExpr = (valueBoolean(value) ? trueExpr : falseExpr);
            return resultExpr !== null ? evaluateExpressionHelper(resultExpr, options, globals, locals, builtins, script, statement) : null;
        }

        // Compute the function arguments
        const argExprs = func.args ?? null;
        let funcArgs = null;
        if (argExprs !== null) {
            const numArgs = argExprs.length;
            funcArgs = new Array(numArgs);
            for (let ixArg = 0; ixArg < numArgs; ixArg++) {
                funcArgs[ixArg] = evaluateExpressionHelper(argExprs[ixArg], options, globals, locals, builtins, script, statement);
            }
        }

        // Global/local function? Globals are checked for an own property so inherited names
        // (e.g. "constructor") do not resolve, matching the Python runtime.
        let funcValue = (locals !== null ? locals[funcName] : undefined);
        if (typeof funcValue === 'undefined') {
            funcValue = (globals !== null && Object.hasOwn(globals, funcName) ? globals[funcName] : undefined);
            if (typeof funcValue === 'undefined') {
                funcValue = (builtins && Object.hasOwn(expressionFunctions, funcName) ? expressionFunctions[funcName] : null);
            }
        }
        if (funcValue !== null) {
            // Async function called within non-async execution?
            if (typeof funcValue === 'function' && funcValue.constructor === AsyncFunction) {
                throw new BareScriptRuntimeError(script, statement, `Async function "${funcName}" called within non-async scope`);
            }

            // Call the function
            try {
                // Intrinsic fast path: run the body inline, skipping valueArgsValidate and the call.
                // Bad arguments throw ValueArgsError, handled by the catch below exactly as the normal
                // call would; a call reaching one of these under a different name (an alias) matches no
                // branch and falls through to the normal call.
                if (intrinsics.has(funcValue)) {
                    // arrayNew has no argument validation - handled before the length access below
                    // because funcArgs is null when the function expression has no arguments
                    if (funcName === 'arrayNew') {
                        return funcArgs;
                    }
                    const funcArgsLength = funcArgs.length;
                    if (funcName === 'arrayGet') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('array', null);
                        }
                        const [array] = funcArgs;
                        if (!Array.isArray(array)) {
                            throw new ValueArgsError('array', array);
                        }
                        if (funcArgsLength < 2) {
                            throw new ValueArgsError('index', null);
                        }
                        const [, index] = funcArgs;
                        if (typeof index !== 'number' || Math.floor(index) !== index || index < 0) {
                            throw new ValueArgsError('index', index);
                        }
                        if (funcArgsLength > 2) {
                            throw new ValueArgsError(null, funcArgsLength);
                        }
                        if (index >= array.length) {
                            throw new ValueArgsError('index', index);
                        }
                        return array[index];
                    }
                    if (funcName === 'arrayLength') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('array', null, 0);
                        }
                        const [array] = funcArgs;
                        if (!Array.isArray(array)) {
                            throw new ValueArgsError('array', array, 0);
                        }
                        if (funcArgsLength > 1) {
                            throw new ValueArgsError(null, funcArgsLength, 0);
                        }
                        return array.length;
                    }
                    if (funcName === 'arrayPush') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('array', null);
                        }
                        const [array] = funcArgs;
                        if (!Array.isArray(array)) {
                            throw new ValueArgsError('array', array);
                        }
                        array.push(...funcArgs.slice(1));
                        return array;
                    }
                    if (funcName === 'arraySet') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('array', null);
                        }
                        const [array] = funcArgs;
                        if (!Array.isArray(array)) {
                            throw new ValueArgsError('array', array);
                        }
                        if (funcArgsLength < 2) {
                            throw new ValueArgsError('index', null);
                        }
                        const [, index] = funcArgs;
                        if (typeof index !== 'number' || Math.floor(index) !== index || index < 0) {
                            throw new ValueArgsError('index', index);
                        }
                        if (funcArgsLength > 3) {
                            throw new ValueArgsError(null, funcArgsLength);
                        }
                        if (index >= array.length) {
                            throw new ValueArgsError('index', index);
                        }
                        const value = (funcArgsLength >= 3 ? funcArgs[2] : null);
                        array[index] = value;
                        return value;
                    }
                    if (funcName === 'mathSqrt') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('x', null);
                        }
                        const [xValue] = funcArgs;
                        if (typeof xValue !== 'number' || !(xValue >= 0)) {
                            throw new ValueArgsError('x', xValue);
                        }
                        if (funcArgsLength > 1) {
                            throw new ValueArgsError(null, funcArgsLength);
                        }
                        return Math.sqrt(xValue);
                    }
                    if (funcName === 'objectGet') {
                        const defaultValue = (funcArgsLength >= 3 ? funcArgs[2] : null);
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('object', null, defaultValue);
                        }
                        const [object] = funcArgs;
                        if (typeof object !== 'object' || object === null || Object.getPrototypeOf(object) !== Object.prototype) {
                            throw new ValueArgsError('object', object, defaultValue);
                        }
                        if (funcArgsLength < 2) {
                            throw new ValueArgsError('key', null, defaultValue);
                        }
                        const [, key] = funcArgs;
                        if (typeof key !== 'string') {
                            throw new ValueArgsError('key', key, defaultValue);
                        }
                        if (funcArgsLength > 3) {
                            throw new ValueArgsError(null, funcArgsLength, defaultValue);
                        }
                        return (Object.hasOwn(object, key) ? object[key] : defaultValue);
                    }
                    if (funcName === 'objectHas') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('object', null, false);
                        }
                        const [object] = funcArgs;
                        if (typeof object !== 'object' || object === null || Object.getPrototypeOf(object) !== Object.prototype) {
                            throw new ValueArgsError('object', object, false);
                        }
                        if (funcArgsLength < 2) {
                            throw new ValueArgsError('key', null, false);
                        }
                        const [, key] = funcArgs;
                        if (typeof key !== 'string') {
                            throw new ValueArgsError('key', key, false);
                        }
                        if (funcArgsLength > 2) {
                            throw new ValueArgsError(null, funcArgsLength, false);
                        }
                        return Object.hasOwn(object, key);
                    }
                    if (funcName === 'objectKeys') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('object', null);
                        }
                        const [object] = funcArgs;
                        if (typeof object !== 'object' || object === null || Object.getPrototypeOf(object) !== Object.prototype) {
                            throw new ValueArgsError('object', object);
                        }
                        if (funcArgsLength > 1) {
                            throw new ValueArgsError(null, funcArgsLength);
                        }
                        return Object.keys(object);
                    }
                    if (funcName === 'objectSet') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('object', null);
                        }
                        const [object] = funcArgs;
                        if (typeof object !== 'object' || object === null || Object.getPrototypeOf(object) !== Object.prototype) {
                            throw new ValueArgsError('object', object);
                        }
                        if (funcArgsLength < 2) {
                            throw new ValueArgsError('key', null);
                        }
                        const [, key] = funcArgs;
                        if (typeof key !== 'string') {
                            throw new ValueArgsError('key', key);
                        }
                        if (funcArgsLength > 3) {
                            throw new ValueArgsError(null, funcArgsLength);
                        }
                        const value = (funcArgsLength >= 3 ? funcArgs[2] : null);
                        valueObjectSet(object, key, value);
                        return value;
                    }
                    if (funcName === 'stringLength') {
                        if (funcArgsLength < 1) {
                            throw new ValueArgsError('string', null, 0);
                        }
                        const [string] = funcArgs;
                        if (typeof string !== 'string') {
                            throw new ValueArgsError('string', string, 0);
                        }
                        if (funcArgsLength > 1) {
                            throw new ValueArgsError(null, funcArgsLength, 0);
                        }
                        return string.length;
                    }
                }
                return funcValue(funcArgs, options) ?? null;
            } catch (error) {
                // Propogate runtime errors
                if (error instanceof BareScriptRuntimeError) {
                    throw error;
                }

                // Log and return null
                if (options !== null && 'logFn' in options && options.debug) {
                    const errorMessage = new BareScriptRuntimeError(
                        script, statement, `BareScript: Function "${funcName}" failed with error: ${error.message}`
                    );
                    options.logFn(errorMessage.message);
                }
                if (error instanceof ValueArgsError) {
                    return error.returnValue;
                }
                return null;
            }
        }

        throw new BareScriptRuntimeError(script, statement, `Undefined function "${funcName}"`);
    }

    // Binary expression
    if (exprKey === 'binary') {
        const {binary} = expr;
        const binOp = binary.op;
        const leftValue = evaluateExpressionHelper(binary.left, options, globals, locals, builtins, script, statement);

        // Short-circuiting "and" binary operator
        if (binOp === '&&') {
            if (!valueBoolean(leftValue)) {
                return leftValue;
            }
            return evaluateExpressionHelper(binary.right, options, globals, locals, builtins, script, statement);

        // Short-circuiting "or" binary operator
        } else if (binOp === '||') {
            if (valueBoolean(leftValue)) {
                return leftValue;
            }
            return evaluateExpressionHelper(binary.right, options, globals, locals, builtins, script, statement);
        }

        // Non-short-circuiting binary operators
        const rightValue = evaluateExpressionHelper(binary.right, options, globals, locals, builtins, script, statement);
        if (binOp === '+') {
            // number + number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue + rightValue;
                return (isFinite(result) ? result : null);

            // string + string
            } else if (typeof leftValue === 'string' && typeof rightValue === 'string') {
                return leftValue + rightValue;

            // string + <any>
            } else if (typeof leftValue === 'string') {
                return leftValue + valueString(rightValue);
            } else if (typeof rightValue === 'string') {
                return valueString(leftValue) + rightValue;

            // datetime + number
            } else if (leftValue instanceof Date && typeof rightValue === 'number') {
                const result = new Date(leftValue.getTime() + rightValue);
                return (isNaN(result.getTime()) ? null : result);
            } else if (typeof leftValue === 'number' && rightValue instanceof Date) {
                const result = new Date(leftValue + rightValue.getTime());
                return (isNaN(result.getTime()) ? null : result);
            }
        } else if (binOp === '-') {
            // number - number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue - rightValue;
                return (isFinite(result) ? result : null);

            // datetime - datetime
            } else if (leftValue instanceof Date && rightValue instanceof Date) {
                return leftValue - rightValue;
            }
        } else if (binOp === '*') {
            // number * number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue * rightValue;
                return (isFinite(result) ? result : null);
            }
        } else if (binOp === '/') {
            // number / number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue / rightValue;
                return (isFinite(result) ? result : null);
            }
        } else if (binOp === '<') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue < rightValue;
            }
            return valueCompare(leftValue, rightValue) < 0;
        } else if (binOp === '<=') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue <= rightValue;
            }
            return valueCompare(leftValue, rightValue) <= 0;
        } else if (binOp === '>') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue > rightValue;
            }
            return valueCompare(leftValue, rightValue) > 0;
        } else if (binOp === '>=') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue >= rightValue;
            }
            return valueCompare(leftValue, rightValue) >= 0;
        } else if (binOp === '==') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue === rightValue;
            }
            return valueCompare(leftValue, rightValue) === 0;
        } else if (binOp === '!=') {
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                return leftValue !== rightValue;
            }
            return valueCompare(leftValue, rightValue) !== 0;
        } else if (binOp === '%') {
            // number % number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue % rightValue;
                return (isFinite(result) ? result : null);
            }
        } else if (binOp === '**') {
            // number ** number
            if (typeof leftValue === 'number' && typeof rightValue === 'number') {
                const result = leftValue ** rightValue;
                return (isFinite(result) ? result : null);
            }
        } else if (binOp === '&') {
            if (Number.isInteger(leftValue) && Number.isInteger(rightValue)) {
                return leftValue & rightValue;
            }
        } else if (binOp === '|') {
            if (Number.isInteger(leftValue) && Number.isInteger(rightValue)) {
                return leftValue | rightValue;
            }
        } else if (binOp === '^') {
            if (Number.isInteger(leftValue) && Number.isInteger(rightValue)) {
                return leftValue ^ rightValue;
            }
        } else if (binOp === '<<') {
            if (Number.isInteger(leftValue) && Number.isInteger(rightValue)) {
                return leftValue << rightValue;
            }
        } else {
            // if (binOp === '>>')
            if (Number.isInteger(leftValue) && Number.isInteger(rightValue)) {
                return leftValue >> rightValue;
            }
        }

        // Invalid operation values
        return null;
    }

    // Unary expression
    if (exprKey === 'unary') {
        const {unary} = expr;
        const unaryOp = unary.op;
        const value = evaluateExpressionHelper(unary.expr, options, globals, locals, builtins, script, statement);
        if (unaryOp === '!') {
            return !valueBoolean(value);
        } else if (unaryOp === '-') {
            if (typeof value === 'number') {
                return -value;
            }
        } else {
            // if (unaryOp === '~'
            if (Number.isInteger(value)) {
                return ~value;
            }
        }

        // Invalid operation value
        return null;
    }

    // Expression group
    // else if (exprKey === 'group')
    return evaluateExpressionHelper(expr.group, options, globals, locals, builtins, script, statement);
}


/**
 * A BareScript runtime error
 *
 * @extends {Error}
 */
export class BareScriptRuntimeError extends Error {
    /**
     * Create a BareScript runtime error
     *
     * @param {string} message - The runtime error message
     */
    constructor(script, statement, message) {
        let messageScript;
        if (script !== null && statement !== null) {
            const [statementKey] = Object.keys(statement);
            const scriptName = script.scriptName ?? '';
            const lineno = statement[statementKey].lineNumber ?? '';
            messageScript = (scriptName && lineno ? `${scriptName}:${lineno}: ${message}` : message);
        } else {
            messageScript = message;
        }
        super(messageScript);
        this.name = this.constructor.name;
    }
}


/**
 * A BareScript parser error
 *
 * @extends {Error}
 * @property {string} error - The error description
 * @property {string} line - The line text
 * @property {number} columnNumber - The error column number
 * @property {?number} lineNumber - The error line number
 * @property {?string} scriptName - The script name
 */
export class BareScriptParserError extends Error {
    /**
     * Create a BareScript parser error
     *
     * @param {string} error - The error description
     * @param {string} line - The line text
     * @param {number} [columnNumber] - The error column number
     * @param {?number} [lineNumber] - The error line number
     * @param {?string} [scriptName] - The script name
     */
    constructor(error, line, columnNumber, lineNumber, scriptName) {
        // Parser error constants
        const lineLengthMax = 120;
        const lineSuffix = ' ...';
        const linePrefix = '... ';

        // Trim the error line, if necessary
        let lineError = line;
        let lineColumn = columnNumber;
        if (line.length > lineLengthMax) {
            const lineLeft = columnNumber - 1 - lineLengthMax / 2;
            const lineRight = lineLeft + lineLengthMax;
            if (lineLeft < 0) {
                lineError = line.slice(0, lineLengthMax) + lineSuffix;
            } else if (lineRight > line.length) {
                lineError = linePrefix + line.slice(line.length - lineLengthMax);
                lineColumn -= lineLeft - linePrefix.length - (lineRight - line.length);
            } else {
                lineError = linePrefix + line.slice(lineLeft, lineRight) + lineSuffix;
                lineColumn -= lineLeft - linePrefix.length;
            }
        }

        // Format the message
        const errorPrefix = (lineNumber ? `${scriptName || ''}:${lineNumber}: ` : '');
        const message = `\
${errorPrefix}${error}
${lineError}
${' '.repeat(lineColumn - 1)}^
`;
        super(message);
        this.name = this.constructor.name;
        this.error = error;
        this.line = line;
        this.columnNumber = columnNumber;
        this.lineNumber = lineNumber;
        this.scriptName = scriptName;
    }
}