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
 * @returns {Object} The [BareScript model](./model/#var.vName='BareScript')
 * @throws [BareScriptParserError]{@link module:lib/parser.BareScriptParserError}
 */
export function parseScript(scriptText, startLineNumber = 1) {
    const script = {'statements': []};

    // Line-split all script text
    const lines = [];
    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?
        if (linePart.match(rScriptComment) !== null) {
            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;
        }

        // Assignment?
        const matchAssignment = line.match(rScriptAssignment);
        if (matchAssignment !== null) {
            try {
                const exprStatement = {
                    'expr': {
                        'name': matchAssignment.groups.name,
                        'expr': parseExpression(matchAssignment.groups.expr)
                    }
                };
                statements.push(exprStatement);
                continue;
            } catch (error) {
                const columnNumber = line.length - matchAssignment.groups.expr.length + error.columnNumber;
                throw new BareScriptParserError(error.error, line, columnNumber, startLineNumber + ixLine);
            }
        }

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

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

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

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

            functionDef = null;
            functionLabelDefDepth = null;
            continue;
        }

        // If-then begin?
        const matchIfBegin = line.match(rScriptIfBegin);
        if (matchIfBegin !== null) {
            // Add the if-then label definition
            const ifthen = {
                'jump': {
                    'label': `__bareScriptIf${labelIndex}`,
                    'expr': {'unary': {'op': '!', 'expr': parseExpression(matchIfBegin.groups.expr)}}
                },
                '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 = line.match(rScriptIfElseIf);
        if (matchIfElseIf !== 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);
            }

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

            // Generate the next if-then jump statement
            const prevLabel = ifthen.jump.label;
            ifthen.jump = {
                'label': `__bareScriptIf${labelIndex}`,
                'expr': {'unary': {'op': '!', 'expr': parseExpression(matchIfElseIf.groups.expr)}}
            };
            labelIndex += 1;

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

        // Else-then?
        const matchIfElse = line.match(rScriptIfElse);
        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);
            }

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

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

        // If-then end?
        const matchIfEnd = line.match(rScriptIfEnd);
        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);
            }

            // 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': ifthen.done});
            continue;
        }

        // While-do begin?
        const matchWhileBegin = line.match(rScriptWhileBegin);
        if (matchWhileBegin !== null) {
            // Add the while-do label
            const whiledo = {
                'loop': `__bareScriptLoop${labelIndex}`,
                'continue': `__bareScriptLoop${labelIndex}`,
                'done': `__bareScriptDone${labelIndex}`,
                'expr': parseExpression(matchWhileBegin.groups.expr),
                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}}}},
                {'label': whiledo.loop}
            );
            continue;
        }

        // While-do end?
        const matchWhileEnd = line.match(rScriptWhileEnd);
        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);
            }

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

        // For-each begin?
        const matchForBegin = line.match(rScriptForBegin);
        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;

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

        // For-each end?
        const matchForEnd = line.match(rScriptForEnd);
        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);
            }

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

        // Break statement?
        const matchBreak = line.match(rScriptBreak);
        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);
            }
            const [labelKey] = Object.keys(labelDef);
            const loopDef = labelDef[labelKey];

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

        // Continue statement?
        const matchContinue = line.match(rScriptContinue);
        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);
            }
            const [labelKey] = Object.keys(labelDef);
            const loopDef = labelDef[labelKey];

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

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

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

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

        // Include definition?
        const matchInclude = line.match(rScriptInclude) || line.match(rScriptIncludeSystem);
        if (matchInclude !== null) {
            const {delim} = matchInclude.groups;
            const url = (delim === '<' ? matchInclude.groups.url : matchInclude.groups.url.replace(rExprStringEscape, '$1'));
            let includeStatement = (statements.length ? statements[statements.length - 1] : null);
            if (includeStatement === null || !('include' in includeStatement)) {
                includeStatement = {'include': {'includes': []}};
                statements.push(includeStatement);
            }
            includeStatement.include.includes.push(delim === '<' ? {url, 'system': true} : {url});
            continue;
        }

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

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

    return script;
}


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


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


