You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/09/13 13:52:21 UTC

(camel) 01/01: CAMEL-24698: camel-yaml-dsl-validator - schema errors say what to write: step, list, property, expression and script hints, required expressions, the file's shape

This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch pr/CAMEL-24698-validator-hints
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 79e2afb741cde7e96cc05cd173868c987c01037d
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun Sep 13 15:09:59 2026 +0200

    CAMEL-24698: camel-yaml-dsl-validator - schema errors say what to write: step, list, property, expression and script hints, required expressions, the file's shape
    
    Squash of the 29 commits on fix/CAMEL-24698-validator-hints (the run-by-run history is on bench/after-run).
    
    camel-yaml-dsl-validator - say what was probably meant when a property is not defined
    camel-yaml-dsl - a route file with a second YAML document is reported by the validator and named by the loader
    camel-yaml-dsl-validator - a map where a list is expected says how to write the list, an empty file says what a file is
    camel-yaml-dsl-validator - a bean item keyed by its name says to write name: as a property
    camel-yaml-dsl-validator - a //DEPS line at the top of a YAML file says to write it as a comment
    camel-yaml-dsl-validator - validate(File) applies the same checks as validate(String), so camel validate yaml reports the //DEPS and empty-file hints too
    camel-yaml-dsl-validator - text after the routes is named as such instead of 'while scanning a simple key'
    restore the generated YAML DSL schema rewritten by a partial build in the worktree
    camel-yaml-dsl-validator - language written as a property of an expression says to use it as the key
    camel-yaml-dsl-validator - a bean written with id: or class: says the properties are name and type
    camel-yaml-dsl-validator - prose after a list item is named by its own line, not the list item's line
    camel-yaml-dsl-validator - propertyName, headerName, variableName suggest name
    camel-yaml-dsl-validator - an expression node without an expression is reported
    camel-yaml-dsl-validator - a step with two keys says a step is one EIP
    camel-yaml-dsl-validator - bean as a language says method:, a header name as the key says name:, bean parameters say the method call
    camel-yaml-dsl-validator - otherwise, when, doTry, doCatch written without steps: say where the EIPs go
    camel-yaml-dsl-validator - steps: at the route level says it goes under from:
    camel-yaml-dsl-validator - a second key at the top level says the lines belong indented under the entry
    camel-yaml-dsl-validator - a log component option on the log EIP says to use a to: log: step
    camel-yaml-dsl-validator - an exception class used as a key under onException says it is a list item under exception:
    camel-yaml-dsl-validator - log: {level: INFO} suggests loggingLevel, not the log component
    camel-yaml-dsl-validator - simple syntax inside a groovy (or other language) expression is named
    camel-yaml-dsl-validator - a top-level entry written as a list (onException: [...]) says it is a map
    camel-yaml-dsl-validator - with the expression required in the schema (CAMEL-24707), the missing-expression message replaces the schema's oneOf noise
    camel-yaml-dsl-validator - a when item without a predicate gets the one-line message; sort is no longer reported (its expression is optional, CAMEL-24707)
    camel-yaml-dsl-validator - an unknown property with a hint drops the strict schema's oneOf and required errors at the same node
    camel-yaml-dsl-validator - resilience4j options written on circuitBreaker point to resilience4jConfiguration, name to id
    camel-yaml-dsl-validator - a string property written as a language map (log message: {simple: ...}) says it is a plain string
    camel-yaml-dsl-validator - a groovy GString is not simple syntax, script: as a language key names the language, a value continuing after its quote is named
    
    Co-Authored-By: Claude Fable 5.1 <no...@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj
---
 .../camel/dsl/yaml/validator/YamlValidator.java    | 712 ++++++++++++++++++++-
 .../validator/YamlValidatorPropertyHintTest.java   | 623 ++++++++++++++++++
 .../validator/YamlValidatorScalarLeniencyTest.java |   3 +
 .../camel/dsl/yaml/YamlRoutesBuilderLoader.java    |   8 +-
 4 files changed, 1335 insertions(+), 11 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 d5be9f63144d..32d9981c0f96 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
@@ -92,39 +92,393 @@ public class YamlValidator {
     }
 
     public List<Error> validate(File file) throws Exception {
-        if (schema == null) {
-            init();
-        }
-        try {
-            var target = mapper.readTree(file);
-            return validate(target);
-        } catch (Exception e) {
-            return List.of(parseError(e));
-        }
+        // the same checks as for content, so the CLI and the tools report the same
+        return validate(java.nio.file.Files.readString(file.toPath()));
     }
 
     public List<Error> validate(String content) throws Exception {
         if (schema == null) {
             init();
         }
+        Error extra = extraDocument(content);
+        if (extra != null) {
+            return List.of(extra);
+        }
+        Error deps = jbangDirective(content);
+        if (deps != null) {
+            return List.of(deps);
+        }
+        if (content == null || content.isBlank() || content.lines().allMatch(l -> l.isBlank() || l.trim().startsWith("#"))) {
+            return List.of(Error.builder().messageKey("empty").format(new MessageFormat("{0}"))
+                    .arguments("the file has no YAML: a Camel YAML file is a list of entries, each starting with \"- \":"
+                               + " - route:, - from:, - beans:, - rest:, - onException:")
+                    .build());
+        }
         try {
             var target = mapper.readTree(content);
             return validate(target);
         } catch (Exception e) {
-            return List.of(parseError(e));
+            return List.of(parseError(e, content));
         }
     }
 
