From 57682dc188b833b66c6fd894a5742a78f7fe0f45 Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Wed, 7 Feb 2024 05:36:32 -0600 Subject: [PATCH 1/9] Expression parsing optimization and improvements. --- .../modularui/api/math/MathExpression.java | 741 ++++++++++++------ .../modularui/test/TestTile.java | 11 +- .../api/math/MathExpressionTest.java | 86 +- 3 files changed, 574 insertions(+), 264 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index de93846..1ffbd63 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -1,310 +1,475 @@ package com.gtnewhorizons.modularui.api.math; -import java.text.ParseException; +import java.text.NumberFormat; +import java.text.ParsePosition; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.function.BiFunction; +import java.util.regex.Pattern; -import com.gtnewhorizons.modularui.common.widget.textfield.TextFieldWidget; +import org.jetbrains.annotations.NotNull; public class MathExpression { - private static final List DEFAULT = Collections.singletonList(0); + /** + * 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 #parseMathExpression(String, Context)} for an + * explanation of the syntax. + */ + public static final Pattern EXPRESSION_PATTERN = Pattern.compile("[0-9.,  +\\-*/^()eEkKmMgGbBtT%]*"); + // Character   to support French locale thousands separator. + + // TODO: + // There should be a config option to default the number format to either the user's computer locale, or to a + // locale specified in a config. This way, users who have their computer locale set to a different one still have an + // option to input numbers using English conventions. + private static final Context defaultContext = new Context(); public static double parseMathExpression(String expr) { - return parseMathExpression(expr, 0); + return parseMathExpression(expr, defaultContext); } + /** + * @deprecated Call as + * parseMathExpression(expr, new MathExpression.Context().setDefaultValue(onFailReturn)) + */ + @Deprecated public static double parseMathExpression(String expr, double onFailReturn) { - List parsed = buildParsedList(expr, onFailReturn); - if (parsed == DEFAULT || parsed.size() == 0) { - return onFailReturn; - } - if (parsed.size() == 1) { - Object value = parsed.get(0); - return value instanceof Double ? (double) value : onFailReturn; - } + return parseMathExpression(expr, new Context().setDefaultValue(onFailReturn)); + } - if (Operator.MINUS == parsed.get(0)) { - parsed.remove(0); - parsed.set(0, -(Double) parsed.get(0)); + /** + * Parses a mathematical expression in a string 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, ctx.success indicates whether parsing was successful or not. In case parsing + * fails, ctx.errorMessage 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 parseMathExpression(String expr, Context ctx) { + if (expr == null) { + ctx.success = true; + return ctx.emptyValue; } - for (int i = 1; i < parsed.size(); i++) { - Object obj = parsed.get(i); - if (obj instanceof Suffix) { - Double left = (Double) parsed.get(i - 1); - Double result = left * ((Suffix) obj).multiplier; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } - } + // Strip all spaces from the input string. + // 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(" ", ""); - for (int i = 1; i < parsed.size() - 1; i++) { - Object obj = parsed.get(i); - if (obj == Operator.SCIENTIFIC) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left * Math.pow(10, right); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } + if (expr.isEmpty()) { + ctx.success = true; + return ctx.emptyValue; } - // ^ is right-associative: a^b^c = a^(b^c) - for (int i = parsed.size() - 2; i > 0; i--) { - Object obj = parsed.get(i); - if (obj == Operator.POWER) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = Math.pow(left, right); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; + // Read the first numeric value, skip any further parsing if the string contains *only* one number. + ParsePosition pp = new ParsePosition(0); + Number value = ctx.numberFormat.parse(expr, pp); + if (ctx.plainOnly) { + // Skip any further parsing, only return what was found. + if (value == null || pp.getIndex() == 0) { + ctx.success = false; + ctx.errorMessage = "Error: No number found"; + return ctx.errorValue; + } else { + ctx.success = true; + return value.doubleValue(); } } - for (int i = 1; i < parsed.size() - 1; i++) { - Object obj = parsed.get(i); - if (obj == Operator.MULTIPLY) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left * right; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } else if (obj == Operator.DIVIDE) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left / right; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } else if (obj == Operator.MOD) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left % right; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } + if (value != null && pp.getIndex() == expr.length()) { + // The entire expr is just a single number. Skip the rest of parsing completely. + ctx.success = true; + return value.doubleValue(); } - for (int i = 1; i < parsed.size() - 1; i++) { - Object obj = parsed.get(i); - if (obj == Operator.PLUS) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left + right; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; - } else if (obj == Operator.MINUS) { - Double left = (Double) parsed.get(i - 1); - Double right = (Double) parsed.get(i + 1); - Double result = left - right; - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.remove(i - 1); - parsed.add(i - 1, result); - i--; + // 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); } } - if (parsed.size() != 1) { - throw new IllegalStateException("Calculated expr has more than 1 result. " + parsed); - } - return (Double) parsed.get(0); - } - - public static List buildParsedList(String expr, double onFailReturn) { - List parsed = new ArrayList<>(); - StringBuilder builder = new StringBuilder(); - if (expr == null || expr.isEmpty()) return parsed; - - for (int i = 0; i < expr.length(); i++) { + for (int i = pp.getIndex(); i < expr.length(); ++i) { char c = expr.charAt(i); - switch (c) { - case ' ': - case ',': - case '_': - break; - case '+': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.PLUS); + switch (c) { + // Binary operators: + case '+': + handleOperator(stack, Operator.PLUS, ctx); break; - } - case '-': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.MINUS); + // Minus needs special handling, could be unary or binary: + case '-': + handleMinus(stack, ctx); break; - } - case '*': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.MULTIPLY); + case '*': + handleOperator(stack, Operator.MULTIPLY, ctx); break; - } - case '/': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.DIVIDE); + case '/': + handleOperator(stack, Operator.DIVIDE, ctx); break; - } - case '%': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.MOD); + case '^': + handleOperator(stack, Operator.POWER, ctx); break; - } - case '^': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.POWER); - break; - } case 'e': - case 'E': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Operator.SCIENTIFIC); + case 'E': + handleOperator(stack, Operator.SCIENTIFIC, ctx); break; - } + + // Suffixes: case 'k': - case 'K': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Suffix.THOUSAND); + case 'K': + handleSuffix(stack, Suffix.THOUSAND, c, ctx); break; - } case 'm': - case 'M': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Suffix.MILLION); + case 'M': + handleSuffix(stack, Suffix.MILLION, c, ctx); break; - } case 'b': case 'B': case 'g': - case 'G': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Suffix.BILLION); + case 'G': + handleSuffix(stack, Suffix.BILLION, c, ctx); break; - } case 't': - case 'T': { - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); - builder.delete(0, builder.length()); - } - parsed.add(Suffix.TRILLION); + 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: - builder.append(c); + pp.setIndex(i); + value = ctx.numberFormat.parse(expr, pp); + if (value == null || pp.getIndex() == i) { + ctx.success = false; + ctx.errorMessage = "Error: Number expected"; + return ctx.errorValue; + } else { + handleNumber(stack, value.doubleValue(), ctx); + i = pp.getIndex() - 1; + } + } + + if (!ctx.success) { + return ctx.errorValue; } } - if (builder.length() > 0) { - parsed.add(parse(builder.toString(), onFailReturn)); + + handleExpressionEnd(stack, ctx); + + if (!ctx.success) { + return ctx.errorValue; } - if (parsed.isEmpty()) return DEFAULT; + 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.name; + 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.name + + ", " + + op.name; + 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); - Object prevToken = null; - Object thisToken = null; - for (int i = 0; i < parsed.size(); ++i) { - prevToken = thisToken; - thisToken = parsed.get(i); + stack.add(new StackElement(op)); + return true; + } - if (prevToken == null && (thisToken instanceof Double || Operator.MINUS == thisToken)) continue; - if (prevToken instanceof Double && (thisToken instanceof Operator || thisToken instanceof Suffix)) continue; - if (prevToken instanceof Operator && thisToken instanceof Double) continue; - if (prevToken instanceof Suffix && (thisToken instanceof Operator || thisToken instanceof Suffix)) continue; - return DEFAULT; + /** + * 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; } - if (thisToken instanceof Operator) return DEFAULT; + return true; + } - return parsed; + /** + * Handles adding a suffix on top of the stack. Suffixes are never actually added to the stack, since they have the + * highest priority 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.name; + 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; } - public static double parse(String num, double onFailReturn) { - try { - return TextFieldWidget.format.parse(num).doubleValue(); - } catch (ParseException e) { - e.printStackTrace(); + /** + * 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; } - return onFailReturn; + stack.add(new StackElement(value)); + return true; } - public enum Operator { + /** + * 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; + } - PLUS("+"), - MINUS("-"), - MULTIPLY("*"), - DIVIDE("/"), - MOD("%"), - POWER("^"), - SCIENTIFIC("e"); + /** + * 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.name; + return false; + } - public final String sign; + // Evaluate everything up to the last open bracket. + evaluateStack(stack, Operator.OPEN_BRACKET.priority + 1); - Operator(String sign) { - this.sign = sign; + // 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; + } - @Override - public String toString() { - return sign; + /** + * 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.name; + 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(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; + } + } + + 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_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; } } public enum Suffix { - THOUSAND(1_000D), - MILLION(1_000_000D), - BILLION(1_000_000_000D), - TRILLION(1_000_000_000_000D); + 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; @@ -312,4 +477,108 @@ public enum Suffix { this.multiplier = multiplier; } } + + /** + * Pass an instance of this to {@link MathExpression#parseMathExpression} 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 = NumberFormat.getNumberInstance(); + + /** + * Format in which to expect the input expression to be. The main purpose of specifying this is properly + * handling thousands separators and decimal point. + *

+ * Defaults to the user's system locale NumberFormat. + */ + 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 parsing was successful. + */ + public boolean getSuccess() { + 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/main/java/com/gtnewhorizons/modularui/test/TestTile.java b/src/main/java/com/gtnewhorizons/modularui/test/TestTile.java index 26e89bf..184fb38 100644 --- a/src/main/java/com/gtnewhorizons/modularui/test/TestTile.java +++ b/src/main/java/com/gtnewhorizons/modularui/test/TestTile.java @@ -178,10 +178,13 @@ private Widget createPage1() { changeableWidget.notifyChangeServer(); }).setShiftClickPriority(0).setPos(10, 30)) .addChild( - new TextFieldWidget().setGetter(() -> String.valueOf(longValue)) - .setSetter(val -> longValue = (long) MathExpression.parseMathExpression(val)) - .setNumbersLong(val -> val).setTextColor(Color.WHITE.dark(1)) - .setTextAlignment(Alignment.CenterLeft).setScrollBar() + new TextFieldWidget().setPattern(MathExpression.EXPRESSION_PATTERN) + .setGetter(() -> String.valueOf(longValue)) + .setSetter( + val -> longValue = (long) MathExpression.parseMathExpression( + val, + new MathExpression.Context().setHundredPercent(1000))) + .setTextColor(Color.WHITE.dark(1)).setTextAlignment(Alignment.CenterLeft).setScrollBar() .setBackground(DISPLAY.withOffset(-2, -2, 4, 4)).setSize(92, 20).setPos(10, 50)) .addChild( SlotWidget.phantom(phantomInventory, 1).setShiftClickPriority(1).setIgnoreStackSizeLimit(true) diff --git a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java index 137389d..cd293d0 100644 --- a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java +++ b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java @@ -2,20 +2,34 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.text.NumberFormat; +import java.util.Locale; + import org.junit.jupiter.api.Test; class MathExpressionTest { + MathExpression.Context ctxEN = new MathExpression.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.US)); + MathExpression.Context ctxFR = new MathExpression.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.FRENCH)); + MathExpression.Context ctxES = new MathExpression.Context() + .setNumberFormat(NumberFormat.getNumberInstance(Locale.forLanguageTag("ES"))); + @Test void NumbersBasic_Test() { - assertEquals(42, MathExpression.parseMathExpression("42")); + assertEquals(41, MathExpression.parseMathExpression("41")); assertEquals(42, MathExpression.parseMathExpression(" 42 ")); - assertEquals(123456, MathExpression.parseMathExpression("123,456")); - assertEquals(123456, MathExpression.parseMathExpression("123 456")); - assertEquals(123456, MathExpression.parseMathExpression("123_456")); + assertEquals(123456.789, MathExpression.parseMathExpression("123456.789", ctxEN)); + assertEquals(234567.891, MathExpression.parseMathExpression("234,567.891", ctxEN)); + + assertEquals(345678.912, MathExpression.parseMathExpression("345 678,912", ctxFR)); - assertEquals(123.456, MathExpression.parseMathExpression("123.456")); + String s = NumberFormat.getNumberInstance(Locale.FRENCH).format(456789.123); + assertEquals(456789.123, MathExpression.parseMathExpression(s, ctxFR)); + + assertEquals(567891.234, MathExpression.parseMathExpression("567.891,234", ctxES)); } @Test @@ -24,10 +38,20 @@ void ArithmeticBasic_Test() { assertEquals(-1, MathExpression.parseMathExpression("2-3")); assertEquals(6, MathExpression.parseMathExpression("2*3")); assertEquals(2, MathExpression.parseMathExpression("6/3")); - assertEquals(1, MathExpression.parseMathExpression("7%3")); assertEquals(8, MathExpression.parseMathExpression("2^3")); + } + + @Test + void UnaryMinus_Test() { + assertEquals(-5, MathExpression.parseMathExpression("-5")); + assertEquals(-3, MathExpression.parseMathExpression("-5+2")); + assertEquals(-7, MathExpression.parseMathExpression("-5-2")); + assertEquals(-15, MathExpression.parseMathExpression("-5*3")); + assertEquals(-2.5, MathExpression.parseMathExpression("-5/2")); + assertEquals(-25, MathExpression.parseMathExpression("-5^2")); // ! this is -(5^2), not (-5)^2. - assertEquals(5, MathExpression.parseMathExpression("2 + 3")); + assertEquals(2, MathExpression.parseMathExpression("4+-2")); + assertEquals(6, MathExpression.parseMathExpression("4--2")); } @Test @@ -43,24 +67,26 @@ void ArithmeticPriority_Test() { } @Test - void UnaryZero_Test() { - assertEquals(-5, MathExpression.parseMathExpression("-5")); - assertEquals(-3, MathExpression.parseMathExpression("-5+2")); - assertEquals(-7, MathExpression.parseMathExpression("-5-2")); - assertEquals(-10, MathExpression.parseMathExpression("-5*2")); - assertEquals(-2.5, MathExpression.parseMathExpression("-5/2")); - assertEquals(-1, MathExpression.parseMathExpression("-5%2")); - assertEquals(25, MathExpression.parseMathExpression("-5^2")); // ! this is (-5)^2, not -(5^2). + void Brackets_Test() { + assertEquals(5, MathExpression.parseMathExpression("(2+3)")); + assertEquals(20, MathExpression.parseMathExpression("(2+3)*4")); + assertEquals(14, MathExpression.parseMathExpression("2+(3*4)")); + assertEquals(42, MathExpression.parseMathExpression("(((42)))")); + + assertEquals(14, MathExpression.parseMathExpression("2(3+4)")); } @Test void ScientificBasic_Test() { assertEquals(2000, MathExpression.parseMathExpression("2e3")); assertEquals(3000, MathExpression.parseMathExpression("3E3")); - assertEquals(4000, MathExpression.parseMathExpression("4 e 3")); - assertEquals(5600, MathExpression.parseMathExpression("5.6e3")); - assertEquals(70_000, MathExpression.parseMathExpression("700e2")); - assertEquals(8, MathExpression.parseMathExpression("8e0")); + assertEquals(0.04, MathExpression.parseMathExpression("4e-2")); + assertEquals(0.05, MathExpression.parseMathExpression("5E-2")); + + assertEquals(6000, MathExpression.parseMathExpression("6 e 3")); + assertEquals(7800, MathExpression.parseMathExpression("7.8e3")); + assertEquals(90_000, MathExpression.parseMathExpression("900e2")); + assertEquals(1, MathExpression.parseMathExpression("1e0")); } @Test @@ -87,8 +113,8 @@ void SuffixesBasic_Test() { assertEquals(10_000_000_000_000D, MathExpression.parseMathExpression("10t")); assertEquals(11_000_000_000_000D, MathExpression.parseMathExpression("11T")); - assertEquals(2050, MathExpression.parseMathExpression("2.05k")); - assertEquals(50, MathExpression.parseMathExpression("0.05k")); + assertEquals(2050, MathExpression.parseMathExpression("2.05k", ctxEN)); + assertEquals(50, MathExpression.parseMathExpression("0.05k", ctxEN)); assertEquals(3000, MathExpression.parseMathExpression("3 k")); } @@ -104,10 +130,22 @@ void SuffixesArithmetic_Test() { assertEquals(4_000_000_000D, MathExpression.parseMathExpression("4kkk")); // Not supported, but shouldn't fail. - assertEquals(6_000_000_000D, MathExpression.parseMathExpression("6km")); - assertEquals(500_000, MathExpression.parseMathExpression("0.5ke3")); + assertEquals(6_000_000_000d, MathExpression.parseMathExpression("6km")); + assertEquals(500_000, MathExpression.parseMathExpression("0.5ke3", ctxEN)); // Please don't do this. - assertEquals(20_000_000_000D, MathExpression.parseMathExpression("2e0.01k")); + assertEquals(20_000_000_000D, MathExpression.parseMathExpression("2e0.01k", ctxEN)); + } + + @Test + void Percent_Test() { + ctxEN.setHundredPercent(1000); + + assertEquals(100, MathExpression.parseMathExpression("10%", ctxEN)); + assertEquals(2000, MathExpression.parseMathExpression("200%", ctxEN)); + assertEquals(-300, MathExpression.parseMathExpression("-30%", ctxEN)); + + assertEquals(450, MathExpression.parseMathExpression("40% + 50", ctxEN)); + assertEquals(500, MathExpression.parseMathExpression("(20+30)%", ctxEN)); } } From 5e2429d38c4a618e0a5d46d5082ab512e42f52e5 Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Wed, 7 Feb 2024 06:25:15 -0600 Subject: [PATCH 2/9] Re-add accidentally removed public method. --- .../gtnewhorizons/modularui/api/math/MathExpression.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index 1ffbd63..34e1895 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -38,6 +38,15 @@ public static double parseMathExpression(String expr, double onFailReturn) { return parseMathExpression(expr, new Context().setDefaultValue(onFailReturn)); } + /** + * @deprecated Call as parseMathExpression(num, new MathExpression.Context() + * .setPlainOnly(true).setDefaultValue(onFailReturn)) + */ + @Deprecated + public static double parse(String num, double onFailReturn) { + return parseMathExpression(num, new Context().setPlainOnly(true).setDefaultValue(onFailReturn)); + } + /** * Parses a mathematical expression in a string and returns the result value. *

From 96c298717b4d0203bb2e599ce78f88e7b5be21ed Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Wed, 7 Feb 2024 06:25:37 -0600 Subject: [PATCH 3/9] Update BS + deps. --- dependencies.gradle | 8 ++++---- settings.gradle | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 4c3cd90..4a61a94 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -3,14 +3,14 @@ dependencies { api("org.jetbrains:annotations:23.0.0") - api("com.github.GTNewHorizons:NotEnoughItems:2.5.4-GTNH:dev") + api("com.github.GTNewHorizons:NotEnoughItems:2.5.17-GTNH:dev") - compileOnly("com.github.GTNewHorizons:Hodgepodge:2.4.12:dev") { transitive = false } - compileOnly("com.github.GTNewHorizons:GT5-Unofficial:5.09.45.47:dev") { + compileOnly("com.github.GTNewHorizons:Hodgepodge:2.4.20:dev") { transitive = false } + compileOnly("com.github.GTNewHorizons:GT5-Unofficial:5.09.45.61:dev") { transitive = false exclude group:"com.github.GTNewHorizons", module:"ModularUI" } - compileOnly("com.github.GTNewHorizons:Applied-Energistics-2-Unofficial:rv3-beta-312-GTNH:dev") { transitive = false } + compileOnly("com.github.GTNewHorizons:Applied-Energistics-2-Unofficial:rv3-beta-315-GTNH:dev") { transitive = false } testImplementation(platform('org.junit:junit-bom:5.9.2')) testImplementation('org.junit.jupiter:junit-jupiter') diff --git a/settings.gradle b/settings.gradle index e8946ad..3b6c62d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,7 +17,7 @@ pluginManagement { } plugins { - id 'com.gtnewhorizons.gtnhsettingsconvention' version '1.0.8' + id 'com.gtnewhorizons.gtnhsettingsconvention' version '1.0.9' } From 5ef0cb2fad07abeb201b422baee6c6e1fa9b3013 Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Wed, 7 Feb 2024 07:09:33 -0600 Subject: [PATCH 4/9] Minor fixes. --- .../com/gtnewhorizons/modularui/api/math/MathExpression.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index 34e1895..04a5704 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -57,10 +57,10 @@ public static double parse(String num, double onFailReturn) { *

  • Locale-specific thousands separator: ',', '.', ' ', or ' ' (Non-breaking space, French locale).
  • *
  • Binary operations: '+', '-', '*', '/', '^'
  • *
  • Unary '-'.
  • + *
  • Parentheses: '(', ')'.
  • *
  • Scientific notation: 'e', 'E'.
  • *
  • Suffixes denoting large values: 'k', 'K', 'm', 'M', 'b', 'B', 'g', 'G', 't', 'T'.
  • *
  • Percentage of maximum amount (specify maximum in the context instance): '%'.
  • - *
  • Parentheses: '(', ')'.
  • * *

    *

    @@ -472,7 +472,7 @@ public double evaluate(double left, double right) { } } - public enum Suffix { + private enum Suffix { THOUSAND(1_000d), MILLION(1_000_000d), From 9c8741ac788844b7040c93356136d5ec20a901ff Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Sat, 17 Feb 2024 14:44:34 -0600 Subject: [PATCH 5/9] Minor fixes and refactors. --- .../modularui/api/math/MathExpression.java | 36 +++++++++++-------- .../api/math/MathExpressionTest.java | 3 ++ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index 04a5704..e56aba3 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -17,7 +17,7 @@ public class MathExpression { * explanation of the syntax. */ public static final Pattern EXPRESSION_PATTERN = Pattern.compile("[0-9.,  +\\-*/^()eEkKmMgGbBtT%]*"); - // Character   to support French locale thousands separator. + // Character ' ' (non-breaking space) to support French locale thousands separator. // TODO: // There should be a config option to default the number format to either the user's computer locale, or to a @@ -84,38 +84,43 @@ public static double parse(String num, double onFailReturn) { public static double parseMathExpression(String expr, Context ctx) { if (expr == null) { ctx.success = true; + ctx.errorMessage = "Success"; return ctx.emptyValue; } - // Strip all spaces from the input string. + // 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(" ", ""); + 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 pp = new ParsePosition(0); - Number value = ctx.numberFormat.parse(expr, pp); + 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 || pp.getIndex() == 0) { + 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 && pp.getIndex() == expr.length()) { + 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(); } @@ -138,7 +143,7 @@ public static double parseMathExpression(String expr, Context ctx) { } } - for (int i = pp.getIndex(); i < expr.length(); ++i) { + for (int i = parsePos.getIndex(); i < expr.length(); ++i) { char c = expr.charAt(i); switch (c) { @@ -197,15 +202,15 @@ public static double parseMathExpression(String expr, Context ctx) { // Otherwise, read the next number. default: - pp.setIndex(i); - value = ctx.numberFormat.parse(expr, pp); - if (value == null || pp.getIndex() == i) { + 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 = pp.getIndex() - 1; + i = parsePos.getIndex() - 1; } } @@ -220,6 +225,7 @@ public static double parseMathExpression(String expr, Context ctx) { return ctx.errorValue; } + ctx.errorMessage = "Success"; return stack.get(0).value; } @@ -406,7 +412,7 @@ private static boolean handleExpressionEnd(@NotNull List stack, Co * * This means that 1 + 2 * 3 ^ 4 / 5 gets correctly parsed as 1 + ((2 * (3 ^ 4)) / 5). */ - private static void evaluateStack(List stack, int minPriority) { + 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) { @@ -572,9 +578,9 @@ public Context setPlainOnly(boolean plainOnly) { /** * Call this after parsing has finished. * - * @return true if the parsing was successful. + * @return true if the last parsing operation using this context was successful. */ - public boolean getSuccess() { + public boolean wasSuccessful() { return success; } diff --git a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java index cd293d0..81ecce6 100644 --- a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java +++ b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java @@ -21,6 +21,9 @@ void NumbersBasic_Test() { assertEquals(41, MathExpression.parseMathExpression("41")); assertEquals(42, MathExpression.parseMathExpression(" 42 ")); + assertEquals(1000000, MathExpression.parseMathExpression("1 000 000")); + assertEquals(1000000, MathExpression.parseMathExpression("1_000_000")); + assertEquals(123456.789, MathExpression.parseMathExpression("123456.789", ctxEN)); assertEquals(234567.891, MathExpression.parseMathExpression("234,567.891", ctxEN)); From 31217045d1b3e9b0e99e8437b30b17f4ede85e5b Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Sat, 17 Feb 2024 14:58:12 -0600 Subject: [PATCH 6/9] Added unary plus and tests. --- .../modularui/api/math/MathExpression.java | 26 ++++++++++++++++--- .../api/math/MathExpressionTest.java | 23 ++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index e56aba3..a054d01 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -147,14 +147,15 @@ public static double parseMathExpression(String expr, Context ctx) { char c = expr.charAt(i); switch (c) { - // Binary operators: + // Plus and minus need special handling, could be unary or binary: case '+': - handleOperator(stack, Operator.PLUS, ctx); + handlePlus(stack, ctx); break; - // Minus needs special handling, could be unary or binary: case '-': handleMinus(stack, ctx); break; + + // Binary operators: case '*': handleOperator(stack, Operator.MULTIPLY, ctx); break; @@ -256,6 +257,24 @@ private static boolean handleOperator(@NotNull List stack, Operato 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. @@ -456,6 +475,7 @@ private enum Operator { 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)), diff --git a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java index 81ecce6..159e9f0 100644 --- a/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java +++ b/src/test/java/com/gtnewhorizons/modularui/api/math/MathExpressionTest.java @@ -53,8 +53,30 @@ void UnaryMinus_Test() { assertEquals(-2.5, MathExpression.parseMathExpression("-5/2")); assertEquals(-25, MathExpression.parseMathExpression("-5^2")); // ! this is -(5^2), not (-5)^2. + assertEquals(16, MathExpression.parseMathExpression("(-4)^2")); + assertEquals(-64, MathExpression.parseMathExpression("(-4)^3")); + assertEquals(2, MathExpression.parseMathExpression("4+-2")); assertEquals(6, MathExpression.parseMathExpression("4--2")); + + assertEquals(7, MathExpression.parseMathExpression("--7")); + assertEquals(-8, MathExpression.parseMathExpression("---8")); + } + + @Test + void UnaryPlus_Test() { + assertEquals(5, MathExpression.parseMathExpression("+5")); + assertEquals(7, MathExpression.parseMathExpression("+5+2")); + assertEquals(3, MathExpression.parseMathExpression("+5-2")); + assertEquals(15, MathExpression.parseMathExpression("+5*3")); + assertEquals(2.5, MathExpression.parseMathExpression("+5/2")); + assertEquals(25, MathExpression.parseMathExpression("+5^2")); + + assertEquals(6, MathExpression.parseMathExpression("4++2")); + assertEquals(2, MathExpression.parseMathExpression("4-+2")); + + assertEquals(7, MathExpression.parseMathExpression("++7")); + assertEquals(8, MathExpression.parseMathExpression("+++8")); } @Test @@ -85,6 +107,7 @@ void ScientificBasic_Test() { assertEquals(3000, MathExpression.parseMathExpression("3E3")); assertEquals(0.04, MathExpression.parseMathExpression("4e-2")); assertEquals(0.05, MathExpression.parseMathExpression("5E-2")); + assertEquals(6000, MathExpression.parseMathExpression("6e+3")); assertEquals(6000, MathExpression.parseMathExpression("6 e 3")); assertEquals(7800, MathExpression.parseMathExpression("7.8e3")); From 23451a0d559df9469bc0a6ffb5d60427e5408581 Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Sat, 17 Feb 2024 15:08:00 -0600 Subject: [PATCH 7/9] Added toString for Operator and StackElement. --- .../modularui/api/math/MathExpression.java | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index a054d01..d78218d 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -239,14 +239,14 @@ public static double parseMathExpression(String expr, Context ctx) { 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.name; + 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.name + ctx.errorMessage = "Syntax error: two operators in a row: " + stack.get(stack.size() - 1).operator + ", " - + op.name; + + op; return false; } // Evaluate any preceding operations with equal or higher priority than op. @@ -310,7 +310,7 @@ private static boolean handleSuffix(@NotNull List stack, Suffix su StackElement a = stack.get(stack.size() - 1); if (!a.isValue) { ctx.success = false; - ctx.errorMessage = "Syntax error: suffix " + chr + " follows operator " + a.operator.name; + ctx.errorMessage = "Syntax error: suffix " + chr + " follows operator " + a.operator; return false; } stack.remove(stack.size() - 1); @@ -372,7 +372,7 @@ private static boolean handleClosedBracket(@NotNull List stack, Co 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.name; + + stack.get(stack.size() - 1).operator; return false; } @@ -405,8 +405,7 @@ private static boolean handleExpressionEnd(@NotNull List stack, Co } 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.name; + ctx.errorMessage = "Syntax error: no right-hand value for operator " + stack.get(stack.size() - 1).operator; return false; } @@ -467,6 +466,20 @@ public StackElement(double 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 { @@ -496,6 +509,11 @@ public double evaluate(double left, double right) { this.priority = priority; this.evaluator = evaluator; } + + @Override + public String toString() { + return String.valueOf(name); + } } private enum Suffix { From 9ab8e9ab8474129841223ec01626a735a5ce0cbf Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Sat, 17 Feb 2024 15:20:25 -0600 Subject: [PATCH 8/9] Default parsing locale to EN_US. See added comment for explanation. --- .../modularui/api/math/MathExpression.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index d78218d..666daee 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -1,9 +1,11 @@ package com.gtnewhorizons.modularui.api.math; +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; @@ -16,7 +18,7 @@ public class MathExpression { * strings that do not evaluate to a valid value. See {@link #parseMathExpression(String, Context)} for an * explanation of the syntax. */ - public static final Pattern EXPRESSION_PATTERN = Pattern.compile("[0-9.,  +\\-*/^()eEkKmMgGbBtT%]*"); + public static final Pattern EXPRESSION_PATTERN = Pattern.compile("[0-9.,  _+\\-*/^()eEkKmMgGbBtT%]*"); // Character ' ' (non-breaking space) to support French locale thousands separator. // TODO: @@ -585,13 +587,19 @@ public Context setHundredPercent(double hundredPercent) { return this; } - private NumberFormat numberFormat = NumberFormat.getNumberInstance(); + 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. *

    - * Defaults to the user's system locale NumberFormat. + * 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; From c84bd5949fc30af987f52f7624a76d56fc6f9209 Mon Sep 17 00:00:00 2001 From: Abdiel Kavash <19243993+AbdielKavash@users.noreply.github.com> Date: Sat, 17 Feb 2024 15:31:25 -0600 Subject: [PATCH 9/9] A word. --- .../com/gtnewhorizons/modularui/api/math/MathExpression.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java index 666daee..83c6521 100644 --- a/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java +++ b/src/main/java/com/gtnewhorizons/modularui/api/math/MathExpression.java @@ -297,7 +297,7 @@ private static boolean handleMinus(@NotNull List stack, Context ct /** * Handles adding a suffix on top of the stack. Suffixes are never actually added to the stack, since they have the - * highest priority the value on top of the stack is directly modified by the suffix. + * 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).