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
-[](https://github.com/unnamed/mocha/actions/workflows/build.yml)
+[](https://repo.redlance.org/#/public/org/redlance/mochafloats)
[](license.txt)
-[](https://discord.gg/xbba2fy)
+[](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.