This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-25243 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 8954f33bdd8ac72bda99d05e5a9a0f1f5af368ce Author: Claus Ibsen <[email protected]> AuthorDate: Fri Oct 2 00:03:55 2026 +0200 CAMEL-25243: camel-jbang - source editor: Java DSL completion inside arguments and for the REST DSL Follow-up of CAMEL-25241 for two of its limitations: - in an argument, the chain of the argument is completed from the route builder method it starts with: .filter(header("x"). offers isEqualTo, isNotNull, contains...; .split(body(). offers tokenize, convertTo... - chains that start with something else than a route: the REST DSL (rest("/api").get("/x")., with param()...endParam() and responseMessage()), restConfiguration(), routeTemplate() and its templateBean()...end(), and builders such as deadLetterChannel(..). On a builder or a definition that is no route EIP the chain follows the returned types (no blocks); a sub-builder whose end() returns its parent as a type parameter goes back to where it was entered. Getter-named methods stay out, except fluent ones (rest get(), header("x").isNotNull()). Recall over the Java route tests of camel-core and 191 components: arguments 6,362 positions 99.5%, REST and templates 2,375 positions 100%, route chains unchanged at 99.94%. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel-tui-source-java-argument-completion.png | Bin 0 -> 311294 bytes .../ROOT/pages/camel-jbang-tui-source-editor.adoc | 14 +-- .../jbang/core/commands/tui/JavaChainContext.java | 37 +++++--- .../core/commands/tui/JavaDslCompletions.java | 95 +++++++++++++++++++-- .../src/main/resources/tui/help/source.md | 9 +- .../core/commands/tui/JavaDslCompletionTest.java | 38 ++++++++- 6 files changed, 164 insertions(+), 29 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-argument-completion.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-argument-completion.png new file mode 100644 index 000000000000..31d24838a678 Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-java-argument-completion.png differ 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 414d4e75d112..a49f7d6208ca 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 @@ -127,7 +127,9 @@ What can be completed: `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. + (`.split(|)`). The documentation comes from the catalog. In an argument the chain of the argument is completed + (`.filter(header("priority").` offers `isEqualTo`, `isNotNull`, `contains`...), and so are the REST DSL + (`rest("/api").get("/orders").`, with `param()` ... `endParam()`), `restConfiguration()` and route templates. * *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 @@ -152,16 +154,18 @@ image::jbang/camel-tui-source-simple-completion.png[After ${header. Tab lists th 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] +image::jbang/camel-tui-source-java-argument-completion.png[In the argument of a filter, Tab lists the methods of the header builder] + === 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 +* *Java routes* -- the chain is followed from what a route builder starts it with (`from(...)`, `onException(...)`, + `rest(...)`, `routeTemplate(...)`, or `body()`, `header(..)`... in an argument). A route kept in a variable + (`RouteDefinition route = from(...); route.`), the code of lambdas and processors, the fluent language builders + (`expression().jsonpath()...`), 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}}+`. 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 index 73c4775962fc..2f986409b96f 100644 --- 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 @@ -24,10 +24,12 @@ import java.util.List; * 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 + * @param calls the calls of the chain before the cursor, the first one starting it (from, onException, rest, or + * body, header... in an argument) + * @param prefix the name typed after the last dot, which the completion replaces + * @param argument whether the chain is in the argument of a call (.split(body().|)) rather than a statement */ -record JavaChainContext(List<Call> calls, String prefix) { +record JavaChainContext(List<Call> calls, String prefix, boolean argument) { /** * A call of the chain. @@ -57,23 +59,30 @@ record JavaChainContext(List<Call> calls, String prefix) { return null; } // the statement the cursor is in starts after the last ; { or } outside of any parentheses - int depth = 0; + // where the statement starts, and where each argument open at the cursor starts: after its ( or , or, in a + // lambda body inside the argument, after the last ; { or } int start = 0; + List<Integer> arguments = new ArrayList<>(); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c == '(') { - depth++; + arguments.add(i + 1); } else if (c == ')') { - depth = Math.max(0, depth - 1); - } else if (depth == 0 && (c == ';' || c == '{' || c == '}')) { - start = i + 1; + if (!arguments.isEmpty()) { + arguments.remove(arguments.size() - 1); + } + } else if (c == ',' && !arguments.isEmpty()) { + arguments.set(arguments.size() - 1, i + 1); + } else if (c == ';' || c == '{' || c == '}') { + if (arguments.isEmpty()) { + start = i + 1; + } else { + arguments.set(arguments.size() - 1, i + 1); + } } } - if (depth > 0) { - // in the arguments of a call - return null; - } - String statement = text.substring(start); + boolean argument = !arguments.isEmpty(); + String statement = text.substring(argument ? arguments.get(arguments.size() - 1) : start); int end = statement.length(); int p = end; while (p > 0 && Character.isJavaIdentifierPart(statement.charAt(p - 1))) { @@ -91,7 +100,7 @@ record JavaChainContext(List<Call> calls, String prefix) { if (calls == null || calls.isEmpty()) { return null; } - return new JavaChainContext(calls, prefix); + return new JavaChainContext(calls, prefix, argument); } /** The calls of a chain: name(args).name(args)..., null when the text is no such chain. */ 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 index 25a0f0329175..d49f9fbf80bc 100644 --- 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 @@ -31,6 +31,8 @@ import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import org.apache.camel.CamelContext; +import org.apache.camel.Expression; +import org.apache.camel.Predicate; import org.apache.camel.builder.RouteBuilder; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.model.ChoiceDefinition; @@ -57,7 +59,7 @@ 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"); + "createChildProcessor", "preCreateProcessor", "configureChild", "addOutput", "clearOutput", "evaluate"); /** 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( @@ -138,7 +140,8 @@ final class JavaDslCompletions { 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)) { + // a route (from, onException...), or a builder: body(), header(..) in an argument, rest(..), routeTemplate(..) + if (start != null && (ProcessorDefinition.class.isAssignableFrom(start) || isBuilder(start))) { states.add(new State(List.of(start), start, null, null)); } } @@ -159,6 +162,9 @@ final class JavaDslCompletions { /** 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 && !ProcessorDefinition.class.isAssignableFrom(s.current())) { + return plainStep(s, call); + } if (s.clause() != null) { List<State> found = new ArrayList<>(); for (Method m : candidates(s.clause(), call)) { @@ -214,6 +220,49 @@ final class JavaDslCompletions { return found; } + /** + * A call on a builder or a definition that is no route EIP (ValueBuilder, RestDefinition, ParamDefinition...): it + * has no blocks, the chain goes on with what the method returns, a route when that is one (routeTemplate().from()). + */ + private static List<State> plainStep(State s, JavaChainContext.Call call) { + List<State> found = new ArrayList<>(); + for (Method m : candidates(s.current(), call)) { + Type ret = m.getGenericReturnType(); + Class<?> raw = raw(ret); + State n = null; + if (ret instanceof TypeVariable<?> && (raw == null || !isBuilder(raw)) && s.after() != null) { + // end() of a sub-builder returning its parent as a type parameter (templateBean(..)...end()) + n = new State(List.of(s.after()), s.after(), null, null); + } else if (raw != null && (ProcessorDefinition.class.isAssignableFrom(raw) || isBuilder(raw))) { + // remember where a sub-builder was entered from, to go back there on its end() + Class<?> parent = raw == s.current() ? s.after() : s.current(); + n = new State(List.of(raw), raw, null, ProcessorDefinition.class.isAssignableFrom(raw) ? null : parent); + } + if (n != null && !found.contains(n)) { + found.add(n); + } + } + return found; + } + + /** Whether a chain can start with the method of the route builder: from, rest, body, header... */ + static boolean isBuilderStart(String name) { + for (Method m : methods(RouteBuilder.class, true).getOrDefault(name, List.of())) { + Class<?> raw = raw(m.getGenericReturnType()); + if (raw != null && (ProcessorDefinition.class.isAssignableFrom(raw) || isBuilder(raw))) { + return true; + } + } + return false; + } + + /** Whether the class is one of the DSL builders or definitions a chain can be on besides the route EIPs. */ + static boolean isBuilder(Class<?> type) { + String name = type.getName(); + return name.startsWith("org.apache.camel.model.") || name.startsWith("org.apache.camel.builder.") + || name.startsWith("org.apache.camel.support.builder."); + } + /** * 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 @@ -233,6 +282,18 @@ final class JavaDslCompletions { Map<String, AutocompletePopup.CompletionItem> ends = new TreeMap<>(); Map<String, AutocompletePopup.CompletionItem> eips = new TreeMap<>(); for (State state : states(context.calls())) { + if (state.clause() == null && !ProcessorDefinition.class.isAssignableFrom(state.current())) { + // a builder or a definition that is no route EIP: its methods, documented where the catalog has them + Map<String, BaseOptionModel> options = optionDocs(catalog, state.current()); + for (Map.Entry<String, List<Method>> e : methods(state.current(), false).entrySet()) { + String name = e.getKey(); + BaseOptionModel option = options.get(name); + EipModel model = option == null && catalog != null ? catalog.eipModel(name) : null; + String doc = option != null ? option.getDescription() : model != null ? model.getDescription() : null; + own.putIfAbsent(name, item(name, doc, state.current().getSimpleName(), e.getValue())); + } + continue; + } 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) @@ -398,11 +459,10 @@ final class JavaDslCompletions { 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.")) { + if (!isBuilder(m.getDeclaringClass())) { return false; } - if (m.getParameterCount() == 0 && (m.getName().startsWith("get") || m.getName().startsWith("is"))) { + if (m.getParameterCount() == 0 && isGetterName(m.getName()) && !isFluent(type, m)) { return false; } for (Class<?> p : m.getParameterTypes()) { @@ -416,6 +476,12 @@ final class JavaDslCompletions { return true; } Class<?> raw = raw(ret); + if (!ProcessorDefinition.class.isAssignableFrom(type) && !isClause(type)) { + // a builder or a definition that is no route EIP: the methods going on in the DSL, or ending it in a + // predicate or expression (header("x").isEqualTo(..)) + return raw != null && (isBuilder(raw) || ProcessorDefinition.class.isAssignableFrom(raw) + || raw == Predicate.class || raw == Expression.class); + } 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 @@ -424,6 +490,25 @@ final class JavaDslCompletions { return raw != null && (ProcessorDefinition.class.isAssignableFrom(raw) || isClause(raw)); } + /** getPath, isStreaming: a getter, while get() of the REST DSL or isNull() of a builder may be DSL. */ + private static boolean isGetterName(String name) { + int at = name.startsWith("get") ? 3 : name.startsWith("is") ? 2 : -1; + return at > 0 && name.length() > at && Character.isUpperCase(name.charAt(at)); + } + + /** + * Whether a getter-named method of a builder is DSL nonetheless: returning the builder itself, or an is... making a + * predicate (header("x").isNotNull()). On the route EIPs the getters stay out (getOutputs, getParent). + */ + private static boolean isFluent(Class<?> type, Method m) { + if (ProcessorDefinition.class.isAssignableFrom(type) || isClause(type)) { + return false; + } + Class<?> ret = m.getReturnType(); + // isNotNull() makes a predicate, while getExpression() is a getter + return m.getName().startsWith("is") && ret == Predicate.class || ret.isAssignableFrom(type) && isBuilder(ret); + } + private static Class<?> raw(Type type) { if (type instanceof Class<?> c) { return c; 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 b09321117da3..04f6f02cc418 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 @@ -108,9 +108,12 @@ Press **F4** to enter edit mode, then **Tab** for context-aware completion: `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 +- In an argument, the chain of the argument (`.filter(header("x").` offers + `isEqualTo`, `isNotNull`...); also the REST DSL (`rest("/api").get(..).`), + `restConfiguration()` and route templates +- Light help for hand-written edits: routes in variables and the code of + lambdas 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 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 index 479259046812..1556f6273948 100644 --- 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 @@ -89,8 +89,15 @@ class JavaDslCompletionTest { 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(); + // in an argument: the chain of the argument, from its start + c = at("from(\"a\").split(body()."); + assertThat(c.argument()).isTrue(); + assertThat(c.calls()).extracting(JavaChainContext.Call::name).containsExactly("body"); + c = at("from(\"a\").filter(header(\"x\"), header(\"y\")."); + assertThat(c.calls().get(0).arguments()).isEqualTo(1); + assertThat(c.calls()).extracting(JavaChainContext.Call::name).containsExactly("header"); + + // in a string, in a comment, not after a dot assertThat(at("from(\"a.")).isNull(); assertThat(at("from(\"a\") // .")).isNull(); assertThat(at("from(\"a\") ")).isNull(); @@ -154,6 +161,33 @@ class JavaDslCompletionTest { "timeoutEnabled"); } + @Test + void theBuildersInArguments() { + assertThat(keys("from(\"a\").split(body().")).contains("tokenize", "convertTo", "regexReplaceAll"); + assertThat(keys("from(\"a\").filter(header(\"x\").")).contains("isEqualTo", "isNotNull", "contains"); + // a predicate ends the chain: nothing to offer after it + assertThat(keys("from(\"a\").filter(header(\"x\").isNotNull().")).isEmpty(); + // the internals of the builders are no DSL + assertThat(keys("from(\"a\").split(body().")).doesNotContain("evaluate", "getExpression", "matches"); + } + + @Test + void theRestDsl() { + assertThat(keys("rest(\"/api\").")).contains("get", "post", "description", "consumes"); + assertThat(keys("rest(\"/api\").get(\"/orders\").")).contains("to", "produces", "param", "get"); + // param() is a sub-builder that endParam() leaves + assertThat(keys("rest(\"/api\").get(\"/orders\").param().")).contains("name", "type", "endParam"); + assertThat(keys("rest(\"/api\").get(\"/orders\").param().name(\"id\").endParam().")).contains("to"); + // and a configuration + assertThat(keys("restConfiguration().")).contains("component", "port", "bindingMode"); + } + + @Test + void aTemplateBeanGoesBackToItsTemplateOnEnd() { + assertThat(keys("routeTemplate(\"t\").templateBean(\"b\").typeClass(\"x\").end().")).contains( + "templateParameter", "from"); + } + @Test void anEipIsInsertedWithTheCursorInItsArguments() throws Exception { SourceViewer viewer = viewer("""
