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 84d5be1c7727 CAMEL-24837: camel validate yaml - a YAML indentation 
error in plain words, not the raw snakeyaml message
84d5be1c7727 is described below

commit 84d5be1c7727e4ccc2d44a852a1b5ae3f54da421
Author: Adriano Machado <[email protected]>
AuthorDate: Sun Sep 20 02:59:04 2026 -0400

    CAMEL-24837: camel validate yaml - a YAML indentation error in plain words, 
not the raw snakeyaml message
    
    camel validate yaml and the camel-jbang-mcp validation tools returned the
    raw snakeyaml MarkedYAMLException for every YAML shape mistake, which says
    where the parser gave up rather than what to change. 
YamlValidator.parseError
    now translates the common messages, keeping the line numbers and naming the
    list or mapping the entry belongs to: a list item or mapping key in a column
    of its own (under- or over-indented, with the owning list derived from the
    source since snakeyaml's context mark points at the parent mapping), a colon
    in an unquoted value (told apart from an over-indented key, which yields the
    same snakeyaml message), a backslash inside double quotes, and a tab in the
    indentation. Lists written with bare dashes are recognised, including the
    '<block sequence start>' variant. The tab check runs before the column-1
    prose scan so a tab-indented line is no longer reported as prose. Anything
    unrecognised falls through to the raw message.
    
    Closes #26617
    
    Co-authored-by: Claude Opus 5 <[email protected]>
    Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
---
 .../camel/dsl/yaml/validator/YamlValidator.java    | 232 ++++++++++++++++++++-
 .../validator/YamlValidatorPropertyHintTest.java   | 203 ++++++++++++++++++
 2 files changed, 427 insertions(+), 8 deletions(-)

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 148be7445cf9..36fbf825d3c3 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
@@ -29,6 +29,8 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -146,8 +148,7 @@ public class YamlValidator {
         }
     }
 
