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 eba842d4ba1e camel-tui - validate camel.* properties on save and fix
Esc dismissing validation popup
eba842d4ba1e is described below
commit eba842d4ba1e68a1ff925959645d2cf14efc41e6
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Aug 5 18:53:51 2026 +0200
camel-tui - validate camel.* properties on save and fix Esc dismissing
validation popup
Add validate-on-save for application.properties files using CamelCatalog
validateConfigurationProperty API to check camel.* option names, types,
and enum values.
Fix bug where pressing Esc when validation error popup is shown also exits
edit mode, losing changes. CamelMonitor routes Esc through handleEscape()
which calls cancelEdit() directly, bypassing the validation popup check in
handleEditKeyEvent(). Now cancelEdit() dismisses the popup first, requiring
a second Esc to exit edit mode. Affects both YAML and properties validation.
camel-yaml-dsl - filter oneOf noise from YAML validation errors
When a oneOf schema has N branches and none match (e.g. a type mismatch
inside one expression language), the JSON Schema validator reports errors
from ALL branches, producing dozens of "required property 'X' not found"
messages for expression languages the user never intended.
Add filterOneOfNoise() to YamlValidator that processes each oneOf
bottom-up, finds the branch matching the user's YAML most closely
(deepest instance location with substantive errors), and drops errors
from all other branches. This reduces e.g. 60 errors for a single
pretty: 123 (boolean expected) down to just 1 clear message.
Also prefix YAML validation errors with the node name extracted from
the instance location (e.g. "pretty: integer found, boolean expected")
for better context.
camel-tui - validate YAML endpoint parameters on save using CamelCatalog
camel-yaml-dsl - prefer property-level errors over type mismatches in oneOf
filter
camel-tui - quote placeholder values in YAML tab completion
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/tui/SourceTab.java | 224 ++++++++++++++++
.../dsl/jbang/core/commands/tui/SourceViewer.java | 110 +++++++-
.../core/commands/tui/SourceViewerEditTest.java | 120 +++++++++
.../commands/tui/YamlEndpointValidationTest.java | 291 +++++++++++++++++++++
.../camel/dsl/yaml/validator/YamlValidator.java | 140 +++++++++-
.../dsl/yaml/validator/YamlValidatorTest.java | 22 ++
.../src/test/resources/type-mismatch.yaml | 34 +++
.../src/test/resources/unknown-eip-option.yaml | 28 ++
8 files changed, 957 insertions(+), 12 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index d72748be9217..7d6ae5926e51 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -59,6 +59,8 @@ import dev.tamboui.widgets.paragraph.Paragraph;
import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.ConfigurationPropertiesValidationResult;
+import org.apache.camel.catalog.EndpointValidationResult;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
import org.apache.camel.tooling.model.BaseOptionModel;
import org.apache.camel.tooling.model.ComponentModel;
@@ -552,6 +554,7 @@ class SourceTab extends AbstractTab {
if (isYamlFile(filePath)) {
sourceViewer.setAutocompleteProvider(this::provideYamlKeyCompletions);
sourceViewer.setAutocompleteValueProvider(this::provideYamlValueCompletions);
+
sourceViewer.setEndpointValidator(this::validateYamlEndpoints);
} else {
sourceViewer.setAutocompleteProvider(null);
sourceViewer.setAutocompleteValueProvider(null);
@@ -561,6 +564,7 @@ class SourceTab extends AbstractTab {
sourceViewer.setDeprecatedLineScanner(this::scanDeprecatedProperties);
sourceViewer.setAutocompleteProvider(this::providePropertyCompletions);
sourceViewer.setAutocompleteValueProvider(this::providePropertyValueCompletions);
+
sourceViewer.setPropertiesValidator(this::validatePropertyLine);
} else {
sourceViewer.setQuickDocProvider(null);
sourceViewer.setDeprecatedLineScanner(null);
@@ -1405,6 +1409,226 @@ class SourceTab extends AbstractTab {
return result;
}
+ private String validatePropertyLine(String line) {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return null;
+ }
+ try {
+ ConfigurationPropertiesValidationResult result =
catalog.validateConfigurationProperty(line);
+ if (!result.isAccepted()) {
+ return null;
+ }
+ if (!result.isSuccess()) {
+ String msg = result.summaryErrorMessage(false);
+ if (msg != null) {
+ return msg.trim();
+ }
+ }
+ } catch (Exception e) {
+ // ignore validation errors
+ }
+ return null;
+ }
+
+ private List<String> validateYamlEndpoints(String content) {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+ return doValidateYamlEndpoints(content, catalog);
+ }
+
+ static List<String> doValidateYamlEndpoints(String content, CamelCatalog
catalog) {
+ List<String> errors = new ArrayList<>();
+ String[] lines = content.split("\n", -1);
+
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+ if (line.isBlank()) {
+ continue;
+ }
+ String trimmed = line.trim();
+ if (trimmed.startsWith("#")) {
+ continue;
+ }
+
+ Matcher m = YAML_URI_PATTERN.matcher(line);
+ if (!m.find()) {
+ continue;
+ }
+
+ String uri = m.group(1);
+ if (uri.endsWith("\"")) {
+ uri = uri.substring(0, uri.length() - 1);
+ }
+ if (uri.startsWith("{{")) {
+ continue;
+ }
+ // scheme-only URI (e.g., "uri: timer") needs a colon for catalog
parsing
+ if (!uri.contains(":")) {
+ uri = uri + ":";
+ }
+
+ String eipName = extractEipFromLine(trimmed);
+ int lineIndent = countLeadingSpaces(line);
+
+ // for "uri:" lines, walk backwards to find the parent EIP (from,
to, etc.)
+ if ("uri".equals(eipName)) {
+ for (int j = i - 1; j >= 0; j--) {
+ String prev = lines[j];
+ if (prev.isBlank()) {
+ continue;
+ }
+ int prevIndent = countLeadingSpaces(prev);
+ if (prevIndent < lineIndent) {
+ eipName = extractEipFromLine(prev.trim());
+ break;
+ }
+ }
+ }
+
+ boolean consumerOnly = eipName != null &&
SourceViewer.CONSUMER_EIPS.contains(eipName);
+ boolean producerOnly = eipName != null &&
SourceViewer.PRODUCER_EIPS.contains(eipName);
+
+ // look ahead for a parameters: block at the same indent level as
uri
+ StringBuilder uriBuilder = new StringBuilder(uri);
+ boolean hasParams = uri.contains("?");
+ for (int j = i + 1; j < lines.length; j++) {
+ String next = lines[j];
+ if (next.isBlank()) {
+ continue;
+ }
+ int nextIndent = countLeadingSpaces(next);
+ if (nextIndent < lineIndent) {
+ break;
+ }
+ String nextTrimmed = next.trim();
+ if (nextIndent == lineIndent &&
nextTrimmed.startsWith("parameters:")) {
+ int paramBlockIndent = nextIndent;
+ for (int k = j + 1; k < lines.length; k++) {
+ String paramLine = lines[k];
+ if (paramLine.isBlank()) {
+ continue;
+ }
+ int paramIndent = countLeadingSpaces(paramLine);
+ if (paramIndent <= paramBlockIndent) {
+ break;
+ }
+ String paramTrimmed = paramLine.trim();
+ int colonPos = paramTrimmed.indexOf(':');
+ if (colonPos > 0) {
+ String key = paramTrimmed.substring(0,
colonPos).trim();
+ String val = paramTrimmed.substring(colonPos +
1).trim();
+ if (val.startsWith("\"") && val.endsWith("\"") &&
val.length() > 1) {
+ val = val.substring(1, val.length() - 1);
+ } else if (val.startsWith("'") &&
val.endsWith("'") && val.length() > 1) {
+ val = val.substring(1, val.length() - 1);
+ }
+ char sep = hasParams ? '&' : '?';
+
uriBuilder.append(sep).append(key).append('=').append(val);
+ hasParams = true;
+ }
+ }
+ break;
+ }
+ if (nextIndent == lineIndent) {
+ break;
+ }
+ }
+
+ String fullUri = uriBuilder.toString();
+ try {
+ EndpointValidationResult result
+ = catalog.validateEndpointProperties(fullUri, false,
consumerOnly, producerOnly);
+ if (!result.isSuccess()) {
+ String scheme = fullUri.contains(":") ?
fullUri.substring(0, fullUri.indexOf(':')) : fullUri;
+ collectEndpointErrors(errors, result, scheme);
+ }
+ } catch (Exception e) {
+ // ignore validation errors
+ }
+ }
+ return errors;
+ }
+
+ private static void collectEndpointErrors(List<String> errors,
EndpointValidationResult result, String scheme) {
+ if (result.getUnknown() != null) {
+ for (String name : result.getUnknown()) {
+ StringBuilder sb = new StringBuilder(scheme).append(": Unknown
option '").append(name).append("'");
+ 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));
+ }
+ }
+ errors.add(sb.toString());
+ }
+ }
+ if (result.getInvalidBoolean() != null) {
+ for (Map.Entry<String, String> entry :
result.getInvalidBoolean().entrySet()) {
+ errors.add(scheme + ": Invalid boolean value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+ }
+ }
+ if (result.getInvalidInteger() != null) {
+ for (Map.Entry<String, String> entry :
result.getInvalidInteger().entrySet()) {
+ errors.add(scheme + ": Invalid integer value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+ }
+ }
+ if (result.getInvalidNumber() != null) {
+ for (Map.Entry<String, String> entry :
result.getInvalidNumber().entrySet()) {
+ errors.add(scheme + ": Invalid number value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+ }
+ }
+ if (result.getInvalidEnum() != null) {
+ for (Map.Entry<String, String> entry :
result.getInvalidEnum().entrySet()) {
+ StringBuilder sb = new StringBuilder(scheme)
+ .append(": Invalid enum value
'").append(entry.getValue())
+ .append("' for option
'").append(entry.getKey()).append("'");
+ if (result.getInvalidEnumChoices() != null) {
+ String[] choices =
result.getInvalidEnumChoices().get(entry.getKey());
+ if (choices != null) {
+ sb.append(". Possible values:
").append(Arrays.asList(choices));
+ }
+ }
+ errors.add(sb.toString());
+ }
+ }
+ if (result.getNotConsumerOnly() != null) {
+ for (String name : result.getNotConsumerOnly()) {
+ errors.add(scheme + ": Option '" + name + "' is not applicable
in consumer only mode");
+ }
+ }
+ if (result.getNotProducerOnly() != null) {
+ for (String name : result.getNotProducerOnly()) {
+ errors.add(scheme + ": Option '" + name + "' is not applicable
in producer only mode");
+ }
+ }
+ }
+
+ private static String extractEipFromLine(String trimmed) {
+ if (trimmed.startsWith("- ")) {
+ trimmed = trimmed.substring(2).trim();
+ }
+ int colon = trimmed.indexOf(':');
+ if (colon > 0) {
+ return trimmed.substring(0, colon).trim();
+ }
+ return null;
+ }
+
+ private static int countLeadingSpaces(String line) {
+ int count = 0;
+ for (int i = 0; i < line.length(); i++) {
+ if (line.charAt(i) == ' ') {
+ count++;
+ } else {
+ break;
+ }
+ }
+ return count;
+ }
+
private BaseOptionModel lookupPropertyOption(CamelCatalog catalog, String
key) {
if (mainOptionsCache != null) {
BaseOptionModel opt = mainOptionsCache.get(key);
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 0dbd55b7d9df..28e3afcc364b 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
@@ -82,6 +82,16 @@ class SourceViewer {
Map<Integer, List<DocEntry>> provideAll(List<JsonObject> codeData);
}
+ @FunctionalInterface
+ interface PropertiesValidator {
+ String validate(String line);
+ }
+
+ @FunctionalInterface
+ interface EndpointValidator {
+ List<String> validate(String content);
+ }
+
@FunctionalInterface
interface DeprecatedLineScanner {
Set<Integer> scan(List<JsonObject> codeData);
@@ -136,6 +146,8 @@ class SourceViewer {
private AutocompletePopup autocompletePopup;
private boolean validateOnSave = true;
private org.apache.camel.dsl.yaml.validator.YamlValidator yamlValidator;
+ private PropertiesValidator propertiesValidator;
+ private EndpointValidator endpointValidator;
private List<String> validationErrors;
private int validationErrorScroll;
@@ -176,6 +188,14 @@ class SourceViewer {
this.validateOnSave = validateOnSave;
}
+ void setPropertiesValidator(PropertiesValidator propertiesValidator) {
+ this.propertiesValidator = propertiesValidator;
+ }
+
+ void setEndpointValidator(EndpointValidator endpointValidator) {
+ this.endpointValidator = endpointValidator;
+ }
+
void hide() {
exitEditMode();
visible = false;
@@ -184,6 +204,8 @@ class SourceViewer {
quickDocEntries = Collections.emptyMap();
deprecatedLines = Collections.emptySet();
editableFile = null;
+ propertiesValidator = null;
+ endpointValidator = null;
}
void reset() {
@@ -218,6 +240,8 @@ class SourceViewer {
autocompleteValueProvider = null;
autocompletePopup = null;
editableFile = null;
+ propertiesValidator = null;
+ endpointValidator = null;
}
boolean isMarkdownMode() {
@@ -247,6 +271,10 @@ class SourceViewer {
if (!editMode) {
return false;
}
+ if (validationErrors != null) {
+ validationErrors = null;
+ return true;
+ }
exitEditMode();
return true;
}
@@ -570,9 +598,9 @@ class SourceViewer {
record YamlEndpointContext(String component, boolean consumer) {
}
- private static final java.util.Set<String> CONSUMER_EIPS
+ static final java.util.Set<String> CONSUMER_EIPS
= java.util.Set.of("from", "pollEnrich", "poll-enrich", "poll",
"interceptFrom", "intercept-from");
- private static final java.util.Set<String> PRODUCER_EIPS
+ static final java.util.Set<String> PRODUCER_EIPS
= java.util.Set.of("to", "toD", "to-d", "wireTap", "wire-tap",
"enrich",
"interceptSendToEndpoint", "intercept-send-to-endpoint");
@@ -1339,11 +1367,15 @@ class SourceViewer {
trimmed = trimmed.substring(2).trim();
}
int colonIdx = trimmed.indexOf(':');
+ String value = item.key();
+ if (value.contains("{{")) {
+ value = "\"" + value + "\"";
+ }
if (colonIdx > 0) {
String keyPart = trimmed.substring(0, colonIdx);
- editState.insert(indentStr + keyPart + ": " + item.key());
+ editState.insert(indentStr + keyPart + ": " + value);
} else {
- editState.insert(indentStr + item.key());
+ editState.insert(indentStr + value);
}
// for component names, add parameters: block if not already
present
if ("component".equals(item.type())) {
@@ -1404,25 +1436,65 @@ class SourceViewer {
private void validateAndNotify(String content) {
if (validateOnSave && isCamelYamlFile()) {
+ List<String> msgs = new ArrayList<>();
List<Error> errors = validateYaml(content);
if (errors != null && !errors.isEmpty()) {
- List<String> msgs = new ArrayList<>();
for (Error error : errors) {
String msg = error.getMessage();
if (msg != null) {
- msgs.add(cleanValidationMessage(msg));
+ String loc = error.getInstanceLocation() != null
+ ? error.getInstanceLocation().toString() :
null;
+ String node = extractNodeName(loc);
+ String clean = cleanValidationMessage(msg);
+ if (node != null) {
+ msgs.add(node + ": " + clean);
+ } else {
+ msgs.add(clean);
+ }
}
}
- if (!msgs.isEmpty()) {
- validationErrors = msgs;
- validationErrorScroll = 0;
- return;
+ }
+ if (endpointValidator != null) {
+ List<String> endpointErrors =
endpointValidator.validate(content);
+ if (endpointErrors != null) {
+ msgs.addAll(endpointErrors);
}
}
+ if (!msgs.isEmpty()) {
+ validationErrors = msgs;
+ validationErrorScroll = 0;
+ return;
+ }
+ } else if (validateOnSave && isPropertiesFile() && propertiesValidator
!= null) {
+ List<String> msgs = validateProperties(content);
+ if (!msgs.isEmpty()) {
+ validationErrors = msgs;
+ validationErrorScroll = 0;
+ return;
+ }
}
notifySave("Saved: " + editableFile.getFileName(), false);
}
+ private List<String> validateProperties(String content) {
+ List<String> msgs = new ArrayList<>();
+ String[] lines = content.split("\n", -1);
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i].trim();
+ if (line.isEmpty() || line.startsWith("#") ||
line.startsWith("!")) {
+ continue;
+ }
+ if (!line.contains("=")) {
+ continue;
+ }
+ String error = propertiesValidator.validate(lines[i]);
+ if (error != null) {
+ msgs.add("Line " + (i + 1) + ": " + error);
+ }
+ }
+ return msgs;
+ }
+
private List<Error> validateYaml(String content) {
try {
if (yamlValidator == null) {
@@ -1453,6 +1525,24 @@ class SourceViewer {
return msg;
}
+ private static String extractNodeName(String instanceLocation) {
+ if (instanceLocation == null || instanceLocation.isEmpty()) {
+ return null;
+ }
+ int slash = instanceLocation.lastIndexOf('/');
+ String last = slash >= 0 ? instanceLocation.substring(slash + 1) :
instanceLocation;
+ if (last.isEmpty()) {
+ return null;
+ }
+ // skip pure numeric segments (array indices)
+ try {
+ Integer.parseInt(last);
+ return null;
+ } catch (NumberFormatException e) {
+ return last;
+ }
+ }
+
private void notifySave(String message, boolean error) {
if (notificationCallback != null) {
notificationCallback.accept(message, error);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
index b4b7339cc268..934eb2a56b4d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
@@ -438,6 +438,126 @@ class SourceViewerEditTest {
assertThat(viewer.isTextInputActive()).isFalse();
}
+ @Test
+ void propertiesValidationOnSaveShowsErrors() throws Exception {
+ Path propsFile = tempDir.resolve("application.properties");
+ Files.writeString(propsFile, "camel.component.seda.queueSize=1234\n",
StandardCharsets.UTF_8);
+ viewer.setValidateOnSave(true);
+ viewer.setPropertiesValidator(line -> {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("camel.component.seda.foo")) {
+ return "Unknown option: camel.component.seda.foo";
+ }
+ return null;
+ });
+
+ viewer.loadFile(propsFile);
+ viewer.enterEditMode();
+
+ // move to end and add an invalid property
+ for (int i = 0; i < 20; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN,
KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER,
KeyModifiers.NONE));
+ for (char ch : "camel.component.seda.foo=abc".toCharArray()) {
+ viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+
+ // save — should stay in edit mode with validation errors
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+ assertThat(viewer.isEditMode()).isTrue();
+
+ // dismiss errors via Esc — must stay in edit mode
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE,
KeyModifiers.NONE));
+ assertThat(viewer.isEditMode()).isTrue();
+
+ // file is still saved (validation is informational)
+ String saved = Files.readString(propsFile, StandardCharsets.UTF_8);
+ assertThat(saved).contains("camel.component.seda.foo=abc");
+ }
+
+ @Test
+ void cancelEditDismissesValidationErrorsFirst() throws Exception {
+ Path propsFile = tempDir.resolve("application.properties");
+ Files.writeString(propsFile, "camel.component.seda.queueSize=1234\n",
StandardCharsets.UTF_8);
+ viewer.setValidateOnSave(true);
+ viewer.setPropertiesValidator(line -> {
+ if (line.trim().startsWith("camel.component.seda.foo")) {
+ return "Unknown option";
+ }
+ return null;
+ });
+
+ viewer.loadFile(propsFile);
+ viewer.enterEditMode();
+
+ for (int i = 0; i < 20; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN,
KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER,
KeyModifiers.NONE));
+ for (char ch : "camel.component.seda.foo=abc".toCharArray()) {
+ viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+ }
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+ assertThat(viewer.isEditMode()).isTrue();
+
+ // cancelEdit (used by CamelMonitor via handleEscape) should dismiss
popup first
+ assertThat(viewer.cancelEdit()).isTrue();
+ assertThat(viewer.isEditMode()).isTrue();
+
+ // second cancelEdit exits edit mode
+ assertThat(viewer.cancelEdit()).isTrue();
+ assertThat(viewer.isEditMode()).isFalse();
+ }
+
+ @Test
+ void propertiesValidationSkipsCommentsAndBlankLines() throws Exception {
+ Path propsFile = tempDir.resolve("application.properties");
+ Files.writeString(propsFile, "#
comment\n\ncamel.component.seda.queueSize=1234\n", StandardCharsets.UTF_8);
+ viewer.setValidateOnSave(true);
+ viewer.setPropertiesValidator(line -> null);
+
+ viewer.loadFile(propsFile);
+ viewer.enterEditMode();
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+ // no errors — should exit edit mode
+ assertThat(viewer.isEditMode()).isFalse();
+ assertThat(lastNotification.get()).contains("Saved");
+ }
+
+ @Test
+ void yamlEndpointValidationOnSaveShowsErrors() throws Exception {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 1000
+ badOption: xyz
+ steps:
+ - log: "${body}"
+ """;
+ Path yamlFile = tempDir.resolve("route.camel.yaml");
+ Files.writeString(yamlFile, yaml, StandardCharsets.UTF_8);
+
+ viewer.setValidateOnSave(true);
+ List<String> capturedErrors = new ArrayList<>();
+ viewer.setEndpointValidator(content -> {
+ capturedErrors.add("timer: Unknown option. Did you mean:
[fixedRate]");
+ return List.of("timer: Unknown option. Did you mean: [fixedRate]");
+ });
+
+ viewer.loadFile(yamlFile);
+ viewer.enterEditMode();
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+ assertThat(viewer.isEditMode()).isTrue();
+ assertThat(capturedErrors).isNotEmpty();
+ }
+
private static String spansToString(List<Span> spans) {
StringBuilder sb = new StringBuilder();
for (Span span : spans) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
new file mode 100644
index 000000000000..978dc1eaa780
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
@@ -0,0 +1,291 @@
+/*
+ * 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.util.List;
+
+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 static org.assertj.core.api.Assertions.assertThat;
+
+class YamlEndpointValidationTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ @Test
+ void validExpandedFormNoErrors() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 1000
+ fixedRate: true
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void expandedFormUnknownOption() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 1000
+ badOption: xyz
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).containsIgnoringCase("unknown");
+ }
+
+ @Test
+ void expandedFormInvalidBoolean() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ fixedRate: notABoolean
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors.get(0)).startsWith("timer:");
+ }
+
+ @Test
+ void inlineUriNoErrors() {
+ String yaml = """
+ - from: timer:tick?period=1000
+ steps:
+ - to: log:myLogger
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void inlineUriUnknownOption() {
+ String yaml = """
+ - from: timer:tick?badOption=xyz
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).containsIgnoringCase("unknown");
+ }
+
+ @Test
+ void expandedUriWithQueryParamsNoErrors() {
+ String yaml = """
+ - from:
+ uri: timer:tick?period=1000
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void expandedUriWithQueryParamsAndParametersBlock() {
+ String yaml = """
+ - from:
+ uri: timer:tick?period=1000
+ parameters:
+ fixedRate: true
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void expandedUriWithQueryParamsAndBadParameter() {
+ String yaml = """
+ - from:
+ uri: timer:tick?period=1000
+ parameters:
+ badOption: xyz
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors.get(0)).startsWith("timer:");
+ }
+
+ @Test
+ void multipleEndpointsValidatedIndependently() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 1000
+ steps:
+ - to:
+ uri: log:myLogger
+ parameters:
+ badOption: xyz
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors).allSatisfy(e -> assertThat(e).startsWith("log:"));
+ assertThat(errors).noneSatisfy(e ->
assertThat(e).startsWith("timer:"));
+ }
+
+ @Test
+ void placeholderUriSkipped() {
+ String yaml = """
+ - from: "{{myUri}}"
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void placeholderValueSkipped() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: "{{myPeriod}}"
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void quotedParameterValues() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: "1000"
+ fixedRate: 'true'
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void inlineToWithDash() {
+ String yaml = """
+ - from: timer:tick?period=1000
+ steps:
+ - to: seda:myQueue?badOption=xyz
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0)).startsWith("seda:");
+ }
+
+ @Test
+ void validRouteNoEndpointErrors() {
+ String yaml = """
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 5000
+ repeatCount: 1
+ steps:
+ - setBody:
+ simple: "Hello World"
+ - to:
+ uri: seda:result
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void schemeOnlyUriWithParameters() {
+ String yaml = """
+ - route:
+ id: timer-log
+ from:
+ uri: timer
+ parameters:
+ timerName: tick
+ period: 1000
+ bridgeErrorHandler2: 123
+ steps:
+ - log:
+ message: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isNotEmpty();
+ assertThat(errors).anyMatch(e -> e.startsWith("timer:") &&
e.contains("bridgeErrorHandler2"));
+ }
+
+ @Test
+ void schemeOnlyUriValidOptions() {
+ String yaml = """
+ - route:
+ id: timer-log
+ from:
+ uri: timer
+ parameters:
+ timerName: tick
+ period: 1000
+ fixedRate: true
+ steps:
+ - log:
+ message: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void commentsIgnored() {
+ String yaml = """
+ # from: timer:tick?badOption=xyz
+ - from:
+ uri: timer:tick
+ parameters:
+ period: 1000
+ steps:
+ - log: "${body}"
+ """;
+ List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
+ assertThat(errors).isEmpty();
+ }
+}
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 4f49743cc366..efc833acb421 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
@@ -19,9 +19,14 @@ package org.apache.camel.dsl.yaml.validator;
import java.io.File;
import java.text.MessageFormat;
import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
+import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -67,7 +72,7 @@ public class YamlValidator {
}
try {
var target = mapper.readTree(file);
- return new ArrayList<>(schema.validate(target));
+ return filterOneOfNoise(new ArrayList<>(schema.validate(target)));
} catch (Exception e) {
return List.of(parseError(e));
}
@@ -79,12 +84,143 @@ public class YamlValidator {
}
try {
var target = mapper.readTree(content);
- return new ArrayList<>(schema.validate(target));
+ return filterOneOfNoise(new ArrayList<>(schema.validate(target)));
} catch (Exception e) {
return List.of(parseError(e));
}
}
+ /**
+ * 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
+ * the user never intended. This method keeps only the errors from the
branch that matched the user's YAML most
+ * closely (deepest structural match) and drops the rest.
+ */
+ static List<Error> filterOneOfNoise(List<Error> errors) {
+ if (errors.size() <= 1) {
+ return errors;
+ }
+
+ List<Error> oneOfMetas = errors.stream()
+ .filter(e -> "oneOf".equals(e.getKeyword()))
+ .sorted(Comparator.comparingInt(
+ (Error e) ->
e.getEvaluationPath().toString().length()).reversed())
+ .toList();
+
+ if (oneOfMetas.isEmpty()) {
+ return errors;
+ }
+
+ Set<Error> toRemove = new LinkedHashSet<>();
+
+ for (Error meta : oneOfMetas) {
+ if (toRemove.contains(meta)) {
+ continue;
+ }
+
+ String prefix = meta.getEvaluationPath().toString();
+
+ Map<String, List<Error>> branches = new LinkedHashMap<>();
+ for (Error e : errors) {
+ if (toRemove.contains(e) || e == meta) {
+ continue;
+ }
+ String path = e.getEvaluationPath().toString();
+ if (path.startsWith(prefix + "/")) {
+ String rest = path.substring(prefix.length() + 1);
+ String branchIndex = rest.contains("/") ?
rest.substring(0, rest.indexOf('/')) : rest;
+ branches.computeIfAbsent(branchIndex, k -> new
ArrayList<>()).add(e);
+ }
+ }
+
+ if (branches.isEmpty()) {
+ continue;
+ }
+
+ // find the best-matching branch using a three-tier priority:
+ // 1. property-level errors (additionalProperties, enum,
pattern, etc.) — "right branch, wrong value/property"
+ // 2. type errors only — "wrong branch entirely" (less
informative)
+ // 3. structural errors only (required, oneOf, not) —
wrong-branch noise
+ // within the same tier, prefer the deepest instance location
+ String bestBranch = null;
+ int bestDepth = -1;
+ int bestTier = 0;
+
+ for (Map.Entry<String, List<Error>> entry : branches.entrySet()) {
+ int tier = branchTier(entry.getValue());
+ int maxDepth = entry.getValue().stream()
+ .mapToInt(e ->
e.getInstanceLocation().toString().length())
+ .max().orElse(0);
+
+ if (tier > bestTier) {
+ bestBranch = entry.getKey();
+ bestDepth = maxDepth;
+ bestTier = tier;
+ } else if (tier == bestTier && maxDepth > bestDepth) {
+ bestBranch = entry.getKey();
+ bestDepth = maxDepth;
+ }
+ }
+
+ for (Map.Entry<String, List<Error>> entry : branches.entrySet()) {
+ if (!entry.getKey().equals(bestBranch)) {
+ toRemove.addAll(entry.getValue());
+ }
+ }
+
+ if (bestTier > 0) {
+ toRemove.add(meta);
+ }
+ }
+
+ if (toRemove.isEmpty()) {
+ return errors;
+ }
+
+ List<Error> result = new ArrayList<>(errors.size() - toRemove.size());
+ for (Error e : errors) {
+ if (!toRemove.contains(e)) {
+ result.add(e);
+ }
+ }
+ return result;
+ }
+
+ private static int branchTier(List<Error> branchErrors) {
+ boolean hasPropertyLevel = false;
+ boolean hasType = false;
+ for (Error e : branchErrors) {
+ String kw = e.getKeyword();
+ if (isPropertyLevelError(kw)) {
+ hasPropertyLevel = true;
+ } else if ("type".equals(kw)) {
+ hasType = true;
+ }
+ }
+ if (hasPropertyLevel) {
+ return 2;
+ }
+ if (hasType) {
+ return 1;
+ }
+ return 0;
+ }
+
+ private static boolean isPropertyLevelError(String keyword) {
+ return keyword != null
+ && ("additionalProperties".equals(keyword)
+ || "enum".equals(keyword)
+ || "pattern".equals(keyword)
+ || "minimum".equals(keyword)
+ || "maximum".equals(keyword)
+ || "minLength".equals(keyword)
+ || "maxLength".equals(keyword)
+ || "format".equals(keyword)
+ || "const".equals(keyword)
+ || "minItems".equals(keyword)
+ || "maxItems".equals(keyword));
+ }
+
private static Error parseError(Exception e) {
String msg = e.getClass().getName() + ": " + e.getMessage();
return Error.builder()
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java
index 49b2a0966a12..fb8ade0b0537 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/java/org/apache/camel/dsl/yaml/validator/YamlValidatorTest.java
@@ -58,6 +58,28 @@ public class YamlValidatorTest {
Assertions.assertTrue(report.get(0).getMessage().contains("setCheese"));
}
+ @Test
+ public void testTypeMismatchFiltersOneOfNoise() throws Exception {
+ var report = validator.validate(new
File("src/test/resources/type-mismatch.yaml"));
+ // should filter dozens of "required property 'X' not found" noise
down to the real error
+ Assertions.assertTrue(report.size() <= 3, "Expected at most 3 errors
but got " + report.size());
+ Assertions.assertTrue(report.stream().anyMatch(e ->
e.getMessage().contains("integer found, boolean expected")),
+ "Should contain the actual type error");
+ Assertions.assertTrue(report.stream().noneMatch(e ->
e.getMessage().contains("required property")),
+ "Should not contain required property noise from oneOf
branches");
+ }
+
+ @Test
+ public void testUnknownEipOptionShowsPropertyError() throws Exception {
+ var report = validator.validate(new
File("src/test/resources/unknown-eip-option.yaml"));
+ Assertions.assertFalse(report.isEmpty());
+ // should show "cheese" as the unknown property, not "object found,
string expected"
+ Assertions.assertTrue(report.stream().anyMatch(e ->
e.getMessage().contains("cheese")),
+ "Should identify the unknown property 'cheese', got: " +
report.stream().map(e -> e.getMessage()).toList());
+ Assertions.assertTrue(report.stream().noneMatch(e ->
e.getMessage().contains("string expected")),
+ "Should not show misleading 'string expected' from the wrong
oneOf branch");
+ }
+
@Test
public void testValidateRuntimeCustomStepRejectedBySchema() throws
Exception {
var report = validator.validate(new
File("src/test/resources/custom-parser-step.yaml"));
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/type-mismatch.yaml
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/type-mismatch.yaml
new file mode 100644
index 000000000000..8e2408b14e3e
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/type-mismatch.yaml
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+
+- route:
+ id: timer-log
+ from:
+ uri: timer
+ parameters:
+ timerName: tick
+ period: "{{timer.period}}"
+ exchangePattern: InOnly
+ runLoggingLevel: WARN
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "{{greeting}}"
+ pretty: 123
+ - log:
+ message: "${body}"
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/unknown-eip-option.yaml
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/unknown-eip-option.yaml
new file mode 100644
index 000000000000..2a3daf7a4711
--- /dev/null
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl-validator/src/test/resources/unknown-eip-option.yaml
@@ -0,0 +1,28 @@
+#
+# 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.
+#
+
+- route:
+ id: timer-log
+ from:
+ uri: timer
+ parameters:
+ timerName: tick
+ period: 1000
+ steps:
+ - log:
+ message: "${body}"
+ cheese: 123