ExpressionPropertyCondition.java

/*
 * Logback: the reliable, generic, fast and flexible logging framework.
 * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
 *
 * This program and the accompanying materials are dual-licensed under
 * either the terms of the Eclipse Public License v2.0 as published by
 * the Eclipse Foundation
 *
 *   or (per the licensee's choosing)
 *
 * under the terms of the GNU Lesser General Public License version 2.1
 * as published by the Free Software Foundation.
 */

package ch.qos.logback.core.boolex;

import ch.qos.logback.core.util.IntHolder;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import java.util.function.BiFunction;
import java.util.function.Function;

/**
 * This class evaluates boolean expressions based on property lookups.
 * <p>It supports logical operators (NOT, AND, OR), boolean literals ({@code true} and
 * {@code false}), and functions like isNull, isDefined, propertyEquals, and propertyContains.
 * Expressions are parsed using the Shunting-Yard algorithm into Reverse Polish Notation (RPN)
 * for evaluation.
 * </p>
 *
 * <p>Example expression: {@code true && isDefined("key1") && propertyEquals("key2", "value")}</p>
 *
 * <p>Properties are resolved via {@link PropertyConditionBase#property(String)}.</p>
 *
 * @since 1.5.24
 */
public class ExpressionPropertyCondition extends PropertyConditionBase {


    public static final String MALFORMED_EXPRESSION = "Malformed expression: ";

    /**
     * A map that associates a string key with a function for evaluating boolean conditions.
     *
     * <p>This map defines the known functions. It can be overridden by subclasses to define
     * new functions.</p>
     *
     * <p>In the context of this class, a function is a function that takes a String
     * argument and returns a boolean.</p>
     */
    protected Map<String, Function<String, Boolean>> functionMap = new HashMap<>();


    /**
     * A map that associates a string key with a bi-function for evaluating boolean conditions.
     *
     * <p>This map defines the known bi-functions. It can be overridden by subclasses to define
     * new bi-functions.</p>
     *
     * <p>In the context of this class, a bi-function is a function that takes two String
     * arguments and returns a boolean.</p>
     */
    protected Map<String, BiFunction<String, String, Boolean>> biFunctionMap = new HashMap<>();

    private static final String IS_NULL_FUNCTION_KEY = "isNull";
    private static final String IS_DEFINED_FUNCTION_KEY = "isDefined";

    private static final String PROPERTY_EQUALS_FUNCTION_KEY = "propertyEquals";
    private static final String PROPERTY_CONTAINS_FUNCTION_KEY = "propertyContains";

    private static final String TRUE_LITERAL = "true";
    private static final String FALSE_LITERAL = "false";

    private static final char QUOTE = '"';
    private static final char COMMA = ',';
    private static final char LEFT_PAREN = '(';
    private static final char RIGHT_PAREN = ')';


    private static final char NOT_CHAR = '!';
    private static final char AMPERSAND_CHAR = '&';
    private static final char OR_CHAR = '|';

    enum Associativity {
        LEFT, RIGHT;
    }

    enum TokenType {
        NOT, AND, OR, TRUE, FALSE, FUNCTION, BI_FUNCTION, LEFT_PAREN, RIGHT_PAREN;

        boolean isLogicalOperator() {
            return this == NOT || this == AND || this == OR;
        }
    }


    static class Token {
        TokenType tokenType;
        String functionName;
        String param0;
        String param1;

        Token(TokenType tokenType) {
            this.tokenType = tokenType;

            switch (tokenType) {
                case LEFT_PAREN:
                case RIGHT_PAREN:
                case NOT:
                case AND:
                case OR:
                case TRUE:
                case FALSE:
                    break;
                default:
                    throw new IllegalStateException("Unexpected value: " + tokenType);
            }
        }

        Token(TokenType tokenType, String functionName, String propertyKey, String value) {
            this.tokenType = tokenType;
            this.functionName = functionName;
            this.param0 = propertyKey;
            this.param1 = value;
        }

        public static Token valueOf(char c) {

            if (c == LEFT_PAREN)
                return new Token(TokenType.LEFT_PAREN);
            if (c == RIGHT_PAREN)
                return new Token(TokenType.RIGHT_PAREN);
            throw new IllegalArgumentException("Unexpected char: " + c);
        }
    }


    String expression;
    List<Token> rpn;

    /**
     * Constructs an ExpressionPropertyCondition and initializes the function maps
     * with supported unary and binary functions.
     */
    public ExpressionPropertyCondition() {
        functionMap.put(IS_NULL_FUNCTION_KEY, this::isNull);
        functionMap.put(IS_DEFINED_FUNCTION_KEY, this::isDefined);
        biFunctionMap.put(PROPERTY_EQUALS_FUNCTION_KEY, this::propertyEquals);
        biFunctionMap.put(PROPERTY_CONTAINS_FUNCTION_KEY, this::propertyContains);
    }

