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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 1c7bcc6c1ff6 CAMEL-26465: camel-core - Simple: an empty function 
argument is kept, and a comma in a nested function does not split an argument 
(#29126)
1c7bcc6c1ff6 is described below

commit 1c7bcc6c1ff6ab0dc7a6a1f454651c7b2f8b79b1
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Nov 4 15:04:52 2026 +0530

    CAMEL-26465: camel-core - Simple: an empty function argument is kept, and a 
comma in a nested function does not split an argument (#29126)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/catalog/docs/simple-functions.adoc       | 10 ++-
 .../modules/languages/pages/simple-functions.adoc  | 10 ++-
 .../language/simple/SimpleFunctionHelper.java      | 85 +++++++++++++++++++---
 .../functions/CollectionFunctionFactory.java       | 83 ++++++++++++++++-----
 .../simple/functions/JoinFunctionFactory.java      | 24 ++++--
 .../simple/functions/StringFunctionFactory.java    | 46 +++++++++---
 .../apache/camel/language/simple/SimpleTest.java   | 11 +++
 .../functions/CollectionFunctionFactoryTest.java   | 62 ++++++++++++++++
 .../simple/functions/JoinFunctionFactoryTest.java  | 46 ++++++++++++
 .../functions/StringFunctionFactoryTest.java       | 78 ++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    | 15 ++++
 11 files changed, 423 insertions(+), 47 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-functions.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-functions.adoc
index af676991a731..b19b30092c7f 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-functions.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-functions.adoc
@@ -8,6 +8,13 @@ xref:simple-language.adoc[Back to Simple Language]
 The Simple language has many built-in functions which allows access to various 
part of Camel and the current `Exchange`, the message payload such as body and 
headers, and much more.
 
 NOTE: Some functions take 1 or more arguments enclosed in parentheses, and 
arguments separated by comma (ie `${replace('custID','customerId')}`.
+In the arguments of `replace`, `substring`, `substringBefore`, 
`substringAfter`, `substringBetween`, `contains`,
+`join`, `split`, `sort`, `setHeader`, `setVariable`, `forEach`, `filter`, 
`listAdd`, `listRemove`, `mapAdd` and
+`mapRemove`, a comma inside quotes or inside a nested function such as 
`$\{header.name.substring(1,3)}` does not
+separate arguments. An argument left out, as in
+`${replace(a,,$\{header.name})}`, stays in its place: it means the default 
where the function has one (the message
+body for an expression, a comma for a separator, the empty text for the `to` 
of `replace`) and is refused otherwise;
+`''` is the empty text.
 
 === Inlined Result Type Function
 
@@ -565,7 +572,8 @@ For example if the message body contains a list of [A, B, 
C] then `${join()}` wo
 To use semicolon as separator you would use `${join(;)}`. The `prefix` 
argument can be used to prefix each value, so for example
 `${join(&,id=)}` would return `id=A&id=B&id=C`. The white space around the 
prefix and the expression is not part of
 them, so `${join(&, id=)}` returns the same. An unquoted separator is kept as 
written, so `${join(; )}` returns
-`A; B; C`; a quoted one is the text inside its quotes.
+`A; B; C`; a quoted one is the text inside its quotes, so `${join('')}` 
returns `ABC`. An empty separator is the
+default, so `${join(,id=)}` returns `id=A,id=B,id=C`.
 
 The `escape` function escapes special characters so a value can be safely 
embedded in another format,
 instead of chaining multiple `replace` calls or using a custom bean. The first 
argument is the kind of escaping:
diff --git 
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-functions.adoc
 
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-functions.adoc
index af676991a731..b19b30092c7f 100644
--- 
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-functions.adoc
+++ 
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-functions.adoc
@@ -8,6 +8,13 @@ xref:simple-language.adoc[Back to Simple Language]
 The Simple language has many built-in functions which allows access to various 
part of Camel and the current `Exchange`, the message payload such as body and 
headers, and much more.
 
 NOTE: Some functions take 1 or more arguments enclosed in parentheses, and 
arguments separated by comma (ie `${replace('custID','customerId')}`.
+In the arguments of `replace`, `substring`, `substringBefore`, 
`substringAfter`, `substringBetween`, `contains`,
+`join`, `split`, `sort`, `setHeader`, `setVariable`, `forEach`, `filter`, 
`listAdd`, `listRemove`, `mapAdd` and
+`mapRemove`, a comma inside quotes or inside a nested function such as 
`$\{header.name.substring(1,3)}` does not
+separate arguments. An argument left out, as in
+`${replace(a,,$\{header.name})}`, stays in its place: it means the default 
where the function has one (the message
+body for an expression, a comma for a separator, the empty text for the `to` 
of `replace`) and is refused otherwise;
+`''` is the empty text.
 
 === Inlined Result Type Function
 
@@ -565,7 +572,8 @@ For example if the message body contains a list of [A, B, 
C] then `${join()}` wo
 To use semicolon as separator you would use `${join(;)}`. The `prefix` 
argument can be used to prefix each value, so for example
 `${join(&,id=)}` would return `id=A&id=B&id=C`. The white space around the 
prefix and the expression is not part of
 them, so `${join(&, id=)}` returns the same. An unquoted separator is kept as 
written, so `${join(; )}` returns
-`A; B; C`; a quoted one is the text inside its quotes.
+`A; B; C`; a quoted one is the text inside its quotes, so `${join('')}` 
returns `ABC`. An empty separator is the
+default, so `${join(,id=)}` returns `id=A,id=B,id=C`.
 
 The `escape` function escapes special characters so a value can be safely 
embedded in another format,
 instead of chaining multiple `replace` calls or using a custom bean. The first 
argument is the kind of escaping:
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleFunctionHelper.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleFunctionHelper.java
index a93e0835beb3..8689f1696911 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleFunctionHelper.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleFunctionHelper.java
@@ -16,8 +16,11 @@
  */
 package org.apache.camel.language.simple;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.StringJoiner;
+
 import org.apache.camel.util.StringHelper;
-import org.apache.camel.util.StringQuoteHelper;
 
 public final class SimpleFunctionHelper {
 
@@ -101,23 +104,87 @@ public final class SimpleFunctionHelper {
     }
 
     /**
-     * Splits the arguments of a function at the commas outside quotes. The 
white space around an unquoted argument is
-     * not part of it, so {@code ${sort(${body}, true)}} and {@code 
${replace('o', '0')}} work, while a quoted argument
-     * such as {@code ' '} is kept as written (without its quotes). An 
argument of only white space is kept as it is, so
-     * {@code ${replace( ,-)}} replaces a space.
+     * Splits the arguments of a function at the commas outside quotes and 
outside nested functions, so
+     * {@code ${replace(b,${header.s.substring(1,3)})}} has two arguments. The 
white space around an unquoted argument
+     * is not part of it, so {@code ${sort(${body}, true)}} and {@code 
${replace('o', '0')}} work, while a quoted
+     * argument such as {@code ' '} is kept as written (without its quotes). 
An argument of only white space is kept as
+     * it is, so {@code ${replace( ,-)}} replaces a space. An argument left 
out (nothing between the commas) is kept in
+     * its place as {@code null}, so {@code ${replace(b,,${header.s})}} has 
three arguments, the second {@code null}; a
+     * quoted empty argument ({@code ''}) is the empty text.
      */
     public static String[] splitArguments(String values) {
-        String[] tokens = StringQuoteHelper.splitSafeQuote(values, ',', false, 
true);
+        String[] tokens = splitArgumentsAsWritten(values);
         for (int i = 0; i < tokens.length; i++) {
-            tokens[i] = trimArgument(tokens[i]);
+            tokens[i] = tokens[i].isEmpty() ? null : trimArgument(tokens[i]);
         }
         return tokens;
     }
 
+    /**
+     * Splits the arguments of a function as written (with their quotes and 
the white space around them) at the commas
+     * outside quotes and outside nested functions: a comma inside a nested 
{@code ${...}}, also inside its parentheses,
+     * such as in {@code ${header.s.substring(1,3)}}, does not split. A 
parenthesis or brace outside a nested function
+     * is text, as before, so {@code ${substringBetween(${body},(,))}} has the 
arguments {@code ${body}}, {@code (} and
+     * {@code )}. Every argument is kept, also an empty one, so {@code a,,b} 
gives three arguments and {@code a,} two.
+     */
+    public static String[] splitArgumentsAsWritten(String values) {
+        List<String> answer = new ArrayList<>();
+        boolean single = false;
+        boolean dubble = false;
+        int depth = 0;
+        int start = 0;
+        for (int i = 0; i < values.length(); i++) {
+            char c = values.charAt(i);
+            if (c == '\'' && !dubble) {
+                single = !single;
+            } else if (c == '"' && !single) {
+                dubble = !dubble;
+            } else if (!single && !dubble) {
+                if (c == '$' && i + 1 < values.length() && values.charAt(i + 
1) == '{') {
+                    // a nested function: its commas, parentheses and braces 
belong to it
+                    depth++;
+                    i++;
+                } else if (depth > 0 && (c == '(' || c == '{')) {
+                    depth++;
+                } else if (depth > 0 && (c == ')' || c == '}')) {
+                    depth--;
+                } else if (c == ',' && depth == 0) {
+                    answer.add(values.substring(start, i));
+                    start = i + 1;
+                }
+            }
+        }
+        answer.add(values.substring(start));
+        return answer.toArray(new String[0]);
+    }
+
+    /**
+     * The expression argument of a function as {@link 
#splitArguments(String)} gives it, or {@code ${body}} when it is
+     * left out, such as in {@code ${replace(a,b,)}}.
+     */
+    public static String expressionOrBody(String token) {
+        return token == null ? "${body}" : token;
+    }
+
+    /**
+     * The arguments from the given index on, joined again with a comma (for a 
function whose last argument is an
+     * expression that may contain commas), or {@code null} when one of them 
is left out, such as with a trailing comma.
+     */
+    public static String joinArguments(String[] tokens, int from) {
+        StringJoiner joiner = new StringJoiner(",");
+        for (int i = from; i < tokens.length; i++) {
+            if (tokens[i] == null) {
+                return null;
+            }
+            joiner.add(tokens[i]);
+        }
+        return joiner.toString();
+    }
+
     /**
      * An argument as {@link #splitArguments(String)} gives it, from the 
argument as written (split with
-     * {@code StringQuoteHelper.splitSafeQuote(values, ',', false, true)}): 
without the white space and the quotes
-     * around it, unless it is only white space.
+     * {@link #splitArgumentsAsWritten(String)}): without the white space and 
the quotes around it, unless it is only
+     * white space.
      */
     public static String trimArgument(String token) {
         String trimmed = token.trim();
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/CollectionFunctionFactory.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/CollectionFunctionFactory.java
index ec5508ef64e5..3860b2af96ec 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/CollectionFunctionFactory.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/CollectionFunctionFactory.java
@@ -16,9 +16,6 @@
  */
 package org.apache.camel.language.simple.functions;
 
-import java.util.Arrays;
-import java.util.stream.Collectors;
-
 import org.apache.camel.CamelContext;
 import org.apache.camel.Expression;
 import org.apache.camel.language.simple.CollectionExpressionBuilder;
@@ -28,7 +25,9 @@ import org.apache.camel.util.ObjectHelper;
 import org.apache.camel.util.StringHelper;
 import org.apache.camel.util.StringQuoteHelper;
 
+import static 
org.apache.camel.language.simple.SimpleFunctionHelper.expressionOrBody;
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.ifStartsWithReturnRemainder;
+import static 
org.apache.camel.language.simple.SimpleFunctionHelper.joinArguments;
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.splitArguments;
 
 /**
@@ -64,7 +63,16 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             } else {
                 exp = tokens[1];
             }
+            if (name == null || exp == null) {
+                // an argument left out is refused where it has no default
+                throw new SimpleParserException(
+                        "Valid syntax: ${setHeader(name,exp)} or 
${setHeader(name,type,exp)} was: " + function, index);
+            }
             type = StringHelper.removeQuotes(type);
+            if (type != null && type.isEmpty()) {
+                // an empty type is no type, as when it is left out
+                type = null;
+            }
             return CollectionExpressionBuilder.setHeaderExpression(name, type, 
exp);
         }
 
@@ -89,7 +97,16 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             } else {
                 exp = tokens[1];
             }
+            if (name == null || exp == null) {
+                // an argument left out is refused where it has no default
+                throw new SimpleParserException(
+                        "Valid syntax: ${setVariable(name,exp)} or 
${setVariable(name,type,exp)} was: " + function, index);
+            }
             type = StringHelper.removeQuotes(type);
+            if (type != null && type.isEmpty()) {
+                // an empty type is no type, as when it is left out
+                type = null;
+            }
             return CollectionExpressionBuilder.setVariableExpression(name, 
type, exp);
         }
 
@@ -117,7 +134,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String values = StringHelper.beforeLast(remainder, ")");
             String[] tokens;
             if (ObjectHelper.isNotEmpty(values)) {
-                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false);
+                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false, true);
             } else {
                 tokens = new String[] { "${body}" };
             }
@@ -129,7 +146,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String values = StringHelper.beforeLast(remainder, ")");
             String[] tokens;
             if (ObjectHelper.isNotEmpty(values)) {
-                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false);
+                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false, true);
             } else {
                 tokens = new String[] { "${body}" };
             }
@@ -141,7 +158,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String values = StringHelper.beforeLast(remainder, ")");
             String[] tokens;
             if (ObjectHelper.isNotEmpty(values)) {
-                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false);
+                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false, true);
             } else {
                 tokens = new String[] { "${body}" };
             }
@@ -160,11 +177,15 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
                             "Valid syntax: ${split(separator)} or 
${split(exp,separator)} was: " + function, index);
                 }
                 if (tokens.length == 2) {
-                    exp = tokens[0];
+                    exp = expressionOrBody(tokens[0]);
                     separator = tokens[1];
                 } else {
                     separator = tokens[0];
                 }
+                if (separator == null) {
+                    // a separator left out is the default
+                    separator = ",";
+                }
             } else if ("\n".equals(values)) {
                 separator = values;
             }
@@ -183,7 +204,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
                             "Valid syntax: ${sort(reverse)} or 
${sort(exp,reverse)} was: " + function, index);
                 }
                 if (tokens.length == 2) {
-                    exp = tokens[0];
+                    exp = expressionOrBody(tokens[0]);
                     reverse = Boolean.parseBoolean(tokens[1]);
                 } else if ("true".equalsIgnoreCase(tokens[0]) || 
"false".equalsIgnoreCase(tokens[0])) {
                     reverse = Boolean.parseBoolean(tokens[0]);
@@ -208,7 +229,11 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
                         "Valid syntax: ${forEach(exp,exp)} was: " + function, 
index);
             }
             String exp1 = tokens[0];
-            String exp2 = 
Arrays.stream(tokens).skip(1).collect(Collectors.joining(","));
+            String exp2 = joinArguments(tokens, 1);
+            if (exp1 == null || exp2 == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${forEach(exp,exp)} was: " + function, 
index);
+            }
             return CollectionExpressionBuilder.forEachExpression(exp1, exp2);
         }
 
@@ -225,7 +250,11 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
                         "Valid syntax: ${filter(exp,exp)} was: " + function, 
index);
             }
             String exp1 = tokens[0];
-            String exp2 = 
Arrays.stream(tokens).skip(1).collect(Collectors.joining(","));
+            String exp2 = joinArguments(tokens, 1);
+            if (exp1 == null || exp2 == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${filter(exp,exp)} was: " + function, 
index);
+            }
             return CollectionExpressionBuilder.filterExpression(exp1, exp2);
         }
 
@@ -241,9 +270,13 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String exp1 = "${body}";
             if (tokens.length > 1) {
                 skip = 1;
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
+            }
+            String exp2 = joinArguments(tokens, skip);
+            if (exp2 == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${listAdd(exp)} or ${listAdd(exp,exp)} 
was: " + function, index);
             }
-            String exp2 = 
Arrays.stream(tokens).skip(skip).collect(Collectors.joining(","));
             return CollectionExpressionBuilder.listAddExpression(exp1, exp2);
         }
 
@@ -259,9 +292,13 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String exp1 = "${body}";
             if (tokens.length > 1) {
                 skip = 1;
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
+            }
+            String exp2 = joinArguments(tokens, skip);
+            if (exp2 == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${listRemove(exp)} or 
${listRemove(exp,exp)} was: " + function, index);
             }
-            String exp2 = 
Arrays.stream(tokens).skip(skip).collect(Collectors.joining(","));
             return CollectionExpressionBuilder.listRemoveExpression(exp1, 
exp2);
         }
 
@@ -277,7 +314,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String exp1 = "${body}";
             String key;
             if (tokens.length > 2) {
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
                 key = tokens[1];
                 skip = 2;
             } else if (tokens.length == 2) {
@@ -287,7 +324,11 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
                 throw new SimpleParserException(
                         "Valid syntax: ${mapAdd(key,exp)} or 
${mapAdd(exp,key,exp)} was: " + function, index);
             }
-            String exp2 = 
Arrays.stream(tokens).skip(skip).collect(Collectors.joining(","));
+            String exp2 = joinArguments(tokens, skip);
+            if (key == null || exp2 == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${mapAdd(key,exp)} or 
${mapAdd(exp,key,exp)} was: " + function, index);
+            }
             return CollectionExpressionBuilder.mapAddExpression(exp1, key, 
exp2);
         }
 
@@ -306,11 +347,15 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String key;
             String exp = "${body}";
             if (tokens.length == 2) {
-                exp = tokens[0];
+                exp = expressionOrBody(tokens[0]);
                 key = tokens[1];
             } else {
                 key = tokens[0];
             }
+            if (key == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${mapRemove(key)} or 
${mapRemove(exp,key)} was: " + function, index);
+            }
             return CollectionExpressionBuilder.mapRemoveExpression(exp, key);
         }
 
@@ -319,7 +364,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String values = StringHelper.beforeLast(remainder, ")");
             String[] tokens = null;
             if (ObjectHelper.isNotEmpty(values)) {
-                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false);
+                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false, true);
             }
             return CollectionExpressionBuilder.listExpression(tokens);
         }
@@ -329,7 +374,7 @@ public final class CollectionFunctionFactory implements 
SimpleLanguageFunctionFa
             String values = StringHelper.beforeLast(remainder, ")");
             String[] tokens = null;
             if (ObjectHelper.isNotEmpty(values)) {
-                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false);
+                tokens = StringQuoteHelper.splitSafeQuote(values, ',', true, 
false, true);
             }
             if (tokens != null && tokens.length % 2 == 1) {
                 throw new SimpleParserException(
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/JoinFunctionFactory.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/JoinFunctionFactory.java
index 7a9d34337d8d..54ef45de8e6f 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/JoinFunctionFactory.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/JoinFunctionFactory.java
@@ -21,11 +21,10 @@ import org.apache.camel.Expression;
 import org.apache.camel.language.simple.CollectionExpressionBuilder;
 import org.apache.camel.language.simple.types.SimpleParserException;
 import org.apache.camel.spi.SimpleLanguageFunctionFactory;
-import org.apache.camel.util.ObjectHelper;
 import org.apache.camel.util.StringHelper;
-import org.apache.camel.util.StringQuoteHelper;
 
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.ifStartsWithReturnRemainder;
+import static 
org.apache.camel.language.simple.SimpleFunctionHelper.splitArgumentsAsWritten;
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.trimArgument;
 
 /**
@@ -48,20 +47,29 @@ public final class JoinFunctionFactory implements 
SimpleLanguageFunctionFactory
         String separator = ",";
         String prefix = null;
         String exp = "${body}";
-        if (ObjectHelper.isNotEmpty(values)) {
-            // the arguments as written (with their quotes), split at the 
commas outside quotes as splitArguments does,
-            // so the separator can be kept as written
-            String[] tokens = StringQuoteHelper.splitSafeQuote(values, ',', 
false, true);
+        // not ObjectHelper.isNotEmpty, which trims: a white space separator, 
as in ${join( )}, is kept as written
+        if (values != null && !values.isEmpty()) {
+            // the arguments as written (with their quotes), split as 
splitArguments does (an empty argument is kept,
+            // and a comma inside a nested function does not split), so the 
separator can be kept as written
+            String[] tokens = splitArgumentsAsWritten(values);
             if (tokens.length > 3) {
                 throw new SimpleParserException(
                         "Valid syntax: ${join(separator,prefix,expression)} 
was: " + function, index);
             }
-            separator = StringHelper.removeLeadingAndEndingQuotes(tokens[0]);
+            if (!tokens[0].isEmpty()) {
+                // a separator left out is the default; '' is no separator
+                separator = 
StringHelper.removeLeadingAndEndingQuotes(tokens[0]);
+            }
             if (tokens.length > 1) {
                 // the prefix and the expression without the white space and 
the quotes around them
                 prefix = trimArgument(tokens[1]);
+                if (prefix.isEmpty()) {
+                    // an empty prefix, as in ${join(&,,${header.ids})}, is no 
prefix
+                    prefix = null;
+                }
                 if (tokens.length == 3) {
-                    exp = trimArgument(tokens[2]);
+                    // an expression left out is the body
+                    exp = tokens[2].isEmpty() ? "${body}" : 
trimArgument(tokens[2]);
                 }
             }
         }
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/StringFunctionFactory.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/StringFunctionFactory.java
index ef44f6227e0f..6d1daa522907 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/StringFunctionFactory.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/functions/StringFunctionFactory.java
@@ -27,6 +27,7 @@ import org.apache.camel.util.ObjectHelper;
 import org.apache.camel.util.StringHelper;
 import org.apache.camel.util.StringQuoteHelper;
 
+import static 
org.apache.camel.language.simple.SimpleFunctionHelper.expressionOrBody;
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.ifStartsWithReturnRemainder;
 import static 
org.apache.camel.language.simple.SimpleFunctionHelper.splitArguments;
 
@@ -51,18 +52,19 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
                         "Valid syntax: ${replace(from,to)} or 
${replace(from,to,expression)} was: " + function, index);
             }
             String[] tokens = splitArguments(values);
-            if (tokens.length < 2 || tokens.length > 3) {
+            // the from cannot be left out; a to left out is the empty text
+            if (tokens.length < 2 || tokens.length > 3 || tokens[0] == null) {
                 throw new SimpleParserException(
                         "Valid syntax: ${replace(from,to)} or 
${replace(from,to,expression)} was: " + function, index);
             }
             String from = StringHelper.xmlDecode(tokens[0]);
-            String to = StringHelper.xmlDecode(tokens[1]);
+            String to = tokens[1] == null ? "" : 
StringHelper.xmlDecode(tokens[1]);
             if ("&empty;".equals(to)) {
                 to = "";
             }
             String exp = "${body}";
             if (tokens.length == 3) {
-                exp = tokens[2];
+                exp = expressionOrBody(tokens[2]);
             }
             return StringExpressionBuilder.replaceExpression(exp, from, to);
         }
@@ -81,14 +83,20 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
                 throw new SimpleParserException(
                         "Valid syntax: ${substring(num,num,expression)} was: " 
+ function, index);
             }
+            if (tokens[0] == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${substring(num)}, 
${substring(num,num)}, or ${substring(num,num,expression)} was: "
+                                                + function,
+                        index);
+            }
             String num1 = tokens[0];
             String num2 = "0";
-            if (tokens.length > 1) {
+            if (tokens.length > 1 && tokens[1] != null) {
                 num2 = tokens[1];
             }
             String exp = "${body}";
             if (tokens.length == 3) {
-                exp = tokens[2];
+                exp = expressionOrBody(tokens[2]);
             }
             return StringExpressionBuilder.substringExpression(exp, num1, 
num2);
         }
@@ -108,11 +116,17 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
             String exp1 = "${body}";
             String before;
             if (tokens.length == 2) {
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
                 before = tokens[1];
             } else {
                 before = tokens[0];
             }
+            if (before == null) {
+                // an argument left out is refused where it has no default, so 
${substringBefore(x,)} is not read as
+                // x before nothing
+                throw new SimpleParserException(
+                        "Valid syntax: ${substringBefore(exp)} or 
${substringBefore(exp,exp)} was: " + function, index);
+            }
             return StringExpressionBuilder.substringBeforeExpression(exp1, 
before);
         }
 
@@ -131,11 +145,15 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
             String exp1 = "${body}";
             String after;
             if (tokens.length == 2) {
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
                 after = tokens[1];
             } else {
                 after = tokens[0];
             }
+            if (after == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${substringAfter(exp)} or 
${substringAfter(exp,exp)} was: " + function, index);
+            }
             return StringExpressionBuilder.substringAfterExpression(exp1, 
after);
         }
 
@@ -159,13 +177,19 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
             String after;
             String before;
             if (tokens.length == 3) {
-                exp1 = tokens[0];
+                exp1 = expressionOrBody(tokens[0]);
                 after = tokens[1];
                 before = tokens[2];
             } else {
                 after = tokens[0];
                 before = tokens[1];
             }
+            if (after == null || before == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${substringBetween(after,before)} or 
${substringBetween(exp,after,before)} was: "
+                                                + function,
+                        index);
+            }
             return StringExpressionBuilder.substringBetweenExpression(exp1, 
after, before);
         }
 
@@ -186,9 +210,13 @@ public final class StringFunctionFactory implements 
SimpleLanguageFunctionFactor
             if (tokens.length == 1) {
                 pattern = tokens[0];
             } else {
-                exp = tokens[0];
+                exp = expressionOrBody(tokens[0]);
                 pattern = tokens[1];
             }
+            if (pattern == null) {
+                throw new SimpleParserException(
+                        "Valid syntax: ${contains(text)} or 
${contains(exp,text)} was: " + function, index);
+            }
             return StringExpressionBuilder.containsExpression(exp, pattern);
         }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
index c598f0158865..509183607543 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleTest.java
@@ -547,6 +547,17 @@ public class SimpleTest extends LanguageTestSupport {
         assertTrue(cause.getMessage().contains("the value is JSON text, not a 
Map: unmarshal it first"), cause.getMessage());
     }
 
+    @Test
+    public void testFunctionArgumentLeftOutAndCommaInANestedFunction() {
+        exchange.getIn().setBody("abc");
+        exchange.getIn().setHeader("s", "abcdef");
+        // an argument left out stays in its place, and a comma inside a 
nested function does not split an argument
+        assertExpression("${replace(b,,${header.s})}", "acdef");
+        assertExpression("${substring(1,2,${header.s.substring(0,4)})}", "b");
+        // a quoted empty argument is the empty text
+        assertExpression("${replace(b,'')}", "ac");
+    }
+
     @Test
     public void testOGNLKeyOnAListSaysToPickAnElement() {
         exchange.getIn().setBody(List.of(Map.of("sku", "CAMEL-MUG", "qty", 
42)));
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/CollectionFunctionFactoryTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/CollectionFunctionFactoryTest.java
index 7cd75dc80ee7..a230d3f88ae6 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/CollectionFunctionFactoryTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/CollectionFunctionFactoryTest.java
@@ -21,11 +21,14 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import org.apache.camel.language.simple.types.SimpleParserException;
 import org.apache.camel.spi.SimpleLanguageFunctionFactory;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class CollectionFunctionFactoryTest extends 
AbstractSimpleFunctionFactoryTestSupport {
@@ -263,4 +266,63 @@ public class CollectionFunctionFactoryTest extends 
AbstractSimpleFunctionFactory
         assertEquals("v", map.get("k"));
     }
 
+    @Test
+    public void testCommaInsideANestedFunction() {
+        exchange.getIn().setBody("abc");
+        exchange.getIn().setHeader("list", List.of("c", "a", "b"));
+        exchange.getIn().setHeader("s", "abcdef");
+
+        // a comma inside a nested function does not split the argument
+        assertEquals(List.of("c", "a"), 
evaluate("sort(${header.list.subList(0,2)},true)", List.class));
+        assertEquals(List.of("c", "a", "x"), 
evaluate("distinct(${header.list.subList(0,2)},x,c)", List.class));
+        assertEquals(List.of("ab", "z"), 
evaluate("list(${header.s.substring(0,2)},z)", List.class));
+        evaluate("setHeader(foo,${header.s.substring(0,2)})");
+        assertEquals("ab", exchange.getIn().getHeader("foo"));
+    }
+
+    @Test
+    public void testEmptyExpressionArgumentIsTheBody() {
+        exchange.getIn().setBody(new ArrayList<>(List.of("b", "a")));
+
+        // an empty expression is the same as leaving it out: the body
+        assertEquals(List.of("b", "a"), evaluate("sort(,true)", List.class));
+        // with a trailing comma the first argument is the expression: the 
text true
+        assertEquals("[true]", String.valueOf(evaluate("sort(true,)")));
+        assertEquals(List.of("b", "a", "x"), evaluate("listAdd(,x)", 
List.class));
+    }
+
+    @Test
+    public void testEmptyArguments() {
+        exchange.getIn().setBody(new ArrayList<>(List.of("a", "b")));
+        exchange.getIn().setHeader("s", "abc");
+        exchange.getIn().setHeader("csv", "x,y");
+
+        // an empty type is no type, as when it is left out
+        evaluate("setHeader(foo,,${header.s})");
+        assertEquals("abc", exchange.getIn().getHeader("foo"));
+        // a separator of split left out is the default, a comma; a quoted 
empty one splits into characters
+        assertArrayEquals(new String[] { "x", "y" }, 
evaluate("split(${header.csv},)", String[].class));
+        assertArrayEquals(new String[] { "x", ",", "y" }, 
evaluate("split(${header.csv},'')", String[].class));
+        // a quoted empty value is the empty text
+        evaluate("setHeader(bar,'')");
+        assertEquals("", exchange.getIn().getHeader("bar"));
+        // an empty argument that has no default is refused
+        for (String function : new String[] {
+                "setHeader(,x)", "setVariable(,x)", "setHeader(foo,)", 
"listAdd(x,)", "listRemove(x,)",
+                "mapRemove(${body},)", "mapAdd(${body},k,)", "mapAdd(,v)", 
"forEach(,x)", "filter(${body},)",
+                "listAdd(${body},x,)", "mapAdd(${body},k,v,)", 
"filter(${body},x,)" }) {
+            assertThrows(SimpleParserException.class, () -> 
createFactory().createFunction(context, function, 0),
+                    function);
+        }
+    }
+
+    @Test
+    public void testCommaInsideANestedFunctionOfAValue() {
+        exchange.getIn().setHeader("s", "abcdef");
+
+        assertEquals(List.of("z", "ab"), 
evaluate("reverse(${header.s.substring(0,2)},z)", List.class));
+        assertEquals(2, evaluate("shuffle(${header.s.substring(0,2)},z)", 
List.class).size());
+        Map<String, Object> map = 
evaluate("map(k,${header.s.substring(0,2)})", Map.class);
+        assertEquals("ab", map.get("k"));
+    }
 }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/JoinFunctionFactoryTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/JoinFunctionFactoryTest.java
index 71a46a75e97c..1c5b2440561f 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/JoinFunctionFactoryTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/JoinFunctionFactoryTest.java
@@ -19,10 +19,12 @@ package org.apache.camel.language.simple.functions;
 import java.util.ArrayList;
 import java.util.List;
 
+import org.apache.camel.language.simple.types.SimpleParserException;
 import org.apache.camel.spi.SimpleLanguageFunctionFactory;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class JoinFunctionFactoryTest extends 
AbstractSimpleFunctionFactoryTestSupport {
 
@@ -103,4 +105,48 @@ public class JoinFunctionFactoryTest extends 
AbstractSimpleFunctionFactoryTestSu
         // a quoted separator is the text inside its quotes
         assertEquals("id=A&id=B", evaluate("join('&' , id=)", String.class));
     }
+
+    @Test
+    public void testJoinEmptyArgument() {
+        List<Object> data = new ArrayList<>();
+        data.add("A");
+        data.add("B");
+        data.add("C");
+        exchange.getIn().setBody(data);
+        exchange.getIn().setHeader("id", List.of("X", "Y"));
+
+        // an empty argument is the same as leaving it out, so the next 
argument does not take its place
+        assertEquals("id=A,id=B,id=C", evaluate("join(,id=)", String.class));
+        assertEquals("X&Y", evaluate("join(&,,${header.id})", String.class));
+        assertEquals("id=A&id=B&id=C", evaluate("join(&,id=,)", String.class));
+        assertEquals("A,B,C", evaluate("join(,,)", String.class));
+        // a quoted empty separator is no separator
+        assertEquals("ABC", evaluate("join('')", String.class));
+        // a space is a separator, as written
+        assertEquals("A B C", evaluate("join( )", String.class));
+    }
+
+    @Test
+    public void testJoinCommaInsideANestedFunction() {
+        exchange.getIn().setHeader("id", List.of("X", "Y", "Z"));
+
+        // a comma inside a nested function does not split the expression
+        assertEquals("id=X&id=Y", 
evaluate("join(&,id=,${header.id.subList(0,2)})", String.class));
+    }
+
+    @Test
+    public void testJoinTooManyArguments() {
+        exchange.getIn().setBody(List.of("A"));
+
+        assertThrows(SimpleParserException.class,
+                () -> createFactory().createFunction(context, 
"join(&,id=,${body},x)", 0));
+    }
+
+    @Test
+    public void testJoinQuotedParenthesisInThePrefix() {
+        exchange.getIn().setHeader("id", List.of("X", "Y"));
+
+        // a parenthesis in an argument is quoted, so it does not nest
+        assertEquals("(X&(Y", evaluate("join(&,'(',${header.id})", 
String.class));
+    }
 }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/StringFunctionFactoryTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/StringFunctionFactoryTest.java
index fc0ea7ceba5d..c79759f19f21 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/StringFunctionFactoryTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/functions/StringFunctionFactoryTest.java
@@ -34,6 +34,17 @@ public class StringFunctionFactoryTest extends 
AbstractSimpleFunctionFactoryTest
         return new StringFunctionFactory();
     }
 
+    @Test
+    public void testParenthesesOutsideANestedFunctionAreText() {
+        // a parenthesis outside a nested ${...} is text, as before: only the 
commas of a nested function do not split
+        exchange.getIn().setBody("order (42) done");
+        assertEquals("42", evaluate("substringBetween(${body},(,))", 
String.class));
+        exchange.getIn().setHeader("s", "abcdef");
+        assertEquals("bc", 
evaluate("substringBetween(${header.s.substring(0,4)},a,d)", String.class));
+        exchange.getIn().setBody("a(b");
+        assertEquals("axb", evaluate("replace((,x)", String.class));
+    }
+
     // --- replace ---
 
     @Test
@@ -308,4 +319,71 @@ public class StringFunctionFactoryTest extends 
AbstractSimpleFunctionFactoryTest
         assertThrows(SimpleParserException.class,
                 () -> createFactory().createFunction(context, "escape()", 0));
     }
+
+    @Test
+    void testEmptyArgumentIsKept() {
+        // an empty argument is kept, so the next argument does not take its 
place
+        exchange.getIn().setBody("abc");
+        exchange.getIn().setHeader("s", "abcdef");
+        assertEquals("acdef", evaluate("replace(b,,${header.s})", 
String.class));
+        assertEquals("ac", evaluate("replace(b,)", String.class));
+        // an empty expression is the same as leaving it out: the body
+        assertEquals("aXc", evaluate("replace(b,X,)", String.class));
+        assertEquals("bc", evaluate("substring(1,)", String.class));
+        assertEquals("a", evaluate("substringBefore(,b)", String.class));
+        assertEquals("c", evaluate("substringAfter(,b)", String.class));
+        assertEquals("b", evaluate("substringBetween(,a,c)", String.class));
+        assertEquals(true, evaluate("contains(,bc)", Boolean.class));
+    }
+
+    @Test
+    void testEmptyArgumentWithoutDefaultIsRefused() {
+        exchange.getIn().setBody("abc");
+        assertThrows(SimpleParserException.class, () -> 
createFactory().createFunction(context, "replace(,X)", 0));
+        assertThrows(SimpleParserException.class, () -> 
createFactory().createFunction(context, "substring(,2)", 0));
+    }
+
+    @Test
+    void testCommaInsideANestedFunction() {
+        // a comma inside a nested function does not split the argument
+        exchange.getIn().setBody("abc");
+        exchange.getIn().setHeader("s", "abcdef");
+        assertEquals("b", 
evaluate("substring(1,2,${header.s.substring(0,4)})", String.class));
+        assertEquals("bc", 
evaluate("substringBefore(${header.s.substring(1,4)},d)", String.class));
+        assertEquals("c", 
evaluate("substringAfter(${header.s.substring(1,3)},b)", String.class));
+        assertEquals("cd", 
evaluate("substringBetween(${header.s.substring(1,5)},b,e)", String.class));
+        assertEquals(true, evaluate("contains(${header.s.substring(0,2)},ab)", 
Boolean.class));
+        // the from and to of replace are text, not expressions
+        assertEquals("a${header.s.substring(1,3)}c", 
evaluate("replace(b,${header.s.substring(1,3)})", String.class));
+        assertEquals("aXcdef", 
evaluate("replace(b,X,${header.s.substring(0,6)})", String.class));
+    }
+
+    @Test
+    void testEmptyTextArgumentIsRefused() {
+        // an empty argument that has no default is refused, so a trailing 
comma is not read as an empty text
+        for (String function : new String[] {
+                "contains(xyz,)", "substringBefore(cd,)", 
"substringAfter(cd,)", "substringBetween(x,,c)",
+                "substringBetween(x,a,)", "replace(a,b,${body},)" }) {
+            assertThrows(SimpleParserException.class, () -> 
createFactory().createFunction(context, function, 0),
+                    function);
+        }
+    }
+
+    @Test
+    void testQuotedEmptyArgumentIsTheEmptyText() {
+        // '' is the empty text, not an argument left out
+        exchange.getIn().setBody("abc");
+        assertEquals(true, evaluate("contains(${body},'')", Boolean.class));
+        assertEquals("abc", evaluate("substringAfter(${body},'')", 
String.class));
+        assertEquals("ac", evaluate("replace(b,'')", String.class));
+        // only the quotes around a whole argument are removed, so a lone 
quote at the end is kept
+        assertEquals("ax'c", evaluate("replace(b,x')", String.class));
+    }
+
+    @Test
+    void testQuotedParenthesisIsText() {
+        // a parenthesis in an argument is quoted, so it does not nest
+        exchange.getIn().setBody("a(b");
+        assertEquals("axb", evaluate("replace('(',x)", String.class));
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 435bf93c39fe..3e329077add8 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -415,6 +415,21 @@ inside the quotes to keep it: `+${join('& ',id=)}+`). A 
prefix with a space at i
 `join` are removed: a quote inside an argument, as in `+${join(&,a'b'c)}+`, is 
now kept (it was removed), so an
 expression such as `+${join(&,id=,${header.x.split(',')})}+` now works.
 
+=== camel-core - Simple: an empty function argument is kept, and a comma in a 
nested function does not split
+
+In the arguments of `replace`, `substring`, `substringBefore`, 
`substringAfter`, `substringBetween`, `contains`, `join`,
+`split`, `sort`, `setHeader`, `setVariable`, `forEach`, `filter`, `listAdd`, 
`listRemove`, `mapAdd` and `mapRemove`, an
+argument left out now stays in its place instead of letting the next argument 
take it: `+${replace(b,,${header.s})}+`
+replaces `b` with nothing in the header (it replaced `b` with the text 
`+${header.s}+`), and `+${join(,id=)}+` joins
+with a comma and the prefix `id=` (it joined with `id=`). Where the function 
has no default for it, an argument left out
+is refused, so a stray comma that was ignored is now an error, such as 
`+${contains(xyz,)}+`, `+${substring(,2)}+` or
+`+${replace(a,b,${body},)}+`: remove the comma. `+${sort(true,)}+` now sorts 
the text `true`, and `+${join( )}+` joins
+with a space (it joined with a comma). A comma inside a nested function, such 
as `+${header.s.substring(1,3)}+`, no
+longer separates two arguments; a parenthesis outside a nested function is 
text, as before
+(`+${substringBetween(${body},(,))}+`). In the values of `distinct`, 
`reverse`, `shuffle`, `list` and `map`, a comma
+after an unquoted `(` or `{` no longer separates two values: quote a value 
with a lone parenthesis. A lone quote at the
+end of an argument is now kept: `+${replace(b,x')}+` replaces with `x'`.
+
 === camel-core - a route called from a doTry uses the error handler of its EIPs
 
 A route that a `doTry` calls from its steps, or from its `doCatch` or 
`doFinally` blocks (with `direct`, `seda`,

Reply via email to