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 24ee906d97a8 chore: camel-jbang, camel-yaml-dsl-validator - parity of
the YAML, Java and XML route checks; YAML errors on their line
24ee906d97a8 is described below
commit 24ee906d97a84f37381f5aa37b2d06e191791c4b
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 10:21:23 2026 +0200
chore: camel-jbang, camel-yaml-dsl-validator - parity of the YAML, Java and
XML route checks; YAML errors on their line
Follow-up of CAMEL-25208 (#27172). YAML routes are checked on their text,
Java and XML routes on
the model they are read into, so the three DSLs could drift apart.
DslParityTest writes the same
routes in all three DSLs and asserts the same problem on the matching line:
unknown option,
invalid enum, producer option on a consumer, bad simple predicate or
function, ${...} in to,
producer-only from, several endpoints in one uri, and an unconsumed direct
endpoint.
It found that YAML schema errors had no line. YamlPointerLines
(camel-yaml-dsl-validator) maps the
JSON pointer of a schema error to its line, so camel validate, the
validator maven plugin, MCP and
the TUI now report "Line N: ..." for YAML as for Java and XML. The TUI
quick doc also names the
language of a YAML expression and whether it is a predicate, and falls back
to the CLI catalog.
Closes #27181
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../jbang/core/commands/ai/SourceValidator.java | 33 ++-
.../dsl/jbang/core/commands/ai/DslParityTest.java | 319 +++++++++++++++++++++
.../jbang/core/commands/tui/SourceEditAssist.java | 70 ++++-
.../core/commands/tui/SourceValidationSupport.java | 4 +
.../dsl/jbang/core/commands/tui/SourceViewer.java | 2 +-
.../commands/tui/YamlLanguageQuickDocTest.java | 76 +++++
.../commands/validate/YamlValidateCommand.java | 14 +-
.../camel/dsl/yaml/validator/ValidateMojo.java | 15 +-
.../camel/dsl/yaml/validator/YamlPointerLines.java | 117 ++++++++
.../camel/dsl/yaml/validator/YamlValidator.java | 32 +++
.../dsl/yaml/validator/YamlPointerLinesTest.java | 73 +++++
11 files changed, 746 insertions(+), 9 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index 0a8c9248ca1e..aef6616b292a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -36,7 +36,9 @@ import com.networknt.schema.Error;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
+import org.apache.camel.dsl.yaml.validator.YamlPointerLines;
import org.apache.camel.dsl.yaml.validator.YamlValidator;
+import org.snakeyaml.engine.v2.nodes.Node;
import static
org.apache.camel.dsl.jbang.core.commands.ai.JavaChecks.JAVA_CLASS_PATTERN;
import static
org.apache.camel.dsl.jbang.core.commands.ai.JavaChecks.JAVA_PACKAGE_PATTERN;
@@ -249,7 +251,7 @@ public final class SourceValidator {
return false;
}
try {
- msgs.addAll(formatSchemaErrors(validator.validate(content,
bodylessEndpoints)));
+ msgs.addAll(formatSchemaErrors(validator.validate(content,
bodylessEndpoints), content));
return true;
} catch (Exception e) {
msgs.add("Invalid YAML: " + e.getMessage());
@@ -397,12 +399,37 @@ public final class SourceValidator {
}
}
+ /**
+ * Whether the expression whose language key is on the given line of a
YAML route is evaluated as a predicate by the
+ * EIP it belongs to (the expression of a filter or a when, the
completionPredicate of an aggregate).
+ *
+ * @param catalog the catalog, for the EIP options that are predicates
+ * @param lines the lines of the YAML route
+ * @param lineIdx the index of the line of the language key (simple:)
+ */
+ public static boolean isYamlPredicate(CamelCatalog catalog, String[]
lines, int lineIdx) {
+ if (lineIdx < 0 || lineIdx >= lines.length) {
+ return false;
+ }
+ return SimpleChecks.isPredicate(catalog, lines, lineIdx,
YamlLines.countLeadingSpaces(lines[lineIdx]));
+ }
+
/** The YAML DSL schema errors in words: the node they are about and the
message without parser noise. */
public static List<String> formatSchemaErrors(List<Error> errors) {
+ return formatSchemaErrors(errors, null);
+ }
+
+ /**
+ * The schema errors of a YAML source as messages; with the content, each
one the line of the YAML it points at can
+ * be found for starts with "Line N: ", as the other checks report, so an
editor marks it on its line.
+ */
+ public static List<String> formatSchemaErrors(List<Error> errors, String
content) {
List<String> msgs = new ArrayList<>();
if (errors == null) {
return msgs;
}
+ Node root = content != null && !errors.isEmpty()
+ ? YamlPointerLines.root(content) : null;
for (Error error : errors) {
String msg = error.getMessage();
if (msg == null) {
@@ -411,7 +438,9 @@ public final class SourceValidator {
String loc = error.getInstanceLocation() != null ?
error.getInstanceLocation().toString() : null;
String node = extractNodeName(loc);
String clean = cleanValidationMessage(msg);
- msgs.add(node != null ? node + ": " + clean : clean);
+ String text = node != null ? node + ": " + clean : clean;
+ int line = YamlPointerLines.line(root, loc, clean);
+ msgs.add(line > 0 && !text.startsWith("Line ") ? "Line " + line +
": " + text : text);
}
return msgs;
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DslParityTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DslParityTest.java
new file mode 100644
index 000000000000..94b37f908e51
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/DslParityTest.java
@@ -0,0 +1,319 @@
+/*
+ * 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.jbang.core.commands.ai;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The same routes written in the YAML, Java and XML DSL get the same problems
on the matching lines. YAML is checked on
+ * its text and Java and XML on the model they are read into; the checks of a
uri and of an expression are shared, and
+ * this keeps what finds them from drifting apart.
+ */
+class DslParityTest {
+
+ private static CamelCatalog catalog;
+
+ @TempDir
+ Path dir;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ /**
+ * A route in the three DSLs.
+ *
+ * @param name what the case is about
+ * @param marker a text on the line the problem is on, the same in the
three DSLs; null when the route is valid
+ * @param key a text the message of the problem has in the three DSLs
+ */
+ record Case(String name, String yaml, String java, String xml, String
marker, String key) {
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ private static String yaml(String steps, String from) {
+ return """
+ - route:
+ from:
+ uri: "%s"
+ steps:
+ %s""".formatted(from, steps.indent(8));
+ }
+
+ private static String java(String steps, String from) {
+ return """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class MyRoute extends RouteBuilder {
+ @Override
+ public void configure() throws Exception {
+ from("%s")
+ %s;
+ }
+ }
+ """.formatted(from,
steps.stripTrailing().indent(12).stripTrailing());
+ }
+
+ private static String xml(String steps, String from) {
+ return """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="%s"/>
+ %s </route>
+ </routes>
+ """.formatted(from.replace("&", "&"), steps.indent(8));
+ }
+
+ static Stream<Case> cases() {
+ return Stream.of(
+ new Case(
+ "valid",
+ yaml("""
+ - filter:
+ expression:
+ simple:
+ expression: "${header.foo} == 'bar'"
+ steps:
+ - to:
+ uri: "seda:out"
+ """, "timer:tick?period=1000"),
+ java("""
+ .filter(simple("${header.foo} == 'bar'"))
+ .to("seda:out")
+ .end()
+ """, "timer:tick?period=1000"),
+ xml("""
+ <filter>
+ <simple>${header.foo} == 'bar'</simple>
+ <to uri="seda:out"/>
+ </filter>
+ """, "timer:tick?period=1000"),
+ null, null),
+ new Case(
+ "unknown option",
+ yaml("""
+ - to:
+ uri: "seda:out"
+ """, "timer:tick?peroid=1000"),
+ java("""
+ .to("seda:out")
+ """, "timer:tick?peroid=1000"),
+ xml("""
+ <to uri="seda:out"/>
+ """, "timer:tick?peroid=1000"),
+ "peroid", "Unknown option 'peroid'"),
+ new Case(
+ "invalid enum value",
+ yaml("""
+ - to:
+ uri: "file:out?fileExist=Overide"
+ """, "timer:tick"),
+ java("""
+ .to("file:out?fileExist=Overide")
+ """, "timer:tick"),
+ xml("""
+ <to uri="file:out?fileExist=Overide"/>
+ """, "timer:tick"),
+ "Overide", "Invalid enum value 'Overide'"),
+ new Case(
+ "producer option on a consumer",
+ yaml("""
+ - to:
+ uri: "seda:out"
+ """, "seda:in?blockWhenFull=true"),
+ java("""
+ .to("seda:out")
+ """, "seda:in?blockWhenFull=true"),
+ xml("""
+ <to uri="seda:out"/>
+ """, "seda:in?blockWhenFull=true"),
+ "blockWhenFull", "not applicable in consumer only
mode"),
+ new Case(
+ "simple predicate that does not parse",
+ yaml("""
+ - filter:
+ expression:
+ simple:
+ expression: "${header.foo} =="
+ steps:
+ - to:
+ uri: "seda:out"
+ """, "timer:tick"),
+ java("""
+ .filter(simple("${header.foo} =="))
+ .to("seda:out")
+ .end()
+ """, "timer:tick"),
+ xml("""
+ <filter>
+ <simple>${header.foo} ==</simple>
+ <to uri="seda:out"/>
+ </filter>
+ """, "timer:tick"),
+ "${header.foo} ==", "Simple syntax error"),
+ new Case(
+ "simple expression with an unknown function",
+ yaml("""
+ - setBody:
+ expression:
+ simple:
+ expression: "Hello ${headr.name}"
+ """, "timer:tick"),
+ java("""
+ .setBody(simple("Hello ${headr.name}"))
+ """, "timer:tick"),
+ xml("""
+ <setBody>
+ <simple>Hello ${headr.name}</simple>
+ </setBody>
+ """, "timer:tick"),
+ "headr", "Simple syntax error"),
+ new Case(
+ "to with an expression",
+ yaml("""
+ - to:
+ uri: "seda:${header.queue}"
+ """, "timer:tick"),
+ java("""
+ .to("seda:${header.queue}")
+ """, "timer:tick"),
+ xml("""
+ <to uri="seda:${header.queue}"/>
+ """, "timer:tick"),
+ "${header.queue}", "toD"),
+ new Case(
+ "producer-only component as from",
+ yaml("""
+ - to:
+ uri: "seda:out"
+ """, "log:in"),
+ java("""
+ .to("seda:out")
+ """, "log:in"),
+ xml("""
+ <to uri="seda:out"/>
+ """, "log:in"),
+ "log:in", "producer-only"),
+ new Case(
+ "several endpoints in one uri",
+ yaml("""
+ - to:
+ uri: "seda:a,seda:b"
+ """, "timer:tick"),
+ java("""
+ .to("seda:a,seda:b")
+ """, "timer:tick"),
+ xml("""
+ <to uri="seda:a,seda:b"/>
+ """, "timer:tick"),
+ "seda:a,seda:b", "names several"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("cases")
+ void theSameProblemsInTheThreeDsls(Case c) {
+ check("route.camel.yaml", c.yaml(), c);
+ check("MyRoute.java", c.java(), c);
+ check("routes.xml", c.xml(), c);
+ }
+
+ private static void check(String file, String content, Case c) {
+ List<String> errors = SourceValidator.validate(file, content, catalog,
null, null);
+ if (c.marker() == null) {
+ assertThat(errors).as(file + "\n" + content).isEmpty();
+ return;
+ }
+ assertThat(errors).as(file + "\n" + content).hasSize(1);
+ assertThat(errors.get(0)).as(file).startsWith("Line " +
lineOf(content, c.marker()) + ": ").contains(c.key());
+ }
+
+ private static int lineOf(String content, String marker) {
+ String[] lines = content.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ if (lines[i].contains(marker)) {
+ return i + 1;
+ }
+ }
+ throw new IllegalArgumentException(marker + " is not in\n" + content);
+ }
+
+ /** A misspelled option of a YAML route: an error of the YAML DSL schema,
reported on the line of the option. */
+ @Test
+ void aMisspelledYamlOptionIsReportedOnItsLine() {
+ String yaml = yaml("""
+ - log:
+ message: "${body}"
+ logLevel: WARN
+ """, "timer:tick");
+ List<String> errors = SourceValidator.validate("route.camel.yaml",
yaml, catalog, null, null);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("Line " + lineOf(yaml,
"logLevel") + ": ").contains("logLevel");
+ }
+
+ /** A direct: endpoint no route of the directory consumes, in the three
DSLs. */
+ @Test
+ void aDirectEndpointNoRouteConsumes() throws Exception {
+ Files.writeString(dir.resolve("other.camel.yaml"), """
+ - route:
+ from:
+ uri: "direct:present"
+ steps:
+ - log:
+ message: "${body}"
+ """);
+ Case c = new Case(
+ "direct",
+ yaml("""
+ - to:
+ uri: "direct:present"
+ - to:
+ uri: "direct:missing"
+ """, "timer:tick"),
+ java("""
+ .to("direct:present")
+ .to("direct:missing")
+ """, "timer:tick"),
+ xml("""
+ <to uri="direct:present"/>
+ <to uri="direct:missing"/>
+ """, "timer:tick"),
+ "direct:missing", "sends to direct:missing, and no route
consumes it");
+ for (String[] f : new String[][] {
+ { "route.camel.yaml", c.yaml() }, { "MyRoute.java", c.java()
}, { "routes.xml", c.xml() } }) {
+ List<String> errors = SourceValidator.validate(f[0], f[1],
catalog, null, dir);
+ assertThat(errors).as(f[0]).hasSize(1);
+ assertThat(errors.get(0)).as(f[0]).contains(c.key());
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index c53c171f4427..5043cc166a5f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -154,7 +154,7 @@ final class SourceEditAssist {
}
Map<Integer, List<SourceViewer.DocEntry>>
provideCamelQuickDocs(List<JsonObject> codeData) {
- CamelCatalog catalog = getCatalog();
+ CamelCatalog catalog = validationCatalog();
if (catalog == null || codeData.isEmpty()) {
return Map.of();
}
@@ -188,7 +188,7 @@ final class SourceEditAssist {
}
List<SourceViewer.DocEntry> provideEditQuickDoc(List<String> lines, int
cursorRow) {
- CamelCatalog catalog = getCatalog();
+ CamelCatalog catalog = validationCatalog();
if (catalog == null || lines == null || cursorRow < 0 || cursorRow >=
lines.size()) {
return List.of();
}
@@ -209,6 +209,12 @@ final class SourceEditAssist {
}
}
+ // the language of an expression, as the Java and XML routes have it
(simple: or its expression:)
+ SourceViewer.DocEntry languageDoc = resolveLanguageDoc(catalog, lines,
cursorRow);
+ if (languageDoc != null) {
+ return List.of(languageDoc);
+ }
+
// check if inside a parameters: block — look up component endpoint
option doc
SourceViewer.DocEntry optionDoc = resolveParameterOptionDoc(catalog,
lines, cursorRow);
if (optionDoc != null) {
@@ -235,6 +241,66 @@ final class SourceEditAssist {
return List.of();
}
+ /**
+ * The language of the expression on the line: a language key (simple:
"${body}" or simple: with expression: below
+ * it) or the expression: under one, as "Simple predicate: ${...}" where
the EIP evaluates it as a predicate.
+ */
+ SourceViewer.DocEntry resolveLanguageDoc(CamelCatalog catalog,
List<String> lines, int cursorRow) {
+ String key = yamlKey(lines.get(cursorRow));
+ if (key == null) {
+ return null;
+ }
+ int languageRow = -1;
+ String text = null;
+ if (catalog.languageModel(key) != null) {
+ languageRow = cursorRow;
+ text = yamlValue(lines.get(cursorRow));
+ if (text.isEmpty() && cursorRow + 1 < lines.size() &&
"expression".equals(yamlKey(lines.get(cursorRow + 1)))) {
+ text = yamlValue(lines.get(cursorRow + 1));
+ }
+ } else if ("expression".equals(key)) {
+ int indent = countLeadingSpaces(lines.get(cursorRow));
+ for (int i = cursorRow - 1; i >= 0; i--) {
+ String l = lines.get(i);
+ if (l.isBlank()) {
+ continue;
+ }
+ if (countLeadingSpaces(l) < indent) {
+ String parent = yamlKey(l);
+ if (parent != null && catalog.languageModel(parent) !=
null) {
+ languageRow = i;
+ key = parent;
+ text = yamlValue(lines.get(cursorRow));
+ }
+ break;
+ }
+ }
+ }
+ if (languageRow < 0 || text == null || text.isEmpty()) {
+ return null;
+ }
+ LanguageModel language = catalog.languageModel(key);
+ boolean predicate = SourceValidator.isYamlPredicate(catalog,
lines.toArray(new String[0]), languageRow);
+ return SourceViewer.DocEntry.of((language.getTitle() != null ?
language.getTitle() : key)
+ + (predicate ? " predicate: " : "
expression: ") + text);
+ }
+
+ /** The key of a YAML line (simple of "- simple: x"), or null. */
+ private static String yamlKey(String line) {
+ Matcher m = YAML_KEY_PATTERN.matcher(line);
+ return m.find() ? m.group(1) : null;
+ }
+
+ /** The value of a YAML line after its key, unquoted; empty when it has
none. */
+ private static String yamlValue(String line) {
+ int colon = line.indexOf(':');
+ String v = colon >= 0 ? line.substring(colon + 1).trim() : "";
+ if (v.length() >= 2 && (v.startsWith("\"") && v.endsWith("\"") ||
v.startsWith("'") && v.endsWith("'"))) {
+ v = v.substring(1, v.length() - 1);
+ }
+ return v;
+ }
+
SourceViewer.DocEntry resolveEipOptionDoc(CamelCatalog catalog,
List<String> lines, int cursorRow) {
String cursorLine = lines.get(cursorRow);
String trimmed = cursorLine.trim();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceValidationSupport.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceValidationSupport.java
index 2f59a9b85cb3..392a1cefdda2 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceValidationSupport.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceValidationSupport.java
@@ -71,6 +71,10 @@ final class SourceValidationSupport {
return SourceValidator.formatSchemaErrors(errors);
}
+ static List<String> formatSchemaErrors(List<com.networknt.schema.Error>
errors, String content) {
+ return SourceValidator.formatSchemaErrors(errors, content);
+ }
+
static String extractNodeName(String instanceLocation) {
return SourceValidator.extractNodeName(instanceLocation);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 356feb3c1d79..67dd394cbc8c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -1462,7 +1462,7 @@ class SourceViewer {
routeProblems = List.of();
if (validateOnSave && isCamelYamlFile()) {
List<String> msgs = new ArrayList<>();
-
msgs.addAll(SourceValidationSupport.formatSchemaErrors(validateYaml(content)));
+
msgs.addAll(SourceValidationSupport.formatSchemaErrors(validateYaml(content),
content));
if (endpointValidator != null) {
List<String> endpointErrors =
endpointValidator.validate(content);
if (endpointErrors != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlLanguageQuickDocTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlLanguageQuickDocTest.java
new file mode 100644
index 000000000000..a2c55b0c9e51
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlLanguageQuickDocTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The quick doc of an expression line of a YAML route says its language, as
the Java and XML routes do.
+ */
+class YamlLanguageQuickDocTest {
+
+ private static SourceEditAssist assist() {
+ AtomicReference<List<IntegrationInfo>> data = new
AtomicReference<>(List.of());
+ AtomicReference<List<InfraInfo>> infraData = new
AtomicReference<>(List.of());
+ return new SourceEditAssist(new MonitorContext(data, infraData));
+ }
+
+ private static final List<String> YAML = List.of("""
+ - route:
+ from:
+ uri: "timer:tick"
+ steps:
+ - filter:
+ expression:
+ simple:
+ expression: "${header.foo} == 'bar'"
+ steps:
+ - setBody:
+ simple: "Hello ${body}"
+ """.split("\\n"));
+
+ @Test
+ void theLanguageOfAnExpressionLine() {
+ SourceEditAssist assist = assist();
+ // the language key and the expression: under it, a predicate of the
filter
+ assertThat(assist.provideEditQuickDoc(YAML,
6)).extracting(SourceViewer.DocEntry::text)
+ .containsExactly("Simple predicate: ${header.foo} == 'bar'");
+ assertThat(assist.provideEditQuickDoc(YAML,
7)).extracting(SourceViewer.DocEntry::text)
+ .containsExactly("Simple predicate: ${header.foo} == 'bar'");
+ // the inline form, an expression of setBody
+ assertThat(assist.provideEditQuickDoc(YAML,
10)).extracting(SourceViewer.DocEntry::text)
+ .containsExactly("Simple expression: Hello ${body}");
+ // the same text as the Java route has for its filter
+ List<String> java = List.of("""
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .filter(simple("${header.foo} == 'bar'"))
+ .to("seda:out");
+ }
+ }
+ """.split("\\n"));
+ assertThat(assist.provideRouteEditQuickDoc(Path.of("MyRoute.java"),
java, 3))
+ .extracting(SourceViewer.DocEntry::text).contains("Simple
predicate: ${header.foo} == 'bar'");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
index 816c7b175ac5..1cda503ed94d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-validate/src/main/java/org/apache/camel/dsl/jbang/core/commands/validate/YamlValidateCommand.java
@@ -17,6 +17,7 @@
package org.apache.camel.dsl.jbang.core.commands.validate;
import java.io.File;
+import java.io.IOException;
import java.nio.file.Files;
import java.text.MessageFormat;
import java.util.ArrayList;
@@ -111,8 +112,9 @@ public class YamlValidateCommand extends CamelCommand {
var report = e.getValue();
if (!report.isEmpty()) {
sb.append("\tFile: ").append(name).append("\n");
- for (var r : report) {
- sb.append("\t\t").append(r.toString()).append("\n");
+ // the line of the file each error points at, not only its
pointer
+ for (String r :
YamlValidator.describeAll(readQuietly(name), report)) {
+ sb.append("\t\t").append(r).append("\n");
}
sb.append("\n");
}
@@ -127,6 +129,14 @@ public class YamlValidateCommand extends CamelCommand {
return 0;
}
+ private static String readQuietly(String file) {
+ try {
+ return Files.readString(new File(file).toPath());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
static Error catalogError(String message) {
return Error.builder()
.messageKey("catalog")
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/ValidateMojo.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/ValidateMojo.java
index 4cac71a01fb7..fa6e23d84426 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/ValidateMojo.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator-maven-plugin/src/main/java/org/apache/camel/dsl/yaml/validator/ValidateMojo.java
@@ -17,6 +17,8 @@
package org.apache.camel.dsl.yaml.validator;
import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -164,8 +166,9 @@ public class ValidateMojo extends AbstractMojo {
var report = e.getValue();
if (!report.isEmpty()) {
sb.append("\tFile: ").append(name).append("\n");
- for (var r : report) {
- sb.append("\t\t").append(r.toString()).append("\n");
+ // the line of the file each error points at, not only its
pointer
+ for (String r :
YamlValidator.describeAll(readQuietly(e.getKey()), report)) {
+ sb.append("\t\t").append(r).append("\n");
}
sb.append("\n");
}
@@ -187,6 +190,14 @@ public class ValidateMojo extends AbstractMojo {
return count;
}
+ private static String readQuietly(File file) {
+ try {
+ return Files.readString(file.toPath());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
private List<Error> validateYamlRoute(File file) throws Exception {
getLog().debug("Validating YAML DSL in file: " + file);
return validator.validate(file);
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlPointerLines.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlPointerLines.java
new file mode 100644
index 000000000000..f6b092ca5827
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/main/java/org/apache/camel/dsl/yaml/validator/YamlPointerLines.java
@@ -0,0 +1,117 @@
+/*
+ * 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.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.snakeyaml.engine.v2.api.LoadSettings;
+import org.snakeyaml.engine.v2.composer.Composer;
+import org.snakeyaml.engine.v2.nodes.MappingNode;
+import org.snakeyaml.engine.v2.nodes.Node;
+import org.snakeyaml.engine.v2.nodes.NodeTuple;
+import org.snakeyaml.engine.v2.nodes.ScalarNode;
+import org.snakeyaml.engine.v2.nodes.SequenceNode;
+import org.snakeyaml.engine.v2.parser.ParserImpl;
+import org.snakeyaml.engine.v2.scanner.StreamReader;
+
+/**
+ * The line of the YAML text a JSON pointer of a schema error points at
(/0/route/from/steps/0/to/uri), so the error can
+ * be reported on its line as the other checks are, and marked on that line in
an editor.
+ */
+public final class YamlPointerLines {
+
+ /** The property a schema error is about: "property 'logLevel' is not
defined in the schema". */
+ private static final Pattern PROPERTY = Pattern.compile("property
'([^']+)'");
+
+ private YamlPointerLines() {
+ }
+
+ /** The root node of the YAML text, or null when it does not parse. */
+ public static Node root(String content) {
+ if (content == null || content.isBlank()) {
+ return null;
+ }
+ try {
+ LoadSettings settings = LoadSettings.builder().build();
+ Composer composer = new Composer(settings, new
ParserImpl(settings, new StreamReader(settings, content)));
+ return composer.getSingleNode().orElse(null);
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The 1-based line the pointer points at, or the line of the property the
message names when the pointer is the
+ * mapping it is not allowed in; 0 when it cannot be found.
+ */
+ public static int line(Node root, String pointer, String message) {
+ if (root == null || pointer == null) {
+ return 0;
+ }
+ Node node = root;
+ for (String segment : pointer.split("/")) {
+ if (segment.isEmpty()) {
+ continue;
+ }
+ String key = segment.replace("~1", "/").replace("~0", "~");
+ node = child(node, key);
+ if (node == null) {
+ return 0;
+ }
+ }
+ if (message != null && node instanceof MappingNode) {
+ // an unknown property: the line of its key rather than of the
mapping it is in
+ Matcher m = PROPERTY.matcher(message);
+ if (m.find()) {
+ Node keyNode = key((MappingNode) node, m.group(1));
+ if (keyNode != null) {
+ node = keyNode;
+ }
+ }
+ }
+ return node.getStartMark().map(mark -> mark.getLine() + 1).orElse(0);
+ }
+
+ private static Node child(Node node, String key) {
+ if (node instanceof SequenceNode seq) {
+ try {
+ int index = Integer.parseInt(key);
+ return index >= 0 && index < seq.getValue().size() ?
seq.getValue().get(index) : null;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ if (node instanceof MappingNode map) {
+ for (NodeTuple t : map.getValue()) {
+ if (t.getKeyNode() instanceof ScalarNode s &&
key.equals(s.getValue())) {
+ return t.getValueNode();
+ }
+ }
+ }
+ return null;
+ }
+
+ private static Node key(MappingNode map, String key) {
+ for (NodeTuple t : map.getValue()) {
+ if (t.getKeyNode() instanceof ScalarNode s &&
key.equals(s.getValue())) {
+ return t.getKeyNode();
+ }
+ }
+ return null;
+ }
+}
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 a991ce33b718..ed05a0318289 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
@@ -55,6 +55,7 @@ import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.tooling.model.EipModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.snakeyaml.engine.v2.nodes.Node;
/**
* YAML DSL validator that tooling can use to validate Camel source files if
they can be parsed and are valid according
@@ -134,6 +135,37 @@ public class YamlValidator {
return validate(content, Set.of());
}
+ /**
+ * The validation errors as reports print them, each with the line of the
YAML content it points at in front when
+ * that is known: "Line 12: /0/route/from/steps/0/log: property 'logLevel'
is not defined in the schema...". The
+ * content is parsed once for all of them.
+ */
+ public static List<String> describeAll(String content, List<Error> errors)
{
+ Node root = content != null && !errors.isEmpty() ?
YamlPointerLines.root(content) : null;
+ List<String> answer = new ArrayList<>(errors.size());
+ for (Error error : errors) {
+ int line = lineOf(root, error);
+ answer.add(line > 0 ? "Line " + line + ": " + error :
String.valueOf(error));
+ }
+ return answer;
+ }
+
+ /**
+ * The 1-based line of the YAML content a validation error points at (the
line of the key of a property that is not
+ * allowed), or 0 when it has no location or the content does not parse.
For several errors of one content,
+ * {@link #describeAll(String, List)} parses it once.
+ */
+ public static int lineOf(String content, Error error) {
+ return lineOf(YamlPointerLines.root(content), error);
+ }
+
+ private static int lineOf(Node root, Error error) {
+ if (root == null || error == null || error.getInstanceLocation() ==
null) {
+ return 0;
+ }
+ return YamlPointerLines.line(root,
error.getInstanceLocation().toString(), error.getMessage());
+ }
+
/**
* @param bodylessEndpoints endpoints the caller knows deliver no body,
such as the {@code direct:} endpoint of a
* GET operation of an OpenAPI specification the
file binds to (CAMEL-24844)
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlPointerLinesTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlPointerLinesTest.java
new file mode 100644
index 000000000000..0713c809b410
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlPointerLinesTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The schema errors of a YAML route are reported on their line, not only by
their pointer.
+ */
+class YamlPointerLinesTest {
+
+ private static final String YAML = """
+ - route:
+ from:
+ uri: "timer:tick"
+ steps:
+ - filter:
+ expression:
+ simple:
+ expression: "${header.foo} == 'bar'"
+ steps:
+ - log:
+ message: "${body}"
+ logLevel: WARN
+ """;
+
+ @Test
+ void thePointerOfANestedStep() {
+ var root = YamlPointerLines.root(YAML);
+ assertThat(YamlPointerLines.line(root, "/0/route/from/uri",
null)).isEqualTo(3);
+ assertThat(YamlPointerLines.line(root,
"/0/route/from/steps/0/filter/steps/0/log", null)).isEqualTo(11);
+ // an unknown property: the line of its key
+ assertThat(YamlPointerLines.line(root,
"/0/route/from/steps/0/filter/steps/0/log",
+ "property 'logLevel' is not defined in the
schema")).isEqualTo(12);
+ assertThat(YamlPointerLines.line(root, "/0/route/nope",
null)).isZero();
+ assertThat(YamlPointerLines.line(YamlPointerLines.root("- route:
[unclosed"), "/0", null)).isZero();
+ }
+
+ @Test
+ void theValidationErrorsAsReportsPrintThem() throws Exception {
+ var errors = new YamlValidator().validate(YAML);
+ assertThat(YamlValidator.describeAll(YAML, errors))
+ .anySatisfy(e -> assertThat(e).startsWith("Line 12:
/0/route/from/steps/0/filter/steps/0/log: ")
+ .contains("logLevel"));
+ // without the content, the errors as they are
+ assertThat(YamlValidator.describeAll(null, errors)).allSatisfy(e ->
assertThat(e).doesNotStartWith("Line "));
+ }
+
+ @Test
+ void theLineOfAValidationError() throws Exception {
+ var errors = new YamlValidator().validate(YAML);
+ assertThat(errors).anySatisfy(e -> {
+ assertThat(e.getMessage()).contains("logLevel");
+ assertThat(YamlValidator.lineOf(YAML, e)).isEqualTo(12);
+ });
+ }
+}