    /**
     * Starts the condition by parsing the expression into tokens and converting
     * them to Reverse Polish Notation (RPN) for evaluation.
     *
     * <p>In case of malformed expression, the instance will not enter the "started" state.</p>
     */
    public void start() {
        if (expression == null || expression.isEmpty()) {
            addError("Empty expression");
            return;
        }

        try {
            List<Token> tokens = tokenize(expression.trim());
            this.rpn = infixToReversePolishNotation(tokens);
        } catch (IllegalArgumentException|IllegalStateException e) {
            addError(MALFORMED_EXPRESSION + e.getMessage());
            return;
        }
        super.start();
    }

    /**
     * Returns the current expression string.
     *
     * @return the expression, or null if not set
     */
    public String getExpression() {
        return expression;
    }

    /**
     * Sets the expression to be evaluated.
     *
     * @param expression the boolean expression string
     */
    public void setExpression(String expression) {
        this.expression = expression;
    }

    /**
     * Evaluates the parsed expression against the current property context.
     *
     * <p>If the instance is not in started state, returns false.</p>
     *
     * @return true if the expression evaluates to true, false otherwise
     */
    @Override
    public boolean evaluate() {
        if (!isStarted()) {
            return false;
        }
        return evaluateRPN(rpn);
    }

    /**
     * Tokenizes the input expression string into a list of tokens, handling
     * functions, operators, and parentheses.
     *
     * @param expr the expression string to tokenize
     * @return list of tokens
     * @throws IllegalArgumentException if the expression is malformed
     */
    private List<Token> tokenize(String expr) throws IllegalArgumentException, IllegalStateException {
        List<Token> tokens = new ArrayList<>();

        int i = 0;
        while (i < expr.length()) {
            char c = expr.charAt(i);

            if (Character.isWhitespace(c)) {
                i++;
                continue;
            }

            if (c == LEFT_PAREN || c == RIGHT_PAREN) {
                tokens.add(Token.valueOf(c));
                i++;
                continue;
            }

            if (c == NOT_CHAR) {
                tokens.add(new Token(TokenType.NOT));
                i++;
                continue;
            }

            if (c == AMPERSAND_CHAR) {
                i++; // consume '&'
                c = expr.charAt(i);
                if (c == AMPERSAND_CHAR) {
                    tokens.add(new Token(TokenType.AND));
                    i++; // consume '&'
                    continue;
                } else {
                    throw new IllegalArgumentException("Expected '&' after '&'");
                }
            }

            if (c == OR_CHAR) {
                i++; // consume '|'
                c = expr.charAt(i);
                if (c == OR_CHAR) {
                    tokens.add(new Token(TokenType.OR));
                    i++; // consume '|'
                    continue;
                } else {
                    throw new IllegalArgumentException("Expected '|' after '|'");
                }
            }

            // Parse identifiers like isNull, isNotNull, etc.
            if (Character.isLetter(c)) {
                StringBuilder sb = new StringBuilder();
                while (i < expr.length() && Character.isLetter(expr.charAt(i))) {
                    sb.append(expr.charAt(i++));
                }
                String identifier = sb.toString();

                if (TRUE_LITERAL.equals(identifier)) {
                    tokens.add(new Token(TokenType.TRUE));
                    continue;
                }
                if (FALSE_LITERAL.equals(identifier)) {
                    tokens.add(new Token(TokenType.FALSE));
                    continue;
                }

                String functionName = identifier;

                // Skip spaces
                i = skipWhitespaces(i);
                checkExpectedCharacter(LEFT_PAREN, i);
                i++; // consume '('

                IntHolder intHolder = new IntHolder(i);
                String param0 = extractQuotedString(intHolder);
                i = intHolder.value;
                // Skip spaces
                i = skipWhitespaces(i);


                if (biFunctionMap.containsKey(functionName)) {
                    checkExpectedCharacter(COMMA, i);
                    i++; // consume ','
                    intHolder.set(i);
                    String param1 = extractQuotedString(intHolder);
                    i = intHolder.get();
                    i = skipWhitespaces(i);
                    tokens.add(new Token(TokenType.BI_FUNCTION, functionName, param0, param1));
                } else {
                    tokens.add(new Token(TokenType.FUNCTION, functionName, param0, null));
                }

                // Skip spaces and expect ')'
                checkExpectedCharacter(RIGHT_PAREN, i);
                i++; // consume ')'

                continue;
            }

            throw new IllegalArgumentException("Unexpected character '" + c + "' at position " + i + " in [" + expr + "]");
        }
        return tokens;
    }

    private String extractQuotedString(IntHolder intHolder) {
        int i = intHolder.get();
        i = skipWhitespaces(i);

        // Expect starting "
        checkExpectedCharacter(QUOTE, i);
        i++; // consume starting "

        int start = i;
        i = findIndexOfClosingQuote(i);
        String param = expression.substring(start, i);
        i++; // consume closing "
        intHolder.set(i);
        return param;
    }

