Source: parser.js

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

/** @module lib/parser */


/**
 * Parse a BareScript script
 *
 * @param {string|string[]} scriptText - The [script text](./language/)
 * @param {number} [startLineNumber = 1] - The script's starting line number
 * @param {?string} [scriptName = null] - The script name
 * @returns {Object} The [BareScript model](./model/#var.vName='BareScript')
 * @throws [BareScriptParserError]{@link module:lib/parser.BareScriptParserError}
 */
export function parseScript(scriptText, startLineNumber = 1, scriptName = null) {
    const lines = [];
    const script = {'statements': [], 'scriptLines': lines};
    if (scriptName !== null) {
        script.scriptName = scriptName;
    }

    // Line-split all script text
    if (typeof scriptText === 'string') {
        lines.push(...scriptText.split(rScriptLineSplit));
    } else {
        for (const scriptTextPart of scriptText) {
            lines.push(...scriptTextPart.split(rScriptLineSplit));
        }
    }

    // Process each line
    const lineContinuation = [];
    let functionDef = null;
    let functionLabelDefDepth = null;
    const labelDefs = [];
    let labelIndex = 0;
    let ixLine;
    for (const [ixLinePart, linePart] of lines.entries()) {
        const statements = (functionDef !== null ? functionDef.function.statements : script.statements);

        // Comment or empty line?
        const linePartTrimmed = linePart.trimStart();
        if (linePartTrimmed === '' || linePartTrimmed.charCodeAt(0) === 0x23) {
            continue;
        }

        // Set the line index
        const isContinued = (lineContinuation.length !== 0);
        if (!isContinued) {
            ixLine = ixLinePart;
        }

        // Line continuation?
        const linePartNoContinuation = linePart.replace(rScriptContinuation, '');
        if (linePart !== linePartNoContinuation) {
            lineContinuation.push(isContinued ? linePartNoContinuation.trim() : linePartNoContinuation.trimEnd());
            continue;
        } else if (isContinued) {
            lineContinuation.push(linePartNoContinuation.trim());
        }

        // Join the continued script lines, if necessary
        let line;
        if (isContinued) {
            line = lineContinuation.join(' ');
            lineContinuation.length = 0;
        } else {
            line = linePart;
        }

        // Base statement members
        const lineNumber = ixLine + 1;
        const statementBase = {lineNumber};
        if (ixLine !== ixLinePart) {
            statementBase.lineCount = (ixLinePart - ixLine) + 1;
        }

        // Determine the line's leading keyword for dispatch
        const matchKeyword = line.match(rScriptKeyword);
        const keyword = matchKeyword !== null ? matchKeyword[1] : null;

        // Function definition begin?
        const matchFunctionBegin = (keyword === 'function' || keyword === 'async') ? line.match(rScriptFunctionBegin) : null;
        if (matchFunctionBegin !== null) {
            // Nested function definitions are not allowed
            if (functionDef !== null) {
                throw new BareScriptParserError('Nested function definition', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Add the function definition statement
            functionLabelDefDepth = labelDefs.length;
            functionDef = {
                'function': {
                    'name': matchFunctionBegin.groups.name,
                    'statements': [],
                    ...statementBase
                }
            };
            if (matchFunctionBegin.groups.args !== undefined) {
                functionDef.function.args = matchFunctionBegin.groups.args.split(rScriptFunctionArgSplit);
            }
            if (matchFunctionBegin.groups.async !== undefined) {
                functionDef.function.async = true;
            }
            if (matchFunctionBegin.groups.lastArgArray !== undefined) {
                functionDef.function.lastArgArray = true;
            }
            statements.push(functionDef);
            continue;
        }

        // Function definition end?
        const matchFunctionEnd = (keyword === 'endfunction') ? line.match(rScriptFunctionEnd) : null;
        if (matchFunctionEnd !== null) {
            if (functionDef === null) {
                throw new BareScriptParserError('No matching function definition', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Check for un-matched label definitions
            if (labelDefs.length > functionLabelDefDepth) {
                const labelDef = labelDefs.pop();
                const [defKey] = Object.keys(labelDef);
                const def = labelDef[defKey];
                throw new BareScriptParserError(`Missing end${defKey} statement`, def.line, 1, def.lineNumber, scriptName);
            }

            functionDef = null;
            functionLabelDefDepth = null;
            continue;
        }

        // If-then begin?
        const matchIfBegin = (keyword === 'if') ? line.match(rScriptIfBegin) : null;
        if (matchIfBegin !== null) {
            // Parse the if-then expression
            let ifthenExpr;
            try {
                ifthenExpr = parseExpression(matchIfBegin.groups.expr, lineNumber, scriptName, true);
            } catch (error) {
                const columnNumber = matchIfBegin.groups.if.length + error.columnNumber;
                throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
            }

            // Add the if-then label definition
            const ifthen = {
                'jump': {
                    'label': `__barescriptIf${labelIndex}`,
                    'expr': {'unary': {'op': '!', 'expr': ifthenExpr}},
                    ...statementBase
                },
                'done': `__barescriptDone${labelIndex}`,
                'hasElse': false,
                line,
                'lineNumber': startLineNumber + ixLine
            };
            labelDefs.push({'if': ifthen});
            labelIndex += 1;

            // Add the if-then header statement
            statements.push({'jump': ifthen.jump});
            continue;
        }

        // Else-if-then?
        const matchIfElseIf = (keyword === 'elif') ? line.match(rScriptIfElseIf) : null;
        if (matchIfElseIf !== null) {
            // Get the else-if-then definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const ifthen = (labelDefs.length > labelDefDepth ? (labelDefs[labelDefs.length - 1].if ?? null) : null);
            if (ifthen === null) {
                throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Cannot come after the else-then statement
            if (ifthen.hasElse) {
                throw new BareScriptParserError(
                    'Elif statement following else statement', line, 1, startLineNumber + ixLine, scriptName
                );
            }

            // Parse the else-if-then expression
            let ifElseIfExpr;
            try {
                ifElseIfExpr = parseExpression(matchIfElseIf.groups.expr, lineNumber, scriptName, true);
            } catch (error) {
                const columnNumber = matchIfElseIf.groups.elif.length + error.columnNumber;
                throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
            }

            // Generate the next if-then jump statement
            const prevLabel = ifthen.jump.label;
            ifthen.jump = {
                'label': `__barescriptIf${labelIndex}`,
                'expr': {'unary': {'op': '!', 'expr': ifElseIfExpr}},
                ...statementBase
            };
            labelIndex += 1;

            // Add the if-then else statements
            statements.push(
                {'jump': {'label': ifthen.done, ...statementBase}},
                {'label': {'name': prevLabel, ...statementBase}},
                {'jump': ifthen.jump}
            );
            continue;
        }

        // Else-then?
        const matchIfElse = (keyword === 'else') ? line.match(rScriptIfElse) : null;
        if (matchIfElse !== null) {
            // Get the if-then definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const ifthen = (labelDefs.length > labelDefDepth ? (labelDefs[labelDefs.length - 1].if ?? null) : null);
            if (ifthen === null) {
                throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Cannot have multiple else-then statements
            if (ifthen.hasElse) {
                throw new BareScriptParserError('Multiple else statements', line, 1, startLineNumber + ixLine, scriptName);
            }
            ifthen.hasElse = true;

            // Add the if-then else statements
            statements.push(
                {'jump': {'label': ifthen.done, ...statementBase}},
                {'label': {'name': ifthen.jump.label, ...statementBase}}
            );
            continue;
        }

        // If-then end?
        const matchIfEnd = (keyword === 'endif') ? line.match(rScriptIfEnd) : null;
        if (matchIfEnd !== null) {
            // Pop the if-then definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const ifthen = (labelDefs.length > labelDefDepth ? (labelDefs.pop().if ?? null) : null);
            if (ifthen === null) {
                throw new BareScriptParserError('No matching if statement', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Update the previous jump statement's label, if necessary
            if (!ifthen.hasElse) {
                ifthen.jump.label = ifthen.done;
            }

            // Add the if-then footer statement
            statements.push({'label': {'name': ifthen.done, ...statementBase}});
            continue;
        }

        // While-do begin?
        const matchWhileBegin = (keyword === 'while') ? line.match(rScriptWhileBegin) : null;
        if (matchWhileBegin !== null) {
            // Parse the while-do expression
            let whileBeginExpr;
            try {
                whileBeginExpr = parseExpression(matchWhileBegin.groups.expr, lineNumber, scriptName, true);
            } catch (error) {
                const columnNumber = matchWhileBegin.groups.while.length + error.columnNumber;
                throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
            }

            // Add the while-do label
            const whiledo = {
                'loop': `__barescriptLoop${labelIndex}`,
                'continue': `__barescriptLoop${labelIndex}`,
                'done': `__barescriptDone${labelIndex}`,
                'expr': whileBeginExpr,
                line,
                'lineNumber': startLineNumber + ixLine
            };
            labelDefs.push({'while': whiledo});
            labelIndex += 1;

            // Add the while-do header statements
            statements.push(
                {'jump': {'label': whiledo.done, 'expr': {'unary': {'op': '!', 'expr': whiledo.expr}}, ...statementBase}},
                {'label': {'name': whiledo.loop, ...statementBase}}
            );
            continue;
        }

        // While-do end?
        const matchWhileEnd = (keyword === 'endwhile') ? line.match(rScriptWhileEnd) : null;
        if (matchWhileEnd !== null) {
            // Pop the while-do definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const whiledo = (labelDefs.length > labelDefDepth ? (labelDefs.pop().while ?? null) : null);
            if (whiledo === null) {
                throw new BareScriptParserError('No matching while statement', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Add the while-do footer statements
            statements.push(
                {'jump': {'label': whiledo.loop, 'expr': whiledo.expr, ...statementBase}},
                {'label': {'name': whiledo.done, ...statementBase}}
            );
            continue;
        }

        // For-each begin?
        const matchForBegin = (keyword === 'for') ? line.match(rScriptForBegin) : null;
        if (matchForBegin !== null) {
            // Add the for-each label
            const foreach = {
                'loop': `__barescriptLoop${labelIndex}`,
                'continue': `__barescriptContinue${labelIndex}`,
                'done': `__barescriptDone${labelIndex}`,
                'index': matchForBegin.groups.index ?? `__barescriptIndex${labelIndex}`,
                'values': `__barescriptValues${labelIndex}`,
                'length': `__barescriptLength${labelIndex}`,
                'value': matchForBegin.groups.value,
                line,
                'lineNumber': startLineNumber + ixLine
            };
            labelDefs.push({'for': foreach});
            labelIndex += 1;

            // Parse the for-each expression
            let forBeginExpr;
            try {
                forBeginExpr = parseExpression(matchForBegin.groups.values, lineNumber, scriptName, true);
            } catch (error) {
                const columnNumber = matchForBegin.groups.for.length + error.columnNumber;
                throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
            }

            // Add the for-each header statements
            statements.push(
                {'expr': {
                    'name': foreach.values,
                    'expr': forBeginExpr,
                    ...statementBase
                }},
                {'expr': {
                    'name': foreach.length,
                    'expr': {'function': {'name': 'arrayLength', 'args': [{'variable': foreach.values}]}},
                    ...statementBase
                }},
                {'jump': {
                    'label': foreach.done,
                    'expr': {'unary': {'op': '!', 'expr': {'variable': foreach.length}}},
                    ...statementBase
                }},
                {'expr': {'name': foreach.index, 'expr': {'number': 0}, ...statementBase}},
                {'label': {'name': foreach.loop, ...statementBase}},
                {'expr': {
                    'name': foreach.value,
                    'expr': {
                        'function': {'name': 'arrayGet', 'args': [{'variable': foreach.values}, {'variable': foreach.index}]}
                    },
                    ...statementBase
                }}
            );
            continue;
        }

        // For-each end?
        const matchForEnd = (keyword === 'endfor') ? line.match(rScriptForEnd) : null;
        if (matchForEnd !== null) {
            // Pop the foreach definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const foreach = (labelDefs.length > labelDefDepth ? (labelDefs.pop().for ?? null) : null);
            if (foreach === null) {
                throw new BareScriptParserError('No matching for statement', line, 1, startLineNumber + ixLine, scriptName);
            }

            // Add the for-each footer statements
            if (foreach.hasContinue) {
                statements.push({'label': {'name': foreach.continue, ...statementBase}});
            }
            statements.push(
                {'expr': {
                    'name': foreach.index,
                    'expr': {'binary': {'op': '+', 'left': {'variable': foreach.index}, 'right': {'number': 1}}},
                    ...statementBase
                }},
                {'jump': {
                    'label': foreach.loop,
                    'expr': {
                        'binary': {'op': '<', 'left': {'variable': foreach.index}, 'right': {'variable': foreach.length}}
                    },
                    ...statementBase
                }},
                {'label': {'name': foreach.done, ...statementBase}}
            );
            continue;
        }

        // Break statement?
        const matchBreak = (keyword === 'break') ? line.match(rScriptBreak) : null;
        if (matchBreak !== null) {
            // Get the loop definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const ixLabelDef = labelDefs.findLastIndex((def) => !('if' in def));
            const labelDef = (ixLabelDef >= labelDefDepth ? labelDefs[ixLabelDef] : null);
            if (labelDef === null) {
                throw new BareScriptParserError('Break statement outside of loop', line, 1, startLineNumber + ixLine, scriptName);
            }
            const [labelKey] = Object.keys(labelDef);
            const loopDef = labelDef[labelKey];

            // Add the break jump statement
            statements.push({'jump': {'label': loopDef.done, ...statementBase}});
            continue;
        }

        // Continue statement?
        const matchContinue = (keyword === 'continue') ? line.match(rScriptContinue) : null;
        if (matchContinue !== null) {
            // Get the loop definition
            const labelDefDepth = (functionDef !== null ? functionLabelDefDepth : 0);
            const ixLabelDef = labelDefs.findLastIndex((def) => !('if' in def));
            const labelDef = (ixLabelDef >= labelDefDepth ? labelDefs[ixLabelDef] : null);
            if (labelDef === null) {
                throw new BareScriptParserError(
                    'Continue statement outside of loop', line, 1, startLineNumber + ixLine, scriptName
                );
            }
            const [labelKey] = Object.keys(labelDef);
            const loopDef = labelDef[labelKey];

            // Add the continue jump statement
            loopDef.hasContinue = true;
            statements.push({'jump': {'label': loopDef.continue, ...statementBase}});
            continue;
        }

        // Jump definition?
        const matchJump = (keyword === 'jump' || keyword === 'jumpif') ? line.match(rScriptJump) : null;
        if (matchJump !== null) {
            const jumpStatement = {'jump': {'label': matchJump.groups.name, ...statementBase}};
            if (matchJump.groups.expr !== undefined) {
                try {
                    jumpStatement.jump.expr = parseExpression(matchJump.groups.expr, lineNumber, scriptName, true);
                } catch (error) {
                    const columnNumber = matchJump.groups.jump.length - matchJump.groups.expr.length - 1 + error.columnNumber;
                    throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
                }
            }
            statements.push(jumpStatement);
            continue;
        }

        // Return definition?
        const matchReturn = (keyword === 'return') ? line.match(rScriptReturn) : null;
        if (matchReturn !== null) {
            const returnStatement = {'return': {...statementBase}};
            if (matchReturn.groups.expr !== undefined) {
                try {
                    returnStatement.return.expr = parseExpression(matchReturn.groups.expr, lineNumber, scriptName, true);
                } catch (error) {
                    const columnNumber = matchReturn.groups.return.length - matchReturn.groups.expr.length + error.columnNumber;
                    throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
                }
            }
            statements.push(returnStatement);
            continue;
        }

        // Include definition?
        const matchInclude = (keyword === 'include') ? line.match(rScriptInclude) || line.match(rScriptIncludeSystem) : null;
        if (matchInclude !== null) {
            const {delim} = matchInclude.groups;
            const url = (
                delim === '<' ? matchInclude.groups.url : matchInclude.groups.url.replace(rExprStringEscapes, replaceStringEscape)
            );
            let includeStatement = (statements.length ? statements[statements.length - 1] : null);
            if (includeStatement === null || !('include' in includeStatement)) {
                includeStatement = {'include': {'includes': [], ...statementBase}};
                statements.push(includeStatement);
            } else {
                includeStatement.include.lineCount = (ixLinePart - includeStatement.include.lineNumber) + 2;
            }
            includeStatement.include.includes.push(delim === '<' ? {url, 'system': true} : {url});
            continue;
        }

        // Catch-all (line starts with an identifier but isn't a recognized control statement)
        if (keyword !== null) {
            // Assignment?
            const matchAssignment = line.match(rScriptAssignment);
            if (matchAssignment !== null) {
                // Parse the expression
                let assignmentExpr;
                try {
                    assignmentExpr = parseExpression(matchAssignment.groups.expr, lineNumber, scriptName, true);
                } catch (error) {
                    const columnNumber = line.length - matchAssignment.groups.expr.length + error.columnNumber;
                    throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine, scriptName);
                }

                // Add the expression statement
                const exprStatement = {
                    'expr': {
                        'name': matchAssignment.groups.name,
                        'expr': assignmentExpr,
                        ...statementBase
                    }
                };
                statements.push(exprStatement);
                continue;
            }

            // Label definition?
            const matchLabel = line.match(rScriptLabel);
            if (matchLabel !== null) {
                statements.push({'label': {'name': matchLabel.groups.name, ...statementBase}});
                continue;
            }
        }

        // Expression
        try {
            const exprStatement = {'expr': {'expr': parseExpression(line, lineNumber, scriptName, true), ...statementBase}};
            statements.push(exprStatement);
        } catch (error) {
            throw new BareScriptParserError(error.error, line, error.columnNumber, startLineNumber + ixLine, scriptName);
        }
    }

    // Dangling label definitions?
    if (labelDefs.length > 0) {
        const labelDef = labelDefs.pop();
        const [defKey] = Object.keys(labelDef);
        const def = labelDef[defKey];
        throw new BareScriptParserError(`Missing end${defKey} statement`, def.line, 1, def.lineNumber, scriptName);
    }

    return script;
}


// BareScript regex
const rScriptLineSplit = /\r?\n/;
const rScriptContinuation = /\\\s*$/;
const rScriptKeyword = /^\s*([A-Za-z_]\w*)/;
const rScriptAssignment = /^\s*(?<name>[A-Za-z_]\w*)\s*=\s*(?<expr>.+)$/;
const rPartComment = '\\s*(#.*)?$';
const rScriptFunctionBegin = new RegExp(
    '^(?<async>\\s*async)?\\s*function\\s+(?<name>[A-Za-z_]\\w*)\\s*\\(' +
        `\\s*(?<args>[A-Za-z_]\\w*(?:\\s*,\\s*[A-Za-z_]\\w*)*)?(?<lastArgArray>\\s*\\.\\.\\.)?\\s*\\)\\s*:${rPartComment}`
);
const rScriptFunctionArgSplit = /\s*,\s*/;
const rScriptFunctionEnd = new RegExp(`^\\s*endfunction${rPartComment}`);
const rScriptLabel = new RegExp(`^\\s*(?<name>[A-Za-z_]\\w*)\\s*:${rPartComment}`);
const rScriptJump = new RegExp(`^(?<jump>\\s*(?:jump|jumpif\\s*\\((?<expr>.+)\\)))\\s+(?<name>[A-Za-z_]\\w*)${rPartComment}`);
const rScriptReturn = new RegExp(`^(?<return>\\s*return(?:\\s+(?<expr>[^#\\s].*))?)${rPartComment}`);
const rScriptInclude = new RegExp(`^\\s*include\\s+(?<delim>')(?<url>(?:\\'|[^'])*)'${rPartComment}`);
const rScriptIncludeSystem = new RegExp(`^\\s*include\\s+(?<delim><)(?<url>[^>]*)>${rPartComment}`);
const rScriptIfBegin = new RegExp(`^(?<if>\\s*if\\s+)(?<expr>.+)\\s*:${rPartComment}`);
const rScriptIfElseIf = new RegExp(`^(?<elif>\\s*elif\\s+)(?<expr>.+)\\s*:${rPartComment}`);
const rScriptIfElse = new RegExp(`^\\s*else\\s*:${rPartComment}`);
const rScriptIfEnd = new RegExp(`^\\s*endif${rPartComment}`);
const rScriptForBegin = new RegExp(
    `^(?<for>\\s*for\\s+(?<value>[A-Za-z_]\\w*)(?:\\s*,\\s*(?<index>[A-Za-z_]\\w*))?\\s+in\\s+)(?<values>.+)\\s*:${rPartComment}`
);
const rScriptForEnd = new RegExp(`^\\s*endfor${rPartComment}`);
const rScriptWhileBegin = new RegExp(`^(?<while>\\s*while\\s+)(?<expr>.+)\\s*:${rPartComment}`);
const rScriptWhileEnd = new RegExp(`^\\s*endwhile${rPartComment}`);
const rScriptBreak = new RegExp(`^\\s*break${rPartComment}`);
const rScriptContinue = new RegExp(`^\\s*continue${rPartComment}`);


/**
 * Parse a BareScript expression
 *
 * @param {string} exprText - The [expression text](./language/#expressions)
 * @param {number} [lineNumber = 1] - 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](./model/#var.vName='Expression')
 * @throws [BareScriptParserError]{@link module:lib/parser.BareScriptParserError}
 */
export function parseExpression(exprText, lineNumber = null, scriptName = null, arrayLiterals = false) {
    try {
        const [expr, nextText] = parseBinaryExpression(exprText, arrayLiterals);
        if (nextText.trim() !== '') {
            throw new BareScriptParserError('Syntax error', nextText, 1, lineNumber, scriptName);
        }
        return expr;
    } catch (error) {
        const columnNumber = exprText.length - error.line.length + 1;
        throw new BareScriptParserError(error.error, exprText, columnNumber, lineNumber, scriptName);
    }
}


// Helper function to parse a binary operator expression chain
function parseBinaryExpression(exprText, arrayLiterals) {
    // Parse the binary operator's left unary expression
    let [leftExpr, binText] = parseUnaryExpression(exprText, arrayLiterals);

    // Consume binary operators while present, building up the binary expression tree
    while (true) {
        // Skip leading whitespace to find the first dispatch char (HT, LF, VT, FF, CR, space)
        let pos = 0;
        const len = binText.length;
        while (pos < len) {
            const cc = binText.charCodeAt(pos);
            if (!((cc >= 0x09 && cc <= 0x0D) || cc === 0x20)) {
                break;
            }
            pos += 1;
        }

        // End of binary expression (empty, whitespace-only, or end-of-line comment)?
        if (pos >= len || binText.charCodeAt(pos) === 0x23) {
            return [leftExpr, ''];
        }

        // Binary operator? First char must be one of "!%&*+-/<=>^|"
        const firstChar = binText.charCodeAt(pos);
        const isBinaryOpStart = firstChar === 0x21 || firstChar === 0x25 || firstChar === 0x26 ||
            firstChar === 0x2A || firstChar === 0x2B || firstChar === 0x2D || firstChar === 0x2F ||
            firstChar === 0x3C || firstChar === 0x3D || firstChar === 0x3E || firstChar === 0x5E ||
            firstChar === 0x7C;
        const matchBinaryOp = isBinaryOpStart ? binText.match(rExprBinaryOp) : null;
        if (matchBinaryOp === null) {
            return [leftExpr, binText];
        }
        const [, binOp] = matchBinaryOp;
        const rightText = binText.slice(matchBinaryOp[0].length);

        // Parse the right sub-expression
        const [rightExpr, nextText] = parseUnaryExpression(rightText, arrayLiterals);

        // Create the binary expression - re-order for binary operators as necessary
        if (leftExpr.binary !== undefined && binaryReorder[binOp].has(leftExpr.binary.op)) {
            // Left expression has lower precendence - find where to put this expression within the left expression
            let reorderExpr = leftExpr;
            while (reorderExpr.binary.right.binary !== undefined &&
                   binaryReorder[binOp].has(reorderExpr.binary.right.binary.op)) {
                reorderExpr = reorderExpr.binary.right;
            }
            reorderExpr.binary.right = {'binary': {'op': binOp, 'left': reorderExpr.binary.right, 'right': rightExpr}};
        } else {
            leftExpr = {'binary': {'op': binOp, 'left': leftExpr, 'right': rightExpr}};
        }

        binText = nextText;
    }
}


// Binary operator re-order map
const binaryReorder = {
    '**': new Set(['*', '/', '%', '+', '-', '<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '*': new Set(['+', '-', '<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '/': new Set(['+', '-', '<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '%': new Set(['+', '-', '<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '+': new Set(['<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '-': new Set(['<<', '>>', '<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '<<': new Set(['<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '>>': new Set(['<=', '<', '>=', '>', '==', '!=', '&', '^', '|', '&&', '||']),
    '<=': new Set(['==', '!=', '&', '^', '|', '&&', '||']),
    '<': new Set(['==', '!=', '&', '^', '|', '&&', '||']),
    '>=': new Set(['==', '!=', '&', '^', '|', '&&', '||']),
    '>': new Set(['==', '!=', '&', '^', '|', '&&', '||']),
    '==': new Set(['&', '^', '|', '&&', '||']),
    '!=': new Set(['&', '^', '|', '&&', '||']),
    '&': new Set(['^', '|', '&&', '||']),
    '^': new Set(['|', '&&', '||']),
    '|': new Set(['&&', '||']),
    '&&': new Set(['||']),
    '||': new Set([])
};


// Helper function to parse a unary expression
function parseUnaryExpression(exprText, arrayLiterals) {
    // Skip leading whitespace to find the first dispatch char (HT, LF, VT, FF, CR, space)
    let pos = 0;
    const len = exprText.length;
    while (pos < len) {
        const cc = exprText.charCodeAt(pos);
        if (!((cc >= 0x09 && cc <= 0x0D) || cc === 0x20)) {
            break;
        }
        pos += 1;
    }
    if (pos >= len) {
        throw new BareScriptParserError('Syntax error', exprText, 1, null, null);
    }
    const firstChar = exprText.charCodeAt(pos);

    // Group? '('
    const matchGroupOpen = (firstChar === 0x28) ? exprText.match(rExprGroupOpen) : null;
    if (matchGroupOpen !== null) {
        const groupText = exprText.slice(matchGroupOpen[0].length);
        const [expr, nextText] = parseBinaryExpression(groupText, arrayLiterals);
        const matchGroupClose = nextText.match(rExprGroupClose);
        if (matchGroupClose === null) {
            throw new BareScriptParserError('Unmatched parenthesis', exprText, 1, null, null);
        }
        return [{'group': expr}, nextText.slice(matchGroupClose[0].length)];
    }

    // Number? digit, '+', or '-' (the '-' falls through to unary below if no number matches)
    const isNumberStart = (firstChar >= 0x30 && firstChar <= 0x39) || firstChar === 0x2B || firstChar === 0x2D;
    const matchNumber = isNumberStart ? exprText.match(rExprNumber) : null;
    if (matchNumber !== null) {
        const [, numberStr] = matchNumber;
        const number = (numberStr.startsWith('0x') ? parseInt(numberStr, 16) : parseFloat(numberStr));
        return [{'number': number}, exprText.slice(matchNumber[0].length)];
    }

    // String? '\''
    const matchString = (firstChar === 0x27) ? exprText.match(rExprString) : null;
    if (matchString !== null) {
        const string = matchString[1].replace(rExprStringEscapes, replaceStringEscape);
        return [{'string': string}, exprText.slice(matchString[0].length)];
    }

    // String (double quotes)? '"'
    const matchStringDouble = (firstChar === 0x22) ? exprText.match(rExprStringDouble) : null;
    if (matchStringDouble !== null) {
        const string = matchStringDouble[1].replace(rExprStringEscapes, replaceStringEscape);
        return [{'string': string}, exprText.slice(matchStringDouble[0].length)];
    }

    // Unary operator? '!', '-', '~'
    const matchUnary = (firstChar === 0x21 || firstChar === 0x2D || firstChar === 0x7E) ? exprText.match(rExprUnaryOp) : null;
    if (matchUnary !== null) {
        const unaryText = exprText.slice(matchUnary[0].length);
        const [expr, nextText] = parseUnaryExpression(unaryText, arrayLiterals);
        return [{
            'unary': {
                'op': matchUnary[1],
                expr
            }
        }, nextText];
    }

    // Function call? identifier followed by '('
    const isIdent = (firstChar >= 0x41 && firstChar <= 0x5A) || (firstChar >= 0x61 && firstChar <= 0x7A) || firstChar === 0x5F;
    const matchFunctionOpen = isIdent ? exprText.match(rExprFunctionOpen) : null;
    if (matchFunctionOpen !== null) {
        let argText = exprText.slice(matchFunctionOpen[0].length);
        const args = [];
        while (true) {
            // Function close?
            const matchFunctionClose = argText.match(rExprFunctionClose);
            if (matchFunctionClose !== null) {
                argText = argText.slice(matchFunctionClose[0].length);
                break;
            }

            // Function argument separator
            if (args.length !== 0) {
                const matchFunctionSeparator = argText.match(rExprFunctionSeparator);
                if (matchFunctionSeparator === null) {
                    throw new BareScriptParserError('Syntax error', argText, 1, null, null);
                }
                argText = argText.slice(matchFunctionSeparator[0].length);
            }

            // Get the argument
            const [argExpr, nextArgText] = parseBinaryExpression(argText, arrayLiterals);
            argText = nextArgText;
            args.push(argExpr);
        }

        return [{
            'function': {
                'name': matchFunctionOpen[1],
                'args': args
            }
        }, argText];
    }

    // Variable
    const matchVariable = isIdent ? exprText.match(rExprVariable) : null;
    if (matchVariable !== null) {
        return [{'variable': matchVariable[1]}, exprText.slice(matchVariable[0].length)];
    }

    // Object creation? '{'
    const matchObjectOpen = (firstChar === 0x7B) ? exprText.match(rExprObjectOpen) : null;
    if (matchObjectOpen !== null) {
        let argText = exprText.slice(matchObjectOpen[0].length);
        const args = [];
        while (true) {
            // Object close?
            const matchObjectClose = argText.match(rExprObjectClose);
            if (matchObjectClose !== null) {
                argText = argText.slice(matchObjectClose[0].length);
                break;
            }

            // Key/value pair separator
            if (args.length !== 0) {
                const matchObjectSeparator = argText.match(rExprObjectSeparator);
                if (matchObjectSeparator === null) {
                    throw new BareScriptParserError('Syntax error', argText, 1, null, null);
                }
                argText = argText.slice(matchObjectSeparator[0].length);
            }

            // Get the key
            const [argKey, nextArgText] = parseBinaryExpression(argText, arrayLiterals);
            argText = nextArgText;
            args.push(argKey);

            // Key/value separator
            if (args.length !== 0) {
                const matchObjectSeparatorKey = argText.match(rExprObjectSeparatorKey);
                if (matchObjectSeparatorKey === null) {
                    throw new BareScriptParserError('Syntax error', argText, 1, null, null);
                }
                argText = argText.slice(matchObjectSeparatorKey[0].length);
            }

            // Get the value
            const [argValue, nextArgText2] = parseBinaryExpression(argText, arrayLiterals);
            argText = nextArgText2;
            args.push(argValue);
        }
        return [{'function': {'name': 'objectNew', 'args': args}}, argText];
    }

    // Array creation? '['
    const matchArrayOpen = (firstChar === 0x5B && arrayLiterals) ? exprText.match(rExprArrayOpen) : null;
    if (matchArrayOpen !== null) {
        let argText = exprText.slice(matchArrayOpen[0].length);
        const args = [];
        while (true) {
            // Array close?
            const matchArrayClose = argText.match(rExprArrayClose);
            if (matchArrayClose !== null) {
                argText = argText.slice(matchArrayClose[0].length);
                break;
            }

            // Array value separator
            if (args.length !== 0) {
                const matchArraySeparator = argText.match(rExprArraySeparator);
                if (matchArraySeparator === null) {
                    throw new BareScriptParserError('Syntax error', argText, 1, null, null);
                }
                argText = argText.slice(matchArraySeparator[0].length);
            }

            // Get the value
            const [argValue, nextArgText2] = parseBinaryExpression(argText, arrayLiterals);
            argText = nextArgText2;
            args.push(argValue);
        }
        return [{'function': {'name': 'arrayNew', 'args': args}}, argText];
    }

    // Variable (brackets)? '['
    const matchVariableEx = (firstChar === 0x5B && !arrayLiterals) ? exprText.match(rExprVariableEx) : null;
    if (matchVariableEx !== null) {
        const variableName = matchVariableEx[1].replace(rExprVariableExEscape, '$1');
        return [{'variable': variableName}, exprText.slice(matchVariableEx[0].length)];
    }

    throw new BareScriptParserError('Syntax error', exprText, 1, null, null);
}


// BareScript expression regex
const rExprBinaryOp = /^\s*(\*\*|\*|\/|%|\+|-|<<|>>|<=|<|>=|>|==|!=|&&|\|\||&|\^|\|)/;
const rExprUnaryOp = /^\s*(!|-|~)/;
const rExprFunctionOpen = /^\s*([A-Za-z_]\w+)\s*\(/;
const rExprFunctionSeparator = /^\s*,/;
const rExprFunctionClose = /^\s*\)/;
const rExprGroupOpen = /^\s*\(/;
const rExprGroupClose = /^\s*\)/;
const rExprNumber = /^\s*(0x[A-Fa-f0-9]+|[+-]?\d+(?:\.\d*)?(?:e[+-]?\d+)?)/;
const rExprArrayOpen = /^\s*\[/;
const rExprArraySeparator = /^\s*,/;
const rExprArrayClose = /^\s*\]/;
const rExprObjectOpen = /^\s*\{/;
const rExprObjectSeparatorKey = /^\s*:/;
const rExprObjectSeparator = /^\s*,/;
const rExprObjectClose = /^\s*\}/;
const rExprString = /^\s*'((?:\\\\|\\'|[^'])*)'/;
const rExprStringDouble = /^\s*"((?:\\\\|\\"|[^"])*)"/;
const rExprVariable = /^\s*([A-Za-z_]\w*)/;
const rExprVariableEx = /^\s*\[\s*((?:\\\]|[^\]])+)\s*\]/;
const rExprVariableExEscape = /\\([\\\]])/g;


// String literal escapes
const rExprStringEscapes = /\\([nrtbf'"\\]|u[0-9a-fA-F]{4})/g;

function replaceStringEscape(unusedMatch, esc) {
    if (esc.startsWith('u')) {
        return String.fromCharCode(parseInt(esc.slice(1), 16));
    }
    return exprStringEscapes[esc];
}

const exprStringEscapes = {
    'n': '\n',
    'r': '\r',
    't': '\t',
    'b': '\b',
    'f': '\f',
    "'": "'",
    '"': '"',
    '\\': '\\'
};


/**
 * 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;
    }
}