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