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 09790c52eeec CAMEL-26874: camel-jbang - AI tools: the refusal of two 
EIPs in one step gives the line to write (#29700)
09790c52eeec is described below

commit 09790c52eeece724bdf706efd7009f615f04bd9e
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Nov 12 13:09:42 2026 +0100

    CAMEL-26874: camel-jbang - AI tools: the refusal of two EIPs in one step 
gives the line to write (#29700)
    
    A step with two EIPs got "start it as its own item, - log:" and no line to
    write, so the write tool could not fix it when the same content came a third
    time (four step runs in r32 of the local-model benchmark, sent 5 to 20 
times).
    The refusal now ends with the line to write. The validator also told 
doCatch:
    at the column of doTry: to start its own item, but doCatch: and doFinally: 
are
    options of doTry: they now get the option hint and its lines, without the 
error
    of doCatch: read as an EIP. filter: of claimCheck is the option only as a 
text.
    
    
    Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/core/commands/ai/IndentLines.java    |  83 ++++++--
 .../ai/AuthoringToolsRefusalFixesTest.java         |  39 ++++
 .../jbang/core/commands/ai/IndentLinesTest.java    | 208 +++++++++++++++++++++
 .../camel/dsl/yaml/validator/SchemaHints.java      |  65 ++++++-
 .../camel/dsl/yaml/validator/YamlValidator.java    |  39 +++-
 .../camel/dsl/yaml/validator/SchemaHintsTest.java  |  26 +++
 .../dsl/yaml/validator/StructureHintTest.java      | 104 +++++++++++
 7 files changed, 532 insertions(+), 32 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
index 91ca88904931..c1225b9f3b62 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLines.java
@@ -26,12 +26,24 @@ import java.util.regex.Pattern;
  * validator says which way to indent them (CAMEL-25529), and a model that 
does not see it in its own lines writes the
  * same YAML again (sixteen times in a row in the local-model benchmark); the 
lines as they must be, it copies
  * (CAMEL-25556).
+ * <p>
+ * The same for a second EIP in a step (log: below - setBody:, at the column 
of setBody:): the line to write is the line
+ * of the second EIP as its own - item. In r32 of the local-model benchmark, 
the refusal said only "start it as its own
+ * item, - setBody:", and four step runs sent the same content 5, 14, 14 and 
20 times: with no line to write, the write
+ * tool could not make the fix when the same content came a third time ({@link 
RefusalFixes}, CAMEL-26874).
  */
 final class IndentLines {
 
     /** The validator's message for it: "Line 13: must have at most 1 
properties (steps: lines up with split:, ...". */
     private static final Pattern OPTION_AT_EIP = Pattern.compile(
             "^Line (\\d+): must have at most 1 properties \\((\\w+): lines up 
with (\\w+):, so it is read as a second key");
+    /**
+     * The validator's message for a second EIP, when it is the only key to 
move: "Line 44: must have at most 1
+     * properties (log: is another EIP: start it as its own item, - log:)". 
With several keys the hint has a part for
+     * each, joined with "; ", and gets no lines.
+     */
+    private static final Pattern ANOTHER_EIP = Pattern.compile(
+            "^Line (\\d+): must have at most 1 properties \\((\\w+): is 
another EIP: start it as its own item, - \\2:\\)$");
 
     /** How many lines are shown at most; a longer block is said in words 
after them. */
     private static final int MAX_LINES = 12;
@@ -39,7 +51,10 @@ final class IndentLines {
     private IndentLines() {
     }
 
-    /** The messages, with the lines to write after each message of an option 
at the column of its EIP. */
+    /**
+     * The messages, with the lines to write after each message of an option 
at the column of its EIP, and the line to
+     * write after each message of a second EIP in a step.
+     */
     static List<String> withLines(List<String> messages, String content) {
         if (messages.isEmpty() || content == null) {
             return messages;
@@ -47,8 +62,13 @@ final class IndentLines {
         String[] lines = content.split("\n", -1);
         List<String> answer = new ArrayList<>(messages.size());
         for (String message : messages) {
+            String written = null;
             Matcher m = OPTION_AT_EIP.matcher(message);
-            String written = m.find() ? linesToWrite(lines, 
Integer.parseInt(m.group(1)) - 1, m.group(2)) : null;
+            if (m.find()) {
+                written = linesToWrite(lines, Integer.parseInt(m.group(1)) - 
1, m.group(2));
+            } else if ((m = ANOTHER_EIP.matcher(message)).find()) {
+                written = itemToWrite(lines, Integer.parseInt(m.group(1)) - 1, 
m.group(2));
+            }
             answer.add(written != null ? message + written : message);
         }
         return answer;
@@ -56,13 +76,50 @@ final class IndentLines {
 
     /** " Write lines 15-17 as:" and the lines two spaces further right; null 
when the key is not found. */
     static String linesToWrite(String[] lines, int itemIndex, String key) {
-        if (itemIndex < 0 || itemIndex >= lines.length) {
+        int j = keyLine(lines, itemIndex, key);
+        if (j < 0) {
+            return null;
+        }
+        int end = blockEnd(lines, j);
+        StringBuilder sb = new StringBuilder(" Write lines ").append(j + 
1).append('-').append(end).append(" as:");
+        int shown = 0;
+        for (int k = j; k < end; k++) {
+            if (shown == MAX_LINES) {
+                sb.append("\n(and the ").append(end - k).append(" lines after 
them, two spaces further right too)");
+                break;
+            }
+            sb.append('\n').append(lines[k].isBlank() ? "" : "  " + lines[k]);
+            shown++;
+        }
+        return sb.toString();
+    }
+
+    /**
+     * " Write line 46 as:" and the line of the key as a - item of its own: 
the dash in the column of the dash of the
+     * item, in place of the two spaces before the key. The key keeps its 
column, so the lines under it stay as they
+     * are. Null when the key is not found.
+     */
+    static String itemToWrite(String[] lines, int itemIndex, String key) {
+        int j = keyLine(lines, itemIndex, key);
+        if (j < 0) {
             return null;
         }
+        int dash = indent(lines[itemIndex]);
+        return " Write line " + (j + 1) + " as:\n" + lines[j].substring(0, 
dash) + "- " + lines[j].substring(dash + 2);
+    }
+
+    /**
+     * The index of the line of the key below the - item at itemIndex, in the 
column of the keys of the item (two more
+     * than its dash); -1 when the line is not a - item or the key is not 
found in it.
+     */
+    private static int keyLine(String[] lines, int itemIndex, String key) {
+        if (itemIndex < 0 || itemIndex >= lines.length) {
+            return -1;
+        }
         String item = lines[itemIndex];
         int dash = indent(item);
         if (!item.startsWith("- ", dash)) {
-            return null;
+            return -1;
         }
         int keyColumn = dash + 2;
         for (int j = itemIndex + 1; j < lines.length; j++) {
@@ -72,25 +129,13 @@ final class IndentLines {
             }
             int i = indent(l);
             if (i < keyColumn) {
-                return null; // past the item
+                return -1; // past the item
             }
             if (i == keyColumn && l.startsWith(key + ":", i)) {
-                int end = blockEnd(lines, j);
-                StringBuilder sb = new StringBuilder(" Write lines ").append(j 
+ 1).append('-').append(end)
-                        .append(" as:");
-                int shown = 0;
-                for (int k = j; k < end; k++) {
-                    if (shown == MAX_LINES) {
-                        sb.append("\n(and the ").append(end - k).append(" 
lines after them, two spaces further right too)");
-                        break;
-                    }
-                    sb.append('\n').append(lines[k].isBlank() ? "" : "  " + 
lines[k]);
-                    shown++;
-                }
-                return sb.toString();
+                return j;
             }
         }
-        return null;
+        return -1;
     }
 
     /**
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
index 307580baada0..e5ffe3e55665 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
@@ -170,4 +170,43 @@ class AuthoringToolsRefusalFixesTest {
         assertThat(Files.readString(dir.resolve(FILE)))
                 .isEqualTo(content.replace("')][]\"", 
"')]\"").replace("constant:\n", "simple:\n"));
     }
+
+    @Test
+    void aSecondEipInAStepIsWrittenAsTheRefusalsSaid(@TempDir Path dir) throws 
IOException {
+        // CAMEL-26874, r32n-2 contracts-rest-code-first step 2 (shortened): 
setBody: in the column of the keys of
+        // - setHeader:, sent 20 times as it was; the refusal said to start it 
as its own item, with no line to write
+        String content = """
+                - route:
+                    id: get-order-by-id
+                    from:
+                      uri: direct:get-order-by-id
+                      steps:
+                        - choice:
+                            when:
+                              - simple: "${body} == null"
+                                steps:
+                                  - setHeader:
+                                      name: CamelHttpResponseCode
+                                      constant: "404"
+                                    setBody:
+                                      constant: '{"error": "unknown order"}'
+                            otherwise:
+                              steps:
+                                - log: "order ${body}"
+                """;
+        for (int i = 1; i <= 2; i++) {
+            JsonObject refused = write(dir, content);
+            
assertThat(refused.getString("status")).as(String.valueOf(refused)).isEqualTo("invalid");
+            assertThat((JsonArray) 
refused.get("errors")).singleElement().asString()
+                    .startsWith("Line 10: ")
+                    .endsWith("(setBody: is another EIP: start it as its own 
item, - setBody:) Write line 13 as:\n"
+                              + "                  - setBody:");
+        }
+        JsonObject third = write(dir, content);
+        
assertThat(third.getString("status")).as(String.valueOf(third)).isEqualTo("created");
+        assertThat((JsonArray) third.get("repaired")).containsExactly("line 
13: written as the refusals said");
+        assertThat(third.getString("message"))
+                .contains("The same content came a third time, so the write 
made the fixes the refusals gave");
+        
assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(content.replace("    
setBody:", "  - setBody:"));
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLinesTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLinesTest.java
new file mode 100644
index 000000000000..85a887893327
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/IndentLinesTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26874: the refusal of a second EIP in a step ends with the line to 
write, the EIP as its own - item; and
+ * doCatch: at the column of doTry: is an option of doTry, with the lines to 
write two spaces further right.
+ */
+class IndentLinesTest {
+
+    private static CamelCatalog catalog;
+
+    @BeforeAll
+    static void loadCatalog() {
+        catalog = new DefaultCamelCatalog();
+    }
+
+    private static List<String> validate(String yaml) {
+        return SourceValidator.validate("orders.camel.yaml", yaml, catalog, 
null);
+    }
+
+    @Test
+    void aLogBelowTheEipGetsTheLineToWrite() {
+        // r32n-7 route-enrich-and-dedupe step 2 (shortened): "Line 44: ... 
(log: is another EIP: ...)", sent 5 times
+        String yaml = """
+                - route:
+                    id: incoming-orders
+                    from:
+                      uri: direct:orders
+                      steps:
+                        - setBody:
+                            simple: "${body}"
+                          log: "order ${body}"
+                        - to: log:orders
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .startsWith("Line 6: must have at most 1 properties")
+                .endsWith("(log: is another EIP: start it as its own item, - 
log:) Write line 8 as:\n"
+                          + "        - log: \"order ${body}\"");
+    }
+
+    @Test
+    void aChoiceWithItsLinesGetsTheLineOfItsKey() {
+        // the lines under choice: keep their columns: choice: is in the same 
column as its own - item
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:get-order
+                      steps:
+                        - setVariable:
+                            name: order
+                            simple: "${body}"
+                          choice:
+                            when:
+                              - simple: "${variable.order} == null"
+                                steps:
+                                  - setHeader:
+                                      name: CamelHttpResponseCode
+                                      constant: "404"
+                            otherwise:
+                              steps:
+                                - log: "order ${variable.order}"
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .startsWith("Line 5: ")
+                .endsWith("(choice: is another EIP: start it as its own item, 
- choice:) Write line 8 as:\n"
+                          + "        - choice:");
+    }
+
+    @Test
+    void aSetBodyBelowTheEipGetsTheLineToWrite() {
+        // r32n-2 contracts-rest-code-first steps 1 and 2: "Line 52: ... 
(setBody: is another EIP: ...)", sent 14 and
+        // 20 times
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:get-order-by-id
+                      steps:
+                        - setHeader:
+                            name: CamelHttpResponseCode
+                            constant: "404"
+                          setBody:
+                            constant: '{"error": "unknown order"}'
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .startsWith("Line 5: ")
+                .endsWith("(setBody: is another EIP: start it as its own item, 
- setBody:) Write line 8 as:\n"
+                          + "        - setBody:");
+    }
+
+    @Test
+    void twoOtherEipsInAStepGetNoLines() {
+        // one line to write per refusal: the hint with a part for each key is 
left as it is
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:orders
+                      steps:
+                        - setBody:
+                            simple: "${body}"
+                          log: "order ${body}"
+                          to: log:orders
+                """;
+        assertThat(validate(yaml)).singleElement().asString()
+                .contains("log: is another EIP: start it as its own item, - 
log:; to: is another EIP")
+                .doesNotContain("Write line");
+    }
+
+    @Test
+    void aKeyThatIsNotInTheItemLeavesTheMessage() {
+        String message
+                = "Line 1: must have at most 1 properties (log: is another 
EIP: start it as its own item, - log:)";
+        // log: further right is in the value of setBody:, not a key of the 
item
+        String content = """
+                - setBody:
+                    simple: "${body}"
+                    log: "order ${body}"
+                - to: log:orders
+                """;
+        assertThat(IndentLines.withLines(List.of(message), 
content)).containsExactly(message);
+        // the line of the message is not a - item
+        assertThat(IndentLines.itemToWrite(content.split("\n"), 1, 
"log")).isNull();
+    }
+
+    /** r32n-4 route-batch-file step 2 (shortened): told that doCatch: is 
another EIP, sent 19 times as it was. */
+    private static final String DO_CATCH_AT_DO_TRY = """
+            - route:
+                from:
+                  uri: direct:batch
+                  steps:
+                    - doTry:
+                        steps:
+                          - to: file:out
+                      doCatch:
+                        - exception:
+                            - java.io.IOException
+                          steps:
+                            - log: "failed ${exception.message}"
+            """;
+
+    @Test
+    void doCatchAtTheColumnOfDoTryGetsTheLinesOfAnOption() {
+        assertThat(validate(DO_CATCH_AT_DO_TRY)).singleElement().asString()
+                .startsWith("Line 5: ")
+                .contains("doCatch: lines up with doTry:, so it is read as a 
second key of the - item; it is an option"
+                          + " of doTry:")
+                .doesNotContain("another EIP")
+                .endsWith(" Write lines 8-12 as:\n"
+                          + "            doCatch:\n"
+                          + "              - exception:\n"
+                          + "                  - java.io.IOException\n"
+                          + "                steps:\n"
+                          + "                  - log: \"failed 
${exception.message}\"");
+    }
+
+    @Test
+    void theWriteIndentsDoCatchUnderDoTry(@TempDir Path dir) throws 
IOException {
+        // an option at the column of its EIP has one reading, so the write 
repairs it (CAMEL-25532)
+        JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, 
"batch.camel.yaml", DO_CATCH_AT_DO_TRY,
+                true);
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("created");
+        assertThat((JsonArray) result.get("repaired"))
+                .containsExactly("line 5: doCatch: indented two spaces, as an 
option of doTry:");
+        
assertThat(Files.readString(dir.resolve("batch.camel.yaml"))).isEqualTo("""
+                - route:
+                    from:
+                      uri: direct:batch
+                      steps:
+                        - doTry:
+                            steps:
+                              - to: file:out
+                            doCatch:
+                              - exception:
+                                  - java.io.IOException
+                                steps:
+                                  - log: "failed ${exception.message}"
+                """);
+    }
+}
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
index 1d2151a98b46..e3ef40249540 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -524,16 +524,18 @@ final class SchemaHints {
             if (k.equals(eip)) {
                 continue;
             }
-            if (steps.contains(k)) {
-                parts.add(k + ": is another EIP: start it as its own item, - " 
+ k + ":");
-            } else if (options.contains(k) && sibling != null) {
+            // an option first: doCatch: is an option of doTry and an EIP too
+            boolean option = isOptionOf(eip, k, node.get(k), options, 
m.validator());
+            if (option && sibling != null) {
                 parts.add(k + ": lines up with " + eip + ":, so it is read as 
a second key of the - item; it is an"
                           + " option of " + eip + ": indent " + k + ": and the 
lines under it two spaces more, so " + k
                           + ": lines up with " + sibling + ":");
-            } else if (options.contains(k)) {
+            } else if (option) {
                 parts.add(k + ": lines up with " + eip + ":, so it is read as 
a second key of the - item; it is an"
                           + " option of " + eip + ": write the options of " + 
eip + ": on the lines under it, two"
                           + " spaces further right than " + eip + ":, with " + 
k + ": among them");
+            } else if (steps.contains(k)) {
+                parts.add(k + ": is another EIP: start it as its own item, - " 
+ k + ":");
             } else {
                 // neither: say what a step is, without guessing where the key 
belongs
                 return generic;
@@ -542,6 +544,24 @@ final class SchemaHints {
         return parts.isEmpty() ? generic : String.join("; ", parts);
     }
 
+    /**
+     * Whether a second key of a step is an option of the step's EIP, which 
goes under the EIP. Three options have the
+     * name of an EIP too: doCatch: and doFinally: of doTry, and filter: of 
claimCheck. Such a key is the option: in r32
+     * of the local-model benchmark, told that doCatch: at the column of 
doTry: is another EIP to start as its own -
+     * item, a model sent the same lines 19 times (CAMEL-26874). The exception 
is an option that takes a plain value, as
+     * filter: takes a text: with a mapping or a list, the key is the EIP.
+     */
+    private static boolean isOptionOf(String eip, String k, JsonNode value, 
Set<String> options, YamlValidator validator) {
+        if (!options.contains(k)) {
+            return false;
+        }
+        if (!validator.stepNames().contains(k) || value == null || 
!value.isContainerNode()) {
+            return true;
+        }
+        String type = validator.optionType(eip, k);
+        return type == null || "array".equals(type) || "object".equals(type);
+    }
+
     /**
      * The options of the step's EIP that are written as second keys of the 
step (at the column of the EIP), when the
      * step has an EIP; empty otherwise.
@@ -560,7 +580,7 @@ final class SchemaHints {
         Set<String> options = validator.optionsOf(eip);
         Set<String> answer = new LinkedHashSet<>();
         for (String k : keys) {
-            if (!k.equals(eip) && !steps.contains(k) && options.contains(k)) {
+            if (!k.equals(eip) && isOptionOf(eip, k, node.get(k), options, 
validator)) {
                 answer.add(k);
             }
         }
@@ -570,16 +590,23 @@ final class SchemaHints {
     /**
      * An option of a step's EIP at the column of the EIP fails twice: "at 
most 1 properties" (the hint above says where
      * it goes) and "property 'steps' is not defined" in the oneOf branch of 
the step, whose hint says to move the items
-     * up a level, the opposite. The second one is dropped.
+     * up a level, the opposite. The second one is dropped. An option with the 
name of an EIP fails as that EIP instead,
+     * at its own line: doCatch: with its list of catches gets "array found, 
object expected", as a - doCatch: step is a
+     * mapping. When the value has the shape of the option, that error is 
dropped too (CAMEL-26874).
      */
     static List<Error> dropMisplacedOptionRepeats(List<Error> errors, 
YamlValidator validator) {
         Set<String> repeats = new HashSet<>();
+        Set<String> readAsEip = new HashSet<>();
         for (Error e : errors) {
             if ("maxProperties".equals(e.getKeyword())) {
                 String at = String.valueOf(e.getInstanceLocation());
                 if (at.matches(".*/steps/\\d+")) {
-                    for (String k : misplacedOptions(e.getInstanceNode(), 
validator)) {
+                    JsonNode node = e.getInstanceNode();
+                    for (String k : misplacedOptions(node, validator)) {
                         repeats.add(at + " " + k);
+                        if (validator.stepNames().contains(k) && 
hasShapeOfOption(node, k, validator)) {
+                            readAsEip.add(at + "/" + k);
+                        }
                     }
                 }
             }
@@ -590,9 +617,33 @@ final class SchemaHints {
         List<Error> answer = new ArrayList<>(errors);
         answer.removeIf(e -> "additionalProperties".equals(e.getKeyword()) && 
e.getMessage() != null
                 && repeats.contains(e.getInstanceLocation() + " " + 
between(e.getMessage(), "property '", "'")));
+        answer.removeIf(e -> "type".equals(e.getKeyword())
+                && 
readAsEip.contains(String.valueOf(e.getInstanceLocation())));
         return answer;
     }
 
+    /**
+     * Whether the value of the key k of a step has the shape of the option k 
of the step's EIP: a list for an array, a
+     * mapping for an object, a plain value for the others. A doFinally: with 
a list is wrong as an option too, so its
+     * error stays.
+     */
+    private static boolean hasShapeOfOption(JsonNode node, String k, 
YamlValidator validator) {
+        Set<String> steps = validator.stepNames();
+        List<String> keys = new ArrayList<>();
+        node.fieldNames().forEachRemaining(keys::add);
+        String eip = 
keys.stream().filter(steps::contains).findFirst().orElse(null);
+        String type = eip != null ? validator.optionType(eip, k) : null;
+        JsonNode value = node.get(k);
+        if (type == null || value == null) {
+            return false;
+        }
+        return switch (type) {
+            case "array" -> value.isArray();
+            case "object" -> value.isObject();
+            default -> value.isValueNode();
+        };
+    }
+
     // 
-------------------------------------------------------------------------------------------------------------
     // expression hints
     // 
-------------------------------------------------------------------------------------------------------------
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
index 476a6772178a..04a3a445b116 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlValidator.java
@@ -2488,20 +2488,47 @@ public class YamlValidator {
 
     /** The options of an EIP as the YAML DSL schema lists them, or empty when 
the EIP is not known (CAMEL-25372). */
     Set<String> optionsOf(String eip) {
+        Set<String> answer = new LinkedHashSet<>();
+        for (JsonNode shape : shapesOf(eip)) {
+            
shape.path("properties").fieldNames().forEachRemaining(answer::add);
+        }
+        return answer;
+    }
+
+    /**
+     * The type the YAML DSL schema gives an option of an EIP: array, object, 
string, boolean...; for an option that
+     * refers to a definition, the type of the definition; null when the EIP, 
the option or its type is not known. Three
+     * options are named as EIPs too, and their type says which one a key is: 
doCatch (an array) and doFinally (an
+     * object) of doTry, filter (a string) of claimCheck (CAMEL-26874).
+     */
+    String optionType(String eip, String option) {
+        for (JsonNode shape : shapesOf(eip)) {
+            JsonNode property = shape.path("properties").path(option);
+            if (property.isMissingNode()) {
+                continue;
+            }
+            JsonNode ref = property.path("$ref");
+            if (ref.isTextual() && ref.asText().startsWith("#")) {
+                property = model.at(ref.asText().substring(1));
+            }
+            JsonNode type = property.path("type");
+            return type.isTextual() ? type.asText() : null;
+        }
+        return null;
+    }
+
+    /** The definition of an EIP in the schema, and the shapes of its oneOf; 
empty when the EIP is not known. */
+    private List<JsonNode> shapesOf(String eip) {
         JsonNode ref = 
model.at("/items/definitions/org.apache.camel.model.ProcessorDefinition/properties/"
 + eip + "/$ref");
         JsonNode definition = ref.isTextual() ? 
model.at(ref.asText().substring(1)) : null;
-        Set<String> answer = new LinkedHashSet<>();
         if (definition == null || definition.isMissingNode()) {
-            return answer;
+            return List.of();
         }
         // the definition is an object, or a oneOf of a short form and an 
object
         List<JsonNode> shapes = new ArrayList<>();
         shapes.add(definition);
         definition.path("oneOf").forEach(shapes::add);
-        for (JsonNode shape : shapes) {
-            
shape.path("properties").fieldNames().forEachRemaining(answer::add);
-        }
-        return answer;
+        return shapes;
     }
 
     /**
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
index 3dfc0d49d529..1e00b0750a2b 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/SchemaHintsTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.yaml.validator;
 import java.text.MessageFormat;
 import java.util.List;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.networknt.schema.Error;
 import com.networknt.schema.path.NodePath;
 import com.networknt.schema.path.PathType;
@@ -113,6 +114,31 @@ public class SchemaHintsTest {
         assertThat(hinted.get(2)).isSameAs(errors.get(3));
     }
 
+    @Test
+    public void 
testAnOptionWithTheNameOfAnEipIsMisplacedByTheShapeOfItsValue() throws 
Exception {
+        // CAMEL-26874: doCatch: and doFinally: of doTry, and filter: of 
claimCheck, are the names of EIPs too
+        YamlValidator validator = new YamlValidator();
+        validator.init();
+        assertThat(validator.optionType("doTry", 
"doCatch")).isEqualTo("array");
+        assertThat(validator.optionType("doTry", 
"doFinally")).isEqualTo("object");
+        assertThat(validator.optionType("claimCheck", 
"filter")).isEqualTo("string");
+        assertThat(validator.optionType("log", "doCatch")).isNull();
+
+        ObjectMapper mapper = new ObjectMapper();
+        assertThat(SchemaHints.misplacedOptions(
+                mapper.readTree("{\"doTry\": {\"steps\": []}, \"doCatch\": [], 
\"doFinally\": {\"steps\": []}}"),
+                validator)).containsExactly("doCatch", "doFinally");
+        assertThat(SchemaHints.misplacedOptions(
+                mapper.readTree("{\"claimCheck\": {\"operation\": \"Get\"}, 
\"filter\": \"header:order*\"}"), validator))
+                .containsExactly("filter");
+        // filter: with a mapping is the filter EIP, and log: is no option of 
setBody
+        assertThat(SchemaHints.misplacedOptions(
+                mapper.readTree("{\"claimCheck\": {\"operation\": \"Push\"}, 
\"filter\": {\"simple\": \"${header.vip}\"}}"),
+                validator)).isEmpty();
+        assertThat(SchemaHints.misplacedOptions(
+                mapper.readTree("{\"setBody\": {\"simple\": \"${body}\"}, 
\"log\": \"${body}\"}"), validator)).isEmpty();
+    }
+
     @Test
     public void testMatchNamesTheItemAndItsParent() {
         SchemaHints.Match m = SchemaHints.Match.of(
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/StructureHintTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/StructureHintTest.java
index bc44197dcede..8ffc227dbbfe 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/StructureHintTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/StructureHintTest.java
@@ -116,6 +116,110 @@ public class StructureHintTest {
                 .contains("to: is another EIP: start it as its own item, - 
to:"));
     }
 
+    @Test
+    public void doCatchAtTheColumnOfDoTry() {
+        // CAMEL-26874, r32n-4 route-batch-file step 2 (shortened): told that 
doCatch: is another EIP to start as its
+        // own - item, the model sent the same lines 19 times. doCatch: is an 
option of doTry, and an EIP too.
+        String yaml = """
+                - route:
+                    from:
+                      uri: file:inbox
+                      steps:
+                        - doTry:
+                            steps:
+                              - unmarshal:
+                                  csv:
+                                    useMaps: true
+                          doCatch:
+                            - exception:
+                                - java.lang.IllegalArgumentException
+                              steps:
+                                - log: "bad line ${body}"
+                """;
+        // doCatch: read as a - doCatch: step, which is a mapping, also gave 
"array found, object expected" at line 10
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .startsWith("Line 5: ")
+                .contains("doCatch: lines up with doTry:, so it is read as a 
second key of the - item; it is an option"
+                          + " of doTry: indent doCatch: and the lines under it 
two spaces more, so doCatch: lines up with"
+                          + " steps:")
+                .doesNotContain("another EIP"));
+    }
+
+    @Test
+    public void doFinallyAtTheColumnOfDoTry() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:batch
+                      steps:
+                        - doTry:
+                            steps:
+                              - to: file:out
+                          doFinally:
+                            steps:
+                              - log: "batch done"
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .contains("doFinally: lines up with doTry:, so it is read as a 
second key of the - item")
+                .doesNotContain("another EIP"));
+    }
+
+    @Test
+    public void aDoFinallyListIsWrongAsAnOptionToo() {
+        // the option is a mapping as well: the error of the list stays, with 
its own hint
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:batch
+                      steps:
+                        - doTry:
+                            steps:
+                              - to: file:out
+                          doFinally:
+                            - log: "batch done"
+                """;
+        assertThat(lines(yaml)).hasSize(2)
+                .anySatisfy(m -> assertThat(m).contains("doFinally: lines up 
with doTry:"))
+                .anySatisfy(m -> assertThat(m).startsWith("Line 9: 
").contains("array found, object expected"));
+    }
+
+    @Test
+    public void aFilterTextAtTheColumnOfClaimCheck() {
+        // filter: of claimCheck takes a text; the errors of filter: read as 
the filter EIP go with it
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:claim
+                      steps:
+                        - claimCheck:
+                            operation: Get
+                            key: order
+                          filter: "header:order*"
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .contains("filter: lines up with claimCheck:, so it is read as 
a second key of the - item")
+                .contains("so filter: lines up with operation:"));
+    }
+
+    @Test
+    public void aFilterWithStepsAfterClaimCheckIsAnotherEip() {
+        // with a mapping, filter: is not the text option of claimCheck but 
the filter EIP
+        String yaml = """
+                - route:
+                    from:
+                      uri: direct:claim
+                      steps:
+                        - claimCheck:
+                            operation: Push
+                          filter:
+                            simple: "${header.vip}"
+                            steps:
+                              - to: log:vip
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .contains("filter: is another EIP: start it as its own item, - 
filter:"));
+    }
+
     private static List<String> lines(String yaml) {
         try {
             return YamlValidator.describeAll(yaml, validator.validate(yaml));

Reply via email to