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/docs/bindings.md b/docs/bindings.md
deleted file mode 100644
index ab44e5e..0000000
--- a/docs/bindings.md
+++ /dev/null
@@ -1,26 +0,0 @@
-## Bindings
-
-You can bind functions by creating a holder class with static methods and
-annotating everything with `@Binding`, see the example below
-
-
-```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> mocha = MochaEngine.createStandard();
-
-mocha.bind(RandomBinding.class);
-
-mocha.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
diff --git a/docs/getting-started.md b/docs/getting-started.md
deleted file mode 100644
index 4538beb..0000000
--- a/docs/getting-started.md
+++ /dev/null
@@ -1,37 +0,0 @@
-## Getting Started
-
-Welcome to the `mocha` documentation
-
-`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.
-
-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).
-
-### Features
-
-- 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.
-
-### mocha vs other Molang libraries
-
-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
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 392f419..0000000
--- a/docs/installation.md
+++ /dev/null
@@ -1,27 +0,0 @@
-## 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.
-
-### Gradle
-
-```kotlin
-dependencies {
- implementation("team.unnamed:mocha:%%REPLACE_latestRelease{team.unnamed:mocha}%%")
-}
-```
-
-### Maven
-
-
-```xml
-
- team.unnamed
- mocha
- %%REPLACE_latestRelease{team.unnamed:mocha}%%
-
-```
-
\ No newline at end of file
diff --git a/docs/usage.md b/docs/usage.md
deleted file mode 100644
index 787bfcf..0000000
--- a/docs/usage.md
+++ /dev/null
@@ -1,85 +0,0 @@
-## 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.
-
-
-```java
-float result = mocha.eval("math.sqrt(3 * 3 + 4 * 4)");
-// evaluates to 5.0
-
-float result2 = mocha.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.
-
-
-```java
-MochaFunction function = mocha.prepareEval("math.sqrt(3 * 3 + 4 * 4)");
-
-function.evaluate();
-// evaluates to 5.0
-
-function.evaluate();
-// evaluates to 5.0
-```
-
-
-### Compile
-
-Compiling expressions is pretty similar to preparing them and evaluating them
-later, but faster.
-
-
-```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
-
-function.evaluate();
-// evaluates to 5.0, no interpretation overhead
-
-function.evaluate();
-// evaluates to 5.0, no interpretation overhead
-```
-
-
-We could also specify the function type we would like to get.
-
-
-```java
-interface CompareFunction extends MochaCompiledFunction {
- boolean compare(@Named("a") double a, @Named("b") double b);
-}
-
-// ...
-CompareFunction gt = mocha.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
diff --git a/gradle.properties b/gradle.properties
index 0a02714..b772e91 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
-group=team.unnamed
-version=6.0.2
+group=org.redlance.mochafloats
+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
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..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,15 @@ 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();
+ break;
+ }
Expression compoundExpression = MolangParserImpl.parseCompoundExpression(lexer, 0);
if (compoundExpression != null) expressions.add(compoundExpression);
token = lexer.current();
@@ -155,7 +164,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 +291,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 +334,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);
}
@@ -350,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/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/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/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/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));
+ }
+}
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..04a6181
--- /dev/null
+++ b/parser/src/test/java/org/redlance/mocha/parser/GroupingTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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 Bedrock and 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[]{
+ "1 +",
+ "math.abs(1,)",
+ "q.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());
+ 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 + ")";
+ }
+ };
+ }
+}
diff --git a/readme.md b/readme.md
index e7c8936..9d230a4 100644
--- a/readme.md
+++ b/readme.md
@@ -1,22 +1,22 @@
-# 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).
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")
-}
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/ExpressionInterpreter.java b/runtime/src/main/java/org/redlance/mocha/runtime/ExpressionInterpreter.java
index 084da29..fca581b 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()),
@@ -73,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);
@@ -110,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;
@@ -140,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(),
@@ -300,12 +315,14 @@ 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());
}
- 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
@@ -335,6 +352,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(
@@ -384,9 +417,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/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;
}
}
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/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/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java b/runtime/src/main/java/org/redlance/mocha/runtime/binding/ReflectiveFunction.java
index ab82a88..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
@@ -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);
}
@@ -108,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