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