001/*
002 * Logback: the reliable, generic, fast and flexible logging framework.
003 * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
004 *
005 * This program and the accompanying materials are dual-licensed under
006 * either the terms of the Eclipse Public License v2.0 as published by
007 * the Eclipse Foundation
008 *
009 *   or (per the licensee's choosing)
010 *
011 * under the terms of the GNU Lesser General Public License version 2.1
012 * as published by the Free Software Foundation.
013 */
014
015package ch.qos.logback.core.boolex;
016
017import ch.qos.logback.core.util.IntHolder;
018
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Stack;
024import java.util.function.BiFunction;
025import java.util.function.Function;
026
027/**
028 * This class evaluates boolean expressions based on property lookups.
029 * <p>It supports logical operators (NOT, AND, OR), boolean literals ({@code true} and
030 * {@code false}), and functions like isNull, isDefined, propertyEquals, and propertyContains.
031 * Expressions are parsed using the Shunting-Yard algorithm into Reverse Polish Notation (RPN)
032 * for evaluation.
033 * </p>
034 *
035 * <p>Example expression: {@code true && isDefined("key1") && propertyEquals("key2", "value")}</p>
036 *
037 * <p>Properties are resolved via {@link PropertyConditionBase#property(String)}.</p>
038 *
039 * @since 1.5.24
040 */
041public class ExpressionPropertyCondition extends PropertyConditionBase {
042
043
044    public static final String MALFORMED_EXPRESSION = "Malformed expression: ";
045
046    /**
047     * A map that associates a string key with a function for evaluating boolean conditions.
048     *
049     * <p>This map defines the known functions. It can be overridden by subclasses to define
050     * new functions.</p>
051     *
052     * <p>In the context of this class, a function is a function that takes a String
053     * argument and returns a boolean.</p>
054     */
055    protected Map<String, Function<String, Boolean>> functionMap = new HashMap<>();
056
057
058    /**
059     * A map that associates a string key with a bi-function for evaluating boolean conditions.
060     *
061     * <p>This map defines the known bi-functions. It can be overridden by subclasses to define
062     * new bi-functions.</p>
063     *
064     * <p>In the context of this class, a bi-function is a function that takes two String
065     * arguments and returns a boolean.</p>
066     */
067    protected Map<String, BiFunction<String, String, Boolean>> biFunctionMap = new HashMap<>();
068
069    private static final String IS_NULL_FUNCTION_KEY = "isNull";
070    private static final String IS_DEFINED_FUNCTION_KEY = "isDefined";
071
072    private static final String PROPERTY_EQUALS_FUNCTION_KEY = "propertyEquals";
073    private static final String PROPERTY_CONTAINS_FUNCTION_KEY = "propertyContains";
074
075    private static final String TRUE_LITERAL = "true";
076    private static final String FALSE_LITERAL = "false";
077
078    private static final char QUOTE = '"';
079    private static final char COMMA = ',';
080    private static final char LEFT_PAREN = '(';
081    private static final char RIGHT_PAREN = ')';
082
083
084    private static final char NOT_CHAR = '!';
085    private static final char AMPERSAND_CHAR = '&';
086    private static final char OR_CHAR = '|';
087
088    enum Associativity {
089        LEFT, RIGHT;
090    }
091
092    enum TokenType {
093        NOT, AND, OR, TRUE, FALSE, FUNCTION, BI_FUNCTION, LEFT_PAREN, RIGHT_PAREN;
094
095        boolean isLogicalOperator() {
096            return this == NOT || this == AND || this == OR;
097        }
098    }
099
100
101    static class Token {
102        TokenType tokenType;
103        String functionName;
104        String param0;
105        String param1;
106
107        Token(TokenType tokenType) {
108            this.tokenType = tokenType;
109
110            switch (tokenType) {
111                case LEFT_PAREN:
112                case RIGHT_PAREN:
113                case NOT:
114                case AND:
115                case OR:
116                case TRUE:
117                case FALSE:
118                    break;
119                default:
120                    throw new IllegalStateException("Unexpected value: " + tokenType);
121            }
122        }
123
124        Token(TokenType tokenType, String functionName, String propertyKey, String value) {
125            this.tokenType = tokenType;
126            this.functionName = functionName;
127            this.param0 = propertyKey;
128            this.param1 = value;
129        }
130
131        public static Token valueOf(char c) {
132
133            if (c == LEFT_PAREN)
134                return new Token(TokenType.LEFT_PAREN);
135            if (c == RIGHT_PAREN)
136                return new Token(TokenType.RIGHT_PAREN);
137            throw new IllegalArgumentException("Unexpected char: " + c);
138        }
139    }
140
141
142    String expression;
143    List<Token> rpn;
144
145    /**
146     * Constructs an ExpressionPropertyCondition and initializes the function maps
147     * with supported unary and binary functions.
148     */
149    public ExpressionPropertyCondition() {
150        functionMap.put(IS_NULL_FUNCTION_KEY, this::isNull);
151        functionMap.put(IS_DEFINED_FUNCTION_KEY, this::isDefined);
152        biFunctionMap.put(PROPERTY_EQUALS_FUNCTION_KEY, this::propertyEquals);
153        biFunctionMap.put(PROPERTY_CONTAINS_FUNCTION_KEY, this::propertyContains);
154    }
155
156    /**
157     * Starts the condition by parsing the expression into tokens and converting
158     * them to Reverse Polish Notation (RPN) for evaluation.
159     *
160     * <p>In case of malformed expression, the instance will not enter the "started" state.</p>
161     */
162    public void start() {
163        if (expression == null || expression.isEmpty()) {
164            addError("Empty expression");
165            return;
166        }
167
168        try {
169            List<Token> tokens = tokenize(expression.trim());
170            this.rpn = infixToReversePolishNotation(tokens);
171        } catch (IllegalArgumentException|IllegalStateException e) {
172            addError(MALFORMED_EXPRESSION + e.getMessage());
173            return;
174        }
175        super.start();
176    }
177
178    /**
179     * Returns the current expression string.
180     *
181     * @return the expression, or null if not set
182     */
183    public String getExpression() {
184        return expression;
185    }
186
187    /**
188     * Sets the expression to be evaluated.
189     *
190     * @param expression the boolean expression string
191     */
192    public void setExpression(String expression) {
193        this.expression = expression;
194    }
195
196    /**
197     * Evaluates the parsed expression against the current property context.
198     *
199     * <p>If the instance is not in started state, returns false.</p>
200     *
201     * @return true if the expression evaluates to true, false otherwise
202     */
203    @Override
204    public boolean evaluate() {
205        if (!isStarted()) {
206            return false;
207        }
208        return evaluateRPN(rpn);
209    }
210
211    /**
212     * Tokenizes the input expression string into a list of tokens, handling
213     * functions, operators, and parentheses.
214     *
215     * @param expr the expression string to tokenize
216     * @return list of tokens
217     * @throws IllegalArgumentException if the expression is malformed
218     */
219    private List<Token> tokenize(String expr) throws IllegalArgumentException, IllegalStateException {
220        List<Token> tokens = new ArrayList<>();
221
222        int i = 0;
223        while (i < expr.length()) {
224            char c = expr.charAt(i);
225
226            if (Character.isWhitespace(c)) {
227                i++;
228                continue;
229            }
230
231            if (c == LEFT_PAREN || c == RIGHT_PAREN) {
232                tokens.add(Token.valueOf(c));
233                i++;
234                continue;
235            }
236
237            if (c == NOT_CHAR) {
238                tokens.add(new Token(TokenType.NOT));
239                i++;
240                continue;
241            }
242
243            if (c == AMPERSAND_CHAR) {
244                i++; // consume '&'
245                c = expr.charAt(i);
246                if (c == AMPERSAND_CHAR) {
247                    tokens.add(new Token(TokenType.AND));
248                    i++; // consume '&'
249                    continue;
250                } else {
251                    throw new IllegalArgumentException("Expected '&' after '&'");
252                }
253            }
254
255            if (c == OR_CHAR) {
256                i++; // consume '|'
257                c = expr.charAt(i);
258                if (c == OR_CHAR) {
259                    tokens.add(new Token(TokenType.OR));
260                    i++; // consume '|'
261                    continue;
262                } else {
263                    throw new IllegalArgumentException("Expected '|' after '|'");
264                }
265            }
266
267            // Parse identifiers like isNull, isNotNull, etc.
268            if (Character.isLetter(c)) {
269                StringBuilder sb = new StringBuilder();
270                while (i < expr.length() && Character.isLetter(expr.charAt(i))) {
271                    sb.append(expr.charAt(i++));
272                }
273                String identifier = sb.toString();
274
275                if (TRUE_LITERAL.equals(identifier)) {
276                    tokens.add(new Token(TokenType.TRUE));
277                    continue;
278                }
279                if (FALSE_LITERAL.equals(identifier)) {
280                    tokens.add(new Token(TokenType.FALSE));
281                    continue;
282                }
283
284                String functionName = identifier;
285
286                // Skip spaces
287                i = skipWhitespaces(i);
288                checkExpectedCharacter(LEFT_PAREN, i);
289                i++; // consume '('
290
291                IntHolder intHolder = new IntHolder(i);
292                String param0 = extractQuotedString(intHolder);
293                i = intHolder.value;
294                // Skip spaces
295                i = skipWhitespaces(i);
296
297
298                if (biFunctionMap.containsKey(functionName)) {
299                    checkExpectedCharacter(COMMA, i);
300                    i++; // consume ','
301                    intHolder.set(i);
302                    String param1 = extractQuotedString(intHolder);
303                    i = intHolder.get();
304                    i = skipWhitespaces(i);
305                    tokens.add(new Token(TokenType.BI_FUNCTION, functionName, param0, param1));
306                } else {
307                    tokens.add(new Token(TokenType.FUNCTION, functionName, param0, null));
308                }
309
310                // Skip spaces and expect ')'
311                checkExpectedCharacter(RIGHT_PAREN, i);
312                i++; // consume ')'
313
314                continue;
315            }
316
317            throw new IllegalArgumentException("Unexpected character '" + c + "' at position " + i + " in [" + expr + "]");
318        }
319        return tokens;
320    }
321
322    private String extractQuotedString(IntHolder intHolder) {
323        int i = intHolder.get();
324        i = skipWhitespaces(i);
325
326        // Expect starting "
327        checkExpectedCharacter(QUOTE, i);
328        i++; // consume starting "
329
330        int start = i;
331        i = findIndexOfClosingQuote(i);
332        String param = expression.substring(start, i);
333        i++; // consume closing "
334        intHolder.set(i);
335        return param;
336    }
337
338    private int findIndexOfClosingQuote(int i) throws IllegalStateException{
339        while (i < expression.length() && expression.charAt(i) != QUOTE) {
340            i++;
341        }
342        if (i >= expression.length()) {
343            throw new IllegalStateException("Missing closing quote");
344        }
345        return i;
346    }
347
348    void checkExpectedCharacter(char expectedChar, int i) throws IllegalArgumentException{
349        if (i >= expression.length() || expression.charAt(i) != expectedChar) {
350            throw new IllegalArgumentException("In [" + expression + "] expecting '" + expectedChar + "' at position " + i);
351        }
352    }
353
354    private int skipWhitespaces(int i) {
355        while (i < expression.length() && Character.isWhitespace(expression.charAt(i))) {
356            i++;
357        }
358        return i;
359    }
360
361    /**
362     * Converts infix notation tokens to Reverse Polish Notation (RPN) using
363     * the Shunting-Yard algorithm.
364     *
365     * @param tokens list of infix tokens
366     * @return list of tokens in RPN
367     * @throws IllegalArgumentException if parentheses are mismatched
368     */
369    private List<Token> infixToReversePolishNotation(List<Token> tokens) {
370        List<Token> output = new ArrayList<>();
371        Stack<Token> operatorStack = new Stack<>();
372
373        for (Token token : tokens) {
374            TokenType tokenType = token.tokenType;
375            if (isPredicate(token)) {
376                output.add(token);
377            } else if (tokenType.isLogicalOperator()) {  // one of NOT, AND, OR types
378                while (!operatorStack.isEmpty() && precedence(operatorStack.peek()) >= precedence(token) &&
379                        operatorAssociativity(token) == Associativity.LEFT) {
380                    output.add(operatorStack.pop());
381                }
382                operatorStack.push(token);
383            } else if (tokenType == TokenType.LEFT_PAREN) {
384                operatorStack.push(token);
385            } else if (tokenType == TokenType.RIGHT_PAREN) {
386                while (!operatorStack.isEmpty() && operatorStack.peek().tokenType != TokenType.LEFT_PAREN) {
387                    output.add(operatorStack.pop());
388                }
389                if (operatorStack.isEmpty())
390                    throw new IllegalArgumentException("Mismatched parentheses, expecting '('");
391                operatorStack.pop(); // remove '('
392            }
393        }
394
395        while (!operatorStack.isEmpty()) {
396            Token token = operatorStack.pop();
397            TokenType tokenType = token.tokenType;
398            if (tokenType == TokenType.LEFT_PAREN)
399                throw new IllegalArgumentException("Mismatched parentheses");
400            output.add(token);
401        }
402
403        return output;
404    }
405
406    private boolean isPredicate(Token token) {
407        TokenType tokenType = token.tokenType;
408        return tokenType == TokenType.FUNCTION
409                || tokenType == TokenType.BI_FUNCTION
410                || tokenType == TokenType.TRUE
411                || tokenType == TokenType.FALSE;
412    }
413
414    private int precedence(Token token) {
415        TokenType tokenType = token.tokenType;
416        switch (tokenType) {
417            case NOT:
418                return 3;
419            case AND:
420                return 2;
421            case OR:
422                return 1;
423            default:
424                return 0;
425        }
426    }
427
428    private Associativity operatorAssociativity(Token token) {
429        TokenType tokenType = token.tokenType;
430
431        return tokenType == TokenType.NOT ? Associativity.RIGHT : Associativity.LEFT;
432    }
433
434    /**
435     * Evaluates the Reverse Polish Notation (RPN) expression.
436     *
437     * @param rpn list of tokens in RPN
438     * @return the boolean result of the evaluation
439     * @throws IllegalStateException if a function is not defined in the function map
440     */
441    private boolean evaluateRPN(List<Token> rpn) throws IllegalStateException {
442        Stack<Boolean> resultStack = new Stack<>();
443
444        for (Token token : rpn) {
445            if (isPredicate(token)) {
446                boolean value = evaluateFunctions(token);
447                resultStack.push(value);
448            } else {
449                switch (token.tokenType) {
450                    case NOT:
451                        boolean a3 = resultStack.pop();
452                        resultStack.push(!a3);
453                        break;
454                    case AND:
455                        boolean b2 = resultStack.pop();
456                        boolean a2 = resultStack.pop();
457                        resultStack.push(a2 && b2);
458                        break;
459
460                    case OR:
461                        boolean b1 = resultStack.pop();
462                        boolean a1 = resultStack.pop();
463                        resultStack.push(a1 || b1);
464                        break;
465                }
466            }
467        }
468
469        return resultStack.pop();
470    }
471
472    // Evaluate a single predicate like isNull("key1") or a boolean literal
473    private boolean evaluateFunctions(Token token) throws IllegalStateException {
474        if (token.tokenType == TokenType.TRUE) {
475            return true;
476        }
477        if (token.tokenType == TokenType.FALSE) {
478            return false;
479        }
480
481        String functionName = token.functionName;
482        String param0 = token.param0;
483        String param1 = token.param1;
484        Function<String, Boolean> function = functionMap.get(functionName);
485        if (function != null) {
486            return function.apply(param0);
487        }
488
489        BiFunction<String, String, Boolean> biFunction = biFunctionMap.get(functionName);
490        if (biFunction != null) {
491            return biFunction.apply(param0, param1);
492        }
493
494        throw new IllegalStateException("Unknown function: " + token);
495    }
496}