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

Croway 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 1567ac7ad41f CAMEL-26401: camel-core - Simple: the elvis operator 
inside ${ }, and AI tools flag the form without spaces (#28679)
1567ac7ad41f is described below

commit 1567ac7ad41f904a3e08df2a587b562c1f33fef3
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Oct 29 13:37:21 2026 +0100

    CAMEL-26401: camel-core - Simple: the elvis operator inside ${ }, and AI 
tools flag the form without spaces (#28679)
    
    * CAMEL-26401: camel-core - Simple: the elvis operator inside ${ }, and AI 
tools flag the form without spaces
    
    Since CAMEL-24921 a ternary and a predicate can be written inside ${ }, and 
coding agents write the elvis
    operator there too: ${header.missing ?: 10} was refused with "${ cannot 
accept ?:". Inside the braces a ?:
    with a space on both sides, outside quotes, brackets, parentheses and 
nested functions, is now the elvis
    operator, and ${a ?: b} means exactly ${a} ?: b (it is parsed as that), so 
the fallback rule, chains and
    nested functions are those of the operator after the braces. Without the 
spaces nothing changes:
    ${header.missing ?:10} is still a header named 'missing ?:10', as ?:10 
after the braces is text. Of the 7563
    distinct texts with ${ in camel, camel-kamelets, camel-examples and 
camel-jbang-examples, only the 7 with a
    spaced ?: inside ${ } parse or evaluate differently, all of them examples 
of an invalid expression in the AI
    tools.
    
    That form without the spaces evaluates to null, and camel_eval_expression 
answered ok with no result, since
    the MCP server leaves out null fields: a model wrote it into its SQL and 
got a BadSqlGrammarException. The
    validator, camel_eval_expression and the answer checks now say what it 
reads and give the rewrite with the
    operator after the function, and the same for the form inside the braces 
when the integration or the
    camelVersion is older than 4.24. camel_eval_expression marks a null value 
with nullResult and a note.
    
    The upgrade guide notes the one change for text that parsed before: in a 
predicate,
    ${header.limit ?: 10} read a header named 'limit ?: 10', and now falls back.
    
    * CAMEL-26401: simple docs - escape ${a} in the elvis section
    
    Asciidoctor read {a} in the inline text as a reference to a missing
    attribute, which fails the documentation build. Escaped as the operator
    table of the page does.
---
 .../camel/catalog/docs/simple-operators.adoc       |  27 ++++
 .../sql/SqlProducerExpressionParameterTest.java    |  25 ++++
 .../sql/selectProjectsElvisExpression.sql          |  22 +++
 .../modules/languages/pages/simple-operators.adoc  |  27 ++++
 .../language/simple/SimpleExpressionParser.java    |  14 +-
 .../camel/language/simple/SimpleSyntaxHints.java   |  48 +++++++
 .../simple/ast/SimpleFunctionExpression.java       |  26 ++++
 .../language/simple/SimpleElvisInBracesTest.java   | 154 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   9 ++
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   5 +-
 .../ROOT/pages/camel-jbang-tui-ai-agents.adoc      |   5 +-
 .../modules/ROOT/pages/camel-jbang-tui-ai.adoc     |   5 +-
 .../dsl/jbang/core/commands/ai/AnswerChecks.java   |   8 +-
 .../core/commands/ai/ExpressionEvaluator.java      |  42 ++++++
 .../dsl/jbang/core/commands/ai/SimpleChecks.java   | 133 +++++++++++++++++-
 .../jbang/core/commands/ai/AnswerChecksTest.java   |  35 +++--
 .../core/commands/ai/EvalInTheIntegrationTest.java |  39 ++++++
 .../core/commands/ai/ExpressionEvaluatorTest.java  |  65 ++++++++-
 .../commands/ai/SourceValidatorSimpleTest.java     | 106 ++++++++++++++
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   |  10 +-
 20 files changed, 778 insertions(+), 27 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc
index 1cd2d38b4c94..996376123b15 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc
@@ -196,6 +196,33 @@ then the default value of `Guest` is returned.
 simple("${header.username} ?: 'Guest'");
 ----
 
+From Camel 4.24 the elvis operator can also be written inside the braces, 
where it means the same:
+`$\{a ?: b}` is `$\{a} ?: b`.
+
+[source,java]
+----
+simple("${header.username ?: 'Guest'}");
+simple("Hello ${header.nickname ?: ${header.username} ?: 'Guest'}");
+----
+
+The same values fall back (null, an empty value, `false` and `0`), and the 
fallback is a quoted literal,
+a number, `true`, `false`, `null` or a function, as after the braces.
+
+The form after the braces stays the one to use in general, as it also works 
before Camel 4.24.
+The form inside the braces is the one to use where a single `${...}` is read, 
such as a
+xref:components::sql-component.adoc[SQL] parameter `:#${...}`, which reads up 
to the first `}`.
+In a query read from a file (`sql:classpath:query.sql`) that is:
+
+[source,sql]
+----
+select * from orders limit :#${header.limit ?: 10}
+----
+
+The endpoint URI itself cannot hold it, as a `?` there starts the options of 
the endpoint.
+
+Like the other operators, `?:` is the operator only with a space on both 
sides: `${header.limit ?:10}`
+reads a header named `limit ?:10`, and in `${header.limit} ?:10` the `?:10` is 
text after the header.
+
 ==== Chain Operator
 
 The syntax for the chain operator is:
diff --git 
a/components/camel-sql/src/test/java/org/apache/camel/component/sql/SqlProducerExpressionParameterTest.java
 
b/components/camel-sql/src/test/java/org/apache/camel/component/sql/SqlProducerExpressionParameterTest.java
index 9cc1253c4279..6e7463a6cb94 100644
--- 
a/components/camel-sql/src/test/java/org/apache/camel/component/sql/SqlProducerExpressionParameterTest.java
+++ 
b/components/camel-sql/src/test/java/org/apache/camel/component/sql/SqlProducerExpressionParameterTest.java
@@ -39,6 +39,9 @@ public class SqlProducerExpressionParameterTest extends 
CamelTestSupport {
     @EndpointInject("mock:result-simple")
     MockEndpoint resultSimple;
 
+    @EndpointInject("mock:result-elvis")
+    MockEndpoint resultElvis;
+
     private EmbeddedDatabase db;
 
     @Override
@@ -90,6 +93,23 @@ public class SqlProducerExpressionParameterTest extends 
CamelTestSupport {
         assertEquals("Linux", row.get("PROJECT"));
     }
 
+    @Test
+    public void testNamedParameterWithTheElvisOperatorInsideTheExpression() 
throws Exception {
+        // the parameter reads one ${...}, so the fallback goes inside it 
(CAMEL-26401)
+        resultElvis.expectedMessageCount(2);
+
+        template.sendBody("direct:start-elvis", "This is a dummy body");
+        template.sendBodyAndProperty("direct:start-elvis", "This is a dummy 
body", "license", "ASF");
+
+        resultElvis.assertIsSatisfied();
+
+        List<?> fallback = assertIsInstanceOf(List.class, 
resultElvis.getReceivedExchanges().get(0).getIn().getBody());
+        assertEquals(1, fallback.size());
+        assertEquals("Linux", assertIsInstanceOf(Map.class, 
fallback.get(0)).get("PROJECT"));
+        List<?> value = assertIsInstanceOf(List.class, 
resultElvis.getReceivedExchanges().get(1).getIn().getBody());
+        assertEquals(2, value.size());
+    }
+
     @Override
     protected RouteBuilder createRouteBuilder() {
         return new RouteBuilder() {
@@ -102,6 +122,11 @@ public class SqlProducerExpressionParameterTest extends 
CamelTestSupport {
                 from("direct:start-simple")
                         .to("sql:select * from projects where license = 
:#$simple{exchangeProperty.license} order by id")
                         .to("mock:result-simple");
+
+                // from a file: in the uri a ? starts the options of the 
endpoint
+                from("direct:start-elvis")
+                        
.to("sql:classpath:sql/selectProjectsElvisExpression.sql")
+                        .to("mock:result-elvis");
             }
         };
     }
diff --git 
a/components/camel-sql/src/test/resources/sql/selectProjectsElvisExpression.sql 
b/components/camel-sql/src/test/resources/sql/selectProjectsElvisExpression.sql
new file mode 100644
index 000000000000..b6744fa8cdb1
--- /dev/null
+++ 
b/components/camel-sql/src/test/resources/sql/selectProjectsElvisExpression.sql
@@ -0,0 +1,22 @@
+--
+-- 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.
+--
+
+-- the elvis operator goes inside the one ${...} that the parameter reads
+select *
+from projects
+where license = :#${exchangeProperty.license ?: 'XXX'}
+order by id
diff --git 
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc
 
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc
index 1cd2d38b4c94..996376123b15 100644
--- 
a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc
+++ 
b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc
@@ -196,6 +196,33 @@ then the default value of `Guest` is returned.
 simple("${header.username} ?: 'Guest'");
 ----
 
+From Camel 4.24 the elvis operator can also be written inside the braces, 
where it means the same:
+`$\{a ?: b}` is `$\{a} ?: b`.
+
+[source,java]
+----
+simple("${header.username ?: 'Guest'}");
+simple("Hello ${header.nickname ?: ${header.username} ?: 'Guest'}");
+----
+
+The same values fall back (null, an empty value, `false` and `0`), and the 
fallback is a quoted literal,
+a number, `true`, `false`, `null` or a function, as after the braces.
+
+The form after the braces stays the one to use in general, as it also works 
before Camel 4.24.
+The form inside the braces is the one to use where a single `${...}` is read, 
such as a
+xref:components::sql-component.adoc[SQL] parameter `:#${...}`, which reads up 
to the first `}`.
+In a query read from a file (`sql:classpath:query.sql`) that is:
+
+[source,sql]
+----
+select * from orders limit :#${header.limit ?: 10}
+----
+
+The endpoint URI itself cannot hold it, as a `?` there starts the options of 
the endpoint.
+
+Like the other operators, `?:` is the operator only with a space on both 
sides: `${header.limit ?:10}`
+reads a header named `limit ?:10`, and in `${header.limit} ?:10` the `?:10` is 
text after the header.
+
 ==== Chain Operator
 
 The syntax for the chain operator is:
diff --git 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
index 93809e9e5f08..b26d6adcb9cb 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleExpressionParser.java
@@ -183,10 +183,20 @@ public class SimpleExpressionParser extends 
BaseSimpleParser {
 
         // white space should be removed before and after the chain/other 
operator
         List<SimpleToken> toRemove = new ArrayList<>();
+        // the functions the token is inside: there the elvis operator is text 
the function reads, spaces included
+        int functions = !tokens.isEmpty() && 
tokens.get(0).getType().isFunctionStart() ? 1 : 0;
         for (int i = 1; i < tokens.size() - 1; i++) {
             SimpleToken prev = tokens.get(i - 1);
             SimpleToken cur = tokens.get(i);
             SimpleToken next = tokens.get(i + 1);
+            if (cur.getType().isFunctionStart()) {
+                functions++;
+            } else if (cur.getType().isFunctionEnd() && functions > 0) {
+                functions--;
+            } else if (cur.getType().isOther() && functions > 0) {
+                // ${header.a ?: 'x'} (CAMEL-26401)
+                continue;
+            }
             if (cur.getType().isOther() || cur.getType().isChain() || 
cur.getType().isInit()) {
                 if (prev.getType().isWhitespace()) {
                     toRemove.add(prev);
@@ -274,7 +284,9 @@ public class SimpleExpressionParser extends 
BaseSimpleParser {
         } else if (token.getType().isChain()) {
             return new ChainExpression(token);
         } else if (token.getType().isOther()) {
-            return new OtherExpression(token);
+            // inside a function the elvis operator is part of its text, which 
the function reads as ${a} ?: b, as it
+            // does in a predicate (CAMEL-26401); this used to be refused with 
"${ cannot accept ?:"
+            return functions.get() > 0 ? null : new OtherExpression(token);
         } else if (token.getType().isInit()) {
             return new InitBlockExpression(token);
         }
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 cfed1a9a57ef..900c9a3b02a1 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
@@ -310,6 +310,54 @@ public final class SimpleSyntaxHints {
         return -1;
     }
 
+    /** The elvis operator, with the spaces it must be surrounded by. */
+    private static final String SPACED_ELVIS = " ?: ";
+
+    /**
+     * The elvis operator written inside {@code ${ }}, as the same expression 
with the operator after the function:
+     * {@code header.a ?: 'x'} is {@code ${header.a} ?: 'x'}, and {@code 
header.a ?: ${header.b} ?: 'x'} is
+     * {@code ${header.a} ?: ${header.b} ?: 'x'}; null when the text has no 
elvis operator (CAMEL-26401).
+     * <p/>
+     * Like the other operators it counts only with a space on both sides, 
outside quotes, brackets, parentheses and
+     * nested functions, so {@code ${header.a ?:10}} is a header named {@code 
a ?:10}, as {@code ?:10} after the braces
+     * is text, and {@code ${body.replace('a ?: b', '')}} is a function. The 
default value of a property is text too:
+     * {@code ${properties:msg:which ?: none}}.
+     */
+    public static String elvisOutside(String function) {
+        int at = elvisIndex(function);
+        if (at <= 0 || at + SPACED_ELVIS.length() >= function.length()) {
+            return null;
+        }
+        String head = function.substring(0, at);
+        if (head.isBlank() || head.startsWith("properties:") && 
head.indexOf(':', 11) > 0) {
+            return null;
+        }
+        return "${" + head + "}" + function.substring(at);
+    }
+
+    /** The index of the first spaced elvis operator outside quotes, brackets, 
parentheses and nested functions. */
+    private static int elvisIndex(String text) {
+        boolean single = false;
+        boolean dubble = false;
+        int depth = 0;
+        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 && (c == '(' || c == '[' || c == 
'{')) {
+                // a { opens a nested ${ } function
+                depth++;
+            } else if (!single && !dubble && (c == ')' || c == ']' || c == 
'}')) {
+                depth--;
+            } else if (!single && !dubble && depth == 0 && 
text.startsWith(SPACED_ELVIS, 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/ast/SimpleFunctionExpression.java
 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
index ba4fdf74b61f..7749231374d4 100644
--- 
a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
+++ 
b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java
@@ -23,12 +23,14 @@ import org.apache.camel.Exchange;
 import org.apache.camel.Expression;
 import org.apache.camel.Predicate;
 import org.apache.camel.language.simple.FileExpressionBuilder;
+import org.apache.camel.language.simple.SimpleExpressionParser;
 import org.apache.camel.language.simple.SimpleFunctionDispatcher;
 import org.apache.camel.language.simple.SimpleFunctionHelper;
 import org.apache.camel.language.simple.SimplePredicateExpression;
 import org.apache.camel.language.simple.SimplePredicateParser;
 import org.apache.camel.language.simple.SimpleSyntaxHints;
 import org.apache.camel.language.simple.functions.DirectFunctionFactory;
+import org.apache.camel.language.simple.types.SimpleIllegalSyntaxException;
 import org.apache.camel.language.simple.types.SimpleParserException;
 import org.apache.camel.language.simple.types.SimpleToken;
 import org.apache.camel.support.PluginHelper;
@@ -137,6 +139,24 @@ public class SimpleFunctionExpression extends 
LiteralExpression {
         };
     }
 
+    /**
+     * The elvis operator written inside the braces, as the expression it is 
the same as: {@code ${header.a ?: 'x'}} is
+     * {@code ${header.a} ?: 'x'}, with the same fallback rule; null when the 
text has none (CAMEL-26401).
+     */
+    private Expression createElvisExpression(CamelContext camelContext, String 
function) {
+        String text = SimpleSyntaxHints.elvisOutside(function);
+        if (text == null) {
+            return null;
+        }
+        try {
+            // the function text has its escapes resolved already, so they are 
not resolved a second time
+            return new SimpleExpressionParser(camelContext, text, false, 
skipFileFunctions, null).parseExpression();
+        } catch (SimpleIllegalSyntaxException e) {
+            // say what is wrong with it at the function: the text it was 
rewritten to was never written
+            throw new SimpleParserException(e.getShortMessage(), 
token.getIndex());
+        }
+    }
+
     /** The function without its leading {@code !}, answering the opposite of 
what it answers (CAMEL-24984). */
     private Expression createNegatedExpression(CamelContext camelContext, 
String function) {
         final String name = function.substring(1).trim();
@@ -171,6 +191,12 @@ public class SimpleFunctionExpression extends 
LiteralExpression {
         if (predicate != null) {
             return predicate;
         }
+        // ${header.a ?: 'x'}: the elvis operator, the same as after the 
braces. It is read after the predicate
+        // above, so ${header.n ?: 0 > 5} compares the value it falls back to 
(CAMEL-26401)
+        Expression elvis = createElvisExpression(camelContext, function);
+        if (elvis != null) {
+            return elvis;
+        }
         // ${!body.isEmpty()}: a ! in front of a single function negates what 
it answers. It is read after the
         // predicate above, so a ! in ${!a && b} negates a and not the whole 
predicate (CAMEL-24984)
         if (function.length() > 1 && function.charAt(0) == '!') {
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleElvisInBracesTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleElvisInBracesTest.java
new file mode 100644
index 000000000000..c20cc67f765c
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleElvisInBracesTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.apache.camel.language.simple.types.SimpleIllegalSyntaxException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-26401: the elvis operator may be written inside {@code ${ }}, where 
{@code ${a ?: b}} means {@code ${a} ?: b},
+ * as a ternary and a predicate may since CAMEL-24921.
+ */
+public class SimpleElvisInBracesTest extends LanguageTestSupport {
+
+    @Override
+    protected String getLanguageName() {
+        return "simple";
+    }
+
+    @Test
+    public void testBothFormsAgree() {
+        assertExpression("${header.missing} ?: 10", "10");
+        assertExpression("${header.missing ?: 10}", "10");
+        exchange.getIn().setHeader("limit", 25);
+        assertExpression("${header.limit} ?: 10", "25");
+        assertExpression("${header.limit ?: 10}", "25");
+        assertExpression("${exchangeProperty.foobar ?: 'none'}", "cba");
+        assertExpression("${variable.missing ?: 'none'}", "none");
+        assertExpression("${body?.length() ?: 0}", "31");
+    }
+
+    @Test
+    public void testTheSameValuesFallBack() {
+        // null, empty, false and 0 fall back, as with the operator after the 
braces
+        exchange.getIn().setHeader("empty", "");
+        exchange.getIn().setHeader("no", false);
+        exchange.getIn().setHeader("zero", 0);
+        exchange.getIn().setHeader("decimalZero", 0.0d);
+        exchange.getIn().setHeader("yes", true);
+        exchange.getIn().setHeader("five", 5);
+        for (String header : new String[] { "missing", "empty", "no", "zero", 
"decimalZero", "yes", "five" }) {
+            Object after = evaluateExpression("${header." + header + "} ?: 
'fallback'", String.class);
+            assertExpression("${header." + header + " ?: 'fallback'}", after);
+        }
+        assertExpression("${header.empty ?: 'fallback'}", "fallback");
+        assertExpression("${header.five ?: 'fallback'}", "5");
+    }
+
+    @Test
+    public void testNestedFunctionsAndChains() {
+        exchange.getIn().setHeader("other", "o");
+        assertExpression("${header.missing ?: ${header.other}}", "o");
+        assertExpression("${header.missing ?: ${header.missing2} ?: 'c'}", 
"c");
+        assertExpression("${header.missing ?: ${header.other} ?: 'c'}", "o");
+        assertExpression("${header.missing ?: ${header.missing2 ?: 'deep'}}", 
"deep");
+        assertExpression("${header.${header.missing ?: 'other'}}", "o");
+        // the same chain after the braces
+        assertExpression("${header.missing} ?: ${header.missing2} ?: 'c'", 
"c");
+    }
+
+    @Test
+    public void testQuotedValues() {
+        assertExpression("${header.missing ?: 'a b'}", "a b");
+        assertExpression("${header.missing ?: \"it's\"}", "it's");
+        assertExpression("${header.missing ?: 'a ?: b'}", "a ?: b");
+        assertExpression("${header.missing ?: ''}", "");
+    }
+
+    @Test
+    public void testInATemplate() {
+        assertExpression("Limit: ${header.missing ?: 10}", "Limit: 10");
+        assertExpression("${header.missing ?: 10} rows", "10 rows");
+        assertExpression("${header.missing ?: 10 rows}", "10 rows");
+        assertExpression("${header.missing} ?: 10 rows", "10 rows");
+        exchange.getIn().setHeader("offset", 20);
+        assertExpression("LIMIT ${header.offset ?: 0}, ${header.limit ?: 10}", 
"LIMIT 20, 10");
+    }
+
+    @Test
+    public void testInAPredicate() {
+        // the function inside a predicate reads it the same way
+        assertPredicate("${header.missing ?: 10} == 10", true);
+        assertPredicate("${header.bar ?: 10} == 123", true);
+        // inside the braces the comparison takes the value the elvis falls 
back to
+        exchange.getIn().setHeader("n", 5);
+        assertPredicate("${header.n ?: 0 > 3}", true);
+        assertPredicate("${header.missing ?: 0 > 3}", false);
+    }
+
+    @Test
+    public void testTheTernaryIsNotConfusedWithIt() {
+        exchange.getIn().setBody("x");
+        assertExpression("${body == 'x' ? 'yes' : 'no'}", "yes");
+        assertExpression("${body?.length()}", "1");
+        exchange.getIn().setHeader("n", 5);
+        assertExpression("${header.n == 5 ? header.missing ?: 'd' : 'no'}", 
"d");
+        assertExpression("${header.n ?: 0 > 3 ? 'big' : 'small'}", "big");
+        assertExpression("${header.missing ?: 0 > 3 ? 'big' : 'small'}", 
"small");
+    }
+
+    @Test
+    public void testWithoutTheSpacesItIsText() {
+        // an operator counts only with a space on both sides, outside the 
braces and inside them
+        assertExpression("${header.missing} ?:10", " ?:10");
+        assertExpression("${header.missing}?: 10", "?: 10");
+        assertExpression("${header.missing ?:10}", null);
+        assertExpression("${header.missing?: 10}", null);
+        exchange.getIn().setHeader("missing ?:10", "a header of that name");
+        assertExpression("${header.missing ?:10}", "a header of that name");
+    }
+
+    @Test
+    public void testInQuotesBracketsOrADefaultItIsText() {
+        assertExpression("${properties:msg:which ?: none}", "which ?: none");
+        exchange.getIn().setHeader("a ?: b", "bracket");
+        assertExpression("${header[a ?: b]}", "bracket");
+    }
+
+    @Test
+    public void testTheRewrite() {
+        assertThat(SimpleSyntaxHints.elvisOutside("header.a ?: 
'x'")).isEqualTo("${header.a} ?: 'x'");
+        assertThat(SimpleSyntaxHints.elvisOutside("body.replace('a ?: b', 
'c')")).isNull();
+        assertThat(SimpleSyntaxHints.elvisOutside("iif(header.a ?: false, 'x', 
'y')")).isNull();
+        assertThat(SimpleSyntaxHints.elvisOutside("header.a ?:10")).isNull();
+        assertThat(SimpleSyntaxHints.elvisOutside("header.a ?: ")).isNull();
+        assertThat(SimpleSyntaxHints.elvisOutside("header.a ?: ${header.b ?: 
'x'}"))
+                .isEqualTo("${header.a} ?: ${header.b ?: 'x'}");
+    }
+
+    @Test
+    public void testAnErrorSaysWhatIsWrong() {
+        assertThatThrownBy(() -> 
context.resolveLanguage("simple").createExpression("${header.missing ?: 
Guest}"))
+                .isInstanceOf(SimpleIllegalSyntaxException.class)
+                .hasMessageContaining("does not accept Guest on the right hand 
side")
+                .hasMessageContaining("'Guest'");
+    }
+}
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 70a0b2850701..9fd36f2a2110 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
@@ -157,6 +157,15 @@ graphql, kubernetes-custom-resources and typesafe-ai) now 
have type converters t
 or `InputStream`, so such a body sent to http or a file failed. A `JsonObject` 
or `JsonArray` that holds a value that
 is not JSON, such as a POJO, converts as before.
 
+=== camel-core - Simple: the elvis operator inside the braces
+
+The elvis operator can now be written inside the braces: `${header.limit ?: 
10}` means `${header.limit} ?: 10`.
+In an expression this used to fail with `${ cannot accept ?:`. In a predicate 
it used to be read as one function whose
+name took in the rest of the text, such as a header named `limit ?: 10`, which 
is not set: so
+`${header.limit ?: 10} == 10` was false, and is true now. Without a space on 
both sides of `?:` nothing changes:
+`${header.limit ?:10}` still reads a header named `limit ?:10`. The default 
value of a property placeholder,
+`${properties:key:a ?: b}`, is not affected.
+
 === camel-util - FilterIterator.remove after hasNext throws 
IllegalStateException
 
 `FilterIterator.hasNext()` looks ahead in the iterator it wraps, so a 
`remove()` after `next()` and then `hasNext()`
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 833be18aab8d..5e8d87da7b0d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -459,7 +459,10 @@ project `directory` as an argument, the runtime tools take 
the integration `name
   and returns the value (true/false for a predicate) or the syntax error with 
the position where it breaks,
   so an agent can check an expression before writing it into a route. Any 
language works: one that is not on
   the server's own classpath, `jsonpath`, `jq`, `xpath`, `groovy`, has its 
component downloaded on first use
-  and the answer says so in `downloaded`.
+  and the answer says so in `downloaded`. A null value is answered with 
`nullResult: true` and a note, since the
+  answer leaves out null fields; an elvis operator without a space on both 
sides inside `${...}` (`${header.limit ?:10}`
+  reads a header named `limit ?:10`), or inside `${...}` for an integration 
older than Camel 4.24, gets a note with
+  the expression rewritten.
 
 | `camel_dependency_for_class`
 | Which Maven dependency provides a class, and how to declare it. Local first: 
the known dependencies
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index 23fb0dba4025..6ac23c3a6293 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -225,8 +225,9 @@ finding them in the log after the reload. Both tools are 
part of the core
 tool set, so local models (Ollama) get them as well.
 
 Simple expressions get two more helpers, because they are what a small model 
gets wrong most often
-(functions belong inside the `${...}` placeholder, operators between 
placeholders: `${header.user} ?: 'Guest'`,
-not `${header.user ?: 'Guest'}`). `camel_catalog_doc` for the `simple` 
language returns those syntax rules
+(functions belong inside the `${...}` placeholder, operators between 
placeholders with a space on both sides:
+`${header.user} ?: 'Guest'`, not `${header.user ?:'Guest'}`, which reads a 
header named `user ?:'Guest'`).
+`camel_catalog_doc` for the `simple` language returns those syntax rules
 together with the catalog's functions and operators (their count and names by 
group, or with `optionsFilter`
 the matching ones with parameters and examples), and `docPage` serves the 
operators, functions, OGNL and
 advanced documentation pages. `camel_eval_expression` evaluates an expression 
inside the running integration,
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
index ebfc32f35e8c..9b83947174ce 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai.adoc
@@ -173,8 +173,9 @@ shows which one.
 What the model says is checked like what it writes. A file goes through the 
validator before
 `camel_write_file` writes it; the answer shown in the chat is what you copy, 
so its simple expressions
 are checked against the catalog too: the `${...}` placeholders in the text and 
in Java or XML code
-blocks, and the `simple:` values and log messages of YAML blocks. A small 
model tends to put the
-operator inside the placeholder (`${header.user ?: 'Guest'}` instead of 
`${header.user} ?: 'Guest'`).
+blocks, and the `simple:` values and log messages of YAML blocks. A small 
model tends to get the
+operator wrong: a fallback without its quotes (`${header.user ?: Guest}`), or 
an elvis operator without
+its spaces (`${header.user ?:'Guest'}` reads a header named `user ?:'Guest'`).
 When the check finds such an expression the panel sends the answer back to the 
model once, with what is
 wrong and the rule, and shows the corrected answer with `1 correction` in the 
byline. When the model
 does not fix it the answer is shown as it is with a *Simple check* line under 
it naming the expression
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
index 1caa76a7f773..441fcf280785 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
@@ -47,7 +47,7 @@ public final class AnswerChecks {
     /** A simple expression of an answer that the catalog rejects: what was 
written, and why. */
     public record Problem(String expression, String error) {
 
-        /** {@code ${header.user ?: 'Guest'}: Unexpected token ?: at location 
13}. */
+        /** {@code ${header.user ?: Guest}: Other operator ?: does not accept 
Guest on the right hand side: ...}. */
         public String message() {
             return expression + ": " + error;
         }
@@ -132,6 +132,12 @@ public final class AnswerChecks {
                     if (error != null && 
!SimpleChecks.isMissingDependency(error)) {
                         problems.put(placeholder, new Problem(placeholder, 
error));
                     }
+                } else {
+                    // valid, and still not what it looks like: 
${header.offset ?:0} reads a header named 'offset ?:0'
+                    String elvis = 
SimpleChecks.elvisInsideFunction(placeholder, catalog.getCatalogVersion());
+                    if (elvis != null) {
+                        problems.put(placeholder, new Problem(placeholder, 
elvis));
+                    }
                 }
             } catch (Exception e) {
                 // best effort: what the catalog cannot judge is not reported, 
the cause is in the debug log
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
index 1bb3c5dfc3ea..2e99d3896939 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluator.java
@@ -87,10 +87,52 @@ public final class ExpressionEvaluator {
             result.put("evaluatedIn", "a local scratch context (no integration 
selected)");
             evaluateLocally(ctx, lang, expression, body, predicate, result);
         }
+        addNullAndElvisNotes(ctx, result, lang, expression);
         addFilterNote(result, lang, expression);
         return result;
     }
 
+    static final String NULL_NOTE = "evaluated to null: a header, property or 
variable that is not set, or a function"
+                                    + " name that took in the rest of the 
text";
+
+    /**
+     * Says that a value is null, and why it may be. An answer that leaves out 
null fields (the MCP server does) has no
+     * result then, which a model read as a form that works: it wrote 
${header.offset ?:0} into its SQL, which reads a
+     * header named 'offset ?:0' (CAMEL-26401). An elvis operator inside 
${...} that the Camel of the evaluation does
+     * not read as one is the note, also when the value is not null or the 
evaluation failed.
+     */
+    static void addNullAndElvisNotes(ToolContext ctx, JsonObject result, 
String lang, String expression) {
+        boolean isNull = "ok".equals(result.getString("status")) && 
result.get("result") == null;
+        if (isNull) {
+            result.put("result", null);
+            result.put("nullResult", true);
+        }
+        String elvis = "simple".equals(lang) && expression != null && 
expression.contains("?:")
+                ? SimpleChecks.elvisInsideFunction(expression, 
camelVersion(ctx)) : null;
+        String note = elvis != null ? elvis : isNull ? NULL_NOTE : null;
+        if (note != null) {
+            result.put("note", result.containsKey("note") ? 
result.getString("note") + "; " + note : note);
+        }
+    }
+
+    /**
+     * The Camel version that evaluates: the selected integration's, else the 
one the tools are for (null for the CLI's
+     * own, which evaluates locally).
+     */
+    private static String camelVersion(ToolContext ctx) {
+        if (!ctx.hasProcess()) {
+            return ctx.camelVersion();
+        }
+        try {
+            JsonObject status = ctx.readFullStatus();
+            JsonObject context = status != null ? status.getMap("context") : 
null;
+            return context != null ? context.getString("version") : null;
+        } catch (Exception e) {
+            // the status cannot be read: the version is not known
+            return null;
+        }
+    }
+
     /** Adds the note about a jsonpath filter to the result of an evaluation, 
after the note it has already. */
     static void addFilterNote(JsonObject result, String lang, String 
expression) {
         String filter = filterNote(lang, expression, 
result.getString("status"));
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
index 57cd4a9fa52c..742c166d0090 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SimpleChecks.java
@@ -23,6 +23,7 @@ import java.util.regex.Pattern;
 
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.LanguageValidationResult;
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
 import org.apache.camel.spi.SimpleLanguageFunctionFactory;
 import org.apache.camel.tooling.model.EipModel;
 import org.apache.camel.util.StringHelper;
@@ -117,8 +118,8 @@ final class SimpleChecks {
     }
 
     /**
-     * The checks of one simple text, whatever the DSL it is written in: its 
syntax, as a predicate or an expression,
-     * and a ternary operator written where it is literal text.
+     * The checks of one simple text, whatever the DSL it is written in: its 
syntax, as a predicate or an expression, a
+     * ternary operator written where it is literal text, and an elvis 
operator inside ${...} that is not read as one.
      *
      * @param lineNum     the line of the text, 1-based
      * @param predicate   whether the EIP evaluates the text as a predicate
@@ -165,6 +166,134 @@ final class SimpleChecks {
                 errors.add("Line " + lineNum + ": " + ternary);
             }
         }
+        // the same for an elvis operator inside ${...} that is not read as 
one, in any kind of text: the catalog
+        // parses it with the CLI's own Camel, so it also says when the 
version the route is for does not read it
+        if (!syntaxError) {
+            String elvis = elvisInsideFunction(text, catalogVersion(catalog));
+            if (elvis != null) {
+                errors.add("Line " + lineNum + ": " + elvis);
+            }
+        }
+    }
+
+    /** The Camel version from which the elvis operator may be written inside 
${...} (CAMEL-26401). */
+    static final String ELVIS_INSIDE_SINCE = "4.24";
+
+    /**
+     * An elvis operator inside ${...} that does not do what it looks like, as 
the message to report, or null. Without a
+     * space on both sides it is not an operator but part of the function, so 
<tt>${header.offset ?:0}</tt> reads a
+     * header named <tt>offset ?:0</tt>: the expression is valid and evaluates 
to null, which a model took for a form
+     * that works, wrote it into its SQL and got a BadSqlGrammarException 
(CAMEL-26401). With the spaces it is the
+     * operator from Camel 4.24, and an older Camel refuses it with "${ cannot 
accept ?:".
+     * <p/>
+     * The rewrite with the operator after the function comes first: it works 
in every version.
+     *
+     * @param  text         the simple text
+     * @param  camelVersion the Camel version the text is for, or null for the 
CLI's own
+     * @return              the message, or null when the text has no such 
operator
+     */
+    static String elvisInsideFunction(String text, String camelVersion) {
+        if (text == null || !text.contains("?:") || !text.contains("${")) {
+            return null;
+        }
+        boolean readsInside = readsElvisInside(camelVersion);
+        List<String> messages = new ArrayList<>();
+        List<String> written = new ArrayList<>();
+        // every function, the nested ones too: each is checked with its own 
text
+        for (int open = text.indexOf("${"); open >= 0; open = 
text.indexOf("${", open + 2)) {
+            int close = closingBrace(text, open);
+            if (close < 0) {
+                continue;
+            }
+            String inner = text.substring(open + 2, close);
+            int at = elvisIndex(inner);
+            if (at < 0) {
+                continue;
+            }
+            String left = inner.substring(0, at).strip();
+            String right = inner.substring(at + 2).strip();
+            boolean spaced = at > 0 && inner.charAt(at - 1) == ' ' && at + 2 < 
inner.length() && inner.charAt(at + 2) == ' ';
+            if (left.isEmpty() || right.isEmpty() || spaced && readsInside) {
+                continue;
+            }
+            String function = text.substring(open, close + 1);
+            String outside = "${" + left + "} ?: " + quoted(right);
+            written.add(function);
+            if (spaced) {
+                messages.add("`" + function + "` needs Camel " + 
ELVIS_INSIDE_SINCE + " or later, where the elvis operator"
+                             + " may be written inside ${...} (here Camel " + 
camelVersion + "): write it after the"
+                             + " function: `" + outside + "`");
+            } else {
+                messages.add("`" + function + "` " + readAs(inner) + ": the 
elvis operator needs a space on both sides"
+                             + " and goes after the function: `" + outside + 
"`"
+                             + (readsInside
+                                     ? " (from Camel " + ELVIS_INSIDE_SINCE + 
" also `${" + left + " ?: " + quoted(right)
+                                       + "}`, the form for a single ${...} 
such as a camel-sql :#${...} parameter)"
+                                     : ""));
+            }
+        }
+        if (messages.isEmpty()) {
+            return null;
+        }
+        return messages.get(0)
+               + (written.size() > 1 ? "; the same for " + String.join(", ", 
written.subList(1, written.size())) : "");
+    }
+
+    /**
+     * What the text of a function with an elvis operator that is not one 
reads, such as a header named 'offset ?:0'.
+     */
+    private static String readAs(String inner) {
+        String[][] kinds = {
+                { "header.", "a header" }, { "headers.", "a header" }, { 
"exchangeProperty.", "an exchange property" },
+                { "variable.", "a variable" } };
+        for (String[] kind : kinds) {
+            if (inner.startsWith(kind[0]) && inner.length() > 
kind[0].length()) {
+                return "reads " + kind[1] + " named '" + 
inner.substring(kind[0].length()) + "'";
+            }
+        }
+        return "is one function, '" + inner + "', not a value with a fallback";
+    }
+
+    /** Whether the Camel version reads the elvis operator inside ${...}: 
unknown is the CLI's own, which does. */
+    static boolean readsElvisInside(String camelVersion) {
+        if (camelVersion == null || 
!camelVersion.matches("\\d+\\.\\d+(?:\\..*)?")) {
+            return true;
+        }
+        return 
VersionHelper.isGE(VersionHelper.getMajorMinorVersion(camelVersion), 
ELVIS_INSIDE_SINCE);
+    }
+
+    private static String catalogVersion(CamelCatalog catalog) {
+        try {
+            return catalog != null ? catalog.getCatalogVersion() : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * The index of the first ?: of the text of a function outside quotes, 
brackets, parentheses and nested functions,
+     * or -1: the places where the parser reads it as the elvis operator when 
it has its spaces.
+     */
+    private static int elvisIndex(String inner) {
+        char quote = 0;
+        int depth = 0;
+        for (int i = 0; i < inner.length() - 1; i++) {
+            char ch = inner.charAt(i);
+            if (quote != 0) {
+                if (ch == quote) {
+                    quote = 0;
+                }
+            } else if (ch == '\'' || ch == '"') {
+                quote = ch;
+            } else if (ch == '(' || ch == '[' || ch == '{') {
+                depth++;
+            } else if (ch == ')' || ch == ']' || ch == '}') {
+                depth--;
+            } else if (depth == 0 && ch == '?' && inner.charAt(i + 1) == ':') {
+                return i;
+            }
+        }
+        return -1;
     }
 
     /**
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
index 0b4093a0726f..4b2d28385e4d 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
@@ -27,23 +27,36 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 /**
  * CAMEL-24805: the simple expressions an AI model writes in an answer are 
checked the way a file is, since the answer
- * is what the user copies and where a small model puts the operator inside 
the placeholder.
+ * is what the user copies and where a small model writes a fallback without 
its quotes or the elvis operator without
+ * its spaces.
  */
 class AnswerChecksTest {
 
     private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
 
     @Test
-    void theOperatorInsideThePlaceholderIsFoundInProseAndInlineCode() {
-        String answer = "Use `${header.user ?: 'Guest'}` to fall back to a 
guest name, and log it with "
+    void anInvalidPlaceholderIsFoundInProseAndInlineCode() {
+        String answer = "Use `${header.user ?: Guest}` to fall back to a guest 
name, and log it with "
                         + "`${header.user} ?: 'Guest'` between placeholders is 
what Camel expects.";
         List<AnswerChecks.Problem> problems = AnswerChecks.checkSimple(answer, 
CATALOG);
         assertThat(problems).hasSize(1);
-        assertThat(problems.get(0).expression()).isEqualTo("${header.user ?: 
'Guest'}");
-        assertThat(problems.get(0).error()).isNotBlank();
-        assertThat(problems.get(0).message()).startsWith("${header.user ?: 
'Guest'}: ");
+        assertThat(problems.get(0).expression()).isEqualTo("${header.user ?: 
Guest}");
+        assertThat(problems.get(0).error()).contains("does not accept Guest");
+        assertThat(problems.get(0).message()).startsWith("${header.user ?: 
Guest}: ");
         // the same mistake twice is one problem
-        assertThat(AnswerChecks.checkSimple(answer + "\nAgain: ${header.user 
?: 'Guest'}", CATALOG)).hasSize(1);
+        assertThat(AnswerChecks.checkSimple(answer + "\nAgain: ${header.user 
?: Guest}", CATALOG)).hasSize(1);
+    }
+
+    @Test
+    void anElvisThatIsPartOfTheFunctionIsFound() {
+        // valid, and a header named 'user ?:Guest' (CAMEL-26401)
+        List<AnswerChecks.Problem> problems
+                = AnswerChecks.checkSimple("Use `${header.user ?:Guest}` to 
fall back to a guest name.", CATALOG);
+        assertThat(problems).hasSize(1);
+        assertThat(problems.get(0).error()).contains("reads a header named 
'user ?:Guest'")
+                .contains("`${header.user} ?: 'Guest'`");
+        // with its spaces it is the elvis operator, since Camel 4.24
+        assertThat(AnswerChecks.checkSimple("Use `${header.user ?: 
'Guest'}`.", CATALOG)).isEmpty();
     }
 
     @Test
@@ -73,7 +86,7 @@ class AnswerChecksTest {
                         - setBody:
                             expression:
                               simple:
-                                expression: "${header.user ?: 'Guest'}"
+                                expression: "${header.user ?: Guest}"
                         - log:
                             message: "Hello ${body}"
                 ```
@@ -81,14 +94,14 @@ class AnswerChecksTest {
                 And in Java:
 
                 ```java
-                from("timer:tick").setBody(simple("${header.user ?: 
'Guest'}"));
+                from("timer:tick").setBody(simple("${header.user ?: Guest}"));
                 ```
                 """;
         List<AnswerChecks.Problem> problems = AnswerChecks.checkSimple(answer, 
CATALOG);
         assertThat(problems).hasSize(2);
         assertThat(problems.get(0).expression()).isEqualTo("the YAML block");
         assertThat(problems.get(0).error()).startsWith("Line 8: Simple syntax 
error: ");
-        assertThat(problems.get(1).expression()).isEqualTo("${header.user ?: 
'Guest'}");
+        assertThat(problems.get(1).expression()).isEqualTo("${header.user ?: 
Guest}");
 
         String valid = """
                 ```yaml
@@ -120,7 +133,7 @@ class AnswerChecksTest {
 
     @Test
     void theDefaultCatalogIsUsedWhenNoneIsGiven() {
-        assertThat(AnswerChecks.checkSimple("${header.user ?: 
'Guest'}")).hasSize(1);
+        assertThat(AnswerChecks.checkSimple("${header.user ?: 
Guest}")).hasSize(1);
         assertThat(AnswerChecks.checkSimple("${header.user} ?: 
'Guest'")).isEmpty();
     }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
index a6b8e4733cd9..0f4597721b11 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/EvalInTheIntegrationTest.java
@@ -87,6 +87,45 @@ class EvalInTheIntegrationTest {
         assertThat(result.getString("result")).isEqualTo("{\"id\":1}");
     }
 
+    @Test
+    void aNullValueOfTheIntegrationIsSaidToBeNull() {
+        JsonObject result = ExpressionEvaluator.evaluate(ctx(), "simple", 
"${header.missing}", null);
+
+        assertThat(result.getString("status")).isEqualTo("ok");
+        assertThat(result.get("result")).isNull();
+        assertThat(result.getBoolean("nullResult")).isTrue();
+        
assertThat(result.getString("note")).isEqualTo(ExpressionEvaluator.NULL_NOTE);
+    }
+
+    @Test
+    void anElvisWithoutItsSpacesInTheIntegration() {
+        // CAMEL-26401: answered ok and without a result, which a model took 
for a form that works
+        JsonObject result = ExpressionEvaluator.evaluate(ctx(), "simple", 
"${header.missing ?:10}", null);
+
+        assertThat(result.getString("status")).isEqualTo("ok");
+        assertThat(result.getBoolean("nullResult")).isTrue();
+        assertThat(result.getString("note")).startsWith("`${header.missing 
?:10}` reads a header named 'missing ?:10'")
+                .contains("goes after the function: `${header.missing} ?: 
10`");
+    }
+
+    @Test
+    void theElvisInsideThePlaceholderInAnIntegrationOfAnOlderCamel() throws 
Exception {
+        // the status of the integration says its version; the one of the test 
reads the form, Camel 4.22 refuses it
+        Files.writeString(CommandLineHelper.getCamelDir().resolve(PID + 
"-status.json"),
+                "{\"context\":{\"version\":\"4.22.1\"}}");
+        JsonObject result = ExpressionEvaluator.evaluate(ctx(), "simple", 
"${header.missing ?: 10}", null);
+
+        assertThat(result.getString("note")).contains("needs Camel 4.24 or 
later")
+                .contains("(here Camel 4.22.1)")
+                .endsWith("write it after the function: `${header.missing} ?: 
10`");
+
+        Files.writeString(CommandLineHelper.getCamelDir().resolve(PID + 
"-status.json"),
+                "{\"context\":{\"version\":\"4.24.0\"}}");
+        JsonObject current = ExpressionEvaluator.evaluate(ctx(), "simple", 
"${header.missing ?: 10}", null);
+        assertThat(current.getString("result")).isEqualTo("10");
+        assertThat(current.get("note")).isNull();
+    }
+
     @Test
     void theEvalToolOfCamelAsk() throws Exception {
         // an expression (not a predicate): the text with its quotes and slash
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
index a314d398405f..29d365bc7136 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExpressionEvaluatorTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.ai;
 
+import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.Test;
 
@@ -57,12 +58,72 @@ class ExpressionEvaluatorTest {
         JsonObject note = ExpressionEvaluator.evaluate(ctx, "simple", 
"${body.length()}", null);
         assertTrue(note.getString("note").contains("empty body"));
 
-        JsonObject error = ExpressionEvaluator.evaluate(ctx, "simple", 
"${header.foo ?: 'x'}", null);
-        assertEquals("error", error.getString("status"), "the operator is 
inside the placeholder");
+        JsonObject error = ExpressionEvaluator.evaluate(ctx, "simple", 
"${header.foo ?: x}", null);
+        assertEquals("error", error.getString("status"), "the fallback is not 
quoted");
         assertFalse(error.getString("error").isBlank());
 
         JsonObject unknown = ExpressionEvaluator.evaluate(ctx, 
"no-such-language", "x", null);
         assertEquals("error", unknown.getString("status"));
         assertTrue(unknown.getString("error").contains("no-such-language"));
     }
+
+    @Test
+    void theElvisOperatorInsideThePlaceholderEvaluates() {
+        // the same as ${header.foo} ?: 'x' since Camel 4.24 (CAMEL-26401)
+        JsonObject value = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "${header.foo ?: 'x'}", null);
+        assertEquals("ok", value.getString("status"));
+        assertEquals("x", value.getString("result"));
+        assertNull(value.get("nullResult"));
+        assertNull(value.get("note"));
+    }
+
+    @Test
+    void aNullValueIsSaidToBeNull() {
+        // an answer without null fields (the MCP server) has no result then, 
which read as a form that works
+        JsonObject value = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "${header.missing}", null);
+        assertEquals("ok", value.getString("status"));
+        assertTrue(value.containsKey("result"));
+        assertNull(value.get("result"));
+        assertTrue(value.getBoolean("nullResult"));
+        assertEquals(ExpressionEvaluator.NULL_NOTE, value.getString("note"));
+
+        JsonObject empty = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "${body}", null);
+        assertNull(empty.get("nullResult"), "an empty body is a value");
+    }
+
+    @Test
+    void anElvisWithoutItsSpacesIsTheNote() {
+        JsonObject value = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "${header.offset ?:0}", null);
+        assertEquals("ok", value.getString("status"));
+        assertTrue(value.getBoolean("nullResult"));
+        assertTrue(value.getString("note").startsWith(
+                "`${header.offset ?:0}` reads a header named 'offset ?:0': the 
elvis operator needs a space on both sides"
+                                                      + " and goes after the 
function: `${header.offset} ?: 0` (from Camel 4.24 also"
+                                                      + " `${header.offset ?: 
0}`"),
+                value.getString("note"));
+
+        // a value that is not null is no help either: the fallback was never 
applied
+        JsonObject text = ExpressionEvaluator.evaluate(new ToolContext(), 
"simple", "Limit ${header.limit?: 10}", null);
+        assertEquals("Limit ", text.getString("result"));
+        assertTrue(text.getString("note").contains("reads a header named 
'limit?: 10'"), text.getString("note"));
+        assertTrue(text.getString("note").contains("`${header.limit} ?: 10`"), 
text.getString("note"));
+    }
+
+    @Test
+    void theElvisInsideThePlaceholderIsRewrittenForAnOlderCamel() {
+        ToolContext ctx = new ToolContext();
+        ctx.setCamelVersion("4.22.1");
+        // the catalog of the CLI, so the test downloads nothing
+        ctx.setCatalog(new DefaultCamelCatalog());
+        JsonObject value = ExpressionEvaluator.evaluate(ctx, "simple", 
"${header.limit ?: 10}", null);
+        assertEquals("ok", value.getString("status"), "this process reads it");
+        assertEquals("10", value.getString("result"));
+        assertEquals("`${header.limit ?: 10}` needs Camel 4.24 or later, where 
the elvis operator may be written inside"
+                     + " ${...} (here Camel 4.22.1): write it after the 
function: `${header.limit} ?: 10`",
+                value.getString("note"));
+        // without a space it is a header name in any version, and the form 
inside the braces is not offered
+        JsonObject offset = ExpressionEvaluator.evaluate(ctx, "simple", 
"${header.offset ?:0}", null);
+        assertTrue(offset.getString("note").endsWith("goes after the function: 
`${header.offset} ?: 0`"),
+                offset.getString("note"));
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
index 290b5f6cae01..c8229fd4a4cc 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorSimpleTest.java
@@ -601,4 +601,110 @@ class SourceValidatorSimpleTest {
         assertThat(msgs).hasSize(1);
         assertThat(msgs.get(0)).contains("Simple syntax error");
     }
+
+    /**
+     * CAMEL-26401: without a space on both sides the elvis operator inside 
${...} is part of the function, so the
+     * expression is valid and evaluates to null; a model wrote this into its 
SQL and got a BadSqlGrammarException.
+     */
+    @Test
+    void anElvisWithoutItsSpacesInsideAFunctionIsReported() {
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          simple: "LIMIT :#${header.offset ?:0}, 
:#${header.limit ?:10}"
+                """, catalog);
+        assertThat(msgs).hasSize(1);
+        assertThat(msgs.get(0)).startsWith("Line 5: `${header.offset ?:0}` 
reads a header named 'offset ?:0': the elvis"
+                                           + " operator needs a space on both 
sides and goes after the function:"
+                                           + " `${header.offset} ?: 0` (from 
Camel 4.24 also `${header.offset ?: 0}`")
+                .endsWith("; the same for ${header.limit ?:10}");
+    }
+
+    @Test
+    void everyFormOfAnElvisWithoutItsSpacesIsReported() {
+        assertThat(SimpleChecks.elvisInsideFunction("${header.limit?: 10}", 
null))
+                .startsWith("`${header.limit?: 10}` reads a header named 
'limit?: 10'")
+                .contains("`${header.limit} ?: 10`");
+        // bare text is quoted, as the operator takes a quoted literal
+        assertThat(SimpleChecks.elvisInsideFunction("${h?:x}", null))
+                .startsWith("`${h?:x}` is one function, 'h?:x', not a value 
with a fallback")
+                .contains("`${h} ?: 'x'`");
+        
assertThat(SimpleChecks.elvisInsideFunction("${exchangeProperty.retries ?:3}", 
null))
+                .contains("reads an exchange property named 'retries 
?:3'").contains("`${exchangeProperty.retries} ?: 3`");
+        assertThat(SimpleChecks.elvisInsideFunction("${variable.name?:none}", 
null))
+                .contains("reads a variable named 
'name?:none'").contains("`${variable.name} ?: 'none'`");
+        // a nested function is checked with its own text
+        assertThat(SimpleChecks.elvisInsideFunction("${header.a ?: 
${header.b?:1}}", null))
+                .startsWith("`${header.b?:1}` reads a header named 'b?:1'");
+    }
+
+    @Test
+    void anElvisInsideAFunctionWithItsSpacesIsAccepted() {
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          simple: "LIMIT :#${header.offset ?: 0}, 
:#${header.limit ?: 10}"
+                      - setBody:
+                          simple: "${header.user} ?: 'Guest'"
+                """, catalog);
+        assertThat(msgs).isEmpty();
+    }
+
+    @Test
+    void aQuestionMarkAndAColonThatAreNotAnElvisAreNotReported() {
+        // in quotes, in arguments and brackets, the null-safe operator, a 
ternary, outside the braces
+        for (String text : List.of("${body.replace('?:', '')}", "${header.a} 
== 'x?:y'", "${body.replaceAll('(?:a|b)', '')}",
+                "${header[a?:b]}", "${body?.name}", "${header.n > 0 ? 'a' : 
'b'}", "${header.a} ?:b", "What?: ${body}",
+                "${header.a ?:}")) {
+            assertThat(SimpleChecks.elvisInsideFunction(text, 
null)).as(text).isNull();
+        }
+    }
+
+    @Test
+    void anElvisWithoutItsSpacesIsReportedInAPredicateAndALogMessage() {
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - filter:
+                          simple: "${header.n ?:0} > 5"
+                          steps:
+                            - log:
+                                message: "Limit ${header.limit ?:10}"
+                """, catalog);
+        assertThat(msgs).hasSize(2);
+        assertThat(msgs.get(0)).startsWith("Line 5: `${header.n ?:0}` reads a 
header named 'n ?:0'");
+        assertThat(msgs.get(1)).startsWith("Line 8: `${header.limit ?:10}` 
reads a header named 'limit ?:10'");
+    }
+
+    @Test
+    void anElvisInsideAFunctionIsRewrittenForACamelBefore424() {
+        // the catalog parses with the CLI's own Camel, which reads it; Camel 
4.22 refuses it with "${ cannot accept ?:"
+        CamelCatalog older = new DefaultCamelCatalog() {
+            @Override
+            public String getCatalogVersion() {
+                return "4.22.1";
+            }
+        };
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          simple: "LIMIT ${header.limit ?: 10}"
+                """, older);
+        assertThat(msgs).containsExactly("Line 5: `${header.limit ?: 10}` 
needs Camel 4.24 or later, where the elvis"
+                                         + " operator may be written inside 
${...} (here Camel 4.22.1): write it after the"
+                                         + " function: `${header.limit} ?: 
10`");
+        // without the space the form inside the braces is not offered
+        assertThat(SimpleChecks.elvisInsideFunction("${header.limit ?:10}", 
"4.22.1"))
+                .endsWith("goes after the function: `${header.limit} ?: 10`");
+        assertThat(SimpleChecks.elvisInsideFunction("${header.limit ?: 10}", 
"4.24.0-SNAPSHOT")).isNull();
+        assertThat(SimpleChecks.elvisInsideFunction("${header.limit ?: 10}", 
"4.24.0")).isNull();
+        assertThat(SimpleChecks.elvisInsideFunction("${header.limit ?: 10}", 
"5.0.0")).isNull();
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index a907667ddf90..64afd11ddc2c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -1175,7 +1175,7 @@ class AiPanelTest {
         assertTrue(client.sawCorrectionRequest, "the model must be told what 
is invalid and asked to fix the answer");
         assertEquals(2, client.answers, "the first answer and the corrected 
one");
         assertTrue(last.text().contains("${header.user} ?: 'Guest'"), 
last.text());
-        assertFalse(last.text().contains("${header.user ?: 'Guest'}"), 
last.text());
+        assertFalse(last.text().contains("${header.user ?: Guest}"), 
last.text());
         assertNull(last.note(), "a corrected answer is shown as it is");
         assertEquals(1, last.corrections());
         assertEquals(2, last.requests());
@@ -1198,9 +1198,9 @@ class AiPanelTest {
         AiPanel.ConversationEntry last = 
panel.conversationForTesting().get(panel.conversationForTesting().size() - 1);
         assertEquals(AiRole.ASSISTANT, last.role(), last.text());
         assertEquals(2, client.answers, "one correction turn, never a second");
-        assertTrue(last.text().contains("${header.user ?: 'Guest'}"), 
last.text());
+        assertTrue(last.text().contains("${header.user ?: Guest}"), 
last.text());
         assertNotNull(last.note(), "the user must see that the expression is 
invalid");
-        assertTrue(last.note().startsWith("**Simple check:** `${header.user ?: 
'Guest'}` is invalid: "), last.note());
+        assertTrue(last.note().startsWith("**Simple check:** `${header.user ?: 
Guest}` is invalid: "), last.note());
         assertEquals(1, last.corrections());
     }
 
@@ -1224,7 +1224,7 @@ class AiPanelTest {
         assertFalse(last.byline().contains("correction"), last.byline());
     }
 
-    /** Answers with the operator inside the placeholder, and fixes it when 
asked, like a small local model. */
+    /** Answers with a fallback that is not quoted, and fixes it when asked, 
like a small local model. */
     private static final class CorrectingLlmClient extends LlmClient {
 
         volatile boolean sawCorrectionRequest;
@@ -1253,7 +1253,7 @@ class AiPanelTest {
             boolean correct = firstAnswerIsCorrect || (correction && fixes);
             String text = correct
                     ? "Use `${header.user} ?: 'Guest'` as the expression: the 
elvis operator goes between placeholders."
-                    : "Use `${header.user ?: 'Guest'}` as the expression to 
fall back to Guest.";
+                    : "Use `${header.user ?: Guest}` as the expression to fall 
back to Guest.";
             return new ChatResponse(text, List.of(), "stop", false, 
TokenUsage.EMPTY);
         }
     }

Reply via email to