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 d7f3713fd867 chore(tui): Add Tab-completion for YAML DSL parameters in
edit mode
d7f3713fd867 is described below
commit d7f3713fd86743123e3b53c1f0c7736a2b0d84f3
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Aug 4 08:54:16 2026 +0200
chore(tui): Add Tab-completion for YAML DSL parameters in edit mode
Tab completion now works inside YAML route `parameters:` blocks.
Key completion shows endpoint options from the Camel catalog with
consumer/producer filtering. Value completion shows enum choices,
boolean values, and property placeholders. Placeholders are filtered
to only show items whose actual value matches a valid choice
(enum match, boolean, or numeric for integer/long types).
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../jbang/core/commands/tui/AutocompletePopup.java | 2 +-
.../dsl/jbang/core/commands/tui/SourceTab.java | 272 ++++++++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 275 +++++++++++-
.../core/commands/tui/YamlCompletionTest.java | 495 +++++++++++++++++++++
4 files changed, 1030 insertions(+), 14 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index 6c5c2dec4bf8..8ea1a047af69 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -277,7 +277,7 @@ class AutocompletePopup {
Style keyStyle = ci.deprecated() ? deprecatedStyle : normalStyle;
String displayKey = key;
- if (!key.endsWith(".")) {
+ if (!key.startsWith("{{") && !key.endsWith(".")) {
int lastDot = key.lastIndexOf('.');
if (lastDot >= 0) {
displayKey = key.substring(lastDot + 1);
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 a9e878121d25..84560c6782d7 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
@@ -332,6 +332,23 @@ class SourceTab extends AbstractTab {
- **w** — toggle word wrap
- **Esc/c** — close source viewer
+ ## Edit Mode (Tab Completion)
+ Press **e** to enter edit mode, then **Tab** for context-aware
completion:
+
+ **application.properties:**
+ - Key completion for `camel.main.*`, `camel.component.*`,
`camel.dataformat.*`,
+ and `camel.language.*` options from the Camel catalog
+ - Value completion with enum choices, boolean values, and
`{{placeholder}}` suggestions
+
+ **YAML DSL routes:**
+ - Inside `parameters:` blocks, key completion shows endpoint
options from the
+ Camel catalog, filtered by consumer/producer role
+ - Value completion shows enum choices, boolean values, and
`{{placeholder}}`
+ suggestions from your `.properties` files
+
+ Use **Up/Down** to navigate, **Enter** to accept, **Esc** to
dismiss, and
+ type to filter the completion list.
+
## General
- **Tab** — toggle focus between file list and source viewer
- The focused panel title is highlighted; the unfocused panel
dims
@@ -505,8 +522,13 @@ class SourceTab extends AbstractTab {
if (isCamelSourceFile(filePath)) {
sourceViewer.setQuickDocProvider(this::provideCamelQuickDocs);
sourceViewer.setDeprecatedLineScanner(null);
- sourceViewer.setAutocompleteProvider(null);
- sourceViewer.setAutocompleteValueProvider(null);
+ if (isYamlFile(filePath)) {
+
sourceViewer.setAutocompleteProvider(this::provideYamlKeyCompletions);
+
sourceViewer.setAutocompleteValueProvider(this::provideYamlValueCompletions);
+ } else {
+ sourceViewer.setAutocompleteProvider(null);
+ sourceViewer.setAutocompleteValueProvider(null);
+ }
} else if (isPropertiesFile(filePath)) {
sourceViewer.setQuickDocProvider(this::providePropertiesQuickDocs);
sourceViewer.setDeprecatedLineScanner(this::scanDeprecatedProperties);
@@ -598,6 +620,11 @@ class SourceTab extends AbstractTab {
return
path.getFileName().toString().toLowerCase().endsWith(".properties");
}
+ private static boolean isYamlFile(Path path) {
+ String name = path.getFileName().toString().toLowerCase();
+ return name.endsWith(".yaml") || name.endsWith(".yml");
+ }
+
private List<AutocompletePopup.CompletionItem>
providePropertyCompletions(String linePrefix) {
CamelCatalog catalog = getCatalog();
if (catalog == null) {
@@ -731,13 +758,13 @@ class SourceTab extends AbstractTab {
private List<AutocompletePopup.CompletionItem>
providePropertyValueCompletions(String key) {
CamelCatalog catalog = getCatalog();
if (catalog == null || key == null || key.isEmpty()) {
- return List.of();
+ return loadPropertyPlaceholders();
}
ensureMainOptionsCache(catalog);
BaseOptionModel opt = lookupOption(catalog, key);
if (opt == null) {
- return List.of();
+ return loadPropertyPlaceholders();
}
String optDesc = opt.getDescription();
@@ -746,28 +773,36 @@ class SourceTab extends AbstractTab {
String optGroup = opt.getGroup();
List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ java.util.function.Predicate<String> valueFilter = null;
// enum values
List<String> enums = opt.getEnums();
if (enums != null && !enums.isEmpty()) {
+ java.util.Set<String> validValues = new java.util.HashSet<>();
for (String value : enums) {
+ validValues.add(value.toLowerCase());
boolean isDefault = value.equals(String.valueOf(optDefault));
items.add(new AutocompletePopup.CompletionItem(
value, optDesc, optType, isDefault ? value :
optDefault,
false, null, optGroup));
}
- return items;
- }
-
- // boolean values
- if ("boolean".equalsIgnoreCase(optType) ||
"java.lang.Boolean".equals(opt.getJavaType())) {
+ valueFilter = v -> validValues.contains(v.toLowerCase());
+ } else if ("boolean".equalsIgnoreCase(optType) ||
"java.lang.Boolean".equals(opt.getJavaType())) {
+ valueFilter = v -> "true".equalsIgnoreCase(v) ||
"false".equalsIgnoreCase(v);
items.add(new AutocompletePopup.CompletionItem(
"true", optDesc, "boolean", optDefault, false, null,
optGroup));
items.add(new AutocompletePopup.CompletionItem(
"false", optDesc, "boolean", optDefault, false, null,
optGroup));
- return items;
+ } else if (isNumericType(optType, opt.getJavaType())) {
+ valueFilter = SourceTab::isNumericValue;
}
+ // only include placeholders whose actual value is compatible with the
option type
+ for (AutocompletePopup.CompletionItem ph : loadPropertyPlaceholders())
{
+ if (valueFilter == null || (ph.description() != null &&
valueFilter.test(ph.description()))) {
+ items.add(ph);
+ }
+ }
return items;
}
@@ -824,6 +859,49 @@ class SourceTab extends AbstractTab {
return null;
}
+ private static boolean isNumericType(String type, String javaType) {
+ if (type != null) {
+ switch (type.toLowerCase()) {
+ case "integer":
+ case "int":
+ case "long":
+ case "short":
+ case "byte":
+ case "float":
+ case "double":
+ case "number":
+ return true;
+ }
+ }
+ if (javaType != null) {
+ switch (javaType) {
+ case "int":
+ case "long":
+ case "short":
+ case "byte":
+ case "float":
+ case "double":
+ case "java.lang.Integer":
+ case "java.lang.Long":
+ case "java.lang.Short":
+ case "java.lang.Byte":
+ case "java.lang.Float":
+ case "java.lang.Double":
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isNumericValue(String value) {
+ try {
+ Double.parseDouble(value);
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
private static String capitalize(String s) {
if (s == null || s.isEmpty()) {
return s;
@@ -831,6 +909,180 @@ class SourceTab extends AbstractTab {
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
+ // ---- YAML DSL completion ----
+
+ private List<AutocompletePopup.CompletionItem>
provideYamlKeyCompletions(String context) {
+ if (context == null || !context.startsWith("yaml:")) {
+ return List.of();
+ }
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+
+ // context format: "yaml:componentName:consumer|producer"
+ String[] parts = context.substring(5).split(":", 2);
+ if (parts.length < 2) {
+ return List.of();
+ }
+ String componentName = parts[0];
+ String role = parts[1];
+ boolean isConsumer = "consumer".equals(role);
+
+ ComponentModel model = catalog.componentModel(componentName);
+ if (model == null) {
+ return List.of();
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
+ if (includeEndpointOption(opt, isConsumer)) {
+ items.add(new AutocompletePopup.CompletionItem(
+ opt.getName(), opt.getDescription(), opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ opt.getGroup()));
+ }
+ }
+
+
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ .thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+
+ private static boolean
includeEndpointOption(ComponentModel.EndpointOptionModel opt, boolean
isConsumer) {
+ String label = opt.getLabel();
+ if (label == null || label.isEmpty()) {
+ return true;
+ }
+ if (label.contains("consumer") && label.contains("producer")) {
+ return true;
+ }
+ if (isConsumer) {
+ return !label.contains("producer");
+ } else {
+ return !label.contains("consumer");
+ }
+ }
+
+ private List<AutocompletePopup.CompletionItem>
provideYamlValueCompletions(String context) {
+ if (context == null || !context.startsWith("yaml:")) {
+ return List.of();
+ }
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+
+ // context format: "yaml:componentName:optionName"
+ String[] parts = context.substring(5).split(":", 2);
+ if (parts.length < 2) {
+ return List.of();
+ }
+ String componentName = parts[0];
+ String optionName = parts[1];
+
+ ComponentModel model = catalog.componentModel(componentName);
+ if (model == null) {
+ return loadPropertyPlaceholders();
+ }
+
+ ComponentModel.EndpointOptionModel opt = null;
+ for (ComponentModel.EndpointOptionModel o :
model.getEndpointOptions()) {
+ if (o.getName().equals(optionName)) {
+ opt = o;
+ break;
+ }
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ java.util.function.Predicate<String> valueFilter = null;
+
+ if (opt != null) {
+ List<String> enums = opt.getEnums();
+ if (enums != null && !enums.isEmpty()) {
+ java.util.Set<String> validValues = new java.util.HashSet<>();
+ for (String value : enums) {
+ validValues.add(value.toLowerCase());
+ boolean isDefault =
value.equals(String.valueOf(opt.getDefaultValue()));
+ items.add(new AutocompletePopup.CompletionItem(
+ value, opt.getDescription(), opt.getType(),
+ isDefault ? value : opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ valueFilter = v -> validValues.contains(v.toLowerCase());
+ } else if ("boolean".equalsIgnoreCase(opt.getType())
+ || "java.lang.Boolean".equals(opt.getJavaType())) {
+ valueFilter = v -> "true".equalsIgnoreCase(v) ||
"false".equalsIgnoreCase(v);
+ items.add(new AutocompletePopup.CompletionItem(
+ "true", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ items.add(new AutocompletePopup.CompletionItem(
+ "false", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ } else if (isNumericType(opt.getType(), opt.getJavaType())) {
+ valueFilter = SourceTab::isNumericValue;
+ }
+ }
+
+ // only include placeholders whose actual value is compatible with the
option type
+ for (AutocompletePopup.CompletionItem ph : loadPropertyPlaceholders())
{
+ if (valueFilter == null || (ph.description() != null &&
valueFilter.test(ph.description()))) {
+ items.add(ph);
+ }
+ }
+ return items;
+ }
+
+ // ---- Property placeholder loading ----
+
+ private List<AutocompletePopup.CompletionItem> placeholderCache;
+ private long placeholderCacheTime;
+ private Path placeholderCacheDir;
+
+ private List<AutocompletePopup.CompletionItem> loadPropertyPlaceholders() {
+ if (rootDir == null || !java.nio.file.Files.isDirectory(rootDir)) {
+ return List.of();
+ }
+
+ long now = System.currentTimeMillis();
+ if (placeholderCache != null && rootDir.equals(placeholderCacheDir) &&
(now - placeholderCacheTime) < 5000) {
+ return placeholderCache;
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ try (var stream = java.nio.file.Files.list(rootDir)) {
+ stream.filter(p ->
p.getFileName().toString().endsWith(".properties"))
+ .forEach(p -> {
+ try {
+ for (String line :
java.nio.file.Files.readAllLines(p)) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() ||
trimmed.startsWith("#") || trimmed.startsWith("!")) {
+ continue;
+ }
+ int eq = trimmed.indexOf('=');
+ if (eq > 0) {
+ String key = trimmed.substring(0,
eq).trim();
+ String value = trimmed.substring(eq +
1).trim();
+ items.add(new
AutocompletePopup.CompletionItem(
+ "{{" + key + "}}", value,
"placeholder",
+ null, false, null,
p.getFileName().toString()));
+ }
+ }
+ } catch (IOException e) {
+ // skip unreadable files
+ }
+ });
+ } catch (IOException e) {
+ return List.of();
+ }
+
+ items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ placeholderCache = items;
+ placeholderCacheTime = now;
+ placeholderCacheDir = rootDir;
+ return items;
+ }
+
private Map<Integer, List<SourceViewer.DocEntry>>
providePropertiesQuickDocs(List<JsonObject> codeData) {
CamelCatalog catalog = getCatalog();
if (catalog == null || codeData.isEmpty()) {
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 21cf225b19eb..93ffd93fe139 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
@@ -484,7 +484,7 @@ class SourceViewer {
dirty = true;
return true;
}
- if (ke.isKey(KeyCode.TAB) && autocompleteProvider != null &&
isPropertiesFile()) {
+ if (ke.isKey(KeyCode.TAB) && autocompleteProvider != null) {
openAutocomplete();
return true;
}
@@ -531,7 +531,186 @@ class SourceViewer {
&&
editableFile.getFileName().toString().toLowerCase().endsWith(".properties");
}
+ private boolean isCamelYamlFile() {
+ if (editableFile == null) {
+ return false;
+ }
+ String name = editableFile.getFileName().toString().toLowerCase();
+ return name.endsWith(".yaml") || name.endsWith(".yml");
+ }
+
+ record YamlEndpointContext(String component, boolean consumer) {
+ }
+
+ private static final java.util.Set<String> CONSUMER_EIPS =
java.util.Set.of("from", "pollEnrich", "poll-enrich");
+ private static final java.util.Set<String> PRODUCER_EIPS
+ = java.util.Set.of("to", "toD", "to-d", "wireTap", "wire-tap",
"enrich");
+
+ YamlEndpointContext findEnclosingComponent(int fromRow) {
+ String cursorLine = editState.getLine(fromRow);
+ int cursorIndent = countLeadingSpaces(cursorLine);
+
+ // blank lines: find the nearest preceding non-blank line for context
+ if (cursorLine.isBlank()) {
+ for (int i = fromRow - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ if (prev.trim().startsWith("parameters:")) {
+ // blank line right after parameters: — cursor is
inside the block
+ cursorIndent = countLeadingSpaces(prev) + 1;
+ fromRow = i;
+ } else {
+ return findEnclosingComponent(i);
+ }
+ break;
+ }
+ }
+ }
+
+ int parametersRow = -1;
+ int parametersIndent = -1;
+
+ for (int i = fromRow; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ String trimmed = line.trim();
+
+ if (trimmed.startsWith("parameters:") && indent < cursorIndent) {
+ parametersRow = i;
+ parametersIndent = indent;
+ break;
+ }
+ if (i < fromRow && indent < cursorIndent &&
!trimmed.startsWith("#")) {
+ break;
+ }
+ }
+
+ if (parametersRow < 0) {
+ return null;
+ }
+
+ String foundScheme = null;
+ for (int i = parametersRow - 1; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ String trimmed = line.trim();
+
+ if (indent == parametersIndent) {
+ if (foundScheme == null && (trimmed.startsWith("uri:") ||
trimmed.startsWith("- uri:"))) {
+ foundScheme = extractSchemeFromUriLine(trimmed);
+ }
+ }
+
+ if (indent < parametersIndent) {
+ String eipName = extractEipName(trimmed);
+ if (foundScheme == null) {
+ foundScheme = extractInlineUri(trimmed);
+ }
+ if (foundScheme != null) {
+ boolean consumer = eipName != null &&
CONSUMER_EIPS.contains(eipName);
+ return new YamlEndpointContext(foundScheme, consumer);
+ }
+ break;
+ }
+ }
+
+ if (foundScheme != null) {
+ return new YamlEndpointContext(foundScheme, false);
+ }
+ 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 static String extractSchemeFromUriLine(String trimmed) {
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx < 0) {
+ return null;
+ }
+ String value = trimmed.substring(colonIdx + 1).trim();
+ if (value.startsWith("\"") || value.startsWith("'")) {
+ value = value.substring(1);
+ }
+ if (value.endsWith("\"") || value.endsWith("'")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ int schemeEnd = value.indexOf(':');
+ if (schemeEnd > 0) {
+ return value.substring(0, schemeEnd);
+ }
+ if (!value.isEmpty()) {
+ return value;
+ }
+ return null;
+ }
+
+ private static String extractEipName(String trimmed) {
+ String line = trimmed;
+ if (line.startsWith("- ")) {
+ line = line.substring(2).trim();
+ }
+ int colonIdx = line.indexOf(':');
+ if (colonIdx > 0) {
+ return line.substring(0, colonIdx).trim();
+ }
+ return null;
+ }
+
+ private static String extractInlineUri(String trimmed) {
+ String line = trimmed;
+ if (line.startsWith("- ")) {
+ line = line.substring(2).trim();
+ }
+ int colonIdx = line.indexOf(':');
+ if (colonIdx <= 0) {
+ return null;
+ }
+ String eipPart = line.substring(0, colonIdx).trim();
+ if (!CONSUMER_EIPS.contains(eipPart) &&
!PRODUCER_EIPS.contains(eipPart)) {
+ return null;
+ }
+ String uriPart = line.substring(colonIdx + 1).trim();
+ if (uriPart.isEmpty()) {
+ return null;
+ }
+ if (uriPart.startsWith("\"") || uriPart.startsWith("'")) {
+ uriPart = uriPart.substring(1);
+ }
+ if (uriPart.endsWith("\"") || uriPart.endsWith("'")) {
+ uriPart = uriPart.substring(0, uriPart.length() - 1);
+ }
+ int schemeEnd = uriPart.indexOf(':');
+ if (schemeEnd > 0) {
+ return uriPart.substring(0, schemeEnd);
+ }
+ return null;
+ }
+
private void openAutocomplete() {
+ if (isCamelYamlFile()) {
+ openYamlAutocomplete();
+ } else {
+ openPropertiesAutocomplete();
+ }
+ }
+
+ private void openPropertiesAutocomplete() {
String lineText = editState.getLine(editState.cursorRow());
int col = editState.cursorCol();
String textBeforeCursor = col <= lineText.length() ?
lineText.substring(0, col) : lineText;
@@ -569,11 +748,61 @@ class SourceViewer {
}
}
+ private void openYamlAutocomplete() {
+ int row = editState.cursorRow();
+ String lineText = editState.getLine(row);
+ String trimmed = lineText.trim();
+ if (trimmed.startsWith("- ")) {
+ trimmed = trimmed.substring(2).trim();
+ }
+
+ YamlEndpointContext ctx = findEnclosingComponent(row);
+ if (ctx == null) {
+ return;
+ }
+
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ // value completion — cursor is on a line with key: or key: value
+ String optionName = trimmed.substring(0, colonIdx).trim();
+ String valueText = trimmed.substring(colonIdx + 1).trim();
+ if (valueText.startsWith("\"") || valueText.startsWith("'")) {
+ valueText = valueText.substring(1);
+ }
+ if (valueText.endsWith("\"") || valueText.endsWith("'")) {
+ valueText = valueText.substring(0, valueText.length() - 1);
+ }
+ if (autocompleteValueProvider != null) {
+ String context = "yaml:" + ctx.component() + ":" + optionName;
+ List<AutocompletePopup.CompletionItem> values =
autocompleteValueProvider.provide(context);
+ if (values != null && !values.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(values, "",
valueText, true);
+ }
+ }
+ } else {
+ // key completion — cursor is on an empty or partial key line
+ String filter = colonIdx > 0 ? trimmed.substring(0,
colonIdx).trim() : trimmed;
+ String role = ctx.consumer() ? "consumer" : "producer";
+ String context = "yaml:" + ctx.component() + ":" + role;
+ List<AutocompletePopup.CompletionItem> items =
autocompleteProvider.provide(context);
+ if (items != null && !items.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(items, filter,
filter);
+ }
+ }
+ }
+
private void insertCompletion(AutocompletePopup.CompletionItem item,
boolean valueMode) {
dirty = true;
String currentLine = editState.getLine(editState.cursorRow());
+ if (isCamelYamlFile()) {
+ insertYamlCompletion(item, valueMode, currentLine);
+ } else {
+ insertPropertiesCompletion(item, valueMode, currentLine);
+ }
+ }
+
+ private void insertPropertiesCompletion(AutocompletePopup.CompletionItem
item, boolean valueMode, String currentLine) {
if (valueMode) {
- // replace value portion (after =)
int eq = currentLine.indexOf('=');
if (eq >= 0) {
String keyPart = currentLine.substring(0, eq + 1);
@@ -597,6 +826,46 @@ class SourceViewer {
}
}
+ private void insertYamlCompletion(AutocompletePopup.CompletionItem item,
boolean valueMode, String currentLine) {
+ int indent = countLeadingSpaces(currentLine);
+ // blank lines: derive indent from the nearest preceding non-blank line
+ if (currentLine.isBlank() && indent == 0) {
+ int row = editState.cursorRow();
+ for (int i = row - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ indent = countLeadingSpaces(prev);
+ if (prev.trim().startsWith("parameters:")) {
+ indent += 2;
+ }
+ break;
+ }
+ }
+ }
+ String indentStr = " ".repeat(indent);
+
+ editState.moveCursorToLineStart();
+ for (int i = 0; i < currentLine.length(); i++) {
+ editState.deleteForward();
+ }
+
+ if (valueMode) {
+ String trimmed = currentLine.trim();
+ if (trimmed.startsWith("- ")) {
+ trimmed = trimmed.substring(2).trim();
+ }
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ String keyPart = trimmed.substring(0, colonIdx);
+ editState.insert(indentStr + keyPart + ": " + item.key());
+ } else {
+ editState.insert(indentStr + item.key());
+ }
+ } else {
+ editState.insert(indentStr + item.key() + ": ");
+ }
+ }
+
private void saveEdit() {
if (!editMode || editableFile == null) {
return;
@@ -925,7 +1194,7 @@ class SourceViewer {
TuiHelper.hint(spans, "Esc", "cancel");
TuiHelper.hint(spans, "F5", "save & close");
TuiHelper.hint(spans, "Shift+F5", "save");
- if (autocompleteProvider != null && isPropertiesFile()) {
+ if (autocompleteProvider != null) {
TuiHelper.hint(spans, "Tab", "complete");
}
TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "move");
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
new file mode 100644
index 000000000000..d68d508242e0
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
@@ -0,0 +1,495 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class YamlCompletionTest {
+
+ private static CamelCatalog catalog;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ // --- Context detection via SourceViewer ---
+
+ @Test
+ void findComponentInsideParametersBlock() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: \"timer:tick\"",
+ " parameters:",
+ " period: 1000",
+ " steps:",
+ " - to:",
+ " uri: \"kafka:myTopic\"",
+ " parameters:",
+ " brokers: localhost",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "brokers: localhost" (line 9, 0-based)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(9);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("kafka");
+ assertThat(ctx.consumer()).isFalse();
+ }
+
+ @Test
+ void findConsumerComponentInsideFromParameters() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: \"timer:tick\"",
+ " parameters:",
+ " period: 1000",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "period: 1000" (line 4, 0-based)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("timer");
+ assertThat(ctx.consumer()).isTrue();
+ }
+
+ @Test
+ void findComponentWithUriWithoutPath() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: timer",
+ " parameters:",
+ " timerName: tick",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "timerName: tick" (line 4, 0-based)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("timer");
+ assertThat(ctx.consumer()).isTrue();
+ }
+
+ @Test
+ void findComponentOnBlankLineInsideParameters() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: timer",
+ " parameters:",
+ " timerName: tick",
+ "",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on the blank line (line 5, 0-based) — still inside
parameters block
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(5);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("timer");
+ assertThat(ctx.consumer()).isTrue();
+ }
+
+ @Test
+ void findComponentOnBlankLineAfterParameters() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: kafka:orders",
+ " parameters:",
+ "",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on blank line right after parameters: (line 3)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(3);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("kafka");
+ assertThat(ctx.consumer()).isTrue();
+ }
+
+ @Test
+ void returnsNullOutsideParametersBlock() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: \"timer:tick\"",
+ " steps:",
+ " - log: \"hello\"",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "- log:" (line 4, 0-based) — inside steps, not parameters
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(4);
+ assertThat(ctx).isNull();
+ }
+
+ @Test
+ void returnsNullOnRouteLevel() throws IOException {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: \"timer:tick\"",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "from:" (line 1, 0-based)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(1);
+ assertThat(ctx).isNull();
+ }
+
+ @Test
+ void findComponentWithInlineUri() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: \"timer:tick\"",
+ " steps:",
+ " - to: \"kafka:orders\"",
+ " parameters:",
+ " brokers: localhost",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on "brokers: localhost" (line 5, 0-based)
+ SourceViewer.YamlEndpointContext ctx =
viewer.findEnclosingComponent(5);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.component()).isEqualTo("kafka");
+ assertThat(ctx.consumer()).isFalse();
+ }
+
+ // --- Key completion from catalog ---
+
+ @Test
+ void keyCompletionReturnsKafkaEndpointOptions() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer");
+
+ assertThat(items).isNotEmpty();
+ assertThat(items).anyMatch(i -> i.key().equals("brokers"));
+ // should have descriptions and types
+ var brokers = items.stream().filter(i ->
i.key().equals("brokers")).findFirst();
+ assertThat(brokers).isPresent();
+ assertThat(brokers.get().description()).isNotNull().isNotEmpty();
+ assertThat(brokers.get().type()).isNotNull();
+ }
+
+ @Test
+ void keyCompletionExcludesPathOptions() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer");
+
+ // "topic" is a path option in kafka, should NOT appear in parameters
completion
+ assertThat(items).noneMatch(i -> i.key().equals("topic"));
+ }
+
+ @Test
+ void keyCompletionFiltersConsumerOnlyOptions() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer");
+
+ // consumer-only options should not appear for a producer
+ ComponentModel model = catalog.componentModel("kafka");
+ List<String> consumerOnlyOptions = new ArrayList<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
+ String label = opt.getLabel();
+ if (label != null && label.contains("consumer") &&
!label.contains("producer")) {
+ consumerOnlyOptions.add(opt.getName());
+ }
+ }
+ if (!consumerOnlyOptions.isEmpty()) {
+ for (String opt : consumerOnlyOptions) {
+ assertThat(items).noneMatch(i -> i.key().equals(opt));
+ }
+ }
+ }
+
+ @Test
+ void keyCompletionShowsConsumerOptionsForConsumer() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "consumer");
+
+ // should include consumer options
+ ComponentModel model = catalog.componentModel("kafka");
+ List<String> consumerOptions = new ArrayList<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
+ String label = opt.getLabel();
+ if (label != null && label.contains("consumer") &&
!label.contains("producer")) {
+ consumerOptions.add(opt.getName());
+ }
+ }
+ if (!consumerOptions.isEmpty()) {
+ for (String opt : consumerOptions) {
+ assertThat(items).anyMatch(i -> i.key().equals(opt));
+ }
+ }
+ }
+
+ @Test
+ void keyCompletionSortedNonDeprecatedFirst() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer");
+
+ int lastNonDeprecatedIdx = -1;
+ int firstDeprecatedIdx = items.size();
+ for (int i = 0; i < items.size(); i++) {
+ if (!items.get(i).deprecated()) {
+ lastNonDeprecatedIdx = i;
+ } else if (i < firstDeprecatedIdx) {
+ firstDeprecatedIdx = i;
+ }
+ }
+ if (firstDeprecatedIdx < items.size()) {
+ assertThat(lastNonDeprecatedIdx).isLessThan(firstDeprecatedIdx);
+ }
+ }
+
+ // --- Value completion ---
+
+ @Test
+ void valueCompletionReturnsBooleanValues() {
+ List<AutocompletePopup.CompletionItem> items =
provideValueCompletions("kafka", "autoCommitEnable");
+
+ assertThat(items).anyMatch(i -> i.key().equals("true"));
+ assertThat(items).anyMatch(i -> i.key().equals("false"));
+ }
+
+ @Test
+ void valueCompletionReturnsEnumValues() {
+ List<AutocompletePopup.CompletionItem> items =
provideValueCompletions("kafka", "autoOffsetReset");
+
+ // kafka autoOffsetReset has enum values: latest, earliest, none
+ assertThat(items).anyMatch(i -> i.key().equals("latest"));
+ assertThat(items).anyMatch(i -> i.key().equals("earliest"));
+ }
+
+ // --- Property placeholder loading ---
+
+ @Test
+ void loadPlaceholdersFromPropertiesFile() throws IOException {
+ Path propsFile = tempDir.resolve("application.properties");
+ Files.writeString(propsFile, String.join("\n",
+ "# Kafka settings",
+ "kafka.brokers=localhost:9092",
+ "kafka.topic=my-orders",
+ "",
+ "# App settings",
+ "myapp.timeout=30000",
+ ""));
+
+ List<AutocompletePopup.CompletionItem> items =
loadPlaceholders(tempDir);
+
+ assertThat(items).hasSize(3);
+ assertThat(items).anyMatch(i -> i.key().equals("{{kafka.brokers}}"));
+ assertThat(items).anyMatch(i -> i.key().equals("{{kafka.topic}}"));
+ assertThat(items).anyMatch(i -> i.key().equals("{{myapp.timeout}}"));
+
+ // descriptions should show the property values
+ var brokers = items.stream().filter(i ->
i.key().equals("{{kafka.brokers}}")).findFirst();
+ assertThat(brokers).isPresent();
+ assertThat(brokers.get().description()).isEqualTo("localhost:9092");
+ }
+
+ @Test
+ void loadPlaceholdersSkipsCommentsAndBlanks() throws IOException {
+ Path propsFile = tempDir.resolve("application.properties");
+ Files.writeString(propsFile, String.join("\n",
+ "# comment",
+ "! another comment",
+ "",
+ "valid.key=value",
+ ""));
+
+ List<AutocompletePopup.CompletionItem> items =
loadPlaceholders(tempDir);
+
+ assertThat(items).hasSize(1);
+ assertThat(items.get(0).key()).isEqualTo("{{valid.key}}");
+ }
+
+ @Test
+ void loadPlaceholdersReturnsEmptyForNoPropertiesFiles() {
+ List<AutocompletePopup.CompletionItem> items =
loadPlaceholders(tempDir);
+ assertThat(items).isEmpty();
+ }
+
+ // --- Helpers that replicate SourceTab logic for testing ---
+
+ private List<AutocompletePopup.CompletionItem>
provideKeyCompletions(String componentName, String role) {
+ ComponentModel model = catalog.componentModel(componentName);
+ if (model == null) {
+ return List.of();
+ }
+ boolean isConsumer = "consumer".equals(role);
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
+ if (includeEndpointOption(opt, isConsumer)) {
+ items.add(new AutocompletePopup.CompletionItem(
+ opt.getName(), opt.getDescription(), opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ opt.getGroup()));
+ }
+ }
+
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ .thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+
+ private static boolean
includeEndpointOption(ComponentModel.EndpointOptionModel opt, boolean
isConsumer) {
+ String label = opt.getLabel();
+ if (label == null || label.isEmpty()) {
+ return true;
+ }
+ if (label.contains("consumer") && label.contains("producer")) {
+ return true;
+ }
+ if (isConsumer) {
+ return !label.contains("producer");
+ } else {
+ return !label.contains("consumer");
+ }
+ }
+
+ private List<AutocompletePopup.CompletionItem>
provideValueCompletions(String componentName, String optionName) {
+ ComponentModel model = catalog.componentModel(componentName);
+ if (model == null) {
+ return List.of();
+ }
+
+ ComponentModel.EndpointOptionModel opt = null;
+ for (ComponentModel.EndpointOptionModel o :
model.getEndpointOptions()) {
+ if (o.getName().equals(optionName)) {
+ opt = o;
+ break;
+ }
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ if (opt != null) {
+ List<String> enums = opt.getEnums();
+ if (enums != null && !enums.isEmpty()) {
+ for (String value : enums) {
+ boolean isDefault =
value.equals(String.valueOf(opt.getDefaultValue()));
+ items.add(new AutocompletePopup.CompletionItem(
+ value, opt.getDescription(), opt.getType(),
+ isDefault ? value : opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ } else if ("boolean".equalsIgnoreCase(opt.getType())
+ || "java.lang.Boolean".equals(opt.getJavaType())) {
+ items.add(new AutocompletePopup.CompletionItem(
+ "true", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ items.add(new AutocompletePopup.CompletionItem(
+ "false", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ }
+ return items;
+ }
+
+ private List<AutocompletePopup.CompletionItem> loadPlaceholders(Path dir) {
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ try (var stream = Files.list(dir)) {
+ stream.filter(p ->
p.getFileName().toString().endsWith(".properties"))
+ .forEach(p -> {
+ try {
+ for (String line : Files.readAllLines(p)) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() ||
trimmed.startsWith("#") || trimmed.startsWith("!")) {
+ continue;
+ }
+ int eq = trimmed.indexOf('=');
+ if (eq > 0) {
+ String key = trimmed.substring(0,
eq).trim();
+ String value = trimmed.substring(eq +
1).trim();
+ items.add(new
AutocompletePopup.CompletionItem(
+ "{{" + key + "}}", value,
"placeholder",
+ null, false, null,
p.getFileName().toString()));
+ }
+ }
+ } catch (IOException e) {
+ // skip
+ }
+ });
+ } catch (IOException e) {
+ return List.of();
+ }
+ items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+}