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 d343f961a389 CAMEL-24714: camel-core-model - complete the @AsPredicate 
marking and have the Simple check read asPredicate from the catalog (#26399)
d343f961a389 is described below

commit d343f961a3896d4a122dacf3edbe305431d30376
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Sep 14 21:16:25 2026 +0200

    CAMEL-24714: camel-core-model - complete the @AsPredicate marking and have 
the Simple check read asPredicate from the catalog (#26399)
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../camel/catalog/models/predicateValidator.json   |   2 +-
 .../java/org/apache/camel/spi/AsPredicate.java     |   6 +-
 .../camel/model/validator/predicateValidator.json  |   2 +-
 .../validator/PredicateValidatorDefinition.java    |   2 +
 .../apache/camel/model/AsPredicateModelTest.java   | 192 +++++++++++++++++++++
 .../dsl/jbang/core/commands/ai/SimpleChecks.java   |  55 ++++--
 .../dsl/jbang/core/commands/ai/YamlLines.java      |  48 +++++-
 .../commands/ai/SourceValidatorSimpleTest.java     |  77 +++++++++
 .../java/org/apache/camel/spi/AsPredicate.java     |   6 +-
 9 files changed, 373 insertions(+), 17 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/predicateValidator.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/predicateValidator.json
index df4898153f81..96812431b3ee 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/predicateValidator.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/predicateValidator.json
@@ -12,7 +12,7 @@
     "output": false
   },
   "properties": {
-    "expression": { "index": 0, "kind": "expression", "displayName": 
"Expression", "group": "common", "required": true, "type": "object", 
"javaType": "org.apache.camel.model.language.ExpressionDefinition", "oneOf": [ 
"constant", "datasonnet", "exchangeProperty", "groovy", "header", "hl7terser", 
"jactl", "java", "joor", "jq", "js", "jsonpath", "language", "method", "mvel", 
"ognl", "python", "python3", "quickjs", "ref", "simple", "spel", "tokenize", 
"variable", "wasm", "xpath", "xquery", " [...]
+    "expression": { "index": 0, "kind": "expression", "displayName": 
"Expression", "group": "common", "required": true, "type": "object", 
"javaType": "org.apache.camel.model.language.ExpressionDefinition", "oneOf": [ 
"constant", "datasonnet", "exchangeProperty", "groovy", "header", "hl7terser", 
"jactl", "java", "joor", "jq", "js", "jsonpath", "language", "method", "mvel", 
"ognl", "python", "python3", "quickjs", "ref", "simple", "spel", "tokenize", 
"variable", "wasm", "xpath", "xquery", " [...]
     "type": { "index": 1, "kind": "attribute", "displayName": "Type", "group": 
"common", "required": false, "type": "string", "javaType": "java.lang.String", 
"deprecated": false, "autowired": false, "secret": false, "description": "The 
data type name to validate. If you specify 'xml:XYZ', the validator is picked 
up when message type is 'xml:XYZ'. If you specify just 'xml', the validator 
matches all xml message types." }
   }
 }
diff --git 
a/core/camel-api/src/generated/java/org/apache/camel/spi/AsPredicate.java 
b/core/camel-api/src/generated/java/org/apache/camel/spi/AsPredicate.java
index 1653ce2ddb85..6075cdc39663 100644
--- a/core/camel-api/src/generated/java/org/apache/camel/spi/AsPredicate.java
+++ b/core/camel-api/src/generated/java/org/apache/camel/spi/AsPredicate.java
@@ -28,7 +28,11 @@ import java.lang.annotation.Target;
  * For example the EIPs which accepts expression/languages may used them as 
either expression or predicate. This
  * annotation is used to mark situation where they should be used as 
predicate. As by default they are used as
  * expression. And example would be the Filter EIP which uses predicate. And 
the transform EIP uses an expression. <br/>
- * Being able to distinguish between these two situations can be of importance 
to tooling.
+ * Being able to distinguish between these two situations can be of importance 
to tooling: the catalog carries the
+ * annotation as <tt>asPredicate</tt> on the option in the model JSON. <br/>
+ * The Loop EIP is the one EIP the annotation cannot describe, as its 
expression is a predicate only when
+ * <tt>doWhile</tt> is enabled (otherwise it is the number of iterations), so 
its expression is not annotated and
+ * tooling checks the <tt>doWhile</tt> option instead.
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Documented
diff --git 
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/validator/predicateValidator.json
 
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/validator/predicateValidator.json
index df4898153f81..96812431b3ee 100644
--- 
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/validator/predicateValidator.json
+++ 
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/validator/predicateValidator.json
@@ -12,7 +12,7 @@
     "output": false
   },
   "properties": {
-    "expression": { "index": 0, "kind": "expression", "displayName": 
"Expression", "group": "common", "required": true, "type": "object", 
"javaType": "org.apache.camel.model.language.ExpressionDefinition", "oneOf": [ 
"constant", "datasonnet", "exchangeProperty", "groovy", "header", "hl7terser", 
"jactl", "java", "joor", "jq", "js", "jsonpath", "language", "method", "mvel", 
"ognl", "python", "python3", "quickjs", "ref", "simple", "spel", "tokenize", 
"variable", "wasm", "xpath", "xquery", " [...]
+    "expression": { "index": 0, "kind": "expression", "displayName": 
"Expression", "group": "common", "required": true, "type": "object", 
"javaType": "org.apache.camel.model.language.ExpressionDefinition", "oneOf": [ 
"constant", "datasonnet", "exchangeProperty", "groovy", "header", "hl7terser", 
"jactl", "java", "joor", "jq", "js", "jsonpath", "language", "method", "mvel", 
"ognl", "python", "python3", "quickjs", "ref", "simple", "spel", "tokenize", 
"variable", "wasm", "xpath", "xquery", " [...]
     "type": { "index": 1, "kind": "attribute", "displayName": "Type", "group": 
"common", "required": false, "type": "string", "javaType": "java.lang.String", 
"deprecated": false, "autowired": false, "secret": false, "description": "The 
data type name to validate. If you specify 'xml:XYZ', the validator is picked 
up when message type is 'xml:XYZ'. If you specify just 'xml', the validator 
matches all xml message types." }
   }
 }
diff --git 
a/core/camel-core-model/src/main/java/org/apache/camel/model/validator/PredicateValidatorDefinition.java
 
b/core/camel-core-model/src/main/java/org/apache/camel/model/validator/PredicateValidatorDefinition.java
index 7da62ae97ca3..1e0550b6d320 100644
--- 
a/core/camel-core-model/src/main/java/org/apache/camel/model/validator/PredicateValidatorDefinition.java
+++ 
b/core/camel-core-model/src/main/java/org/apache/camel/model/validator/PredicateValidatorDefinition.java
@@ -24,6 +24,7 @@ import jakarta.xml.bind.annotation.XmlRootElement;
 import org.apache.camel.Expression;
 import org.apache.camel.model.ExpressionNodeHelper;
 import org.apache.camel.model.language.ExpressionDefinition;
+import org.apache.camel.spi.AsPredicate;
 import org.apache.camel.spi.Metadata;
 
 /**
@@ -35,6 +36,7 @@ import org.apache.camel.spi.Metadata;
 public class PredicateValidatorDefinition extends ValidatorDefinition {
 
     @XmlElementRef
+    @AsPredicate
     @Metadata(required = true, description = "The predicate expression to use 
for validation.")
     private ExpressionDefinition expression;
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/model/AsPredicateModelTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/model/AsPredicateModelTest.java
new file mode 100644
index 000000000000..d363ebde8a68
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/model/AsPredicateModelTest.java
@@ -0,0 +1,192 @@
+/*
+ * 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.model;
+
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import jakarta.xml.bind.annotation.XmlRootElement;
+
+import org.apache.camel.Expression;
+import org.apache.camel.Predicate;
+import org.apache.camel.builder.AggregationStrategies;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.model.language.LanguageExpression;
+import org.apache.camel.model.validator.PredicateValidatorDefinition;
+import org.apache.camel.spi.AsPredicate;
+import org.apache.camel.spi.Language;
+import org.apache.camel.support.builder.ExpressionBuilder;
+import org.apache.camel.support.builder.PredicateBuilder;
+import org.apache.camel.support.scan.DefaultPackageScanClassResolver;
+import org.apache.camel.util.IOHelper;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The expression options the model marks with {@link AsPredicate} 
(asPredicate in the model JSON the catalog ships) are
+ * the ones the reifiers evaluate with createPredicate, so the annotation and 
the runtime cannot drift apart.
+ */
+public class AsPredicateModelTest {
+
+    /**
+     * The one EIP the annotation cannot describe: the loop expression is a 
predicate only when doWhile is enabled.
+     */
+    private static final String LOOP = "loop.expression";
+
+    /** Records for each site (the expression text) whether the reifier asked 
for a predicate or an expression. */
+    private static class SpyLanguage implements Language {
+        private final Set<String> predicates = new TreeSet<>();
+        private final Set<String> expressions = new TreeSet<>();
+
+        @Override
+        public Predicate createPredicate(String expression) {
+            predicates.add(expression);
+            return PredicateBuilder.constant(true);
+        }
+
+        @Override
+        public Expression createExpression(String expression) {
+            expressions.add(expression);
+            return ExpressionBuilder.constantExpression("1");
+        }
+    }
+
+    /** A spy expression at the given site (model.option) that binds to the 
predicate overloads of the DSL. */
+    private static Predicate site(String site) {
+        return new LanguageExpression("spy", site);
+    }
+
+    /** A spy expression at the given site for the options set as expression 
sub element (handled, retryWhile). */
+    private static ExpressionSubElementDefinition subElement(String site) {
+        return new ExpressionSubElementDefinition(new 
LanguageExpression("spy", site));
+    }
+
+    /** A spy expression at the given site that binds to the expression 
overloads of the DSL. */
+    private static Expression expressionSite(String site) {
+        return new LanguageExpression("spy", site);
+    }
+
+    @Test
+    public void testAsPredicateMatchesReifier() throws Exception {
+        Map<String, String> marked = findAsPredicateOptions();
+        // the expression options marked as predicate; the element options 
(choice.when, doCatch.onWhen) hold a when
+        // or onWhen node whose own expression is marked
+        Set<String> expected = new TreeSet<>();
+        Set<String> holders = new TreeSet<>();
+        marked.forEach((site, kind) -> ("expression".equals(kind) ? expected : 
holders).add(site));
+        assertTrue(expected.size() >= 9, "Expected at least 9 predicate 
expression options, was: " + expected);
+
+        SpyLanguage spy = new SpyLanguage();
+        try (DefaultCamelContext context = new DefaultCamelContext()) {
+            context.getRegistry().bind("spy", spy);
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    // the fluent handled(Predicate), retryWhile(Predicate) 
and completionPredicate(Predicate) keep
+                    // the predicate as is (no language to spy on) so those 
are set as expression sub elements
+                    OnExceptionDefinition handled = 
onException(Exception.class).onWhen(site("onException.onWhen"));
+                    handled.setHandled(subElement("onException.handled"));
+                    
handled.setRetryWhile(subElement("onException.retryWhile"));
+                    
onException(IllegalStateException.class).setContinued(subElement("onException.continued"));
+                    
onCompletion().onWhen(site("onWhen.expression")).to("mock:done");
+                    
intercept().when(site("intercept.onWhen")).log("intercept");
+                    
interceptFrom().when(site("interceptFrom.onWhen")).log("interceptFrom");
+                    
interceptSendToEndpoint("mock:*").when(site("interceptSendToEndpoint.onWhen"))
+                            .log("interceptSendToEndpoint");
+
+                    AggregateDefinition aggregate
+                            = from("direct:start")
+                            
.filter(site("filter.expression")).log("filter").end()
+                            
.choice().when(site("when.expression")).log("when").end()
+                            .validate(site("validate.expression"))
+                            .loopDoWhile(site(LOOP)).log("loop").end()
+                            
.doTry().log("try").doCatch(Exception.class).onWhen(site("doCatch.onWhen")).log("catch")
+                            .endDoTry().end()
+                            .aggregate(constant("key"), 
AggregationStrategies.useLatest());
+                    
aggregate.setCompletionPredicate(subElement("aggregate.completionPredicate"));
+                    aggregate.log("aggregate").end()
+                            // expression sites, for contrast
+                            
.split(expressionSite("split.expression")).log("split").end()
+                            .transform(expressionSite("transform.expression"))
+                            .setBody(expressionSite("setBody.expression"))
+                            .to("mock:result");
+                }
+            });
+            PredicateValidatorDefinition validator = new 
PredicateValidatorDefinition();
+            validator.setType("spy");
+            validator.setExpression(new LanguageExpression("spy", 
"predicateValidator.expression"));
+            context.registerValidator(validator);
+            context.start();
+        }
+
+        // every marked option was evaluated as a predicate, and only as a 
predicate
+        for (String site : expected) {
+            assertTrue(spy.predicates.contains(site), site + " is marked 
asPredicate but the reifier did not create a"
+                                                      + " predicate for it (is 
the test missing the site?)");
+            assertFalse(spy.expressions.contains(site), site + " is marked 
asPredicate but is evaluated as expression");
+        }
+        // every site evaluated as a predicate is marked, or holds a node 
whose expression is
+        for (String site : spy.predicates) {
+            if (LOOP.equals(site)) {
+                continue;
+            }
+            assertTrue(expected.contains(site) || holders.contains(site),
+                    site + " is evaluated as predicate but not marked with 
@AsPredicate");
+        }
+        // the contrast sites are expressions and not marked
+        Set<String> contrast = Set.of("split.expression", 
"transform.expression", "setBody.expression");
+        assertTrue(spy.expressions.containsAll(contrast), "Expression sites 
not evaluated: " + spy.expressions);
+        assertTrue(Collections.disjoint(spy.predicates, contrast), "Expression 
sites evaluated as predicate");
+        assertFalse(marked.containsKey(LOOP), "loop is a predicate only with 
doWhile and cannot be marked");
+        assertEquals("expression", 
marked.get("predicateValidator.expression"));
+    }
+
+    /** The options with asPredicate in the model JSON, as model.option to the 
kind of the option. */
+    private static Map<String, String> findAsPredicateOptions() throws 
Exception {
+        Map<String, String> answer = new TreeMap<>();
+        DefaultPackageScanClassResolver resolver = new 
DefaultPackageScanClassResolver();
+        resolver.start();
+        for (Class<?> clazz : resolver.findAnnotated(XmlRootElement.class, 
Constants.JAXB_CONTEXT_PACKAGES.split(":"))) {
+            String name = clazz.getAnnotation(XmlRootElement.class).name();
+            String path = "META-INF/" + clazz.getPackageName().replace('.', 
'/') + "/" + name + ".json";
+            InputStream is = 
AsPredicateModelTest.class.getClassLoader().getResourceAsStream(path);
+            if (is == null) {
+                continue;
+            }
+            JsonObject json = (JsonObject) 
Jsoner.deserialize(IOHelper.loadText(is));
+            JsonObject properties = json.getMap("properties");
+            for (String option : properties.keySet()) {
+                JsonObject p = properties.getMap(option);
+                if (p.getBooleanOrDefault("asPredicate", false)) {
+                    answer.put(name + "." + option, p.getString("kind"));
+                }
+            }
+        }
+        return answer;
+    }
+
+}
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 c6ed0761cd88..0389cde96a97 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
@@ -18,16 +18,19 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Set;
 
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.LanguageValidationResult;
 import org.apache.camel.spi.SimpleLanguageFunctionFactory;
+import org.apache.camel.tooling.model.EipModel;
+import org.apache.camel.util.StringHelper;
 
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.countLeadingSpaces;
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.extractEipFromLine;
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.extractYamlValue;
 import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.findParentEip;
+import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.findParentEipLine;
+import static 
org.apache.camel.dsl.jbang.core.commands.ai.YamlLines.findSiblingValue;
 
 /**
  * The Simple language check of {@link SourceValidator}: every simple: value 
and log message parsed by the catalog, as a
@@ -38,12 +41,6 @@ final class SimpleChecks {
     private SimpleChecks() {
     }
 
-    static final Set<String> PREDICATE_EIPS = Set.of(
-            "filter", "when", "validate", "onWhen", "on-when",
-            "handled", "continued", "retryWhile", "retry-while",
-            "completionPredicate", "completion-predicate",
-            "completion", "loopDoWhile", "loop-do-while");
-
     /** The simple expressions of a YAML route checked against the catalog, as 
predicate where the EIP expects one. */
     public static List<String> validateYamlSimple(String content, CamelCatalog 
catalog) {
         List<String> errors = new ArrayList<>();
@@ -103,11 +100,7 @@ final class SimpleChecks {
             }
 
             // Determine predicate vs expression context
-            boolean predicate = false;
-            if (!isLogMessage) {
-                String parentEip = findParentEip(lines, i, lineIndent);
-                predicate = parentEip != null && 
PREDICATE_EIPS.contains(parentEip);
-            }
+            boolean predicate = !isLogMessage && isPredicate(catalog, lines, 
i, lineIndent);
 
             try {
                 LanguageValidationResult result = predicate
@@ -137,6 +130,44 @@ final class SimpleChecks {
                         && error.contains("service could be found in the 
classpath");
     }
 
+    /**
+     * Whether the EIP the line is nested in evaluates the simple text as a 
predicate: the expression the model marks
+     * with @AsPredicate, which the catalog carries as asPredicate on the 
option. The parent line is either the EIP
+     * itself (filter, when, validate: its expression option) or an expression 
option of the EIP above it (the handled
+     * of onException, the completionPredicate of aggregate). loop is the one 
EIP a flag on the option cannot describe:
+     * its expression is a predicate only when doWhile is true.
+     */
+    static boolean isPredicate(CamelCatalog catalog, String[] lines, int 
lineIdx, int lineIndent) {
+        int parentIdx = findParentEipLine(lines, lineIdx, lineIndent);
+        if (parentIdx < 0) {
+            return false;
+        }
+        String parent = 
StringHelper.dashToCamelCase(extractEipFromLine(lines[parentIdx].trim()));
+        if (parent == null) {
+            return false;
+        }
+        if ("loop".equals(parent)) {
+            return "true".equals(findSiblingValue(lines, lineIdx, lineIndent, 
"doWhile"));
+        }
+        EipModel eip = catalog.eipModel(parent);
+        if (eip != null) {
+            return isPredicateOption(eip, "expression");
+        }
+        // not an EIP but an expression option of the EIP above it
+        int ownerIdx = findParentEipLine(lines, parentIdx, 
countLeadingSpaces(lines[parentIdx]));
+        if (ownerIdx < 0) {
+            return false;
+        }
+        String ownerName = 
StringHelper.dashToCamelCase(extractEipFromLine(lines[ownerIdx].trim()));
+        EipModel owner = ownerName != null ? catalog.eipModel(ownerName) : 
null;
+        return owner != null && isPredicateOption(owner, parent);
+    }
+
+    private static boolean isPredicateOption(EipModel eip, String option) {
+        return eip.getOptions().stream()
+                .anyMatch(o -> option.equals(o.getName()) && 
"expression".equals(o.getKind()) && o.isAsPredicate());
+    }
+
     /**
      * ${size} or ${count} written inside an aggregate: the parser's 
did-you-mean (${length}) is about the function,
      * what the author wants is the number of aggregated messages, an exchange 
property.
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
index 9c95ff6c80c2..0220b2ee7b86 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/YamlLines.java
@@ -18,6 +18,8 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 
 import java.util.regex.Pattern;
 
+import org.apache.camel.util.StringHelper;
+
 /**
  * Line-level helpers over a YAML source shared by the checks of {@link 
SourceValidator}: the enclosing EIP of a line,
  * the value of a key, quotes, indentation.
@@ -45,6 +47,12 @@ final class YamlLines {
     }
 
     static String findParentEip(String[] lines, int lineIdx, int lineIndent) {
+        int parent = findParentEipLine(lines, lineIdx, lineIndent);
+        return parent >= 0 ? extractEipFromLine(lines[parent].trim()) : null;
+    }
+
+    /** The index of the line with the EIP the line is nested in, or -1 (as 
{@link #findParentEip} but the line). */
+    static int findParentEipLine(String[] lines, int lineIdx, int lineIndent) {
         int indent = lineIndent;
         for (int j = lineIdx - 1; j >= 0; j--) {
             String prev = lines[j];
@@ -59,12 +67,50 @@ final class YamlLines {
                     indent = prevIndent;
                     continue;
                 }
-                return eip;
+                return j;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * The value of the given option among the siblings of the line (the 
entries at the same indent of the same block),
+     * or null when there is none. The option is matched in camelCase and 
kebab-case.
+     */
+    static String findSiblingValue(String[] lines, int lineIdx, int 
lineIndent, String option) {
+        for (int j = lineIdx - 1; j >= 0; j--) {
+            String value = siblingValue(lines[j], lineIndent, option);
+            if (value != null) {
+                return value;
+            }
+            if (!lines[j].isBlank() && countLeadingSpaces(lines[j]) < 
lineIndent) {
+                break;
+            }
+        }
+        for (int j = lineIdx + 1; j < lines.length; j++) {
+            String value = siblingValue(lines[j], lineIndent, option);
+            if (value != null) {
+                return value;
+            }
+            if (!lines[j].isBlank() && countLeadingSpaces(lines[j]) < 
lineIndent) {
+                break;
             }
         }
         return null;
     }
 
+    private static String siblingValue(String line, int indent, String option) 
{
+        if (line.isBlank() || countLeadingSpaces(line) != indent) {
+            return null;
+        }
+        String trimmed = line.trim();
+        String key = extractEipFromLine(trimmed);
+        if (key == null || !option.equals(StringHelper.dashToCamelCase(key))) {
+            return null;
+        }
+        return unquote(trimmed.substring(trimmed.indexOf(':') + 1).trim());
+    }
+
     static String extractEipFromLine(String trimmed) {
         if (trimmed.startsWith("- ")) {
             trimmed = trimmed.substring(2).trim();
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 8fb32cb123af..4661b775abfc 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
@@ -254,4 +254,81 @@ class SourceValidatorSimpleTest {
                 """, catalog);
         assertThat(msgs).isEmpty();
     }
+
+    @Test
+    void thePredicateEipsComeFromTheCatalog() {
+        // the same text is a predicate (error: no ${}) where the catalog 
marks the option asPredicate, and a
+        // literal expression elsewhere
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - filter:
+                          simple: "body contains 'critical'"
+                      - setBody:
+                          simple: "body contains 'critical'"
+                """, catalog);
+        assertThat(msgs).hasSize(1);
+        assertThat(msgs.get(0)).startsWith("Line 5:").contains("Unexpected 
token body");
+    }
+
+    @Test
+    void anExpressionOptionOfTheEipAboveIsAPredicateWhenTheCatalogSaysSo() {
+        // handled, continued, retryWhile of onException and 
completionPredicate of aggregate are not EIPs but
+        // expression options the catalog marks asPredicate; onWhen is its own 
model
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - onException:
+                    exception: java.lang.Exception
+                    handled:
+                      simple: "body contains 'a'"
+                    retry-while:
+                      simple: "body contains 'b'"
+                    onWhen:
+                      simple: "body contains 'c'"
+                    steps:
+                      - log: "x"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - aggregate:
+                          aggregationStrategy: myStrategy
+                          correlationExpression:
+                            simple: "body contains 'd'"
+                          completionPredicate:
+                            simple: "body contains 'e'"
+                          steps:
+                            - log: "y"
+                """, catalog);
+        assertThat(msgs).hasSize(4);
+        assertThat(msgs.get(0)).startsWith("Line 4:").contains("Unexpected 
token body");
+        assertThat(msgs.get(1)).startsWith("Line 6:").contains("Unexpected 
token body");
+        assertThat(msgs.get(2)).startsWith("Line 8:").contains("Unexpected 
token body");
+        assertThat(msgs.get(3)).startsWith("Line 19:").contains("Unexpected 
token body");
+    }
+
+    @Test
+    void theLoopExpressionIsAPredicateOnlyWithDoWhile() {
+        List<String> msgs = SourceValidator.validateYamlSimple("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - loop:
+                          simple: "body contains 'a'"
+                          steps:
+                            - log: "x"
+                      - loop:
+                          doWhile: true
+                          simple: "body contains 'b'"
+                          steps:
+                            - log: "y"
+                      - loop:
+                          simple: "body contains 'c'"
+                          do-while: "true"
+                          steps:
+                            - log: "z"
+                """, catalog);
+        assertThat(msgs).hasSize(2);
+        assertThat(msgs.get(0)).startsWith("Line 10:").contains("Unexpected 
token body");
+        assertThat(msgs.get(1)).startsWith("Line 14:").contains("Unexpected 
token body");
+    }
 }
diff --git 
a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/AsPredicate.java 
b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/AsPredicate.java
index 1653ce2ddb85..6075cdc39663 100644
--- 
a/tooling/spi-annotations/src/main/java/org/apache/camel/spi/AsPredicate.java
+++ 
b/tooling/spi-annotations/src/main/java/org/apache/camel/spi/AsPredicate.java
@@ -28,7 +28,11 @@ import java.lang.annotation.Target;
  * For example the EIPs which accepts expression/languages may used them as 
either expression or predicate. This
  * annotation is used to mark situation where they should be used as 
predicate. As by default they are used as
  * expression. And example would be the Filter EIP which uses predicate. And 
the transform EIP uses an expression. <br/>
- * Being able to distinguish between these two situations can be of importance 
to tooling.
+ * Being able to distinguish between these two situations can be of importance 
to tooling: the catalog carries the
+ * annotation as <tt>asPredicate</tt> on the option in the model JSON. <br/>
+ * The Loop EIP is the one EIP the annotation cannot describe, as its 
expression is a predicate only when
+ * <tt>doWhile</tt> is enabled (otherwise it is the number of iterations), so 
its expression is not annotated and
+ * tooling checks the <tt>doWhile</tt> option instead.
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Documented

Reply via email to