Files
2025-01-04 00:34:03 +01:00

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(", ")})`;
}
}