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));