371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
|
|
import { getType } from "../../common/utils/helpers.mjs";
|
||
|
|
import OperatorTerm from "./terms/operator.mjs";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {import("../_types.mjs").RollParseNode} RollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").RollParseTreeNode} RollParseTreeNode
|
||
|
|
* @typedef {import("../_types.mjs").NumericRollParseNode} NumericRollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").FunctionRollParseNode} FunctionRollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").PoolRollParseNode} PoolRollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").ParentheticalRollParseNode} ParentheticalRollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").DiceRollParseNode} DiceRollParseNode
|
||
|
|
* @typedef {import("../_types.mjs").RollParseArg} RollParseArg
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A class for transforming events from the Peggy grammar lexer into various formats.
|
||
|
|
*/
|
||
|
|
export default class RollParser {
|
||
|
|
/**
|
||
|
|
* @param {string} formula The full formula.
|
||
|
|
*/
|
||
|
|
constructor(formula) {
|
||
|
|
this.formula = formula;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The full formula.
|
||
|
|
* @type {string}
|
||
|
|
*/
|
||
|
|
formula;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Parse Events */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a base roll expression.
|
||
|
|
* @param {RollParseNode} head The first operand.
|
||
|
|
* @param {[string[], RollParseNode][]} tail Zero or more subsequent (operators, operand) tuples.
|
||
|
|
* @param {string} [leading] A leading operator.
|
||
|
|
* @param {string} formula The original matched text.
|
||
|
|
* @param {function} error The peggy error callback to invoke on a parse error.
|
||
|
|
* @returns {RollParseTreeNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onExpression(head, tail, leading, formula, error) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onExpression", head, tail));
|
||
|
|
if ( leading.length ) leading = this._collapseOperators(leading);
|
||
|
|
if ( leading === "-" ) head = this._wrapNegativeTerm(head);
|
||
|
|
|
||
|
|
// We take the list of (operator, operand) tuples and arrange them into a left-skewed binary tree.
|
||
|
|
return tail.reduce((acc, [operators, operand]) => {
|
||
|
|
let operator;
|
||
|
|
let [multiplicative, ...additive] = operators;
|
||
|
|
if ( additive.length ) additive = this._collapseOperators(additive);
|
||
|
|
if ( multiplicative ) {
|
||
|
|
operator = multiplicative;
|
||
|
|
if ( additive === "-" ) operand = this._wrapNegativeTerm(operand);
|
||
|
|
}
|
||
|
|
else operator = additive;
|
||
|
|
if ( typeof operator !== "string" ) error(`Failed to parse ${formula}. Unexpected operator.`);
|
||
|
|
const operands = [acc, operand];
|
||
|
|
return { class: "Node", formula: `${acc.formula} ${operator} ${operand.formula}`, operands, operator };
|
||
|
|
}, head);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a dice term.
|
||
|
|
* @param {NumericRollParseNode|ParentheticalRollParseNode|null} number The number of dice.
|
||
|
|
* @param {string|NumericRollParseNode|ParentheticalRollParseNode|null} faces The number of die faces or a string
|
||
|
|
* denomination like "c" or "f".
|
||
|
|
* @param {string|null} modifiers The matched modifiers string.
|
||
|
|
* @param {string|null} flavor Associated flavor text.
|
||
|
|
* @param {string} formula The original matched text.
|
||
|
|
* @returns {DiceRollParseNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onDiceTerm(number, faces, modifiers, flavor, formula) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) {
|
||
|
|
console.debug(this.constructor.formatDebug("onDiceTerm", number, faces, modifiers, flavor, formula));
|
||
|
|
}
|
||
|
|
return { class: "DiceTerm", formula, modifiers, number, faces, evaluated: false, options: { flavor } };
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a numeric term.
|
||
|
|
* @param {number} number The number.
|
||
|
|
* @param {string} flavor Associated flavor text.
|
||
|
|
* @returns {NumericRollParseNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onNumericTerm(number, flavor) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) console.debug(this.constructor.formatDebug("onNumericTerm", number, flavor));
|
||
|
|
return {
|
||
|
|
class: "NumericTerm", number,
|
||
|
|
formula: `${number}${flavor ? `[${flavor}]` : ""}`,
|
||
|
|
evaluated: false,
|
||
|
|
options: { flavor }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a math term.
|
||
|
|
* @param {string} fn The Math function.
|
||
|
|
* @param {RollParseNode} head The first term.
|
||
|
|
* @param {RollParseNode[]} tail Zero or more additional terms.
|
||
|
|
* @param {string} flavor Associated flavor text.
|
||
|
|
* @param {string} formula The original matched text.
|
||
|
|
* @returns {FunctionRollParseNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onFunctionTerm(fn, head, tail, flavor, formula) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) {
|
||
|
|
console.debug(this.constructor.formatDebug("onFunctionTerm", fn, head, tail, flavor, formula));
|
||
|
|
}
|
||
|
|
const terms = [];
|
||
|
|
if ( head ) terms.push(head, ...tail);
|
||
|
|
return { class: "FunctionTerm", fn, terms, formula, evaluated: false, options: { flavor } };
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a pool term.
|
||
|
|
* @param {RollParseNode} head The first term.
|
||
|
|
* @param {RollParseNode[]} tail Zero or more additional terms.
|
||
|
|
* @param {string|null} modifiers The matched modifiers string.
|
||
|
|
* @param {string|null} flavor Associated flavor text.
|
||
|
|
* @param {string} formula The original matched text.
|
||
|
|
* @returns {PoolRollParseNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onPoolTerm(head, tail, modifiers, flavor, formula) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) {
|
||
|
|
console.debug(this.constructor.formatDebug("onPoolTerm", head, tail, modifiers, flavor, formula));
|
||
|
|
}
|
||
|
|
const terms = [];
|
||
|
|
if ( head ) terms.push(head, ...tail);
|
||
|
|
return { class: "PoolTerm", terms, formula, modifiers, evaluated: false, options: { flavor } };
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a parenthetical.
|
||
|
|
* @param {RollParseNode} term The inner term.
|
||
|
|
* @param {string|null} flavor Associated flavor text.
|
||
|
|
* @param {string} formula The original matched text.
|
||
|
|
* @returns {ParentheticalRollParseNode}
|
||
|
|
* @internal
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onParenthetical(term, flavor, formula) {
|
||
|
|
if ( CONFIG.debug.rollParsing ) {
|
||
|
|
console.debug(this.constructor.formatDebug("onParenthetical", term, flavor, formula));
|
||
|
|
}
|
||
|
|
return { class: "ParentheticalTerm", term, formula, evaluated: false, options: { flavor } };
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle some string that failed to be classified.
|
||
|
|
* @param {string} term The term.
|
||
|
|
* @param {string|null} [flavor] Associated flavor text.
|
||
|
|
* @returns {StringParseNode}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onStringTerm(term, flavor) {
|
||
|
|
return { class: "StringTerm", term, evaluated: false, options: { flavor } };
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Collapse multiple additive operators into a single one.
|
||
|
|
* @param {string[]} operators A sequence of additive operators.
|
||
|
|
* @returns {string}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_collapseOperators(operators) {
|
||
|
|
let head = operators.pop();
|
||
|
|
for ( const operator of operators ) {
|
||
|
|
if ( operator === "-" ) head = head === "+" ? "-" : "+";
|
||
|
|
}
|
||
|
|
return head;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Wrap a term with a leading minus.
|
||
|
|
* @param {RollParseNode} term The term to wrap.
|
||
|
|
* @returns {RollParseNode}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_wrapNegativeTerm(term) {
|
||
|
|
// Special case when we have a numeric term, otherwise we wrap it in a parenthetical.
|
||
|
|
if ( term.class === "NumericTerm" ) {
|
||
|
|
term.number *= -1;
|
||
|
|
term.formula = `-${term.formula}`;
|
||
|
|
return term;
|
||
|
|
}
|
||
|
|
|
||
|
|
return foundry.dice.RollGrammar.parse(`(${term.formula} * -1)`, { parser: this.constructor });
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Tree Manipulation */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Flatten a tree structure (either a parse tree or AST) into an array with operators in infix notation.
|
||
|
|
* @param {RollParseNode} root The root of the tree.
|
||
|
|
* @returns {RollParseNode[]}
|
||
|
|
*/
|
||
|
|
static flattenTree(root) {
|
||
|
|
const list = [];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Flatten the given node.
|
||
|
|
* @param {RollParseNode} node The node.
|
||
|
|
*/
|
||
|
|
function flattenNode(node) {
|
||
|
|
if ( node.class !== "Node" ) {
|
||
|
|
list.push(node);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const [left, right] = node.operands;
|
||
|
|
flattenNode(left);
|
||
|
|
list.push({ class: "OperatorTerm", operator: node.operator, evaluated: false });
|
||
|
|
flattenNode(right);
|
||
|
|
}
|
||
|
|
|
||
|
|
flattenNode(root);
|
||
|
|
return list;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Use the Shunting Yard algorithm to convert a parse tree or list of terms into an AST with correct operator
|
||
|
|
* precedence.
|
||
|
|
* See https://en.wikipedia.org/wiki/Shunting_yard_algorithm for a description of the algorithm in detail.
|
||
|
|
* @param {RollParseNode|RollTerm[]} root The root of the parse tree or a list of terms.
|
||
|
|
* @returns {RollParseNode} The root of the AST.
|
||
|
|
*/
|
||
|
|
static toAST(root) {
|
||
|
|
// Flatten the parse tree to an array representing the original formula in infix notation.
|
||
|
|
const list = Array.isArray(root) ? root : this.flattenTree(root);
|
||
|
|
const operators = [];
|
||
|
|
const output = [];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pop operators from the operator stack and push them onto the output stack until we reach an operator with lower
|
||
|
|
* or equal precedence and left-associativity.
|
||
|
|
* @param {RollParseNode} op The target operator to push.
|
||
|
|
*/
|
||
|
|
function pushOperator(op) {
|
||
|
|
let peek = operators.at(-1);
|
||
|
|
// We assume all our operators are left-associative, so we only check if the precedence is lower or equal here.
|
||
|
|
while ( peek && ((OperatorTerm.PRECEDENCE[peek.operator] ?? 0) >= (OperatorTerm.PRECEDENCE[op.operator] ?? 0)) ) {
|
||
|
|
output.push(operators.pop());
|
||
|
|
peek = operators.at(-1);
|
||
|
|
}
|
||
|
|
operators.push(op);
|
||
|
|
}
|
||
|
|
|
||
|
|
for ( const node of list ) {
|
||
|
|
// If this is an operator, push it onto the operators stack.
|
||
|
|
if ( this.isOperatorTerm(node) ) {
|
||
|
|
pushOperator(node);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Recursively reorganize inner terms to AST sub-trees.
|
||
|
|
if ( node.class === "ParentheticalTerm" ) node.term = this.toAST(node.term);
|
||
|
|
else if ( (node.class === "FunctionTerm") || (node.class === "PoolTerm") ) {
|
||
|
|
node.terms = node.terms.map(term => this.toAST(term));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Push the node onto the output stack.
|
||
|
|
output.push(node);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pop remaining operators off the operator stack and onto the output stack.
|
||
|
|
while ( operators.length ) output.push(operators.pop());
|
||
|
|
|
||
|
|
// The output now contains the formula in postfix notation, with correct operator precedence applied. We recombine
|
||
|
|
// it into a tree by matching each postfix operator with two operands.
|
||
|
|
const ast = [];
|
||
|
|
for ( const node of output ) {
|
||
|
|
if ( !this.isOperatorTerm(node) ) {
|
||
|
|
ast.push(node);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const right = ast.pop();
|
||
|
|
const left = ast.pop();
|
||
|
|
ast.push({ class: "Node", operator: node.operator, operands: [left, right] });
|
||
|
|
}
|
||
|
|
|
||
|
|
// The postfix array has been recombined into an array of one element, which is the root of the new AST.
|
||
|
|
return ast.pop();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determine if a given node is an operator term.
|
||
|
|
* @param {RollParseNode|RollTerm} node
|
||
|
|
*/
|
||
|
|
static isOperatorTerm(node) {
|
||
|
|
return (node instanceof OperatorTerm) || (node.class === "OperatorTerm");
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Debug Formatting */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a list argument.
|
||
|
|
* @param {RollParseArg[]} list The list to format.
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
static formatList(list) {
|
||
|
|
if ( !list ) return "[]";
|
||
|
|
return `[${list.map(RollParser.formatArg).join(", ")}]`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a parser argument.
|
||
|
|
* @param {RollParseArg} arg The argument.
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
static formatArg(arg) {
|
||
|
|
switch ( getType(arg) ) {
|
||
|
|
case "null": return "null";
|
||
|
|
case "number": return `${arg}`;
|
||
|
|
case "string": return `"${arg}"`;
|
||
|
|
case "Object": return arg.class;
|
||
|
|
case "Array": return RollParser.formatList(arg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format arguments for debugging.
|
||
|
|
* @param {string} method The method name.
|
||
|
|
* @param {...RollParseArg} args The arguments.
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
static formatDebug(method, ...args) {
|
||
|
|
return `${method}(${args.map(RollParser.formatArg).join(", ")})`;
|
||
|
|
}
|
||
|
|
}
|