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 de1489abdb9d CAMEL-25219: camel-jbang - Tab completes simple
expressions in the source editor (#27232)
de1489abdb9d is described below
commit de1489abdb9d41d417e23f562dc6c5a3211063d3
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 22:38:27 2026 +0200
CAMEL-25219: camel-jbang - Tab completes simple expressions in the source
editor (#27232)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../jbang/camel-tui-source-simple-completion.png | Bin 0 -> 370850 bytes
.../ROOT/pages/camel-jbang-tui-source-editor.adoc | 10 +-
.../jbang/core/commands/tui/AutocompletePopup.java | 34 ++-
.../core/commands/tui/SimpleCompletionContext.java | 141 ++++++++++
.../jbang/core/commands/tui/SimpleCompletions.java | 286 +++++++++++++++++++
.../jbang/core/commands/tui/SourceEditAssist.java | 5 +
.../dsl/jbang/core/commands/tui/SourceTab.java | 3 +
.../dsl/jbang/core/commands/tui/SourceViewer.java | 115 ++++++++
.../src/main/resources/tui/help/source.md | 10 +
.../core/commands/tui/SimpleCompletionTest.java | 307 +++++++++++++++++++++
10 files changed, 906 insertions(+), 5 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-completion.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-completion.png
new file mode 100644
index 000000000000..913c6d966f9b
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-simple-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 cb1ec9113de0..a775c0c57404 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,7 +13,7 @@ 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.
+* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options
and their values, and simple expressions.
* <<_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
@@ -122,10 +122,18 @@ What can be completed:
given), and the value of an option after `=`.
* *YAML routes* -- EIP names, component names (only the components that can
consume on a `from`, and produce on a
`to`), endpoint options in a `parameters:` block (the required ones first),
EIP options, and option values.
+* *Simple expressions*, in YAML, Java and XML routes alike -- after `+${+` the
functions of the
+ xref:components:languages:simple-language.adoc[Simple] language, with their
parameters and examples in the details
+ panel, inserted the way they are written (`+${body}+`, `+${date:+`,
`+${random(+`). After `+${header.+` (and
+ `exchangeProperty.`, `variable.`) the names the file sets or reads, then the
headers of the components it uses. After a
+ function and a space the operators: comparisons, `&&` and `||` where the EIP
takes a predicate (`when`, `filter`,
+ `validate`, `onWhen`, ...), chaining (`~>`) and a default value (`?:`) in
other expressions.
* *`application.properties`* -- Camel options (`+camel.main.*+`,
`+camel.component.*+`, ...) and Spring Boot properties
(`+server.*+`, `+spring.*+`, ...), with their values and `+{{placeholder}}+`
suggestions. This works even when the
project is not running.
+image::jbang/camel-tui-source-simple-completion.png[After ${header. Tab lists
the headers the route sets, then the ones of the Kafka component]
+
== 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/AutocompletePopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index 8de140e54531..245bcb6fcde6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -45,12 +45,26 @@ import dev.tamboui.widgets.scrollbar.ScrollbarState;
class AutocompletePopup {
+ /**
+ * An entry of the popup; insert is the text that goes into the line when
it differs from the key shown (a simple
+ * function is listed as date:command and inserted as date:), null
otherwise.
+ */
record CompletionItem(String key, String description, String type, Object
defaultValue,
- boolean deprecated, String deprecationNote, String group, boolean
required) {
+ boolean deprecated, String deprecationNote, String group, boolean
required, String insert) {
+
+ CompletionItem(String key, String description, String type, Object
defaultValue,
+ boolean deprecated, String deprecationNote, String
group, boolean required) {
+ this(key, description, type, defaultValue, deprecated,
deprecationNote, group, required, null);
+ }
CompletionItem(String key, String description, String type, Object
defaultValue,
boolean deprecated, String deprecationNote, String
group) {
- this(key, description, type, defaultValue, deprecated,
deprecationNote, group, false);
+ this(key, description, type, defaultValue, deprecated,
deprecationNote, group, false, null);
+ }
+
+ /** The text that goes into the line. */
+ String insertText() {
+ return insert != null ? insert : key;
}
}
@@ -83,6 +97,7 @@ class AutocompletePopup {
private CompletionItem selectedItem;
private Rect popupRect;
private String titlePrefix;
+ private boolean fullKeys;
AutocompletePopup(List<CompletionItem> items, String initialPrefix, String
lineKeyText) {
this(items, initialPrefix, lineKeyText, false);
@@ -314,7 +329,7 @@ class AutocompletePopup {
Style keyStyle = ci.deprecated() ? deprecatedStyle : ci.required()
? boldStyle : normalStyle;
String displayKey = key;
- if (!key.startsWith("{{") && !key.endsWith(".")) {
+ if (!fullKeys && !key.startsWith("{{") && !key.endsWith(".")) {
int lastDot = key.lastIndexOf('.');
if (lastDot >= 0) {
displayKey = key.substring(lastDot + 1);
@@ -435,7 +450,10 @@ class AutocompletePopup {
}
if (selected.description() != null) {
lines.add(Line.empty());
- lines.add(Line.from(Span.styled(selected.description(),
dimStyle)));
+ // a simple function's description is followed by its
parameters and examples, each on a line
+ for (String part : selected.description().split("\n", -1)) {
+ lines.add(part.isEmpty() ? Line.empty() :
Line.from(Span.styled(part, dimStyle)));
+ }
}
}
@@ -499,6 +517,14 @@ class AutocompletePopup {
return null;
}
+ /**
+ * Lists the keys whole: header.name of the simple functions is not a
property key whose group (up to its last dot)
+ * goes without saying.
+ */
+ void setFullKeys(boolean fullKeys) {
+ this.fullKeys = fullKeys;
+ }
+
boolean isValueMode() {
return valueMode;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java
new file mode 100644
index 000000000000..c808e6cb9482
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionContext.java
@@ -0,0 +1,141 @@
+/*
+ * 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;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The simple expression the cursor is in, read from the line alone so it
works on a line being typed, which does not
+ * parse, and the same in YAML, Java and XML (CAMEL-25219): ${hea| is in a
function, ${header.Camel| in a header name,
+ * ${body} | after a function where an operator goes.
+ *
+ * @param kind what goes at the cursor
+ * @param prefix the text before the cursor that the completion replaces
+ * @param closing what follows a chosen name: } after header.x, ']} after
header['x
+ * @param owners the words before the expression, nearest first (when,
filter, setBody, log...), to tell whether the
+ * expression is a predicate; the language words (simple,
expression) are left out
+ */
+record SimpleCompletionContext(Kind kind, String prefix, String closing,
List<String> owners) {
+
+ enum Kind {
+ FUNCTION,
+ HEADER,
+ PROPERTY,
+ VARIABLE,
+ OPERATOR
+ }
+
+ /** How many lines above the cursor are looked at for the EIP the
expression belongs to. */
+ private static final int OWNER_LINES = 8;
+ private static final Set<String> LANGUAGE_WORDS = Set.of("simple",
"expression", "language", "camel");
+ private static final Pattern NAME = Pattern.compile(
+
"(header|headers|in\\.header|in\\.headers|exchangeProperty|variable|variables)(\\.|\\[['\"]?)([\\w.\\-]*)");
+ private static final Pattern FUNCTION = Pattern.compile("[\\w.:\\-]*");
+ private static final Pattern OPERATOR =
Pattern.compile("\\s+([^\\s'\"$]*)");
+ private static final Pattern WORD = Pattern.compile("[A-Za-z_][\\w-]*");
+
+ /** The context at the cursor, or null when the cursor is not where a
simple completion goes. */
+ static SimpleCompletionContext 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;
+ }
+ // the ${ still open at the cursor, nested ones included:
${abs(${header.pr|
+ List<Integer> open = new ArrayList<>();
+ int firstOpen = -1;
+ int lastClose = -1;
+ for (int i = 0; i < col; i++) {
+ char c = line.charAt(i);
+ if (c == '$' && i + 1 < col && line.charAt(i + 1) == '{') {
+ if (firstOpen < 0) {
+ firstOpen = i;
+ }
+ open.add(i + 2);
+ i++;
+ } else if (c == '}' && !open.isEmpty()) {
+ open.remove(open.size() - 1);
+ lastClose = i;
+ }
+ }
+ if (firstOpen < 0) {
+ return null;
+ }
+ if (!open.isEmpty()) {
+ String inner = line.substring(open.get(open.size() - 1), col);
+ Matcher m = NAME.matcher(inner);
+ if (m.matches()) {
+ Kind kind = switch (m.group(1)) {
+ case "exchangeProperty" -> Kind.PROPERTY;
+ case "variable", "variables" -> Kind.VARIABLE;
+ default -> Kind.HEADER;
+ };
+ String bracket = m.group(2);
+ String closing = ".".equals(bracket) ? "}" :
bracket.substring(1) + "]}";
+ return new SimpleCompletionContext(kind, m.group(3), closing,
List.of());
+ }
+ if (FUNCTION.matcher(inner).matches()) {
+ return new SimpleCompletionContext(Kind.FUNCTION, inner, "",
List.of());
+ }
+ // in the arguments of a function, which take expressions of their
own
+ return null;
+ }
+ Matcher m = OPERATOR.matcher(line.substring(lastClose + 1, col));
+ if (!m.matches()) {
+ return null;
+ }
+ return new SimpleCompletionContext(Kind.OPERATOR, m.group(1), " ",
owners(lines, row, firstOpen));
+ }
+
+ /** The words before the expression that starts at col of row, nearest
first. */
+ private static List<String> owners(List<String> lines, int row, int col) {
+ List<String> owners = new ArrayList<>();
+ for (int r = row; r >= 0 && r > row - OWNER_LINES; r--) {
+ String text = r == row ? lines.get(r).substring(0, col) :
lines.get(r);
+ Matcher m = WORD.matcher(text);
+ List<String> words = new ArrayList<>();
+ while (m.find()) {
+ if (!LANGUAGE_WORDS.contains(m.group())) {
+ words.add(m.group());
+ }
+ }
+ for (int i = words.size() - 1; i >= 0; i--) {
+ owners.add(words.get(i));
+ }
+ }
+ return owners;
+ }
+
+ /** Whether the expression is a predicate: the nearest EIP word before it
takes one. */
+ boolean isPredicate(Set<String> predicateWords, Set<String> eipWords) {
+ for (String w : owners) {
+ if (predicateWords.contains(w)) {
+ return true;
+ }
+ if (eipWords.contains(w)) {
+ return false;
+ }
+ }
+ return false;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java
new file mode 100644
index 000000000000..b43640d1d7fc
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletions.java
@@ -0,0 +1,286 @@
+/*
+ * 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.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.tooling.model.BaseOptionModel;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.EipModel;
+import org.apache.camel.tooling.model.LanguageModel;
+
+/**
+ * The Tab completions of simple expressions in the Source editor, for YAML,
Java and XML routes alike (CAMEL-25219):
+ * the functions of the simple language after ${, the header, exchange
property and variable names after ${header., and
+ * the operators after a function. All from the catalog's simple language
model, so they follow the Camel version of the
+ * project.
+ */
+final class SimpleCompletions {
+
+ /** The operators after a function of a predicate (when, filter...):
comparisons, and joining predicates. */
+ private static final Set<String> PREDICATE_OPERATOR_KINDS =
Set.of("binary", "logical");
+ /** The operators after a function of an expression (setBody, log...):
chaining, and a default value. */
+ private static final Set<String> EXPRESSION_OPERATOR_KINDS =
Set.of("chain", "other");
+ private static final int MAX_EXAMPLES = 3;
+ private static final Pattern IDENT = Pattern.compile("[A-Za-z0-9-]+");
+ private static final Pattern SCHEME =
Pattern.compile("(?<![\\w.+-])([a-z][a-z0-9-]*):[^\\s:]");
+
+ // the predicate and EIP words of the last catalog: reading them goes
through every model
+ private static volatile Words words;
+
+ private record Words(CamelCatalog catalog, Set<String> predicate,
Set<String> eip) {
+ }
+
+ private SimpleCompletions() {
+ }
+
+ /** The completions at the context, given the lines of the file being
edited. */
+ static List<AutocompletePopup.CompletionItem> provide(
+ CamelCatalog catalog, SimpleCompletionContext context,
List<String> lines) {
+ if (catalog == null || context == null) {
+ return List.of();
+ }
+ LanguageModel simple = catalog.languageModel("simple");
+ if (simple == null) {
+ return List.of();
+ }
+ return switch (context.kind()) {
+ case FUNCTION -> functions(simple);
+ case OPERATOR -> {
+ Words w = words(catalog);
+ yield operators(simple, context.isPredicate(w.predicate(),
w.eip()));
+ }
+ case HEADER -> names(catalog, lines, "Header", "header", true);
+ case PROPERTY -> names(catalog, lines, "Property",
"exchangeProperty", false);
+ case VARIABLE -> names(catalog, lines, "Variable", "variable",
false);
+ };
+ }
+
+ static List<AutocompletePopup.CompletionItem> functions(LanguageModel
simple) {
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (LanguageModel.LanguageFunctionModel f : simple.getFunctions()) {
+ String insert = insertOf(f);
+ items.add(new AutocompletePopup.CompletionItem(
+ displayOf(f.getName(), insert), describe(f),
f.getJavaType(), null,
+ f.isDeprecated(), f.getDeprecationNote(), f.getGroup(),
false, insert));
+ }
+ items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+
+ /**
+ * What goes into the line for a function: the whole of it with its } when
it takes nothing (body}), else the start
+ * of it in the syntax its examples use, up to where the user goes on
(header. date: substring( bean:). The names of
+ * the catalog are not that syntax: date(command) is written ${date:now}.
+ */
+ static String insertOf(LanguageModel.LanguageFunctionModel f) {
+ String key = f.getName();
+ Matcher m = IDENT.matcher(key);
+ if (!m.lookingAt()) {
+ return key;
+ }
+ String ident = m.group();
+ if (f.getParams().isEmpty() && key.indexOf('(') < 0) {
+ return key + "}";
+ }
+ boolean optional =
f.getParams().stream().noneMatch(LanguageModel.FunctionParamModel::isRequired);
+ String stem = null;
+ for (String example : f.getExamples()) {
+ if (!example.startsWith("${" + ident)) {
+ continue;
+ }
+ String rest = example.substring(2 + ident.length());
+ if (optional && (rest.startsWith("}") || rest.startsWith("()}"))) {
+ // used as is: ${uuid()}, ${messageHistory()}
+ return ident + rest.substring(0, rest.indexOf('}') + 1);
+ }
+ if (stem == null && !rest.isEmpty() &&
"(:.[".indexOf(rest.charAt(0)) >= 0) {
+ stem = ident + rest.charAt(0);
+ }
+ }
+ if (stem != null) {
+ return stem;
+ }
+ return key.length() > ident.length() ? key.substring(0, ident.length()
+ 1) : key + "}";
+ }
+
+ /** The name a function is listed under: the catalog name in the syntax it
is inserted with (date:command). */
+ static String displayOf(String key, String insert) {
+ if (insert.endsWith("}")) {
+ return key;
+ }
+ char delimiter = insert.charAt(insert.length() - 1);
+ int at = insert.length() - 1;
+ if (key.length() <= at || key.charAt(at) == delimiter) {
+ return key;
+ }
+ String rest = key.substring(at + 1);
+ if (key.charAt(at) == '(' && rest.endsWith(")")) {
+ rest = rest.substring(0, rest.length() - 1);
+ }
+ return insert + rest;
+ }
+
+ private static String describe(LanguageModel.LanguageFunctionModel f) {
+ StringBuilder sb = new StringBuilder(f.getDescription() != null ?
f.getDescription() : "");
+ if (!f.getParams().isEmpty()) {
+ sb.append("\n\nParameters:");
+ for (LanguageModel.FunctionParamModel p : f.getParams()) {
+ sb.append("\n ").append(p.getName());
+ if (p.isRequired()) {
+ sb.append(" (required)");
+ } else if (p.getDefaultValue() != null) {
+ sb.append(" (default
").append(p.getDefaultValue()).append(')');
+ }
+ if (p.getDescription() != null) {
+ sb.append(": ").append(p.getDescription());
+ }
+ }
+ }
+ appendExamples(sb, f.getExamples());
+ return sb.toString();
+ }
+
+ private static void appendExamples(StringBuilder sb, List<String>
examples) {
+ if (!examples.isEmpty()) {
+ sb.append("\n\nExamples:");
+ examples.stream().limit(MAX_EXAMPLES).forEach(e -> sb.append("\n
").append(e));
+ }
+ }
+
+ static List<AutocompletePopup.CompletionItem> operators(LanguageModel
simple, boolean predicate) {
+ Set<String> kinds = predicate ? PREDICATE_OPERATOR_KINDS :
EXPRESSION_OPERATOR_KINDS;
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (LanguageModel.LanguageOperatorModel op : simple.getOperators()) {
+ // ! goes before a function, not after one
+ if (!kinds.contains(op.getOperatorKind()) ||
"!".equals(op.getName())) {
+ continue;
+ }
+ StringBuilder sb = new StringBuilder(op.getDescription() != null ?
op.getDescription() : "");
+ if (op.getOperatorSyntax() != null) {
+ sb.append("\n\nSyntax: ").append(op.getOperatorSyntax());
+ }
+ appendExamples(sb, op.getExamples());
+ items.add(new AutocompletePopup.CompletionItem(
+ op.getName(), sb.toString(), null, null,
op.isDeprecated(), op.getDeprecationNote(),
+ op.getDisplayName(), false));
+ }
+ return items;
+ }
+
+ /**
+ * The header (exchange property, variable) names of the file: the ones it
sets or reads, then for headers the ones
+ * of the components it uses, from the catalog.
+ */
+ static List<AutocompletePopup.CompletionItem> names(
+ CamelCatalog catalog, List<String> lines, String setWord, String
readWord, boolean componentHeaders) {
+ String text = String.join("\n", lines);
+ Map<String, AutocompletePopup.CompletionItem> items = new
LinkedHashMap<>();
+ // Java: setHeader("foo", ...; YAML and XML: setHeader with a name:
foo or name="foo" below or after it
+ for (String verb : List.of("set" + setWord, "remove" + setWord)) {
+ addNames(items, text, Pattern.compile("\\b" + verb +
"\\s*\\(\\s*\"([^\"]+)\""), "Set in this file");
+ addNames(items, text,
+ Pattern.compile(
+ "\\b" + verb +
"\\b[^(]{0,300}?\\bname\\s*[:=]\\s*['\"]?([\\w.\\-]+)"),
+ "Set in this file");
+ }
+ addNames(items, text, Pattern.compile("\\$\\{(?:in\\.)?" + readWord +
"s?(?:\\.|\\[['\"]?)([\\w\\-]+)"),
+ "Used in this file");
+ addNames(items, text, Pattern.compile("\\b" + readWord +
"\\s*\\(\\s*\"([^\"]+)\""), "Used in this file");
+ if (componentHeaders) {
+ Set<String> components = new
HashSet<>(catalog.findComponentNames());
+ Matcher m = SCHEME.matcher(text);
+ Set<String> seen = new HashSet<>();
+ while (m.find()) {
+ String scheme = m.group(1);
+ if (!components.contains(scheme) || !seen.add(scheme)) {
+ continue;
+ }
+ ComponentModel model = catalog.componentModel(scheme);
+ if (model == null) {
+ continue;
+ }
+ for (ComponentModel.EndpointHeaderModel h :
model.getEndpointHeaders()) {
+ items.putIfAbsent(h.getName(), new
AutocompletePopup.CompletionItem(
+ h.getName(), h.getDescription(), h.getJavaType(),
h.getDefaultValue(),
+ h.isDeprecated(), h.getDeprecationNote(), scheme,
false));
+ }
+ }
+ }
+ return new ArrayList<>(items.values());
+ }
+
+ private static void addNames(
+ Map<String, AutocompletePopup.CompletionItem> items, String text,
Pattern pattern, String description) {
+ Matcher m = pattern.matcher(text);
+ while (m.find()) {
+ String name = m.group(1);
+ items.putIfAbsent(name, new AutocompletePopup.CompletionItem(
+ name, description, null, null, false, null, null, false));
+ }
+ }
+
+ private static Words words(CamelCatalog catalog) {
+ Words w = words;
+ if (w == null || w.catalog() != catalog) {
+ w = new Words(catalog, predicateWords(catalog), eipWords(catalog));
+ words = w;
+ }
+ return w;
+ }
+
+ /**
+ * The words that make the expression after them a predicate: the EIPs
whose expression is one (when, filter,
+ * validate...) and the predicate options of the others (onWhen, handled,
retryWhile, completionPredicate...).
+ */
+ static Set<String> predicateWords(CamelCatalog catalog) {
+ Set<String> words = new HashSet<>();
+ for (String name : catalog.findModelNames()) {
+ EipModel model = catalog.eipModel(name);
+ if (model == null) {
+ continue;
+ }
+ for (BaseOptionModel option : model.getOptions()) {
+ if (option.isAsPredicate()) {
+ words.add("expression".equals(option.getName()) ? name :
option.getName());
+ }
+ }
+ }
+ return words;
+ }
+
+ /** The EIP names: the nearest one before an expression tells what it
belongs to. */
+ static Set<String> eipWords(CamelCatalog catalog) {
+ Set<String> words = new HashSet<>();
+ for (String name : catalog.findModelNames()) {
+ EipModel model = catalog.eipModel(name);
+ if (model != null && model.getLabel() != null &&
model.getLabel().contains("eip")) {
+ words.add(name);
+ }
+ }
+ return words;
+ }
+}
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 34e1b800e9c6..062f631d5e11 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
@@ -1011,6 +1011,11 @@ final class SourceEditAssist {
return items;
}
+ /** The completions of the simple expression at the cursor (CAMEL-25219).
*/
+ List<AutocompletePopup.CompletionItem>
provideSimpleCompletions(SimpleCompletionContext context, List<String> lines) {
+ return SimpleCompletions.provide(validationCatalog(), context, lines);
+ }
+
List<AutocompletePopup.CompletionItem>
provideComponentNameCompletions(String role) {
CamelCatalog catalog = validationCatalog();
if (catalog == null) {
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 7c53bc357ec9..05a641c5e5d1 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
@@ -845,6 +845,9 @@ class SourceTab extends AbstractTab {
sourceViewer.setAskAi(ctx.askAiCallback);
String name = filePath.getFileName().toString();
sourceViewer.setUriCompletion(!routeFile ? null :
name.endsWith(".java") ? "java" : "xml");
+ // simple expressions in every route file: YAML, Java and XML
(CAMEL-25219)
+ sourceViewer.setSimpleCompletion(
+ routeFile || isCamelSourceFile(filePath) ?
assist::provideSimpleCompletions : null);
if (isCamelSourceFile(filePath)) {
sourceViewer.setQuickDocProvider(assist::provideCamelQuickDocs);
sourceViewer.setDeprecatedLineScanner(null);
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 2ebc6e3d5ae1..229f7d4f56f3 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
@@ -29,6 +29,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
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.IntConsumer;
@@ -214,6 +215,8 @@ class SourceViewer {
private String uriCompletion;
/** The completion being chosen in a uri: its row, the column its prefix
ends at, the prefix and suffix. */
private UriCompletion pendingUriCompletion;
+ private BiFunction<SimpleCompletionContext, List<String>,
List<AutocompletePopup.CompletionItem>> simpleCompletion;
+ private SimpleCompletion pendingSimpleCompletion;
private List<String> routeProblems = List.of();
private List<String> validationErrors;
private int validationErrorScroll;
@@ -407,6 +410,19 @@ class SourceViewer {
private record UriCompletion(int row, int endCol, String prefix, String
suffix) {
}
+ /**
+ * Tab completion in the simple expressions of a YAML, Java or XML route
file (CAMEL-25219): the functions after ${,
+ * the header names after ${header., the operators after a function. Given
the context at the cursor and the lines
+ * of the file; null for a file without routes.
+ */
+ void setSimpleCompletion(
+ BiFunction<SimpleCompletionContext, List<String>,
List<AutocompletePopup.CompletionItem>> provider) {
+ this.simpleCompletion = provider;
+ }
+
+ private record SimpleCompletion(int row, int endCol,
SimpleCompletionContext 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;
@@ -425,6 +441,7 @@ class SourceViewer {
simpleValidator = null;
routeValidator = null;
uriCompletion = null;
+ simpleCompletion = null;
}
void reset() {
@@ -466,6 +483,7 @@ class SourceViewer {
simpleValidator = null;
routeValidator = null;
uriCompletion = null;
+ simpleCompletion = null;
}
boolean isMarkdownMode() {
@@ -1176,6 +1194,10 @@ class SourceViewer {
private void openAutocomplete() {
pendingUriCompletion = null;
+ pendingSimpleCompletion = null;
+ if (openSimpleAutocomplete()) {
+ return;
+ }
if (isCamelYamlFile()) {
openYamlAutocomplete();
} else if (uriCompletion != null) {
@@ -1246,6 +1268,95 @@ class SourceViewer {
}
}
+ /**
+ * The completion of the simple expression the cursor is in, read from the
line alone like the uris: true when the
+ * cursor is in one, so no other completion is tried, also when nothing
matches there.
+ */
+ private boolean openSimpleAutocomplete() {
+ if (simpleCompletion == null) {
+ return false;
+ }
+ 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));
+ }
+ SimpleCompletionContext c = SimpleCompletionContext.at(all, row, col);
+ if (c == null) {
+ return false;
+ }
+ List<AutocompletePopup.CompletionItem> items =
simpleCompletion.apply(c, all);
+ if (items != null && !items.isEmpty()) {
+ // not in value mode, which puts a popup with a long description
over the line being edited
+ autocompletePopup = new AutocompletePopup(items, c.prefix(),
c.prefix(), false);
+ autocompletePopup.setFullKeys(true);
+ autocompletePopup.setTitlePrefix(switch (c.kind()) {
+ case FUNCTION -> "Simple functions";
+ case HEADER -> "Headers";
+ case PROPERTY -> "Exchange properties";
+ case VARIABLE -> "Variables";
+ case OPERATOR -> "Operators";
+ });
+ pendingSimpleCompletion = new SimpleCompletion(row, col, c);
+ }
+ return true;
+ }
+
+ /**
+ * Replaces the prefix the simple completion was opened on with the chosen
item, and what closes it (the } of a
+ * function or name, the space after an operator) unless that is there
already. A chosen header. or
+ * exchangeProperty. opens the names right away.
+ */
+ private void insertSimpleCompletion(AutocompletePopup.CompletionItem item)
{
+ SimpleCompletion sc = pendingSimpleCompletion;
+ pendingSimpleCompletion = null;
+ if (editState.cursorRow() != sc.row()) {
+ return;
+ }
+ SimpleCompletionContext c = sc.context();
+ int col = editState.cursorCol();
+ for (; col < sc.endCol(); col++) {
+ editState.moveCursorRight();
+ }
+ for (; col > sc.endCol(); col--) {
+ editState.moveCursorLeft();
+ }
+ for (int i = 0; i < c.prefix().length(); i++) {
+ editState.deleteBackward();
+ }
+ // Tab in the middle of a word replaces all of it
+ String line = editState.getLine(sc.row());
+ int from = editState.cursorCol();
+ int end = from;
+ boolean operator = c.kind() == SimpleCompletionContext.Kind.OPERATOR;
+ while (end < line.length() && isSimpleWordChar(line.charAt(end),
operator)) {
+ end++;
+ }
+ for (int i = from; i < end; i++) {
+ editState.deleteForward();
+ }
+ String text = item.insertText();
+ String closing = c.closing();
+ if (c.kind() == SimpleCompletionContext.Kind.FUNCTION &&
text.endsWith("}")) {
+ text = text.substring(0, text.length() - 1);
+ closing = "}";
+ }
+ boolean hasClosing = !closing.isEmpty() && line.startsWith(closing,
end);
+ editState.insert(text + (hasClosing ? "" : closing));
+ if (c.kind() == SimpleCompletionContext.Kind.FUNCTION &&
(text.endsWith(".") || text.endsWith("["))) {
+ openAutocomplete();
+ }
+ }
+
+ /** A character of the function, name or operator being completed. */
+ private static boolean isSimpleWordChar(char c, boolean operator) {
+ if (operator) {
+ return !Character.isWhitespace(c) && c != '$' && c != '\'' && c !=
'"';
+ }
+ return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c ==
'.' || c == ':';
+ }
+
/** 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;
@@ -1456,6 +1567,10 @@ class SourceViewer {
insertUriCompletion(item);
return;
}
+ if (pendingSimpleCompletion != null) {
+ insertSimpleCompletion(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 c563f8f24b66..4e39454073f4 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
@@ -100,6 +100,16 @@ 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 `=`
+**Simple expressions (YAML, Java and XML routes):**
+- After `${`, Tab lists the functions of the simple language, with their
+ parameters and examples; the chosen one is inserted as it is written
+ (`${body}`, `${date:`, `${random(`)
+- After `${header.` (also `exchangeProperty.` and `variable.`), Tab lists the
+ names the file sets or reads, then the headers of the components it uses
+- After a function and a space, Tab lists the operators: comparisons and
+ `&&` `||` where the EIP takes a predicate (`when`, `filter`, `validate`,
+ `onWhen`...), chaining (`~>`) and the default value (`?:`) elsewhere
+
Use **Up/Down** to navigate, **Enter** to accept, **Esc** to dismiss, and
type to filter the completion list (an exact or prefix match comes first).
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java
new file mode 100644
index 000000000000..e01d45e42e46
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SimpleCompletionTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+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.tooling.model.LanguageModel;
+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 in the simple expressions of YAML, Java and XML route files
(CAMEL-25219).
+ */
+class SimpleCompletionTest {
+
+ private static CamelCatalog catalog;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ @BeforeEach
+ void setUp() {
+ Theme.resetForTesting();
+ }
+
+ private static SimpleCompletionContext at(String line) {
+ return SimpleCompletionContext.at(List.of(line), 0, line.length());
+ }
+
+ @Test
+ void theFunctionTheCursorIsIn() {
+ SimpleCompletionContext c = at(" .setBody(simple(\"Hello
${hea");
+ assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.FUNCTION);
+ assertThat(c.prefix()).isEqualTo("hea");
+
+ assertThat(at(" simple: \"${").prefix()).isEmpty();
+ // nested in the argument of another function
+ assertThat(at("${abs(${bo").prefix()).isEqualTo("bo");
+ // in the arguments of a function, after a closed one, with no ${ at
all
+ assertThat(at("${substring(1, ")).isNull();
+ assertThat(at("${body}")).isNull();
+ assertThat(at(" .log(\"Hello")).isNull();
+ }
+
+ @Test
+ void theNameTheCursorIsIn() {
+ SimpleCompletionContext c = at("${header.Camel");
+ assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.HEADER);
+ assertThat(c.prefix()).isEqualTo("Camel");
+ assertThat(c.closing()).isEqualTo("}");
+
+ c = at("${headers['ord");
+ assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.HEADER);
+ assertThat(c.prefix()).isEqualTo("ord");
+ assertThat(c.closing()).isEqualTo("']}");
+
+
assertThat(at("${exchangeProperty.").kind()).isEqualTo(SimpleCompletionContext.Kind.PROPERTY);
+
assertThat(at("${variable.x").kind()).isEqualTo(SimpleCompletionContext.Kind.VARIABLE);
+ }
+
+ @Test
+ void theOperatorAfterAFunction() {
+ SimpleCompletionContext c = at(" .filter(simple(\"${header.foo}
con");
+ assertThat(c.kind()).isEqualTo(SimpleCompletionContext.Kind.OPERATOR);
+ assertThat(c.prefix()).isEqualTo("con");
+ assertThat(c.owners()).startsWith("filter");
+
+ // right after the } or past the string
+ assertThat(at("${header.foo}")).isNull();
+ assertThat(at(" .log(\"${body}\" + ")).isNull();
+ }
+
+ @Test
+ void aPredicateIsToldByTheEipBeforeIt() {
+ assertThat(predicate(List.of(" .filter(simple(\"${body}
"))).isTrue();
+ assertThat(predicate(List.of("
.choice().when().simple(\"${body} "))).isTrue();
+ assertThat(predicate(List.of(" .setBody(simple(\"${body}
"))).isFalse();
+ assertThat(predicate(List.of(" .log(\"${body} "))).isFalse();
+ // onException options
+ assertThat(predicate(List.of("
onException(Exception.class).handled(simple(\"${body} "))).isTrue();
+ // YAML and XML: the EIP is on a line above
+ assertThat(predicate(List.of("- filter:", " simple: \"${body}
"))).isTrue();
+ assertThat(predicate(List.of("- filter:", " expression:", "
simple:", " expression: \"${body} ")))
+ .isTrue();
+ assertThat(predicate(List.of("- log:", " message: \"${body}
"))).isFalse();
+ assertThat(predicate(List.of("<when>", " <simple>${body}
"))).isTrue();
+ assertThat(predicate(List.of("<setBody>", " <simple>${body}
"))).isFalse();
+ }
+
+ private static boolean predicate(List<String> lines) {
+ int row = lines.size() - 1;
+ SimpleCompletionContext c = SimpleCompletionContext.at(lines, row,
lines.get(row).length());
+ List<String> ops = SimpleCompletions.provide(catalog, c,
lines).stream()
+ .map(AutocompletePopup.CompletionItem::key).toList();
+ boolean predicate = ops.contains("==");
+ // the operators of an expression are never mixed with the ones of a
predicate
+ assertThat(ops.contains("~>")).isNotEqualTo(predicate);
+ return predicate;
+ }
+
+ @Test
+ void functionsAreInsertedInTheirSyntax() {
+ LanguageModel simple = catalog.languageModel("simple");
+ Map<String, AutocompletePopup.CompletionItem> items =
SimpleCompletions.functions(simple).stream()
+
.collect(Collectors.toMap(AutocompletePopup.CompletionItem::key,
Function.identity()));
+
+ assertThat(items.get("body").insertText()).isEqualTo("body}");
+ assertThat(items.get("header.name").insertText()).isEqualTo("header.");
+
assertThat(items.get("random(min,max)").insertText()).isEqualTo("random(");
+ assertThat(items.get("uuid(type)").insertText()).isEqualTo("uuid()}");
+
assertThat(items.get("properties:key:default").insertText()).isEqualTo("properties:");
+ // the catalog names date(command) and bean(name.method), written
${date:now} and ${bean:foo}
+ assertThat(items.get("date:command").insertText()).isEqualTo("date:");
+
assertThat(items.get("bean:name.method").insertText()).isEqualTo("bean:");
+ assertThat(items).doesNotContainKey("date(command)");
+ // the details show the parameters and examples
+
assertThat(items.get("random(min,max)").description()).contains("Parameters:",
"min (required)", "Examples:");
+ }
+
+ @Test
+ void functionsAreListedWithTheirWholeName() {
+ AutocompletePopup popup = new AutocompletePopup(
+ SimpleCompletions.functions(catalog.languageModel("simple")),
"header.", "header.", false);
+ popup.setFullKeys(true);
+ Rect area = new Rect(0, 0, 120, 30);
+ Buffer buffer = Buffer.empty(area);
+ popup.render(Frame.forTesting(buffer), area, 2, 10);
+
+ // not name, as a property key in its group would be shown
+ assertThat(HealthTabRenderTest.bufferToString(buffer)).contains("
header.name ");
+ }
+
+ @Test
+ void theHeaderNamesOfTheFile() {
+ List<String> lines = List.of(
+ "from(\"kafka:orders\")",
+ " .setHeader(\"orderId\", simple(\"${body}\"))",
+ " .filter(simple(\"${header.priority} == 'high'\"))",
+ "- setHeader:",
+ " name: region",
+ "<setHeader id=\"x\" name=\"customer\">");
+ List<String> names = SimpleCompletions.names(catalog, lines, "Header",
"header", true).stream()
+ .map(AutocompletePopup.CompletionItem::key).toList();
+ // the ones the file sets or reads first, then the ones of the kafka
component
+ assertThat(names).startsWith("orderId", "region", "customer",
"priority");
+ assertThat(names).contains("CamelKafkaKey");
+ }
+
+ @Test
+ void aJavaFunction() throws Exception {
+ SourceViewer viewer = viewer("MyRoute.java", """
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .setHeader("orderId", constant(1))
+ .filter(simple("${headerA"));
+ }
+ }
+ """);
+ cursorAt(viewer, 4, "\"));");
+ tab(viewer);
+ enter(viewer);
+ assertThat(line(viewer, 4)).isEqualTo("
.filter(simple(\"${headerAs(\"));");
+ }
+
+ @Test
+ void aJavaHeaderThenAnOperator() throws Exception {
+ // header. opens the header names right away
+ SourceViewer v2 = viewer("MyRoute.java", """
+ public class MyRoute extends RouteBuilder {
+ public void configure() {
+ from("timer:tick")
+ .setHeader("orderId", constant(1))
+ .filter(simple("${heade"));
+ }
+ }
+ """);
+ cursorAt(v2, 4, "\"));");
+ tab(v2);
+ type(v2, "r.");
+ enter(v2);
+ assertThat(line(v2, 4)).isEqualTo("
.filter(simple(\"${header.\"));");
+ type(v2, "ord");
+ enter(v2);
+ assertThat(line(v2, 4)).isEqualTo("
.filter(simple(\"${header.orderId}\"));");
+
+ // then the operators of a predicate
+ type(v2, " ");
+ tab(v2);
+ type(v2, "contains");
+ enter(v2);
+ assertThat(line(v2, 4)).isEqualTo("
.filter(simple(\"${header.orderId} contains \"));");
+ }
+
+ @Test
+ void aYamlFunction() throws Exception {
+ SourceViewer viewer = viewer("route.camel.yaml", """
+ - from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ simple: "Hello ${bod"
+ """);
+ cursorAt(viewer, 4, "\"");
+ tab(viewer);
+ enter(viewer);
+ assertThat(line(viewer, 4)).isEqualTo(" simple: \"Hello
${body}\"");
+ }
+
+ @Test
+ void anXmlFunctionKeepsTheClosingBrace() throws Exception {
+ SourceViewer viewer = viewer("routes.xml", """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ <log message="Got ${exchangeI}"/>
+ </route>
+ </routes>
+ """);
+ cursorAt(viewer, 3, "}\"/>");
+ tab(viewer);
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo(" <log message=\"Got
${exchangeId}\"/>");
+ }
+
+ private SourceViewer viewer(String fileName, String src) throws Exception {
+ Path file = tempDir.resolve(fileName);
+ 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.setSimpleCompletion(assist::provideSimpleCompletions);
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+ return viewer;
+ }
+
+ /** Puts the cursor on the line, before the given text at its end. */
+ private static void cursorAt(SourceViewer viewer, int row, String tail) {
+ for (int i = 0; i < row; i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN,
KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+ for (int i = 0; i < tail.length(); i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT,
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];
+ }
+}