1   /*
2    * Logback: the reliable, generic, fast and flexible logging framework.
3    * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
4    *
5    * This program and the accompanying materials are dual-licensed under
6    * either the terms of the Eclipse Public License v2.0 as published by
7    * the Eclipse Foundation
8    *
9    *   or (per the licensee's choosing)
10   *
11   * under the terms of the GNU Lesser General Public License version 2.1
12   * as published by the Free Software Foundation.
13   */
14  
15  package ch.qos.logback.core.boolex;
16  
17  import ch.qos.logback.core.util.IntHolder;
18  
19  import java.util.ArrayList;
20  import java.util.HashMap;
21  import java.util.List;
22  import java.util.Map;
23  import java.util.Stack;
24  import java.util.function.BiFunction;
25  import java.util.function.Function;
26  
27  /**
28   * This class evaluates boolean expressions based on property lookups.
29   * <p>It supports logical operators (NOT, AND, OR) and functions like isNull, isDefined,
30   * propertyEquals, and propertyContains. Expressions are parsed using the Shunting-Yard
31   * algorithm into Reverse Polish Notation (RPN) for evaluation.
32   * </p>
33   *
34   * <p>Example expression: {@code isDefined("key1") && propertyEquals("key2", "value")}</p>
35   *
36   * <p>Properties are resolved via {@link PropertyConditionBase#property(String)}.</p>
37   *
38   * @since 1.5.24
39   */
40  public class ExpressionPropertyCondition extends PropertyConditionBase {
41  
42  
43      /**
44       * A map that associates a string key with a function for evaluating boolean conditions.
45       *
46       * <p>This map defines the known functions. It can be overridden by subclasses to define
47       * new functions.</p>
48       *
49       * <p>In the context of this class, a function is a function that takes a String
50       * argument and returns a boolean.</p>
51       */
52      protected Map<String, Function<String, Boolean>> functionMap = new HashMap<>();
53  
54  
55      /**
56       * A map that associates a string key with a bi-function for evaluating boolean conditions.
57       *
58       * <p>This map defines the known bi-functions. It can be overridden by subclasses to define
59       * new bi-functions.</p>
60       *
61       * <p>In the context of this class, a bi-function is a function that takes two String
62       * arguments and returns a boolean.</p>
63       */
64      protected Map<String, BiFunction<String, String, Boolean>> biFunctionMap = new HashMap<>();
65  
66      private static final String IS_NULL_FUNCTION_KEY = "isNull";
67      private static final String IS_DEFINEDP_FUNCTION_KEY = "isDefined";
68  
69      private static final String PROPERTY_EQUALS_FUNCTION_KEY = "propertyEquals";
70      private static final String PROPERTY_CONTAINS_FUNCTION_KEY = "propertyContains";
71  
72      private static final char QUOTE = '"';
73      private static final char COMMA = ',';
74      private static final char LEFT_PAREN = '(';
75      private static final char RIGHT_PAREN = ')';
76  
77  
78      private static final char NOT_CHAR = '!';
79      private static final char AMPERSAND_CHAR = '&';
80      private static final char OR_CHAR = '|';
81  
82      enum Associativity {
83          LEFT, RIGHT;
84      }
85  
86      enum TokenType {
87          NOT, AND, OR, FUNCTION, BI_FUNCTION, LEFT_PAREN, RIGHT_PAREN;
88  
89          boolean isLogicalOperator() {
90              return this == NOT || this == AND || this == OR;
91          }
92      }
93  
94  
95      static class Token {
96          TokenType tokenType;
97          String functionName;
98          String param0;
99          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 }