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 078162263bed CAMEL-25241: camel-jbang - Tab completes the Java DSL
route chain in the source editor (#27238)
078162263bed is described below
commit 078162263bedc5c1248a7f98934c4397ea894a7a
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 2 00:03:46 2026 +0200
CAMEL-25241: camel-jbang - Tab completes the Java DSL route chain in the
source editor (#27238)
Co-Authored-By: Claude Opus 5.5 (1M context) <[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 `&` (`&` 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];
+ }
+}