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 3179128928d2 camel-jbang (tui) - Add Spring Boot auto-configuration
tab completion
3179128928d2 is described below
commit 3179128928d27044913cc3a27aaa8c120643e69d
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Aug 12 19:39:54 2026 +0200
camel-jbang (tui) - Add Spring Boot auto-configuration tab completion
Resolves Spring Boot configuration metadata from starter JARs in the
local Maven repository so that tab completion works for Spring Boot
properties (server.*, spring.*, management.*, etc.) even when the
application is not running (phantom/stopped projects).
- New SpringBootMetadataResolver reads spring-configuration-metadata.json
from JARs in ~/.m2/repository, following starter pom transitives
- DependencyLoader.detectCamelVersion/detectSpringBootVersion extract
versions from pom.xml with fallback to camel-parent pom for SB version
- Phantom projects now get camelVersion set from pom.xml
- Metadata loading runs on background thread to avoid UI freeze
- Hierarchical sub-group navigation matching Camel completion style
- Updated F1 help text and camel-jbang-tui.adoc documentation
camel-jbang (tui) - Filter unresolved ${} version placeholders in pom deps
Versions like ${spring-boot-version} that cannot be resolved from the
pom properties were passed through as literal strings, preventing the
fallback to camel-parent version detection. Now unresolved placeholders
are treated as null so starter transitive resolution uses the correct
Spring Boot version.
camel-jbang (tui) - Recurse Spring Boot module deps for metadata resolution
Spring Boot module JARs can depend on other module JARs (e.g.
spring-boot-jdbc -> spring-boot-sql). Recurse into org.springframework.boot
dependencies to pick up metadata from deeper transitives. The scanned set
prevents infinite loops.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 7 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 3 +
.../jbang/core/commands/tui/ConfigurationTab.java | 29 ++-
.../jbang/core/commands/tui/DependencyLoader.java | 98 ++++++++
.../jbang/core/commands/tui/FolderInputPopup.java | 3 +
.../dsl/jbang/core/commands/tui/McpFacade.java | 3 +
.../dsl/jbang/core/commands/tui/SourceTab.java | 201 +++++++++++++++-
.../commands/tui/SpringBootMetadataResolver.java | 264 +++++++++++++++++++++
8 files changed, 596 insertions(+), 12 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index f24a63453d02..9df667818106 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -193,8 +193,11 @@ Camel routes:
* *Delete line* -- *Ctrl+K* to delete the current line
* *Word navigation* -- *Ctrl+Left/Right* to jump by word
* *Smart Home* -- *Home* key alternates between content indent and column 0
-* *Tab completion* -- press *Tab* for context-aware YAML completion (EIP
names, component URIs,
- option keys and values)
+* *Tab completion* -- press *Tab* for context-aware completion:
+** In `application.properties`: Camel configuration options (`camel.main.*`,
`camel.component.*`, etc.)
+ and Spring Boot auto-configuration properties (`server.*`, `spring.*`,
`management.*`, etc.)
+ resolved from starter JARs in the local Maven repository — works even for
stopped projects
+** In YAML DSL routes: EIP names, component URIs, option keys and values
The editor shows *gutter change markers* -- a green background on line numbers
that have been
modified or added since the file was opened. This gives an at-a-glance view of
what you've changed.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index a4b751233121..5cde61da6931 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -2486,6 +2486,9 @@ public class CamelMonitor extends CamelCommand {
} else {
phantom.platform = "Camel";
}
+ if (runtime != null) {
+ phantom.camelVersion =
DependencyLoader.detectCamelVersion(pomFile);
+ }
ctx.addPhantom(phantom);
ctx.selectedPid = phantom.pid;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
index 084a8c555848..31494d01f090 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -63,9 +65,10 @@ class ConfigurationTab extends AbstractTableTab {
private Map<String, BaseOptionModel> mainOptionsMap;
private final Map<String, Map<String, BaseOptionModel>>
componentOptionsCache = new HashMap<>();
- // Spring Boot configuration metadata cache (lazy-loaded on-demand via IPC)
+ // Spring Boot configuration metadata cache (lazy-loaded on-demand via IPC
or from local JARs)
private Map<String, JsonObject> springBootMetadataCache;
private boolean springBootMetadataLoaded;
+ private
java.util.concurrent.CompletableFuture<SpringBootMetadataResolver.MetadataResult>
springBootMetadataFuture;
ConfigurationTab(MonitorContext ctx) {
super(ctx, "key", "value", "source");
@@ -368,6 +371,13 @@ class ConfigurationTab extends AbstractTableTab {
private void ensureSpringBootMetadataCache() {
if (springBootMetadataLoaded) {
+ if (springBootMetadataFuture != null &&
springBootMetadataFuture.isDone()) {
+ SpringBootMetadataResolver.MetadataResult result =
springBootMetadataFuture.join();
+ if (result != null) {
+ springBootMetadataCache = result.properties();
+ }
+ springBootMetadataFuture = null;
+ }
return;
}
springBootMetadataLoaded = true;
@@ -375,7 +385,21 @@ class ConfigurationTab extends AbstractTableTab {
if (info == null || !"Spring Boot".equals(info.platform)) {
return;
}
- springBootMetadataCache = SpringBootMetadataHelper.fetchMetadata(ctx,
info.pid);
+
+ if (!info.phantom && info.pid != null && !info.pid.isEmpty()) {
+ springBootMetadataCache =
SpringBootMetadataHelper.fetchMetadata(ctx, info.pid);
+ }
+
+ if ((springBootMetadataCache == null ||
springBootMetadataCache.isEmpty())
+ && info.directory != null) {
+ Path pomFile = Path.of(info.directory, "pom.xml");
+ if (Files.isRegularFile(pomFile)) {
+ String camelVer = info.camelVersion;
+ springBootMetadataFuture =
java.util.concurrent.CompletableFuture.supplyAsync(
+ () -> SpringBootMetadataResolver.loadFromPom(pomFile,
camelVer),
+ ctx.backgroundExecutor);
+ }
+ }
}
private void initCatalog() {
@@ -397,6 +421,7 @@ class ConfigurationTab extends AbstractTableTab {
componentOptionsCache.clear();
springBootMetadataCache = null;
springBootMetadataLoaded = false;
+ springBootMetadataFuture = null;
MainModel mainModel = catalog.mainModel();
if (mainModel != null) {
for (MainModel.MainOptionModel opt : mainModel.getOptions()) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DependencyLoader.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DependencyLoader.java
index 0ec787f902ec..d563069365fe 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DependencyLoader.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DependencyLoader.java
@@ -287,6 +287,9 @@ final class DependencyLoader {
g = resolveProperty(g, properties);
a = resolveProperty(a, properties);
v = resolveProperty(v, properties);
+ if (v != null && v.contains("${")) {
+ v = null;
+ }
if (skipArtifact(g, a)) {
continue;
@@ -368,6 +371,101 @@ final class DependencyLoader {
return value;
}
+ static String detectCamelVersion(Path pomFile) {
+ Map<String, String> versions = detectPomVersions(pomFile);
+ return versions != null ? versions.get("camel") : null;
+ }
+
+ static String detectSpringBootVersion(Path pomFile) {
+ Map<String, String> versions = detectPomVersions(pomFile);
+ return versions != null ? versions.get("spring-boot") : null;
+ }
+
+ private static Map<String, String> detectPomVersions(Path pomFile) {
+ try {
+ DocumentBuilderFactory dbf =
XmlHelper.createDocumentBuilderFactory();
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ Document dom;
+ try (InputStream is = Files.newInputStream(pomFile)) {
+ dom = db.parse(is);
+ }
+
+ Map<String, String> properties = new HashMap<>();
+ NodeList propsList = dom.getElementsByTagName("properties");
+ if (propsList.getLength() > 0) {
+ Element propsEl = (Element) propsList.item(0);
+ for (int i = 0; i < propsEl.getChildNodes().getLength(); i++) {
+ if (propsEl.getChildNodes().item(i) instanceof Element
prop) {
+ properties.put(prop.getTagName(),
prop.getTextContent().trim());
+ }
+ }
+ }
+
+ // resolve implicit Maven properties (project.version inherits
from parent)
+ String parentVersion = null;
+ NodeList parentList = dom.getElementsByTagName("parent");
+ if (parentList.getLength() > 0) {
+ parentVersion = textContent((Element) parentList.item(0),
"version");
+ }
+ Element root = dom.getDocumentElement();
+ String projectVersion = textContent(root, "version");
+ if (projectVersion == null) {
+ projectVersion = parentVersion;
+ }
+ if (projectVersion != null) {
+ properties.putIfAbsent("project.version", projectVersion);
+ }
+
+ String camelVersion = null;
+ String springBootVersion = null;
+
+ if (parentList.getLength() > 0) {
+ Element parentEl = (Element) parentList.item(0);
+ String pg = textContent(parentEl, "groupId");
+ String pa = textContent(parentEl, "artifactId");
+ String pv = resolveProperty(parentVersion, properties);
+ if ("org.springframework.boot".equals(pg)
+ && ("spring-boot-starter-parent".equals(pa) ||
"spring-boot-dependencies".equals(pa))) {
+ springBootVersion = pv;
+ }
+ if ("org.apache.camel.springboot".equals(pg) &&
"camel-spring-boot-bom".equals(pa)) {
+ camelVersion = pv;
+ }
+ }
+
+ NodeList nl = dom.getElementsByTagName("dependency");
+ for (int i = 0; i < nl.getLength(); i++) {
+ Element node = (Element) nl.item(i);
+ String g = textContent(node, "groupId");
+ String a = textContent(node, "artifactId");
+ String v = textContent(node, "version");
+ v = resolveProperty(v, properties);
+ if (camelVersion == null && "org.apache.camel".equals(g) &&
"camel-bom".equals(a)) {
+ camelVersion = v;
+ }
+ if (camelVersion == null &&
"org.apache.camel.springboot".equals(g)
+ && "camel-spring-boot-bom".equals(a)) {
+ camelVersion = v;
+ }
+ if (springBootVersion == null &&
"org.springframework.boot".equals(g)
+ && "spring-boot-dependencies".equals(a)) {
+ springBootVersion = v;
+ }
+ }
+
+ Map<String, String> result = new HashMap<>();
+ if (camelVersion != null && !camelVersion.contains("${")) {
+ result.put("camel", camelVersion);
+ }
+ if (springBootVersion != null &&
!springBootVersion.contains("${")) {
+ result.put("spring-boot", springBootVersion);
+ }
+ return result;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
static String textContent(Element parent, String tag) {
NodeList nl = parent.getElementsByTagName(tag);
return nl.getLength() > 0 ? nl.item(0).getTextContent().trim() : null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
index 8fec5ccb3424..76700775abab 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
@@ -273,6 +273,9 @@ class FolderInputPopup {
} else {
phantom.platform = "Camel";
}
+ if (runtime != null) {
+ phantom.camelVersion =
DependencyLoader.detectCamelVersion(pomFile);
+ }
ctx.addPhantom(phantom);
ctx.selectedPid = phantom.pid;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index df238ba3e82d..fcc6af4bfd90 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -922,6 +922,9 @@ class McpFacade {
} else {
phantom.platform = "Camel";
}
+ if (runtime != null) {
+ phantom.camelVersion =
DependencyLoader.detectCamelVersion(pomFile);
+ }
ctx.addPhantom(phantom);
ctx.selectedPid = phantom.pid;
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 71aebf8fc91d..a12165d3d913 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
@@ -112,9 +112,13 @@ class SourceTab extends AbstractTab {
private JsonObject completionTree;
private boolean completionTreeLoaded;
- // Spring Boot configuration metadata cache (lazy-loaded on-demand via IPC)
+ // Spring Boot configuration metadata cache (lazy-loaded on-demand via IPC
or from local JARs)
private Map<String, JsonObject> springBootMetadataCache;
private boolean springBootMetadataLoaded;
+ private Map<String, BaseOptionModel> springBootOptionsCache;
+ private Map<String, String> springBootGroupsCache;
+ private Map<String, List<String>> springBootHintsCache;
+ private
java.util.concurrent.CompletableFuture<SpringBootMetadataResolver.MetadataResult>
springBootMetadataFuture;
private static final Pattern YAML_URI_PATTERN = Pattern.compile(
"^\\s*-?\\s*(?:uri|from|to|toD|wireTap|enrich|pollEnrich|deadLetterChannel):\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*(?::[^\"\\s]*)?)");
@@ -455,7 +459,11 @@ class SourceTab extends AbstractTab {
**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
+ - Spring Boot auto-configuration properties (`server.*`,
`spring.*`, `management.*`,
+ etc.) resolved from starter JARs in the local Maven
repository — works even when
+ the application is not running (phantom/stopped projects)
+ - Value completion with enum choices, boolean values, Spring
Boot value hints,
+ and `{{placeholder}}` suggestions
**YAML DSL routes:**
- On `uri:` lines (or inline EIPs like `to:`, `from:`), Tab
shows a list of
@@ -786,10 +794,13 @@ class SourceTab extends AbstractTab {
private List<AutocompletePopup.CompletionItem>
providePropertyCompletions(String linePrefix) {
CamelCatalog catalog = getCatalog();
- if (catalog == null) {
+ if (catalog != null) {
+ ensureMainOptionsCache(catalog);
+ }
+ ensureSpringBootMetadataCache();
+ if (catalog == null && (springBootOptionsCache == null ||
springBootOptionsCache.isEmpty())) {
return List.of();
}
- ensureMainOptionsCache(catalog);
String keyPrefix = linePrefix != null ?
linePrefix.trim().toLowerCase() : "";
@@ -849,6 +860,10 @@ class SourceTab extends AbstractTab {
LanguageModel m = catalog.languageModel(name);
return m != null ? m.getOptions() : null;
});
+ } else if (!keyPrefix.isEmpty() && !keyPrefix.startsWith("camel.")
+ && springBootOptionsCache != null) {
+ // Spring Boot property completions (e.g., server.,
spring.datasource.)
+ addSpringBootCompletions(items, keyPrefix);
} else {
// show group-level entries
if (mainGroupsCache != null) {
@@ -872,10 +887,19 @@ class SourceTab extends AbstractTab {
items.add(new AutocompletePopup.CompletionItem(
"camel.language.", "Language configuration prefix",
null, null, false, null, null));
}
+ // Spring Boot top-level groups
+ addSpringBootTopLevelGroups(items, keyPrefix);
}
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
- .thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ .thenComparing((a, b) -> {
+ boolean aGroup = a.key().endsWith(".");
+ boolean bGroup = b.key().endsWith(".");
+ if (aGroup != bGroup) {
+ return aGroup ? -1 : 1;
+ }
+ return String.CASE_INSENSITIVE_ORDER.compare(a.key(),
b.key());
+ }));
return items;
}
@@ -914,15 +938,94 @@ class SourceTab extends AbstractTab {
}
}
+ private void
addSpringBootCompletions(List<AutocompletePopup.CompletionItem> items, String
keyPrefix) {
+ // collect next-level sub-groups under this prefix
+ Set<String> subGroups = new java.util.TreeSet<>();
+ List<Map.Entry<String, BaseOptionModel>> directOptions = new
ArrayList<>();
+
+ for (Map.Entry<String, BaseOptionModel> entry :
springBootOptionsCache.entrySet()) {
+ String key = entry.getKey();
+ if (!key.toLowerCase().startsWith(keyPrefix)) {
+ continue;
+ }
+ String rest = key.substring(keyPrefix.length());
+ int dot = rest.indexOf('.');
+ if (dot > 0) {
+ // has sub-group: e.g. "aop.auto" under "spring." → sub-group
"aop"
+ subGroups.add(keyPrefix + rest.substring(0, dot));
+ } else {
+ // direct property at this level
+ directOptions.add(entry);
+ }
+ }
+
+ if (subGroups.size() > 1 || (!subGroups.isEmpty() &&
!directOptions.isEmpty())) {
+ // show sub-groups as drill-down entries
+ for (String group : subGroups) {
+ String groupKey = group + ".";
+ items.add(new AutocompletePopup.CompletionItem(
+ groupKey, "Spring Boot configuration", null, null,
false, null, null));
+ }
+ // also show any direct properties at this level
+ for (Map.Entry<String, BaseOptionModel> entry : directOptions) {
+ BaseOptionModel opt = entry.getValue();
+ items.add(new AutocompletePopup.CompletionItem(
+ entry.getKey(), opt.getDescription(), opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ "Spring Boot"));
+ }
+ } else {
+ // single sub-group or leaf level: show all matching properties
+ for (Map.Entry<String, BaseOptionModel> entry :
springBootOptionsCache.entrySet()) {
+ if (entry.getKey().toLowerCase().startsWith(keyPrefix)) {
+ BaseOptionModel opt = entry.getValue();
+ items.add(new AutocompletePopup.CompletionItem(
+ entry.getKey(), opt.getDescription(),
opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ "Spring Boot"));
+ }
+ }
+ }
+ }
+
+ private void addSpringBootTopLevelGroups(
+ List<AutocompletePopup.CompletionItem> items, String keyPrefix) {
+ if (springBootGroupsCache == null || springBootGroupsCache.isEmpty()) {
+ return;
+ }
+ Set<String> topLevelGroups = new java.util.TreeSet<>();
+ for (String group : springBootGroupsCache.keySet()) {
+ int dot = group.indexOf('.');
+ String topLevel = dot > 0 ? group.substring(0, dot) : group;
+ topLevelGroups.add(topLevel);
+ }
+ for (String group : topLevelGroups) {
+ String groupKey = group + ".";
+ if (keyPrefix.isEmpty() ||
groupKey.toLowerCase().contains(keyPrefix)) {
+ items.add(new AutocompletePopup.CompletionItem(
+ groupKey, "Spring Boot configuration", null, null,
false, null, null));
+ }
+ }
+ }
+
private List<AutocompletePopup.CompletionItem>
providePropertyValueCompletions(String key) {
CamelCatalog catalog = getCatalog();
- if (catalog == null || key == null || key.isEmpty()) {
+ if (key == null || key.isEmpty()) {
return loadPropertyPlaceholders();
}
- ensureMainOptionsCache(catalog);
+ if (catalog != null) {
+ ensureMainOptionsCache(catalog);
+ }
+ ensureSpringBootMetadataCache();
BaseOptionModel opt = lookupOption(catalog, key);
if (opt == null) {
+ // check Spring Boot hints even without a matching option model
+ List<AutocompletePopup.CompletionItem> hintItems =
lookupSpringBootHints(key);
+ if (!hintItems.isEmpty()) {
+ hintItems.addAll(loadPropertyPlaceholders());
+ return hintItems;
+ }
return loadPropertyPlaceholders();
}
@@ -956,6 +1059,11 @@ class SourceTab extends AbstractTab {
valueFilter = SourceTab::isNumericValue;
}
+ // Spring Boot hints for values without enum metadata
+ if (items.isEmpty()) {
+ items.addAll(lookupSpringBootHints(key));
+ }
+
// 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()))) {
@@ -965,12 +1073,34 @@ class SourceTab extends AbstractTab {
return items;
}
+ private List<AutocompletePopup.CompletionItem>
lookupSpringBootHints(String key) {
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ if (springBootHintsCache != null) {
+ List<String> hintValues = springBootHintsCache.get(key);
+ if (hintValues != null) {
+ for (String value : hintValues) {
+ items.add(new AutocompletePopup.CompletionItem(
+ value, null, null, null, false, null, "Spring
Boot"));
+ }
+ }
+ }
+ return items;
+ }
+
private BaseOptionModel lookupOption(CamelCatalog catalog, String key) {
// camel.main.* options
if (mainOptionsCache != null && mainOptionsCache.containsKey(key)) {
return mainOptionsCache.get(key);
}
+ if (catalog == null) {
+ // no Camel catalog — only Spring Boot options available
+ if (springBootOptionsCache != null &&
springBootOptionsCache.containsKey(key)) {
+ return springBootOptionsCache.get(key);
+ }
+ return null;
+ }
+
// camel.component.<name>.<option>
if (key.startsWith("camel.component.")) {
return lookupPrefixedOption(catalog, key, "camel.component.",
@@ -995,6 +1125,10 @@ class SourceTab extends AbstractTab {
return m != null ? m.getOptions() : null;
});
}
+ // Spring Boot options
+ if (springBootOptionsCache != null &&
springBootOptionsCache.containsKey(key)) {
+ return springBootOptionsCache.get(key);
+ }
return null;
}
@@ -2082,6 +2216,10 @@ class SourceTab extends AbstractTab {
dataformatOptionsCache.clear();
springBootMetadataCache = null;
springBootMetadataLoaded = false;
+ springBootOptionsCache = null;
+ springBootGroupsCache = null;
+ springBootHintsCache = null;
+ springBootMetadataFuture = null;
propsCatalogVersion = version;
}
if (mainOptionsCache == null) {
@@ -2105,6 +2243,11 @@ class SourceTab extends AbstractTab {
private void ensureSpringBootMetadataCache() {
if (springBootMetadataLoaded) {
+ // check if async loading completed
+ if (springBootMetadataFuture != null &&
springBootMetadataFuture.isDone()) {
+ applySpringBootMetadataResult(springBootMetadataFuture.join());
+ springBootMetadataFuture = null;
+ }
return;
}
springBootMetadataLoaded = true;
@@ -2112,7 +2255,49 @@ class SourceTab extends AbstractTab {
if (info == null || !"Spring Boot".equals(info.platform)) {
return;
}
- springBootMetadataCache = SpringBootMetadataHelper.fetchMetadata(ctx,
info.pid);
+
+ if (!info.phantom && info.pid != null && !info.pid.isEmpty()) {
+ springBootMetadataCache =
SpringBootMetadataHelper.fetchMetadata(ctx, info.pid);
+ if (springBootMetadataCache != null &&
!springBootMetadataCache.isEmpty()) {
+ applySpringBootMetadataResult(
+ new
SpringBootMetadataResolver.MetadataResult(springBootMetadataCache, Map.of()));
+ return;
+ }
+ }
+
+ if (info.directory != null) {
+ Path pomFile = Path.of(info.directory, "pom.xml");
+ if (Files.isRegularFile(pomFile)) {
+ String camelVer = info.camelVersion;
+ springBootMetadataFuture =
java.util.concurrent.CompletableFuture.supplyAsync(
+ () -> SpringBootMetadataResolver.loadFromPom(pomFile,
camelVer),
+ ctx.backgroundExecutor);
+ }
+ }
+ }
+
+ private void
applySpringBootMetadataResult(SpringBootMetadataResolver.MetadataResult result)
{
+ if (result == null) {
+ return;
+ }
+ springBootMetadataCache = result.properties();
+ if (springBootMetadataCache != null &&
!springBootMetadataCache.isEmpty()) {
+ springBootOptionsCache = new HashMap<>();
+ springBootGroupsCache = new HashMap<>();
+ springBootHintsCache = result.hints() != null ? result.hints() :
Map.of();
+
+ for (Map.Entry<String, JsonObject> entry :
springBootMetadataCache.entrySet()) {
+ String name = entry.getKey();
+ BaseOptionModel model =
SpringBootMetadataHelper.toOptionModel(entry.getValue());
+ springBootOptionsCache.put(name, model);
+
+ int lastDot = name.lastIndexOf('.');
+ if (lastDot > 0) {
+ String group = name.substring(0, lastDot);
+ springBootGroupsCache.putIfAbsent(group, "");
+ }
+ }
+ }
}
private void renderFileList(Frame frame, Rect area) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpringBootMetadataResolver.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpringBootMetadataResolver.java
new file mode 100644
index 000000000000..652b89d1e955
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SpringBootMetadataResolver.java
@@ -0,0 +1,264 @@
+/*
+ * 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.File;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Resolves Spring Boot configuration metadata statically from JARs in the
local Maven repository. This enables
+ * autocomplete for Spring Boot properties even when the application is not
running (phantom/stopped projects).
+ */
+final class SpringBootMetadataResolver {
+
+ private SpringBootMetadataResolver() {
+ }
+
+ record MetadataResult(Map<String, JsonObject> properties, Map<String,
List<String>> hints) {
+ }
+
+ static MetadataResult loadFromPom(Path pomFile, String camelVersion) {
+ Map<String, JsonObject> properties = new HashMap<>();
+ Map<String, List<String>> hints = new HashMap<>();
+
+ String springBootVersion =
DependencyLoader.detectSpringBootVersion(pomFile);
+ if (springBootVersion == null && camelVersion != null) {
+ springBootVersion =
detectSpringBootVersionFromCamelParent(camelVersion);
+ }
+
+ if (springBootVersion != null) {
+ // core JARs always present in Spring Boot projects
+ readJarIfExists("org.springframework.boot", "spring-boot",
springBootVersion, properties, hints);
+ readJarIfExists("org.springframework.boot",
"spring-boot-autoconfigure", springBootVersion, properties, hints);
+ }
+
+ Set<String> scanned = new HashSet<>();
+ List<DependencyLoader.DepEntry> deps =
DependencyLoader.loadFromPomXml(pomFile);
+ for (DependencyLoader.DepEntry dep : deps) {
+ String v = dep.version() != null ? dep.version() :
springBootVersion;
+ if (v == null) {
+ continue;
+ }
+ String key = dep.groupId() + ":" + dep.artifactId() + ":" + v;
+ if (!scanned.add(key)) {
+ continue;
+ }
+ readJarIfExists(dep.groupId(), dep.artifactId(), v, properties,
hints);
+
+ // for starter dependencies, resolve one level of transitives from
the starter pom
+ if (dep.artifactId().contains("starter")) {
+ resolveStarterTransitives(dep.groupId(), dep.artifactId(), v,
scanned, properties, hints);
+ }
+ }
+
+ return new MetadataResult(properties, hints);
+ }
+
+ private static void resolveStarterTransitives(
+ String groupId, String artifactId, String version,
+ Set<String> scanned,
+ Map<String, JsonObject> properties, Map<String, List<String>>
hints) {
+ Path pomPath = localRepoPomPath(groupId, artifactId, version);
+ if (pomPath == null) {
+ return;
+ }
+ try {
+ String content = Files.readString(pomPath);
+ // simple XML parsing for dependency elements in starter poms
+ int pos = 0;
+ while (true) {
+ int depStart = content.indexOf("<dependency>", pos);
+ if (depStart < 0) {
+ break;
+ }
+ int depEnd = content.indexOf("</dependency>", depStart);
+ if (depEnd < 0) {
+ break;
+ }
+ String depBlock = content.substring(depStart, depEnd);
+ pos = depEnd + 1;
+
+ String g = extractXmlElement(depBlock, "groupId");
+ String a = extractXmlElement(depBlock, "artifactId");
+ if (g == null || a == null) {
+ continue;
+ }
+ // use same version for Spring Boot group deps
+ String v = "org.springframework.boot".equals(g) ? version :
extractXmlElement(depBlock, "version");
+ if (v == null) {
+ continue;
+ }
+ String key = g + ":" + a + ":" + v;
+ if (!scanned.add(key)) {
+ continue;
+ }
+ readJarIfExists(g, a, v, properties, hints);
+ // recurse into Spring Boot module deps (e.g. spring-boot-jdbc
-> spring-boot-sql)
+ if ("org.springframework.boot".equals(g)) {
+ resolveStarterTransitives(g, a, v, scanned, properties,
hints);
+ }
+ }
+ } catch (Exception e) {
+ // skip
+ }
+ }
+
+ private static String extractXmlElement(String xml, String element) {
+ String open = "<" + element + ">";
+ String close = "</" + element + ">";
+ int start = xml.indexOf(open);
+ if (start < 0) {
+ return null;
+ }
+ start += open.length();
+ int end = xml.indexOf(close, start);
+ return end > start ? xml.substring(start, end).trim() : null;
+ }
+
+ private static void readJarIfExists(
+ String groupId, String artifactId, String version,
+ Map<String, JsonObject> properties, Map<String, List<String>>
hints) {
+ Path jarPath = localRepoJarPath(groupId, artifactId, version);
+ if (jarPath != null) {
+ readMetadataFromJar(jarPath, properties, hints);
+ }
+ }
+
+ private static String detectSpringBootVersionFromCamelParent(String
camelVersion) {
+ Path localRepo = Path.of(System.getProperty("user.home"), ".m2",
"repository");
+ Path parentPom = localRepo
+ .resolve("org/apache/camel/camel-parent")
+ .resolve(camelVersion)
+ .resolve("camel-parent-" + camelVersion + ".pom");
+ if (!Files.isRegularFile(parentPom)) {
+ return null;
+ }
+ try {
+ String content = Files.readString(parentPom);
+ int start = content.indexOf("<spring-boot-version>");
+ if (start < 0) {
+ return null;
+ }
+ start += "<spring-boot-version>".length();
+ int end = content.indexOf("</spring-boot-version>", start);
+ if (end < 0) {
+ return null;
+ }
+ return content.substring(start, end).trim();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static void readMetadataFromJar(
+ Path jarPath, Map<String, JsonObject> properties, Map<String,
List<String>> hints) {
+ try (JarFile jar = new JarFile(jarPath.toFile())) {
+ readMetadataEntry(jar,
"META-INF/spring-configuration-metadata.json", properties, hints);
+ readMetadataEntry(jar,
"META-INF/additional-spring-configuration-metadata.json", properties, hints);
+ } catch (Exception e) {
+ // skip unreadable JARs
+ }
+ }
+
+ private static void readMetadataEntry(
+ JarFile jar, String entryName, Map<String, JsonObject> properties,
Map<String, List<String>> hints) {
+ JarEntry entry = jar.getJarEntry(entryName);
+ if (entry == null) {
+ return;
+ }
+ try (InputStream is = jar.getInputStream(entry)) {
+ String content = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ Object parsed = Jsoner.deserialize(content);
+ if (!(parsed instanceof JsonObject root)) {
+ return;
+ }
+
+ Object propsObj = root.get("properties");
+ if (propsObj instanceof JsonArray arr) {
+ for (int i = 0; i < arr.size(); i++) {
+ if (arr.get(i) instanceof JsonObject prop) {
+ String name = prop.getString("name");
+ if (name != null && !name.isEmpty()) {
+ properties.putIfAbsent(name, prop);
+ }
+ }
+ }
+ }
+
+ Object hintsObj = root.get("hints");
+ if (hintsObj instanceof JsonArray arr) {
+ for (int i = 0; i < arr.size(); i++) {
+ if (arr.get(i) instanceof JsonObject hint) {
+ String name = hint.getString("name");
+ Object valuesObj = hint.get("values");
+ if (name != null && valuesObj instanceof JsonArray
valuesArr && !valuesArr.isEmpty()) {
+ List<String> values = new ArrayList<>();
+ for (int j = 0; j < valuesArr.size(); j++) {
+ if (valuesArr.get(j) instanceof JsonObject
valueObj) {
+ Object v = valueObj.get("value");
+ if (v != null) {
+ values.add(v.toString());
+ }
+ }
+ }
+ if (!values.isEmpty()) {
+ hints.putIfAbsent(name, values);
+ }
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ // skip unparseable metadata
+ }
+ }
+
+ static Path localRepoJarPath(String groupId, String artifactId, String
version) {
+ Path localRepo = Path.of(System.getProperty("user.home"), ".m2",
"repository");
+ Path jarPath = localRepo
+ .resolve(groupId.replace('.', File.separatorChar))
+ .resolve(artifactId)
+ .resolve(version)
+ .resolve(artifactId + "-" + version + ".jar");
+ return Files.isRegularFile(jarPath) ? jarPath : null;
+ }
+
+ private static Path localRepoPomPath(String groupId, String artifactId,
String version) {
+ Path localRepo = Path.of(System.getProperty("user.home"), ".m2",
"repository");
+ Path pomPath = localRepo
+ .resolve(groupId.replace('.', File.separatorChar))
+ .resolve(artifactId)
+ .resolve(version)
+ .resolve(artifactId + "-" + version + ".pom");
+ return Files.isRegularFile(pomPath) ? pomPath : null;
+ }
+}