+    private static final java.util.regex.Pattern LINE_COLUMN
+            = java.util.regex.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
+     * file, or a markdown fence) says so; the parser's "while scanning a simple key" does not.
+     */
+    static Error parseError(Exception e, String content) {
+        Error plain = parseError(e);
+        String msg = e.getMessage();
+        if (msg == null || content == null) {
+            return plain;
+        }
+        // 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);
+        while (m.find()) {
+            int l = Integer.parseInt(m.group(1));
+            if (!"1".equals(m.group(2)) || l < 2 || l > lines.length) {
+                continue;
+            }
+            String t = lines[l - 1].trim();
+            if (t.isEmpty() || t.startsWith("-") || t.startsWith("#") || t.startsWith("%")) {
+                continue;
+            }
+            line = l;
+            text = t;
+        }
+        if (text == null) {
+            // a value that continues after its closing quote: message: ">>> " + exchange.getIn().getBody()
+            java.util.regex.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);
+                if (q.find()) {
+                    String key = t.trim().contains(":") ? t.trim().substring(0, t.trim().indexOf(':')) : "the value";
+                    return Error.builder()
+                            .messageKey("parser")
+                            .format(new MessageFormat("{0}"))
+                            .arguments("line " + last + ": the value of " + key + " continues after its closing quote"
+                                       + " (\"...\" + ...): a YAML value is one string, there is no concatenation; a"
+                                       + " log message is a simple expression, write it as one quoted text such as"
+                                       + " \">>> ${body}\"")
+                            .build();
+                }
+            }
+            return plain;
+        }
+        String cleaned = msg.replace("\n", " ").replaceAll("\\s+", " ").trim();
+        int cut = cleaned.indexOf("in 'reader'");
+        String head = cut > 0 ? cleaned.substring(0, cut).trim() : cleaned;
+        return Error.builder()
+                .messageKey("parser")
+                .format(new MessageFormat("{0}"))
+                .arguments("line " + line + " is not YAML (\"" + (text.length() > 40 ? text.substring(0, 40) + "..." : text)
+                           + "\"): a route file holds only the YAML, put explanations in a # comment or leave them out"
+                           + " (" + head + ")")
+                .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
+     * YAML file uses instead.
+     */
+    static Error jbangDirective(String content) {
+        if (content == null) {
+            return null;
+        }
+        String[] lines = content.split("\n", -1);
+        for (int i = 0; i < lines.length; i++) {
+            String t = lines[i].trim();
+            if (t.isEmpty() || t.startsWith("#")) {
+                continue;
+            }
+            if (t.startsWith("//DEPS") || t.startsWith("//JAVA") || t.startsWith("//SOURCES") || t.startsWith("//")) {
+                return Error.builder()
+                        .messageKey("jbang")
+                        .format(new MessageFormat("{0}"))
+                        .arguments("line " + (i + 1) + ": " + t.split("\\s+")[0] + " is read as text by YAML, so the whole"
+                                   + " file becomes one string; write it as a YAML comment: # " + t
+                                   + " (camel-jbang reads //DEPS inside comments), or add the dependency with --dep or"
+                                   + " camel.jbang.dependencies in application.properties")
+                        .build();
+            }
+            break;
+        }
+        return null;
+    }
+
+    /**
+     * A Camel YAML file is one YAML document. The YAML parser used here reads the first document and ignores what
+     * follows a {@code ---} separator (often an explanation the author appended), while the runtime rejects the file
+     * with "expected a single document in the stream"; so it is reported here, with the line of the separator.
+     */
+    static Error extraDocument(String content) {
+        if (content == null) {
+            return null;
+        }
+        String[] lines = content.split("\n", -1);
+        boolean seenContent = false;
+        for (int i = 0; i < lines.length; i++) {
+            String t = lines[i].trim();
+            if (t.equals("---") || t.equals("...")) {
+                if (seenContent) {
+                    return Error.builder()
+                            .messageKey("document")
+                            .format(new MessageFormat("{0}"))
+                            .arguments("line " + (i + 1) + ": the file has more than one YAML document (a " + t
+                                       + " separator): a Camel YAML file is one document, remove the " + t
+                                       + " and everything after it")
+                            .build();
+                }
+            } else if (!t.isEmpty() && !t.startsWith("#") && !t.startsWith("%")) {
+                seenContent = true;
+            }
+        }
+        return null;
+    }
+
     private List<Error> validate(JsonNode target) {
         var errors = filterOneOfNoise(new ArrayList<>(schema.validate(target)));
         errors.removeIf(YamlValidator::isRuntimeAcceptedScalar);
         errors = withExpressionHints(errors);
+        errors = withPropertyHints(errors);
+        errors = withListHints(errors);
+        errors = withStepHints(errors);
+        // CAMEL-24707: the schema requires the expression, so a node without one fails its oneOf with "0 are valid"
+        // plus one "required property <language> not found" per language; replace that with one line that says
+        // what to write, at the node's own location
+        List<Error> missing = new ArrayList<>();
+        checkRequiredExpressions(target, new NodePath(PathType.JSON_POINTER), missing);
+        for (Error m : missing) {
+            String at = String.valueOf(m.getInstanceLocation());
+            errors.removeIf(e -> String.valueOf(e.getInstanceLocation()).equals(at)
+                    && ("oneOf".equals(e.getKeyword()) || "required".equals(e.getKeyword())));
+        }
+        errors.addAll(missing);
+        // an unknown property that got a hint (bean: as a language, a header name as the key...) is the cause; the
+        // oneOf and required errors the strict schema adds at the same location only repeat it thirty times
+        java.util.Set<String> hinted = new java.util.HashSet<>();
+        for (Error e : errors) {
+            if ("additionalProperties".equals(e.getKeyword())) {
+                hinted.add(String.valueOf(e.getInstanceLocation()));
+            }
+        }
+        if (!hinted.isEmpty()) {
+            errors.removeIf(e -> hinted.contains(String.valueOf(e.getInstanceLocation()))
+                    && ("oneOf".equals(e.getKeyword()) || "required".equals(e.getKeyword())));
+        }
+        if (errors.isEmpty()) {
+            checkSimpleSyntaxInScripts(target, new NodePath(PathType.JSON_POINTER), errors);
+        }
         if (canonical) {
             checkOneOfCardinality(target, new NodePath(PathType.JSON_POINTER), errors);
         }
         return errors;
     }
 
