From 130b89e442661f8293abdf0bb59ec9fcf0ff464d Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:33:23 +0700 Subject: [PATCH 01/13] Speed up interpreted calls to Java bindings ReflectiveFunction called MethodHandle.invokeWithArguments, which builds a new spreader on every call. The handle is now adapted once to (Object[])Object and called with invokeExact. MochaMath registered a function for every math binding, but it didn't override set or entries(), so nothing was stored and JavaObjectBinding found no backing functions: every interpreted math.* call went through reflection. The functions are now stored and exposed as backing functions, with the same purity as their bindings. pi and e stay out of entries() so their fields are still inlined as constants. warnOnReflectiveFunctionUsage printed a line on every call, including calls that don't use reflection. It now reports each reflectively called method once. JMH, JDK 25, interpreting pre-parsed expressions (Mocha 3.0.1 / before / after): math.sin(v.t * 50) * 45 + math.cos(v.t * 20) 240 / 304 / 132 ns jq.speed(v.t) + 1 (instance @Binding method) 137 / 174 / 106 ns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/runtime/ExpressionInterpreter.java | 10 +- .../mocha/runtime/MolangInterpreter.java | 2 +- .../mocha/runtime/binding/JavaFunction.java | 10 ++ .../runtime/binding/ReflectiveFunction.java | 8 +- .../mocha/runtime/standard/MochaMath.java | 80 +++++++++++- .../mocha/runtime/ReflectiveWarningTest.java | 76 +++++++++++ .../standard/MochaMathBindingTest.java | 119 ++++++++++++++++++ 7 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/ReflectiveWarningTest.java create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/standard/MochaMathBindingTest.java diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java index 084da29..47fb128 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java @@ -30,13 +30,19 @@ import org.redlance.mocha.runtime.binding.JavaFunction; import org.redlance.mocha.runtime.value.*; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import static java.util.Objects.requireNonNull; @ApiStatus.Internal public final class ExpressionInterpreter implements ExpressionVisitor, ExecutionContext { + // methods already reported by warnOnReflectiveFunctionUsage, each one is reported once + private static final Set REPORTED_REFLECTIVE_METHODS = ConcurrentHashMap.newKeySet(); + private static final List BINARY_EVALUATORS = Arrays.asList( bool((a, b) -> a.eval() && b.eval()), bool((a, b) -> a.eval() || b.eval()), @@ -300,8 +306,8 @@ public T entity() { return Value.nil(); } - if (warnOnReflectiveFunctionUsage && function instanceof JavaFunction) { - final JavaFunction javaFunction = (JavaFunction) function; + if (warnOnReflectiveFunctionUsage && function instanceof JavaFunction javaFunction + && javaFunction.reflective() && REPORTED_REFLECTIVE_METHODS.add(javaFunction.method())) { System.err.println("Warning: Reflective function usage detected for method: " + javaFunction.method()); } diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/MolangInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/MolangInterpreter.java index 1f001d4..7b35834 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/MolangInterpreter.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/MolangInterpreter.java @@ -357,7 +357,7 @@ public boolean warnOnReflectiveFunctionUsage() { *

When set to true, {@link #eval} may log a warning when evaluating * code that includes a call to a function that was registered using only * annotations and therefore has to be invoked via Reflection, taking some - * extra time.

+ * extra time. Each method is reported once.

* *

Note that this behavior can be avoided by setting an * {@link ObjectValue} when binding static or non-static methods and fields.

diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaFunction.java b/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaFunction.java index 8abe37f..cd81bf9 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaFunction.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaFunction.java @@ -66,6 +66,16 @@ private void evaluate() { return method; } + /** + * Returns whether calls to this function are made through + * reflection, because no generic function was given for it. + * + * @return If this function is invoked reflectively. + */ + public boolean reflective() { + return function instanceof ReflectiveFunction; + } + @Override public @Nullable Value evaluate(final @NotNull ExecutionContext context, final @NotNull Arguments arguments) { return function.evaluate(context, arguments); diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java b/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java index ab82a88..9e36c4a 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java @@ -61,9 +61,11 @@ final class ReflectiveFunction implements Function { return; } try { - MethodHandle handle = lookup.unreflect(method); + MethodHandle handle = lookup.unreflect(method).asFixedArity(); if (object != null) handle = handle.bindTo(object); - this.mh = handle; + // adapt once to (Object[])Object, invokeWithArguments would build this spreader on every call + this.mh = handle.asType(handle.type().generic()) + .asSpreader(Object[].class, handle.type().parameterCount()); } catch (IllegalAccessException e) { throw new RuntimeException(e); } @@ -167,7 +169,7 @@ final class ReflectiveFunction implements Function { } try { - return of(mh != null ? mh.invokeWithArguments(values) : method.invoke(object, values)); + return of(mh != null ? (Object) mh.invokeExact(values) : method.invoke(object, values)); } catch (final Throwable throwable) { throw new RuntimeException(throwable); } diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java index ad4c2cb..7911b67 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java @@ -25,15 +25,23 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.redlance.mocha.runtime.ExecutionContext; import org.redlance.mocha.runtime.binding.BindExternalFunction; import org.redlance.mocha.runtime.binding.Binding; +import org.redlance.mocha.runtime.value.Function; import org.redlance.mocha.runtime.value.NumberValue; import org.redlance.mocha.runtime.value.ObjectProperty; import org.redlance.mocha.runtime.value.ObjectValue; import org.redlance.mocha.runtime.util.CaseInsensitiveStringHashMap; +import org.redlance.mocha.runtime.value.Value; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.Map; import java.util.Random; +import java.util.Set; /** * Math function bindings inside an object @@ -55,7 +63,20 @@ public final class MochaMath implements ObjectValue { private static final Random RANDOM = new Random(); private static final int DECIMAL_PART = 4; + // the functions below back the Java bindings of this class, and + // JavaObjectBinding.of requires a backing function to be exactly as pure as its binding + private static final Set PURE_FUNCTIONS = pureFunctionNames(); + + // kept out of entries(): a backing value would stop JavaObjectBinding from inlining the fields + private static final Map CONSTANTS = new CaseInsensitiveStringHashMap<>(); + + static { + CONSTANTS.put("pi", ObjectProperty.property(NumberValue.of(PI), true)); + CONSTANTS.put("e", ObjectProperty.property(NumberValue.of(E), true)); + } + private final Map entries = new CaseInsensitiveStringHashMap<>(); + private boolean frozen; public MochaMath() { setFunction("abs", Math::abs); @@ -80,8 +101,6 @@ public MochaMath() { setFunction("min", Math::min); setFunction("min_angle", MochaMath::minAngle); setFunction("mod", MochaMath::mod); - entries.put("pi", ObjectProperty.property(NumberValue.of(Math.PI), true)); - entries.put("e", ObjectProperty.property(NumberValue.of(Math.E), true)); setFunction("pow", MochaMath::pow); setFunction("random", MochaMath::random); setFunction("random_integer", MochaMath::randomInteger); @@ -94,7 +113,23 @@ public MochaMath() { setFunction("r2d", MochaMath::r2d); // all of our properties are constant - entries.replaceAll((key, property) -> ObjectProperty.property(property.value(), true)); + frozen = true; + } + + private static @NotNull Set pureFunctionNames() { + final Set names = new HashSet<>(); + for (final Method method : MochaMath.class.getDeclaredMethods()) { + final Binding binding = method.getDeclaredAnnotation(Binding.class); + if (binding != null && binding.pure()) { + names.addAll(Arrays.asList(binding.value())); + } + } + for (final BindExternalFunction function : MochaMath.class.getAnnotationsByType(BindExternalFunction.class)) { + if (function.pure()) { + names.add(function.as().isEmpty() ? function.name() : function.as()); + } + } + return names; } @Binding(value = "ceil", pure = true) @@ -276,6 +311,43 @@ public static float r2d(final float value) { @Override public @Nullable ObjectProperty getProperty(final @NotNull String name) { - return entries.get(name); + final ObjectProperty property = entries.get(name); + return property != null ? property : CONSTANTS.get(name); + } + + @Override + public boolean set(final @NotNull String name, final @Nullable Value value) { + if (frozen || value == null) { + return false; + } + final Value function = value instanceof Function && PURE_FUNCTIONS.contains(name) + ? new PureFunction((Function) value) + : value; + entries.put(name, ObjectProperty.property(function, true)); + return true; + } + + @Override + public @NotNull Map entries() { + return Collections.unmodifiableMap(entries); + } + + private static final class PureFunction implements Function { + private final Function function; + + @SuppressWarnings("unchecked") + PureFunction(final @NotNull Function function) { + this.function = (Function) function; + } + + @Override + public @Nullable Value evaluate(final @NotNull ExecutionContext context, final @NotNull Arguments arguments) { + return function.evaluate(context, arguments); + } + + @Override + public boolean pure() { + return true; + } } } diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/ReflectiveWarningTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/ReflectiveWarningTest.java new file mode 100644 index 0000000..c2a426b --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/ReflectiveWarningTest.java @@ -0,0 +1,76 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.binding.Binding; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code warnOnReflectiveFunctionUsage} reports each reflectively called + * method once, and never reports functions that don't use reflection. + */ +class ReflectiveWarningTest { + @Test + void reported_once_and_only_for_reflection() { + final MolangInterpreter molang = MolangInterpreter.standard(); + molang.warnOnReflectiveFunctionUsage(true); + molang.bind(OnlyAnnotated.class); + + final String output = captureErr(() -> { + for (int i = 0; i < 3; i++) { + assertEquals(10F, molang.eval("only_annotated.twice(5)")); + assertEquals(1F, molang.eval("math.sin(90) * math.cos(0)")); + } + }); + + assertEquals(1, output.lines().count(), output); + assertEquals(true, output.contains("OnlyAnnotated.twice"), output); + } + + private static String captureErr(final Runnable runnable) { + final PrintStream original = System.err; + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + System.setErr(new PrintStream(buffer, true, StandardCharsets.UTF_8)); + try { + runnable.run(); + } finally { + System.setErr(original); + } + return buffer.toString(StandardCharsets.UTF_8); + } + + @Binding("only_annotated") + public static final class OnlyAnnotated { + @Binding("twice") + public static float twice(final float value) { + return value * 2; + } + } +} diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/standard/MochaMathBindingTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/standard/MochaMathBindingTest.java new file mode 100644 index 0000000..646ce3b --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/standard/MochaMathBindingTest.java @@ -0,0 +1,119 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime.standard; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.MolangInterpreter; +import org.redlance.mocha.runtime.binding.BindExternalFunction; +import org.redlance.mocha.runtime.binding.Binding; +import org.redlance.mocha.runtime.binding.JavaFunction; +import org.redlance.mocha.runtime.value.Function; +import org.redlance.mocha.runtime.value.NumberValue; +import org.redlance.mocha.runtime.value.ObjectProperty; +import org.redlance.mocha.runtime.value.ObjectValue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The interpreter must call the math functions through the functions + * registered in {@link MochaMath}'s constructor, not through reflection. + */ +class MochaMathBindingTest { + private static ObjectValue math() { + return (ObjectValue) MolangInterpreter.standard().scope().get("math"); + } + + private static List functionNames() { + final List names = new ArrayList<>(); + for (final Method method : MochaMath.class.getDeclaredMethods()) { + final Binding binding = method.getDeclaredAnnotation(Binding.class); + if (binding != null && Modifier.isStatic(method.getModifiers())) { + names.addAll(Arrays.asList(binding.value())); + } + } + for (final BindExternalFunction function : MochaMath.class.getAnnotationsByType(BindExternalFunction.class)) { + names.add(function.as().isEmpty() ? function.name() : function.as()); + } + return names; + } + + @Test + void functions_are_not_reflective() { + final ObjectValue math = math(); + for (final String name : functionNames()) { + final JavaFunction function = assertInstanceOf(JavaFunction.class, math.get(name), name); + assertFalse(function.reflective(), () -> "math." + name + " is called through reflection"); + } + } + + @Test + void purity_is_kept() { + final ObjectValue math = math(); + assertTrue(((Function) math.get("sin")).pure()); + assertTrue(((Function) math.get("abs")).pure()); + assertFalse(((Function) math.get("random")).pure()); + assertFalse(((Function) math.get("die_roll")).pure()); + } + + @Test + void constants_stay_inlineable() { + final ObjectValue math = math(); + final ObjectProperty pi = math.getProperty("pi"); + assertNotNull(pi); + assertTrue(pi.constant()); + assertEquals(NumberValue.of(MochaMath.PI), pi.value()); + assertTrue(math.getProperty("e").constant()); + } + + @Test + void results() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(1F, molang.eval("math.sin(90)")); + assertEquals(-30F, molang.eval("-math.sin(90) * 30")); + assertEquals(3F, molang.eval("math.abs(-3)")); + assertEquals(2F, molang.eval("math.max(1, 2)")); + assertEquals(3F, molang.eval("math.round(2.5)")); + assertEquals(1024F, molang.eval("math.pow(2, 10)")); + assertEquals(0.25F, molang.eval("math.inverse_lerp(0, 10, 2.5)")); + assertEquals(MochaMath.PI, molang.eval("math.pi")); + } + + @Test + void standalone_object() { + final MochaMath math = new MochaMath(); + assertEquals(NumberValue.of(MochaMath.PI), math.get("PI")); + assertInstanceOf(Function.class, math.get("sin")); + assertFalse(math.set("sin", NumberValue.zero()), "math can't be changed after construction"); + } +} From 96e2a325656eda8e2061fad0b529d3b26a459ef3 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:35:34 +0700 Subject: [PATCH 02/13] Pass ExecutionContext, Lazy and varargs arguments to Java bindings ReflectiveFunction wrapped these arguments in a JavaValue and then ran them through JavaTypes.convert, which returns null for anything but numbers, booleans and strings. ExecutionContext and Lazy parameters received null, varargs parameters received null (or a one-element array through invokeWithArguments), and primitive varargs threw a ClassCastException. Values wrapped in a JavaValue are now passed as they are when the parameter accepts them, varargs are collected into an array of the component type with each element converted, and a Lazy whose type argument is itself parameterized (Lazy>) no longer throws. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../runtime/binding/ReflectiveFunction.java | 35 +++-- .../binding/JavaBindingParametersTest.java | 132 ++++++++++++++++++ 2 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/binding/JavaBindingParametersTest.java diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java b/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java index 9e36c4a..c9c59a4 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java @@ -110,22 +110,28 @@ final class ReflectiveFunction implements Function { final Value value; if (i == parameters.length - 1 && method.isVarArgs()) { - // varargs + // varargs, collect the remaining arguments into an array of the component type final Class componentType = parameterType.getComponentType(); - final List varArgsValues = new ArrayList<>(); + final List varArgsValues = new ArrayList<>(); while (true) { final Argument argument = arguments.next(); if (argument.expression() == null) { break; } final Value object = argument.eval(); - if (componentType.isInstance(object)) { + if (object == null) { + varArgsValues.add(JavaTypes.getNullValueForType(componentType)); + } else if (componentType.isInstance(object)) { varArgsValues.add(object); } else { - varArgsValues.add(null); + varArgsValues.add(JavaTypes.convert(object, componentType)); } } - value = ArrayValue.of(varArgsValues.toArray(size -> (Value[]) Array.newInstance(componentType, size))); + final Object array = Array.newInstance(componentType, varArgsValues.size()); + for (int j = 0; j < varArgsValues.size(); j++) { + Array.set(array, j, varArgsValues.get(j)); + } + value = new JavaValue(array); } else if (parameterType == Lazy.class) { // use the Lazy argument as type, and pass the argument final Type genericParameterType = genericParameterTypes[i]; @@ -134,8 +140,11 @@ final class ReflectiveFunction implements Function { "Lazy parameter must be a parameterized type." ); } - parameterType = (Class) ((ParameterizedType) genericParameterType) - .getActualTypeArguments()[0]; + final Type typeArgument = ((ParameterizedType) genericParameterType).getActualTypeArguments()[0]; + // Lazy> has a parameterized type argument + parameterType = (Class) (typeArgument instanceof ParameterizedType parameterized + ? parameterized.getRawType() + : typeArgument); if (parameterType == ExecutionContext.class) { value = new JavaValue((Lazy>) () -> context); } else { @@ -159,12 +168,16 @@ final class ReflectiveFunction implements Function { value = argument.eval(); } + final Class declaredType = parameter.getType(); if (value == null) { - values[i] = JavaTypes.getNullValueForType(parameterType); - } else if (parameter.isAnnotationPresent(Entity.class)) { - values[i] = ((JavaValue) value).value(); + values[i] = JavaTypes.getNullValueForType(declaredType); + } else if (value instanceof JavaValue javaValue) { + // the execution context, the entity, a Lazy, the varargs array + // or a Java object, passed as is when the parameter accepts it + final Object object = javaValue.value(); + values[i] = declaredType.isInstance(object) ? object : JavaTypes.getNullValueForType(declaredType); } else { - values[i] = JavaTypes.convert(value, parameterType); + values[i] = JavaTypes.convert(value, declaredType); } } diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/binding/JavaBindingParametersTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/binding/JavaBindingParametersTest.java new file mode 100644 index 0000000..a9ef611 --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/binding/JavaBindingParametersTest.java @@ -0,0 +1,132 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime.binding; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.ExecutionContext; +import org.redlance.mocha.runtime.MolangInterpreter; +import org.redlance.mocha.runtime.value.Value; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Parameters of interpreted Java bindings that don't take a plain + * MoLang value: the execution context, the entity, lazy arguments + * and varargs. + */ +class JavaBindingParametersTest { + private static MolangInterpreter interpreter() { + final MolangInterpreter molang = MolangInterpreter.standard("steve"); + molang.bind(Parameters.class); + return molang; + } + + @Test + void execution_context() { + assertEquals(1F, interpreter().eval("params.has_context()")); + assertEquals(15F, interpreter().eval("params.context_plus(5)")); + } + + @Test + void entity() { + assertEquals(5F, interpreter().eval("params.entity_length()")); + assertEquals(0F, interpreter().eval("params.wrong_entity_type()")); + } + + @Test + void lazy() { + assertEquals(7F, interpreter().eval("params.lazy(7)")); + assertEquals(1F, interpreter().eval("params.lazy_context()")); + } + + @Test + void varargs() { + assertEquals(6F, interpreter().eval("params.sum(1, 2, 3)")); + assertEquals(0F, interpreter().eval("params.sum()")); + assertEquals(3F, interpreter().eval("params.count(1, 'two', 3)")); + assertEquals(12F, interpreter().eval("params.scaled_sum(2, 1, 2, 3)")); + assertEquals(5F, interpreter().eval("params.joined_length('ab', 'cde')")); + } + + @Binding("params") + public static final class Parameters { + @Binding("has_context") + public static boolean hasContext(final ExecutionContext context) { + return context != null; + } + + @Binding("context_plus") + public static float contextPlus(final ExecutionContext context, final float value) { + return context == null ? value : value + 10; + } + + @Binding("entity_length") + public static int entityLength(final @Entity String entity) { + return entity == null ? -1 : entity.length(); + } + + @Binding("wrong_entity_type") + public static float wrongEntityType(final @Entity Integer entity) { + return entity == null ? 0 : 1; + } + + @Binding("lazy") + public static float lazy(final Lazy value) { + return value.get().getAsNumber(); + } + + @Binding("lazy_context") + public static boolean lazyContext(final Lazy> context) { + return context.get() != null; + } + + @Binding("sum") + public static float sum(final float... values) { + float sum = 0; + for (final float value : values) { + sum += value; + } + return sum; + } + + @Binding("count") + public static int count(final Value... values) { + return values.length; + } + + @Binding("scaled_sum") + public static double scaledSum(final double scale, final double... values) { + double sum = 0; + for (final double value : values) { + sum += value; + } + return sum * scale; + } + + @Binding("joined_length") + public static int joinedLength(final String... values) { + return String.join("", values).length(); + } + } +} From 6eeb55a732bf54f99d5f3ee1c69fe2b823eeb4a6 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:54:13 +0700 Subject: [PATCH 03/13] Bind a single @BindExternalFunction and read null function results as zero @BindExternalFunction is repeatable, and Java only wraps it into BindExternalFunction.Multiple when it's repeated. JavaObjectBinding only read the container, so a class with a single external function bound nothing. It now uses getDeclaredAnnotationsByType, which returns both. A bound Function returning null worked as a whole expression, but "q.nothing() + 1" threw a NullPointerException because visitCall passed the null on. It now reads as Value.nil(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/runtime/ExpressionInterpreter.java | 4 +- .../runtime/binding/JavaObjectBinding.java | 59 +++++++++--------- .../mocha/runtime/NullFunctionResultTest.java | 49 +++++++++++++++ .../runtime/binding/ExternalFunctionTest.java | 61 +++++++++++++++++++ 4 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/NullFunctionResultTest.java create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/binding/ExternalFunctionTest.java diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java index 47fb128..d731f1c 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java @@ -311,7 +311,9 @@ public T entity() { System.err.println("Warning: Reflective function usage detected for method: " + javaFunction.method()); } - return ((Function) function).evaluate(this, args); + // a function may return null for nothing, which must read as zero like Value.nil() + final Value result = ((Function) function).evaluate(this, args); + return result == null ? Value.nil() : result; } @Override diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaObjectBinding.java b/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaObjectBinding.java index b3cc0a7..be6fb1e 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaObjectBinding.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/binding/JavaObjectBinding.java @@ -73,42 +73,39 @@ private static T getBacking(final @Nullable Map backingProperties = backingObject != null ? backingObject.entries() : null; { - // check external bindings - final BindExternalFunction.Multiple annotation = clazz.getDeclaredAnnotation(BindExternalFunction.Multiple.class); - if (annotation != null) { - for (final BindExternalFunction externalFunctionBinding : annotation.value()) { - final Class atClass = externalFunctionBinding.at(); - final String methodName = externalFunctionBinding.name(); - final Class[] parameterTypes = externalFunctionBinding.args(); - - final Method method; - try { - method = atClass.getDeclaredMethod(methodName, parameterTypes); - } catch (final NoSuchMethodException e) { - throw new IllegalArgumentException("No method found with name " + methodName - + " and parameter types " + Arrays.toString(parameterTypes) + ". Declared as" - + " external binding for " + clazz, e); - } + // check external bindings, a single annotation isn't wrapped in BindExternalFunction.Multiple + for (final BindExternalFunction externalFunctionBinding : clazz.getDeclaredAnnotationsByType(BindExternalFunction.class)) { + final Class atClass = externalFunctionBinding.at(); + final String methodName = externalFunctionBinding.name(); + final Class[] parameterTypes = externalFunctionBinding.args(); + + final Method method; + try { + method = atClass.getDeclaredMethod(methodName, parameterTypes); + } catch (final NoSuchMethodException e) { + throw new IllegalArgumentException("No method found with name " + methodName + + " and parameter types " + Arrays.toString(parameterTypes) + ". Declared as" + + " external binding for " + clazz, e); + } - final String functionName; - { - final String alias = externalFunctionBinding.as(); - if (alias.isEmpty()) { - functionName = methodName; - } else { - functionName = alias; - } + final String functionName; + { + final String alias = externalFunctionBinding.as(); + if (alias.isEmpty()) { + functionName = methodName; + } else { + functionName = alias; } + } - final Function backing = getBacking(backingProperties, functionName, Function.class); - final boolean pure = externalFunctionBinding.pure(); - - if (backing != null && backing.pure() != pure) { - throw new IllegalStateException("Different 'pure' values for interface and Java functions for function " + functionName); - } + final Function backing = getBacking(backingProperties, functionName, Function.class); + final boolean pure = externalFunctionBinding.pure(); - object.entries.put(functionName, new JavaFunction<>(instance, method, backing, pure)); + if (backing != null && backing.pure() != pure) { + throw new IllegalStateException("Different 'pure' values for interface and Java functions for function " + functionName); } + + object.entries.put(functionName, new JavaFunction<>(instance, method, backing, pure)); } } diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/NullFunctionResultTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/NullFunctionResultTest.java new file mode 100644 index 0000000..5010160 --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/NullFunctionResultTest.java @@ -0,0 +1,49 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.value.Function; +import org.redlance.mocha.runtime.value.MutableObjectBinding; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A function that returns null reads as zero everywhere, not only when + * it's the whole expression. + */ +class NullFunctionResultTest { + @Test + void null_reads_as_zero() { + final MolangInterpreter molang = MolangInterpreter.standard(); + final MutableObjectBinding query = new MutableObjectBinding(); + query.set("nothing", (Function) (ctx, args) -> null); + molang.scope().set("q", query); + + assertEquals(0F, molang.eval("q.nothing()")); + assertEquals(1F, molang.eval("q.nothing() + 1")); + assertEquals(1F, molang.eval("!q.nothing()")); + assertEquals(2F, molang.eval("q.nothing() ? 1 : 2")); + } +} diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/binding/ExternalFunctionTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/binding/ExternalFunctionTest.java new file mode 100644 index 0000000..e7fdaa0 --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/binding/ExternalFunctionTest.java @@ -0,0 +1,61 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime.binding; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.MolangInterpreter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link BindExternalFunction} must work when it's used once, not only + * when it's repeated. + */ +class ExternalFunctionTest { + @Test + void single_annotation() { + final MolangInterpreter molang = MolangInterpreter.standard(); + molang.bind(Single.class); + assertEquals(5F, molang.eval("single.hypot(3, 4)")); + } + + @Test + void repeated_annotations() { + final MolangInterpreter molang = MolangInterpreter.standard(); + molang.bind(Repeated.class); + assertEquals(5F, molang.eval("repeated.hypot(3, 4)")); + assertEquals(3F, molang.eval("repeated.cube_root(27)")); + } + + @Binding("single") + @BindExternalFunction(at = Math.class, name = "hypot", args = {double.class, double.class}, pure = true) + public static final class Single { + } + + @Binding("repeated") + @BindExternalFunction(at = Math.class, name = "hypot", args = {double.class, double.class}, pure = true) + @BindExternalFunction(at = Math.class, name = "cbrt", args = {double.class}, as = "cube_root", pure = true) + public static final class Repeated { + } +} From fd26ffacd96dff514da935885b8b4ec2338ed9b3 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:58:00 +0700 Subject: [PATCH 04/13] Run blocks in conditionals, fall back on ?? only when missing, and fix random ranges Behaviour decided by comparing MolangJS, bridge's molang, bedrockk/MoLang, Moonflower's molang-compiler and molang-py: - In "c ? { ... } : { ... }" neither block ran: the branch evaluated to the block's function, which was never called. The chosen block now runs, like the block of "c ? { ... }" already did (every library that supports both blocks and conditionals). - "??" fell back on any falsy value, so with v.x = 0, "v.x ?? 7" was 7. It now only falls back when the variable or property doesn't exist (4 of 5). - math.random, math.random_integer, math.die_roll and math.die_roll_integer threw IllegalArgumentException when the bounds were equal or reversed, which escaped MolangInterpreter.eval. No other library throws: the bounds can now come in either order and be equal. - math.random_integer and math.die_roll_integer never returned the upper bound. Both bounds are now included: MolangJS, bridge and molang-py include it, molang-compiler excludes it, bedrockk throws. - math.die_roll summed values from low to low + high and divided the sum by 4. It now sums random numbers between low and high (3 of 5). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/runtime/ExpressionInterpreter.java | 37 ++++-- .../mocha/runtime/standard/MochaMath.java | 17 ++- .../redlance/mocha/runtime/SemanticsTest.java | 111 ++++++++++++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java index d731f1c..32ac603 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java @@ -79,12 +79,9 @@ public final class ExpressionInterpreter implements ExpressionVisitor, } }, (evaluator, a, b) -> { // null coalesce - final Value val = a.visit(evaluator); - if (val.getAsBoolean()) { - return val; - } else { - return b.visit(evaluator); - } + // only a missing value falls back, 0 counts as present + final Value val = evaluator.valueIfPresent(a); + return val != null ? val : b.visit(evaluator); }, (evaluator, a, b) -> { // assignation final Value val = b.visit(evaluator); @@ -343,6 +340,22 @@ public T entity() { return scope.get(expression.name()); } + // the value of the expression, or null when it names a variable or property that doesn't exist + private @Nullable Value valueIfPresent(final @NotNull Expression expression) { + final ObjectProperty property; + if (expression instanceof AccessExpression access) { + if (!(access.object().visit(this) instanceof ObjectValue object)) { + return null; + } + property = object.getProperty(access.property()); + } else if (expression instanceof IdentifierExpression identifier) { + property = scope.getProperty(identifier.name()); + } else { + return expression.visit(this); + } + return property == null ? null : property.value(); + } + @Override public @NotNull Value visitBinary(@NotNull BinaryExpression expression) { return BINARY_EVALUATORS.get(expression.op().ordinal()).eval( @@ -392,9 +405,15 @@ public T entity() { @Override public @NotNull Value visitTernaryConditional(@NotNull TernaryConditionalExpression expression) { final Value conditionResult = expression.condition().visit(this); - return conditionResult.getAsBoolean() - ? expression.trueExpression().visit(this) - : expression.falseExpression().visit(this); + final Expression branch = conditionResult.getAsBoolean() + ? expression.trueExpression() + : expression.falseExpression(); + final Value value = branch.visit(this); + // a block branch runs, like the block of "c ? { ... }" does + if (branch instanceof ExecutionScopeExpression && value instanceof Function block) { + return Value.of(((Function) block).evaluate(this)); + } + return value; } @Override diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java index 7911b67..a202096 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java @@ -61,7 +61,6 @@ public final class MochaMath implements ObjectValue { private static final double RADIAN = Math.toRadians(1); private static final Random RANDOM = new Random(); - private static final int DECIMAL_PART = 4; // the functions below back the Java bindings of this class, and // JavaObjectBinding.of requires a backing function to be exactly as pure as its binding @@ -202,20 +201,22 @@ public static float cos(final float value) { return (float) Math.cos(value * RADIAN); } + // the sum of "amount" random numbers between low and high @Binding("die_roll") public static float dieRoll(final float amount, final float low, final float high) { float result = 0; for (int i = 0; i < amount; i++) { - result += RANDOM.nextInt((int) high) + low; + result += random(low, high); } - return result / DECIMAL_PART; + return result; } + // the sum of "amount" random whole numbers from low to high, both included @Binding("die_roll_integer") public static float dieRollInteger(final float amount, final float low, final float high) { int result = 0; for (int i = 0; i < amount; i++) { - result += RANDOM.nextInt((int) low, (int) high); + result += randomInteger(low, high); } return result; } @@ -272,14 +273,18 @@ public static float mod(final float a, final float b) { return a % b; } + // between the two bounds, in either order (Random.nextFloat(min, max) would throw unless min < max) @Binding("random") public static float random(final float min, final float max) { - return RANDOM.nextFloat(min, max); + return min + RANDOM.nextFloat() * (max - min); } + // from the smaller bound to the bigger one, both included @Binding("random_integer") public static int randomInteger(final float min, final float max) { - return RANDOM.nextInt((int) min, (int) max); + final int low = (int) Math.min(min, max); + final int high = (int) Math.max(min, max); + return (int) (low + RANDOM.nextLong((long) high - low + 1)); } @Binding(value = "sign", pure = true) diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java new file mode 100644 index 0000000..b6bebec --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java @@ -0,0 +1,111 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.value.MutableObjectBinding; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Behaviour decided by what the majority of other MoLang implementations + * (MolangJS, bridge's molang, bedrockk/MoLang, Moonflower's + * molang-compiler, molang-py) do. + */ +class SemanticsTest { + private static final int SAMPLES = 5_000; + + @Test + void blocks_in_conditionals_run() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(4F, molang.eval("v.w = 0; 1 ? { v.w = 4; } : { v.w = 5; }; return v.w;")); + assertEquals(5F, molang.eval("v.w = 0; 0 ? { v.w = 4; } : { v.w = 5; }; return v.w;")); + assertEquals(4F, molang.eval("v.q = 0; 1 ? { v.q = 4; }; return v.q;")); + assertEquals(2F, molang.eval("t.i = 0; t.x = 0; loop(5, { t.i = t.i + 1; (t.i > 2) ? { break; } : { t.x = t.i; }; }); return t.x;")); + } + + @Test + void only_missing_values_fall_back() { + final MolangInterpreter molang = MolangInterpreter.standard(); + molang.scope().set("q", new MutableObjectBinding()); + assertEquals(0F, molang.eval("v.zero = 0; return v.zero ?? 7;")); + assertEquals(2F, molang.eval("v.two = 2; return v.two ?? 7;")); + assertEquals(7F, molang.eval("v.never ?? 7")); + assertEquals(3F, molang.eval("t.x ?? 3")); + assertEquals(4F, molang.eval("q.missing ?? 4")); + assertEquals(5F, molang.eval("unknown ?? 5")); + assertEquals(6F, molang.eval("unknown.thing ?? 6")); + assertEquals((float) Math.PI, molang.eval("math.pi ?? 1"), 1e-6F); + } + + @Test + void empty_and_reversed_ranges() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(1F, molang.eval("math.random(1, 1)")); + assertEquals(2F, molang.eval("math.random_integer(2, 2)")); + assertEquals(3F, molang.eval("math.die_roll(3, 1, 1)")); + assertEquals(2F, molang.eval("math.die_roll_integer(2, 1, 1)")); + for (int i = 0; i < SAMPLES; i++) { + final float value = molang.eval("math.random(5, 1)"); + assertTrue(value >= 1 && value <= 5, () -> "math.random(5, 1) = " + value); + } + assertEquals(Set.of(1F, 2F, 3F), sample(molang, "math.random_integer(3, 1)")); + } + + @Test + void integer_ranges_include_both_bounds() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(Set.of(1F, 2F, 3F), sample(molang, "math.random_integer(1, 3)")); + assertEquals(Set.of(1F, 2F, 3F), sample(molang, "math.die_roll_integer(1, 1, 3)")); + assertEquals(Set.of(2F, 3F, 4F), sample(molang, "math.die_roll_integer(2, 1, 2)")); + } + + @Test + void die_roll_sums_rolls_between_the_bounds() { + final MolangInterpreter molang = MolangInterpreter.standard(); + boolean fractional = false; + for (int i = 0; i < SAMPLES; i++) { + final float value = molang.eval("math.die_roll(1, 5, 6)"); + assertTrue(value >= 5 && value <= 6, () -> "math.die_roll(1, 5, 6) = " + value); + fractional |= value != (int) value; + } + assertTrue(fractional, "math.die_roll returns fractional rolls"); + for (int i = 0; i < SAMPLES; i++) { + final float value = molang.eval("math.die_roll(2, 1, 2)"); + assertTrue(value >= 2 && value <= 4, () -> "math.die_roll(2, 1, 2) = " + value); + } + } + + private static Set sample(final MolangInterpreter molang, final String code) { + final Set values = new HashSet<>(); + for (int i = 0; i < SAMPLES; i++) { + values.add(molang.eval(code)); + } + return values; + } +} From 41b00d6af94f251a83d8d042b9604e854d3b33c4 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:25:14 +0700 Subject: [PATCH 05/13] Compare strings by text and round halves away from zero, like Bedrock Checked in Bedrock with a behaviour pack: - "'a' == 'b'" is 0 and "'a' != 'b'" is 1 in Bedrock. mochafloats compared every string as 0, so two strings were always equal. The libraries didn't agree here (only bridge compares the text). Two strings now compare by their text; a string and a number still compare as numbers. - math.round(-2.5) is -3 in Bedrock, which rounds halves away from zero. Math.round gave -2. Positive values round the same way as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/runtime/ExpressionInterpreter.java | 16 +++++++++-- .../mocha/runtime/standard/MochaMath.java | 9 +++++-- .../redlance/mocha/runtime/SemanticsTest.java | 27 +++++++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java index 32ac603..fca581b 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java @@ -113,8 +113,8 @@ public final class ExpressionInterpreter implements ExpressionVisitor, } return NumberValue.zero(); }, - arithmetic((a, b) -> ((a.eval() == b.eval()) ? 1.0F : 0.0F)), // eq - arithmetic((a, b) -> ((a.eval() != b.eval()) ? 1.0F : 0.0F)) // neq + equality(true), // eq + equality(false) // neq ); private final T entity; @@ -143,6 +143,18 @@ private static Evaluator compare(Comparator comp) { )); } + // two strings compare by their text, like in Bedrock, anything else compares as numbers + private static Evaluator equality(final boolean equal) { + return (evaluator, a, b) -> { + final Value left = a.visit(evaluator); + final Value right = b.visit(evaluator); + final boolean same = left instanceof StringValue leftString && right instanceof StringValue rightString + ? leftString.value().equals(rightString.value()) + : left.getAsNumber() == right.getAsNumber(); + return Value.of(same == equal); + }; + } + private static Evaluator arithmetic(ArithmeticOperator op) { return (evaluator, a, b) -> NumberValue.of(op.operate( () -> a.visit(evaluator).getAsNumber(), diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java index a202096..ff02de5 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/standard/MochaMath.java @@ -51,7 +51,6 @@ @BindExternalFunction(at = Math.class, name = "abs", args = {float.class}, pure = true) @BindExternalFunction(at = Math.class, name = "max", args = {float.class, float.class}, pure = true) @BindExternalFunction(at = Math.class, name = "min", args = {float.class, float.class}, pure = true) -@BindExternalFunction(at = Math.class, name = "round", args = {float.class}, pure = true) public final class MochaMath implements ObjectValue { @Binding("pi") public static final float PI = (float) Math.PI; @@ -103,7 +102,7 @@ public MochaMath() { setFunction("pow", MochaMath::pow); setFunction("random", MochaMath::random); setFunction("random_integer", MochaMath::randomInteger); - setFunction("round", Math::round); + setFunction("round", MochaMath::round); setFunction("sign", MochaMath::sign); setFunction("sin", MochaMath::sin); setFunction("sqrt", MochaMath::sqrt); @@ -299,6 +298,12 @@ public static float sin(final float value) { return (float) Math.sin(value * RADIAN); } + // halves round away from zero like in Bedrock, so -2.5 is -3 (Math.round would give -2) + @Binding(value = "round", pure = true) + public static float round(final float value) { + return (float) Math.copySign(Math.floor(Math.abs((double) value) + 0.5), value); + } + @Binding(value = "trunc", pure = true) public static float trunc(final float value) { return value - value % 1; diff --git a/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java index b6bebec..0ed2b9d 100644 --- a/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java +++ b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java @@ -33,13 +33,36 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Behaviour decided by what the majority of other MoLang implementations - * (MolangJS, bridge's molang, bedrockk/MoLang, Moonflower's + * Behaviour decided by what Bedrock and the majority of other MoLang + * implementations (MolangJS, bridge's molang, bedrockk/MoLang, Moonflower's * molang-compiler, molang-py) do. */ class SemanticsTest { private static final int SAMPLES = 5_000; + @Test + void strings_compare_by_text() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(0F, molang.eval("'a' == 'b'")); + assertEquals(1F, molang.eval("'a' == 'a'")); + assertEquals(1F, molang.eval("'a' != 'b'")); + assertEquals(0F, molang.eval("'a' != 'a'")); + assertEquals(1F, molang.eval("v.name = 'steve'; return v.name == 'steve';")); + assertEquals(0F, molang.eval("v.name = 'steve'; return v.name == 'alex';")); + // a string and a number still compare as numbers, a string being 0 + assertEquals(1F, molang.eval("'a' == 0")); + } + + @Test + void halves_round_away_from_zero() { + final MolangInterpreter molang = MolangInterpreter.standard(); + assertEquals(-3F, molang.eval("math.round(-2.5)")); + assertEquals(3F, molang.eval("math.round(2.5)")); + assertEquals(-2F, molang.eval("math.round(-2.4)")); + assertEquals(11F, molang.eval("math.round(10.5)")); + assertEquals(0F, molang.eval("math.round(0.49999997)")); + } + @Test void blocks_in_conditionals_run() { final MolangInterpreter molang = MolangInterpreter.standard(); From bc89d23a1d19cabd9dc15b01d08a4021b96afcb0 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:52:31 +0700 Subject: [PATCH 06/13] Group assignments and conditionals like other MoLang implementations, and print parseable text Behaviour decided by comparing MolangJS, bridge's molang, bedrockk/MoLang, Moonflower's molang-compiler and molang-py: - "v.x = c ? 5 : 6" assigned c and then evaluated "(v.x = c) ? 5 : 6". Assignment now takes everything on its right (4 of 5 libraries), and "v.a = v.b = 1" assigns both. - "a ? 1 : b ? 2 : 3" was grouped as "(a ? 1 : b) ? 2 : 3". Conditionals now group from the right (4 of 5). - An empty statement (";;"), a missing operand ("1 +") or a trailing comma in a call ("math.abs(1,)") made parseSingle return null, which silently dropped the rest of the script or put null into the arguments. They are now parse errors (3 to 4 of 5). "{}" and a semicolon before "}" still parse. toString() dropped every grouping parenthesis, so "(1 + 2) * 3" printed as "1+2*3". It now prints parentheses wherever the parser would group differently without them, and the true branch of a conditional is wrapped when it ends with a "?" that has no ":". A test parses 20,000 random expressions, prints them and checks that they parse back into the same tree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/parser/MolangParserImpl.java | 21 +++- .../parser/ast/ArrayAccessExpression.java | 2 +- .../mocha/parser/ast/BinaryExpression.java | 32 +++++- .../mocha/parser/ast/CallExpression.java | 11 +- .../ast/TernaryConditionalExpression.java | 18 ++- .../redlance/mocha/parser/GroupingTest.java | 97 +++++++++++++++++ .../mocha/parser/PrintRoundTripTest.java | 103 ++++++++++++++++++ 7 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java create mode 100644 parser/src/test/java/org/redlance/mocha/parser/PrintRoundTripTest.java diff --git a/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java b/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java index 49c5d43..498dfd5 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java +++ b/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java @@ -89,6 +89,11 @@ final class MolangParserImpl implements MolangParser { lexer.next(); List expressions = new ArrayList<>(); while (true) { + // "{}" and a semicolon after the last statement, as in "{ v.a = 1; }" + if (lexer.current().kind() == TokenKind.RBRACE) { + lexer.next(); + break; + } Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, 0); if (compoundExpression != null) expressions.add(compoundExpression); token = lexer.current(); @@ -155,7 +160,12 @@ final class MolangParserImpl implements MolangParser { return new UnaryExpression(UnaryExpression.Op.RETURN, compoundExpression); } - return null; + // an empty statement (";;"), a missing operand ("1 +") or argument ("f(1,)"): + // returning null here made the caller stop parsing and drop the rest of the script + if (token.kind() == TokenKind.ERROR) { + throw new ParseException("Found error token: " + token.value(), lexer.cursor()); + } + throw new ParseException("Expected an expression, but found " + token.kind(), lexer.cursor()); } // @@ -277,7 +287,9 @@ final class MolangParserImpl implements MolangParser { // then it's a ternary expression, since there is a ':', indicating the next expression lexer.next(); if (trueValue == null) return null; - Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, ternaryPrecedence); + // the false branch may hold another conditional, so "a ? 1 : b ? 2 : 3" + // is "a ? 1 : (b ? 2 : 3)", like in MolangJS and most other implementations + Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, ternaryPrecedence - 1); if (compoundExpression == null || left == null) return null; return new TernaryConditionalExpression(left, trueValue, compoundExpression); } else { @@ -318,7 +330,10 @@ final class MolangParserImpl implements MolangParser { } lexer.next(); - Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, precedence); + // assignment takes everything on its right, so "v.x = a ? 1 : 2" assigns the + // conditional and "v.a = v.b = 1" assigns both, the other operators group from the left + final int rightPrecedence = op == BinaryExpression.Op.ASSIGN ? precedence - 1 : precedence; + Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, rightPrecedence); if (compoundExpression == null || left == null) return null; return new BinaryExpression(op, left, compoundExpression); } diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/ArrayAccessExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/ArrayAccessExpression.java index 146f447..2e51281 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/ArrayAccessExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/ArrayAccessExpression.java @@ -112,7 +112,7 @@ public void write(ByteBuf buf) { @Override public @NotNull String toString() { - return String.format("%s[%s]", array, index); + return BinaryExpression.parenthesize(array, !CallExpression.isPostfixOperand(array)) + "[" + index + "]"; } @Override diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/BinaryExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/BinaryExpression.java index 6366105..8c3c2a9 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/BinaryExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/BinaryExpression.java @@ -127,7 +127,37 @@ public void write(ByteBuf buf) { @Override public String toString() { - return String.format("%s%s%s", this.left, this.op.toString(), this.right); + return parenthesize(left, leftNeedsParentheses()) + op + parenthesize(right, rightNeedsParentheses()); + } + + // "=" and "?" take everything on their right, the other operators group from the left + private boolean rightAssociative() { + return op == Op.ASSIGN || op == Op.CONDITIONAL; + } + + // parentheses are only printed where the parser would otherwise group differently + boolean leftNeedsParentheses() { + final int leftPrecedence = printPrecedence(left); + return leftPrecedence < op.precedence() || (rightAssociative() && leftPrecedence == op.precedence()); + } + + boolean rightNeedsParentheses() { + final int rightPrecedence = printPrecedence(right); + return rightPrecedence < op.precedence() || (!rightAssociative() && rightPrecedence == op.precedence()); + } + + // how tightly an expression binds when printed, everything but the operators binds tightest + static int printPrecedence(final @NotNull Expression expression) { + if (expression instanceof BinaryExpression binary) { + return binary.op.precedence(); + } else if (expression instanceof TernaryConditionalExpression) { + return Op.CONDITIONAL.precedence(); + } + return Integer.MAX_VALUE; + } + + static @NotNull String parenthesize(final @NotNull Expression expression, final boolean parentheses) { + return parentheses ? "(" + expression + ")" : expression.toString(); } @Override diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/CallExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/CallExpression.java index 9fe61ce..8137793 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/CallExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/CallExpression.java @@ -98,7 +98,16 @@ public void write(ByteBuf buf) { @Override public String toString() { - return String.format("%s(%s)", function, ExpressionListUtils.toString(this.arguments, ",")); + return BinaryExpression.parenthesize(function, !isPostfixOperand(function)) + + "(" + ExpressionListUtils.toString(this.arguments, ",") + ")"; + } + + // whether the expression can be followed by "(...)" or "[...]" without parentheses + static boolean isPostfixOperand(final @NotNull Expression expression) { + return expression instanceof IdentifierExpression + || expression instanceof AccessExpression + || expression instanceof CallExpression + || expression instanceof ArrayAccessExpression; } @Override diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/TernaryConditionalExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/TernaryConditionalExpression.java index 61fb45f..e400aa5 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/TernaryConditionalExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/TernaryConditionalExpression.java @@ -138,7 +138,23 @@ public void write(ByteBuf buf) { @Override public String toString() { - return String.format("(%s)?%s:%s", conditional, trueExpression, falseExpression); + // the condition must bind tighter than "?", and the true branch can't end + // with a "?" without ":" of its own, it would take this conditional's ":" + final boolean conditionParentheses = BinaryExpression.printPrecedence(conditional) + <= BinaryExpression.Op.CONDITIONAL.precedence(); + return BinaryExpression.parenthesize(conditional, conditionParentheses) + "?" + + BinaryExpression.parenthesize(trueExpression, endsWithOpenConditional(trueExpression)) + ":" + + falseExpression; + } + + private static boolean endsWithOpenConditional(final @NotNull Expression expression) { + if (expression instanceof BinaryExpression binary) { + return binary.op() == BinaryExpression.Op.CONDITIONAL + || (!binary.rightNeedsParentheses() && endsWithOpenConditional(binary.right())); + } else if (expression instanceof TernaryConditionalExpression ternary) { + return endsWithOpenConditional(ternary.falseExpression()); + } + return false; } @Override diff --git a/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java b/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java new file mode 100644 index 0000000..1c2b67d --- /dev/null +++ b/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java @@ -0,0 +1,97 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.parser; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.parser.ast.*; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.redlance.mocha.parser.ParserAssertions.assertCreateSameTree; +import static org.redlance.mocha.parser.ParserAssertions.assertCreateTree; + +/** + * Grouping of assignments and conditionals, and incomplete scripts, decided + * by what the majority of other MoLang implementations do. + */ +class GroupingTest { + private static Expression access(final String object, final String property) { + return new AccessExpression(new IdentifierExpression(object), property); + } + + @Test + void assignment_takes_the_whole_conditional() throws ParseException { + assertCreateTree("v.x = 1 ? 5 : 6", new BinaryExpression( + BinaryExpression.Op.ASSIGN, + access("v", "x"), + new TernaryConditionalExpression(FloatExpression.ONE, FloatExpression.of(5), FloatExpression.of(6)) + )); + assertCreateSameTree("v.a = (v.b = 1)", "v.a = v.b = 1"); + assertCreateSameTree("t.a = (t.b ?? 3)", "t.a = t.b ?? 3"); + assertCreateSameTree("v.x = (q.a ? 1)", "v.x = q.a ? 1"); + } + + @Test + void conditionals_group_from_the_right() throws ParseException { + assertCreateTree("1 ? 2 : 3 ? 4 : 5", new TernaryConditionalExpression( + FloatExpression.ONE, + FloatExpression.of(2), + new TernaryConditionalExpression(FloatExpression.of(3), FloatExpression.of(4), FloatExpression.of(5)) + )); + assertCreateTree("(1 ? 2 : 3) ? 4 : 5", new TernaryConditionalExpression( + new TernaryConditionalExpression(FloatExpression.ONE, FloatExpression.of(2), FloatExpression.of(3)), + FloatExpression.of(4), + FloatExpression.of(5) + )); + assertCreateSameTree("q.a ? 1 : (q.b + 1)", "q.a ? 1 : q.b + 1"); + assertCreateSameTree("(q.a == 1) ? 2 : 3", "q.a == 1 ? 2 : 3"); + } + + @Test + void incomplete_scripts_are_errors() { + for (final String code : new String[]{ + "v.a = 1;; v.b = 2; return v.b;", + "v.a = 1; ; return 5;", + "1 +", + "math.abs(1,)", + "q.a ? : 1", + "return;", + "{ v.a = 1;; }" + }) { + assertThrows(ParseException.class, () -> MolangParser.parseAll(code), code); + } + } + + @Test + void complete_scripts_still_parse() throws IOException { + assertEquals(1, MolangParser.parseAll("math.pi;").size()); + assertEquals(2, MolangParser.parseAll("v.a = 1; return v.a;").size()); + assertEquals(1, MolangParser.parseAll("loop(2, {})").size()); + assertEquals(1, MolangParser.parseAll("loop(2, { v.a = v.a + 1; })").size()); + assertEquals(1, MolangParser.parseAll("loop(2, { v.a = v.a + 1; v.b = 1 })").size()); + assertEquals(0, MolangParser.parseAll("").size()); + } +} diff --git a/parser/src/test/java/org/redlance/mocha/parser/PrintRoundTripTest.java b/parser/src/test/java/org/redlance/mocha/parser/PrintRoundTripTest.java new file mode 100644 index 0000000..e4ee048 --- /dev/null +++ b/parser/src/test/java/org/redlance/mocha/parser/PrintRoundTripTest.java @@ -0,0 +1,103 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.parser; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.parser.ast.Expression; +import org.redlance.mocha.parser.util.ExpressionListUtils; + +import java.io.IOException; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@code toString()} must print text that parses back into the same tree. + */ +class PrintRoundTripTest { + private static String print(final String source) throws IOException { + return ExpressionListUtils.toString(MolangParser.parseAll(source)); + } + + private static void assertRoundTrip(final String source) throws IOException { + final List parsed = MolangParser.parseAll(source); + final String printed = ExpressionListUtils.toString(parsed); + assertEquals(parsed, MolangParser.parseAll(printed), () -> source + " was printed as " + printed); + } + + @Test + void parentheses_are_kept_where_needed() throws IOException { + assertEquals("(1+2)*3", print("(1 + 2) * 3")); + assertEquals("1+2*3", print("1 + (2 * 3)")); + assertEquals("10-(4-1)", print("10 - (4 - 1)")); + assertEquals("1-2-3", print("(1 - 2) - 3")); + assertEquals("math.abs(1-(2-3))", print("math.abs(1 - (2 - 3))")); + assertEquals("(v.a??2)+1", print("(v.a ?? 2) + 1")); + assertEquals("v.x=1?5:6", print("v.x = (1 ? 5 : 6)")); + assertEquals("(v.x=1)?5:6", print("(v.x = 1) ? 5 : 6")); + assertEquals("1?2:3?4:5", print("1 ? 2 : (3 ? 4 : 5)")); + assertEquals("(1?2:3)?4:5", print("(1 ? 2 : 3) ? 4 : 5")); + assertEquals("q.a?(q.b?1):2", print("q.a ? (q.b ? 1) : 2")); + assertEquals("(1+2)[0]", print("(1 + 2)[0]")); + } + + @Test + void random_expressions() throws IOException { + final Random random = new Random(1234); + for (int i = 0; i < 20_000; i++) { + assertRoundTrip(randomExpression(random, 4)); + } + } + + // fully parenthesized source text, so the tree is known + private static String randomExpression(final Random random, final int depth) { + if (depth <= 0 || random.nextInt(4) == 0) { + // calls only while there is depth left, so the expression stays finite + return switch (random.nextInt(depth <= 0 ? 4 : 6)) { + case 0 -> Integer.toString(random.nextInt(10)); + case 1 -> "v.a"; + case 2 -> "t.b"; + case 3 -> "'text'"; + case 4 -> "math.abs(" + randomExpression(random, depth - 1) + ")"; + default -> "q.f(" + randomExpression(random, depth - 1) + ", " + randomExpression(random, depth - 1) + ")"; + }; + } + final String a = randomExpression(random, depth - 1); + final String b = randomExpression(random, depth - 1); + return switch (random.nextInt(9)) { + case 0 -> "-(" + a + ")"; + case 1 -> "!(" + a + ")"; + case 2 -> "(" + a + ") ? (" + b + ") : (" + randomExpression(random, depth - 1) + ")"; + case 3 -> "(" + a + ") ? (" + b + ")"; + case 4 -> "v.c = (" + a + ")"; + case 5 -> "(v.arr)[" + a + "]"; + case 6 -> "(v.d) -> (" + a + ")"; + default -> { + final String[] operators = {"+", "-", "*", "/", "<", "<=", ">", ">=", "==", "!=", "&&", "||", "??"}; + yield "(" + a + ") " + operators[random.nextInt(operators.length)] + " (" + b + ")"; + } + }; + } +} From 9c4b1bbabac546efe19b9296c2022fc8d6bf3405 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:22:59 +0700 Subject: [PATCH 07/13] Skip empty statements like Bedrock does The library comparison made ";;" a parse error (3 of 5 libraries), but Bedrock itself accepts "v.a = 1;; v.b = 2; return v.b;" and skips the empty statement, as Mocha 3 and MolangJS do. Empty statements, at the top level and in blocks, are now skipped instead of rejected. Bedrock does reject a trailing comma ("math.abs(-1,)") and a missing operand ("1 +"), so those stay parse errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../redlance/mocha/parser/MolangParserImpl.java | 9 +++++++++ .../org/redlance/mocha/parser/GroupingTest.java | 15 ++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java b/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java index 498dfd5..c135786 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java +++ b/parser/src/main/java/org/redlance/mocha/parser/MolangParserImpl.java @@ -89,6 +89,10 @@ final class MolangParserImpl implements MolangParser { lexer.next(); List expressions = new ArrayList<>(); while (true) { + // empty statements are skipped, like Bedrock does with "{ v.a = 1;; }" + while (lexer.current().kind() == TokenKind.SEMICOLON) { + lexer.next(); + } // "{}" and a semicolon after the last statement, as in "{ v.a = 1; }" if (lexer.current().kind() == TokenKind.RBRACE) { lexer.next(); @@ -365,6 +369,11 @@ final class MolangParserImpl implements MolangParser { private @Nullable Expression next0() throws IOException { Token token = lexer.next(); + // empty statements are skipped, like Bedrock does with "v.a = 1;; v.b = 2" + while (token.kind() == TokenKind.SEMICOLON) { + token = lexer.next(); + } + if (token.kind() == TokenKind.EOF) { // reached end-of-file! return null; diff --git a/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java b/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java index 1c2b67d..04a6181 100644 --- a/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java +++ b/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java @@ -35,7 +35,7 @@ /** * Grouping of assignments and conditionals, and incomplete scripts, decided - * by what the majority of other MoLang implementations do. + * by what Bedrock and the majority of other MoLang implementations do. */ class GroupingTest { private static Expression access(final String object, final String property) { @@ -73,18 +73,23 @@ void conditionals_group_from_the_right() throws ParseException { @Test void incomplete_scripts_are_errors() { for (final String code : new String[]{ - "v.a = 1;; v.b = 2; return v.b;", - "v.a = 1; ; return 5;", "1 +", "math.abs(1,)", "q.a ? : 1", - "return;", - "{ v.a = 1;; }" + "return;" }) { assertThrows(ParseException.class, () -> MolangParser.parseAll(code), code); } } + @Test + void empty_statements_are_skipped() throws IOException { + assertEquals(MolangParser.parseAll("v.a = 1; v.b = 2; return v.b;"), MolangParser.parseAll("v.a = 1;; v.b = 2; return v.b;")); + assertEquals(MolangParser.parseAll("v.a = 1; return 5;"), MolangParser.parseAll("v.a = 1; ; return 5;")); + assertEquals(MolangParser.parseAll("v.a = 1"), MolangParser.parseAll(";; v.a = 1;;")); + assertEquals(MolangParser.parseAll("{ v.a = 1; }"), MolangParser.parseAll("{ ; v.a = 1;; }")); + } + @Test void complete_scripts_still_parse() throws IOException { assertEquals(1, MolangParser.parseAll("math.pi;").size()); From e05aa5e2e1a85364db1803581e912798d67540b8 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:37:47 +0700 Subject: [PATCH 08/13] Read empty strings back from the network format and reject unknown operators ProtocolUtils.writeString writes an empty string and null the same way, and readString returns null for both, so an expression containing '' was written fine but threw a NullPointerException when read back. A string literal is never null, so StringExpression now reads it as "". readString itself is unchanged, because callers rely on it returning null. readEnum turned an unknown ordinal into the first constant, so corrupt data or a newer peer silently changed an operator into AND, LOGICAL_NEGATION or BREAK. It now throws. StatementExpression (break, continue) now implements equals and hashCode like the other expressions, so equal trees compare equal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/parser/ast/StatementExpression.java | 12 +++ .../mocha/parser/ast/StringExpression.java | 3 +- .../parser/util/network/ProtocolUtils.java | 4 +- .../redlance/mocha/parser/ExprBytesTest.java | 77 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 parser/src/test/java/org/redlance/mocha/parser/ExprBytesTest.java diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/StatementExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/StatementExpression.java index 7340e8c..0d9ccc3 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/StatementExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/StatementExpression.java @@ -75,6 +75,18 @@ public String toString() { return op.toString(); } + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + return op == ((StatementExpression) o).op; + } + + @Override + public int hashCode() { + return op.hashCode(); + } + /** * Enum containing all the possible operations/types * of statement expressions. diff --git a/parser/src/main/java/org/redlance/mocha/parser/ast/StringExpression.java b/parser/src/main/java/org/redlance/mocha/parser/ast/StringExpression.java index 246f19b..cdf779b 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/ast/StringExpression.java +++ b/parser/src/main/java/org/redlance/mocha/parser/ast/StringExpression.java @@ -42,7 +42,8 @@ public final class StringExpression implements Expression { private final String value; public StringExpression(ByteBuf buf) { - this(ProtocolUtils.readString(buf)); + // writeString writes an empty string like null, and a literal is never null + this(Objects.requireNonNullElse(ProtocolUtils.readString(buf), "")); } public StringExpression(final @NotNull String value) { diff --git a/parser/src/main/java/org/redlance/mocha/parser/util/network/ProtocolUtils.java b/parser/src/main/java/org/redlance/mocha/parser/util/network/ProtocolUtils.java index 8c05df7..b24a176 100644 --- a/parser/src/main/java/org/redlance/mocha/parser/util/network/ProtocolUtils.java +++ b/parser/src/main/java/org/redlance/mocha/parser/util/network/ProtocolUtils.java @@ -58,8 +58,8 @@ public static T readEnum(Class enumClass, ByteBuf buf) { int ordinal = buf.readUnsignedByte(); T[] constants = enumClass.getEnumConstants(); - if (ordinal < 0 || ordinal >= constants.length) { - return constants[0]; // TODO + if (ordinal >= constants.length) { + throw new IllegalArgumentException("Unknown " + enumClass.getSimpleName() + " ordinal: " + ordinal); } return constants[ordinal]; } diff --git a/parser/src/test/java/org/redlance/mocha/parser/ExprBytesTest.java b/parser/src/test/java/org/redlance/mocha/parser/ExprBytesTest.java new file mode 100644 index 0000000..4cb90f9 --- /dev/null +++ b/parser/src/test/java/org/redlance/mocha/parser/ExprBytesTest.java @@ -0,0 +1,77 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.parser; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; +import org.redlance.mocha.parser.ast.Expression; +import org.redlance.mocha.parser.util.ExprBytesUtils; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Expressions written with {@link ExprBytesUtils} must be read back + * unchanged, and corrupt data must not be read silently. + */ +class ExprBytesTest { + private static void assertRoundTrip(final String source) throws IOException { + final List expressions = MolangParser.parseAll(source); + final ByteBuf buf = Unpooled.buffer(); + ExprBytesUtils.writeExpressions(expressions, buf); + + assertEquals(expressions, ExprBytesUtils.readExpressions(buf), source); + assertFalse(buf.isReadable(), () -> "bytes left after reading " + source); + } + + @Test + void round_trip() throws IOException { + assertRoundTrip("v.a = 1; return math.sin(q.anim_time * 360) * 30;"); + assertRoundTrip("t.x = 'minecraft:pig'; return t.x;"); + assertRoundTrip("loop(3, { t.i = t.i + 1; (t.i > 1) ? break; }); return t.i;"); + assertRoundTrip("q.is_baby() ? -math.abs(-3) : !v.flag"); + assertRoundTrip("v.values[1] + (v.missing ?? 7)"); + assertRoundTrip("v.target -> q.health"); + assertRoundTrip("1.5e-3 * 2"); + } + + @Test + void empty_string() throws IOException { + assertRoundTrip("q.length('')"); + assertRoundTrip("'' == ''"); + } + + @Test + void unknown_operator() { + final ByteBuf buf = Unpooled.buffer(); + buf.writeByte(8); // BinaryExpression + buf.writeByte(200); // no such operator + assertThrows(IllegalArgumentException.class, () -> ExprBytesUtils.readExpression(buf)); + } +} From e38f21f0543b15ce85c14a7d68db3554f8cd0af9 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 15:44:40 +0700 Subject: [PATCH 09/13] Make compiled functions match the interpreter and reject what they can't compile Temp variables: an assignment compiled its value with the method's return type and stored it with fstore, so t.* failed verification in functions returning double, int or boolean. The value is now always a float, and an assignment keeps it on the stack, so a script ending with one compiles. Booleans: float, double and long values were converted to boolean by truncating them to an int first, so 0.5 was false and "a && 1", "a ? 10 : 20" and "!a" gave the wrong result for fractional values. They are now compared with zero. Arithmetic results and field reads are converted to the type the surrounding code expects instead of always being left as floats. Numbers: like the interpreter, dividing by zero gives zero, and NaN or infinite results of arithmetic and of Java methods (unless the binding sets skipChecking) become zero. This costs about 0.07 ns per call of the benchmark function. Unsupported constructs: variable/v, properties of ObjectValues such as a query made of lambdas, non-static fields, ->, ??, array access, blocks, break, continue, loop, for_each, lambda functions and methods taking an ExecutionContext, a Lazy or varargs used to emit invalid bytecode or, for loop and for_each, silently compile to 0. They now throw an UnsupportedOperationException that names the construct. loop and for_each are no longer folded as constants, which is what made them disappear. "a ? b" without an else branch is now supported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .../mocha/runtime/MolangCompilingVisitor.java | 250 +++++++++++------- .../mocha/runtime/util/ClassFileUtil.java | 15 ++ .../runtime/jvm/CompilerSemanticsTest.java | 169 ++++++++++++ .../mocha/runtime/IsConstantExpression.java | 5 +- 4 files changed, 337 insertions(+), 102 deletions(-) create mode 100644 runtime-compiler/src/test/java/org/redlance/mocha/runtime/jvm/CompilerSemanticsTest.java diff --git a/runtime-compiler/src/main/java/org/redlance/mocha/runtime/MolangCompilingVisitor.java b/runtime-compiler/src/main/java/org/redlance/mocha/runtime/MolangCompilingVisitor.java index 1dd55b1..4ea5fe4 100644 --- a/runtime-compiler/src/main/java/org/redlance/mocha/runtime/MolangCompilingVisitor.java +++ b/runtime-compiler/src/main/java/org/redlance/mocha/runtime/MolangCompilingVisitor.java @@ -25,11 +25,14 @@ import com.google.j2objc.annotations.J2ObjCIncompatible; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.redlance.mocha.parser.ast.*; +import org.redlance.mocha.runtime.binding.Binding; import org.redlance.mocha.runtime.binding.Entity; import org.redlance.mocha.runtime.binding.JavaFieldBinding; import org.redlance.mocha.runtime.binding.JavaFunction; import org.redlance.mocha.runtime.binding.JavaObjectBinding; +import org.redlance.mocha.runtime.binding.Lazy; import org.redlance.mocha.runtime.value.Function; import org.redlance.mocha.runtime.value.NumberValue; import org.redlance.mocha.runtime.value.ObjectValue; @@ -114,35 +117,28 @@ final class MolangCompilingVisitor implements ExpressionVisitor { - int index = functionCompileState.maxLocals(); - if (result.lastPushedType() != null && - (result.lastPushedType().equals(CD_double) || result.lastPushedType().equals(CD_long))) { - functionCompileState.maxLocals(index + 2); - } else { - functionCompileState.maxLocals(index + 1); - } - return index; - }); - codeBuilder.fstore(localIndex); - return null; - } - } + if (expression.left() instanceof AccessExpression access && isTemp(access)) { + // temp variables are float locals, so the value must be a float + // whatever the method returns, they are read back with fload + expectedType = CD_float; + expression.right().visit(this); + expectedType = currentExpectedType; + final int localIndex = localsByName.computeIfAbsent(access.property(), k -> { + final int index = functionCompileState.maxLocals(); + functionCompileState.maxLocals(index + 1); + return index; + }); + // keep the assigned value on the stack, the interpreter evaluates an assignment to it too + codeBuilder.dup(); + codeBuilder.fstore(localIndex); + return castTo(CD_float, currentExpectedType); } + throw unsupported("assignments to anything but temp variables", expression); } - final ClassDesc currentExpectedType = expectedType; - //@formatter:off switch (op) { case AND: { @@ -211,45 +207,85 @@ public CompileVisitResult visitBinary(final @NotNull BinaryExpression expression codeBuilder.labelBinding(end); return new CompileVisitResult(expectedType == null ? CD_boolean : expectedType); } - case ADD: { - expectedType = CD_float; - expression.left().visit(this); - expression.right().visit(this); - expectedType = currentExpectedType; - codeBuilder.fadd(); - return CompileVisitResult.FLOAT; - } - case SUB: { - expectedType = CD_float; - expression.left().visit(this); - expression.right().visit(this); - expectedType = currentExpectedType; - codeBuilder.fsub(); - return CompileVisitResult.FLOAT; - } - case MUL: { - expectedType = CD_float; - expression.left().visit(this); - expression.right().visit(this); - expectedType = currentExpectedType; - codeBuilder.fmul(); - return CompileVisitResult.FLOAT; - } + case ADD: + case SUB: + case MUL: case DIV: { expectedType = CD_float; expression.left().visit(this); // pushes lhs value to stack expression.right().visit(this); // pushes rhs value to stack expectedType = currentExpectedType; - codeBuilder.fdiv(); - return CompileVisitResult.FLOAT; + switch (op) { + case ADD -> codeBuilder.fadd(); + case SUB -> codeBuilder.fsub(); + case MUL -> codeBuilder.fmul(); + default -> addDivision(); + } + // like the interpreter, which never produces NaN or infinities + addNormalize(CD_float); + return castTo(CD_float, currentExpectedType); } + case CONDITIONAL: + // "a ? b" is "a ? b : 0" + return new TernaryConditionalExpression(expression.left(), expression.right(), FloatExpression.ZERO).visit(this); case ARROW: + throw unsupported("the arrow operator (->)", expression); case NULL_COALESCE: - case CONDITIONAL: - break; + throw unsupported("the null coalescing operator (??)", expression); + default: + throw new IllegalStateException("Unknown binary operator: " + op); } //@formatter:on - return null; + } + + // MoLang divides by zero as zero, the stack holds the dividend and the divisor + private void addDivision() { + final Label divide = codeBuilder.newLabel(); + final Label end = codeBuilder.newLabel(); + codeBuilder.dup(); + codeBuilder.fconst_0(); + codeBuilder.fcmpl(); + codeBuilder.ifne(divide); + codeBuilder.pop2(); + codeBuilder.fconst_0(); + codeBuilder.goto_(end); + codeBuilder.labelBinding(divide); + codeBuilder.fdiv(); + codeBuilder.labelBinding(end); + } + + // replaces NaN and infinities on the stack with zero, like NumberValue does + private void addNormalize(final @NotNull ClassDesc type) { + if (type.equals(CD_float) || type.equals(CD_double)) { + codeBuilder.invokestatic(classDescOf(NumberValue.class), "normalize", MethodTypeDesc.of(type, type)); + } + } + + // converts the value on the stack to the type the caller expects, if it expects one + private @NotNull CompileVisitResult castTo(final @NotNull ClassDesc from, final @Nullable ClassDesc to) { + if (to == null || to.equals(from)) { + return new CompileVisitResult(from); + } + if (to.equals(CD_void)) { + if (from.equals(CD_double) || from.equals(CD_long)) { + codeBuilder.pop2(); + } else if (!from.equals(CD_void)) { + codeBuilder.pop(); + } + return CompileVisitResult.VOID; + } + ClassFileUtil.addCast(codeBuilder, from, to); + return new CompileVisitResult(to); + } + + private static boolean isTemp(final @NotNull AccessExpression access) { + return access.object() instanceof IdentifierExpression identifier + && (identifier.name().equals("temp") || identifier.name().equals("t")); + } + + private static @NotNull UnsupportedOperationException unsupported(final @NotNull String what, final @NotNull Expression expression) { + return new UnsupportedOperationException("The compiler doesn't support " + what + " (in '" + expression + + "'), evaluate this expression with MolangInterpreter instead"); } public void endVisit() { @@ -468,49 +504,48 @@ public CompileVisitResult visitIdentifier(final @NotNull IdentifierExpression ex @Override public CompileVisitResult visitAccess(final @NotNull AccessExpression expression) { - final Expression objectExpr = expression.object(); final String property = expression.property(); - if (objectExpr instanceof IdentifierExpression) { - final String name = ((IdentifierExpression) objectExpr).name(); - if (name.equals("temp") || name.equals("t")) { - // temps are locals - final Integer localIndex = localsByName.get(property); - if (localIndex == null) { - codeBuilder.fconst_0(); - } else { - codeBuilder.fload(localIndex); - } - return CompileVisitResult.FLOAT; + if (isTemp(expression)) { + // temps are float locals, an unassigned one reads as zero + final Integer localIndex = localsByName.get(property); + if (localIndex == null) { + codeBuilder.fconst_0(); + } else { + codeBuilder.fload(localIndex); } + return castTo(CD_float, expectedType); } - final Value objectValue = objectExpr.visit(this.scopeResolver); - - if (objectValue instanceof ObjectValue) { - final ObjectValue actualObjectValue = (ObjectValue) objectValue; - if (actualObjectValue instanceof JavaObjectBinding) { - final JavaFieldBinding javaFieldBinding = ((JavaObjectBinding) actualObjectValue).getField(property); - if (javaFieldBinding == null) { - // push zero only - codeBuilder.fconst_0(); - } else if (javaFieldBinding.constant()) { - // inline const - codeBuilder.loadConstant(javaFieldBinding.get().getAsNumber()); - } else { - final Field field = javaFieldBinding.field(); - if (Modifier.isStatic(field.getModifiers())) { - codeBuilder.getstatic( - classDescOf(field.getDeclaringClass()), - field.getName(), - classDescOf(field.getType()) - ); - } - } + final Value objectValue = expression.object().visit(this.scopeResolver); + + if (objectValue instanceof JavaObjectBinding javaObjectBinding) { + final JavaFieldBinding javaFieldBinding = javaObjectBinding.getField(property); + if (javaFieldBinding == null) { + // not a field, reads as zero like in the interpreter + codeBuilder.fconst_0(); + return castTo(CD_float, expectedType); + } else if (javaFieldBinding.constant()) { + // inline const + codeBuilder.loadConstant(javaFieldBinding.get().getAsNumber()); + return castTo(CD_float, expectedType); } + + final Field field = javaFieldBinding.field(); + if (field == null || !Modifier.isStatic(field.getModifiers())) { + throw unsupported("non-static fields", expression); + } + final ClassDesc fieldType = classDescOf(field.getType()); + codeBuilder.getstatic(classDescOf(field.getDeclaringClass()), field.getName(), fieldType); + return castTo(fieldType, expectedType); + } else if (objectValue instanceof ObjectValue) { + throw unsupported("reading properties of " + expression.object() + + ", only temp variables and @Binding static fields can be read", expression); } - return null; + // unknown names evaluate to zero + codeBuilder.fconst_0(); + return castTo(CD_float, expectedType); } @Override @@ -518,26 +553,34 @@ public CompileVisitResult visitCall(final @NotNull CallExpression expression) { final ClassDesc targetType = this.expectedType; final Expression functionExpr = expression.function(); + if (functionExpr instanceof IdentifierExpression identifier + && (identifier.name().equals("loop") || identifier.name().equals("for_each"))) { + throw unsupported(identifier.name(), expression); + } + final Value functionValue = functionExpr.visit(this.scopeResolver); if (!(functionValue instanceof Function)) { - // not a function, just add 0 + // not a function, calling it evaluates to zero like in the interpreter codeBuilder.fconst_0(); - return CompileVisitResult.FLOAT; + return castTo(CD_float, targetType); } final Function function = (Function) functionValue; - if (function instanceof JavaFunction) { + if (function instanceof JavaFunction javaFunction && javaFunction.method() != null) { // we can compile to directly call this function (Java Method) - final JavaFunction javaFunction = (JavaFunction) function; final Method nativeMethod = javaFunction.method(); final Parameter[] parameters = nativeMethod.getParameters(); final List arguments = expression.arguments(); final ClassDesc[] ctParameters = new ClassDesc[parameters.length]; for (int i = 0; i < parameters.length; i++) { - ctParameters[i] = classDescOf(parameters[i].getType()); + final Class parameterType = parameters[i].getType(); + if (parameterType == ExecutionContext.class || parameterType == Lazy.class || parameters[i].isVarArgs()) { + throw unsupported("calling " + nativeMethod.getName() + ", which takes an ExecutionContext, a Lazy or varargs", expression); + } + ctParameters[i] = classDescOf(parameterType); } final boolean isStatic = Modifier.isStatic(nativeMethod.getModifiers()); @@ -613,20 +656,27 @@ public CompileVisitResult visitCall(final @NotNull CallExpression expression) { } return CompileVisitResult.VOID; } else { - if (targetType != null && !returnTypeDesc.equals(targetType)) { - ClassFileUtil.addCast(codeBuilder, returnTypeDesc, targetType); - return new CompileVisitResult(targetType); + // the interpreter replaces NaN and infinities returned by Java methods with zero + final Binding binding = nativeMethod.getDeclaredAnnotation(Binding.class); + if (binding == null || !binding.skipChecking()) { + addNormalize(returnTypeDesc); } - return new CompileVisitResult(returnTypeDesc); + return castTo(returnTypeDesc, targetType); } } else { - throw new UnsupportedOperationException("Not supporting non-Java functions yet"); + throw unsupported("functions that aren't Java methods bound with @Binding", expression); } } @Override public CompileVisitResult visit(final @NotNull Expression expression) { - throw new UnsupportedOperationException("Unsupported expression type: " + expression); + final String what = switch (expression) { + case ArrayAccessExpression ignored -> "array access"; + case ExecutionScopeExpression ignored -> "blocks ({ ... })"; + case StatementExpression ignored -> "break and continue"; + default -> expression.getClass().getSimpleName(); + }; + throw unsupported(what, expression); } private void addConst0(final ClassDesc type) { diff --git a/runtime-compiler/src/main/java/org/redlance/mocha/runtime/util/ClassFileUtil.java b/runtime-compiler/src/main/java/org/redlance/mocha/runtime/util/ClassFileUtil.java index dfce3fa..9ab7193 100644 --- a/runtime-compiler/src/main/java/org/redlance/mocha/runtime/util/ClassFileUtil.java +++ b/runtime-compiler/src/main/java/org/redlance/mocha/runtime/util/ClassFileUtil.java @@ -245,6 +245,11 @@ public static void addCastDoubleTo(final @NotNull CodeBuilder cb, final @NotNull cb.d2l(); } else if (to.equals(CD_float)) { cb.d2f(); + } else if (to.equals(CD_boolean)) { + // compare instead of truncating, 0.5 is true + cb.dconst_0(); + cb.dcmpl(); + addCastIntTo(cb, CD_boolean); } else if (to.equals(CD_void)) { throw new IllegalArgumentException("Cannot cast double to void"); } else { @@ -271,6 +276,11 @@ public static void addCastLongTo(final @NotNull CodeBuilder cb, final @NotNull C cb.l2d(); } else if (to.equals(CD_float)) { cb.l2f(); + } else if (to.equals(CD_boolean)) { + // compare instead of truncating to an int, 2^32 is true + cb.lconst_0(); + cb.lcmp(); + addCastIntTo(cb, CD_boolean); } else if (to.equals(CD_void)) { throw new IllegalArgumentException("Cannot cast long to void"); } else { @@ -297,6 +307,11 @@ public static void addCastFloatTo(final @NotNull CodeBuilder cb, final @NotNull cb.f2d(); } else if (to.equals(CD_long)) { cb.f2l(); + } else if (to.equals(CD_boolean)) { + // compare instead of truncating, 0.5 is true + cb.fconst_0(); + cb.fcmpl(); + addCastIntTo(cb, CD_boolean); } else if (to.equals(CD_void)) { throw new IllegalArgumentException("Cannot cast float to void"); } else { diff --git a/runtime-compiler/src/test/java/org/redlance/mocha/runtime/jvm/CompilerSemanticsTest.java b/runtime-compiler/src/test/java/org/redlance/mocha/runtime/jvm/CompilerSemanticsTest.java new file mode 100644 index 0000000..d8d7c49 --- /dev/null +++ b/runtime-compiler/src/test/java/org/redlance/mocha/runtime/jvm/CompilerSemanticsTest.java @@ -0,0 +1,169 @@ +/* + * This file is part of mocha, licensed under the MIT license + * + * Copyright (c) 2021-2025 Unnamed Team + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.redlance.mocha.runtime.jvm; + +import org.junit.jupiter.api.Test; +import org.redlance.mocha.runtime.ExecutionContext; +import org.redlance.mocha.runtime.MochaEngine; +import org.redlance.mocha.runtime.binding.Binding; +import org.redlance.mocha.runtime.compiled.MochaCompiledFunction; +import org.redlance.mocha.runtime.compiled.Named; +import org.redlance.mocha.runtime.value.Function; +import org.redlance.mocha.runtime.value.MutableObjectBinding; +import org.redlance.mocha.runtime.value.NumberValue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.redlance.mocha.MochaAssertions.assertEvaluatesAndCompiles; + +/** + * Compiled functions must give the interpreter's results, and constructs + * the compiler doesn't support must be rejected while compiling instead of + * producing invalid bytecode or a wrong result. + */ +class CompilerSemanticsTest { + @Test + void temp_variables_with_any_return_type() { + final MochaEngine engine = MochaEngine.createStandard(); + assertEquals(6F, engine.compiler().compile("t.x = 2; return t.x * a;", FloatFn.class).get(3)); + assertEquals(6D, engine.compiler().compile("t.x = 2; return t.x * a;", DoubleFn.class).get(3)); + assertEquals(6, engine.compiler().compile("t.x = 2; return t.x * a;", IntFn.class).get(3)); + assertTrue(engine.compiler().compile("t.x = a; return t.x > 1;", BoolFn.class).get(3)); + assertFalse(engine.compiler().compile("t.x = a; return t.x > 1;", BoolFn.class).get(1)); + } + + @Test + void assignment_as_last_statement() { + assertEvaluatesAndCompiles(2, "t.x = 2"); + assertEvaluatesAndCompiles(5, "t.x = 2; t.y = t.x + 3"); + } + + @Test + void fractions_are_true() { + final MochaEngine engine = MochaEngine.createStandard(); + assertTrue(engine.compiler().compile("a", BoolFn.class).get(0.5F)); + assertTrue(engine.compiler().compile("a", DoubleBoolFn.class).get(0.5)); + assertFalse(engine.compiler().compile("a", BoolFn.class).get(0)); + assertEquals(1F, engine.compiler().compile("a && 1", FloatFn.class).get(0.5F)); + assertEquals(10F, engine.compiler().compile("a ? 10 : 20", FloatFn.class).get(0.5F)); + assertEquals(0F, engine.compiler().compile("!a", FloatFn.class).get(0.5F)); + assertEquals(1F, engine.compiler().compile("math.sin(a) && 1", FloatFn.class).get(30)); + assertEquals(1F, engine.compiler().compile("(a + 0.25) || 0", FloatFn.class).get(0.25F)); + } + + @Test + void division_by_zero_and_non_finite_results() { + final MochaEngine engine = MochaEngine.createStandard(); + assertEquals(0F, engine.compiler().compile("1 / a", FloatFn.class).get(0)); + assertEquals(0.5F, engine.compiler().compile("1 / a", FloatFn.class).get(2)); + assertEquals(0F, engine.compiler().compile("math.ln(a)", FloatFn.class).get(0)); + assertEquals(0F, engine.compiler().compile("math.sqrt(a)", FloatFn.class).get(-1)); + assertEquals(5F, engine.compiler().compile("math.ln(a) + 5", FloatFn.class).get(0)); + assertEquals(0F, engine.compiler().compile("a * 3e38", FloatFn.class).get(10)); + assertEvaluatesAndCompiles(0, "1 / 0"); + } + + @Test + void conditional_without_else() { + final MochaEngine engine = MochaEngine.createStandard(); + assertEquals(5F, engine.compiler().compile("a ? 5", FloatFn.class).get(1)); + assertEquals(0F, engine.compiler().compile("a ? 5", FloatFn.class).get(0)); + } + + @Test + void same_results_as_the_interpreter() { + assertEvaluatesAndCompiles(-30, "-math.sin(90) * 30"); + assertEvaluatesAndCompiles(1, "math.pi > 3 && math.pi < 4"); + assertEvaluatesAndCompiles(6, "t.a = 1; t.b = t.a + 1; return t.a + t.b * 2 + 1;"); + assertEvaluatesAndCompiles(20, "t.missing ? 10 : 20"); + assertEvaluatesAndCompiles(0, "unknown.thing + nothing()"); + } + + @Test + void unsupported_constructs_are_rejected() { + final MochaEngine engine = MochaEngine.createStandard(); + engine.interpreter().bind(StaticBindings.class); + engine.interpreter().bindInstance(InstanceBindings.class, new InstanceBindings(), "instance"); + final MutableObjectBinding query = new MutableObjectBinding(); + query.set("speed", (Function) (ctx, args) -> NumberValue.of(2)); + engine.scope().set("q", query); + + for (final String code : new String[]{ + "v.x = 2; return v.x;", + "v.preset", + "q.speed()", + "q.speed * 2", + "t.a ?? 3", + "v.a -> v.b", + "t.x[0]", + "loop(3, { t.x = t.x + 1; }); return t.x;", + "for_each(t.x, v.list, { t.y = t.x; })", + "break", + "instance.speed", + "bindings.with_context(5)" + }) { + final UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> engine.compiler().compile(code), + code + ); + assertTrue(exception.getMessage().startsWith("The compiler doesn't support"), exception::getMessage); + } + } + + public interface FloatFn extends MochaCompiledFunction { + float get(@Named("a") float a); + } + + public interface DoubleFn extends MochaCompiledFunction { + double get(@Named("a") double a); + } + + public interface IntFn extends MochaCompiledFunction { + int get(@Named("a") int a); + } + + public interface BoolFn extends MochaCompiledFunction { + boolean get(@Named("a") float a); + } + + public interface DoubleBoolFn extends MochaCompiledFunction { + boolean get(@Named("a") double a); + } + + @Binding("bindings") + public static final class StaticBindings { + @Binding("with_context") + public static float withContext(final ExecutionContext context, final float value) { + return value; + } + } + + public static final class InstanceBindings { + @Binding("speed") + public float speed = 3; + } +} diff --git a/runtime/src/main/java/org/redlance/mocha/runtime/IsConstantExpression.java b/runtime/src/main/java/org/redlance/mocha/runtime/IsConstantExpression.java index 4d76216..1c8c68b 100644 --- a/runtime/src/main/java/org/redlance/mocha/runtime/IsConstantExpression.java +++ b/runtime/src/main/java/org/redlance/mocha/runtime/IsConstantExpression.java @@ -165,11 +165,12 @@ public static boolean test(final @NotNull Expression expression, final @NotNull final Expression functionExpr = expression.function(); - // check for built-in functions + // check for built-in functions, their blocks usually assign variables, + // so evaluating them ahead of time would lose those assignments if (functionExpr instanceof IdentifierExpression) { final String name = ((IdentifierExpression) functionExpr).name(); if (name.equalsIgnoreCase("loop") || name.equalsIgnoreCase("for_each")) { - return true; + return false; } } From caeb0b2fa3dc2c4b95781d93b15b6b147c413cd1 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:00:33 +0700 Subject: [PATCH 10/13] Describe mochafloats in the readme and docs instead of Mocha The readme and the docs folder still described Mocha 3: Java 8+, Javassist, team.unnamed:mocha on Maven Central, Mocha's build badge and Discord, and an API where MochaEngine evaluates and compiles by itself. They now describe the modules, the Redlance repository, Java 24 and the interpreter/compiler API, and link to the full documentation. gradle.properties set group=team.unnamed; it's now org.redlance.mochafloats, which the publications already used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- docs/bindings.md | 33 ++++++++++++++++---- docs/getting-started.md | 45 ++++++++++----------------- docs/installation.md | 38 +++++++++++++++-------- docs/usage.md | 67 +++++++++++++++++------------------------ gradle.properties | 2 +- readme.md | 33 ++++++++++---------- 6 files changed, 115 insertions(+), 103 deletions(-) diff --git a/docs/bindings.md b/docs/bindings.md index ab44e5e..e136c3d 100644 --- a/docs/bindings.md +++ b/docs/bindings.md @@ -1,7 +1,26 @@ ## Bindings -You can bind functions by creating a holder class with static methods and -annotating everything with `@Binding`, see the example below +### Functions + +The simplest binding is a `MutableObjectBinding` holding functions; the interpreter calls +them directly. + + +```java +MolangInterpreter molang = MolangInterpreter.standard(); + +MutableObjectBinding query = new MutableObjectBinding(); +query.set("get_age", (Function) (ctx, args) -> NumberValue.of(18)); +molang.scope().set("query", query); + +molang.eval("query.get_age()"); // evaluates to 18 +``` + + +### Java methods + +Annotate a class and its static members with `@Binding` and bind the class. Compiled code +calls these methods directly; the interpreter calls them through a method handle. ```java @@ -15,12 +34,14 @@ public class RandomBinding { } // ... -MochaEngine mocha = MochaEngine.createStandard(); +MochaEngine engine = MochaEngine.createStandard(); -mocha.bind(RandomBinding.class); +engine.interpreter().bind(RandomBinding.class); -mocha.compile("random.select(1, 2)").evaluate(); // evaluates to either 1 or 2 +engine.compiler().compile("random.select(1, 2)").evaluate(); // evaluates to either 1 or 2 // generates the following code: // return RandomBinding.select(1, 2); ``` - \ No newline at end of file + + +`bindInstance(type, instance, name)` binds the non-static members of an object instead. diff --git a/docs/getting-started.md b/docs/getting-started.md index 4538beb..1e78b16 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,37 +1,24 @@ ## Getting Started -Welcome to the `mocha` documentation +`mochafloats` is a Molang lexer, parser, interpreter and JVM bytecode compiler for Java 24+, +forked from [Mocha](https://github.com/unnamed/mocha). Molang is the expression language +Minecraft Bedrock uses for data-driven values; for the language itself, see +[learn.microsoft.com](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/molangreference/examples/molangconcepts/molangintroduction?view=minecraft-bedrock-stable). -`mocha` is a lightweight, fast and efficient Molang lexer, parser, interpreter -and compiler for Java 8+. Molang is a simple **expression-based** language designed -for fast and **data-driven** calculation of values at run-time. +The full documentation is at [docs.zigythebird.com/mochafloats](https://docs.zigythebird.com/mochafloats/intro). -For more information about the Molang language, check their official documentation -at [learn.microsoft.com](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/molangreference/examples/molangconcepts/molangintroduction?view=minecraft-bedrock-stable). +### Modules -### Features +| Module | Contains | +|--------------------|---------------------------------------------------------------------------| +| `lexer` | The tokenizer. | +| `parser` | The parser, the syntax tree and its network format (Netty `ByteBuf`). | +| `runtime` | `MolangInterpreter`, scopes, values, bindings and the `math` library. | +| `runtime-compiler` | `MolangCompiler`, which compiles expressions to bytecode, and `MochaEngine`. | -- Fast computing. mocha can compile Molang expressions directly to JVM bytecode, - no need to parse and interpret the expression every time, zero overhead. -- Extensive API, you can bind objects and functions to the Molang expression - environment, so that they can be called from the evaluated expressions. -- Lightweight, mocha is a small library with a single dependency: javassist, it's easy to - integrate in your project. +Each module depends on the one above it, so declare only the one you need. Most projects only need `runtime`. -### mocha vs other Molang libraries +### Benchmark -Here are some (reduced) results from a benchmark comparing mocha with other Molang -libraries, ordered from fastest to slowest. *(To run the benchmark yourself, run -`./gradlew jmh`)* - - -```python - Benchmark Mode Cnt Score Error Units -1. unnamed's mocha sample 8874677 0.035 ± 0.006 us/op -2. MoonflowerTeam's molang-compiler sample 7507288 0.041 ± 0.007 us/op -3. bedrockk's MoLang sample 5404802 6.449 ± 0.158 us/op -``` - - -*To run the benchmark yourself, run `./gradlew jmh`. You can also check the benchmark -source code [here](https://github.com/unnamed/mocha/blob/main/src/jmh/java/team/unnamed/mocha/CompareBenchmark.java)*. \ No newline at end of file +`./gradlew jmh` compares mochafloats with Moonflower's molang-compiler and bedrockk's MoLang. +The benchmark source is in [`src/jmh`](../src/jmh/java/team/unnamed/mocha/CompareBenchmark.java). diff --git a/docs/installation.md b/docs/installation.md index 392f419..39f45db 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,16 +1,18 @@ ## Installation -You can add `mocha` to your project using [Gradle](https://gradle.org/) -*(recommended)*, [Maven](https://maven.apache.org/) or manually downloading the -JAR files from [GitHub Releases](https://github.com/unnamed/mocha/releases). - -Note that `mocha` is available in the Maven Central Repository. +mochafloats is published to `https://repo.redlance.org/public` under the group +`org.redlance.mochafloats`, with one artifact per module: `lexer`, `parser`, `runtime` +and `runtime-compiler`. It requires Java 24 or newer. ### Gradle ```kotlin +repositories { + maven("https://repo.redlance.org/public") +} + dependencies { - implementation("team.unnamed:mocha:%%REPLACE_latestRelease{team.unnamed:mocha}%%") + implementation("org.redlance.mochafloats:runtime:") } ``` @@ -18,10 +20,22 @@ dependencies { ```xml - - team.unnamed - mocha - %%REPLACE_latestRelease{team.unnamed:mocha}%% - + + + redlance + https://repo.redlance.org/public + + + + + + org.redlance.mochafloats + runtime + VERSION + + ``` - \ No newline at end of file + + +Use `runtime-compiler` instead of `runtime` to compile expressions to bytecode. +The `parser` module depends on `netty-buffer`, which Minecraft already provides. diff --git a/docs/usage.md b/docs/usage.md index 787bfcf..c184f88 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,85 +1,74 @@ ## Usage -Create a new MochaEngine instance with the static factory method `MochaEngine.createStandard()`, -which will create the instance with the standard configuration, where everything tries to be -as spec-compliant as possible. - - -```java -MochaEngine mocha = MochaEngine.createStandard(); -``` - - -Then we can either evaluate (interpret) or compile Molang code. - ### Evaluate -To evaluate expressions we can just use the `eval` method. +`MolangInterpreter.standard()` creates an interpreter with the standard `math`, `variable` +and `v` bindings. `eval` parses and evaluates Molang code and returns a `float`. ```java -float result = mocha.eval("math.sqrt(3 * 3 + 4 * 4)"); +MolangInterpreter molang = MolangInterpreter.standard(); + +float result = molang.eval("math.sqrt(3 * 3 + 4 * 4)"); // evaluates to 5.0 -float result2 = mocha.eval("math.abs(-5) + 5"); +float result2 = molang.eval("math.abs(-5) + 5"); // evaluates to 10.0 ``` -Or if we might want to evaluate the same expression multiple times, -we can cache the parsed expressions. +To evaluate the same code many times, parse it once and keep the expressions. ```java -MochaFunction function = mocha.prepareEval("math.sqrt(3 * 3 + 4 * 4)"); +List expressions = MolangParser.parseAll("math.sqrt(3 * 3 + 4 * 4)"); -function.evaluate(); -// evaluates to 5.0 - -function.evaluate(); +molang.eval(expressions); // evaluates to 5.0 ``` +`prepareEval(code)` does the same and returns a `Supplier`. + ### Compile -Compiling expressions is pretty similar to preparing them and evaluating them -later, but faster. +`MochaEngine` (in `runtime-compiler`) holds an interpreter and a compiler that share one +scope. A compiled function runs without any interpretation overhead; constant parts are +evaluated while compiling. ```java -MochaFunction function = mocha.compile("math.sqrt(3 * 3 + 4 * 4)"); -// will compile a class that implements MochaFunction, with the following -// code: -// return Math.sqrt(25); -// note that the compiler can optimize constant expressions like 3*3+4*4 to just 25 +MochaEngine engine = MochaEngine.createStandard(); -function.evaluate(); -// evaluates to 5.0, no interpretation overhead +MochaFunction function = engine.compiler().compile("math.sqrt(3 * 3 + 4 * 4)"); +// compiles a class that implements MochaFunction and returns 5.0 function.evaluate(); -// evaluates to 5.0, no interpretation overhead +// evaluates to 5.0 ``` -We could also specify the function type we would like to get. +The compiled class can implement your own interface with a single method; its parameters +are available by name. ```java interface CompareFunction extends MochaCompiledFunction { - boolean compare(@Named("a") double a, @Named("b") double b); + boolean compare(@Named("a") float a, @Named("b") float b); } // ... -CompareFunction gt = mocha.compile("a > b", CompareFunction.class); +CompareFunction gt = engine.compiler().compile("a > b", CompareFunction.class); gt.compare(5, 4); // true gt.compare(4, 5); // false - -gt.compare(5, 5); -// false ``` - \ No newline at end of file + + +The compiler supports a subset of Molang: arithmetic, comparisons, logic, conditionals, +temp variables, interface parameters and Java methods and static fields bound with +`@Binding`. Evaluate anything else, such as `variable`, loops or lambda bindings, with the +interpreter. diff --git a/gradle.properties b/gradle.properties index 0a02714..7dae0f4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -group=team.unnamed +group=org.redlance.mochafloats version=6.0.2 description=A lightweight, fast and efficient Molang lexer, parser, interpreter and compiler for Java 24+ kotlin.stdlib.default.dependency=false \ No newline at end of file diff --git a/readme.md b/readme.md index e7c8936..a07e7ad 100644 --- a/readme.md +++ b/readme.md @@ -1,22 +1,23 @@ -# mocha +# mochafloats -[![Build Status](https://img.shields.io/github/actions/workflow/status/unnamed/mocha/build.yml?branch=main)](https://github.com/unnamed/mocha/actions/workflows/build.yml) +[![Maven](https://img.shields.io/maven-metadata/v?metadataUrl=https://repo.redlance.org/public/org/redlance/mochafloats/runtime/maven-metadata.xml&label=maven)](https://repo.redlance.org/#/public/org/redlance/mochafloats) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](license.txt) -[![Discord](https://img.shields.io/discord/683899335405994062)](https://discord.gg/xbba2fy) +[![Discord](https://img.shields.io/badge/discord-PlayerAnimationLibrary-5865F2)](https://discord.com/invite/PSW2t4Ujm6) -`mocha` is a lightweight, fast and efficient Molang lexer, parser, interpreter and compiler -for Java 8+. Molang is a simple **expression-based** language designed for fast and **data-driven** -calculation of values at run-time. +`mochafloats` is a Molang lexer, parser, interpreter and JVM bytecode compiler for Java 24+. +Molang is the small expression language Minecraft Bedrock uses for data-driven values; pretty much +everything in it evaluates to a number. -Its focus is to enable low-level systems like animation to support flexible data-driven -behavior, while staying highly performant. +It is a fork of [Mocha](https://github.com/unnamed/mocha) by Unnamed Team, maintained by the +PlayerAnimationLibrary team. It computes with `float` instead of `double`, is split into modules, +uses the JDK's ClassFile API instead of Javassist, can send parsed expressions over the network, +and fixes many of Mocha's bugs. -Pretty much everything in this language evaluates to a number; if something doesn't evaluate -to a number, you can use an operator to make it into one. You can basically just think of -Molang as one big math equation. +```java +MolangInterpreter molang = MolangInterpreter.standard(); +float result = molang.eval("math.sqrt(3 * 3 + 4 * 4)"); // 5.0 +``` -This library lets programmers easily evaluate Molang expressions and bind objects -or functions so that they can be called from the evaluated expressions. - -Check the [documentation](https://unnamed.team/docs/molang) for installation, usage and -some more information for this project \ No newline at end of file +The documentation, with installation, usage and the differences from Mocha, is at +[docs.zigythebird.com/mochafloats](https://docs.zigythebird.com/mochafloats/intro). +The [`docs`](docs) folder has a short version. From ee24554d4420b5e079a5e2fcbc952d6b98916740 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:32:28 +0700 Subject: [PATCH 11/13] Build and test on every push and pull request, and test before publishing The only workflow ran on workflow_dispatch and ran "./gradlew publish", which doesn't depend on test: pushes and pull requests were never built, and a release could be published with failing tests. - build.yml now runs "./gradlew build" on pushes to main and on pull requests. It doesn't use any secret, so it also works for pull requests from forks. - publish.yml keeps the manual trigger, the secrets and the steps of the old workflow, and runs "./gradlew build publish". The test task no longer runs generateExpectations, so the tests don't need Node.js and use the committed expectations.txt. build.yml regenerates it with the Node.js the runner already has and fails if it differs, so a tests.txt change can't be merged without regenerating it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy --- .github/workflows/build.yml | 30 ++++++++++++++---------------- .github/workflows/publish.yml | 34 ++++++++++++++++++++++++++++++++++ runtime-compiler/build.gradle | 6 ++---- 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25741f1..ea8094b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,11 @@ name: 'build' -on: [ 'workflow_dispatch' ] +on: + push: + branches: [ 'main' ] + pull_request: + +permissions: + contents: read jobs: build: @@ -8,26 +14,18 @@ jobs: - name: Checkout Branch uses: actions/checkout@v7 - - name: Restore gradle.properties - shell: bash - env: - MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} - run: | - mkdir -p ~/.gradle/ - echo "GRADLE_USER_HOME=${HOME}/.gradle" >> $GITHUB_ENV - echo "${MAVEN_TOKEN}" > ~/.gradle/gradle.properties - - name: Setup Java uses: actions/setup-java@v6 with: distribution: microsoft java-version: 25 + cache: gradle - - name: Grant Execute Permission - run: chmod +x gradlew + # the tests compare with expectations.txt, which MolangJS generates from tests.txt + - name: Check MolangJS Expectations + run: | + ./gradlew :runtime-compiler:generateExpectations + git diff --exit-code -- runtime-compiler/src/test/resources/expectations.txt - name: Build - run: | - ./gradlew publish - env: - MAVEN_PASSWORD: ${{ secrets.REDLANCE_PASSWORD }} + run: ./gradlew build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9c2fba0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: 'publish' +on: [ 'workflow_dispatch' ] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout Branch + uses: actions/checkout@v7 + + - name: Restore gradle.properties + shell: bash + env: + MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} + run: | + mkdir -p ~/.gradle/ + echo "GRADLE_USER_HOME=${HOME}/.gradle" >> $GITHUB_ENV + echo "${MAVEN_TOKEN}" > ~/.gradle/gradle.properties + + - name: Setup Java + uses: actions/setup-java@v6 + with: + distribution: microsoft + java-version: 25 + + - name: Grant Execute Permission + run: chmod +x gradlew + + # build runs the tests and the license checks, so a failing build is never published + - name: Build and Publish + run: | + ./gradlew build publish + env: + MAVEN_PASSWORD: ${{ secrets.REDLANCE_PASSWORD }} diff --git a/runtime-compiler/build.gradle b/runtime-compiler/build.gradle index 3e89378..d1c1bb6 100644 --- a/runtime-compiler/build.gradle +++ b/runtime-compiler/build.gradle @@ -8,6 +8,8 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-stdlib:2.4.20") } +// Regenerates src/test/resources/expectations.txt with MolangJS (needs Node.js). +// Run it after changing tests.txt; the tests use the committed file, and CI checks it's up to date. tasks.register('generateExpectations', Exec) { workingDir = rootProject.projectDir def nvmDir = file("${System.getenv('HOME')}/.nvm/versions/node") @@ -18,7 +20,3 @@ tasks.register('generateExpectations', Exec) { tasks.named("compileTestKotlin") { enabled = true } - -tasks.named("test") { - dependsOn tasks.named("generateExpectations") -} From 1b2966d7166c0f4e97a0d7f16dd584df5ff801e9 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:47:37 +0700 Subject: [PATCH 12/13] Bump version to 6.1.0 Co-Authored-By: Claude Opus 5.5 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 7dae0f4..b772e91 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ group=org.redlance.mochafloats -version=6.0.2 +version=6.1.0 description=A lightweight, fast and efficient Molang lexer, parser, interpreter and compiler for Java 24+ kotlin.stdlib.default.dependency=false \ No newline at end of file From 364cd5edff5aaeeb9db6062c717ad62d00d4f460 Mon Sep 17 00:00:00 2001 From: dima_dencep Date: Sun, 4 Oct 2026 16:52:16 +0700 Subject: [PATCH 13/13] Remove the docs folder The documentation now lives only in PlayerAnimationLibrary/Documentation, published at docs.zigythebird.com/mochafloats, which the readme links to. Co-Authored-By: Claude Opus 5.5 --- docs/bindings.md | 47 -------------------------- docs/getting-started.md | 24 ------------- docs/index.txt | 4 --- docs/installation.md | 41 ----------------------- docs/usage.md | 74 ----------------------------------------- readme.md | 1 - 6 files changed, 191 deletions(-) delete mode 100644 docs/bindings.md delete mode 100644 docs/getting-started.md delete mode 100644 docs/index.txt delete mode 100644 docs/installation.md delete mode 100644 docs/usage.md diff --git a/docs/bindings.md b/docs/bindings.md deleted file mode 100644 index e136c3d..0000000 --- a/docs/bindings.md +++ /dev/null @@ -1,47 +0,0 @@ -## Bindings - -### Functions - -The simplest binding is a `MutableObjectBinding` holding functions; the interpreter calls -them directly. - - -```java -MolangInterpreter molang = MolangInterpreter.standard(); - -MutableObjectBinding query = new MutableObjectBinding(); -query.set("get_age", (Function) (ctx, args) -> NumberValue.of(18)); -molang.scope().set("query", query); - -molang.eval("query.get_age()"); // evaluates to 18 -``` - - -### Java methods - -Annotate a class and its static members with `@Binding` and bind the class. Compiled code -calls these methods directly; the interpreter calls them through a method handle. - - -```java -@Binding("random") -public class RandomBinding { - // random.select(a, b) - @Binding("select") - public static float select(float a, float b) { - return Math.random() < 0.5 ? a : b; - } -} - -// ... -MochaEngine engine = MochaEngine.createStandard(); - -engine.interpreter().bind(RandomBinding.class); - -engine.compiler().compile("random.select(1, 2)").evaluate(); // evaluates to either 1 or 2 -// generates the following code: -// return RandomBinding.select(1, 2); -``` - - -`bindInstance(type, instance, name)` binds the non-static members of an object instead. diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 1e78b16..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,24 +0,0 @@ -## Getting Started - -`mochafloats` is a Molang lexer, parser, interpreter and JVM bytecode compiler for Java 24+, -forked from [Mocha](https://github.com/unnamed/mocha). Molang is the expression language -Minecraft Bedrock uses for data-driven values; for the language itself, see -[learn.microsoft.com](https://learn.microsoft.com/en-us/minecraft/creator/reference/content/molangreference/examples/molangconcepts/molangintroduction?view=minecraft-bedrock-stable). - -The full documentation is at [docs.zigythebird.com/mochafloats](https://docs.zigythebird.com/mochafloats/intro). - -### Modules - -| Module | Contains | -|--------------------|---------------------------------------------------------------------------| -| `lexer` | The tokenizer. | -| `parser` | The parser, the syntax tree and its network format (Netty `ByteBuf`). | -| `runtime` | `MolangInterpreter`, scopes, values, bindings and the `math` library. | -| `runtime-compiler` | `MolangCompiler`, which compiles expressions to bytecode, and `MochaEngine`. | - -Each module depends on the one above it, so declare only the one you need. Most projects only need `runtime`. - -### Benchmark - -`./gradlew jmh` compares mochafloats with Moonflower's molang-compiler and bedrockk's MoLang. -The benchmark source is in [`src/jmh`](../src/jmh/java/team/unnamed/mocha/CompareBenchmark.java). diff --git a/docs/index.txt b/docs/index.txt deleted file mode 100644 index 88f16f6..0000000 --- a/docs/index.txt +++ /dev/null @@ -1,4 +0,0 @@ -getting-started.md -installation.md -usage.md -bindings.md \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md deleted file mode 100644 index 39f45db..0000000 --- a/docs/installation.md +++ /dev/null @@ -1,41 +0,0 @@ -## Installation - -mochafloats is published to `https://repo.redlance.org/public` under the group -`org.redlance.mochafloats`, with one artifact per module: `lexer`, `parser`, `runtime` -and `runtime-compiler`. It requires Java 24 or newer. - -### Gradle - -```kotlin -repositories { - maven("https://repo.redlance.org/public") -} - -dependencies { - implementation("org.redlance.mochafloats:runtime:") -} -``` - -### Maven - - -```xml - - - redlance - https://repo.redlance.org/public - - - - - - org.redlance.mochafloats - runtime - VERSION - - -``` - - -Use `runtime-compiler` instead of `runtime` to compile expressions to bytecode. -The `parser` module depends on `netty-buffer`, which Minecraft already provides. diff --git a/docs/usage.md b/docs/usage.md deleted file mode 100644 index c184f88..0000000 --- a/docs/usage.md +++ /dev/null @@ -1,74 +0,0 @@ -## Usage - -### Evaluate - -`MolangInterpreter.standard()` creates an interpreter with the standard `math`, `variable` -and `v` bindings. `eval` parses and evaluates Molang code and returns a `float`. - - -```java -MolangInterpreter molang = MolangInterpreter.standard(); - -float result = molang.eval("math.sqrt(3 * 3 + 4 * 4)"); -// evaluates to 5.0 - -float result2 = molang.eval("math.abs(-5) + 5"); -// evaluates to 10.0 -``` - - -To evaluate the same code many times, parse it once and keep the expressions. - - -```java -List expressions = MolangParser.parseAll("math.sqrt(3 * 3 + 4 * 4)"); - -molang.eval(expressions); -// evaluates to 5.0 -``` - - -`prepareEval(code)` does the same and returns a `Supplier`. - -### Compile - -`MochaEngine` (in `runtime-compiler`) holds an interpreter and a compiler that share one -scope. A compiled function runs without any interpretation overhead; constant parts are -evaluated while compiling. - - -```java -MochaEngine engine = MochaEngine.createStandard(); - -MochaFunction function = engine.compiler().compile("math.sqrt(3 * 3 + 4 * 4)"); -// compiles a class that implements MochaFunction and returns 5.0 - -function.evaluate(); -// evaluates to 5.0 -``` - - -The compiled class can implement your own interface with a single method; its parameters -are available by name. - - -```java -interface CompareFunction extends MochaCompiledFunction { - boolean compare(@Named("a") float a, @Named("b") float b); -} - -// ... -CompareFunction gt = engine.compiler().compile("a > b", CompareFunction.class); - -gt.compare(5, 4); -// true - -gt.compare(4, 5); -// false -``` - - -The compiler supports a subset of Molang: arithmetic, comparisons, logic, conditionals, -temp variables, interface parameters and Java methods and static fields bound with -`@Binding`. Evaluate anything else, such as `variable`, loops or lambda bindings, with the -interpreter. diff --git a/readme.md b/readme.md index a07e7ad..9d230a4 100644 --- a/readme.md +++ b/readme.md @@ -20,4 +20,3 @@ float result = molang.eval("math.sqrt(3 * 3 + 4 * 4)"); // 5.0 The documentation, with installation, usage and the differences from Mocha, is at [docs.zigythebird.com/mochafloats](https://docs.zigythebird.com/mochafloats/intro). -The [`docs`](docs) folder has a short version.