This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-25241
in repository https://gitbox.apache.org/repos/asf/camel.git

commit cc87c7e21fa1f96e0f1304d403bb972ecad1f504
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 23:43:55 2026 +0200

    CAMEL-25241: camel-jbang - Tab completes the Java DSL route chain in the 
source editor
    
    After a dot in a Java route chain, Tab lists the methods that compile there:
    the options of the EIP the chain is on first (.split(body()). offers
    parallelProcessing, streaming...), then the EIPs, and the end(), endChoice()
    or endDoTry() that closes the current block. Documentation from the catalog.
    
    The chain is read from the text above the cursor (comments, strings, lambdas
    and anonymous class bodies in arguments skipped), so it works while the file
    does not parse, and walked from its start over the model classes with
    reflection, with a stack of the open blocks as the DSL has at runtime: block
    EIPs open one, end() closes it (or ends a non-block EIP such as
    recipientList), endChoice()/endDoTry() go back to the choice or try, builder
    clauses (expression, data format, process) and model configurations
    (resilience4jConfiguration) return to their EIP. Overloads the text cannot
    tell apart are kept until a following call rules them out. Deprecated
    methods are followed but not offered.
    
    Recall over 5500 Java route test files of camel-core and 191 components:
    at 25,980 chain positions the method written next is offered at 99.94%; the
    15 misses are deprecated methods.
    
    The source editor doc page lists the known limitations of Tab completion: 
light
    assistance for smaller hand-written edits; an AI coding agent in the TUI 
(any
    agent that speaks ACP) helps with more.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../jbang/camel-tui-source-java-completion.png     | Bin 0 -> 337003 bytes
 .../ROOT/pages/camel-jbang-tui-ai-agents.adoc      |   3 +-
 .../ROOT/pages/camel-jbang-tui-source-editor.adoc  |  36 +-
 .../jbang/core/commands/tui/JavaChainContext.java  | 243 +++++++++++
 .../core/commands/tui/JavaDslCompletions.java      | 447 +++++++++++++++++++++
 .../jbang/core/commands/tui/SourceEditAssist.java  |   5 +
 .../dsl/jbang/core/commands/tui/SourceTab.java     |   2 +
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  98 ++++-
 .../src/main/resources/tui/help/source.md          |  11 +
 .../core/commands/tui/JavaDslCompletionTest.java   | 256 ++++++++++++
 10 files changed, 1096 insertions(+), 5 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-completion.png
 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-completion.png
new file mode 100644
index 000000000000..afbffd544203
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-completion.png
 differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
index 0b5d04b27567..e007b72799e0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-ai-agents.adoc
@@ -75,7 +75,8 @@ gives it the TUI's own MCP server, and shows the agent's 
answer, tool calls and
 drives the TUI through the same `tui_*` tools that external MCP clients use, 
and can also read and edit your
 route sources with its own tools.
 
-Press *Ctrl+P* in the AI panel and pick one of the agents:
+Any agent that speaks ACP works. Press *Ctrl+P* in the AI panel and pick one 
of the agents known to work, or
+`acp:custom` to run any other ACP agent:
 
 [options="header"]
 |===
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
index 095f684d2694..414d4e75d112 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
@@ -13,8 +13,9 @@ routes.
   as a file opens and while you type, in YAML, Java and XML routes; a YAML or 
XML file with problems is not saved.
 * <<_quick_fixes,*Quick fixes*>> -- *Shift+F9* applies the fix a problem 
names, such as an option typo.
 * <<_fix_with_ai,*Fix with AI*>> -- *Shift+F8* asks the AI to fix the problem 
of a line; you confirm every change.
-* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options 
and their values, XML elements and
-  attributes, and simple expressions.
+* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options 
and their values, Java DSL chains, XML
+  elements and attributes, and simple expressions; light help for hand-written 
edits, see
+  <<_known_limitations,Known limitations>>.
 * <<_quick_documentation,*Quick documentation*>> -- the documentation of the 
line the cursor is on, at the bottom, with
   the values of its property placeholders and where its bean is declared.
 * <<_navigation,*Navigation*>> -- jump between routes and to beans, see where 
an endpoint is used with *u*, find any node
@@ -121,6 +122,12 @@ What can be completed:
   string given to them in Java, their `uri` attribute in XML): the component 
name before the `:`, the endpoint options
   after `?` or `&` (consumer or producer options, depending on where the 
endpoint is, and without the ones already
   given), and the value of an option after `=`.
+* *Java routes* -- after a dot in a route chain, the methods that compile 
there: first the options of the EIP the
+  chain is on (after `.split(body())` the `parallelProcessing`, `streaming`... 
of the split), then the EIPs, and the
+  `end()`, `endChoice()` or `endDoTry()` that closes the block you are in. The 
chain is followed from its `from` through
+  its blocks, so after `.end()` the options of the split are gone again, and 
after `.split().` the languages of the
+  expression come. The method goes in with its parentheses, the cursor inside 
them when it takes arguments
+  (`.split(|)`). The documentation comes from the catalog.
 * *XML routes* -- after `<`, or on an empty line, the elements that go inside 
the parent element (the EIPs of a route,
   `when` and `otherwise` in a `choice`, the languages where an expression 
goes, and once it has one, no other). The
   chosen element is inserted with its required attributes and its end tag 
