ConditionParser.java
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.maven.impl.model.profile;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.UnaryOperator;
/**
* The {@code ConditionParser} class is responsible for parsing and evaluating expressions.
* It supports tokenizing the input expression and resolving custom functions passed in a map.
* This class implements a recursive descent parser to handle various operations including
* arithmetic, logical, and comparison operations, as well as function calls.
*/
public class ConditionParser {
/**
* A functional interface that represents an expression function to be applied
* to a list of arguments. Implementers can define custom functions.
*/
public interface ExpressionFunction {
/**
* Applies the function to the given list of arguments.
*
* @param args the list of arguments passed to the function
* @return the result of applying the function
*/
Object apply(List<Object> args);
}
private final Map<String, ExpressionFunction> functions; // Map to store functions by their names
private final UnaryOperator<String> propertyResolver; // Property resolver
private List<String> tokens; // List of tokens derived from the expression
private int current; // Keeps track of the current token index
/**
* Constructs a new {@code ConditionParser} with the given function mappings.
*
* @param functions a map of function names to their corresponding {@code ExpressionFunction} implementations
* @param propertyResolver the property resolver
*/
public ConditionParser(Map<String, ExpressionFunction> functions, UnaryOperator<String> propertyResolver) {
this.functions = functions;
this.propertyResolver = propertyResolver;
}
/**
* Parses the given expression and returns the result of the evaluation.
*
* @param expression the expression to be parsed
* @return the result of parsing and evaluating the expression
*/
public Object parse(String expression) {
this.tokens = tokenize(expression);
this.current = 0;
return parseExpression();
}
/**
* Tokenizes the input expression into a list of string tokens for further parsing.
* This method handles quoted strings, property aliases, and various operators.
*
* @param expression the expression to tokenize
* @return a list of tokens
*/
private List<String> tokenize(String expression) {
List<String> tokens = new ArrayList<>();
StringBuilder sb = new StringBuilder();
char quoteType = 0;
boolean inPropertyReference = false;
for (int i = 0; i < expression.length(); i++) {
char c = expression.charAt(i);
if (quoteType != 0) {
if (c == quoteType) {
quoteType = 0;
sb.append(c);
tokens.add(sb.toString());
sb.setLength(0);
} else {
sb.append(c);
}
continue;
}
if (inPropertyReference) {
if (c == '}') {
inPropertyReference = false;
tokens.add("${" + sb + "}");
sb.setLength(0);
} else {
sb.append(c);
}
continue;
}
if (c == '$' && i + 1 < expression.length() && expression.charAt(i + 1) == '{') {
if (!sb.isEmpty()) {
tokens.add(sb.toString());
sb.setLength(0);
}
inPropertyReference = true;
i++; // Skip the '{'
continue;
}
if (c == '"' || c == '\'') {
if (!sb.isEmpty()) {
tokens.add(sb.toString());
sb.setLength(0);
}
quoteType = c;
sb.append(c);
} else if (c == ' ' || c == '(' || c == ')' || c == ',' || c == '+' || c == '>' || c == '<' || c == '='
|| c == '!') {
if (!sb.isEmpty()) {
tokens.add(sb.toString());
sb.setLength(0);
}
if (c != ' ') {
if ((c == '>' || c == '<' || c == '=' || c == '!')
&& i + 1 < expression.length()
&& expression.charAt(i + 1) == '=') {
tokens.add(c + "=");
i++; // Skip the next character
} else {
tokens.add(String.valueOf(c));
}
}
} else {
sb.append(c);
}
}
if (inPropertyReference) {
throw new RuntimeException("Unclosed property reference: ${");
}
if (!sb.isEmpty()) {
tokens.add(sb.toString());
}
return tokens;
}
/**
* Parses the next expression from the list of tokens.
*
* @return the parsed expression as an object
* @throws RuntimeException if there are unexpected tokens after the end of the expression
*/
private Object parseExpression() {
Object result = parseLogicalOr();
if (current < tokens.size()) {
throw new RuntimeException("Unexpected tokens after end of expression");
}
return result;
}
/**
* Parses logical OR operations.
*
* @return the result of parsing logical OR operations
*/
private Object parseLogicalOr() {
Object left = parseLogicalAnd();
while (current < tokens.size() && tokens.get(current).equals("||")) {
current++;
Object right = parseLogicalAnd();
left = (boolean) left || (boolean) right;
}
return left;
}
/**
* Parses logical AND operations.
*
* @return the result of parsing logical AND operations
*/
private Object parseLogicalAnd() {
Object left = parseComparison();
while (current < tokens.size() && tokens.get(current).equals("&&")) {
current++;
Object right = parseComparison();
left = (boolean) left && (boolean) right;
}
return left;
}
/**
* Parses comparison operations.
*
* @return the result of parsing comparison operations
*/
private Object parseComparison() {
Object left = parseAddSubtract();
while (current < tokens.size()
&& (tokens.get(current).equals(">")
|| tokens.get(current).equals("<")
|| tokens.get(current).equals(">=")
|| tokens.get(current).equals("<=")
|| tokens.get(current).equals("==")
|| tokens.get(current).equals("!="))) {
String operator = tokens.get(current);
current++;
Object right = parseAddSubtract();
left = compare(left, operator, right);
}
return left;
}
/**
* Parses addition and subtraction operations.
*
* @return the result of parsing addition and subtraction operations
*/
private Object parseAddSubtract() {
Object left = parseMultiplyDivide();
while (current < tokens.size()
&& (tokens.get(current).equals("+") || tokens.get(current).equals("-"))) {
String operator = tokens.get(current);
current++;
Object right = parseMultiplyDivide();
if (operator.equals("+")) {
left = add(left, right);
} else {
left = subtract(left, right);
}
}
return left;
}
/**
* Parses multiplication and division operations.
*
* @return the result of parsing multiplication and division operations
*/
private Object parseMultiplyDivide() {
Object left = parseUnary();
while (current < tokens.size()
&& (tokens.get(current).equals("*") || tokens.get(current).equals("/"))) {
String operator = tokens.get(current);
current++;
Object right = parseUnary();
if (operator.equals("*")) {
left = multiply(left, right);
} else {
left = divide(left, right);
}
}
return left;
}
/**
* Parses unary operations (negation).
*
* @return the result of parsing unary operations
*/
private Object parseUnary() {
if (current < tokens.size() && tokens.get(current).equals("-")) {
current++;
Object value = parseUnary();
return negate(value);
}
return parseTerm();
}
/**
* Parses individual terms (numbers, strings, booleans, parentheses, functions).
*
* @return the parsed term
* @throws RuntimeException if the expression ends unexpectedly or contains unknown tokens
*/
private Object parseTerm() {
if (current >= tokens.size()) {
throw new RuntimeException("Unexpected end of expression");
}
String token = tokens.get(current);
if (token.equals("(")) {
return parseParentheses();
} else if (functions.containsKey(token)) {
return parseFunction();
} else if ((token.startsWith("\"") && token.endsWith("\"")) || (token.startsWith("'") && token.endsWith("'"))) {
current++;
return token.length() > 1 ? token.substring(1, token.length() - 1) : "";
} else if (token.equalsIgnoreCase("true") || token.equalsIgnoreCase("false")) {
current++;
return Boolean.parseBoolean(token);
} else if (token.startsWith("${") && token.endsWith("}")) {
current++;
String propertyName = token.substring(2, token.length() - 1);
return propertyResolver.apply(propertyName);
} else {
try {
current++;
return Double.parseDouble(token);
} catch (NumberFormatException e) {
// If it's not a number, treat it as a variable or unknown function
return parseVariableOrUnknownFunction();
}
}
}
/**
* Parses a token that could be either a variable or an unknown function.
*
* @return the result of parsing a variable or unknown function
* @throws RuntimeException if an unknown function is encountered
*/
private Object parseVariableOrUnknownFunction() {
current--; // Move back to the token we couldn't parse as a number
String name = tokens.get(current);
current++;
// Check if it's followed by an opening parenthesis, indicating a function call
if (current < tokens.size() && tokens.get(current).equals("(")) {
// It's a function call, parse it as such
List<Object> args = parseArgumentList();
if (functions.containsKey(name)) {
return functions.get(name).apply(args);
} else {
throw new RuntimeException("Unknown function: " + name);
}
} else {
// It's a variable
// Here you might want to handle variables differently
// For now, we'll throw an exception
throw new RuntimeException("Unknown variable: " + name);
}
}
/**
* Parses a list of arguments for a function call.
*
* @return a list of parsed arguments
* @throws RuntimeException if there's a mismatch in parentheses
*/
private List<Object> parseArgumentList() {
List<Object> args = new ArrayList<>();
current++; // Skip the opening parenthesis
while (current < tokens.size() && !tokens.get(current).equals(")")) {
args.add(parseLogicalOr());
if (current < tokens.size() && tokens.get(current).equals(",")) {
current++;
}
}
if (current >= tokens.size() || !tokens.get(current).equals(")")) {
throw new RuntimeException("Mismatched parentheses: missing closing parenthesis in function call");
}
current++; // Skip the closing parenthesis
return args;
}
/**
* Parses a function call.
*
* @return the result of the function call
*/
private Object parseFunction() {
String functionName = tokens.get(current);
current++;
List<Object> args = parseArgumentList();
return functions.get(functionName).apply(args);
}
/**
* Parses an expression within parentheses.
*
* @return the result of parsing the expression within parentheses
* @throws RuntimeException if there's a mismatch in parentheses
*/
private Object parseParentheses() {
current++; // Skip the opening parenthesis
Object result = parseLogicalOr();
if (current >= tokens.size() || !tokens.get(current).equals(")")) {
throw new RuntimeException("Mismatched parentheses: missing closing parenthesis");
}
current++; // Skip the closing parenthesis
return result;
}
/**
* Adds two objects, handling string concatenation and numeric addition.
*
* @param left the left operand
* @param right the right operand
* @return the result of the addition
* @throws RuntimeException if the operands cannot be added
*/
private static Object add(Object left, Object right) {
if (left instanceof String || right instanceof String) {
return toString(left) + toString(right);
} else if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
return leftNumber.doubleValue() + rightNumber.doubleValue();
} else {
throw new RuntimeException("Cannot add " + left + " and " + right);
}
}
/**
* Negates a numeric value.
*
* @param value the value to negate
* @return the negated value
* @throws RuntimeException if the value cannot be negated
*/
private Object negate(Object value) {
if (value instanceof Number number) {
return -number.doubleValue();
}
throw new RuntimeException("Cannot negate non-numeric value: " + value);
}
/**
* Subtracts the right operand from the left operand.
*
* @param left the left operand
* @param right the right operand
* @return the result of the subtraction
* @throws RuntimeException if the operands cannot be subtracted
*/
private static Object subtract(Object left, Object right) {
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
return leftNumber.doubleValue() - rightNumber.doubleValue();
} else {
throw new RuntimeException("Cannot subtract " + right + " from " + left);
}
}
/**
* Multiplies two numeric operands.
*
* @param left the left operand
* @param right the right operand
* @return the result of the multiplication
* @throws RuntimeException if the operands cannot be multiplied
*/
private static Object multiply(Object left, Object right) {
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
return leftNumber.doubleValue() * rightNumber.doubleValue();
} else {
throw new RuntimeException("Cannot multiply " + left + " and " + right);
}
}
/**
* Divides the left operand by the right operand.
*
* @param left the left operand (dividend)
* @param right the right operand (divisor)
* @return the result of the division
* @throws RuntimeException if the operands cannot be divided
* @throws ArithmeticException if attempting to divide by zero
*/
private static Object divide(Object left, Object right) {
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
double divisor = rightNumber.doubleValue();
if (divisor == 0) {
throw new ArithmeticException("Division by zero");
}
return leftNumber.doubleValue() / divisor;
} else {
throw new RuntimeException("Cannot divide " + left + " by " + right);
}
}
/**
* Compares two objects based on the given operator.
* Supports comparison of numbers and strings, and equality checks for null values.
*
* @param left the left operand
* @param operator the comparison operator (">", "<", ">=", "<=", "==", or "!=")
* @param right the right operand
* @return the result of the comparison (a boolean value)
* @throws IllegalStateException if an unknown operator is provided
* @throws RuntimeException if the operands cannot be compared
*/
private static Object compare(Object left, String operator, Object right) {
if (left == null && right == null) {
return true;
}
if (left == null || right == null) {
if ("==".equals(operator)) {
return false;
} else if ("!=".equals(operator)) {
return true;
}
}
if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
double leftVal = leftNumber.doubleValue();
double rightVal = rightNumber.doubleValue();
return switch (operator) {
case ">" -> leftVal > rightVal;
case "<" -> leftVal < rightVal;
case ">=" -> leftVal >= rightVal;
case "<=" -> leftVal <= rightVal;
case "==" -> Math.abs(leftVal - rightVal) < 1e-9;
case "!=" -> Math.abs(leftVal - rightVal) >= 1e-9;
default -> throw new IllegalStateException("Unknown operator: " + operator);
};
} else if (left instanceof String leftString && right instanceof String rightString) {
int comparison = leftString.compareTo(rightString);
return switch (operator) {
case ">" -> comparison > 0;
case "<" -> comparison < 0;
case ">=" -> comparison >= 0;
case "<=" -> comparison <= 0;
case "==" -> comparison == 0;
case "!=" -> comparison != 0;
default -> throw new IllegalStateException("Unknown operator: " + operator);
};
}
throw new RuntimeException("Cannot compare " + left + " and " + right + " with operator " + operator);
}
/**
* Converts an object to a string representation.
* If the object is a {@code Double}, it formats it without any decimal places.
* Otherwise, it uses the {@code String.valueOf} method.
*
* @param value the object to convert to a string
* @return the string representation of the object
*/
public static String toString(Object value) {
if (value instanceof Double || value instanceof Float) {
double doubleValue = ((Number) value).doubleValue();
if (doubleValue == Math.floor(doubleValue) && !Double.isInfinite(doubleValue)) {
return String.format("%.0f", doubleValue);
}
}
return String.valueOf(value);
}
/**
* Converts an object to a boolean value.
* If the object is:
* - a {@code Boolean}, returns its value directly.
* - a {@code String}, returns {@code true} if the string is non-blank.
* - a {@code Number}, returns {@code true} if its integer value is not zero.
* For other object types, returns {@code true} if the object is non-null.
*
* @param value the object to convert to a boolean
* @return the boolean representation of the object
*/
public static Boolean toBoolean(Object value) {
if (value instanceof Boolean b) {
return b; // Returns the boolean value
} else if (value instanceof String s) {
return !s.isBlank(); // True if the string is not blank
} else if (value instanceof Number b) {
return b.intValue() != 0; // True if the number is not zero
} else {
return value != null; // True if the object is not null
}
}
/**
* Converts an object to a double value.
* If the object is:
* - a {@code Number}, returns its double value.
* - a {@code String}, tries to parse it as a double.
* - a {@code Boolean}, returns {@code 1.0} for {@code true}, {@code 0.0} for {@code false}.
* If the object cannot be converted, a {@code RuntimeException} is thrown.
*
* @param value the object to convert to a double
* @return the double representation of the object
* @throws RuntimeException if the object cannot be converted to a double
*/
public static double toDouble(Object value) {
if (value instanceof Number number) {
return number.doubleValue(); // Converts number to double
} else if (value instanceof String string) {
try {
return Double.parseDouble(string); // Tries to parse string as double
} catch (NumberFormatException e) {
throw new RuntimeException("Cannot convert string to number: " + value);
}
} else if (value instanceof Boolean bool) {
return bool ? 1.0 : 0.0; // True = 1.0, False = 0.0
} else {
throw new RuntimeException("Cannot convert to number: " + value);
}
}
/**
* Converts an object to an integer value.
* If the object is:
* - a {@code Number}, returns its integer value.
* - a {@code String}, tries to parse it as an integer, or as a double then converted to an integer.
* - a {@code Boolean}, returns {@code 1} for {@code true}, {@code 0} for {@code false}.
* If the object cannot be converted, a {@code RuntimeException} is thrown.
*
* @param value the object to convert to an integer
* @return the integer representation of the object
* @throws RuntimeException if the object cannot be converted to an integer
*/
public static int toInt(Object value) {
if (value instanceof Number number) {
return number.intValue(); // Converts number to int
} else if (value instanceof String string) {
try {
return Integer.parseInt(string); // Tries to parse string as int
} catch (NumberFormatException e) {
// If string is not an int, tries parsing as double and converting to int
try {
return (int) Double.parseDouble((String) value);
} catch (NumberFormatException e2) {
throw new RuntimeException("Cannot convert string to integer: " + value);
}
}
} else if (value instanceof Boolean bool) {
return bool ? 1 : 0; // True = 1, False = 0
} else {
throw new RuntimeException("Cannot convert to integer: " + value);
}
}
}