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 -[![Build Status](https://img.shields.io/github/actions/workflow/status/unnamed/mocha/build.yml?branch=main)](https://github.com/unnamed/mocha/actions/workflows/build.yml) +[![Maven](https://img.shields.io/maven-metadata/v?metadataUrl=https://repo.redlance.org/public/org/redlance/mochafloats/runtime/maven-metadata.xml&label=maven)](https://repo.redlance.org/#/public/org/redlance/mochafloats) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](license.txt) -[![Discord](https://img.shields.io/discord/683899335405994062)](https://discord.gg/xbba2fy) +[![Discord](https://img.shields.io/badge/discord-PlayerAnimationLibrary-5865F2)](https://discord.com/invite/PSW2t4Ujm6) -`mocha` is a lightweight, fast and efficient Molang lexer, parser, interpreter and compiler -for Java 8+. Molang is a simple **expression-based** language designed for fast and **data-driven** -calculation of values at run-time. +`mochafloats` is a Molang lexer, parser, interpreter and JVM bytecode compiler for Java 24+. +Molang is the small expression language Minecraft Bedrock uses for data-driven values; pretty much +everything in it evaluates to a number. -Its focus is to enable low-level systems like animation to support flexible data-driven -behavior, while staying highly performant. +It is a fork of [Mocha](https://github.com/unnamed/mocha) by Unnamed Team, maintained by the +PlayerAnimationLibrary team. It computes with `float` instead of `double`, is split into modules, +uses the JDK's ClassFile API instead of Javassist, can send parsed expressions over the network, +and fixes many of Mocha's bugs. -Pretty much everything in this language evaluates to a number; if something doesn't evaluate -to a number, you can use an operator to make it into one. You can basically just think of -Molang as one big math equation. +```java +MolangInterpreter molang = MolangInterpreter.standard(); +float result = molang.eval("math.sqrt(3 * 3 + 4 * 4)"); // 5.0 +``` -This library lets programmers easily evaluate Molang expressions and bind objects -or functions so that they can be called from the evaluated expressions. - -Check the [documentation](https://unnamed.team/docs/molang) for installation, usage and -some more information for this project \ No newline at end of file +The documentation, with installation, usage and the differences from Mocha, is at +[docs.zigythebird.com/mochafloats](https://docs.zigythebird.com/mochafloats/intro). 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 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]; @@ -132,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 { @@ -157,17 +168,21 @@ 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); } } 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..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 @@ -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 @@ -43,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; @@ -53,9 +60,21 @@ 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 + 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,12 +99,10 @@ 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); - setFunction("round", Math::round); + setFunction("round", MochaMath::round); setFunction("sign", MochaMath::sign); setFunction("sin", MochaMath::sin); setFunction("sqrt", MochaMath::sqrt); @@ -94,7 +111,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) @@ -167,20 +200,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; } @@ -237,14 +272,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) @@ -259,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; @@ -276,6 +321,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/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/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/SemanticsTest.java b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java new file mode 100644 index 0000000..0ed2b9d --- /dev/null +++ b/runtime/src/test/java/org/redlance/mocha/runtime/SemanticsTest.java @@ -0,0 +1,134 @@ +/* + * 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 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(); + 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; + } +} 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 { + } +} 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(); + } + } +} 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"); + } +}