(`<to uri=""/>`, `<split></split>`), the
@@ -143,6 +150,31 @@ image::jbang/camel-tui-source-xml-completion.png[In the 
start tag of log, Tab li
 
 image::jbang/camel-tui-source-simple-completion.png[After ${header. Tab lists 
the headers the route sets, then the ones of the Kafka component]
 
+image::jbang/camel-tui-source-java-completion.png[After a split in a Java 
route, Tab lists the options of the split first, then the EIPs]
+
+=== Known limitations
+
+*Tab* completion is light assistance for smaller, hand-written edits. It reads 
the line you are on and the text above
+the cursor, so it keeps working while the file is half typed, but it is no 
compiler and does not know the whole
+project:
+
+* *Java routes* -- the chain is followed from `from(...)`, `onException(...)`, 
`intercept...()` and `onCompletion()` in a
+  route builder. A route kept in a variable (`RouteDefinition route = 
from(...); route.`), the REST DSL (`rest()`),
+  the expression builders inside the arguments (`body().tokenize(",")`), 
lambdas, and your own builder methods get no
+  completion. The methods come from the Camel version of the TUI, their 
documentation from the catalog of your
+  project. Deprecated methods are not offered.
+* *XML routes* -- the routes of the XML DSL (`camel-xml-io`); the wrapper 
elements of Spring XML are not completed.
+  Values are completed for options with a fixed set of values (enums, 
`true`/`false`) and `+{{placeholders}}+`.
+* *Simple expressions* -- header, property and variable names are the ones the 
file sets or reads and the headers of
+  the components it uses, not the ones other routes or your own code set at 
runtime.
+* Nothing is completed from your own Java classes and beans, and there are no 
refactorings (rename, extract) or imports.
+
+For more than that, an AI coding agent is the more powerful companion: working 
together with you in the TUI, it knows
+Camel, reads and writes the whole project, runs the routes and checks them, 
and explains what it does as it goes. See
+xref:camel-jbang-tui-ai.adoc[the AI panel] to ask about and change your routes 
from the TUI, and
+xref:camel-jbang-tui-ai-agents.adoc#_using_a_coding_agent_acp[using a coding 
agent] to work in the TUI with any coding
+agent that speaks the Agent Client Protocol (ACP); Claude Code, Codex and 
opencode are some known to work.
+
 == Quick Documentation
 
 The panel at the bottom shows the documentation of the line the cursor is on, 
from the Camel catalog: the component
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaChainContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaChainContext.java
new file mode 100644
index 000000000000..73c4775962fc
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaChainContext.java
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The route chain the cursor is in, in a Java route, read from the text above 
it so it works while the file does not
+ * parse (CAMEL-25241): the calls of the statement up to the cursor, 
from(...).split(...).to(...)., and the name being
+ * typed after the last dot. Only the calls of the chain itself count; what is 
inside their arguments is skipped.
+ *
+ * @param calls  the calls of the chain before the cursor, the first one 
starting it (from, onException...)
+ * @param prefix the name typed after the last dot, which the completion 
replaces
+ */
+record JavaChainContext(List<Call> calls, String prefix) {
+
+    /**
+     * A call of the chain.
+     *
+     * @param name      the method name
+     * @param arguments the number of arguments given
+     */
+    record Call(String name, int arguments) {
+    }
+
+    /** The chain at the cursor, or null when the cursor is not after a dot of 
a route chain. */
+    static JavaChainContext at(List<String> lines, int row, int col) {
+        if (lines == null || row < 0 || row >= lines.size()) {
+            return null;
+        }
+        String line = lines.get(row);
+        if (col < 0 || col > line.length()) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int r = 0; r < row; r++) {
+            sb.append(lines.get(r)).append('\n');
+        }
+        sb.append(line, 0, col);
+        String text = code(sb.toString());
+        if (text == null) {
+            return null;
+        }
+        // the statement the cursor is in starts after the last ; { or } 
outside of any parentheses
+        int depth = 0;
+        int start = 0;
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            if (c == '(') {
+                depth++;
+            } else if (c == ')') {
+                depth = Math.max(0, depth - 1);
+            } else if (depth == 0 && (c == ';' || c == '{' || c == '}')) {
+                start = i + 1;
+            }
+        }
+        if (depth > 0) {
+            // in the arguments of a call
+            return null;
+        }
+        String statement = text.substring(start);
+        int end = statement.length();
+        int p = end;
+        while (p > 0 && Character.isJavaIdentifierPart(statement.charAt(p - 
1))) {
+            p--;
+        }
+        String prefix = statement.substring(p, end);
+        int dot = p - 1;
+        while (dot >= 0 && Character.isWhitespace(statement.charAt(dot))) {
+            dot--;
+        }
+        if (dot < 0 || statement.charAt(dot) != '.') {
+            return null;
+        }
+        List<Call> calls = calls(statement.substring(0, dot));
+        if (calls == null || calls.isEmpty()) {
+            return null;
+        }
+        return new JavaChainContext(calls, prefix);
+    }
+
+    /** The calls of a chain: name(args).name(args)..., null when the text is 
no such chain. */
+    private static List<Call> calls(String chain) {
+        List<Call> calls = new ArrayList<>();
+        int i = 0;
+        int n = chain.length();
+        boolean first = true;
+        while (i < n) {
+            while (i < n && Character.isWhitespace(chain.charAt(i))) {
+                i++;
+            }
+            if (i == n) {
+                break;
+            }
+            if (!first) {
+                if (chain.charAt(i) != '.') {
+                    return null;
+                }
+                i++;
+                while (i < n && Character.isWhitespace(chain.charAt(i))) {
+                    i++;
+                }
+            }
+            // a type argument before the name: .<String> foo()
+            if (i < n && chain.charAt(i) == '<') {
+                int close = chain.indexOf('>', i);
+                if (close < 0) {
+                    return null;
+                }
+                i = close + 1;
+                while (i < n && Character.isWhitespace(chain.charAt(i))) {
+                    i++;
+                }
+            }
+            int nameStart = i;
+            while (i < n && Character.isJavaIdentifierPart(chain.charAt(i))) {
+                i++;
+            }
+            String name = chain.substring(nameStart, i);
+            if (name.isEmpty()) {
+                return null;
+            }
+            while (i < n && Character.isWhitespace(chain.charAt(i))) {
+                i++;
+            }
+            if (i >= n || chain.charAt(i) != '(') {
+                // this.from(...) or a field: only the leading this is skipped
+                if (first && "this".equals(name) && i < n && chain.charAt(i) 
== '.') {
+                    i++;
+                    continue;
+                }
+                return null;
+            }
+            int depth = 0;
+            // the commas of a lambda or anonymous class body ({...}) and of 
type arguments (<String, String>) are
+            // no argument separators
+            int braces = 0;
+            int angles = 0;
+            int arguments = 0;
+            boolean any = false;
+            int j = i;
+            for (; j < n; j++) {
+                char c = chain.charAt(j);
+                if (c == '(') {
+                    depth++;
+                } else if (c == ')') {
+                    depth--;
+                    if (depth == 0) {
+                        break;
+                    }
+                } else if (c == '{') {
+                    braces++;
+                } else if (c == '}') {
+                    braces = Math.max(0, braces - 1);
+                } else if (c == '<' && j > 0 && 
Character.isJavaIdentifierPart(chain.charAt(j - 1))
+                        && j + 1 < n && 
(Character.isJavaIdentifierStart(chain.charAt(j + 1)) || chain.charAt(j + 1) == 
'?'
+                                || chain.charAt(j + 1) == '>')) {
+                    angles++;
+                } else if (c == '>' && angles > 0) {
+                    angles--;
+                } else if (depth == 1 && braces == 0 && angles == 0 && c == 
',') {
+                    arguments++;
+                }
+                if (depth >= 1 && c != '(' && !Character.isWhitespace(c)) {
+                    any = true;
+                }
+            }
+            if (j >= n) {
+                return null;
+            }
+            calls.add(new Call(name, any ? arguments + 1 : 0));
+            i = j + 1;
+            first = false;
+        }
+        return calls;
+    }
+
+    /**
+     * The text with its comments, strings and character literals blanked 
(kept as spaces, or "" for strings), so their
+     * brackets and dots do not count; null when the cursor is in one of them.
+     */
+    static String code(String text) {
+        StringBuilder sb = new StringBuilder(text.length());
+        int i = 0;
+        int n = text.length();
+        while (i < n) {
+            char c = text.charAt(i);
+            if (c == '/' && i + 1 < n && text.charAt(i + 1) == '/') {
+                int nl = text.indexOf('\n', i);
+                if (nl < 0) {
+                    return null;
+                }
+                i = nl;
+            } else if (c == '/' && i + 1 < n && text.charAt(i + 1) == '*') {
+                int close = text.indexOf("*/", i + 2);
+                if (close < 0) {
+                    return null;
+                }
+                sb.append(' ');
+                i = close + 2;
+            } else if (text.startsWith("\"\"\"", i)) {
+                int close = text.indexOf("\"\"\"", i + 3);
+                if (close < 0) {
+                    return null;
+                }
+                sb.append("\"\"");
+                i = close + 3;
+            } else if (c == '"' || c == '\'') {
+                int j = i + 1;
+                while (j < n && text.charAt(j) != c && text.charAt(j) != '\n') 
{
+                    if (text.charAt(j) == '\\') {
+                        j++;
+                    }
+                    j++;
+                }
+                if (j >= n || text.charAt(j) != c) {
+                    return null;
+                }
+                sb.append(c).append(c);
+                i = j + 1;
+            } else {
+                sb.append(c);
+                i++;
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletions.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletions.java
new file mode 100644
index 000000000000..25a0f0329175
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletions.java
@@ -0,0 +1,447 @@
+/*
+ * 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.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.model.ChoiceDefinition;
+import org.apache.camel.model.CircuitBreakerDefinition;
+import org.apache.camel.model.OutputNode;
+import org.apache.camel.model.ProcessorDefinition;
+import org.apache.camel.model.TryDefinition;
+import org.apache.camel.tooling.model.BaseOptionModel;
+import org.apache.camel.tooling.model.EipModel;
+import org.apache.camel.tooling.model.LanguageModel;
+
+/**
+ * The Tab completions of the Java DSL route chain in the Source editor 
(CAMEL-25241): after a dot, the methods that
+ * compile there. The chain is walked from its start (from, onException...) 
over the model classes with reflection, with
+ * a stack of the open blocks as the DSL has at runtime: a block EIP (split, 
filter, choice, doTry...) opens one, end()
+ * closes it, endChoice() and endDoTry() go back to the choice or try, a 
builder clause (.split().simple(..),
+ * .marshal().json()) returns to its EIP. An overload the text cannot tell 
apart (recipientList(header("x")) or
+ * recipientList(",")) is kept both ways until a following call rules one out. 
The options of the EIP the chain is on
+ * come first, then the EIPs; the documentation comes from the catalog. This 
is light assistance for hand-written edits,
+ * not a Java compiler: a chain it cannot follow gets no completion.
+ */
+final class JavaDslCompletions {
+
+    /** Names that are no DSL, although public on the model classes. */
+    private static final Set<String> NOT_DSL = Set.of(
+            "copyDefinition", "toString", "equals", "hashCode", "getClass", 
"clone", "wait", "notify", "notifyAll",
+            "createChildProcessor", "preCreateProcessor", "configureChild", 
"addOutput", "clearOutput");
+
+    /** The methods that close blocks, with what they do: they have no EIP in 
the catalog. */
+    private static final Map<String, String> END_DOCS = Map.of(
+            "end", "Ends the current block (split, filter, choice, doTry...), 
going back to the one around it",
+            "endParent", "Ends the current block and goes back to the parent 
of the block",
+            "endChoice", "Ends the current when or otherwise, going back to 
the choice to add another when or otherwise",
+            "endDoTry", "Ends the current doCatch or doFinally, going back to 
the doTry",
+            "endDoCatch", "Ends the current block, going back to the doCatch",
+            "endCircuitBreaker", "Ends the current onFallback, going back to 
the circuitBreaker");
+
+    /** The most states kept for overloads the text cannot tell apart. */
+    private static final int MAX_STATES = 8;
+
+    // the DSL methods of a class by name, without and with the deprecated ones
+    private static final Map<Class<?>, Map<String, List<Method>>> METHODS = 
new ConcurrentHashMap<>();
+    private static final Map<Class<?>, Map<String, List<Method>>> ALL_METHODS 
= new ConcurrentHashMap<>();
+    // whether a model class is a configuration ended by end(), as a clause
+    private static final Map<Class<?>, Boolean> CLAUSES = new 
ConcurrentHashMap<>();
+    // the EIP name of a definition class, by the java type the catalog has 
for it
+    private static final Map<String, String> EIP_BY_TYPE = new 
ConcurrentHashMap<>();
+
+    private JavaDslCompletions() {
+    }
+
+    /**
+     * Where the chain is after its calls.
+     *
+     * @param blocks  the open blocks, the outermost (the route) first
+     * @param current the definition the next call is on: the innermost block, 
or the EIP just added that has options
+     * @param clause  the builder clause the chain is on (.split().|), whose 
methods return to {@code after}; else null
+     * @param after   the definition a clause returns to
+     */
+    record State(List<Class<?>> blocks, Class<?> current, Class<?> clause, 
Class<?> after) {
+
+        Class<?> top() {
+            return blocks.get(blocks.size() - 1);
+        }
+
+        State on(Class<?> definition) {
+            return new State(blocks, definition, null, null);
+        }
+
+        State push(Class<?> block) {
+            List<Class<?>> b = new ArrayList<>(blocks);
+            b.add(block);
+            return new State(b, block, null, null);
+        }
+
+        State popTo(Class<?> block) {
+            int at = blocks.lastIndexOf(block);
+            if (at < 0) {
+                return null;
+            }
+            List<Class<?>> b = new ArrayList<>(blocks.subList(0, at + 1));
+            return new State(b, block, null, null);
+        }
+
+        State pop() {
+            if (blocks.size() == 1) {
+                return on(top());
+            }
+            List<Class<?>> b = new ArrayList<>(blocks.subList(0, blocks.size() 
- 1));
+            return new State(b, b.get(b.size() - 1), null, null);
+        }
+    }
+
+    /** The state after the calls of the chain, or null when the chain is not 
one this can follow. */
+    static State resolve(List<JavaChainContext.Call> calls) {
+        List<State> states = states(calls);
+        return states.isEmpty() ? null : states.get(0);
+    }
+
+    /** The possible states after the calls of the chain (one unless overloads 
leave it open); empty when it is lost. */
+    static List<State> states(List<JavaChainContext.Call> calls) {
+        if (calls.isEmpty()) {
+            return List.of();
+        }
+        List<State> states = new ArrayList<>();
+        for (Method m : candidates(RouteBuilder.class, calls.get(0))) {
+            Class<?> start = raw(m.getGenericReturnType());
+            if (start != null && 
ProcessorDefinition.class.isAssignableFrom(start)) {
+                states.add(new State(List.of(start), start, null, null));
+            }
+        }
+        for (int i = 1; i < calls.size() && !states.isEmpty(); i++) {
+            List<State> next = new ArrayList<>();
+            for (State s : states) {
+                for (State n : step(s, calls.get(i))) {
+                    if (next.size() < MAX_STATES && !next.contains(n)) {
+                        next.add(n);
+                    }
+                }
+            }
+            states = next;
+        }
+        return states;
+    }
+
+    /** The states one call leads to from a state: none when the call does not 
compile there. */
+    private static List<State> step(State s, JavaChainContext.Call call) {
+        String name = call.name();
+        if (s.clause() != null) {
+            List<State> found = new ArrayList<>();
+            for (Method m : candidates(s.clause(), call)) {
+                // an option of the clause (marshal().variableSend(..)) stays 
on it, the others return to the EIP
+                State n = raw(m.getGenericReturnType()) == s.clause() ? s : 
s.on(s.after());
+                if (!found.contains(n)) {
+                    found.add(n);
+                }
+            }
+            return found;
+        }
+        if (END_DOCS.containsKey(name)) {
+            State n = switch (name) {
+                case "endChoice" -> s.popTo(ChoiceDefinition.class);
+                case "endDoTry", "endDoCatch" -> s.popTo(TryDefinition.class);
+                case "endCircuitBreaker" -> 
s.popTo(CircuitBreakerDefinition.class);
+                // after an EIP that is no block (recipientList...), end() 
ends that EIP
+                default -> s.current() != s.top() ? s.on(s.top()) : s.pop();
+            };
+            return n != null ? List.of(n) : List.of();
+        }
+        List<State> found = new ArrayList<>();
+        List<Method> methods = candidates(s.current(), call);
+        State from = s;
+        if (methods.isEmpty() && s.current() != s.top()) {
+            // an EIP after one whose options were being set: it goes into the 
innermost block
+            from = s.on(s.top());
+            methods = candidates(from.current(), call);
+        }
+        for (Method m : methods) {
+            State base = isOption(m) ? from : from.on(from.top());
+            Type ret = m.getGenericReturnType();
+            Class<?> raw = raw(ret);
+            if (raw == null) {
+                continue;
+            }
+            if (isClause(raw)) {
+                Type arg = argument(ret);
+                Class<?> of = raw(arg);
+                State target = creates(m, arg, of, base) ? (isBlock(of) ? 
base.push(of) : base.on(of)) : base;
+                found.add(new State(target.blocks(), target.current(), raw, 
target.current()));
+            } else if (ProcessorDefinition.class.isAssignableFrom(raw)) {
+                if (!creates(m, ret, raw, base)) {
+                    // Type, or an option returning its definition: the chain 
stays where it is
+                    found.add(base);
+                } else if (isBlock(raw)) {
+                    found.add(base.push(raw));
+                } else {
+                    found.add(base.on(raw));
+                }
+            }
+        }
+        return found;
+    }
+
+    /**
+     * Whether the call adds a new definition of the returned class: an EIP 
returning a definition class (also of the
+     * class the chain is on: a choice in a choice) does, an option returning 
its own definition and a method returning
+     * Type (to, log...) do not.
+     */
+    private static boolean creates(Method m, Type returned, Class<?> raw, 
State base) {
+        if (raw == null || returned instanceof TypeVariable<?> || raw == 
ProcessorDefinition.class
+                || !ProcessorDefinition.class.isAssignableFrom(raw)) {
+            return false;
+        }
+        return !isOption(m) || !raw.isAssignableFrom(base.current());
+    }
+
+    /** The completions after the calls of the chain; empty when the chain is 
not one this can follow. */
+    static List<AutocompletePopup.CompletionItem> provide(CamelCatalog 
catalog, JavaChainContext context) {
+        Map<String, AutocompletePopup.CompletionItem> own = new TreeMap<>();
+        Map<String, AutocompletePopup.CompletionItem> ends = new TreeMap<>();
+        Map<String, AutocompletePopup.CompletionItem> eips = new TreeMap<>();
+        for (State state : states(context.calls())) {
+            boolean clause = state.clause() != null;
+            Map<String, BaseOptionModel> options = clause ? Map.of() : 
optionDocs(catalog, state.current());
+            for (Map.Entry<String, List<Method>> e : methods(clause ? 
state.clause() : state.current(), false)
+                    .entrySet()) {
+                String name = e.getKey();
+                List<Method> overloads = e.getValue();
+                if (END_DOCS.containsKey(name)) {
+                    if (clause) {
+                        // the end() of a configuration 
(resilience4jConfiguration()...)
+                        ends.putIfAbsent(name, item(name, "Ends the 
configuration, going back to the "
+                                                          + 
state.after().getSimpleName(),
+                                "end", overloads));
+                    } else if (closes(state, name)) {
+                        ends.putIfAbsent(name, item(name, END_DOCS.get(name), 
"end", overloads));
+                    }
+                } else if (clause) {
+                    LanguageModel language = catalog != null ? 
catalog.languageModel(name) : null;
+                    own.putIfAbsent(name, item(name, language != null ? 
language.getDescription() : null,
+                            state.clause().getSimpleName(), overloads));
+                } else if (isOption(overloads.get(0))) {
+                    BaseOptionModel option = options.get(name);
+                    own.putIfAbsent(name, item(name, option != null ? 
option.getDescription() : null, "option", overloads));
+                } else {
+                    EipModel eip = catalog != null ? catalog.eipModel(name) : 
null;
+                    eips.putIfAbsent(name, item(name, eip != null ? 
eip.getDescription() : null, "EIP", overloads));
+                }
+            }
+        }
+        List<AutocompletePopup.CompletionItem> items = new 
ArrayList<>(own.values());
+        items.addAll(ends.values());
+        eips.keySet().removeAll(own.keySet());
+        items.addAll(eips.values());
+        return items;
+    }
+
+    /** Whether the end method closes something: end() always compiles, 
endChoice() only inside a choice... */
+    private static boolean closes(State state, String name) {
+        return switch (name) {
+            case "endChoice" -> 
state.blocks().contains(ChoiceDefinition.class);
+            case "endDoTry", "endDoCatch" -> 
state.blocks().contains(TryDefinition.class);
+            case "endCircuitBreaker" -> 
state.blocks().contains(CircuitBreakerDefinition.class);
+            default -> true;
+        };
+    }
+
+    private static AutocompletePopup.CompletionItem item(String name, String 
doc, String kind, List<Method> overloads) {
+        StringBuilder sb = new StringBuilder(doc != null ? doc : "");
+        sb.append(sb.isEmpty() ? "" : "\n\n");
+        overloads.stream().limit(6).forEach(m -> 
sb.append(signature(m)).append('\n'));
+        if (overloads.size() > 6) {
+            sb.append("... ").append(overloads.size() - 6).append(" more\n");
+        }
+        boolean args = overloads.stream().anyMatch(m -> m.getParameterCount() 
> 0);
+        // the cursor goes inside the parentheses when the method can take 
something (split(|)), else after them
+        String insert = name + (args ? "(" + XmlCompletions.CARET + ")" : "()" 
+ XmlCompletions.CARET);
+        return new AutocompletePopup.CompletionItem(
+                name, sb.toString().strip(), kind, null, false, null, null, 
false, insert);
+    }
+
+    private static String signature(Method m) {
+        StringBuilder sb = new StringBuilder(m.getName()).append('(');
+        Class<?>[] types = m.getParameterTypes();
+        for (int i = 0; i < types.length; i++) {
+            sb.append(i > 0 ? ", " : "").append(types[i].getSimpleName());
+        }
+        return sb.append(')').toString();
+    }
+
+    /** The catalog options of the EIP of a definition class, by name, for the 
documentation of its methods. */
+    private static Map<String, BaseOptionModel> optionDocs(CamelCatalog 
catalog, Class<?> definition) {
+        Map<String, BaseOptionModel> found = new HashMap<>();
+        if (catalog == null) {
+            return found;
+        }
+        if (EIP_BY_TYPE.isEmpty()) {
+            for (String name : catalog.findModelNames()) {
+                EipModel m = catalog.eipModel(name);
+                if (m != null && m.getJavaType() != null) {
+                    EIP_BY_TYPE.putIfAbsent(m.getJavaType(), name);
+                }
+            }
+        }
+        String name = EIP_BY_TYPE.get(definition.getName());
+        EipModel eip = name != null ? catalog.eipModel(name) : null;
+        if (eip != null) {
+            for (BaseOptionModel o : eip.getOptions()) {
+                found.put(o.getName(), o);
+            }
+        }
+        return found;
+    }
+
+    /** Whether the definition holds the EIPs that follow it, until its end(). 
*/
+    static boolean isBlock(Class<?> definition) {
+        return OutputNode.class.isAssignableFrom(definition) || definition == 
ChoiceDefinition.class;
+    }
+
+    /**
+     * Whether the type is a clause that returns to its EIP: a builder clause 
(ExpressionClause, DataFormatClause...),
+     * or a configuration of the model ended by end() 
(circuitBreaker().resilience4jConfiguration()...end()).
+     */
+    private static boolean isClause(Class<?> type) {
+        if (ProcessorDefinition.class.isAssignableFrom(type)) {
+            return false;
+        }
+        if (type.getName().startsWith("org.apache.camel.builder.")) {
+            return type.getTypeParameters().length == 1;
+        }
+        return type.getName().startsWith("org.apache.camel.model.") && 
CLAUSES.computeIfAbsent(type, t -> {
+            for (Method m : t.getMethods()) {
+                if ("end".equals(m.getName()) && m.getParameterCount() == 0
+                        && 
ProcessorDefinition.class.isAssignableFrom(m.getReturnType())) {
+                    return true;
+                }
+            }
+            return false;
+        });
+    }
+
+    /** Whether the method is an option of its definition (declared below 
ProcessorDefinition) rather than an EIP. */
+    static boolean isOption(Method m) {
+        Class<?> declaring = m.getDeclaringClass();
+        if (ProcessorDefinition.class.isAssignableFrom(declaring)) {
+            return declaring != ProcessorDefinition.class;
+        }
+        // id, description... of the identified definitions
+        return declaring.getName().startsWith("org.apache.camel.model.");
+    }
+
+    /** The methods of the class the call can be: by name and number of 
arguments, deprecated ones included. */
+    private static List<Method> candidates(Class<?> type, 
JavaChainContext.Call call) {
+        List<Method> overloads = methods(type, true).get(call.name());
+        if (overloads == null) {
+            return List.of();
+        }
+        List<Method> found = new ArrayList<>();
+        for (Method m : overloads) {
+            if (m.getParameterCount() == call.arguments()
+                    || m.isVarArgs() && call.arguments() >= 
m.getParameterCount() - 1) {
+                found.add(m);
+            }
+        }
+        return found;
+    }
+
+    /**
+     * The DSL methods of a class by name: public, of the Camel model or 
builders, returning where the chain goes on.
+     * The deprecated ones are followed in a chain but not offered.
+     */
+    static Map<String, List<Method>> methods(Class<?> type, boolean 
deprecated) {
+        return (deprecated ? ALL_METHODS : METHODS).computeIfAbsent(type, t -> 
{
+            Map<String, List<Method>> found = new LinkedHashMap<>();
+            for (Method m : t.getMethods()) {
+                if (isDsl(t, m) && (deprecated || 
!m.isAnnotationPresent(Deprecated.class))) {
+                    found.computeIfAbsent(m.getName(), k -> new 
ArrayList<>()).add(m);
+                }
+            }
+            return found;
+        });
+    }
+
+    private static boolean isDsl(Class<?> type, Method m) {
+        if (Modifier.isStatic(m.getModifiers()) || m.isBridge() || 
m.isSynthetic() || NOT_DSL.contains(m.getName())) {
+            return false;
+        }
+        String pkg = m.getDeclaringClass().getName();
+        if (!pkg.startsWith("org.apache.camel.model.") && 
!pkg.startsWith("org.apache.camel.builder.")) {
+            return false;
+        }
+        if (m.getParameterCount() == 0 && (m.getName().startsWith("get") || 
m.getName().startsWith("is"))) {
+            return false;
+        }
+        for (Class<?> p : m.getParameterTypes()) {
+            if (CamelContext.class.isAssignableFrom(p)) {
+                return false;
+            }
+        }
+        Type ret = m.getGenericReturnType();
+        if (ret instanceof TypeVariable<?>) {
+            // Type of the definitions, or T of a clause: where the chain goes 
on
+            return true;
+        }
+        Class<?> raw = raw(ret);
+        if (isClause(type)) {
+            // a clause ends with the methods that return to its EIP (T, or 
end() of a configuration); its options
+            // return the clause itself
+            return raw == type || raw != null && 
ProcessorDefinition.class.isAssignableFrom(raw);
+        }
+        return raw != null && (ProcessorDefinition.class.isAssignableFrom(raw) 
|| isClause(raw));
+    }
+
+    private static Class<?> raw(Type type) {
+        if (type instanceof Class<?> c) {
+            return c;
+        }
+        if (type instanceof ParameterizedType p) {
+            return raw(p.getRawType());
+        }
+        if (type instanceof TypeVariable<?> v) {
+            Type[] bounds = v.getBounds();
+            return bounds.length > 0 ? raw(bounds[0]) : Object.class;
+        }
+        return null;
+    }
+
+    private static Type argument(Type type) {
+        if (type instanceof ParameterizedType p && 
p.getActualTypeArguments().length > 0) {
+            return p.getActualTypeArguments()[0];
+        }
+        return null;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index a20544adeef8..460773832678 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -1016,6 +1016,11 @@ final class SourceEditAssist {
         return SimpleQuickDoc.at(validationCatalog(), lines, row, col);
     }
 
+    /** The completions of the Java DSL route chain at the cursor 
(CAMEL-25241). */
+    List<AutocompletePopup.CompletionItem> 
provideJavaCompletions(JavaChainContext context) {
+        return JavaDslCompletions.provide(validationCatalog(), context);
+    }
+
     /** The completions of the XML DSL at the cursor (CAMEL-25240). */
     List<AutocompletePopup.CompletionItem> 
provideXmlCompletions(XmlCompletionContext context, List<String> lines) {
         return XmlCompletions.provide(validationCatalog(), context, 
this::loadPropertyPlaceholders);
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 788cd694a316..719313f05fcc 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
@@ -851,6 +851,8 @@ class SourceTab extends AbstractTab {
         sourceViewer.setCursorQuickDocProvider(
                 routeFile || isCamelSourceFile(filePath) ? 
assist::provideSimpleQuickDoc : null);
         // the elements, attributes and values of an XML route (CAMEL-25240)
+        // the route chain of a Java route (CAMEL-25241)
+        sourceViewer.setJavaCompletion(routeFile && name.endsWith(".java") ? 
assist::provideJavaCompletions : null);
         sourceViewer.setXmlCompletion(routeFile && 
name.toLowerCase().endsWith(".xml") ? assist::provideXmlCompletions : null);
         if (isCamelSourceFile(filePath)) {
             sourceViewer.setQuickDocProvider(assist::provideCamelQuickDocs);
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 a489b4286ded..35d89bc36dbd 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
@@ -31,6 +31,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BiConsumer;
 import java.util.function.BiFunction;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.function.IntConsumer;
 
 import com.networknt.schema.Error;
@@ -226,6 +227,8 @@ class SourceViewer {
     private CursorQuickDocProvider cursorQuickDocProvider;
     private BiFunction<XmlCompletionContext, List<String>, 
List<AutocompletePopup.CompletionItem>> xmlCompletion;
     private XmlCompletion pendingXmlCompletion;
+    private Function<JavaChainContext, List<AutocompletePopup.CompletionItem>> 
javaCompletion;
+    private JavaCompletion pendingJavaCompletion;
     private List<String> routeProblems = List.of();
     private List<String> validationErrors;
     private int validationErrorScroll;
@@ -453,6 +456,17 @@ class SourceViewer {
     private record XmlCompletion(int row, int endCol, XmlCompletionContext 
context) {
     }
 
+    /**
+     * Tab completion of the Java DSL route chain (CAMEL-25241): after a dot, 
the options of the EIP the chain is on and
+     * the EIPs. Null for a file that is no Java route.
+     */
+    void setJavaCompletion(Function<JavaChainContext, 
List<AutocompletePopup.CompletionItem>> provider) {
+        this.javaCompletion = provider;
+    }
+
+    private record JavaCompletion(int row, int endCol, JavaChainContext 
context) {
+    }
+
     /** Opens the AI panel to fix the problem of the cursor line (Shift+F8); 
null when there is no AI panel. */
     void setAskAi(MonitorContext.AskAi askAi) {
         this.askAi = askAi;
@@ -474,6 +488,7 @@ class SourceViewer {
         simpleCompletion = null;
         cursorQuickDocProvider = null;
         xmlCompletion = null;
+        javaCompletion = null;
     }
 
     void reset() {
@@ -518,6 +533,7 @@ class SourceViewer {
         simpleCompletion = null;
         cursorQuickDocProvider = null;
         xmlCompletion = null;
+        javaCompletion = null;
     }
 
     boolean isMarkdownMode() {
@@ -1230,14 +1246,19 @@ class SourceViewer {
         pendingUriCompletion = null;
         pendingSimpleCompletion = null;
         pendingXmlCompletion = null;
+        pendingJavaCompletion = null;
         if (openSimpleAutocomplete()) {
             return;
         }
         if (isCamelYamlFile()) {
             openYamlAutocomplete();
         } else if (uriCompletion != null) {
-            if (!openUriAutocomplete() && "xml".equals(uriCompletion)) {
-                openXmlAutocomplete();
+            if (!openUriAutocomplete()) {
+                if ("xml".equals(uriCompletion)) {
+                    openXmlAutocomplete();
+                } else if ("java".equals(uriCompletion)) {
+                    openJavaAutocomplete();
+                }
             }
         } else {
             openPropertiesAutocomplete();
@@ -1475,6 +1496,75 @@ class SourceViewer {
         }
     }
 
+    /** The completion of the Java DSL route chain after a dot, read from the 
text above the cursor like the uris. */
+    private void openJavaAutocomplete() {
+        if (javaCompletion == null) {
+            return;
+        }
+        int row = editState.cursorRow();
+        int col = editState.cursorCol();
+        List<String> all = new ArrayList<>(editState.lineCount());
+        for (int i = 0; i < editState.lineCount(); i++) {
+            all.add(editState.getLine(i));
+        }
+        JavaChainContext c = JavaChainContext.at(all, row, col);
+        if (c == null) {
+            return;
+        }
+        List<AutocompletePopup.CompletionItem> items = javaCompletion.apply(c);
+        if (items != null && !items.isEmpty()) {
+            autocompletePopup = new AutocompletePopup(items, c.prefix(), 
c.prefix(), false);
+            autocompletePopup.setFullKeys(true);
+            autocompletePopup.setTitlePrefix("Java DSL");
+            pendingJavaCompletion = new JavaCompletion(row, col, c);
+        }
+    }
+
+    /**
+     * Replaces the name typed after the dot with the chosen method, with its 
parentheses (the cursor inside them when
+     * it takes arguments) unless they follow already.
+     */
+    private void insertJavaCompletion(AutocompletePopup.CompletionItem item) {
+        JavaCompletion jc = pendingJavaCompletion;
+        pendingJavaCompletion = null;
+        if (editState.cursorRow() != jc.row()) {
+            return;
+        }
+        int col = editState.cursorCol();
+        for (; col < jc.endCol(); col++) {
+            editState.moveCursorRight();
+        }
+        for (; col > jc.endCol(); col--) {
+            editState.moveCursorLeft();
+        }
+        for (int i = 0; i < jc.context().prefix().length(); i++) {
+            editState.deleteBackward();
+        }
+        String line = editState.getLine(jc.row());
+        int from = editState.cursorCol();
+        int end = from;
+        while (end < line.length() && 
Character.isJavaIdentifierPart(line.charAt(end))) {
+            end++;
+        }
+        for (int i = from; i < end; i++) {
+            editState.deleteForward();
+        }
+        String text = item.insertText();
+        int caret = text.indexOf(XmlCompletions.CARET);
+        if (caret >= 0) {
+            text = text.substring(0, caret) + text.substring(caret + 1);
+        }
+        if (end < line.length() && line.charAt(end) == '(') {
+            // the arguments are there already: only the name changes
+            text = item.key();
+            caret = -1;
+        }
+        editState.insert(text);
+        for (int i = caret >= 0 ? text.length() - caret : 0; i > 0; i--) {
+            editState.moveCursorLeft();
+        }
+    }
+
     /** Replaces the prefix the completion was opened on with the chosen item 
and what follows it (: or =). */
     private void insertUriCompletion(AutocompletePopup.CompletionItem item) {
         UriCompletion jc = pendingUriCompletion;
@@ -1693,6 +1783,10 @@ class SourceViewer {
             insertXmlCompletion(item);
             return;
         }
+        if (pendingJavaCompletion != null) {
+            insertJavaCompletion(item);
+            return;
+        }
         String currentLine = editState.getLine(editState.cursorRow());
         if (isCamelYamlFile()) {
             insertYamlCompletion(item, valueMode, currentLine, listItem);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index 92ca88aec00c..b09321117da3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -101,6 +101,17 @@ Press **F4** to enter edit mode, then **Tab** for 
context-aware completion:
   `?` or `&` (`&amp;` in XML; filtered by consumer/producer role, already given
   ones left out), and the value of an option after `=`
 
+**Java DSL routes:**
+- After a dot in a route chain, Tab lists the methods that compile there: the
+  options of the EIP the chain is on first (`.split(body()).` offers
+  `parallelProcessing`, `streaming`...), then the EIPs, and the `end()`,
+  `endChoice()` or `endDoTry()` that closes the block you are in
+- The method is inserted with its parentheses, the cursor inside them when it
+  takes arguments; the documentation comes from the catalog
+- Light help for hand-written edits: routes in variables, the REST DSL and
+  expression builders inside arguments are not completed; an AI coding agent
+  helps with more: the F8 AI panel, or any agent that speaks ACP
+
 **XML DSL routes:**
 - After `<`, or on an empty line, Tab lists the elements that go inside the
   parent element (the EIPs of a route, `when` and `otherwise` in a `choice`,
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletionTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletionTest.java
new file mode 100644
index 000000000000..479259046812
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JavaDslCompletionTest.java
@@ -0,0 +1,256 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.model.ChoiceDefinition;
+import org.apache.camel.model.RecipientListDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.SplitDefinition;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tab completion of the Java DSL route chain in the Source editor 
(CAMEL-25241).
+ */
+class JavaDslCompletionTest {
+
+    private static CamelCatalog catalog;
+
+    @TempDir
+    Path tempDir;
+
+    @BeforeAll
+    static void loadCatalog() {
+        catalog = new DefaultCamelCatalog();
+    }
+
+    @BeforeEach
+    void setUp() {
+        Theme.resetForTesting();
+    }
+
+    /** The context at the end of the last line. */
+    private static JavaChainContext at(String... lines) {
+        int row = lines.length - 1;
+        return JavaChainContext.at(List.of(lines), row, lines[row].length());
+    }
+
+    private static List<String> keys(String... lines) {
+        return JavaDslCompletions.provide(catalog, 
at(lines)).stream().map(AutocompletePopup.CompletionItem::key)
+                .toList();
+    }
+
+    private static JavaDslCompletions.State state(String... lines) {
+        return JavaDslCompletions.resolve(at(lines).calls());
+    }
+
+    @Test
+    void theChainAtTheCursor() {
+        JavaChainContext c = at(
+                "    public void configure() {",
+                "        from(\"timer:tick?period=1000\") // a comment with (",
+                "            .split(body().tokenize(\",\")) /* and ) */",
+                "            .str");
+        
assertThat(c.calls()).extracting(JavaChainContext.Call::name).containsExactly("from",
 "split");
+        assertThat(c.calls().get(1).arguments()).isEqualTo(1);
+        assertThat(c.prefix()).isEqualTo("str");
+
+        // the commas of an anonymous class body and of type arguments are no 
argument separators
+        c = at("this.from(\"a\").process(new Processor() { Map<String, String> 
m; void x() { f(1, 2); } }).");
+        
assertThat(c.calls()).extracting(JavaChainContext.Call::name).containsExactly("from",
 "process");
+        assertThat(c.calls().get(1).arguments()).isEqualTo(1);
+
+        // in an argument, in a string, in a comment, not after a dot
+        assertThat(at("from(\"a\").split(body().")).isNull();
+        assertThat(at("from(\"a.")).isNull();
+        assertThat(at("from(\"a\") // .")).isNull();
+        assertThat(at("from(\"a\") ")).isNull();
+    }
+
+    @Test
+    void theOptionsOfTheEipComeFirst() {
+        List<String> keys = keys("from(\"a\").split(body()).");
+        assertThat(keys).contains("parallelProcessing", "streaming", "to", 
"log", "end");
+        assertThat(keys.indexOf("streaming")).isLessThan(keys.indexOf("to"));
+        // getters, internals and deprecated methods are no DSL to offer
+        assertThat(keys).doesNotContain("getOutputs", "addOutput", 
"copyDefinition", "parallelAggregate");
+    }
+
+    @Test
+    void theBlocksOfTheChain() {
+        // to() returns the split itself: its options still apply; end() goes 
back to the route
+        
assertThat(state("from(\"a\").split(body()).to(\"b\").").current()).isEqualTo(SplitDefinition.class);
+        
assertThat(state("from(\"a\").split(body()).to(\"b\").end().").current()).isEqualTo(RouteDefinition.class);
+
+        // when and otherwise stay on the choice; endChoice goes back to it 
from inside
+        
assertThat(keys("from(\"a\").choice().when(header(\"x\")).to(\"b\").")).contains("when",
 "otherwise",
+                "endChoice");
+        assertThat(state(
+                
"from(\"a\").choice().when(header(\"x\")).split(body()).endChoice().").current())
+                .isEqualTo(ChoiceDefinition.class);
+        // a choice in a choice is a new block: end() ends the inner one only
+        assertThat(state(
+                
"from(\"a\").choice().when(header(\"x\")).choice().when(header(\"y\")).end().").blocks())
+                .containsExactly(RouteDefinition.class, 
ChoiceDefinition.class);
+        assertThat(keys("from(\"a\").to(\"b\").")).doesNotContain("endChoice", 
"endDoTry");
+    }
+
+    @Test
+    void anEipThatIsNoBlockHasItsOptionsUntilTheNextEip() {
+        
assertThat(state("from(\"a\").recipientList(header(\"x\")).").current())
+                .isEqualTo(RecipientListDefinition.class);
+        
assertThat(keys("from(\"a\").recipientList(header(\"x\")).")).contains("parallelProcessing",
 "to");
+        // the next EIP goes into the route, and end() ends the recipient list
+        
assertThat(state("from(\"a\").recipientList(header(\"x\")).to(\"b\").").current())
+                .isEqualTo(RouteDefinition.class);
+        assertThat(state(
+                
"from(\"a\").split(body()).recipientList(header(\"x\")).end().").current())
+                .isEqualTo(SplitDefinition.class);
+    }
+
+    @Test
+    void clausesReturnToTheirEip() {
+        // the expression clause of split: its languages, then back on the 
split
+        assertThat(keys("from(\"a\").split().")).contains("simple", "header", 
"jsonpath").doesNotContain("to");
+        
assertThat(state("from(\"a\").split().simple(\"${body}\").").current()).isEqualTo(SplitDefinition.class);
+        // a data format clause, its options stay on it
+        assertThat(keys("from(\"a\").marshal().")).contains("json", "csv");
+        
assertThat(state("from(\"a\").marshal().variableSend(\"v\").json().").current())
+                .isEqualTo(RouteDefinition.class);
+        // an overload the text cannot tell apart is decided by what follows it
+        
assertThat(state("from(\"a\").recipientList(\",\").header(\"x\").").current())
+                .isEqualTo(RecipientListDefinition.class);
+        // a configuration ended by end()
+        
assertThat(keys("from(\"a\").circuitBreaker().resilience4jConfiguration().")).contains("end",
+                "timeoutEnabled");
+    }
+
+    @Test
+    void anEipIsInsertedWithTheCursorInItsArguments() throws Exception {
+        SourceViewer viewer = viewer("""
+                public class MyRoute extends RouteBuilder {
+                    public void configure() {
+                        from("timer:tick")
+                            .spl
+                    }
+                }
+                """);
+        cursorAt(viewer, 3);
+        tab(viewer);
+        enter(viewer);
+        assertThat(line(viewer, 3)).isEqualTo("            .split()");
+        type(viewer, "body()");
+        assertThat(line(viewer, 3)).isEqualTo("            .split(body())");
+    }
+
+    @Test
+    void anOptionOfTheEip() throws Exception {
+        SourceViewer viewer = viewer("""
+                public class MyRoute extends RouteBuilder {
+                    public void configure() {
+                        from("timer:tick")
+                            .split(body()).strea
+                    }
+                }
+                """);
+        cursorAt(viewer, 3);
+        tab(viewer);
+        enter(viewer);
+        assertThat(line(viewer, 3)).isEqualTo("            
.split(body()).streaming()");
+    }
+
+    @Test
+    void theUriStringStaysWithTheUriCompletion() throws Exception {
+        SourceViewer viewer = viewer("""
+                public class MyRoute extends RouteBuilder {
+                    public void configure() {
+                        from("timer:tick")
+                            .to("sed");
+                    }
+                }
+                """);
+        for (int i = 0; i < 3; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, 
KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        for (int i = 0; i < 3; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, 
KeyModifiers.NONE));
+        }
+        tab(viewer);
+        type(viewer, "a");
+        enter(viewer);
+        assertThat(line(viewer, 3)).isEqualTo("            .to(\"seda:\");");
+    }
+
+    private SourceViewer viewer(String src) throws Exception {
+        Path file = tempDir.resolve("MyRoute.java");
+        Files.writeString(file, src, StandardCharsets.UTF_8);
+        AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(List.of());
+        AtomicReference<List<InfraInfo>> infraData = new 
AtomicReference<>(List.of());
+        SourceEditAssist assist = new SourceEditAssist(new 
MonitorContext(data, infraData));
+        SourceViewer viewer = new SourceViewer();
+        viewer.setAutocompleteProvider(assist::provideYamlKeyCompletions);
+        
viewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions);
+        viewer.setUriCompletion("java");
+        viewer.setSimpleCompletion(assist::provideSimpleCompletions);
+        viewer.setJavaCompletion(c -> JavaDslCompletions.provide(catalog, c));
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        return viewer;
+    }
+
+    /** Puts the cursor at the end of the line. */
+    private static void cursorAt(SourceViewer viewer, int row) {
+        for (int i = 0; i < row; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, 
KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+    }
+
+    private static void tab(SourceViewer viewer) {
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, KeyModifiers.NONE));
+    }
+
+    private static void enter(SourceViewer viewer) {
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE));
+    }
+
+    private static void type(SourceViewer viewer, String text) {
+        for (char ch : text.toCharArray()) {
+            viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+        }
+    }
+
+    private static String line(SourceViewer viewer, int row) {
+        return viewer.editText().split("\n")[row];
+    }
+}

Reply via email to