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. + *

+ * + * @param expr String representation of expression to be parsed. + * @param ctx Context to use for parsing. + * @return Value of the expression. + */ + public static double parse(String expr, Context ctx) { + if (expr == null) { + ctx.success = true; + ctx.errorMessage = "Success"; + return ctx.emptyValue; + } + + // Strip all spaces and underscores from the input string. + // This allows using them for readability and as thousands separators (using java convention 1_000_000). + // This also correctly interprets numbers in the French locale typed by user using spaces as thousands + // separators. + // See: https://bugs.java.com/bugdatabase/view_bug?bug_id=4510618 + expr = expr.replace(" ", "").replace("_", ""); + + if (expr.isEmpty()) { + ctx.success = true; + ctx.errorMessage = "Success"; + return ctx.emptyValue; + } + + // Read the first numeric value, skip any further parsing if the string contains *only* one number. + ParsePosition parsePos = new ParsePosition(0); + Number value = ctx.numberFormat.parse(expr, parsePos); + if (ctx.plainOnly) { + // Skip any further parsing, only return what was found. + if (value == null || parsePos.getIndex() == 0) { + ctx.success = false; + ctx.errorMessage = "Error: No number found"; + return ctx.errorValue; + } else { + ctx.success = true; + ctx.errorMessage = "Success"; + return value.doubleValue(); + } + } + + if (value != null && parsePos.getIndex() == expr.length()) { + // The entire expr is just a single number. Skip the rest of parsing completely. + ctx.success = true; + ctx.errorMessage = "Success"; + return value.doubleValue(); + } + + // There are still characters to be read, continue with full parsing. + List stack = new ArrayList<>(); + ctx.success = true; + + if (value != null) { + double d = value.doubleValue(); + if (d < 0) { + // Special case to fix a problem with operator priority: + // Input "-5^2" needs to be parsed as (Operator.UNARY_MINUS) (5) (Operator.POWER) (2), + // to be correctly evaluated as -(5^2). + // Using value as it is would result in parsing this as (-5) (Operator.POWER) (2), + // and evaluate incorrectly as (-5)^2. + handleMinus(stack, ctx); + handleNumber(stack, -d, ctx); + } else { + handleNumber(stack, d, ctx); + } + } + + for (int i = parsePos.getIndex(); i < expr.length(); ++i) { + char c = expr.charAt(i); + + switch (c) { + // Plus and minus need special handling, could be unary or binary: + case '+': + handlePlus(stack, ctx); + break; + case '-': + handleMinus(stack, ctx); + break; + + // Binary operators: + case '*': + handleOperator(stack, Operator.MULTIPLY, ctx); + break; + case '/': + handleOperator(stack, Operator.DIVIDE, ctx); + break; + case '^': + handleOperator(stack, Operator.POWER, ctx); + break; + case 'e': + case 'E': + handleOperator(stack, Operator.SCIENTIFIC, ctx); + break; + + // Suffixes: + case 'k': + case 'K': + handleSuffix(stack, Suffix.THOUSAND, c, ctx); + break; + case 'm': + case 'M': + handleSuffix(stack, Suffix.MILLION, c, ctx); + break; + case 'b': + case 'B': + case 'g': + case 'G': + handleSuffix(stack, Suffix.BILLION, c, ctx); + break; + case 't': + case 'T': + handleSuffix(stack, Suffix.TRILLION, c, ctx); + break; + case '%': + handleSuffix(stack, Suffix.PERCENT, c, ctx); + break; + + // Brackets: + case '(': + handleOpenBracket(stack, ctx); + break; + case ')': + handleClosedBracket(stack, ctx); + break; + + // Otherwise, read the next number. + default: + parsePos.setIndex(i); + value = ctx.numberFormat.parse(expr, parsePos); + if (value == null || parsePos.getIndex() == i) { + ctx.success = false; + ctx.errorMessage = "Error: Number expected"; + return ctx.errorValue; + } else { + handleNumber(stack, value.doubleValue(), ctx); + i = parsePos.getIndex() - 1; + } + } + + if (!ctx.success) { + return ctx.errorValue; + } + } + + handleExpressionEnd(stack, ctx); + + if (!ctx.success) { + return ctx.errorValue; + } + + ctx.errorMessage = "Success"; + return stack.get(0).value; + } + + /** + * Adds a new operator to the top of the stack. If the top of the stack contains any operations with a priority + * higher than or equal to this operator, they are evaluated first. + * + * @return True on success, false on failure. + */ + private static boolean handleOperator(@NotNull List stack, Operator op, Context ctx) { + if (stack.isEmpty()) { + ctx.success = false; + ctx.errorMessage = "Syntax error: no left-hand value for operator " + op; + return false; + } + if (stack.get(stack.size() - 1).isOperator) { + ctx.success = false; + ctx.errorMessage = "Syntax error: two operators in a row: " + stack.get(stack.size() - 1).operator + + ", " + + op; + return false; + } + // Evaluate any preceding operations with equal or higher priority than op. + // Exponentiation is right-associative, so in a ^ b ^ c we do not evaluate a ^ b yet. + evaluateStack(stack, op == Operator.POWER ? op.priority + 1 : op.priority); + + stack.add(new StackElement(op)); + return true; + } + + /** + * Special handling for plus, we need to determine whether this is a unary or binary plus. If the top of the stack + * is a number, this is binary; if the stack is empty or the top is an operator, this is unary. + * + * @return True on success, false on failure. + */ + private static boolean handlePlus(@NotNull List stack, Context ctx) { + if (stack.isEmpty() || stack.get(stack.size() - 1).isOperator) { + // Unary plus. + stack.add(new StackElement(0)); + stack.add(new StackElement(Operator.UNARY_PLUS)); + } else { + // Binary plus. + if (!handleOperator(stack, Operator.PLUS, ctx)) return false; + } + return true; + } + + /** + * Special handling for minus, we need to determine whether this is a unary or binary minus. If the top of the stack + * is a number, this is binary; if the stack is empty or the top is an operator, this is unary. + * + * @return True on success, false on failure. + */ + private static boolean handleMinus(@NotNull List stack, Context ctx) { + if (stack.isEmpty() || stack.get(stack.size() - 1).isOperator) { + // Unary minus. + stack.add(new StackElement(0)); + stack.add(new StackElement(Operator.UNARY_MINUS)); + } else { + // Binary minus. + if (!handleOperator(stack, Operator.MINUS, ctx)) return false; + } + return true; + } + + /** + * Handles adding a suffix on top of the stack. Suffixes are never actually added to the stack, since they have the + * highest priority. Instead, the value on top of the stack is directly modified by the suffix. + * + * @param chr Character representing the suffix. This is used for error reporting, as the same suffix can be + * represented by multiple different characters (for example, k and K). + * @return True on success, false on failure. + */ + private static boolean handleSuffix(@NotNull List stack, Suffix suf, char chr, Context ctx) { + if (stack.isEmpty()) { + ctx.success = false; + ctx.errorMessage = "Syntax error: no value for suffix " + chr; + return false; + } + StackElement a = stack.get(stack.size() - 1); + if (!a.isValue) { + ctx.success = false; + ctx.errorMessage = "Syntax error: suffix " + chr + " follows operator " + a.operator; + return false; + } + stack.remove(stack.size() - 1); + if (suf == Suffix.PERCENT) { + // a% of hundredPercent + stack.add(new StackElement(a.value * 0.01 * ctx.hundredPercent)); + } else { + stack.add(new StackElement(a.value * suf.multiplier)); + } + return true; + } + + /** + * Handle adding a number on the stack. Check that the top of the stack is an operator, then add the number. + * + * @return True on success, false on failure. + */ + private static boolean handleNumber(@NotNull List stack, double value, Context ctx) { + if (!stack.isEmpty() && stack.get(stack.size() - 1).isValue) { + ctx.success = false; + ctx.errorMessage = "Syntax error: Number " + stack.get(stack.size() - 1).value + + " followed by number " + + value; + return false; + } + stack.add(new StackElement(value)); + return true; + } + + /** + * Handle an open bracket. If the bracket is immediately preceded by a number, interpret this as multiplication. + * Otherwise, only add the bracket to the stack. + * + * @return True on success, false on failure. + */ + private static boolean handleOpenBracket(@NotNull List stack, Context ctx) { + if (!stack.isEmpty() && stack.get(stack.size() - 1).isValue) { + if (!handleOperator(stack, Operator.MULTIPLY, ctx)) { + return false; + } + } + // Add a fake value to keep the stack always alternating between values and operators. + stack.add(new StackElement(0)); + stack.add(new StackElement(Operator.OPEN_BRACKET)); + return true; + } + + /** + * Handle closed bracket on the stack: Evaluate everything up to the preceding open bracket. + * + * @return True on success, false on failure. + */ + private static boolean handleClosedBracket(@NotNull List stack, Context ctx) { + if (stack.isEmpty()) { + ctx.success = false; + ctx.errorMessage = "Syntax error: Mismatched closed bracket"; + return false; + } + if (stack.get(stack.size() - 1).isOperator) { + ctx.success = false; + ctx.errorMessage = "Syntax error: Closed bracket immediately after operator " + + stack.get(stack.size() - 1).operator; + return false; + } + + // Evaluate everything up to the last open bracket. + evaluateStack(stack, Operator.OPEN_BRACKET.priority + 1); + + // Check for and remove matching open bracket. + if (stack.size() < 2 || !stack.get(stack.size() - 2).isOperator + || stack.get(stack.size() - 2).operator != Operator.OPEN_BRACKET) { + ctx.success = false; + ctx.errorMessage = "Syntax error: Mismatched closed bracket"; + return false; + } + // Open bracket is preceded by a fake value to always alternate between values and operators. + // Remove both the bracket and this value. + stack.remove(stack.size() - 2); + stack.remove(stack.size() - 2); + return true; + } + + /** + * Handle the end of expression. Evaluate everything, make sure that only one value is left. + */ + private static boolean handleExpressionEnd(@NotNull List stack, Context ctx) { + if (stack.isEmpty()) { + // We should never get here, if the expression is empty parsing does not even begin. + ctx.success = false; + ctx.errorMessage = "Internal error: Evaluating empty expression"; + return false; + } + if (stack.get(stack.size() - 1).isOperator) { + ctx.success = false; + ctx.errorMessage = "Syntax error: no right-hand value for operator " + stack.get(stack.size() - 1).operator; + return false; + } + + // Evaluate the rest of the expression. + // This will also automatically close any remaining open brackets, + // since an open bracket is an "operator" that simply returns its right hand argument. + evaluateStack(stack, -1); + + if (stack.size() > 1) { + // This should never happen, there are still operators to be parsed? + ctx.success = false; + ctx.errorMessage = "Internal error: operators remaining after evaluating expression"; + return false; + } + return true; + } + + /** + * Evaluates operators from the top of the stack, which have a priority of at least minPriority. For example, if the + * stack contains 1 + 2 * 3 ^ 4, and minPriority is the priority of division, the exponentiation and multiplication + * are evaluated, but the addition is not. + * + * This means that 1 + 2 * 3 ^ 4 / 5 gets correctly parsed as 1 + ((2 * (3 ^ 4)) / 5). + */ + private static void evaluateStack(@NotNull List stack, int minPriority) { + // The invariant is that values and operators always alternate on the stack. + // This loop must preserve it for the internals of the stack. + while (stack.size() >= 3) { + StackElement op = stack.get(stack.size() - 2); + + if (op.operator.priority >= minPriority) { + StackElement right = stack.remove(stack.size() - 1); + stack.remove(stack.size() - 1); // op + StackElement left = stack.remove(stack.size() - 1); + stack.add(new StackElement(op.operator.evaluate(left.value, right.value))); + // Removed value - operator - value, added value. Invariant is preserved. + } else { + break; + } + } + } + + private static class StackElement { + + public Operator operator; + public double value; + public boolean isValue; + public boolean isOperator; + + public StackElement(Operator operator) { + this.operator = operator; + this.isValue = false; + this.isOperator = true; + } + + public StackElement(double value) { + this.value = value; + this.isValue = true; + this.isOperator = false; + } + + @Override + public String toString() { + if (isValue && isOperator) { + return "Error! Stack element incorrectly set to both value and operator."; + } + if (isValue) { + return "Value: " + value; + } + if (isOperator) { + return "Operator: " + operator; + } + return "Error! Stack element incorrectly set to neither value nor operator."; + } + } + + private enum Operator { + + PLUS('+', 10, (a, b) -> a + b), + MINUS('-', 10, (a, b) -> a - b), + MULTIPLY('*', 20, (a, b) -> a * b), + DIVIDE('/', 20, (a, b) -> a / b), + UNARY_PLUS('+', 30, (a, b) -> b), + UNARY_MINUS('-', 30, (a, b) -> -b), + POWER('^', 40, (a, b) -> Math.pow(a, b)), + SCIENTIFIC('e', 50, (a, b) -> a * Math.pow(10, b)), + + OPEN_BRACKET('(', 1, (a, b) -> b); + + public final char name; + public final int priority; + + public double evaluate(double left, double right) { + return evaluator.apply(left, right); + } + + private final BiFunction evaluator; + + Operator(char name, int priority, BiFunction evaluator) { + this.name = name; + this.priority = priority; + this.evaluator = evaluator; + } + + @Override + public String toString() { + return String.valueOf(name); + } + } + + private enum Suffix { + + THOUSAND(1_000d), + MILLION(1_000_000d), + BILLION(1_000_000_000d), + TRILLION(1_000_000_000_000d), + PERCENT(0); // Handled separately. + + public final double multiplier; + + Suffix(double multiplier) { + this.multiplier = multiplier; + } + } + + /** + * Pass an instance of this to {@link MathExpressionParser#parse} to configure details of parsing. + */ + public static class Context { + + private double emptyValue = 0; + + /** + * Value to return if the expression is empty. + *

+ * 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)); + } +}