// Helper function to parse a binary operator expression chain
function parseBinaryExpression(exprText, binLeftExpr = null) {
    // Parse the binary operator's left unary expression if none was passed
    let leftExpr;
    let binText;
    if (binLeftExpr !== null) {
        binText = exprText;
        leftExpr = binLeftExpr;
    } else {
        [leftExpr, binText] = parseUnaryExpression(exprText);
    }

    // Match a binary operator - if not found, return the left expression
    const matchBinaryOp = binText.match(rExprBinaryOp);
    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);

    // Create the binary expression - re-order for binary operators as necessary
    let binExpr;
    if (Object.keys(leftExpr)[0] === 'binary' && binaryReorder[binOp].has(leftExpr.binary.op)) {
        // Left expression has lower precendence - find where to put this expression within the left expression
        binExpr = leftExpr;
        let reorderExpr = leftExpr;
        while (Object.keys(reorderExpr.binary.right)[0] === 'binary' &&
               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 {
        binExpr = {'binary': {'op': binOp, 'left': leftExpr, 'right': rightExpr}};
    }

    // Parse the next binary expression in the chain
    return parseBinaryExpression(nextText, binExpr);
}


// 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([])
};


// Helper function to parse a unary expression
function parseUnaryExpression(exprText) {
    // Group open?
    const matchGroupOpen = exprText.match(rExprGroupOpen);
    if (matchGroupOpen !== null) {
        const groupText = exprText.slice(matchGroupOpen[0].length);
        const [expr, nextText] = parseBinaryExpression(groupText);
        const matchGroupClose = nextText.match(rExprGroupClose);
        if (matchGroupClose === null) {
            throw new BareScriptParserError('Unmatched parenthesis', exprText);
        }
        return [{'group': expr}, nextText.slice(matchGroupClose[0].length)];
    }

    // Unary operator?
    const matchUnary = exprText.match(rExprUnaryOp);
    if (matchUnary !== null) {
        const unaryText = exprText.slice(matchUnary[0].length);
        const [expr, nextText] = parseUnaryExpression(unaryText);
        const unaryExpr = {
            'unary': {
                'op': matchUnary[1],
                expr
            }
        };
        return [unaryExpr, nextText];
    }

    // Function?
    const matchFunctionOpen = exprText.match(rExprFunctionOpen);
    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);
                }
                argText = argText.slice(matchFunctionSeparator[0].length);
            }

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

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

    // Number?
    const matchNumber = exprText.match(rExprNumber);
    if (matchNumber !== null) {
        const number = parseFloat(matchNumber[1]);
        const expr = {'number': number};
        return [expr, exprText.slice(matchNumber[0].length)];
    }

    // String?
    const matchString = exprText.match(rExprString);
    if (matchString !== null) {
        const string = matchString[1].replace(rExprStringEscape, '$1');
        const expr = {'string': string};
        return [expr, exprText.slice(matchString[0].length)];
    }

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

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

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

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


// 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*([+-]?\d+(?:\.\d*)?(?:e[+-]\d+)?)/;
const rExprString = /^\s*'((?:\\\\|\\'|[^'])*)'/;
const rExprStringEscape = /\\([\\'])/g;
const rExprStringDouble = /^\s*"((?:\\\\|\\"|[^"])*)"/;
const rExprStringDoubleEscape = /\\([\\"])/g;
const rExprVariable = /^\s*([A-Za-z_]\w*)/;
const rExprVariableEx = /^\s*\[\s*((?:\\\]|[^\]])+)\s*\]/;
const rExprVariableExEscape = /\\([\\\]])/g;


/**
 * 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
 */
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=1] - The error column number
     * @param {?number} [lineNumber=null] - The error line number
     * @param {?string} [prefix=null] - The error message prefix line
     */
    constructor(error, line, columnNumber = 1, lineNumber = null, prefix = null) {
        // 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 message = `\
${prefix !== null ? `${prefix}\n` : ''}${error}${lineNumber !== null ? `, line number ${lineNumber}` : ''}:
${lineError}
${' '.repeat(lineColumn - 1)}^
`;
        super(message);
        this.name = this.constructor.name;
        this.error = error;
        this.line = line;
        this.columnNumber = columnNumber;
        this.lineNumber = lineNumber;
    }
}