MacroExpressionEvaluator.java
package com.varnernet.gerb4j;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Evaluates arithmetic expressions in Gerber aperture macros. Supports variable substitution
* ($1-$9) and arithmetic operations (+, -, x, /). Follows standard operator precedence:
* multiplication/division before addition/subtraction.
*
* <p>This is a service class that can be dependency-injected. Provides a static DEFAULT instance
* for convenience.
*/
public class MacroExpressionEvaluator {
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$(\\d+)");
/**
* Default instance for convenience.
*/
public static final MacroExpressionEvaluator DEFAULT = new MacroExpressionEvaluator();
/**
* Creates a new {@code MacroExpressionEvaluator}.
* Prefer using the shared {@link #DEFAULT} instance where possible.
*/
public MacroExpressionEvaluator() {
}
/**
* Evaluates a macro expression with variable substitution. Only AD-command parameters ($1…$N) are
* available.
*
* @param expression The expression string (e.g., "$1/2-$3")
* @param parameters List of parameter values (1-indexed: $1 = parameters.get(0))
* @return The evaluated double result
*/
public double evaluate(final String expression, final List<String> parameters) {
return evaluateWithVariables(expression, parameters, null);
}
/**
* Evaluates a macro expression with both AD-command parameters AND previously-computed macro
* variables available for substitution.
*
* <p>This is required for macros where a variable expression references another variable (e.g.,
* $7=$6x$3/$4 where $6 was computed earlier in the same macro).
*
* @param expression The expression string (e.g., "$6x$3/$4")
* @param parameters AD-command parameter values (1-indexed)
* @param computedVars Already-evaluated macro variables (may be null)
* @return The evaluated double result
*/
public double evaluateWithVariables(
final String expression, final List<String> parameters, final java.util.Map<Integer, Double> computedVars) {
if (expression == null || expression.isEmpty()) {
throw new IllegalArgumentException("Expression cannot be null or empty");
}
String expanded = substituteVariables(expression, parameters, computedVars);
try {
ExpressionParser parser = new ExpressionParser(expanded);
return parser.parseExpression();
} catch (Exception e) {
throw new IllegalArgumentException("Failed to evaluate expression: " + expression, e);
}
}
/**
* Substitutes macro variables ($1, $2, etc.) with their numeric values. Computed variables take
* priority over parameters for the same index.
*
* @param expression the expression string
* @param parameters the parameter values
* @param computedVars the computed variables
* @return the substituted expression
*/
protected String substituteVariables(
final String expression, final List<String> parameters, final java.util.Map<Integer, Double> computedVars) {
StringBuffer sb = new StringBuffer();
Matcher matcher = VARIABLE_PATTERN.matcher(expression);
while (matcher.find()) {
int varNumber = Integer.parseInt(matcher.group(1));
String value;
if (varNumber >= 1 && varNumber <= parameters.size()) {
// Parameters ($1…$N) always take priority — they are read-only per spec.
value = parameters.get(varNumber - 1);
} else if (computedVars != null && computedVars.containsKey(varNumber)) {
// Computed variable (number beyond the parameter range).
value = String.valueOf(computedVars.get(varNumber));
} else {
throw new IllegalArgumentException(String.format("Variable $%d not defined", varNumber));
}
matcher.appendReplacement(sb, Matcher.quoteReplacement(value));
}
matcher.appendTail(sb);
return sb.toString();
}
/**
* Backward-compat overload: parameters only, no computed vars.
*
* @param expression the expression string
* @param parameters the parameter values
* @return the substituted expression
*/
protected String substituteVariables(final String expression, final List<String> parameters) {
return substituteVariables(expression, parameters, null);
}
/**
* Recursive descent parser for arithmetic expressions. Protected so it can be customized in
* subclasses if needed.
*/
protected class ExpressionParser {
private final String input;
private int pos = 0;
ExpressionParser(final String input) {
this.input = input.replaceAll("\\s+", "").replace('x', '*');
this.pos = 0;
}
/**
* Parses the expression.
*
* @return the parsed double value
*/
double parseExpression() {
return parseAdditive();
}
// Handles + and - (lowest precedence)
private double parseAdditive() {
double result = parseMultiplicative();
while (pos < input.length()) {
char c = peek();
if (c == '+') {
consume('+');
result += parseMultiplicative();
} else if (c == '-' && !isLeadingMinus()) {
consume('-');
result -= parseMultiplicative();
} else {
break;
}
}
return result;
}
// Handles * and / (higher precedence)
private double parseMultiplicative() {
double result = parseUnary();
while (pos < input.length()) {
char c = peek();
if (c == '*') {
consume('*');
result *= parseUnary();
} else if (c == '/') {
consume('/');
double divisor = parseUnary();
if (divisor == 0) {
throw new ArithmeticException("Division by zero");
}
result /= divisor;
} else {
break;
}
}
return result;
}
// Handles unary minus and parentheses
private double parseUnary() {
if (pos < input.length() && input.charAt(pos) == '-') {
consume('-');
return -parsePrimary();
}
return parsePrimary();
}
// Handles numbers and parenthesized expressions
private double parsePrimary() {
if (pos < input.length() && input.charAt(pos) == '(') {
consume('(');
double result = parseExpression();
if (pos < input.length() && input.charAt(pos) == ')') {
consume(')');
} else {
throw new IllegalArgumentException("Missing closing parenthesis");
}
return result;
}
return parseNumber();
}
// Parses a number
private double parseNumber() {
int start = pos;
if (pos < input.length() && (input.charAt(pos) == '-' || input.charAt(pos) == '+')) {
pos++;
}
while (pos < input.length() && Character.isDigit(input.charAt(pos))) {
pos++;
}
if (pos < input.length() && input.charAt(pos) == '.') {
pos++;
while (pos < input.length() && Character.isDigit(input.charAt(pos))) {
pos++;
}
}
if (start == pos) {
throw new IllegalArgumentException("Expected number at position " + pos);
}
try {
return Double.parseDouble(input.substring(start, pos));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid number: " + input.substring(start, pos), e);
}
}
private char peek() {
if (pos < input.length()) {
return input.charAt(pos);
}
return '\0';
}
private void consume(final char expected) {
if (pos < input.length() && input.charAt(pos) == expected) {
pos++;
} else {
throw new IllegalArgumentException("Expected '" + expected + "' at position " + pos);
}
}
// Check if current minus is part of a negative number (leading minus)
private boolean isLeadingMinus() {
if (peek() != '-') {
return false;
}
// A minus is leading if it's not preceded by a number, ), or closing bracket
if (pos == 0) {
return true;
}
char prev = input.charAt(pos - 1);
return prev == '(' || prev == '*' || prev == '/' || prev == '+' || prev == '-';
}
}
}