diff --git a/src/main/java/com/gtnewhorizon/gtnhlib/util/parsing/MathExpressionParser.java b/src/main/java/com/gtnewhorizon/gtnhlib/util/parsing/MathExpressionParser.java new file mode 100644 index 0000000..d5a83ea --- /dev/null +++ b/src/main/java/com/gtnewhorizon/gtnhlib/util/parsing/MathExpressionParser.java @@ -0,0 +1,632 @@ +package com.gtnewhorizon.gtnhlib.util.parsing; + +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.text.ParsePosition; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +import org.jetbrains.annotations.NotNull; + +@SuppressWarnings("UnusedReturnValue") +public class MathExpressionParser { + + /** + * Matches any string that can be evaluated to a number. The pattern might be too generous, i.e., matches some + * strings that do not evaluate to a valid value. See {@link #parse(String, Context)} for an explanation of the + * syntax. + */ + public static final Pattern EXPRESSION_PATTERN = Pattern.compile("[0-9., _+\\-*/^()eEkKmMgGbBtT%]*"); + // Character ' ' (non-breaking space) to support French locale thousands separator. + + private static final Context defaultContext = new Context(); + + /** + * Parses a mathematical expression using default settings, and returns the result value. See + * {@link #parse(String, Context)}. + * + * @see #parse(String, Context) + * @param expr String representation of expression to be parsed. + * @return Value of the expression. + */ + public static double parse(String expr) { + return parse(expr, defaultContext); + } + + /** + * Parses a mathematical expression and returns the result value. + *
+ * Supported concepts: + *
+ * All evaluation is done with double
precision. Standard rules of operator priority are followed.
+ *
+ * To further tune details of parsing, pass an instance of {@link Context}. See documentation of this class for + * details of options. + *
+ *
+ * After parsing finishes, calling {@link Context#wasSuccessful()} indicates whether parsing was successful or not.
+ * In case parsing fails, {@link Context#getErrorMessage()} will try to give a description of what went wrong. Note
+ * that this only handles syntax errors; arithmetic errors (such as division by zero) are not checked and will
+ * return a value according to Java specification of the double
type.
+ *
+ * Default: 0 + */ + public Context setEmptyValue(double emptyValue) { + this.emptyValue = emptyValue; + return this; + } + + private double errorValue = 0; + + /** + * Value to return if the expression contains an error. Note that this only catches syntax errors, not + * evaluation errors like overflow or division by zero. + *
+ * Default: 0 + */ + public Context setErrorValue(double errorValue) { + this.errorValue = errorValue; + return this; + } + + /** + * Default value to return when the expression is empty or has an error. + *
+ * Equivalent to ctx.setEmptyValue(defaultValue).setErrorValue(defaultValue)
.
+ */
+ public Context setDefaultValue(double defaultValue) {
+ this.emptyValue = defaultValue;
+ this.errorValue = defaultValue;
+ return this;
+ }
+
+ private double hundredPercent = 100;
+
+ /**
+ * Value to be considered 100% for expressions which contain percentages. For example, if this is 500, then
+ * "20%" evaluates to 100.
+ *
+ * Default: 100 + */ + public Context setHundredPercent(double hundredPercent) { + this.hundredPercent = hundredPercent; + return this; + } + + private NumberFormat numberFormat = DecimalFormat.getNumberInstance(Locale.US); + + /** + * Format in which to expect the input expression to be. The main purpose of specifying this is properly + * handling thousands separators and decimal point. + *
+ * This defaults to the EN_US locale. Care should be taken when changing this in a multiplayer setting. Code + * that blindly trusts the player's system locale will run into issues. One player could input a value, which + * will be formatted for that player's locale and potentially stored as a string. Then another player with a + * different locale might open the same UI, and see what to their client looks like a malformed string. + *
+ * Proper locale-aware code needs to communicate only the numeric value between server and all clients, and let + * every client both parse and format it on their own. + */ + public Context setNumberFormat(NumberFormat numberFormat) { + this.numberFormat = numberFormat; + return this; + } + + private boolean plainOnly = false; + + /** + * If this is true, no expression parsing is performed, and the input is expected to be just a plain number. The + * parsing still handles localization, error handling, etc. + *
+ * Default: false + */ + public Context setPlainOnly(boolean plainOnly) { + this.plainOnly = plainOnly; + return this; + } + + private boolean success = false; + + /** + * Call this after parsing has finished. + * + * @return true if the last parsing operation using this context was successful. + */ + public boolean wasSuccessful() { + return success; + } + + private String errorMessage = ""; + + /** + * Call this after parsing has finished. + * + * @return If the parsing has failed with an error, this will try to explain what went wrong. + */ + public String getErrorMessage() { + return errorMessage; + } + + } +} diff --git a/src/test/java/com/gtnewhorizon/gtnhlib/test/util/parsing/MathExpressionParserTest.java b/src/test/java/com/gtnewhorizon/gtnhlib/test/util/parsing/MathExpressionParserTest.java new file mode 100644 index 0000000..6bd1488 --- /dev/null +++ b/src/test/java/com/gtnewhorizon/gtnhlib/test/util/parsing/MathExpressionParserTest.java @@ -0,0 +1,179 @@ +package com.gtnewhorizon.gtnhlib.test.util.parsing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.text.NumberFormat; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import com.gtnewhorizon.gtnhlib.util.parsing.MathExpressionParser; + +class MathExpressionParserTest { + + MathExpressionParser.Context ctxEN = new MathExpressionParser.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.US)); + MathExpressionParser.Context ctxFR = new MathExpressionParser.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.FRENCH)); + MathExpressionParser.Context ctxES = new MathExpressionParser.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.forLanguageTag("ES"))); + + @Test + void NumbersBasic_Test() { + assertEquals(41, MathExpressionParser.parse("41")); + assertEquals(42, MathExpressionParser.parse(" 42 ")); + + assertEquals(1000000, MathExpressionParser.parse("1 000 000")); + assertEquals(1000000, MathExpressionParser.parse("1_000_000")); + + assertEquals(123456.789, MathExpressionParser.parse("123456.789", ctxEN)); + assertEquals(234567.891, MathExpressionParser.parse("234,567.891", ctxEN)); + + assertEquals(345678.912, MathExpressionParser.parse("345 678,912", ctxFR)); + + String s = NumberFormat.getNumberInstance(Locale.FRENCH).format(456789.123); + assertEquals(456789.123, MathExpressionParser.parse(s, ctxFR)); + + assertEquals(567891.234, MathExpressionParser.parse("567.891,234", ctxES)); + } + + @Test + void ArithmeticBasic_Test() { + assertEquals(5, MathExpressionParser.parse("2+3")); + assertEquals(-1, MathExpressionParser.parse("2-3")); + assertEquals(6, MathExpressionParser.parse("2*3")); + assertEquals(2, MathExpressionParser.parse("6/3")); + assertEquals(8, MathExpressionParser.parse("2^3")); + } + + @Test + void UnaryMinus_Test() { + assertEquals(-5, MathExpressionParser.parse("-5")); + assertEquals(-3, MathExpressionParser.parse("-5+2")); + assertEquals(-7, MathExpressionParser.parse("-5-2")); + assertEquals(-15, MathExpressionParser.parse("-5*3")); + assertEquals(-2.5, MathExpressionParser.parse("-5/2")); + assertEquals(-25, MathExpressionParser.parse("-5^2")); // ! this is -(5^2), not (-5)^2. + + assertEquals(16, MathExpressionParser.parse("(-4)^2")); + assertEquals(-64, MathExpressionParser.parse("(-4)^3")); + + assertEquals(2, MathExpressionParser.parse("4+-2")); + assertEquals(6, MathExpressionParser.parse("4--2")); + + assertEquals(7, MathExpressionParser.parse("--7")); + assertEquals(-8, MathExpressionParser.parse("---8")); + } + + @Test + void UnaryPlus_Test() { + assertEquals(5, MathExpressionParser.parse("+5")); + assertEquals(7, MathExpressionParser.parse("+5+2")); + assertEquals(3, MathExpressionParser.parse("+5-2")); + assertEquals(15, MathExpressionParser.parse("+5*3")); + assertEquals(2.5, MathExpressionParser.parse("+5/2")); + assertEquals(25, MathExpressionParser.parse("+5^2")); + + assertEquals(6, MathExpressionParser.parse("4++2")); + assertEquals(2, MathExpressionParser.parse("4-+2")); + + assertEquals(7, MathExpressionParser.parse("++7")); + assertEquals(8, MathExpressionParser.parse("+++8")); + } + + @Test + void ArithmeticPriority_Test() { + assertEquals(4, MathExpressionParser.parse("2+3-1")); + assertEquals(14, MathExpressionParser.parse("2+3*4")); + assertEquals(10, MathExpressionParser.parse("2*3+4")); + assertEquals(7, MathExpressionParser.parse("2^3-1")); + assertEquals(13, MathExpressionParser.parse("1+2^3+4")); + + // a^b^c = a^(b^c) + assertEquals(262_144, MathExpressionParser.parse("4^3^2")); + } + + @Test + void Brackets_Test() { + assertEquals(5, MathExpressionParser.parse("(2+3)")); + assertEquals(20, MathExpressionParser.parse("(2+3)*4")); + assertEquals(14, MathExpressionParser.parse("2+(3*4)")); + assertEquals(42, MathExpressionParser.parse("(((42)))")); + + assertEquals(14, MathExpressionParser.parse("2(3+4)")); + } + + @Test + void ScientificBasic_Test() { + assertEquals(2000, MathExpressionParser.parse("2e3")); + assertEquals(3000, MathExpressionParser.parse("3E3")); + assertEquals(0.04, MathExpressionParser.parse("4e-2")); + assertEquals(0.05, MathExpressionParser.parse("5E-2")); + assertEquals(6000, MathExpressionParser.parse("6e+3")); + + assertEquals(6000, MathExpressionParser.parse("6 e 3")); + assertEquals(7800, MathExpressionParser.parse("7.8e3")); + assertEquals(90_000, MathExpressionParser.parse("900e2")); + assertEquals(1, MathExpressionParser.parse("1e0")); + } + + @Test + void ScientificArithmetic_Test() { + assertEquals(4000, MathExpressionParser.parse("2*2e3")); + assertEquals(6000, MathExpressionParser.parse("2e3 * 3")); + assertEquals(-200, MathExpressionParser.parse("-2e2")); + assertEquals(1024, MathExpressionParser.parse("2^1e1")); + + // Not supported, but shouldn't fail. (2e2)e2 = 200e2 = 20_000. + assertEquals(20_000, MathExpressionParser.parse("2e2e2")); + } + + @Test + void SuffixesBasic_Test() { + assertEquals(2000, MathExpressionParser.parse("2k")); + assertEquals(3000, MathExpressionParser.parse("3K")); + assertEquals(4_000_000, MathExpressionParser.parse("4m")); + assertEquals(5_000_000, MathExpressionParser.parse("5M")); + assertEquals(6_000_000_000D, MathExpressionParser.parse("6b")); + assertEquals(7_000_000_000D, MathExpressionParser.parse("7B")); + assertEquals(8_000_000_000D, MathExpressionParser.parse("8g")); + assertEquals(9_000_000_000D, MathExpressionParser.parse("9G")); + assertEquals(10_000_000_000_000D, MathExpressionParser.parse("10t")); + assertEquals(11_000_000_000_000D, MathExpressionParser.parse("11T")); + + assertEquals(2050, MathExpressionParser.parse("2.05k", ctxEN)); + assertEquals(50, MathExpressionParser.parse("0.05k", ctxEN)); + assertEquals(3000, MathExpressionParser.parse("3 k")); + } + + @Test + void SuffixesArithmetic_Test() { + assertEquals(2005, MathExpressionParser.parse("2k+5")); + assertEquals(2005, MathExpressionParser.parse("5+2k")); + assertEquals(4000, MathExpressionParser.parse("2k*2")); + assertEquals(4000, MathExpressionParser.parse("2*2k")); + assertEquals(-2000, MathExpressionParser.parse("-2k")); + + assertEquals(3_000_000, MathExpressionParser.parse("3kk")); + assertEquals(4_000_000_000D, MathExpressionParser.parse("4kkk")); + + // Not supported, but shouldn't fail. + assertEquals(6_000_000_000d, MathExpressionParser.parse("6km")); + assertEquals(500_000, MathExpressionParser.parse("0.5ke3", ctxEN)); + + // Please don't do this. + assertEquals(20_000_000_000D, MathExpressionParser.parse("2e0.01k", ctxEN)); + } + + @Test + void Percent_Test() { + ctxEN.setHundredPercent(1000); + + assertEquals(100, MathExpressionParser.parse("10%", ctxEN)); + assertEquals(2000, MathExpressionParser.parse("200%", ctxEN)); + assertEquals(-300, MathExpressionParser.parse("-30%", ctxEN)); + + assertEquals(450, MathExpressionParser.parse("40% + 50", ctxEN)); + assertEquals(500, MathExpressionParser.parse("(20+30)%", ctxEN)); + } +}