+    /**
+     * "must have at most 1 properties" at a step: a step holds one EIP, and the second key is either an option that
+     * belongs under the EIP (indented one level more) or another step (its own - item).
+     */
+    static List<Error> withStepHints(List<Error> errors) {
+        List<Error> answer = new ArrayList<>(errors.size());
+        for (Error error : errors) {
+            String location = String.valueOf(error.getInstanceLocation());
+            if ("maxProperties".equals(error.getKeyword()) && location.matches("/\\d+")) {
+                // - beans:\n  myBean: ... : the second key was meant to be inside the first; it is not indented enough
+                answer.add(Error.builder()
+                        .keyword("maxProperties")
+                        .instanceLocation(error.getInstanceLocation())
+                        .messageKey("maxProperties")
+                        .format(new MessageFormat("{0}"))
+                        .arguments(error.getMessage() + " (a top-level entry is one key: - route:, - beans:, - rest:...;"
+                                   + " the lines that belong to it must be indented under it, a second key at the same"
+                                   + " level as the entry is read as a separate property)")
+                        .build());
+                continue;
+            }
+            if ("maxProperties".equals(error.getKeyword()) && location.matches(".*/steps/\\d+")) {
+                answer.add(Error.builder()
+                        .keyword("maxProperties")
+                        .instanceLocation(error.getInstanceLocation())
+                        .messageKey("maxProperties")
+                        .format(new MessageFormat("{0}"))
+                        .arguments(error.getMessage() + " (a step is one EIP: an option of that EIP is indented under"
+                                   + " its key, and the next EIP is its own - item)")
+                        .build());
+            } else {
+                answer.add(error);
+            }
+        }
+        return answer;
+    }
+
+    /**
+     * The EIPs whose expression the runtime needs: the schema leaves it optional for every expression node, and a split
+     * written with only delimiter: "," (or a filter, setBody, when... with only options) fails when the route is
+     * created with "Unsupported definition: null".
+     */
+    private static final Set<String> LOG_COMPONENT_OPTIONS = Set.of(
+            "showAll", "showBody", "showBodyType", "showHeaders", "showExchangePattern", "showProperties",
+            "showAllProperties", "showVariables", "showExchangeId", "showException", "showCaughtException",
+            "showStackTrace", "showStreams", "showFiles", "showFuture", "showRouteId", "showRouteGroup", "multiline",
+            "maxChars", "skipBodyLineSeparator", "groupSize", "groupInterval", "groupDelay", "groupActiveOnly",
+            "level", "plain", "sourceLocationLoggerName", "style");
+
+    private static final Set<String> EXPRESSION_REQUIRED = Set.of(
+            "split", "filter", "when", "setBody", "setHeader", "setProperty", "setVariable", "transform", "loop",
+            "delay", "recipientList", "routingSlip", "dynamicRouter", "validate", "script", "throttle", "resequence",
+            "idempotentConsumer");
+
+    private static final Map<String, String> EXPRESSION_EXAMPLES = Map.of(
+            "split", "split: {tokenize: \",\"} or split: {simple: \"${body}\"} (delimiter only applies to the result of"
+                     + " the expression)",
+            "filter", "filter: {simple: \"${header.type} == 'urgent'\"}",
+            "when", "when: {simple: \"${body} contains 'x'\"}",
+            "setBody", "setBody: {simple: \"Hello ${body}\"} or setBody: {constant: \"Hello\"}",
+            "setHeader", "setHeader: {name: id, simple: \"${exchangeId}\"}",
+            "loop", "loop: {constant: \"3\"}",
+            "recipientList", "recipientList: {simple: \"${header.to}\"}",
+            "script", "script: {groovy: \"...\"}",
+            "delay", "delay: {constant: \"1000\"}");
+
+    private static final Set<String> SCRIPT_LANGUAGES = Set.of("groovy", "js", "python", "python3", "mvel", "ognl",
+            "jq", "jsonpath", "xpath", "xquery", "spel", "jactl", "java", "joor", "quickjs", "wasm", "datasonnet");
+
+    /**
+     * ${body.value} < 1 written as a groovy expression: Simple syntax inside another language, which groovy reads as a
+     * call to a method named $ and jsonpath as an invalid path. Says which language it is and how to write it there.
+     */
+    void checkSimpleSyntaxInScripts(JsonNode node, NodePath path, List<Error> errors) {
+        if (node == null) {
+            return;
+        }
+        if (node.isArray()) {
+            for (int i = 0; i < node.size(); i++) {
+                checkSimpleSyntaxInScripts(node.get(i), path.append(i), errors);
+            }
+            return;
+        }
+        if (!node.isObject()) {
+            return;
+        }
+        var fields = node.fieldNames();
+        while (fields.hasNext()) {
+            String name = fields.next();
+            JsonNode value = node.get(name);
+            String text = null;
+            if (SCRIPT_LANGUAGES.contains(name)) {
+                if (value.isTextual()) {
+                    text = value.asText();
+                } else if (value.isObject() && value.has("expression") && value.get("expression").isTextual()) {
+                    text = value.get("expression").asText();
+                }
+            }
+            String outsideQuotes
+                    = text != null ? text.replaceAll("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'", "\"\"") : null;
+            if (outsideQuotes != null && outsideQuotes.contains("${") && !name.equals("js") && !name.equals("quickjs")) {
+                // "${x}" inside quotes is a groovy GString (or a JS template) and is fine; ${...} outside quotes is simple
+                String example;
+                if (name.equals("groovy") || name.equals("mvel") || name.equals("ognl") || name.equals("jactl")) {
+                    example = "body.value < 1, headers.foo, exchange.getIn().getBody()";
+                } else if (name.equals("jsonpath") || name.equals("jq")) {
+                    example = "$.value for a JSON body";
+                } else if (name.equals("xpath") || name.equals("xquery")) {
+                    example = "/order/value for an XML body";
+                } else {
+                    example = "the language's own syntax";
+                }
+                errors.add(Error.builder()
+                        .keyword("type")
+                        .instanceLocation(path.append(name))
+                        .messageKey("type")
+                        .format(new MessageFormat("{0}"))
+                        .arguments(name + ": ${...} is simple syntax, not " + name + ": write the expression in " + name
+                                   + " (" + example + "), or use simple: \"" + text.replace("\"", "'") + "\"")
+                        .build());
+            }
+            checkSimpleSyntaxInScripts(value, path.append(name), errors);
+        }
+    }
+
+    /** Adds an error for every expression node in the tree that has neither expression: nor a language key. */
+    void checkRequiredExpressions(JsonNode node, NodePath path, List<Error> errors) {
+        if (node == null) {
+            return;
+        }
+        if (node.isArray()) {
+            for (int i = 0; i < node.size(); i++) {
+                checkRequiredExpressions(node.get(i), path.append(i), errors);
+            }
+            return;
+        }
+        if (!node.isObject()) {
+            return;
+        }
+        var fields = node.fieldNames();
+        while (fields.hasNext()) {
+            String name = fields.next();
+            JsonNode value = node.get(name);
+            if ("when".equals(name) && value != null && value.isArray()) {
+                // choice: {when: [...]}: each item is an expression node
+                for (int i = 0; i < value.size(); i++) {
+                    JsonNode item = value.get(i);
+                    if (item != null && item.isObject() && !hasExpression(item)) {
+                        errors.add(Error.builder()
+                                .keyword("required")
+                                .instanceLocation(path.append(name).append(i))
+                                .messageKey("required")
+                                .format(new MessageFormat("{0}"))
+                                .arguments("when has no expression: write the language as a key, for example "
+                                           + EXPRESSION_EXAMPLES.get("when"))
+                                .build());
+                    }
+                }
+            }
+            if (EXPRESSION_REQUIRED.contains(name) && (value == null || value.isNull() || value.isObject())
+                    && !hasExpression(value)) {
+                String example = EXPRESSION_EXAMPLES.getOrDefault(name,
+                        name + ": {simple: \"...\"} or " + name + ": {constant: \"...\"}");
+                errors.add(Error.builder()
+                        .keyword("required")
+                        .instanceLocation(path.append(name))
+                        .messageKey("required")
+                        .format(new MessageFormat("{0}"))
+                        .arguments(name + " has no expression: write the language as a key, for example " + example)
+                        .build());
+            }
+            checkRequiredExpressions(value, path.append(name), errors);
+        }
+    }
+
+    private boolean hasExpression(JsonNode value) {
+        if (value == null || value.isNull() || !value.isObject()) {
+            return true;
+        }
+        if (value.has("expression")) {
+            return true;
+        }
+        for (String language : languageKeys) {
+            if (value.has(language)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Filters noise from {@code oneOf} validation. When a {@code oneOf} has N branches and none match, the validator
      * reports errors from ALL branches — producing dozens of "required property 'X' not found" messages for branches
@@ -432,6 +786,332 @@ public class YamlValidator {
 
     private static final String EXPRESSION_SUB_ELEMENT = "ExpressionSubElementDefinition";
 
+    private JsonNode model;
+    private Set<String> topLevelEntries = Set.of();
+    private Set<String> languageKeys = Set.of();
+    private Set<String> stepNames = Set.of();
+    private Set<String> resilienceProperties = Set.of();
+
+    /**
+     * "object found, array expected" says what the schema wants, not how to write it: a list, each item starting with
+     * "- ". At the root of the file it also names the entries (route, from, beans, rest, onException).
+     */
+    static List<Error> withListHints(List<Error> errors) {
+        List<Error> answer = new ArrayList<>(errors.size());
+        for (Error error : errors) {
+            answer.add(withListHint(error));
+        }
+        return answer;
+    }
+
+    static Error withListHint(Error error) {
+        if (!"type".equals(error.getKeyword()) || error.getMessage() == null) {
+            return error;
+        }
+        String location = String.valueOf(error.getInstanceLocation());
+        if (location.endsWith("/language") && error.getMessage().contains("object expected")) {
+            // script: {language: groovy, text: ...}: the language is the key of the expression, not a property
+            return Error.builder()
+                    .keyword("type")
+                    .instanceLocation(error.getInstanceLocation())
+                    .messageKey("type")
+                    .format(new MessageFormat("{0}"))
+                    .arguments(error.getMessage() + " (an expression is written with the language as the key, e.g."
+                               + " groovy: \"...\", simple: \"...\", constant: \"...\"; the language: form is"
+                               + " language: {language: groovy, expression: \"...\"})")
+                    .build();
+        }
+        if (error.getMessage().contains("object found, string expected")) {
+            // message: {simple: "..."}: a string property that is already an expression, or a plain option
+            String prop = location.substring(location.lastIndexOf('/') + 1);
+            return Error.builder()
+                    .keyword("type")
+                    .instanceLocation(error.getInstanceLocation())
+                    .messageKey("type")
+                    .format(new MessageFormat("{0}"))
+                    .arguments(error.getMessage() + " (" + prop + " is a plain string"
+                               + (location.endsWith("/log/message")
+                                       ? " that is already a simple expression: write message: \"... ${body} ...\""
+                                       : ": write " + prop + ": \"...\", not a language map")
+                               + ")")
+                    .build();
+        }
+        if (location.matches(
+                "/\\d+/(onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|errorHandler|route|rest|restConfiguration)")
+                && error.getMessage().contains("array found, object expected")) {
+            // - onException: [ ... ]: the entry is a map; several handlers are several - onException: items
+            String entry = location.substring(location.lastIndexOf('/') + 1);
+            return Error.builder()
+                    .keyword("type")
+                    .instanceLocation(error.getInstanceLocation())
+                    .messageKey("type")
+                    .format(new MessageFormat("{0}"))
+                    .arguments(error.getMessage() + " (" + entry + " is a map, not a list: - " + entry + ": followed by its"
+                               + " properties indented" + (entry.equals("onException")
+                                       ? " (exception: [java.lang.Exception], handled: {constant: \"true\"}, steps: [...])"
+                                       : "")
+                               + "; several of them are several - " + entry + ": items)")
+                    .build();
+        }
+        if (location.matches(".*/(otherwise|doTry|doFinally|doCatch/\\d+)") && error.getMessage().contains("object expected")) {
+            // otherwise: [- log: ...]: the block is a map whose steps: holds the list
+            String eip = location.substring(location.lastIndexOf('/') + 1);
+            if (eip.matches("\\d+")) {
+                eip = "doCatch";
+            }
+            return Error.builder()
+                    .keyword("type")
+                    .instanceLocation(error.getInstanceLocation())
+                    .messageKey("type")
+                    .format(new MessageFormat("{0}"))
+                    .arguments(error.getMessage() + " (" + eip + " holds its EIPs under steps: " + eip
+                               + ": {steps: [- log: \"...\"]}" + (eip.equals("doCatch")
+                                       ? ", each - doCatch: item with"
+                                         + " exception: and steps:"
+                                       : "")
+                               + ")")
+                    .build();
+        }
+        if (!error.getMessage().contains("array expected")) {
+            return error;
+        }
+        String name = location.substring(location.lastIndexOf('/') + 1);
+        String hint;
+        if (location.isEmpty() || location.equals("/")) {
+            hint = "a Camel YAML file is a list of entries, each starting with \"- \": - route:, - from:, - beans:, - rest:,"
+                   + " - onException:";
+        } else if (name.equals("beans")) {
+            hint = "beans is a list: - name: myBean followed by type: \"#class:com.example.MyBean\" (indented under the -)";
+        } else if (name.equals("steps") || name.equals("when") || name.equals("get") || name.equals("post")
+                || name.equals("exception") || name.equals("doCatch")) {
+            hint = name + " is a list: each item starts with \"- \"";
+        } else {
+            hint = "write it as a list: each item starts with \"- \"";
+        }
+        return Error.builder()
+                .keyword("type")
+                .instanceLocation(error.getInstanceLocation())
+                .messageKey("type")
+                .format(new MessageFormat("{0}"))
+                .arguments(error.getMessage() + " (" + hint + ")")
+                .build();
+    }
+
+    /**
+     * Adds a hint to "property 'x' is not defined in the schema": the closest property name of that node (did you mean
+     * 'logName'?), or, when the property is a top-level entry such as onException written inside a route, where it goes
+     * instead.
+     */
+    List<Error> withPropertyHints(List<Error> errors) {
+        List<Error> answer = new ArrayList<>(errors.size());
+        for (Error error : errors) {
+            answer.add(withPropertyHint(error));
+        }
+        return answer;
+    }
+
+    Error withPropertyHint(Error error) {
+        if ("required".equals(error.getKeyword()) && error.getMessage() != null
+                && error.getMessage().contains("required property 'steps' not found")
+                && String.valueOf(error.getInstanceLocation()).matches(".*/route/from")) {
+            return Error.builder()
+                    .keyword("required")
+                    .instanceLocation(error.getInstanceLocation())
+                    .messageKey("required")
+                    .format(new MessageFormat("{0}"))
+                    .arguments(error.getMessage() + " (steps: is a property of from:, next to uri:; a steps: written at"
+                               + " the route level must be indented under from:)")
+                    .build();
+        }
+        if (!"additionalProperties".equals(error.getKeyword()) || error.getMessage() == null) {
+            return error;
+        }
+        String message = error.getMessage();
+        String unknown = between(message, "property '", "'");
+        if (unknown == null) {
+            return error;
+        }
+        String location = String.valueOf(error.getInstanceLocation());
+        String hint = null;
+        if (location.matches("/\\d+/beans/\\d+")
+                && (unknown.equals("id") || unknown.equals("ref") || unknown.equals("class"))) {
+            // - id: myBean / class: ... : the bean properties are name and type
+            hint = "a bean is - name: myBean followed by type: \"#class:com.example.MyBean\" (name instead of " + unknown
+                   + (unknown.equals("class") ? ", type instead of class" : "") + ")";
+        } else if (location.matches("/\\d+/beans/\\d+")) {
+            // - myBean: {type: ...} instead of - name: myBean / type: ...
+            hint = "a bean item is written as - name: " + unknown + " followed by type: \"#class:com.example.MyBean\" "
+                   + "(the name is a property, not the key)";
+        } else if (topLevelEntries.contains(unknown) && location.chars().filter(c -> c == '/').count() >= 2) {
+            hint = "'" + unknown + "' is a top-level entry: write it as a list item at the same level as the route, "
+                   + "not inside it";
+        } else if (location.matches(".*/(onException|doCatch/\\d+)") && unknown.matches("([a-z][\\w]*\\.)+[A-Z]\\w*")) {
+            // onException: {java.lang.Exception: ...}: the class is a list item under exception:
+            String eip = location.endsWith("/onException") ? "onException" : "doCatch";
+            hint = "the exception class is a list item under exception: (" + eip + ": {exception: [" + unknown
+                   + "], steps: [...]})";
+        } else if (location.endsWith("/circuitBreaker") && unknown.equals("name")) {
+            hint = "the circuit breaker's name is its id: circuitBreaker: {id: myBreaker, ...}";
+        } else if (location.endsWith("/circuitBreaker") && !resilienceProperties.isEmpty()
+                && (resilienceProperties.contains(unknown) || closest(unknown, resilienceProperties) != null
+                        || unknown.toLowerCase(Locale.ROOT).contains("threshold")
+                        || unknown.toLowerCase(Locale.ROOT).contains("timeout"))) {
+            // circuitBreaker: {failureThreshold: 5}: the thresholds and timeouts are resilience4j configuration
+            String best = resilienceProperties.contains(unknown) ? unknown : closest(unknown, resilienceProperties);
+            hint = "the thresholds, timeouts and the like are written under resilience4jConfiguration: (circuitBreaker:"
+                   + " {resilience4jConfiguration: {" + (best != null ? best : "failureRateThreshold") + ": ...}, steps:"
+                   + " [...], onFallback: {steps: [...]}})";
+        } else if (location.endsWith("/log") && (unknown.equals("level") || unknown.equals("logLevel"))) {
+            hint = "did you mean 'loggingLevel'?";
+        } else if (location.endsWith("/log") && LOG_COMPONENT_OPTIONS.contains(unknown)) {
+            // log: {message: ..., showHeaders: true}: those are options of the log component endpoint
+            hint = "'" + unknown + "' is an option of the log component, not of the log EIP: write a to: step with"
+                   + " uri: \"log:com.example?" + unknown + "=...\" (the log EIP has message, loggingLevel, logName,"
+                   + " marker)";
+        } else if (unknown.equals("steps") && location.matches(".*/route")) {
+            // - route: {from: {uri: ...}, steps: [...]}: steps belongs under from:
+            hint = "steps: goes under from:, indented at the same level as uri: (route: {from: {uri: ..., steps: [...]}})";
+        } else if (stepNames.contains(unknown) && !location.matches(".*/steps/\\d+")
+                && location.matches(".*/(otherwise|when/\\d+|doTry|doCatch/\\d+|doFinally|split|filter|loop|aggregate"
+                                    + "|circuitBreaker|onFallback|multicast|pipeline|saga|resequence|throttle|delay"
+                                    + "|onException|onCompletion|intercept|interceptFrom|interceptSendToEndpoint|route|from)")) {
+            // otherwise: {log: ...} or when: [- simple: ..., log: ...]: the EIPs go under steps:
+            String eip = location.substring(location.lastIndexOf('/') + 1);
+            if (eip.matches("\\d+")) {
+                String parent = location.substring(0, location.lastIndexOf('/'));
+                eip = parent.substring(parent.lastIndexOf('/') + 1);
+            }
+            hint = "'" + unknown + "' is a step: the steps of " + eip + " go under steps: (" + eip
+                   + ": {steps: [- " + unknown + ": ...]})";
+        } else if (unknown.equals("script") && !location.endsWith("/steps")
+                && (EXPRESSION_REQUIRED.contains(location.substring(location.lastIndexOf('/') + 1))
+                        || location.endsWith("/expression"))) {
+            // setBody: {script: ...}: script is an EIP; the language is the key of an expression
+            hint = "script is an EIP step, not a language: write the language as the key of the expression (groovy:"
+                   + " \"...\", simple: \"...\"), or run a script as its own step with - script: {groovy: \"...\"}";
+        } else if (unknown.equals("bean") && !location.endsWith("/steps")) {
+            // setBody: {bean: myBean} : the bean language is method:
+            hint = "the bean language is written as method: (method: {ref: myBean, method: process}), or call the bean"
+                   + " as a step with - bean: {ref: myBean, method: process}";
+        } else if (location.matches(".*/(setHeader|setProperty|setVariable|removeHeader|removeProperty|removeVariable)")
+                && closest(unknown, knownProperties(String.valueOf(error.getSchemaLocation()))) == null) {
+            // setHeader: {CamelNumberA: {simple: ...}} : the name is a property, not the key
+            String eip = location.substring(location.lastIndexOf('/') + 1);
+            hint = "the name is a property: " + eip + ": {name: " + unknown
+                   + (eip.startsWith("set") ? ", simple: \"...\"}" : "}") + " (" + unknown + " is not the key)";
+        } else if (location.endsWith("/bean")
+                && (unknown.equals("parameters") || unknown.equals("args") || unknown.equals("arguments"))) {
+            hint = "arguments are written in the method call: bean: {ref: myBean, method: \"process(${body}, 'x')\"}";
+        } else {
+            String best = closest(unknown, knownProperties(String.valueOf(error.getSchemaLocation())));
+            if (best != null) {
+                hint = "did you mean '" + best + "'?";
+            }
+        }
+        if (hint == null) {
+            return error;
+        }
+        return Error.builder()
+                .keyword("additionalProperties")
+                .instanceLocation(error.getInstanceLocation())
+                .messageKey("additionalProperties")
+                .format(new MessageFormat("{0}"))
+                .arguments(message + " (" + hint + ")")
+                .build();
+    }
+
+    /** The property names the schema allows at the definition an additionalProperties error points to. */
+    Set<String> knownProperties(String schemaLocation) {
+        Set<String> answer = new LinkedHashSet<>();
+        if (model == null) {
+            return answer;
+        }
+        int hash = schemaLocation.indexOf('#');
+        String pointer = hash >= 0 ? schemaLocation.substring(hash + 1) : schemaLocation;
+        if (pointer.endsWith("/additionalProperties")) {
+            pointer = pointer.substring(0, pointer.length() - "/additionalProperties".length());
+        }
+        collectProperties(model.at(pointer), answer, 0);
+        return answer;
+    }
+
+    private void collectProperties(JsonNode node, Set<String> answer, int depth) {
+        if (node == null || node.isMissingNode() || depth > 3) {
+            return;
+        }
+        JsonNode props = node.get("properties");
+        if (props != null) {
+            props.fieldNames().forEachRemaining(answer::add);
+        }
+        JsonNode ref = node.get("$ref");
+        if (ref != null && ref.isTextual() && ref.asText().startsWith("#")) {
+            collectProperties(model.at(ref.asText().substring(1)), answer, depth + 1);
+        }
+        for (String composition : new String[] { "anyOf", "oneOf", "allOf" }) {
+            JsonNode entries = node.get(composition);
+            if (entries != null && entries.isArray()) {
+                for (JsonNode entry : entries) {
+                    if (!entry.has("not")) {
+                        collectProperties(entry, answer, depth + 1);
+                    }
+                }
+            }
+        }
+    }
+
+    static String closest(String unknown, Set<String> known) {
+        String best = null;
+        int bestDistance = Integer.MAX_VALUE;
+        int threshold = Math.max(2, unknown.length() / 3);
+        String u = unknown.toLowerCase(Locale.ROOT);
+        for (String k : known) {
+            int d = distance(u, k.toLowerCase(Locale.ROOT));
+            if (d <= threshold && (d < bestDistance || d == bestDistance && k.length() < best.length())) {
+                best = k;
+                bestDistance = d;
+            }
+        }
+        if (best == null) {
+            // propertyName, headerName, variableName: the schema property is the last word (name)
+            for (String k : known) {
+                String kl = k.toLowerCase(Locale.ROOT);
+                if (kl.length() >= 3 && u.length() > kl.length() && u.endsWith(kl)
+                        && (best == null || k.length() > best.length())) {
+                    best = k;
+                }
+            }
+        }
+        return best;
+    }
+
+    static int distance(String a, String b) {
+        int[] prev = new int[b.length() + 1];
+        int[] cur = new int[b.length() + 1];
+        for (int j = 0; j <= b.length(); j++) {
+            prev[j] = j;
+        }
+        for (int i = 1; i <= a.length(); i++) {
+            cur[0] = i;
+            for (int j = 1; j <= b.length(); j++) {
+                int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+                cur[j] = Math.min(Math.min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
+            }
+            int[] t = prev;
+            prev = cur;
+            cur = t;
+        }
+        return prev[b.length()];
+    }
+
+    private static String between(String text, String start, String end) {
+        int i = text.indexOf(start);
+        if (i < 0) {
+            return null;
+        }
+        int j = text.indexOf(end, i + start.length());
+        return j < 0 ? null : text.substring(i + start.length(), j);
+    }
+
     private static boolean isBooleanText(String text) {
         String s = text.trim();
         return "true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s);
@@ -482,6 +1162,18 @@ public class YamlValidator {
     public void init() throws Exception {
         String location = canonical ? LOCATION_CANONICAL : LOCATION;
         var model = mapper.readTree(YamlValidator.class.getResourceAsStream(location));
+        this.model = model;
+        this.topLevelEntries = new LinkedHashSet<>();
+        model.at("/items/properties").fieldNames().forEachRemaining(topLevelEntries::add);
+        this.stepNames = new LinkedHashSet<>();
+        model.at("/items/definitions/org.apache.camel.model.ProcessorDefinition/properties").fieldNames()
+                .forEachRemaining(stepNames::add);
+        this.resilienceProperties = new LinkedHashSet<>();
+        model.at("/items/definitions/org.apache.camel.model.Resilience4jConfigurationDefinition/properties").fieldNames()
+                .forEachRemaining(resilienceProperties::add);
+        this.languageKeys = new LinkedHashSet<>();
+        model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition/properties").fieldNames()
+                .forEachRemaining(languageKeys::add);
         var version = getSpecificationVersion(model).orElse(SpecificationVersion.DRAFT_4);
         // no typeLoose: besides accepting quoted scalars it also accepts a single value where the schema expects a
         // list (steps: written as a map), which the runtime rejects. The scalar leniency the runtime has is done as
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
new file mode 100644
index 000000000000..87ebfe43a8ba
--- /dev/null
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorPropertyHintTest.java
@@ -0,0 +1,623 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.yaml.validator;
+
+import java.util.List;
+
+import com.networknt.schema.Error;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24698: "property 'x' is not defined in the schema" should say what was probably meant: the closest option name,
+ * or that the entry belongs at the top level of the file.
+ */
+public class YamlValidatorPropertyHintTest {
+
+    private static YamlValidator validator;
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        validator = new YamlValidator();
+        validator.init();
+    }
+
+    @Test
+    public void testMisspelledOptionGetsTheClosestName() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                          loggerName: sensor
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).contains("loggerName").contains("did you mean 'logName'?");
+    }
+
+    @Test
+    public void testTopLevelEntryInsideARouteSaysWhereItGoes() throws Exception {
+        List<Error> errors = validator.validate("""
+                - route:
+                    id: r
+                    onException:
+                      exception:
+                        - java.lang.Exception
+                      steps:
+                        - log:
+                            message: "error"
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - log:
+                            message: "hi"
+                """);
+        assertThat(errors).isNotEmpty();
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("'onException' is a top-level entry"));
+    }
+
+    @Test
+    public void testFarOffNameGetsNoGuess() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - split:
+                          tokenize: ","
+                          cheese: true
+                          steps:
+                            - log:
+                                message: "hi"
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).contains("cheese").doesNotContain("did you mean");
+    }
+
+    @Test
+    public void testSecondDocumentIsReported() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                ---
+                Run it with camel run and you will see hi every second.
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).contains("line 6").contains("more than one YAML document");
+        // a leading document marker is fine
+        assertThat(validator.validate("""
+                ---
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """)).isEmpty();
+    }
+
+    @Test
+    public void testMapWhereAListIsExpectedSaysHowToWriteIt() throws Exception {
+        List<Error> errors = validator.validate("""
+                route:
+                  from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """);
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("array expected") && e.getMessage().contains("- route:"));
+
+        errors = validator.validate("""
+                - beans:
+                    myBean:
+                      type: "#class:com.example.MyBean"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """);
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("beans is a list: - name: myBean"));
+
+        errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      log:
+                        message: "hi"
+                """);
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("steps is a list"));
+    }
+
+    @Test
+    public void testBeanItemKeyedByNameSaysHowToWriteIt() throws Exception {
+        List<Error> errors = validator.validate("""
+                - beans:
+                    - myBean:
+                        type: "#class:com.example.MyBean"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """);
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("a bean item is written as - name: myBean"));
+    }
+
+    @Test
+    public void testJbangDepsLineIsNamed() throws Exception {
+        List<Error> errors = validator.validate("""
+                //DEPS org.apache.camel:camel-groovy
+
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).contains("line 1: //DEPS is read as text by YAML")
+                .contains("# //DEPS org.apache.camel:camel-groovy").contains("camel.jbang.dependencies");
+        // the comment form is what camel-jbang reads, and it is plain YAML
+        assertThat(validator.validate("""
+                # //DEPS org.apache.camel:camel-groovy
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """)).isEmpty();
+    }
+
+    @Test
+    public void testProseAfterTheRoutesIsNamed() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+
+                Run it with camel run and you will see hi every second.
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).startsWith("line 7 is not YAML (\"Run it with camel run").contains("# comment");
+    }
+
+    @Test
+    public void testProseAfterAListItemNamesTheProseLine() throws Exception {
+        // the parser reports the list item on line 1 first and the prose line last; the prose line is the problem
+        List<Error> errors = validator.validate("""
+                - route:
+                    from:
+                      uri: "timer:tick?period=5000"
+                      steps:
+                        - log:
+                            message: "tick"
+
+                To run it, execute in the same directory:
+                camel run timer-log.camel.yaml
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).startsWith("line 8 is not YAML (\"To run it, execute").contains("# comment");
+    }
+
+    @Test
+    public void testPropertyNameSuggestsName() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setProperty:
+                          propertyName: attempt
+                          constant: "1"
+                """);
+        assertThat(errors).anySatisfy(
+                e -> assertThat(e.getMessage()).contains("property 'propertyName' is not defined")
+                        .contains("did you mean 'name'?"));
+    }
+
+    @Test
+    public void testSplitWithoutAnExpressionIsReported() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          constant: "a,b,c"
+                      - split:
+                          delimiter: ","
+                          streaming: true
+                          steps:
+                            - log: "${body}"
+                      - filter:
+                          steps:
+                            - log: "kept"
+                """);
+        assertThat(errors).hasSize(2);
+        assertThat(errors.get(0).getInstanceLocation().toString()).isEqualTo("/0/from/steps/1/split");
+        assertThat(errors.get(0).getMessage()).startsWith("split has no expression: write the language as a key")
+                .contains("tokenize").contains("delimiter only applies");
+        assertThat(errors.get(1).getMessage()).startsWith("filter has no expression");
+    }
+
+    @Test
+    public void testExpressionNodesWithAnExpressionAreFine() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - split:
+                          tokenize: ","
+                          steps:
+                            - log: "${body}"
+                      - setHeader:
+                          name: id
+                          expression:
+                            simple: "${exchangeId}"
+                      - choice:
+                          when:
+                            - simple: "${body} != null"
+                              steps:
+                                - log: "x"
+                """);
+        assertThat(errors).isEmpty();
+    }
+
+    @Test
+    public void testTwoKeysInOneStepSayAStepIsOneEip() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - to:
+                          uri: file:input?fileName=customers.xml
+                        steps: []
+                      - log: "done"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("must have at most 1 properties")
+                .contains("a step is one EIP"));
+    }
+
+    @Test
+    public void testBeanAsALanguageSaysMethod() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          bean: myProcessor
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'bean' is not defined")
+                .contains("the bean language is written as method:"));
+        // the strict schema's oneOf and per-language required errors at the same node are dropped
+        assertThat(errors).noneSatisfy(e -> assertThat(e.getMessage()).contains("0 are valid"));
+        assertThat(errors).hasSize(1);
+    }
+
+    @Test
+    public void testHeaderNameAsTheKeySaysName() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setHeader:
+                          CamelNumberA:
+                            constant: "5"
+                      - bean:
+                          ref: calc
+                          parameters: [1, 2]
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'CamelNumberA' is not defined")
+                .contains("the name is a property: setHeader: {name: CamelNumberA, simple: \"...\"}"));
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'parameters' is not defined")
+                .contains("arguments are written in the method call"));
+    }
+
+    @Test
+    public void testOtherwiseAsAListSaysSteps() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - choice:
+                          when:
+                            - simple: "${body} != null"
+                              steps:
+                                - log: "a"
+                          otherwise:
+                            - log: "b"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("array found, object expected")
+                .contains("otherwise holds its EIPs under steps: otherwise: {steps: [- log: \"...\"]}"));
+    }
+
+    @Test
+    public void testStepDirectlyUnderOtherwiseOrWhenSaysSteps() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - choice:
+                          when:
+                            - simple: "${body} != null"
+                              log: "a"
+                          otherwise:
+                            log: "b"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'log' is not defined")
+                .contains("the steps of otherwise go under steps: (otherwise: {steps: [- log: ...]})"));
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'log' is not defined")
+                .contains("the steps of when go under steps:"));
+    }
+
+    @Test
+    public void testStepsAtTheRouteLevelSaysUnderFrom() throws Exception {
+        List<Error> errors = validator.validate("""
+                - route:
+                    id: r
+                    from:
+                      uri: timer:tick
+                    steps:
+                      - log: "a"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'steps' is not defined")
+                .contains("steps: goes under from:"));
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("required property 'steps' not found")
+                .contains("indented under from:"));
+    }
+
+    @Test
+    public void testSecondKeyAtTheTopLevelSaysIndent() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log: "a"
+                - beans:
+                  myBean:
+                    type: "#class:com.example.MyBean"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("must have at most 1 properties")
+                .contains("must be indented under it"));
+    }
+
+    @Test
+    public void testLogComponentOptionOnTheLogEipSaysToStep() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "a"
+                          showHeaders: true
+                          level: INFO
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'showHeaders' is not defined")
+                .contains("is an option of the log component, not of the log EIP")
+                .contains("log:com.example?showHeaders=..."));
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'level' is not defined")
+                .contains("did you mean 'loggingLevel'?"));
+    }
+
+    @Test
+    public void testExceptionClassAsTheKeySaysList() throws Exception {
+        List<Error> errors = validator.validate("""
+                - onException:
+                    java.lang.Exception:
+                      handled:
+                        constant: "true"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log: "a"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'java.lang.Exception' is not defined")
+                .contains(
+                        "the exception class is a list item under exception: (onException: {exception: [java.lang.Exception]"));
+    }
+
+    @Test
+    public void testSimpleSyntaxInsideGroovyIsNamed() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - choice:
+                          when:
+                            - groovy: "${body.value} < 1"
+                              steps:
+                                - log: "small"
+                          otherwise:
+                            steps:
+                              - log: "big"
+                      - setBody:
+                          groovy: "body.value * 2"
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage())
+                .startsWith("groovy: ${...} is simple syntax, not groovy: write the expression in groovy (body.value < 1")
+                .contains("or use simple: \"${body.value} < 1\"");
+    }
+
+    @Test
+    public void testOnExceptionAsAListSaysMap() throws Exception {
+        List<Error> errors = validator.validate("""
+                - onException:
+                    - exception:
+                        - java.lang.Exception
+                      steps:
+                        - log: "oops"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log: "a"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("array found, object expected")
+                .contains("onException is a map, not a list").contains("several - onException: items"));
+    }
+
+    @Test
+    public void testWhenItemWithoutAnExpressionIsReportedOnce() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - choice:
+                          when:
+                            - steps:
+                                - log: "a"
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getInstanceLocation().toString()).isEqualTo("/0/from/steps/0/choice/when/0");
+        assertThat(errors.get(0).getMessage()).startsWith("when has no expression");
+    }
+
+    @Test
+    public void testSortWithoutAnExpressionIsFine() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - sort:
+                          comparator: "#cmp"
+                      - log: "sorted"
+                """);
+        assertThat(errors).isEmpty();
+    }
+
+    @Test
+    public void testResilienceOptionsOnTheCircuitBreakerSayConfiguration() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - circuitBreaker:
+                          name: myBreaker
+                          failureThreshold: 5
+                          steps:
+                            - to: "http://example.com"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'name' is not defined")
+                .contains("the circuit breaker's name is its id"));
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'failureThreshold' is not defined")
+                .contains("resilience4jConfiguration: {failureRateThreshold: ...}"));
+    }
+
+    @Test
+    public void testLogMessageAsAnExpressionMapSaysPlainString() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message:
+                            simple: "Body: ${body}"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("object found, string expected")
+                .contains("message is a plain string that is already a simple expression"));
+    }
+
+    @Test
+    public void testGroovyGStringInsideQuotesIsFine() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          groovy: |
+                            def t = exchange.getIn().getHeader('type')
+                            "type is ${t}"
+                """);
+        assertThat(errors).isEmpty();
+    }
+
+    @Test
+    public void testScriptAsALanguageKeySaysLanguage() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - setBody:
+                          script: "body.toUpperCase()"
+                """);
+        assertThat(errors).anySatisfy(e -> assertThat(e.getMessage()).contains("property 'script' is not defined")
+                .contains("script is an EIP step, not a language"));
+    }
+
+    @Test
+    public void testValueContinuingAfterItsQuoteIsNamed() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: ">>> [INFO] " + exchange.getIn().getBody()
+                """);
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).startsWith("line 5: the value of message continues after its closing quote")
+                .contains("no concatenation");
+    }
+
+    @Test
+    public void testLanguageAsAPropertySaysToUseItAsTheKey() throws Exception {
+        List<Error> errors = validator.validate("""
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - script:
+                          language: groovy
+                          text: "println 'hi'"
+                """);
+        assertThat(errors).anyMatch(
+                e -> e.getMessage().contains("the language as the key") && e.getMessage().contains("groovy: \"...\""));
+    }
+
+    @Test
+    public void testBeanWithIdInsteadOfNameSaysSo() throws Exception {
+        List<Error> errors = validator.validate("""
+                - beans:
+                    - id: myBean
+                      type: "#class:com.example.MyBean"
+                - from:
+                    uri: timer:tick
+                    steps:
+                      - log:
+                          message: "hi"
+                """);
+        assertThat(errors).anyMatch(e -> e.getMessage().contains("name instead of id"));
+    }
+
+    @Test
+    public void testEmptyFileSaysWhatAFileIs() throws Exception {
+        List<Error> errors = validator.validate("# nothing here yet\n");
+        assertThat(errors).hasSize(1);
+        assertThat(errors.get(0).getMessage()).contains("no YAML").contains("- route:");
+    }
+
+    @Test
+    public void testDistance() {
+        assertThat(YamlValidator.distance("loggername", "logname")).isEqualTo(3);
+        assertThat(YamlValidator.closest("loggerName", java.util.Set.of("logName", "message", "marker"))).isEqualTo("logName");
+        assertThat(YamlValidator.closest("cheese", java.util.Set.of("steps", "id"))).isNull();
+    }
+}
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
index 7e3d6ba5b807..961f31a64cb9 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorScalarLeniencyTest.java
@@ -195,6 +195,9 @@ public class YamlValidatorScalarLeniencyTest {
                       steps:
                         - %s:
                             %s
+                            expression:
+                              tokenize:
+                                token: ","
                             steps:
                               - log:
                                   message: "${body}"
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
index de4298dc917b..87b35d19718c 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/main/java/org/apache/camel/dsl/yaml/YamlRoutesBuilderLoader.java
@@ -677,7 +677,13 @@ public class YamlRoutesBuilderLoader extends YamlRoutesBuilderLoaderSupport {
                 composer.getSingleNode()
                         .map(node -> preParseNode(ctx, node));
             } catch (Exception e) {
-                throw new RuntimeCamelException("Error pre-parsing resource: " + ctx.getResource().getLocation(), e);
+                String hint = "";
+                if (e.getMessage() != null && e.getMessage().contains("single document")) {
+                    // a --- separator followed by more text, often an explanation appended to the route file
+                    hint = ": the file has more than one YAML document (a --- separator); a Camel YAML file is one"
+                           + " document, remove the --- and everything after it";
+                }
+                throw new RuntimeCamelException("Error pre-parsing resource: " + ctx.getResource().getLocation() + hint, e);
             } finally {
                 ctx.close();
             }