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

Reply via email to