UNPKG

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)

504 lines (441 loc) 14.9 kB
/** * Safe expression evaluator for Yarn Spinner conditions. * Supports variables, functions, comparisons, and logical operators. */ export class ExpressionEvaluator { private smartVariables: Record<string, string> = {}; // variable name -> expression constructor( private variables: Record<string, unknown> = {}, private functions: Record<string, (...args: unknown[]) => unknown> = {}, private enums: Record<string, string[]> = {} // enum name -> cases ) {} /** * Evaluate a condition expression and return a boolean result. * Supports: variables, literals (numbers, strings, booleans), comparisons, logical ops, function calls. */ evaluate(expr: string): boolean { try { const result = this.evaluateExpression(expr); return !!result; } catch { return false; } } /** * Evaluate an expression that can return any value (not just boolean). */ evaluateExpression(expr: string): unknown { 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); } private preprocess(expr: string): string { // 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, "<"); } private evaluateFunctionCall(expr: string): unknown { 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); } private parseArguments(argsStr: string): string[] { if (!argsStr.trim()) return []; const args: string[] = []; 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; } private containsComparison(expr: string): boolean { return /[<>=!]/.test(expr); } private looksLikeFunctionCall(expr: string): boolean { return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr); } private containsArithmetic(expr: string): boolean { // Remove quoted strings to avoid false positives on "-" or "+" inside literals const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, ""); return /[+\-*/%]/.test(unquoted); } private evaluateArithmetic(expr: string): number { const input = expr; let index = 0; const skipWhitespace = () => { while (index < input.length && /\s/.test(input[index])) { index++; } }; const toNumber = (value: unknown): number => { 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 = (): string => { 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 = (): unknown => { 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 = (): number => { skipWhitespace(); if (input[index] === "+") { index++; return parseUnary(); } if (input[index] === "-") { index++; return -parseUnary(); } return toNumber(parsePrimary()); }; const parseMulDiv = (): number => { 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 = (): number => { 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; } private evaluateComparison(expr: string): boolean { // 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}`); } } private evaluateLogical(expr: string): boolean { // Split by && or ||, respecting parentheses const parts: Array<{ expr: string; op: "&&" | "||" | null }> = []; let depth = 0; let current = ""; let lastOp: "&&" | "||" | null = 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; } private resolveValue(expr: string): unknown { // 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: string, enumName?: string): string { 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: string): string | undefined { // 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; } private deepEquals(a: unknown, b: unknown): boolean { 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: string, value: unknown): void { // 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: string, expression: string): void { // 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: string): boolean { return Object.prototype.hasOwnProperty.call(this.smartVariables, name); } /** * Get variable value. */ getVariable(name: string): unknown { return this.variables[name]; } }