@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
374 lines (373 loc) • 13.3 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
Object.defineProperty(exports, "__esModule", { value: true });
/**
* Evaluates Molang expressions against an entity context.
*
* Design: stateless evaluator — create once, call evaluate() many times with
* different expressions and contexts. Array definitions are passed per-call
* since they come from the render controller, not the entity.
*/
class MolangEvaluator {
/**
* Evaluate a Molang expression string and return the result.
*
* @param expression The Molang expression (e.g., "query.is_baby ? Texture.baby : Texture.default")
* @param context Entity state context (query values, variables)
* @param arrays Optional array definitions from render controller (e.g., {"Array.geos": ["Geometry.default", "Geometry.sheared"]})
* @returns The evaluated result — a number or a string reference
*/
evaluate(expression, context, arrays) {
const trimmed = expression.trim();
if (trimmed.length === 0) {
return 0;
}
const parser = new ExpressionParser(trimmed, context, arrays);
return parser.parseTernary();
}
/**
* Evaluate a Molang expression and return only the numeric result.
* String references resolve to 0.
*/
evaluateNumber(expression, context, arrays) {
const result = this.evaluate(expression, context, arrays);
return typeof result === "number" ? result : 0;
}
/**
* Evaluate a Molang expression and return only the string result.
* Numeric results resolve to empty string.
*/
evaluateString(expression, context, arrays) {
const result = this.evaluate(expression, context, arrays);
return typeof result === "string" ? result : "";
}
}
exports.default = MolangEvaluator;
/**
* Recursive descent parser/evaluator for a single Molang expression.
* Handles operator precedence via grammar rules:
*
* ternary → logicalOr ('?' ternary ':' ternary)?
* logicalOr → logicalAnd ('||' logicalAnd)*
* logicalAnd → equality ('&&' equality)*
* equality → comparison (('==' | '!=') comparison)*
* comparison → additive (('<' | '>' | '<=' | '>=') additive)*
* additive → multiplicative (('+' | '-') multiplicative)*
* multiplicative → unary (('*' | '/') unary)*
* unary → '!' unary | primary
* primary → NUMBER | REFERENCE | '(' ternary ')' | arrayAccess
*/
class ExpressionParser {
_expr;
_pos;
_context;
_arrays;
constructor(expr, context, arrays) {
this._expr = expr;
this._pos = 0;
this._context = context;
this._arrays = arrays;
}
parseTernary() {
const condition = this.parseLogicalOr();
this.skipWhitespace();
if (this.peek() === "?") {
this.advance(); // skip '?'
const trueBranch = this.parseTernary();
this.skipWhitespace();
this.expect(":");
const falseBranch = this.parseTernary();
return this.isTruthy(condition) ? trueBranch : falseBranch;
}
return condition;
}
parseLogicalOr() {
let left = this.parseLogicalAnd();
this.skipWhitespace();
while (this.matchStr("||")) {
const right = this.parseLogicalAnd();
left = (this.isTruthy(left) || this.isTruthy(right)) ? 1 : 0;
}
return left;
}
parseLogicalAnd() {
let left = this.parseEquality();
this.skipWhitespace();
while (this.matchStr("&&")) {
const right = this.parseEquality();
left = (this.isTruthy(left) && this.isTruthy(right)) ? 1 : 0;
}
return left;
}
parseEquality() {
let left = this.parseComparison();
this.skipWhitespace();
if (this.matchStr("==")) {
const right = this.parseComparison();
return this.toNumber(left) === this.toNumber(right) ? 1 : 0;
}
if (this.matchStr("!=")) {
const right = this.parseComparison();
return this.toNumber(left) !== this.toNumber(right) ? 1 : 0;
}
return left;
}
parseComparison() {
let left = this.parseAdditive();
this.skipWhitespace();
if (this.matchStr("<=")) {
return this.toNumber(left) <= this.toNumber(this.parseAdditive()) ? 1 : 0;
}
if (this.matchStr(">=")) {
return this.toNumber(left) >= this.toNumber(this.parseAdditive()) ? 1 : 0;
}
if (this.matchStr("<")) {
return this.toNumber(left) < this.toNumber(this.parseAdditive()) ? 1 : 0;
}
if (this.matchStr(">")) {
return this.toNumber(left) > this.toNumber(this.parseAdditive()) ? 1 : 0;
}
return left;
}
parseAdditive() {
let left = this.parseMultiplicative();
this.skipWhitespace();
while (true) {
if (this.matchStr("+")) {
left = this.toNumber(left) + this.toNumber(this.parseMultiplicative());
}
else if (this.matchStr("-")) {
left = this.toNumber(left) - this.toNumber(this.parseMultiplicative());
}
else {
break;
}
this.skipWhitespace();
}
return left;
}
parseMultiplicative() {
let left = this.parseUnary();
this.skipWhitespace();
while (true) {
if (this.matchStr("*")) {
left = this.toNumber(left) * this.toNumber(this.parseUnary());
}
else if (this.matchStr("/")) {
const right = this.toNumber(this.parseUnary());
left = right !== 0 ? this.toNumber(left) / right : 0;
}
else {
break;
}
this.skipWhitespace();
}
return left;
}
parseUnary() {
this.skipWhitespace();
if (this.matchStr("!")) {
const val = this.parseUnary();
return this.isTruthy(val) ? 0 : 1;
}
return this.parsePrimary();
}
parsePrimary() {
this.skipWhitespace();
// Parenthesized expression
if (this.peek() === "(") {
this.advance();
const val = this.parseTernary();
this.skipWhitespace();
if (this.peek() === ")") {
this.advance();
}
return val;
}
// Number literal (including negative and decimal)
if (this.isDigit(this.peek()) || (this.peek() === "-" && this.isDigit(this.peekAt(1)))) {
return this.parseNumber();
}
// Identifier/reference (query.x, variable.y, Texture.default, Array.geos, etc.)
if (this.isIdentStart(this.peek())) {
return this.parseReference();
}
// Single-quoted string literal
if (this.peek() === "'") {
return this.parseStringLiteral();
}
// Unknown — return 0
return 0;
}
parseNumber() {
let numStr = "";
if (this.peek() === "-") {
numStr += "-";
this.advance();
}
while (this._pos < this._expr.length && (this.isDigit(this.peek()) || this.peek() === ".")) {
numStr += this.peek();
this.advance();
}
return parseFloat(numStr) || 0;
}
parseStringLiteral() {
this.advance(); // skip opening '
let str = "";
while (this._pos < this._expr.length && this.peek() !== "'") {
str += this.peek();
this.advance();
}
if (this.peek() === "'") {
this.advance(); // skip closing '
}
return str;
}
parseReference() {
let ident = this.parseIdentifier();
// Expand short forms
if (ident === "q")
ident = "query";
else if (ident === "v")
ident = "variable";
else if (ident === "t")
ident = "temp";
else if (ident === "c")
ident = "context";
// Check for dot-notation: query.is_baby, Texture.default, Array.geos
if (this.peek() === ".") {
this.advance(); // skip '.'
const member = this.parseIdentifier();
const fullRef = ident + "." + member;
// Handle query lookups
if (ident === "query") {
return this._context.queries.get(fullRef) ?? this._context.queries.get("query." + member) ?? 0;
}
// Handle variable lookups
if (ident === "variable") {
return this._context.variables.get(fullRef) ?? this._context.variables.get("variable." + member) ?? 0;
}
// Handle temp lookups
if (ident === "temp") {
return this._context.temps.get(fullRef) ?? this._context.temps.get("temp." + member) ?? 0;
}
// Handle Array access: Array.geos[index]
if (ident === "Array" || ident === "array") {
this.skipWhitespace();
if (this.peek() === "[") {
this.advance(); // skip '['
const indexVal = this.parseTernary();
this.skipWhitespace();
if (this.peek() === "]") {
this.advance(); // skip ']'
}
return this.resolveArrayAccess(fullRef, this.toNumber(indexVal));
}
// Array reference without index — return as string
return fullRef;
}
// Anything else (Texture.default, Geometry.baby, Material.body) — return as string reference
return fullRef;
}
// Check for function call: math.sin(x) — simplified, just skip args for now
if (this.peek() === "(") {
// For now, evaluate function arguments but return 0 for unsupported functions
this.advance(); // skip '('
if (this.peek() !== ")") {
this.parseTernary(); // evaluate argument (discard result)
}
if (this.peek() === ")") {
this.advance(); // skip ')'
}
return 0;
}
// Bare identifier — try as variable
const queryVal = this._context.queries.get("query." + ident);
if (queryVal !== undefined)
return queryVal;
const varVal = this._context.variables.get("variable." + ident);
if (varVal !== undefined)
return varVal;
// Could be "this" keyword (passthrough in color context)
if (ident === "this")
return "this";
return ident;
}
resolveArrayAccess(arrayName, index) {
if (!this._arrays)
return arrayName;
const arr = this._arrays.get(arrayName);
if (!arr || arr.length === 0)
return arrayName;
// Molang uses integer index, clamp to valid range
const idx = Math.max(0, Math.min(Math.floor(index), arr.length - 1));
return arr[idx];
}
parseIdentifier() {
let id = "";
while (this._pos < this._expr.length && this.isIdentChar(this.peek())) {
id += this.peek();
this.advance();
}
return id;
}
// -- Helpers --
isTruthy(val) {
if (typeof val === "number")
return val !== 0;
if (typeof val === "string")
return val.length > 0;
return false;
}
toNumber(val) {
if (typeof val === "number")
return val;
return 0;
}
peek() {
return this._pos < this._expr.length ? this._expr[this._pos] : "";
}
peekAt(offset) {
const idx = this._pos + offset;
return idx < this._expr.length ? this._expr[idx] : "";
}
advance() {
this._pos++;
}
skipWhitespace() {
while (this._pos < this._expr.length && /\s/.test(this._expr[this._pos])) {
this._pos++;
}
}
matchStr(s) {
this.skipWhitespace();
if (this._expr.substring(this._pos, this._pos + s.length) === s) {
// For multi-char operators, make sure next char isn't part of a longer operator
if (s.length === 1 && (s === "<" || s === ">" || s === "!")) {
const next = this.peekAt(s.length);
if (next === "=")
return false;
}
this._pos += s.length;
return true;
}
return false;
}
expect(s) {
this.skipWhitespace();
if (this._expr.substring(this._pos, this._pos + s.length) === s) {
this._pos += s.length;
}
// Silently skip if missing — defensive for malformed expressions
}
isDigit(ch) {
return ch >= "0" && ch <= "9";
}
isIdentStart(ch) {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
}
isIdentChar(ch) {
return this.isIdentStart(ch) || this.isDigit(ch) || ch === "_";
}
}