    private int findIndexOfClosingQuote(int i) throws IllegalStateException{
        while (i < expression.length() && expression.charAt(i) != QUOTE) {
            i++;
        }
        if (i >= expression.length()) {
            throw new IllegalStateException("Missing closing quote");
        }
        return i;
    }

    void checkExpectedCharacter(char expectedChar, int i) throws IllegalArgumentException{
        if (i >= expression.length() || expression.charAt(i) != expectedChar) {
            throw new IllegalArgumentException("In [" + expression + "] expecting '" + expectedChar + "' at position " + i);
        }
    }

    private int skipWhitespaces(int i) {
        while (i < expression.length() && Character.isWhitespace(expression.charAt(i))) {
            i++;
        }
        return i;
    }

    /**
     * Converts infix notation tokens to Reverse Polish Notation (RPN) using
     * the Shunting-Yard algorithm.
     *
     * @param tokens list of infix tokens
     * @return list of tokens in RPN
     * @throws IllegalArgumentException if parentheses are mismatched
     */
    private List<Token> infixToReversePolishNotation(List<Token> tokens) {
        List<Token> output = new ArrayList<>();
        Stack<Token> operatorStack = new Stack<>();

        for (Token token : tokens) {
            TokenType tokenType = token.tokenType;
            if (isPredicate(token)) {
                output.add(token);
            } else if (tokenType.isLogicalOperator()) {  // one of NOT, AND, OR types
                while (!operatorStack.isEmpty() && precedence(operatorStack.peek()) >= precedence(token) &&
                        operatorAssociativity(token) == Associativity.LEFT) {
                    output.add(operatorStack.pop());
                }
                operatorStack.push(token);
            } else if (tokenType == TokenType.LEFT_PAREN) {
                operatorStack.push(token);
            } else if (tokenType == TokenType.RIGHT_PAREN) {
                while (!operatorStack.isEmpty() && operatorStack.peek().tokenType != TokenType.LEFT_PAREN) {
                    output.add(operatorStack.pop());
                }
                if (operatorStack.isEmpty())
                    throw new IllegalArgumentException("Mismatched parentheses, expecting '('");
                operatorStack.pop(); // remove '('
            }
        }

        while (!operatorStack.isEmpty()) {
            Token token = operatorStack.pop();
            TokenType tokenType = token.tokenType;
            if (tokenType == TokenType.LEFT_PAREN)
                throw new IllegalArgumentException("Mismatched parentheses");
            output.add(token);
        }

        return output;
    }

    private boolean isPredicate(Token token) {
        TokenType tokenType = token.tokenType;
        return tokenType == TokenType.FUNCTION
                || tokenType == TokenType.BI_FUNCTION
                || tokenType == TokenType.TRUE
                || tokenType == TokenType.FALSE;
    }

    private int precedence(Token token) {
        TokenType tokenType = token.tokenType;
        switch (tokenType) {
            case NOT:
                return 3;
            case AND:
                return 2;
            case OR:
                return 1;
            default:
                return 0;
        }
    }

    private Associativity operatorAssociativity(Token token) {
        TokenType tokenType = token.tokenType;

        return tokenType == TokenType.NOT ? Associativity.RIGHT : Associativity.LEFT;
    }

    /**
     * Evaluates the Reverse Polish Notation (RPN) expression.
     *
     * @param rpn list of tokens in RPN
     * @return the boolean result of the evaluation
     * @throws IllegalStateException if a function is not defined in the function map
     */
    private boolean evaluateRPN(List<Token> rpn) throws IllegalStateException {
        Stack<Boolean> resultStack = new Stack<>();

        for (Token token : rpn) {
            if (isPredicate(token)) {
                boolean value = evaluateFunctions(token);
                resultStack.push(value);
            } else {
                switch (token.tokenType) {
                    case NOT:
                        boolean a3 = resultStack.pop();
                        resultStack.push(!a3);
                        break;
                    case AND:
                        boolean b2 = resultStack.pop();
                        boolean a2 = resultStack.pop();
                        resultStack.push(a2 && b2);
                        break;

                    case OR:
                        boolean b1 = resultStack.pop();
                        boolean a1 = resultStack.pop();
                        resultStack.push(a1 || b1);
                        break;
                }
            }
        }

        return resultStack.pop();
    }

    // Evaluate a single predicate like isNull("key1") or a boolean literal
    private boolean evaluateFunctions(Token token) throws IllegalStateException {
        if (token.tokenType == TokenType.TRUE) {
            return true;
        }
        if (token.tokenType == TokenType.FALSE) {
            return false;
        }

        String functionName = token.functionName;
        String param0 = token.param0;
        String param1 = token.param1;
        Function<String, Boolean> function = functionMap.get(functionName);
        if (function != null) {
            return function.apply(param0);
        }

        BiFunction<String, String, Boolean> biFunction = biFunctionMap.get(functionName);
        if (biFunction != null) {
            return biFunction.apply(param0, param1);
        }

        throw new IllegalStateException("Unknown function: " + token);
    }
}