yarn-spinner-runner-ts
Version:
TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)
470 lines • 16.7 kB
JavaScript
/**
* Safe expression evaluator for Yarn Spinner conditions.
* Supports variables, functions, comparisons, and logical operators.
*/
export class ExpressionEvaluator {
constructor(variables = {}, functions = {}, enums = {} // enum name -> cases
) {
this.variables = variables;
this.functions = functions;
this.enums = enums;
this.smartVariables = {}; // variable name -> expression
}
/**
* Evaluate a condition expression and return a boolean result.
* Supports: variables, literals (numbers, strings, booleans), comparisons, logical ops, function calls.
*/
evaluate(expr) {
try {
const result = this.evaluateExpression(expr);
return !!result;
}
catch {
return false;
}
}
/**
* Evaluate an expression that can return any value (not just boolean).
*/
evaluateExpression(expr) {
const trimmed = this.preprocess(expr.trim());
if (!trimmed)
return false;
// Handle function calls like `functionName(arg1, arg2)`
if (this.looksLikeFunctionCall(trimmed)) {
return this.evaluateFunctionCall(trimmed);
}
// Handle comparisons
if (this.containsComparison(trimmed)) {
return this.evaluateComparison(trimmed);
}
// Handle logical operators
if (trimmed.includes("&&") || trimmed.includes("||")) {
return this.evaluateLogical(trimmed);
}
// Handle negation
if (trimmed.startsWith("!")) {
return !this.evaluateExpression(trimmed.slice(1).trim());
}
// Handle arithmetic expressions (+, -, *, /, %)
if (this.containsArithmetic(trimmed)) {
return this.evaluateArithmetic(trimmed);
}
// Simple variable or literal
return this.resolveValue(trimmed);
}
preprocess(expr) {
// Normalize operator word aliases to JS-like symbols
// Whole word replacements only
return expr
.replace(/\bnot\b/gi, "!")
.replace(/\band\b/gi, "&&")
.replace(/\bor\b/gi, "||")
.replace(/\bxor\b/gi, "^")
.replace(/\beq\b|\bis\b/gi, "==")
.replace(/\bneq\b/gi, "!=")
.replace(/\bgte\b/gi, ">=")
.replace(/\blte\b/gi, "<=")
.replace(/\bgt\b/gi, ">")
.replace(/\blt\b/gi, "<");
}
evaluateFunctionCall(expr) {
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
if (!match)
throw new Error(`Invalid function call: ${expr}`);
const [, name, argsStr] = match;
const func = this.functions[name];
if (!func)
throw new Error(`Function not found: ${name}`);
const args = this.parseArguments(argsStr);
const evaluatedArgs = args.map((arg) => this.evaluateExpression(arg.trim()));
return func(...evaluatedArgs);
}
parseArguments(argsStr) {
if (!argsStr.trim())
return [];
const args = [];
let depth = 0;
let current = "";
for (const char of argsStr) {
if (char === "(")
depth++;
else if (char === ")")
depth--;
else if (char === "," && depth === 0) {
args.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim())
args.push(current.trim());
return args;
}
containsComparison(expr) {
return /[<>=!]/.test(expr);
}
looksLikeFunctionCall(expr) {
return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
}
containsArithmetic(expr) {
// Remove quoted strings to avoid false positives on "-" or "+" inside literals
const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
return /[+\-*/%]/.test(unquoted);
}
evaluateArithmetic(expr) {
const input = expr;
let index = 0;
const skipWhitespace = () => {
while (index < input.length && /\s/.test(input[index])) {
index++;
}
};
const toNumber = (value) => {
if (typeof value === "number")
return value;
if (typeof value === "boolean")
return value ? 1 : 0;
if (value == null || value === "")
return 0;
const num = Number(value);
if (Number.isNaN(num)) {
throw new Error(`Cannot convert ${String(value)} to number`);
}
return num;
};
const readToken = () => {
skipWhitespace();
const start = index;
let depth = 0;
let inQuotes = false;
let quoteChar = "";
while (index < input.length) {
const char = input[index];
if (inQuotes) {
if (char === quoteChar) {
inQuotes = false;
quoteChar = "";
}
index++;
continue;
}
if (char === '"' || char === "'") {
inQuotes = true;
quoteChar = char;
index++;
continue;
}
if (char === "(") {
depth++;
index++;
continue;
}
if (char === ")") {
if (depth === 0)
break;
depth--;
index++;
continue;
}
if (depth === 0 && "+-*/%".includes(char)) {
break;
}
if (depth === 0 && /\s/.test(char)) {
break;
}
index++;
}
return input.slice(start, index).trim();
};
const parsePrimary = () => {
skipWhitespace();
if (index >= input.length) {
throw new Error("Unexpected end of expression");
}
const char = input[index];
if (char === "(") {
index++;
const value = parseAddSub();
skipWhitespace();
if (input[index] !== ")") {
throw new Error("Unmatched parenthesis in expression");
}
index++;
return value;
}
const token = readToken();
if (!token) {
throw new Error("Invalid expression token");
}
return this.evaluateExpression(token);
};
const parseUnary = () => {
skipWhitespace();
if (input[index] === "+") {
index++;
return parseUnary();
}
if (input[index] === "-") {
index++;
return -parseUnary();
}
return toNumber(parsePrimary());
};
const parseMulDiv = () => {
let value = parseUnary();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "*" || char === "/" || char === "%") {
index++;
const right = parseUnary();
if (char === "*") {
value = value * right;
}
else if (char === "/") {
value = value / right;
}
else {
value = value % right;
}
continue;
}
break;
}
return value;
};
const parseAddSub = () => {
let value = parseMulDiv();
while (true) {
skipWhitespace();
const char = input[index];
if (char === "+" || char === "-") {
index++;
const right = parseMulDiv();
if (char === "+") {
value = value + right;
}
else {
value = value - right;
}
continue;
}
break;
}
return value;
};
const result = parseAddSub();
skipWhitespace();
if (index < input.length) {
throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
}
return result;
}
evaluateComparison(expr) {
// Match comparison operators (avoid matching !=, <=, >=)
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
if (!match)
throw new Error(`Invalid comparison: ${expr}`);
const [, left, rawOp, right] = match;
const op = rawOp === "=" ? "==" : rawOp;
const leftVal = this.evaluateExpression(left.trim());
const rightVal = this.evaluateExpression(right.trim());
switch (op) {
case "===":
case "==":
return this.deepEquals(leftVal, rightVal);
case "!==":
case "!=":
return !this.deepEquals(leftVal, rightVal);
case "<":
return Number(leftVal) < Number(rightVal);
case ">":
return Number(leftVal) > Number(rightVal);
case "<=":
return Number(leftVal) <= Number(rightVal);
case ">=":
return Number(leftVal) >= Number(rightVal);
default:
throw new Error(`Unknown operator: ${op}`);
}
}
evaluateLogical(expr) {
// Split by && or ||, respecting parentheses
const parts = [];
let depth = 0;
let current = "";
let lastOp = null;
for (const char of expr) {
if (char === "(")
depth++;
else if (char === ")")
depth--;
else if (depth === 0 && expr.includes(char === "&" ? "&&" : char === "|" ? "||" : "")) {
// Check for && or ||
const remaining = expr.slice(expr.indexOf(char));
if (remaining.startsWith("&&")) {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "&&";
// skip &&
continue;
}
else if (remaining.startsWith("||")) {
if (current.trim()) {
parts.push({ expr: current.trim(), op: lastOp });
current = "";
}
lastOp = "||";
// skip ||
continue;
}
}
current += char;
}
if (current.trim())
parts.push({ expr: current.trim(), op: lastOp });
// Simple case: single expression
if (parts.length === 0)
return !!this.evaluateExpression(expr);
// Evaluate parts (supports &&, ||, ^ as xor)
let result = this.evaluateExpression(parts[0].expr);
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const val = this.evaluateExpression(part.expr);
if (part.op === "&&") {
result = result && val;
}
else if (part.op === "||") {
result = result || val;
}
}
return !!result;
}
resolveValue(expr) {
// Try enum syntax: EnumName.CaseName or .CaseName
const enumMatch = expr.match(/^\.?([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (enumMatch) {
const [, enumName, caseName] = enumMatch;
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`; // Store as "EnumName.CaseName" string
}
}
// Try shorthand enum: .CaseName (requires context from variables)
if (expr.startsWith(".") && expr.length > 1) {
// Try to infer enum from variable types - for now, return as-is and let validation handle it
return expr;
}
// Try as variable first
const key = expr.startsWith("$") ? expr.slice(1) : expr;
// Check if this is a smart variable (has stored expression)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, key)) {
// Re-evaluate the expression each time it's accessed
return this.evaluateExpression(this.smartVariables[key]);
}
if (Object.prototype.hasOwnProperty.call(this.variables, key)) {
return this.variables[key];
}
// Try as number
const num = Number(expr);
if (!isNaN(num) && expr.trim() === String(num)) {
return num;
}
// Try as boolean
if (expr === "true")
return true;
if (expr === "false")
return false;
// Try as string (quoted)
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1);
}
// Default: treat as variable (may be undefined)
return this.variables[key];
}
/**
* Resolve shorthand enum (.CaseName) when setting a variable with known enum type
*/
resolveEnumValue(expr, enumName) {
if (expr.startsWith(".") && enumName) {
const caseName = expr.slice(1);
if (this.enums[enumName] && this.enums[enumName].includes(caseName)) {
return `${enumName}.${caseName}`;
}
throw new Error(`Invalid enum case ${caseName} for enum ${enumName}`);
}
// Check if it's already EnumName.CaseName format
const match = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
if (match) {
const [, name, caseName] = match;
if (this.enums[name] && this.enums[name].includes(caseName)) {
return expr;
}
throw new Error(`Invalid enum case ${caseName} for enum ${name}`);
}
return expr;
}
/**
* Get enum type for a variable (if it was declared with enum type)
*/
getEnumTypeForVariable(varName) {
// Check if variable value matches EnumName.CaseName pattern
const key = varName.startsWith("$") ? varName.slice(1) : varName;
const value = this.variables[key];
if (typeof value === "string") {
const match = value.match(/^([A-Za-z_][A-Za-z0-9_]*)\./);
if (match) {
return match[1];
}
}
return undefined;
}
deepEquals(a, b) {
if (a === b)
return true;
if (a == null || b == null)
return a === b;
if (typeof a !== typeof b)
return false;
if (typeof a === "object") {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}
/**
* Update variables. Can be used to mutate state during dialogue.
*/
setVariable(name, value) {
// If setting a smart variable, remove it (converting to regular variable)
if (Object.prototype.hasOwnProperty.call(this.smartVariables, name)) {
delete this.smartVariables[name];
}
this.variables[name] = value;
}
/**
* Register a smart variable (variable with expression that recalculates on access).
*/
setSmartVariable(name, expression) {
// Remove from regular variables if it exists
if (Object.prototype.hasOwnProperty.call(this.variables, name)) {
delete this.variables[name];
}
this.smartVariables[name] = expression;
}
/**
* Check if a variable is a smart variable.
*/
isSmartVariable(name) {
return Object.prototype.hasOwnProperty.call(this.smartVariables, name);
}
/**
* Get variable value.
*/
getVariable(name) {
return this.variables[name];
}
}
//# sourceMappingURL=evaluator.js.map