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 5d9d38caefdc CAMEL-26730: camel-jbang - AI tools: a literal \n between 
a value and the next key is written as a line break, and a file named as a 
Citrus http body goes under resource: (#29410)
5d9d38caefdc is described below

commit 5d9d38caefdc16b9e0eb87f0aaa92e2b2a09aad3
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Nov 8 09:50:56 2026 +0100

    CAMEL-26730: camel-jbang - AI tools: a literal \n between a value and the 
next key is written as a line break, and a file named as a Citrus http body 
goes under resource: (#29410)
    
    * CAMEL-26730: camel-jbang - AI tools: a literal \n between a value and…
    * CAMEL-26730: camel-jbang - AI tools: the name of a file as the body o…
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dsl/jbang/core/commands/ai/AuthoringTools.java |  10 +-
 .../jbang/core/commands/ai/CitrusTestChecks.java   |  60 ++++++-
 .../dsl/jbang/core/commands/ai/LenientRepairs.java |  98 +++++++++++-
 .../commands/ai/AuthoringToolsLenientTest.java     | 173 +++++++++++++++++++++
 .../core/commands/ai/CitrusTestChecksTest.java     |  76 +++++++++
 .../camel/dsl/yaml/validator/YamlValidator.java    |  68 +++++++-
 .../validator/YamlValidatorPropertyHintTest.java   |  54 +++++++
 7 files changed, 522 insertions(+), 17 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index c607a02e3178..53396c2afd8b 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -2052,8 +2052,11 @@ public final class AuthoringTools {
             "is not defined in the schema|must have at most \\d+ 
propert|found, \\w+ expected|starts in column"
                                                                  + "|required 
property");
 
-    /** The line a repair or a problem names: "line 101: otherwise: its steps 
put under steps:", "Line 102: ...". */
-    private static final Pattern NAMED_LINE = Pattern.compile("^[Ll]ine 
(\\d+)\\b");
+    /**
+     * The line a repair or a problem names: "line 101: otherwise: its steps 
put under steps:", "Line 102: ...", or the
+     * lines: "lines 45-46: the two characters \n after the value of 
expression written as a line break".
+     */
+    private static final Pattern NAMED_LINE = Pattern.compile("^[Ll]ines? 
(\\d+)(?:-(\\d+))?\\b");
 
     /** What a repair says when a problem of the shape is left in the lines it 
changed. */
     static final String SHAPE_LEFT = " (these lines still have errors)";
@@ -2079,7 +2082,8 @@ public final class AuthoringTools {
             boolean left = false;
             if (m.find()) {
                 int start = Integer.parseInt(m.group(1));
-                int end = blockEnd(lines, start);
+                // a line split in two has its second line beside the first, 
not in its block
+                int end = Math.max(blockEnd(lines, start), m.group(2) != null 
? Integer.parseInt(m.group(2)) : start);
                 left = shapeLines.stream().anyMatch(l -> l >= start && l <= 
end);
             }
             answer.add(left ? applied + SHAPE_LEFT : applied);
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
index 8b4bbf2924a3..0c435d48fb54 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecks.java
@@ -20,6 +20,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Comparator;
@@ -413,6 +414,36 @@ public final class CitrusTestChecks {
             return Files.exists(inProject) ? 
testDir.relativize(inProject).toString().replace('\\', '/') : file;
         }
 
+        /**
+         * The file a body written as text names, as Citrus loads it: a 
classpath: or file: resource as written, or a
+         * file of the project, from the test; null for text, which is the 
body itself. Written under data:, the name of
+         * the file was sent as the body (r29n-4 test-citrus-orders step 1 
copied body: {data:
+         * "classpath:test/sample-order.json"} from the lines of a refused 
write, and the service answered 500).
+         */
+        private String bodyFile(String text) {
+            String name = text.strip();
+            if (name.isEmpty() || 
name.chars().anyMatch(Character::isWhitespace)) {
+                return null;
+            }
+            if (name.matches("(classpath|file):.+")) {
+                return name;
+            }
+            try {
+                return testDir != null
+                        && (Files.isRegularFile(testDir.resolve(name)) || 
Files.isRegularFile(project.resolve(name)))
+                                ? fromTest(name) : null;
+            } catch (InvalidPathException e) {
+                return null;
+            }
+        }
+
+        /** The body loaded from a file: {resource: {file: ...}}. */
+        private static ObjectNode resource(String file) {
+            ObjectNode body = JSON.createObjectNode();
+            body.set("resource", JSON.createObjectNode().put("file", file));
+            return body;
+        }
+
         List<String> problems() {
             // the actions first, also those of a container (sequential, 
parallel...), which the schema takes as any
             // object: that each item is one Citrus test action, and what the 
actions of a container have
@@ -691,16 +722,27 @@ public final class CitrusTestChecks {
                                : "");
             }
             if (!at.equals(bodyAt)) {
-                return "; the body the test expects goes under body: {data: " 
+ quoted(text) + "}";
+                String file = bodyFile(text);
+                return "; the body the test expects goes under body: "
+                       + (file != null ? "{resource: {file: " + quoted(file) + 
"}}" : "{data: " + quoted(text) + "}");
             }
             return bodyText(at, text);
         }
 
         /**
          * The lines of a body written as text, under data:, and what data is; 
for an expected JSON body, jsonPath too.
+         * The name of a file goes under resource:, which loads the body from 
the file.
          */
         private String bodyText(String at, String text) {
             boolean expected = isReceived(at);
+            String file = bodyFile(text);
+            if (file != null) {
+                String written = keyLines(at, resource(file));
+                return "; " + quoted(text.strip()) + " names a file: it goes 
under resource: {file: ...}, and the body "
+                       + (expected ? "the test expects" : "to send") + " is 
read from that file (under data:, the name"
+                       + " itself would be the body)."
+                       + (written != null ? written : " Write body: {resource: 
{file: " + quoted(file) + "}}");
+            }
             Map<String, String> fields = expected ? jsonFields(text) : 
Map.of();
             String jsonPath = fields.isEmpty() ? null : jsonPathCheck(at, 
fields);
             String written = keyLines(at, JSON.createObjectNode().put("data", 
text));
@@ -1007,12 +1049,11 @@ public final class CitrusTestChecks {
                 } else if (forRequest && METHOD_KEYS.contains(key) && 
value.isTextual()) {
                     method = value.asText().strip().toUpperCase(Locale.ROOT);
                 } else if (forRequest && FILE_KEYS.contains(key) && 
value.isTextual()) {
-                    request.set("body", JSON.createObjectNode().set("resource",
-                            JSON.createObjectNode().put("file", 
fromTest(value.asText().strip()))));
+                    request.set("body", 
resource(fromTest(value.asText().strip())));
                 } else if (!"request".equals(side) && 
STATUS_KEYS.contains(key) && value.isValueNode()) {
                     response.put("status", value.asText());
                 } else if (side == null && RESPONSE_BODY_KEYS.contains(key) && 
value.isTextual()) {
-                    response.set("body", JSON.createObjectNode().put("data", 
value.asText()));
+                    response.set("body", body(value));
                 } else if ("body".equals(key) || forRequest && 
BODY_KEYS.contains(key)) {
                     JsonNode body = body(value);
                     if (body == null) {
@@ -1029,8 +1070,15 @@ public final class CitrusTestChecks {
                 return true;
             }
 
-            /** The body of a request or a response: text as data, or a map of 
one of data, resource or script. */
-            private static JsonNode body(JsonNode value) {
+            /**
+             * The body of a request or a response: the name of a file as a 
resource to load, other text as data, or a
+             * map of one of data, resource or script.
+             */
+            private JsonNode body(JsonNode value) {
+                String file = value.isTextual() ? bodyFile(value.asText()) : 
null;
+                if (file != null) {
+                    return resource(file);
+                }
                 if (value.isValueNode() && !value.isNull()) {
                     return JSON.createObjectNode().put("data", value.asText());
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
index efb0cc650253..fd0ee0f174d7 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
@@ -30,10 +30,11 @@ import java.util.regex.Pattern;
  * CAMEL-25532: the YAML shapes a model writes for a Camel route that have one 
reading, repaired before a write is
  * refused for them: an option at the column of its EIP, a plain value where 
an expression goes, a list where a map goes
  * (and the other way round), a list item or a key one column off, steps: or 
otherwise: in the column of the dashes of a
- * list where the schema takes it in one place, an unquoted placeholder, an 
enum value in another case, and the names
- * models use for an option that has another name (from the benchmark traces). 
Each repair is driven by the validator's
- * message for that shape and changes only the lines it names; a name is never 
guessed by similarity. The caller
- * validates the result again, and keeps it only when no new problem is left.
+ * list where the schema takes it in one place, an unquoted placeholder, an 
enum value in another case, a line break
+ * escaped twice (the two characters \n) between a value and the next key, and 
the names models use for an option that
+ * has another name (from the benchmark traces). Each repair is driven by the 
validator's message for that shape and
+ * changes only the lines it names; a name is never guessed by similarity. The 
caller validates the result again, and
+ * keeps it only when no new problem is left.
  */
 final class LenientRepairs {
 
@@ -100,6 +101,16 @@ final class LenientRepairs {
                                                                       + " line 
(\\d+))\\. Write lines? (\\d+)(?:-(\\d+))?"
                                                                       + " 
as:\\n",
             Pattern.DOTALL);
+    /**
+     * A value followed by the two characters \n of a line break escaped twice 
and the next key, with the lines to
+     * write: the parts of the line between the \n.
+     */
+    private static final Pattern ESCAPED_LINE_BREAK = Pattern.compile(
+            "^line (\\d+): the value of (.+?) is followed by \\\\n, the two 
characters \\\\ and n: YAML reads them as"
+                                                                      + " 
text, not as a line break, so what comes after"
+                                                                      + " them 
is not a key\\. Write line \\1 as these"
+                                                                      + " 
(\\d+) lines:\\n",
+            Pattern.DOTALL);
 
     /** How many columns a list item or key may be off to be moved: a slip, 
not another structure. */
     private static final int MAX_SHIFT = 2;
@@ -168,6 +179,24 @@ final class LenientRepairs {
                     ls -> replaceLines(ls, first - 1, last, written)));
             return answer;
         }
+        if ((m = ESCAPED_LINE_BREAK.matcher(error)).find()) {
+            // a line break escaped twice in the tool call is not text: r29 of 
the local-model benchmark,
+            // contracts-rest-code-first step 1 sent the same lines 15, 10 and 
16 times in three runs
+            int line = Integer.parseInt(m.group(1));
+            List<String> written = 
List.of(error.substring(m.end()).split("\n", -1));
+            if (written.size() == Integer.parseInt(m.group(3))) {
+                // the note names all the lines the line became, so an error 
left in one of them marks this repair
+                List<String> keys = written.subList(0, written.size() - 
1).stream().map(LenientRepairs::keyOf).toList();
+                answer.add(new Repair(
+                        line, false, "lines " + line + "-" + (line + 
keys.size()) + ": the two characters \\n after the "
+                                     + (keys.size() == 1
+                                             ? "value of " + keys.get(0) + " 
written as a line break"
+                                             : "values of " + String.join(", 
", keys.subList(0, keys.size() - 1))
+                                               + " and " + 
keys.get(keys.size() - 1) + " written as line breaks"),
+                        ls -> splitLine(ls, line - 1, written)));
+            }
+            return answer;
+        }
         if (error.contains("lines up with") && error.startsWith("Line ")) {
             int line = leadingLine(error);
             Matcher o = OPTION_AT_EIP.matcher(error);
@@ -606,6 +635,67 @@ final class LenientRepairs {
         return true;
     }
 
+    /** The key a line declares, as the validator names it: - to of - to: 
"direct:a". */
+    private static String keyOf(String line) {
+        String stripped = line.strip();
+        int colon = stripped.indexOf(':');
+        return colon > 0 ? stripped.substring(0, colon) : "the value";
+    }
+
+    /**
+     * The lines the validator says to write in place of a line with the two 
characters \n of a line break escaped
+     * twice, when they are that line with each \n turned into a line break, 
and each line after the first starts in a
+     * column the lines above have for it: a key of the mapping of the line 
above or of one that holds it, an item of
+     * the list of the line above or of one that holds it. Text after a \n 
goes nowhere else, and is not repaired.
+     */
+    private static boolean splitLine(List<String> lines, int index, 
List<String> written) {
+        if (!valid(lines, index) || written.size() < 2) {
+            return false;
+        }
+        StringBuilder same = new StringBuilder(Pattern.quote(written.get(0)));
+        for (String part : written.subList(1, written.size())) {
+            same.append("[ \\t]*\\\\n").append(Pattern.quote(part));
+        }
+        if (!lines.get(index).matches(same + "[ \\t]*")) {
+            return false;
+        }
+        List<String> split = new ArrayList<>(lines);
+        split.remove(index);
+        split.addAll(index, written);
+        for (int i = 1; i < written.size(); i++) {
+            String part = written.get(i);
+            if (!openColumn(split, index + i, indent(part), 
part.strip().startsWith("- "))) {
+                return false;
+            }
+        }
+        lines.clear();
+        lines.addAll(split);
+        return true;
+    }
+
+    /**
+     * Whether a key (or a list item) can start in the column on the line at 
index: the column of the key of the line
+     * above or of a mapping that holds it (of the - of the line above or of a 
list item that holds it).
+     */
+    private static boolean openColumn(List<String> lines, int index, int 
column, boolean item) {
+        int limit = Integer.MAX_VALUE;
+        for (int j = index - 1; j >= 0 && limit > 0; j--) {
+            String l = lines.get(j);
+            String s = l.strip();
+            int i = indent(l);
+            if (s.isEmpty() || s.startsWith("#") || i >= limit) {
+                continue;
+            }
+            boolean dash = s.startsWith("- ");
+            int key = dash ? i + 1 + indent(s.substring(1)) : i;
+            if (item ? dash && i == column : key == column) {
+                return true;
+            }
+            limit = i;
+        }
+        return false;
+    }
+
     /** A list item or a key one column off, moved with its lines to the 
column the validator names. */
     private static boolean shiftBlock(List<String> lines, int index, int 
delta) {
         if (!valid(lines, index) || delta == 0) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
index e8cc8d6d9d41..6a45766cfde6 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
@@ -591,6 +591,179 @@ class AuthoringToolsLenientTest {
         assertThat(result.get("errors").toString()).contains("mesage");
     }
 
+    /**
+     * r29 of the local-model benchmark, contracts-rest-code-first step 1: a 
line break escaped twice in the tool call,
+     * so the line holds the two characters \n before the next key. Told what 
they are and given the two lines to write,
+     * the model sent the same lines 15, 10 and 16 times in three runs. They 
cannot be text there: the write makes them
+     * a line break, and says so.
+     */
+    @Test
+    void 
theTwoCharactersOfALineBreakBeforeTheNextKeyAreWrittenAsALineBreak(@TempDir 
Path dir) throws IOException {
+        JsonObject result = write(dir, """
+                - route:
+                    from:
+                      uri: direct:get-order
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$[?(@.orderId == 
'${header.id}')]"\\n                resultType: java.util.List
+                        - log: "${body}"
+                """);
+        assertRepairedAndValid(dir, result,
+                "lines 8-9: the two characters \\n after the value of 
expression written as a line break");
+        assertThat(result.getString("message")).startsWith("Written with these 
repairs of the YAML (the file has them"
+                                                           + " now): lines 
8-9: the two characters \\n after the value of"
+                                                           + " expression 
written as a line break. ");
+        assertThat(Files.readString(dir.resolve(FILE))).contains("""
+                              jsonpath:
+                                expression: "$[?(@.orderId == '${header.id}')]"
+                                resultType: java.util.List
+                        - log: "${body}"
+                """);
+    }
+
+    /** Several in one write: two on one line, one on another line, after a 
plain value, and before a list item. */
+    @Test
+    void eachLineBreakEscapedTwiceIsWrittenAsALineBreak(@TempDir Path dir) 
throws IOException {
+        JsonObject result = write(dir,
+                """
+                        - route:
+                            from:
+                              uri: direct:get-order
+                              steps:
+                                - setBody:
+                                    expression:
+                                      jsonpath:
+                                        expression: '$[0]'\\n                
resultType: java.util.Map\\n                suppressExceptions: true
+                                - setHeader:
+                                    name: orderId
+                                    expression:
+                                      simple:
+                                        expression: ${body[orderId]}\\n        
        trim: true
+                                - to: direct:audit\\n        - log: 
"${header.orderId}"
+                        - route:
+                            from:
+                              uri: direct:audit
+                              steps:
+                                - log: "audit"
+                        """);
+        assertRepairedAndValid(dir, result,
+                "lines 8-10: the two characters \\n after the values of 
expression and resultType written as line breaks",
+                "lines 15-16: the two characters \\n after the value of 
expression written as a line break",
+                "lines 17-18: the two characters \\n after the value of - to 
written as a line break");
+        assertThat(Files.readString(dir.resolve(FILE))).contains("""
+                              jsonpath:
+                                expression: '$[0]'
+                                resultType: java.util.Map
+                                suppressExceptions: true
+                        - setHeader:
+                            name: orderId
+                            expression:
+                              simple:
+                                expression: ${body[orderId]}
+                                trim: true
+                        - to: direct:audit
+                        - log: "${header.orderId}"
+                """);
+    }
+
+    /** The edit tool writes through the write, so a replacement with the two 
characters \n is repaired the same way. */
+    @Test
+    void anEditWithALineBreakEscapedTwiceIsRepairedAsAWriteIs(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve(FILE), """
+                - route:
+                    from:
+                      uri: direct:get-order
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$[0]"
+                        - log: "${body}"
+                """);
+        JsonObject result = AuthoringTools.editFile(new ToolContext(), dir, 
FILE,
+                "                expression: \"$[0]\"",
+                "                expression: \"$[?(@.orderId == 
'${header.id}')]\"\\n                resultType: java.util.List");
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+        assertThat(result.get("repaired").toString())
+                .contains("lines 8-9: the two characters \\n after the value 
of expression written as a line break");
+        assertThat(Files.readString(dir.resolve(FILE))).contains("""
+                                expression: "$[?(@.orderId == '${header.id}')]"
+                                resultType: java.util.List
+                        - log: "${body}"
+                """);
+    }
+
+    /**
+     * A \n inside quotes is an escape (double quotes) or text (single 
quotes), and a block scalar or Groovy code holds
+     * it as written: none of them is a line break escaped twice, so the file 
is written as it is.
+     */
+    @Test
+    void theTwoCharactersAsTextAreNotRepaired(@TempDir Path dir) throws 
IOException {
+        String content = """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - setBody:
+                            expression:
+                              groovy:
+                                expression: "body.split('\\n').collect { it + 
': x' }.join('\\n')"
+                        - setBody:
+                            expression:
+                              simple:
+                                expression: "line one\\nresultType: ${body}"
+                        - setBody:
+                            expression:
+                              groovy:
+                                expression: |
+                                  def lines = "a\\nb: c".split('\\n')
+                                  return lines.size()
+                        - log:
+                            message: 'first\\n  second: ${body}'
+                        - log:
+                            message: Hello\\nWorld
+                """;
+        JsonObject result = write(dir, content);
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("created");
+        assertThat(result.get("repaired")).isNull();
+        assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(content);
+    }
+
+    /**
+     * After a \n, text that starts in no column of a key of the lines above 
is not a key the line break was meant for:
+     * not repaired, and the answer is the one the validator gives. A key in 
the column of a mapping above is repaired,
+     * and when that is not its place, the repair says its lines still have 
errors.
+     */
+    @Test
+    void 
aLineBreakEscapedTwiceBeforeAKeyInNoColumnOfTheLinesAboveIsNotRepaired(@TempDir 
Path dir) {
+        String route = """
+                - route:
+                    from:
+                      uri: direct:get-order
+                      steps:
+                        - setBody:
+                            expression:
+                              jsonpath:
+                                expression: "$[0]"\\n%sresultType: 
java.util.List
+                """;
+        for (String indent : List.of("", "                  ")) {
+            JsonObject result = write(dir, route.formatted(indent));
+            
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("invalid");
+            assertThat(result.get("repaired")).isNull();
+            assertThat(result.get("repairable")).isNull();
+            assertThat(result.get("errors").toString()).contains("the value of 
expression is followed by \\n");
+        }
+        JsonObject result = write(dir, route.formatted("              "));
+        
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("invalid");
+        assertThat(result.get("errors").toString()).contains("property 
'resultType' is not defined");
+        assertThat(result.get("repairable").toString()).contains("lines 8-9: 
the two characters \\n after the value of"
+                                                                 + " 
expression written as a line break (these lines still"
+                                                                 + " have 
errors)");
+        assertThat(result.getString("message")).doesNotContain("needs no 
change from you");
+    }
+
     // 
-------------------------------------------------------------------------------------------------------------
     // the snippet to find, written from memory
     // 
-------------------------------------------------------------------------------------------------------------
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
index a8932a6584eb..f33c3257660e 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/CitrusTestChecksTest.java
@@ -226,6 +226,57 @@ class CitrusTestChecksTest {
         assertThat(CitrusTestChecks.validate(written(test, msgs.get(0)), dir, 
"test/orders.citrus.it.yaml")).isEmpty();
     }
 
+    /**
+     * r29n-4 test-citrus-orders step 1: the body under http: is the name of a 
file. The lines to write had it under
+     * data:, the model copied them, and the test sent the name as the body. 
The name of a file goes under resource:, as
+     * written for a classpath: or file: resource, and from the test for a 
file of the project.
+     */
+    @Test
+    void theNameOfAFileAsTheBodyUnderHttpIsAResource(@TempDir Path dir) throws 
Exception {
+        Files.writeString(dir.resolve("sample-order.json"), "{\"orderId\": 
\"ORD-1001\"}");
+        Files.createDirectories(dir.resolve("test"));
+        String test = """
+                name: orders.citrus.it
+                actions:
+                  # Send POST request with the sample order JSON to /orders 
endpoint
+                  - http:
+                      client: default
+                      url: http://localhost:8080/orders
+                      method: POST
+                      contentType: application/json
+                      body: classpath:test/sample-order.json
+                      outgoingHeaders:
+                        Content-Type: application/json
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test, dir, 
"test/orders.citrus.it.yaml");
+
+        assertThat(msgs).singleElement().asString()
+                .startsWith("Line 6: url, method, contentType, body and 
outgoingHeaders are not keys of the http action")
+                .endsWith(" Write lines 4-11 as:\n" + """
+                          - http:
+                              client: "http://localhost:8080";
+                              sendRequest:
+                                POST:
+                                  path: "/orders"
+                                  contentType: "application/json"
+                                  body:
+                                    resource:
+                                      file: "classpath:test/sample-order.json"
+                        """.stripTrailing());
+        assertThat(CitrusTestChecks.validate(written(test, msgs.get(0)), dir, 
"test/orders.citrus.it.yaml")).isEmpty();
+
+        // a file of the project, named from the project, is read from the test
+        msgs = 
CitrusTestChecks.validate(test.replace("classpath:test/sample-order.json", 
"sample-order.json"), dir,
+                "test/orders.citrus.it.yaml");
+        assertThat(msgs).singleElement().asString()
+                .endsWith("          body:\n            resource:\n            
  file: \"../sample-order.json\"");
+        // text that names no file is the body itself, under data:
+        msgs = 
CitrusTestChecks.validate(test.replace("classpath:test/sample-order.json", 
"missing-order.json"), dir,
+                "test/orders.citrus.it.yaml");
+        assertThat(msgs).singleElement().asString()
+                .endsWith("          body:\n            data: 
\"missing-order.json\"");
+    }
+
     @Test
     void theTextOfTheRequestUnderHttpGoesUnderData() {
         // r28n-3 step 1: url, the order as text, and statusCode
@@ -377,6 +428,31 @@ class CitrusTestChecksTest {
         assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
     }
 
+    /** The name of a file as the body of a request in its place: a resource, 
not the text to send. */
+    @Test
+    void theNameOfAFileAsTheBodyOfARequestIsAResource() {
+        String test = """
+                name: t
+                actions:
+                  - http:
+                      client: "http://localhost:8080";
+                      sendRequest:
+                        POST:
+                          path: "/orders"
+                          body: classpath:test/sample-order.json
+                """;
+        List<String> msgs = CitrusTestChecks.validate(test);
+
+        assertThat(msgs).singleElement().asString().isEqualTo("""
+                Line 8: http.sendRequest.POST.body: string found, object 
expected; body takes data, resource, script;\
+                 "classpath:test/sample-order.json" names a file: it goes 
under resource: {file: ...}, and the body to\
+                 send is read from that file (under data:, the name itself 
would be the body). Write line 8 as:
+                          body:
+                            resource:
+                              file: "classpath:test/sample-order.json\"""");
+        assertThat(CitrusTestChecks.validate(written(test, 
msgs.get(0)))).isEmpty();
+    }
+
     /** The case of test-citrus-orders step 2, with the check of the body of 
the response to fill in. */
     private static final String CASE_400 = """
             name: t
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 458134a52bcb..6be9030b486c 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
@@ -297,6 +297,9 @@ public class YamlValidator {
     /** The two characters \n before a key or a list item, as a line break 
escaped twice writes them. */
     private static final Pattern ESCAPED_LINE_BREAK = 
Pattern.compile("\\\\n(?=\\s*(?:-\\s|[A-Za-z_][\\w.-]*:))");
 
+    /** The key or the list item after the two characters \n: the - of the 
item, and the key up to its colon. */
+    private static final Pattern AFTER_ESCAPED_LINE_BREAK = Pattern.compile("( 
*)(-\\s+)?[A-Za-z_][\\w.-]*:");
+
     /**
      * A YAML parse error whose line is a line of text at column 1 after the 
routes (an explanation appended to the
      * file, or a markdown fence) says so; the parser's "while scanning a 
simple key" does not.
@@ -371,10 +374,7 @@ public class YamlValidator {
                               + " quotes belong): YAML reads them as text, so 
the line is one value. Write each part on"
                               + " a line of its own, with real line breaks and 
quotes"
                             : escaped
-                                    ? "line " + last + ": the value of " + key 
+ " is followed by \\n, the two characters"
-                                      + " \\ and n: YAML reads them as text, 
not as a line break, so what comes after them"
-                                      + " is not a key. Write line " + last + 
" as these " + parts.size() + " lines:\n"
-                                      + String.join("\n", 
parts.stream().map(String::stripTrailing).toList())
+                                    ? escapedLineBreak(last, key, parts)
                             // the value was corrected twice: it ends with two 
quotes, not with a concatenation
                             // (CAMEL-24906)
                             : "\"".equals(tail)
@@ -431,6 +431,61 @@ public class YamlValidator {
                 .build();
     }
 
+    /**
+     * The value of a key followed by the two characters \n of a line break 
escaped twice, before the next key: said
+     * with the lines to write, the parts of the line between the \n (the AI 
write tool repairs the line with them).
+     */
+    private static String escapedLineBreak(int line, String key, List<String> 
parts) {
+        return "line " + line + ": the value of " + key + " is followed by 
\\n, the two characters \\ and n: YAML reads"
+               + " them as text, not as a line break, so what comes after them 
is not a key. Write line " + line
+               + " as these " + parts.size() + " lines:\n"
+               + String.join("\n", 
parts.stream().map(String::stripTrailing).toList());
+    }
+
+    /**
+     * The parts of a line whose plain value is followed by the two characters 
\n and the next key, which the parser
+     * reads as a colon in the value; null when the value holds that colon in 
its text: the key after the \n is not the
+     * one the parser stopped at, or it is not in a column that the lines 
above have for a key (or a list item).
+     */
+    private static List<String> escapedLineBreakParts(Mark problem, String[] 
lines) {
+        String text = lines[problem.line() - 1];
+        int valueStart = text.indexOf(':') + 1;
+        Matcher escaped = ESCAPED_LINE_BREAK.matcher(text);
+        if (!escaped.find(valueStart) || text.substring(valueStart, 
escaped.start()).isBlank()) {
+            return null;
+        }
+        Matcher next = 
AFTER_ESCAPED_LINE_BREAK.matcher(text).region(escaped.end(), text.length());
+        if (!next.lookingAt() || next.end() != problem.column()
+                || !openColumn(lines, problem.line() - 1, 
next.group(1).length(), next.group(2) != null)) {
+            return null;
+        }
+        List<String> parts = new 
ArrayList<>(List.of(ESCAPED_LINE_BREAK.split(text.substring(escaped.start()), 
-1)));
+        parts.set(0, text.substring(0, escaped.start()) + parts.get(0));
+        return parts;
+    }
+
+    /**
+     * Whether a line after the line at index can start a key (or a list item) 
in the column: the column of the key of
+     * that line or of a mapping that holds it (of the - of that line or of a 
list item that holds it).
+     */
+    private static boolean openColumn(String[] lines, int index, int column, 
boolean item) {
+        int limit = Integer.MAX_VALUE;
+        for (int i = index; i >= 0 && limit > 0; i--) {
+            String stripped = lines[i].stripLeading();
+            int indent = lines[i].length() - stripped.length();
+            if (stripped.isEmpty() || stripped.startsWith("#") || indent >= 
limit) {
+                continue;
+            }
+            boolean dash = stripped.startsWith("- ");
+            int key = dash ? indent + stripped.length() - 
stripped.substring(1).stripLeading().length() : indent;
+            if (item ? dash && indent == column : key == column) {
+                return true;
+            }
+            limit = indent;
+        }
+        return false;
+    }
+
     /** A value (of a key or a list item) that starts with a property 
placeholder without quotes. */
     private static final Pattern UNQUOTED_PLACEHOLDER = 
Pattern.compile("^(\\s*(?:-\\s+)?(?:[\\w.-]+:\\s+)?)(\\{\\{.*)$");
 
@@ -910,6 +965,11 @@ public class YamlValidator {
                            + ", but the keys of the mapping that starts at 
line " + mapping.line() + " are in column "
                            + mapping.column() + "; every key of a mapping must 
start in the same column");
         }
+        // a line break escaped twice after a plain value, as after a quoted 
one: the next key is not text to quote
+        List<String> parts = escapedLineBreakParts(problem, lines);
+        if (parts != null) {
+            return hint(escapedLineBreak(problem.line(), keyName(text), 
parts));
+        }
         String value = text.substring(text.indexOf(':') + 1).trim();
         String quoted = value.contains("\"") ? "'" + value + "'" : "\"" + 
value + "\"";
         return hint("line " + problem.line() + ": the value of " + 
keyName(text) + " holds a colon (" + quoted
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
index 91dff4e36b13..896e5f6171b5 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
@@ -1310,6 +1310,60 @@ public class YamlValidatorPropertyHintTest {
                 .contains("continues after its closing quote");
     }
 
+    /**
+     * The line break escaped twice after a plain value: the parser reads the 
next key as a colon in the value, and the
+     * value is not text to quote. With the key after the \n in a column the 
lines above have for a key or a list item,
+     * the lines to write are the parts of the line, as after a quoted value.
+     */
+    @Test
+    public void testTheTwoCharactersOfALineBreakAfterAPlainValueAreNamed() 
throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: direct:get-order
+                    steps:
+                      - setBody:
+                          expression:
+                            jsonpath:
+                              expression: $[0]\\n              resultType: 
java.util.Map
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).isEqualTo(
+                "line 7: the value of expression is followed by \\n, the two 
characters \\ and n: YAML reads them as text,"
+                                                         + " not as a line 
break, so what comes after them is not a key."
+                                                         + " Write line 7 as 
these 2 lines:\n"
+                                                         + "              
expression: $[0]\n"
+                                                         + "              
resultType: java.util.Map");
+        // the next step after the \n, in the column of the - of its list
+        errors = validator.validate("""
+                - from:
+                    uri: direct:get-order
+                    steps:
+                      - to: direct:audit\\n      - log: done
+                """);
+        
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+                .endsWith("Write line 4 as these 2 lines:\n      - to: 
direct:audit\n      - log: done");
+        // text with a colon after the \n, in no column of a key: a value to 
quote, as before
+        errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: Order failed\\nCause: unknown
+                """);
+        
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+                .startsWith("line 5: the value of message holds a colon");
+        // a key deeper than the key of the value has no place there either
+        errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: Order failed\\n              cause: unknown
+                """);
+        
assertThat(errors).singleElement().extracting(Error::getMessage).asString()
+                .startsWith("line 5: the value of message holds a colon");
+    }
+
     /**
      * r28 of the local-model benchmark, connect-sftp-transfer step 2: - 
route: in column 3 after a top-level
      * onException was told to line up with the steps of the onException, and 
the model sent the same lines 12 times. It

Reply via email to