-    private static final java.util.regex.Pattern LINE_COLUMN
-            = java.util.regex.Pattern.compile("line:? (\\d+), column:? 
(\\d+)");
+    private static final Pattern LINE_COLUMN = Pattern.compile("line:? (\\d+), 
column:? (\\d+)");
 
     /**
      * A YAML parse error whose line is a line of text at column 1 after the 
routes (an explanation appended to the
@@ -159,12 +160,17 @@ public class YamlValidator {
         if (msg == null || content == null) {
             return plain;
         }
+        String[] lines = content.split("\n", -1);
+        // a tab in the indentation points at column 1, which the scan below 
would read as a line of prose
+        Error tab = tabIndentation(msg);
+        if (tab != null) {
+            return tab;
+        }
         // the message names several positions (the collection being parsed, 
then the token that broke it); the
         // problem is at the last one
-        String[] lines = content.split("\n", -1);
         int line = -1;
         String text = null;
-        java.util.regex.Matcher m = LINE_COLUMN.matcher(msg);
+        Matcher m = LINE_COLUMN.matcher(msg);
         while (m.find()) {
             int l = Integer.parseInt(m.group(1));
             if (!"1".equals(m.group(2)) || l < 2 || l > lines.length) {
@@ -179,15 +185,14 @@ public class YamlValidator {
         }
         if (text == null) {
             // a value that continues after its closing quote: message: ">>> " 
+ exchange.getIn().getBody()
-            java.util.regex.Matcher any = LINE_COLUMN.matcher(msg);
+            Matcher any = LINE_COLUMN.matcher(msg);
             int last = -1;
             while (any.find()) {
                 last = Integer.parseInt(any.group(1));
             }
             if (last >= 1 && last <= lines.length) {
                 String t = lines[last - 1];
-                java.util.regex.Matcher q
-                        = 
java.util.regex.Pattern.compile(":\\s*(\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*')\\s*\\S").matcher(t);
+                Matcher q = 
Pattern.compile(":\\s*(\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*')\\s*\\S").matcher(t);
                 if (q.find()) {
                     String key = t.trim().contains(":") ? 
t.trim().substring(0, t.trim().indexOf(':')) : "the value";
                     return Error.builder()
@@ -200,7 +205,8 @@ public class YamlValidator {
                             .build();
                 }
             }
-            return plain;
+            Error marked = indentationError(msg, lines);
+            return marked != null ? marked : plain;
         }
         String cleaned = msg.replace("\n", " ").replaceAll("\\s+", " ").trim();
         int cut = cleaned.indexOf("in 'reader'");
@@ -214,6 +220,216 @@ public class YamlValidator {
                 .build();
     }
 
+    private static final Pattern SNAKE_MARK = Pattern.compile("in 'reader', 
line (\\d+), column (\\d+):");
+    private static final Pattern ESCAPE_CHAR = Pattern.compile("found unknown 
escape character (.)\\(");
+    private static final Pattern KEY_LINE = 
Pattern.compile("^\\s*[^-\\s#][^:]*:(\\s|$)");
+
+    /** A position the parser reported: the line and the column it points at, 
both 1-based. */
+    private record Mark(int line, int column) {
+    }
+
+    /**
+     * CAMEL-24837: the snakeyaml messages that only say where the parser gave 
up, said in YAML words. Returns null for
+     * the messages this does not know, so the raw one is still reported.
+     */
+    static Error indentationError(String msg, String[] lines) {
+        List<Mark> marks = marks(msg, lines);
+        if (marks.isEmpty()) {
+            return null;
+        }
+        Mark problem = marks.get(marks.size() - 1);
+        // the stray item is named '-', or '<block sequence start>' when the 
list it broke uses bare dashes
+        if (msg.contains("expected <block end>, but found '-'")
+                || msg.contains("expected <block end>, but found '<block 
sequence start>'")) {
+            return listItemColumn(problem, lines);
+        }
+        if (msg.contains("expected <block end>, but found '<block mapping 
start>'")) {
+            return marks.size() > 1 ? mappingKeyColumn(marks.get(0), problem, 
lines) : null;
+        }
+        if (msg.contains("mapping values are not allowed here")) {
+            return mappingValue(problem, lines);
+        }
+        if (msg.contains("found unknown escape character")) {
+            return unknownEscape(msg, problem, lines);
+        }
+        return null;
+    }
+
+    /** {@code Do not use (TAB) for indentation}: the line is indented with a 
tab. */
+    static Error tabIndentation(String msg) {
+        if (!msg.contains("(TAB) for indentation")) {
+            return null;
+        }
+        Matcher m = SNAKE_MARK.matcher(msg);
+        int line = -1;
+        while (m.find()) {
+            line = Integer.parseInt(m.group(1));
+        }
+        return line < 1
+                ? null
+                : hint("line " + line + ": the indentation uses a tab; YAML 
indents with spaces only, replace the"
+                       + " tab with spaces");
+    }
+
+    /**
+     * A list item in a column of its own: the parser only says that it 
expected the end of what it was reading. Name
+     * the list the item belongs to, which is the shallowest list still open 
below the item's column, or the deepest one
+     * above it when the item is the over-indented one.
+     */
+    private static Error listItemColumn(Mark problem, String[] lines) {
+        Mark deeper = null;
+        Mark shallower = null;
+        for (Mark open : openLists(problem, lines)) {
+            if (open.column() > problem.column() && (deeper == null || 
open.column() < deeper.column())) {
+                deeper = open;
+            } else if (open.column() < problem.column() && (shallower == null 
|| open.column() > shallower.column())) {
+                shallower = open;
+            }
+        }
+        Mark list = deeper != null ? deeper : shallower;
+        String belongs = list == null
+                ? "" : ", but the list that starts at line " + list.line() + " 
has its items in column " + list.column();
+        return hint("line " + problem.line() + ": this list item starts in 
column " + problem.column() + belongs
+                    + "; every item of a list must start in the same column");
+    }
+
+    /**
+     * The lists still open above the problem, each as the line and column of 
its first item. A list at column c is open
+     * while every line below it is indented to at least c.
+     */
+    private static List<Mark> openLists(Mark problem, String[] lines) {
+        Map<Integer, Integer> firstItem = new LinkedHashMap<>();
+        int deepest = Integer.MAX_VALUE;
+        for (int i = problem.line() - 2; i >= 0; i--) {
+            String stripped = lines[i].stripLeading();
+            if (stripped.isEmpty() || stripped.startsWith("#")) {
+                continue;
+            }
+            int indent = lines[i].length() - stripped.length();
+            if (listItem(stripped) && indent <= deepest) {
+                firstItem.put(indent + 1, i + 1);
+            }
+            deepest = Math.min(deepest, indent);
+        }
+        List<Mark> open = new ArrayList<>();
+        firstItem.forEach((column, line) -> open.add(new Mark(line, column)));
+        return open;
+    }
+
+    /** A list item: the indicator followed by its value, or alone on its line 
with the value below it. */
+    private static boolean listItem(String stripped) {
+        return stripped.startsWith("-")
+                && (stripped.length() == 1 || 
Character.isWhitespace(stripped.charAt(1)));
+    }
+
+    /** A key in a column of its own, where the parser names the mapping it 
was reading. */
+    private static Error mappingKeyColumn(Mark mapping, Mark problem, String[] 
lines) {
+        return hint("line " + problem.line() + ": " + 
keyName(lines[problem.line() - 1]) + " starts in column "
+                    + problem.column() + ", 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");
+    }
+
+    /**
+     * "mapping values are not allowed here" has two causes: a key indented 
deeper than the keys around it, and a colon
+     * inside a value that is not quoted. The parser points at the colon, so 
the key's own colon is the first.
+     */
+    private static Error mappingValue(Mark problem, String[] lines) {
+        String text = lines[problem.line() - 1];
+        int colon = problem.column() - 1;
+        if (colon < 0 || colon >= text.length() || text.charAt(colon) != ':') {
+            return null;
+        }
+        if (colon == text.indexOf(':')) {
+            String stripped = text.stripLeading();
+            int indent = text.length() - stripped.length();
+            Mark mapping = enclosingMapping(problem.line(), indent, lines);
+            return mapping == null
+                    ? null
+                    : hint("line " + problem.line() + ": " + keyName(text) + " 
starts in column " + (indent + 1)
+                           + ", 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");
+        }
+        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
+                    + "): a colon followed by a space starts a new key, so the 
value must be quoted");
+    }
+
+    /** The mapping an over-indented key was meant to join: the first key of 
the nearest shallower run of keys. */
+    private static Mark enclosingMapping(int line, int indent, String[] lines) 
{
+        for (int i = line - 2; i >= 0; i--) {
+            String stripped = lines[i].stripLeading();
+            if (stripped.isEmpty() || stripped.startsWith("#")) {
+                continue;
+            }
+            int other = lines[i].length() - stripped.length();
+            if (other >= indent || !KEY_LINE.matcher(lines[i]).find()) {
+                continue;
+            }
+            int first = i;
+            for (int j = i - 1; j >= 0; j--) {
+                String above = lines[j].stripLeading();
+                int aboveIndent = lines[j].length() - above.length();
+                if (above.isEmpty() || above.startsWith("#") || aboveIndent > 
other) {
+                    continue;
+                }
+                if (aboveIndent < other || !KEY_LINE.matcher(lines[j]).find()) 
{
+                    break;
+                }
+                first = j;
+            }
+            return new Mark(first + 1, other + 1);
+        }
+        return null;
+    }
+
+    /** A backslash inside a double-quoted value: YAML reads it as an escape, 
so the value belongs in single quotes. */
+    private static Error unknownEscape(String msg, Mark problem, String[] 
lines) {
+        Matcher m = ESCAPE_CHAR.matcher(msg);
+        if (!m.find()) {
+            return null;
+        }
+        String escaped = m.group(1);
+        String text = lines[problem.line() - 1];
+        int open = text.indexOf('"');
+        int close = text.lastIndexOf('"');
+        String rewrite = "";
+        if (open >= 0 && close > open) {
+            String value = text.substring(open + 1, close);
+            rewrite = value.contains("'") ? "" : ": '" + value + "'";
+        }
+        return hint("line " + problem.line() + ": \\" + escaped + " inside 
double quotes is an escape character and "
+                    + escaped + " is not one; write the value in single 
quotes" + rewrite);
+    }
+
+    /** The positions the parser reported, in the order it reported them, 
keeping only those the file has. */
+    private static List<Mark> marks(String msg, String[] lines) {
+        List<Mark> marks = new ArrayList<>();
+        Matcher m = SNAKE_MARK.matcher(msg);
+        while (m.find()) {
+            int line = Integer.parseInt(m.group(1));
+            if (line >= 1 && line <= lines.length) {
+                marks.add(new Mark(line, Integer.parseInt(m.group(2))));
+            }
+        }
+        return marks;
+    }
+
+    /** The name of the key a line declares, or "the value" when the line has 
none. */
+    private static String keyName(String line) {
+        String stripped = line.strip();
+        int colon = stripped.indexOf(':');
+        return colon > 0 ? stripped.substring(0, colon) : "the value";
+    }
+
+    private static Error hint(String message) {
+        return Error.builder()
+                .messageKey("parser")
+                .format(new MessageFormat("{0}"))
+                .arguments(message)
+                .build();
+    }
+
     /**
      * {@code //DEPS org.apache.camel:camel-groovy} at the top of a YAML file: 
JBang's Java directive, which YAML reads
      * as a plain string so the whole file becomes one scalar ("string found, 
array expected"). Name it, and say what a
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 0492b6a97f44..f4b502bfc9a9 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
@@ -658,4 +658,207 @@ public class YamlValidatorPropertyHintTest {
         assertThat(errors).hasSize(1);
         
assertThat(errors.get(0).getMessage()).contains("no-such-file.camel.yaml");
     }
+
+    /**
+     * CAMEL-24837: a list item indented differently from the first item of 
its list gets the raw snakeyaml "expected
+     * <block end>, but found '-'"; say which list it belongs to and that the 
items share one column.
+     */
+    @Test
+    void aListItemInAnotherColumnNamesTheListItBelongsTo() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - choice:
+                            when:
+                              - simple: "${body} > 1"
+                                steps:
+                                  - log:
+                                      message: big
+                            - simple: "${body} > 2"
+                              steps:
+                                - log:
+                                    message: bigger
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 11: this list item starts in column 13")
+                .contains("the list that starts at line 7 has its items in 
column 15")
+                .contains("every item of a list must start in the same column")
+                .doesNotContain("block end");
+    }
+
+    /** CAMEL-24837: the item is over-indented, so the list it belongs to is 
the shallower one above it. */
+    @Test
+    void anOverIndentedListItemNamesTheListAboveIt() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - log:
+                            message: a
+                          - log:
+                              message: b
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 7: this list item starts in column 11")
+                .contains("the list that starts at line 5 has its items in 
column 9");
+    }
+
+    /**
+     * CAMEL-24837: a key indented differently from its siblings gets 
"expected <block end>, but found '&lt;block
+     * mapping start&gt;'"; name the key and the column its siblings are in.
+     */
+    @Test
+    void aKeyInAnotherColumnNamesTheMappingItBelongsTo() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - log:
+                            message: hi
+                     id: foo
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 7: id starts in column 6")
+                .contains("the keys of the mapping that starts at line 2 are 
in column 5")
+                .contains("every key of a mapping must start in the same 
column")
+                .doesNotContain("block mapping start");
+    }
+
+    /**
+     * CAMEL-24837: a key indented deeper than its siblings gets "mapping 
values are not allowed here", the same message
+     * as a colon inside a value; the marker is on the key's own colon, so it 
is the indentation.
+     */
+    @Test
+    void anOverIndentedKeySaysItIsTheIndentation() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                       steps:
+                        - log:
+                            message: hi
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 4: steps starts in column 8")
+                .contains("the keys of the mapping that starts at line 3 are 
in column 7")
+                .doesNotContain("mapping values are not allowed here");
+    }
+
+    /**
+     * CAMEL-24837: the other cause of "mapping values are not allowed here" 
is a colon inside an unquoted value; the
+     * marker is on a later colon of the line, not on the key's own.
+     */
+    @Test
+    void aColonInsideAnUnquotedValueSaysToQuoteIt() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - log:
+                            message: hello: world
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 6: the value of message holds a colon")
+                .contains("\"hello: world\"")
+                .doesNotContain("mapping values are not allowed here");
+    }
+
+    /**
+     * CAMEL-24837: a backslash inside double quotes is an escape character; 
the value is meant literally, so it goes in
+     * single quotes.
+     */
+    @Test
+    void aBackslashInDoubleQuotesSaysToUseSingleQuotes() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: "file:orders?include=.*\\.json"
+                      steps:
+                        - log:
+                            message: hi
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 3: \\. inside double quotes is an escape 
character")
+                .contains("'file:orders?include=.*\\.json'")
+                .doesNotContain("unknown escape character");
+    }
+
+    /** CAMEL-24837: a tab used for indentation, said in YAML words instead of 
"cannot start any token". */
+    @Test
+    void aTabUsedForIndentationIsNamed() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                \t      uri: timer:tick
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 3: the indentation uses a tab")
+                .contains("YAML indents with spaces")
+                .doesNotContain("cannot start any token");
+    }
+
+    /**
+     * CAMEL-24837: a list whose items start with a bare "-" on its own line 
is still the list the stray item belongs
+     * to; naming the nested list instead would point at the wrong place.
+     */
+    @Test
+    void aListWrittenWithBareDashesIsStillTheListThatIsNamed() throws 
Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - choice:
+                            when:
+                              -
+                                simple: "${body} > 1"
+                                steps:
+                                  - log:
+                                      message: big
+                            - simple: "${body} > 2"
+                              steps:
+                                - log:
+                                    message: bigger
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 12: this list item starts in column 13")
+                .contains("the list that starts at line 7 has its items in 
column 15");
+    }
+
+    /**
+     * CAMEL-24837: the parser names the stray item "&lt;block sequence 
start&gt;" instead of "-" when the list it broke
+     * uses bare dashes; it is the same mistake and gets the same message.
+     */
+    @Test
+    void aStrayItemReportedAsABlockSequenceStartIsNamedToo() throws Exception {
+        List<Error> errors = new YamlValidator().validate("""
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        -
+                          log:
+                            message: hi
+                         - log:
+                             message: there
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("line 8: this list item starts in column 10")
+                .contains("the list that starts at line 5 has its items in 
column 9")
+                .doesNotContain("block sequence start");
+    }
 }

Reply via email to