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);
+ }
+ }
+}