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 9432ba16d026 chore: camel-yaml-dsl-validator, camel-jbang - hints for 
five shapes a local model could not fix from the message (#27666)
9432ba16d026 is described below

commit 9432ba16d026909f02a191992155d795b739afda
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 10 17:42:43 2026 +0200

    chore: camel-yaml-dsl-validator, camel-jbang - hints for five shapes a 
local model could not fix from the message (#27666)
    
    * chore: camel-yaml-dsl-validator, camel-jbang - hints for five shapes a 
local model could not fix from the message
    
    - period: {{welcome.period}} without quotes: the parse error "Expected a 
field name ... MappingStartEvent" had no line; now the line and the quoted form 
to write.
    - path: {sku} without quotes: says it is a YAML map and how to quote it.
    - cron:cron?cron=...: an option named after the component no longer gets 
"Did you mean: [cronService]" (and the quick fix that renamed it); it says what 
the endpoint takes, cron:name?schedule=...
    - maximumRedeliveries on onException, retryAttemptedLogLevel on 
deadLetterChannel: a redelivery policy option goes under redeliveryPolicy: 
(instead of no hint, or "did you mean 'level'?" and its quick fix).
    - groovy: {script: ...}, simple: {text: ...}: the text of any language goes 
in expression:.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    * chore: review: an INVENTED_OPTIONS entry is the one hint for an option 
named after the component; compute the braces once
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../dsl/jbang/core/commands/ai/EndpointChecks.java |  33 ++++-
 .../commands/ai/SourceValidatorEndpointTest.java   |  17 +++
 .../camel/dsl/yaml/validator/SchemaHints.java      |  38 +++++
 .../camel/dsl/yaml/validator/YamlValidator.java    |  38 +++++
 .../QuotingAndOptionPlacementHintTest.java         | 161 +++++++++++++++++++++
 5 files changed, 285 insertions(+), 2 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
index c6ef07caacc7..1325afaa38bc 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointChecks.java
@@ -662,6 +662,29 @@ final class EndpointChecks {
         return false;
     }
 
+    /**
+     * What an endpoint takes, for an option written with the component's 
name: its syntax and required options, as "no
+     * option is named after the component: write cron:name?schedule=...". 
Null when the catalog does not know it.
+     */
+    static String namedAfterComponent(CamelCatalog catalog, String scheme) {
+        try {
+            var model = catalog.componentModel(scheme);
+            if (model == null || model.getSyntax() == null) {
+                return null;
+            }
+            List<String> required = new ArrayList<>();
+            for (var option : model.getEndpointOptions()) {
+                if (option.isRequired() && 
"parameter".equals(option.getKind())) {
+                    required.add(option.getName() + "=...");
+                }
+            }
+            return "no option is named after the component: write " + 
model.getSyntax()
+                   + (required.isEmpty() ? "" : "?" + String.join("&", 
required));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     static void collectEndpointErrors(
             List<String> errors, EndpointValidationResult result, String 
scheme, CamelCatalog catalog,
             int uriLineIdx, Map<String, Integer> optionLineMap) {
@@ -672,13 +695,19 @@ final class EndpointChecks {
                     continue;
                 }
                 StringBuilder sb = new StringBuilder(scheme).append(": Unknown 
option '").append(name).append("'");
-                if (result.getUnknownSuggestions() != null) {
+                String known = INVENTED_OPTIONS.get(scheme + ":" + name);
+                // cron:cron?cron=0/10 * * * * ?: an option named after the 
component, for which the closest name
+                // (cronService) is a fix that makes it worse; say what the 
endpoint takes instead (an entry of
+                // INVENTED_OPTIONS for it says more, and is the one hint)
+                String named = known == null && name.equals(scheme) ? 
namedAfterComponent(catalog, scheme) : null;
+                if (named != null) {
+                    sb.append(" (").append(named).append(")");
+                } else if (result.getUnknownSuggestions() != null) {
                     String[] suggestions = 
result.getUnknownSuggestions().get(name);
                     if (suggestions != null && suggestions.length > 0) {
                         sb.append(". Did you mean: 
").append(Arrays.asList(suggestions));
                     }
                 }
-                String known = INVENTED_OPTIONS.get(scheme + ":" + name);
                 if (known != null) {
                     sb.append(" (").append(known).append(")");
                 }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
index d0c9c9346bf4..39382f8f2c9c 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointTest.java
@@ -374,6 +374,23 @@ class SourceValidatorEndpointTest {
         assertThat(msgs.get(2)).contains("Unknown option 
'mkdir'").contains("autoCreate=true");
     }
 
+    @Test
+    void anOptionNamedAfterTheComponentSaysWhatTheEndpointTakes() {
+        // the closest name was cronService, and the quick fix renamed cron= 
to that
+        List<String> msgs = SourceValidator.validateYamlEndpoints("""
+                - from:
+                    uri: cron:cron?cron=0/10 * * * * ?
+                    steps:
+                      - log:
+                          message: "x"
+                """, catalog);
+        assertThat(msgs).singleElement().satisfies(m -> assertThat(m)
+                .contains("Unknown option 'cron'")
+                .contains("no option is named after the component: write 
cron:name?schedule=...")
+                .doesNotContain("Did you mean"));
+        assertThat(QuickFixes.fixFor(msgs.get(0), "    uri: 
cron:cron?cron=0/10 * * * * ?")).isNull();
+    }
+
     @Test
     void aWildcardInTheFileIncludeOptionIsNamedAsNotARegex() {
         List<String> msgs = SourceValidator.validateYamlEndpoints("""
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
index 7f4c8edec0c8..7f4e46efc8b1 100644
--- 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/SchemaHints.java
@@ -118,6 +118,23 @@ final class SchemaHints {
 
     private static final Predicate<Match> ANY = m -> true;
 
+    /** {sku} when the node is the map YAML reads an unquoted {sku} as (one 
key, no value); null otherwise. */
+    static String unquotedBraces(Match m) {
+        JsonNode instance = m.error().getInstanceNode();
+        if (instance == null || !instance.isObject() || instance.size() != 1) {
+            return null;
+        }
+        String key = instance.fieldNames().next();
+        return instance.get(key).isNull() ? "{" + key + "}" : null;
+    }
+
+    /** The text of a scalar property of the error's node, or the fallback 
when it is not a scalar. */
+    private static String valueOf(Match m, String property, String fallback) {
+        JsonNode instance = m.error().getInstanceNode();
+        JsonNode value = instance != null ? instance.get(property) : null;
+        return value != null && value.isValueNode() && !value.isNull() ? 
value.asText() : fallback;
+    }
+
     /** The EIP names a model writes for the exchange properties, and the ones 
Camel has. */
     private static final Map<String, String> EXCHANGE_PROPERTY_EIPS = Map.of(
             "setExchangeProperty", "setProperty", "setExchangeProperties", 
"setProperties",
@@ -338,6 +355,13 @@ final class SchemaHints {
                     m -> "an expression is written with the language as the 
key and its expression: property, e.g."
                          + " groovy: {expression: \"...\"}, simple: 
{expression: \"...\"}, constant: {expression: \"...\"};"
                          + " the language: form is language: {language: 
groovy, expression: \"...\"}"),
+            // path: {sku}: braces without quotes are a YAML map ({sku: 
null}), not the text {sku}
+            append("type", null, m -> m.message().contains("object found, 
string expected") && unquotedBraces(m) != null,
+                    m -> {
+                        String braces = unquotedBraces(m);
+                        return braces + " without quotes is a YAML map (braces 
open a map in YAML), not text: quote it, "
+                               + m.name() + ": \"" + braces + "\"";
+                    }),
             // message: {simple: "..."}: a string property that is already an 
expression, or a plain option
             append("type", null, m -> m.message().contains("object found, 
string expected"),
                     m -> m.name() + " is a plain string"
@@ -552,6 +576,20 @@ final class SchemaHints {
             unknownProperty(".*/jsonpath", m -> 
m.unknown().equalsIgnoreCase("jsonPath") || m.unknown().equals("path"),
                     m -> "the JSONPath text goes under expression: (jsonpath: 
{expression: \"$[?(@.sku == 'X')]\","
                          + " resultType: java.util.List})"),
+            // onException: {maximumRedeliveries: 3}, deadLetterChannel: 
{retryAttemptedLogLevel: WARN}: an option of
+            // the redelivery policy written next to it, where the closest 
name (level) is not what was meant
+            unknownProperty(".*/(onException|" + String.join("|", 
ROUTE_ERROR_HANDLER_KINDS) + ")",
+                    m -> 
m.validator().redeliveryPolicyProperties().contains(m.unknown()),
+                    m -> m.unknown() + " is an option of the redelivery 
policy: write it under redeliveryPolicy:, as"
+                         + " redeliveryPolicy: {" + m.unknown() + ": " + 
valueOf(m, m.unknown(), "...") + "}"),
+            // groovy: {script: "..."}, simple: {text: "..."}: the text of 
every language goes in expression:
+            unknownProperty(null,
+                    m -> m.validator().languageKeys().contains(m.name()) && 
!m.name().equals("language")
+                            && m.error().getInstanceNode() != null
+                            && !m.error().getInstanceNode().has("expression")
+                            && 
m.error().getInstanceNode().path(m.unknown()).isTextual(),
+                    m -> "the " + m.name() + " text goes in expression:, write 
" + m.name() + ": {expression: \""
+                         + valueOf(m, m.unknown(), "...").replace("\"", 
"\\\"") + "\"}"),
             unknownProperty(null,
                     m -> YamlValidator.closest(m.unknown(), 
m.validator().knownProperties(m.schemaLocation())) != null,
                     m -> "did you mean '"
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 5db8ff7df451..badbef19ab4b 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
@@ -222,6 +222,12 @@ public class YamlValidator {
         if (tab != null) {
             return tab;
         }
+        // period: {{welcome.period}}: braces open a YAML map, and a map as a 
key fails with "Expected a field name"
+        // and no line; the property placeholder must be quoted
+        Error placeholder = unquotedPlaceholder(lines);
+        if (placeholder != null) {
+            return placeholder;
+        }
         // the message names several positions (the collection being parsed, 
then the token that broke it); the
         // problem is at the last one
         int line = -1;
@@ -302,6 +308,29 @@ public class YamlValidator {
                 .build();
     }
 
+    /** A value (of a key or a list item) that starts with a property 
placeholder without quotes. */
+    private static final Pattern UNQUOTED_PLACEHOLDER = 
Pattern.compile("^(\\s*(?:-\\s+)?(?:[\\w.-]+:\\s+)?)(\\{\\{.*)$");
+
+    /** The first line whose value is an unquoted {{placeholder}}, said with 
the line to write; null when none. */
+    static Error unquotedPlaceholder(String[] lines) {
+        for (int i = 0; i < lines.length; i++) {
+            String line = lines[i];
+            if (line.strip().startsWith("#")) {
+                continue;
+            }
+            Matcher m = UNQUOTED_PLACEHOLDER.matcher(line);
+            // a line of a block scalar (a script) that starts with {{ has no 
key or - in front: not this
+            if (m.matches() && !m.group(1).isBlank()) {
+                String value = m.group(2).strip();
+                String quoted = value.contains("\"") ? "'" + value + "'" : 
"\"" + value + "\"";
+                return hint("line " + (i + 1) + ": " + value + " without 
quotes is read as a YAML map (braces open a"
+                            + " map in YAML), not as a property placeholder: 
quote the value, write the line as "
+                            + (m.group(1) + quoted).strip());
+            }
+        }
+        return null;
+    }
+
     /** A mapping key alone at column 1: errorHandler:, beans:, 
restConfiguration:. */
     private static final Pattern TOP_KEY = 
Pattern.compile("^([A-Za-z][\\w-]*):\\s*$");
 
@@ -1366,6 +1395,7 @@ public class YamlValidator {
     private Set<String> languageKeys = Set.of();
     private Set<String> stepNames = Set.of();
     private Set<String> resilienceProperties = Set.of();
+    private Set<String> redeliveryPolicyProperties = Set.of();
 
     /** The keys of the file's entries (route, from, beans, rest, 
onException...), from the schema. */
     Set<String> topLevelEntries() {
@@ -1400,6 +1430,11 @@ public class YamlValidator {
         return answer;
     }
 
+    /** The properties of redeliveryPolicy (maximumRedeliveries, 
redeliveryDelay...), from the schema. */
+    Set<String> redeliveryPolicyProperties() {
+        return redeliveryPolicyProperties;
+    }
+
     /** The properties of resilience4jConfiguration, from the schema. */
     Set<String> resilienceProperties() {
         return resilienceProperties;
@@ -1585,6 +1620,9 @@ public class YamlValidator {
         this.resilienceProperties = new LinkedHashSet<>();
         
model.at("/items/definitions/org.apache.camel.model.Resilience4jConfigurationDefinition/properties").fieldNames()
                 .forEachRemaining(resilienceProperties::add);
+        this.redeliveryPolicyProperties = new LinkedHashSet<>();
+        
model.at("/items/definitions/org.apache.camel.model.RedeliveryPolicyDefinition/properties").fieldNames()
+                .forEachRemaining(redeliveryPolicyProperties::add);
         this.languageKeys = new LinkedHashSet<>();
         
model.at("/items/definitions/org.apache.camel.model.language.ExpressionDefinition/properties").fieldNames()
                 .forEachRemaining(languageKeys::add);
diff --git 
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/QuotingAndOptionPlacementHintTest.java
 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/QuotingAndOptionPlacementHintTest.java
new file mode 100644
index 000000000000..bb401243e07c
--- /dev/null
+++ 
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/QuotingAndOptionPlacementHintTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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 org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Shapes a local model wrote and could not fix from the message it got: 
braces without quotes, an option of the
+ * redelivery policy next to it, and the text of a language under another key 
than expression.
+ */
+public class QuotingAndOptionPlacementHintTest {
+
+    private static YamlValidator validator;
+
+    @BeforeAll
+    public static void setup() throws Exception {
+        validator = new YamlValidator();
+        validator.init();
+    }
+
+    @Test
+    public void anUnquotedPropertyPlaceholder() {
+        // the parser said "Expected a field name (Scalar value in YAML), got 
this instead: <MappingStartEvent...>"
+        String yaml = """
+                - route:
+                    from:
+                      uri: timer:welcome
+                      parameters:
+                        period: {{welcome.period}}
+                      steps:
+                        - log:
+                            message: "hi"
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .contains("line 5: {{welcome.period}} without quotes is read 
as a YAML map")
+                .contains("write the line as period: \"{{welcome.period}}\"")
+                .doesNotContain("MappingStartEvent"));
+    }
+
+    @Test
+    public void unquotedBracesWhereTextIsExpected() {
+        String yaml = """
+                - rest:
+                    path: /stock
+                    get:
+                      - path: {sku}
+                        to:
+                          uri: direct:one-sku
+                - route:
+                    from:
+                      uri: direct:one-sku
+                      steps:
+                        - log:
+                            message: "x"
+                """;
+        assertThat(lines(yaml)).singleElement().satisfies(m -> assertThat(m)
+                .startsWith("Line 4: ")
+                .contains("{sku} without quotes is a YAML map")
+                .contains("quote it, path: \"{sku}\""));
+    }
+
+    @Test
+    public void anOptionOfTheRedeliveryPolicyNextToIt() {
+        String yaml = """
+                - onException:
+                    exception:
+                      - java.lang.IllegalStateException
+                    maximumRedeliveries: 0
+                    handled:
+                      constant: "true"
+                    steps:
+                      - to:
+                          uri: direct:parked
+                - errorHandler:
+                    deadLetterChannel:
+                      deadLetterUri: direct:parked
+                      redeliveryPolicy:
+                        maximumRedeliveries: 2
+                      retryAttemptedLogLevel: WARN
+                - route:
+                    from:
+                      uri: direct:parked
+                      steps:
+                        - log:
+                            message: "x"
+                """;
+        List<String> lines = lines(yaml);
+        assertThat(lines).hasSize(2);
+        assertThat(lines).anySatisfy(m -> assertThat(m)
+                .startsWith("Line 4: ")
+                .contains("maximumRedeliveries is an option of the redelivery 
policy")
+                .contains("redeliveryPolicy: {maximumRedeliveries: 0}"));
+        // the closest name was level, and the quick fix renamed it to that
+        assertThat(lines).anySatisfy(m -> assertThat(m)
+                .startsWith("Line 15: ")
+                .contains("redeliveryPolicy: {retryAttemptedLogLevel: WARN}")
+                .doesNotContain("did you mean"));
+    }
+
+    @Test
+    public void theTextOfALanguageUnderAnotherKey() {
+        String yaml = """
+                - route:
+                    from:
+                      uri: timer:x
+                      steps:
+                        - setBody:
+                            expression:
+                              groovy:
+                                script: "body.find { it.sku == headers.sku }"
+                        - setHeader:
+                            name: sku
+                            expression:
+                              groovy:
+                                code: 'exchange.getVariable("sku")'
+                        - filter:
+                            expression:
+                              simple:
+                                text: "${body} != null"
+                            steps:
+                              - log:
+                                  message: "x"
+                """;
+        List<String> lines = lines(yaml);
+        assertThat(lines).anySatisfy(m -> assertThat(m)
+                .contains("the groovy text goes in expression:, write groovy: 
{expression:"
+                          + " \"body.find { it.sku == headers.sku }\"}"));
+        // a double quote in the text is escaped in the form to write
+        assertThat(lines).anySatisfy(m -> assertThat(m)
+                .contains("write groovy: {expression: 
\"exchange.getVariable(\\\"sku\\\")\"}"));
+        assertThat(lines).anySatisfy(m -> assertThat(m)
+                .contains("the simple text goes in expression:, write simple: 
{expression: \"${body} != null\"}"));
+    }
+
+    private static List<String> lines(String yaml) {
+        try {
+            return YamlValidator.describeAll(yaml, validator.validate(yaml));
+        } catch (Exception e) {
+            throw new AssertionError("Failed to validate:\n" + yaml, e);
+        }
+    }
+}

Reply via email to