This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-24962
in repository https://gitbox.apache.org/repos/asf/camel.git

commit c6211c63109adac5fb4ddb44f4792a537f9d4765
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 19:04:07 2026 +0200

    CAMEL-24964: simple - fix quotes, braces, chain and ternary edge cases in 
the parser
    
    - a single quote inside double quotes in a function no longer breaks the 
predicate:
      ${body.replace("'", "")} == 'x' was true for any body
    - a } inside double quotes no longer breaks what follows
    - a chain followed by an operator with a number or null: ${body} ~> 
${length()} > 5
    - ~> needs a space on both sides, so A~>B and ${body.replace('~>', '-')} 
are text
    - ${body == 'a > b'} compares with == (operators inside quotes are text)
    - ${bean:svc?method=echo('10:30')} is not a ternary: ? and : need 
whitespace around them
    - fix numericSupported typo in the binary operator grammar
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../language/simple/SimplePredicateParser.java     | 23 ++++--
 .../camel/language/simple/SimpleSyntaxHints.java   | 57 +++++++++++---
 .../camel/language/simple/SimpleTokenizer.java     |  4 +
 .../language/simple/ast/SimpleFunctionStart.java   |  9 ++-
 .../language/simple/SimpleParserEdgeCasesTest.java | 89 ++++++++++++++++++++++
 5 files changed, 162 insertions(+), 20 deletions(-)

diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
index c9bde8c99ae8..3e28d2b9184c 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimplePredicateParser.java
@@ -389,19 +389,25 @@ public class SimplePredicateParser extends 
BaseSimpleParser {
         tokens.removeIf(t -> t.getType().isIgnore());
 
         // white space can be removed if its not part of a quoted text or 
within function(s)
-        boolean quote = false;
+        // a single quote inside double quotes (and vice versa) is text, such 
as ${body.replace("'", "")}
+        boolean single = false;
+        boolean dubble = false;
         int functionCount = 0;
 
         Iterator<SimpleToken> it = tokens.iterator();
         while (it.hasNext()) {
             SimpleToken token = it.next();
-            if (token.getType().isSingleQuote()) {
-                quote = !quote;
-            } else if (!quote) {
+            if (token.getType().isSingleQuote() && !dubble) {
+                single = !single;
+            } else if (token.getType().isDoubleQuote() && !single) {
+                dubble = !dubble;
+            } else if (!single && !dubble) {
                 if (token.getType().isFunctionStart()) {
                     functionCount++;
                 } else if (token.getType().isFunctionEnd()) {
-                    functionCount--;
+                    if (functionCount > 0) {
+                        functionCount--;
+                    }
                 } else if (token.getType().isWhitespace() && functionCount == 
0) {
                     it.remove();
                 }
@@ -702,7 +708,7 @@ public class SimplePredicateParser extends BaseSimpleParser 
{
                     literalSupported |= parameterType.isLiteralSupported();
                     literalWithFunctionsSupported |= 
parameterType.isLiteralWithFunctionSupport();
                     functionSupported |= parameterType.isFunctionSupport();
-                    nullSupported |= parameterType.isNumericValueSupported();
+                    numericSupported |= 
parameterType.isNumericValueSupported();
                     booleanSupported |= 
parameterType.isBooleanValueSupported();
                     nullSupported |= parameterType.isNullValueSupported();
                     minusSupported |= parameterType.isMinusValueSupported();
@@ -809,9 +815,10 @@ public class SimplePredicateParser extends 
BaseSimpleParser {
                     || booleanValue()
                     || nullValue()) {
                 // then after the right hand side value, there should be a 
whitespace if there is more tokens
+                // (do not accept more, as the token after the whitespace, 
such as an operator, is parsed next)
                 nextToken();
                 if (!token.getType().isEol()) {
-                    expectAndAcceptMore(TokenType.whiteSpace);
+                    expect(TokenType.whiteSpace);
                 }
             } else {
                 throw new SimpleParserException(
@@ -870,6 +877,8 @@ public class SimplePredicateParser extends BaseSimpleParser 
{
     }
 
     protected boolean minusValue() {
+        // note: this skips the current token without checking it is a minus 
sign, which is lenient on purpose
+        // as routes may compare with unquoted text such as ${header.version} 
== v2
         nextToken();
         return accept(TokenType.numericValue);
         // no other tokens to check so do not use nextToken
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
index a046b1294e53..f39dbb5c30be 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java
@@ -204,26 +204,59 @@ public final class SimpleSyntaxHints {
 
     /** Wraps the left hand side of one comparison with {@code ${ }} when it 
is a function reference. */
     private static String wrapComparison(String text) {
+        // the first operator outside quotes, so ${body == 'a > b'} is 
compared with ==
+        String first = null;
+        int firstAt = -1;
         for (String op : SPACED_OPERATORS) {
-            int at = text.indexOf(op);
-            if (at < 0 && text.endsWith(op.stripTrailing())) {
-                // the operator ends the text: wrap what is there, so the 
parser says what is missing after it
-                at = text.length() - op.stripTrailing().length();
+            int at = indexOutsideQuotes(text, op);
+            if (at > 0 && (firstAt < 0 || at < firstAt)) {
+                firstAt = at;
+                first = op;
             }
-            if (at > 0) {
-                String left = text.substring(0, at).trim();
-                String right = at + op.length() <= text.length() ? 
text.substring(at + op.length()).trim() : "";
-                if (!left.startsWith("${") && !left.startsWith("'") && 
!left.startsWith("\"")
-                        && !isNumeric(left) && !"true".equalsIgnoreCase(left)
-                        && !"false".equalsIgnoreCase(left) && 
!"null".equalsIgnoreCase(left)) {
-                    left = "${" + left + "}";
+        }
+        if (first != null) {
+            return wrapComparison(text, first, firstAt);
+        }
+        for (String op : SPACED_OPERATORS) {
+            if (text.endsWith(op.stripTrailing())) {
+                // the operator ends the text: wrap what is there, so the 
parser says what is missing after it
+                int at = text.length() - op.stripTrailing().length();
+                if (at > 0) {
+                    return wrapComparison(text, op, at);
                 }
-                return left + op + right;
             }
         }
         return text;
     }
 
+    private static String wrapComparison(String text, String op, int at) {
+        String left = text.substring(0, at).trim();
+        String right = at + op.length() <= text.length() ? text.substring(at + 
op.length()).trim() : "";
+        if (!left.startsWith("${") && !left.startsWith("'") && 
!left.startsWith("\"")
+                && !isNumeric(left) && !"true".equalsIgnoreCase(left)
+                && !"false".equalsIgnoreCase(left) && 
!"null".equalsIgnoreCase(left)) {
+            left = "${" + left + "}";
+        }
+        return left + op + right;
+    }
+
+    /** The index of the text outside single and double quotes, or -1. */
+    private static int indexOutsideQuotes(String text, String find) {
+        boolean single = false;
+        boolean dubble = false;
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            if (c == '\'' && !dubble) {
+                single = !single;
+            } else if (c == '"' && !single) {
+                dubble = !dubble;
+            } else if (!single && !dubble && text.startsWith(find, i)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
     private static boolean isNumeric(String text) {
         if (text == null || text.isEmpty()) {
             return false;
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleTokenizer.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleTokenizer.java
index 4b4ae0956b67..65ece116c6eb 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleTokenizer.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleTokenizer.java
@@ -291,6 +291,10 @@ public class SimpleTokenizer {
         if (token.isTernary()) {
             return evalTernary(token, text, expression, index);
         }
+        if (token.isChain()) {
+            // like the other infix operators, so text such as A~>B is not a 
chain
+            return evalSurroundedBySpace(token, text, expression, index);
+        }
 
         return text.startsWith(token.getValue());
     }
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java
index 54a320073a09..809398525d62 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java
@@ -340,6 +340,11 @@ public class SimpleFunctionStart extends BaseSimpleNode 
implements BlockStart {
     /**
      * Find the index of the ternary operator character, skipping nested ${}, 
quotes, etc.
      */
+    private static boolean surroundedByWhitespace(String text, int index) {
+        return index > 0 && index < text.length() - 1
+                && Character.isWhitespace(text.charAt(index - 1)) && 
Character.isWhitespace(text.charAt(index + 1));
+    }
+
     private int findTernaryOperator(String text, char operator) {
         int depth = 0;
         boolean inSingleQuote = false;
@@ -366,7 +371,9 @@ public class SimpleFunctionStart extends BaseSimpleNode 
implements BlockStart {
                     inDoubleQuote = true;
                     continue;
                 }
-                if (c == operator && depth == 0) {
+                if (c == operator && depth == 0 && 
surroundedByWhitespace(text, i)) {
+                    // like the tokenizer, the operator must have whitespace 
around it,
+                    // so ${bean:svc?method=at(10:30)} is not a ternary
                     return i;
                 }
             } else if (inSingleQuote && c == '\'') {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleParserEdgeCasesTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleParserEdgeCasesTest.java
new file mode 100644
index 000000000000..9621bc9cdf3a
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleParserEdgeCasesTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.camel.language.simple;
+
+import org.apache.camel.LanguageTestSupport;
+import org.junit.jupiter.api.Test;
+
+/**
+ * CAMEL-24964: edge cases of the tokenizer and the predicate parser.
+ */
+public class SimpleParserEdgeCasesTest extends LanguageTestSupport {
+
+    @Override
+    protected String getLanguageName() {
+        return "simple";
+    }
+
+    @Test
+    public void testSingleQuoteInsideDoubleQuotesInFunction() {
+        exchange.getMessage().setBody("a'b'c");
+        assertPredicate("${body.replace(\"'\", \"\")} == 'abc'", true);
+        assertPredicate("${body.replace(\"'\", \"\")} == 'x'", false);
+    }
+
+    @Test
+    public void testBraceInsideDoubleQuotes() {
+        exchange.getMessage().setBody("a}b");
+        exchange.getMessage().setHeader("foo", "y");
+        assertPredicate("${body} contains \"}\" && ${header.foo} == 'y'", 
true);
+        assertPredicate("${body} contains \"}\" && ${header.foo} == 'z'", 
false);
+    }
+
+    @Test
+    public void testChainFollowedByNumberOrNull() {
+        exchange.getMessage().setBody("abcdef");
+        assertPredicate("${body} ~> ${length()} > 5", true);
+        assertPredicate("${body} ~> ${length()} > 10", false);
+        assertPredicate("${body} ~> ${length()} == null", false);
+        assertPredicate("${body} ~> ${length()} > -5", true);
+    }
+
+    @Test
+    public void testChainNeedsSpaces() {
+        assertExpression("Move A~>B", "Move A~>B");
+        exchange.getMessage().setBody("a~>b");
+        assertExpression("${body.replace('~>', '-')}", "a-b");
+    }
+
+    @Test
+    public void testOperatorInsideQuotesInBraces() {
+        exchange.getMessage().setBody("a > b");
+        assertPredicate("${body == 'a > b'}", true);
+        exchange.getMessage().setBody("x == y");
+        assertPredicate("${body startsWith 'x == y'}", true);
+    }
+
+    @Test
+    public void testUnquotedTextWithDigitIsLenient() {
+        // kept working as routes may compare with unquoted text such as v2
+        exchange.getMessage().setHeader("version", "v2");
+        assertPredicate("${header.version} == v2", true);
+    }
+
+    @Test
+    public void testColonWithoutSpacesIsNotATernary() {
+        context.getRegistry().bind("svc", new MyService());
+        assertExpression("${bean:svc?method=echo('10:30')}", "10:30");
+    }
+
+    public static class MyService {
+        public String echo(String s) {
+            return s;
+        }
+    }
+}

Reply via email to