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; + } + } +}
