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 == '-';
        }
    }
}