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