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 `&` (`&` 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]; + } +}
