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 db7391b5c787 CAMEL-25240: camel-jbang - Tab completes XML DSL
elements, attributes and values in the source editor (#27236)
db7391b5c787 is described below
commit db7391b5c787e3a39593e7d8e26201b3620bee29
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 23:48:34 2026 +0200
CAMEL-25240: camel-jbang - Tab completes XML DSL elements, attributes and
values in the source editor (#27236)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../jbang/camel-tui-source-xml-completion.png | Bin 0 -> 302485 bytes
.../ROOT/pages/camel-jbang-tui-source-editor.adoc | 11 +-
.../jbang/core/commands/tui/SourceEditAssist.java | 5 +
.../dsl/jbang/core/commands/tui/SourceTab.java | 2 +
.../dsl/jbang/core/commands/tui/SourceViewer.java | 115 +++++++-
.../core/commands/tui/XmlCompletionContext.java | 214 +++++++++++++++
.../jbang/core/commands/tui/XmlCompletions.java | 232 ++++++++++++++++
.../jbang/core/commands/tui/XmlSchemaModel.java | 255 ++++++++++++++++++
.../src/main/resources/tui/help/source.md | 10 +
.../jbang/core/commands/tui/XmlCompletionTest.java | 292 +++++++++++++++++++++
10 files changed, 1130 insertions(+), 6 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-completion.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-completion.png
new file mode 100644
index 000000000000..8be0c6897898
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-xml-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 7593528cf780..095f684d2694 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,8 @@ 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, and simple expressions.
+* <<_completion,*Completion*>> -- *Tab* completes components, endpoint options
and their values, XML elements and
+ attributes, 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
@@ -120,6 +121,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 `=`.
+* *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
+ cursor where you go on. In a start tag the attributes of the element (the
required ones first, without the ones
+ already given), in an attribute its values. The structure and documentation
come from the XML schema of the catalog,
+ so they follow the Camel version of the project.
* *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
@@ -132,6 +139,8 @@ What can be completed:
(`+server.*+`, `+spring.*+`, ...), with their values and `+{{placeholder}}+`
suggestions. This works even when the
project is not running.
+image::jbang/camel-tui-source-xml-completion.png[In the start tag of log, Tab
lists its attributes with their documentation]
+
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
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 b5afd4eb7a42..a20544adeef8 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 XML DSL at the cursor (CAMEL-25240). */
+ List<AutocompletePopup.CompletionItem>
provideXmlCompletions(XmlCompletionContext context, List<String> lines) {
+ return XmlCompletions.provide(validationCatalog(), context,
this::loadPropertyPlaceholders);
+ }
+
/** 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);
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 9eff3188c3fe..788cd694a316 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
@@ -850,6 +850,8 @@ class SourceTab extends AbstractTab {
routeFile || isCamelSourceFile(filePath) ?
assist::provideSimpleCompletions : null);
sourceViewer.setCursorQuickDocProvider(
routeFile || isCamelSourceFile(filePath) ?
assist::provideSimpleQuickDoc : null);
+ // the elements, attributes and values of an XML route (CAMEL-25240)
+ sourceViewer.setXmlCompletion(routeFile &&
name.toLowerCase().endsWith(".xml") ? assist::provideXmlCompletions : 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 2c2b081f8c21..a489b4286ded 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
@@ -224,6 +224,8 @@ class SourceViewer {
private BiFunction<SimpleCompletionContext, List<String>,
List<AutocompletePopup.CompletionItem>> simpleCompletion;
private SimpleCompletion pendingSimpleCompletion;
private CursorQuickDocProvider cursorQuickDocProvider;
+ private BiFunction<XmlCompletionContext, List<String>,
List<AutocompletePopup.CompletionItem>> xmlCompletion;
+ private XmlCompletion pendingXmlCompletion;
private List<String> routeProblems = List.of();
private List<String> validationErrors;
private int validationErrorScroll;
@@ -438,6 +440,19 @@ class SourceViewer {
this.cursorQuickDocProvider = provider;
}
+ /**
+ * Tab completion of the XML DSL (CAMEL-25240): the elements that go
inside the parent after < or on an empty
+ * line, the attributes in a start tag, their values. Given the context at
the cursor and the lines of the file;
+ * null for a file that is no XML route.
+ */
+ void setXmlCompletion(
+ BiFunction<XmlCompletionContext, List<String>,
List<AutocompletePopup.CompletionItem>> provider) {
+ this.xmlCompletion = provider;
+ }
+
+ private record XmlCompletion(int row, int endCol, XmlCompletionContext
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;
@@ -458,6 +473,7 @@ class SourceViewer {
uriCompletion = null;
simpleCompletion = null;
cursorQuickDocProvider = null;
+ xmlCompletion = null;
}
void reset() {
@@ -501,6 +517,7 @@ class SourceViewer {
uriCompletion = null;
simpleCompletion = null;
cursorQuickDocProvider = null;
+ xmlCompletion = null;
}
boolean isMarkdownMode() {
@@ -1212,13 +1229,16 @@ class SourceViewer {
private void openAutocomplete() {
pendingUriCompletion = null;
pendingSimpleCompletion = null;
+ pendingXmlCompletion = null;
if (openSimpleAutocomplete()) {
return;
}
if (isCamelYamlFile()) {
openYamlAutocomplete();
} else if (uriCompletion != null) {
- openUriAutocomplete();
+ if (!openUriAutocomplete() && "xml".equals(uriCompletion)) {
+ openXmlAutocomplete();
+ }
} else {
openPropertiesAutocomplete();
}
@@ -1228,14 +1248,14 @@ class SourceViewer {
* The completion of the endpoint uri the cursor is in, in a Java or XML
route: the component before the colon, an
* option after ? or &, its value after =. Read from the line alone, so it
works while the file does not parse.
*/
- private void openUriAutocomplete() {
+ private boolean openUriAutocomplete() {
int row = editState.cursorRow();
int col = editState.cursorCol();
String line = editState.getLine(row);
EndpointUriContext c = "xml".equals(uriCompletion)
? EndpointUriContext.inXml(line, col) :
EndpointUriContext.inJava(line, col);
if (c == null || !c.isEndpoint() || autocompleteProvider == null) {
- return;
+ return false;
}
String before = c.before();
int colon = before.indexOf(':');
@@ -1251,14 +1271,14 @@ class SourceViewer {
int q = before.indexOf('?');
if (q < 0) {
// the path of the uri: what goes there is the component's own
- return;
+ return true;
}
int sep = Math.max(before.lastIndexOf('?'),
before.lastIndexOf('&'));
String segment = before.substring(sep + 1);
int eq = segment.indexOf('=');
if (eq >= 0) {
if (autocompleteValueProvider == null) {
- return;
+ return true;
}
items = autocompleteValueProvider.provide("yaml:" + scheme +
":" + segment.substring(0, eq));
prefix = segment.substring(eq + 1);
@@ -1283,6 +1303,7 @@ class SourceViewer {
}
pendingUriCompletion = new UriCompletion(row, col, prefix, suffix);
}
+ return true;
}
/**
@@ -1374,6 +1395,86 @@ class SourceViewer {
return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c ==
'.' || c == ':';
}
+ /**
+ * The completion of the XML DSL at the cursor, read from the text above
it like the uris: the elements after <
+ * or on an empty line, the attributes in a start tag, the values in an
attribute.
+ */
+ private void openXmlAutocomplete() {
+ if (xmlCompletion == 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));
+ }
+ XmlCompletionContext c = XmlCompletionContext.at(all, row, col);
+ if (c == null) {
+ return;
+ }
+ List<AutocompletePopup.CompletionItem> items = xmlCompletion.apply(c,
all);
+ if (items != null && !items.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(items, c.prefix(),
c.prefix(), false);
+ autocompletePopup.setFullKeys(true);
+ autocompletePopup.setTitlePrefix(switch (c.kind()) {
+ case ELEMENT -> c.parent() != null ? "Inside " + c.parent() :
"Elements";
+ case ATTRIBUTE -> c.element() + " attributes";
+ case VALUE -> c.attribute();
+ });
+ pendingXmlCompletion = new XmlCompletion(row, col, c);
+ }
+ }
+
+ /**
+ * Replaces the prefix the XML completion was opened on with the chosen
element, attribute or value, and puts the
+ * cursor where the snippet says (in the first required attribute, inside
the new element, in the quotes of the new
+ * attribute, whose values open right away).
+ */
+ private void insertXmlCompletion(AutocompletePopup.CompletionItem item) {
+ XmlCompletion xc = pendingXmlCompletion;
+ pendingXmlCompletion = null;
+ if (editState.cursorRow() != xc.row()) {
+ return;
+ }
+ XmlCompletionContext c = xc.context();
+ int col = editState.cursorCol();
+ for (; col < xc.endCol(); col++) {
+ editState.moveCursorRight();
+ }
+ for (; col > xc.endCol(); col--) {
+ editState.moveCursorLeft();
+ }
+ for (int i = 0; i < c.prefix().length(); i++) {
+ editState.deleteBackward();
+ }
+ // Tab in the middle of a name replaces all of it
+ String line = editState.getLine(xc.row());
+ int from = editState.cursorCol();
+ int end = from;
+ boolean value = c.kind() == XmlCompletionContext.Kind.VALUE;
+ while (end < line.length() && (value
+ ? line.charAt(end) != '"' && line.charAt(end) != '\''
+ : Character.isLetterOrDigit(line.charAt(end)) ||
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);
+ }
+ editState.insert(text);
+ for (int i = caret >= 0 ? text.length() - caret : 0; i > 0; i--) {
+ editState.moveCursorLeft();
+ }
+ if (c.kind() == XmlCompletionContext.Kind.ATTRIBUTE) {
+ openAutocomplete();
+ }
+ }
+
/** 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;
@@ -1588,6 +1689,10 @@ class SourceViewer {
insertSimpleCompletion(item);
return;
}
+ if (pendingXmlCompletion != null) {
+ insertXmlCompletion(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/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionContext.java
new file mode 100644
index 000000000000..69ef61b5adfa
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionContext.java
@@ -0,0 +1,214 @@
+/*
+ * 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;
+
+/**
+ * Where the cursor is in an XML route, read from the text above it so it
works while the file does not parse
+ * (CAMEL-25240): the elements open around it (routes, route, choice...), and
whether an element name, an attribute name
+ * or an attribute value goes at the cursor.
+ *
+ * @param kind what goes at the cursor
+ * @param path the elements open around the cursor, the outermost first,
without namespace prefixes
+ * @param element the element whose start tag the cursor is in (attribute
kinds), else null
+ * @param attribute the attribute whose value the cursor is in (value kind),
else null
+ * @param prefix the text before the cursor that the completion replaces
+ * @param given the attributes the start tag has already (attribute name
kind)
+ * @param open whether the < of a new element is there already (element
kind)
+ * @param ns the namespace prefix typed with the new element (camel: of
camel:to), else empty
+ * @param siblings the elements the parent has already above the cursor
(element kind), such as the simple of a when
+ */
+record XmlCompletionContext(
+ Kind kind, List<String> path, String element, String attribute, String
prefix, List<String> given,
+ boolean open, String ns, List<String> siblings) {
+
+ enum Kind {
+ ELEMENT,
+ ATTRIBUTE,
+ VALUE
+ }
+
+ /** The context at the cursor, or null when nothing completes there (text,
comments, end tags). */
+ static XmlCompletionContext 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 = sb.toString();
+
+ List<String> path = new ArrayList<>();
+ // the children seen so far of each open element, and of the top level
+ List<List<String>> kids = new ArrayList<>();
+ kids.add(new ArrayList<>());
+ int textStart = 0;
+ int i = 0;
+ int n = text.length();
+ while (i < n) {
+ char c = text.charAt(i);
+ if (c != '<') {
+ i++;
+ continue;
+ }
+ if (text.startsWith("<!--", i)) {
+ int end = text.indexOf("-->", i + 4);
+ if (end < 0) {
+ return null;
+ }
+ i = end + 3;
+ textStart = i;
+ continue;
+ }
+ if (text.startsWith("<![CDATA[", i)) {
+ int end = text.indexOf("]]>", i + 9);
+ if (end < 0) {
+ return null;
+ }
+ i = end + 3;
+ textStart = i;
+ continue;
+ }
+ if (text.startsWith("<?", i) || text.startsWith("<!", i)) {
+ int end = text.indexOf('>', i + 2);
+ if (end < 0) {
+ return null;
+ }
+ i = end + 1;
+ textStart = i;
+ continue;
+ }
+ if (text.startsWith("</", i)) {
+ int end = text.indexOf('>', i + 2);
+ if (end < 0) {
+ // typing an end tag
+ return null;
+ }
+ String name = localName(text.substring(i + 2, end).strip());
+ int at = path.lastIndexOf(name);
+ if (at >= 0) {
+ while (path.size() > at) {
+ path.remove(path.size() - 1);
+ kids.remove(kids.size() - 1);
+ }
+ }
+ i = end + 1;
+ textStart = i;
+ continue;
+ }
+ // a start tag: its name, then its attributes up to > or />, a >
in a quoted value not ending it
+ int nameEnd = i + 1;
+ while (nameEnd < n && isNameChar(text.charAt(nameEnd))) {
+ nameEnd++;
+ }
+ String qname = text.substring(i + 1, nameEnd);
+ if (nameEnd == n) {
+ // typing the name of a new element
+ int colon = qname.indexOf(':');
+ return new XmlCompletionContext(
+ Kind.ELEMENT, path, null, null, qname.substring(colon
+ 1), List.of(),
+ true, qname.substring(0, colon + 1),
kids.get(kids.size() - 1));
+ }
+ List<String> given = new ArrayList<>();
+ int j = nameEnd;
+ char quote = 0;
+ int valueStart = -1;
+ String attribute = null;
+ int attrStart = -1;
+ boolean closed = false;
+ boolean selfClosing = false;
+ while (j < n) {
+ char d = text.charAt(j);
+ if (quote != 0) {
+ if (d == quote) {
+ quote = 0;
+ valueStart = -1;
+ }
+ } else if (d == '"' || d == '\'') {
+ quote = d;
+ valueStart = j + 1;
+ } else if (d == '>') {
+ closed = true;
+ selfClosing = text.charAt(j - 1) == '/';
+ break;
+ } else if (Character.isWhitespace(d)) {
+ attrStart = -1;
+ } else if (d == '=') {
+ if (attrStart >= 0) {
+ attribute = text.substring(attrStart, j);
+ given.add(attribute);
+ }
+ attrStart = -1;
+ } else if (attrStart < 0 && isNameChar(d)) {
+ attrStart = j;
+ }
+ j++;
+ }
+ String name = localName(qname);
+ if (!closed) {
+ // the cursor is in this start tag
+ if (quote != 0) {
+ return new XmlCompletionContext(
+ Kind.VALUE, path, name, attribute,
text.substring(valueStart), given, true, "", List.of());
+ }
+ String typed = attrStart >= 0 ? text.substring(attrStart) : "";
+ if (attrStart < 0 && !Character.isWhitespace(text.charAt(n -
1))) {
+ // right after a value's closing quote: no attribute goes
there without a space
+ return null;
+ }
+ return new XmlCompletionContext(Kind.ATTRIBUTE, path, name,
null, typed, given, true, "", List.of());
+ }
+ kids.get(kids.size() - 1).add(name);
+ if (!selfClosing) {
+ path.add(name);
+ kids.add(new ArrayList<>());
+ }
+ i = j + 1;
+ textStart = i;
+ }
+ // in the text of an element: a new element goes on a line with
nothing else before the cursor
+ String tail = text.substring(textStart);
+ int nl = tail.lastIndexOf('\n');
+ if (!tail.substring(nl + 1).isBlank()) {
+ return null;
+ }
+ return new XmlCompletionContext(
+ Kind.ELEMENT, path, null, null, "", List.of(), false, "",
kids.get(kids.size() - 1));
+ }
+
+ /** The parent element of what goes at the cursor (element kind), or the
element of the start tag. */
+ String parent() {
+ return path.isEmpty() ? null : path.get(path.size() - 1);
+ }
+
+ private static boolean isNameChar(char c) {
+ return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c ==
'.' || c == ':';
+ }
+
+ private static String localName(String qname) {
+ int colon = qname.indexOf(':');
+ return colon >= 0 ? qname.substring(colon + 1) : qname;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletions.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletions.java
new file mode 100644
index 000000000000..a17acc96787e
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletions.java
@@ -0,0 +1,232 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import org.apache.camel.catalog.CamelCatalog;
+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 XML DSL in the Source editor (CAMEL-25240):
after < the elements that go inside the parent
+ * element, in a start tag its attributes, in an attribute its values. The
structure comes from the camel-xml-io XSD of
+ * the catalog, the values (enums, true/false) from the catalog models of the
EIPs and languages, as YAML values do.
+ */
+final class XmlCompletions {
+
+ /** Where the cursor goes in an inserted text; taken out before it is
inserted. */
+ static final char CARET = '';
+
+ /** The elements an XML routes file starts with. */
+ private static final List<String> ROOTS = List.of(
+ "routes", "route", "routeConfiguration", "routeTemplates",
"templatedRoutes", "rests", "rest", "beans");
+
+ // the schema of the last catalog: reading it once per catalog version
+ private static volatile Schema schema;
+
+ private record Schema(CamelCatalog catalog, XmlSchemaModel model) {
+ }
+
+ private XmlCompletions() {
+ }
+
+ static List<AutocompletePopup.CompletionItem> provide(
+ CamelCatalog catalog, XmlCompletionContext context,
+ Supplier<List<AutocompletePopup.CompletionItem>> placeholders) {
+ if (catalog == null || context == null) {
+ return List.of();
+ }
+ XmlSchemaModel model = model(catalog);
+ if (model == null) {
+ return List.of();
+ }
+ return switch (context.kind()) {
+ case ELEMENT -> elements(catalog, model, context);
+ case ATTRIBUTE -> attributes(catalog, model, context);
+ case VALUE -> values(catalog, context, placeholders);
+ };
+ }
+
+ static XmlSchemaModel model(CamelCatalog catalog) {
+ Schema s = schema;
+ if (s == null || s.catalog() != catalog) {
+ XmlSchemaModel model =
XmlSchemaModel.parse(catalog.xmlIoSchemaAsXml());
+ if (model == null) {
+ return null;
+ }
+ s = new Schema(catalog, model);
+ schema = s;
+ }
+ return s.model();
+ }
+
+ private static List<AutocompletePopup.CompletionItem> elements(
+ CamelCatalog catalog, XmlSchemaModel model, XmlCompletionContext
c) {
+ List<XmlSchemaModel.Child> children = new ArrayList<>();
+ if (c.path().isEmpty()) {
+ for (String root : ROOTS) {
+ if (model.elementType(root) != null) {
+ children.add(new XmlSchemaModel.Child(root,
model.elementType(root), model.elementDoc(root)));
+ }
+ }
+ } else {
+ String type = model.typeOf(c.path());
+ if (type == null) {
+ return List.of();
+ }
+ children.addAll(model.children(type));
+ // one expression per element: once the when has its simple, the
other languages are no choice anymore
+ Set<String> languages = new HashSet<>(catalog.findLanguageNames());
+ // the generic expression elements, besides the languages by name
+ languages.add("language");
+ languages.add("expressionDefinition");
+ if (c.siblings().stream().anyMatch(languages::contains)) {
+ children.removeIf(child -> languages.contains(child.name()));
+ }
+ }
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (XmlSchemaModel.Child child : children) {
+ items.add(new AutocompletePopup.CompletionItem(
+ child.name(), child.doc(), null, null, false, null, null,
false,
+ elementSnippet(catalog, model, child, c.open(), c.ns())));
+ }
+ return items;
+ }
+
+ /**
+ * The text of a new element: its required attributes, and its end tag or
/> when nothing goes inside it, the cursor
+ * in the first required attribute, else inside the element: <to
uri="|"/>, <split>|</split>.
+ */
+ static String elementSnippet(
+ CamelCatalog catalog, XmlSchemaModel model, XmlSchemaModel.Child
child, boolean open, String ns) {
+ StringBuilder sb = new StringBuilder(open ? "" : "<" + ns);
+ sb.append(child.name());
+ boolean caret = false;
+ if (child.type() != null) {
+ for (XmlSchemaModel.Attribute a : attributes(catalog, model,
child.name(), child.type())) {
+ if (a.required()) {
+ sb.append(' ').append(a.name()).append("=\"");
+ if (!caret) {
+ sb.append(CARET);
+ caret = true;
+ }
+ sb.append('"');
+ }
+ }
+ }
+ boolean empty = child.type() != null &&
model.children(child.type()).isEmpty()
+ && !model.hasText(child.type());
+ if (empty) {
+ sb.append("/>");
+ if (!caret) {
+ sb.append(CARET);
+ }
+ } else {
+ sb.append('>');
+ if (!caret) {
+ sb.append(CARET);
+ }
+ sb.append("</").append(ns).append(child.name()).append('>');
+ }
+ return sb.toString();
+ }
+
+ private static List<AutocompletePopup.CompletionItem> attributes(
+ CamelCatalog catalog, XmlSchemaModel model, XmlCompletionContext
c) {
+ List<String> path = new ArrayList<>(c.path());
+ path.add(c.element());
+ String type = model.typeOf(path);
+ if (type == null) {
+ return List.of();
+ }
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (XmlSchemaModel.Attribute a : attributes(catalog, model,
c.element(), type)) {
+ if (c.given().contains(a.name())) {
+ continue;
+ }
+ items.add(new AutocompletePopup.CompletionItem(
+ a.name(), a.doc(), null, null, false, null, null,
a.required(),
+ a.name() + "=\"" + CARET + "\""));
+ }
+ // the required ones first, then as the schema has them (the element's
own before id, description...)
+ items.sort((x, y) -> Boolean.compare(y.required(), x.required()));
+ return items;
+ }
+
+ /**
+ * The attributes of an element, the schema's completed by the catalog
model of the EIP or language: the schema
+ * leaves out required and the documentation of some inherited ones (uri
of to, toD, wireTap).
+ */
+ static List<XmlSchemaModel.Attribute> attributes(
+ CamelCatalog catalog, XmlSchemaModel model, String element, String
type) {
+ List<XmlSchemaModel.Attribute> found = new ArrayList<>();
+ List<? extends BaseOptionModel> options = options(catalog, element);
+ for (XmlSchemaModel.Attribute a : model.attributes(type)) {
+ BaseOptionModel option = options.stream().filter(o ->
o.getName().equals(a.name())).findFirst().orElse(null);
+ if (option == null) {
+ found.add(a);
+ } else {
+ found.add(new XmlSchemaModel.Attribute(
+ a.name(), a.required() || option.isRequired(), a.doc()
!= null ? a.doc() : option.getDescription()));
+ }
+ }
+ return found;
+ }
+
+ /** The options of the EIP, else of the language, of an element. */
+ private static List<? extends BaseOptionModel> options(CamelCatalog
catalog, String element) {
+ EipModel eip = catalog.eipModel(element);
+ if (eip != null) {
+ return eip.getOptions();
+ }
+ LanguageModel language = catalog.languageModel(element);
+ return language != null ? language.getOptions() : List.of();
+ }
+
+ /** The values of an attribute: the enums or true/false of the option it
sets, and the property placeholders. */
+ private static List<AutocompletePopup.CompletionItem> values(
+ CamelCatalog catalog, XmlCompletionContext c,
Supplier<List<AutocompletePopup.CompletionItem>> placeholders) {
+ BaseOptionModel option = options(catalog, c.element()).stream()
+ .filter(o ->
o.getName().equals(c.attribute())).findFirst().orElse(null);
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ if (option != null) {
+ if (option.getEnums() != null && !option.getEnums().isEmpty()) {
+ for (String value : option.getEnums()) {
+ items.add(new AutocompletePopup.CompletionItem(
+ value, option.getDescription(), option.getType(),
option.getDefaultValue(), false, null,
+ option.getGroup()));
+ }
+ } else if ("boolean".equals(option.getType())) {
+ for (String value : List.of("true", "false")) {
+ items.add(new AutocompletePopup.CompletionItem(
+ value, option.getDescription(), "boolean",
option.getDefaultValue(), false, null,
+ option.getGroup()));
+ }
+ }
+ }
+ if (placeholders != null) {
+ items.addAll(placeholders.get());
+ }
+ return items;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlSchemaModel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlSchemaModel.java
new file mode 100644
index 000000000000..047b6ea0a764
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlSchemaModel.java
@@ -0,0 +1,255 @@
+/*
+ * 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.io.StringReader;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import org.xml.sax.InputSource;
+
+import org.apache.camel.dsl.jbang.core.common.XmlHelper;
+
+/**
+ * The elements and attributes of the XML DSL, read from the camel-xml-io XSD
of the catalog (CAMEL-25240): which
+ * elements go inside an element (each type lists them, outputs and expression
languages included), and its attributes
+ * with whether they are required and their documentation, inherited ones (id,
description...) included.
+ */
+final class XmlSchemaModel {
+
+ private static final String XS = "http://www.w3.org/2001/XMLSchema";
+
+ /** An attribute of an element. */
+ record Attribute(String name, boolean required, String doc) {
+ }
+
+ /** An element that can go inside another one, with its type (to go
further down) and documentation. */
+ record Child(String name, String type, String doc) {
+ }
+
+ private record Type(String base, List<Attribute> attributes, List<Child>
children, boolean text) {
+ }
+
+ private final Map<String, String> elementTypes = new LinkedHashMap<>();
+ private final Map<String, String> elementDocs = new HashMap<>();
+ private final Map<String, Type> types = new HashMap<>();
+
+ private XmlSchemaModel() {
+ }
+
+ /** Reads the schema; null when it cannot be read. */
+ static XmlSchemaModel parse(String xsd) {
+ if (xsd == null || xsd.isEmpty()) {
+ return null;
+ }
+ try {
+ DocumentBuilderFactory dbf =
XmlHelper.createDocumentBuilderFactory();
+ dbf.setNamespaceAware(true);
+ DocumentBuilder builder = dbf.newDocumentBuilder();
+ Element root = builder.parse(new InputSource(new
StringReader(xsd))).getDocumentElement();
+ XmlSchemaModel model = new XmlSchemaModel();
+ // the top-level elements first: the children that refer to them
take their type and documentation
+ for (Element e : children(root, "element")) {
+ model.elementTypes.put(e.getAttribute("name"),
localName(e.getAttribute("type")));
+ model.elementDocs.put(e.getAttribute("name"), doc(e));
+ }
+ for (Element ct : children(root, "complexType")) {
+ model.types.put(ct.getAttribute("name"),
model.readType(ct.getAttribute("name"), ct));
+ }
+ return model;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private Type readType(String name, Element complexType) {
+ String base = null;
+ boolean text = false;
+ List<Attribute> attributes = new ArrayList<>();
+ List<Child> children = new ArrayList<>();
+ Element content = complexType;
+ for (Element c : children(complexType, null)) {
+ if ("complexContent".equals(c.getLocalName()) ||
"simpleContent".equals(c.getLocalName())) {
+ text = "simpleContent".equals(c.getLocalName());
+ for (Element ext : children(c, "extension")) {
+ String b = localName(ext.getAttribute("base"));
+ // xs:string of simpleContent is the text of the element,
not a type of the schema
+ base = ext.getAttribute("base").startsWith("xs:") ? null :
b;
+ content = ext;
+ }
+ }
+ }
+ collect(name, content, attributes, children);
+ return new Type(base, attributes, children, text);
+ }
+
+ /**
+ * The attributes and child elements under a type's content, through its
sequences and choices. A local element with
+ * its type inline (allowableValues of param) gets that type under the
name owner/element.
+ */
+ private void collect(String owner, Element parent, List<Attribute>
attributes, List<Child> children) {
+ for (Element c : children(parent, null)) {
+ switch (c.getLocalName()) {
+ case "attribute" -> attributes.add(new Attribute(
+ c.getAttribute("name"),
"required".equals(c.getAttribute("use")), doc(c)));
+ case "element" -> {
+ if (c.hasAttribute("ref")) {
+ String name = localName(c.getAttribute("ref"));
+ children.add(new Child(name, elementTypes.get(name),
elementDocs.get(name)));
+ } else if (c.hasAttribute("type")) {
+ children.add(new Child(c.getAttribute("name"),
localName(c.getAttribute("type")), doc(c)));
+ } else {
+ String inline = owner + "/" + c.getAttribute("name");
+ for (Element ct : children(c, "complexType")) {
+ types.put(inline, readType(inline, ct));
+ }
+ children.add(new Child(c.getAttribute("name"),
types.containsKey(inline) ? inline : null, doc(c)));
+ }
+ }
+ case "sequence", "choice" -> collect(owner, c, attributes,
children);
+ default -> {
+ }
+ }
+ }
+ }
+
+ /** The type of a top-level element, null when there is none. */
+ String elementType(String element) {
+ return elementTypes.get(element);
+ }
+
+ /** The documentation of a top-level element. */
+ String elementDoc(String element) {
+ return elementDocs.get(element);
+ }
+
+ /** The top-level elements, in schema order. */
+ List<String> elements() {
+ return new ArrayList<>(elementTypes.keySet());
+ }
+
+ /**
+ * The type of the innermost element of a path from the outermost one
(routes, route, choice, when): each one is
+ * looked up among the children of the one before it, else as a top-level
element, so a path that starts in a Spring
+ * XML file or another wrapper still finds its Camel elements.
+ */
+ String typeOf(List<String> path) {
+ String type = null;
+ for (String name : path) {
+ String next = null;
+ if (type != null) {
+ for (Child c : children(type)) {
+ if (c.name().equals(name)) {
+ next = c.type();
+ break;
+ }
+ }
+ }
+ type = next != null ? next : elementTypes.get(name);
+ }
+ return type;
+ }
+
+ /** The elements that go inside one of the type, inherited ones first,
without repeats. */
+ List<Child> children(String type) {
+ Map<String, Child> found = new LinkedHashMap<>();
+ for (Type t : hierarchy(type)) {
+ for (Child c : t.children()) {
+ found.putIfAbsent(c.name(), c);
+ }
+ }
+ return new ArrayList<>(found.values());
+ }
+
+ /** The attributes of the type, its own first, then the inherited ones
(id, description...). */
+ List<Attribute> attributes(String type) {
+ Map<String, Attribute> found = new LinkedHashMap<>();
+ List<Type> chain = hierarchy(type);
+ for (int i = chain.size() - 1; i >= 0; i--) {
+ for (Attribute a : chain.get(i).attributes()) {
+ found.putIfAbsent(a.name(), a);
+ }
+ }
+ return new ArrayList<>(found.values());
+ }
+
+ /** Whether the element of the type has text, such as the expression of
simple. */
+ boolean hasText(String type) {
+ for (Type t : hierarchy(type)) {
+ if (t.text()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** The type and its base types, the outermost base first. */
+ private List<Type> hierarchy(String type) {
+ List<Type> chain = new ArrayList<>();
+ String name = type;
+ while (name != null && chain.size() < 20) {
+ Type t = types.get(name);
+ if (t == null) {
+ break;
+ }
+ chain.add(0, t);
+ name = t.base();
+ }
+ return chain;
+ }
+
+ private static List<Element> children(Element parent, String localName) {
+ List<Element> found = new ArrayList<>();
+ NodeList nodes = parent.getChildNodes();
+ for (int i = 0; i < nodes.getLength(); i++) {
+ Node n = nodes.item(i);
+ if (n instanceof Element e && XS.equals(e.getNamespaceURI())
+ && (localName == null ||
localName.equals(e.getLocalName()))) {
+ found.add(e);
+ }
+ }
+ return found;
+ }
+
+ private static String doc(Element e) {
+ for (Element annotation : children(e, "annotation")) {
+ for (Element d : children(annotation, "documentation")) {
+ String text = d.getTextContent().strip().replaceAll("\\s+", "
");
+ return text.isEmpty() ? null : text;
+ }
+ }
+ return null;
+ }
+
+ private static String localName(String qname) {
+ if (qname == null || qname.isEmpty()) {
+ return null;
+ }
+ int colon = qname.indexOf(':');
+ return colon >= 0 ? qname.substring(colon + 1) : qname;
+ }
+}
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 6169e2386e74..92ca88aec00c 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,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 `=`
+**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`,
+ the languages where an expression goes); the chosen one is inserted with its
+ required attributes and its end tag (`<to uri=""/>`, `<split></split>`)
+- In a start tag, Tab lists the element's attributes, the required ones first,
+ without the ones already given; in an attribute value, its values (enums,
+ `true`/`false`, `{{placeholders}}`)
+- The structure and documentation come from the XML schema of the catalog
+
**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
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionTest.java
new file mode 100644
index 000000000000..b71f6721afce
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/XmlCompletionTest.java
@@ -0,0 +1,292 @@
+/*
+ * 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.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 XML DSL in the Source editor (CAMEL-25240).
+ */
+class XmlCompletionTest {
+
+ private static CamelCatalog catalog;
+ private static XmlSchemaModel model;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeAll
+ static void loadSchema() {
+ catalog = new DefaultCamelCatalog();
+ model = XmlCompletions.model(catalog);
+ }
+
+ @BeforeEach
+ void setUp() {
+ Theme.resetForTesting();
+ }
+
+ private static List<String> names(List<XmlSchemaModel.Child> children) {
+ return children.stream().map(XmlSchemaModel.Child::name).toList();
+ }
+
+ @Test
+ void theSchemaHasTheStructure() {
+ assertThat(model).isNotNull();
+
assertThat(names(model.children(model.elementType("route")))).contains("from",
"to", "setBody", "choice");
+
assertThat(names(model.children(model.elementType("choice")))).contains("when",
"otherwise");
+ // an expression goes inside setBody
+
assertThat(names(model.children(model.elementType("setBody")))).contains("simple",
"constant");
+ assertThat(model.hasText(model.elementType("simple"))).isTrue();
+ assertThat(model.children(model.elementType("to"))).isEmpty();
+
+ // uri of to is required and documented in the catalog model, not in
the schema
+ List<XmlSchemaModel.Attribute> to = XmlCompletions.attributes(catalog,
model, "to", model.elementType("to"));
+ assertThat(to).anyMatch(a -> a.name().equals("uri") && a.required() &&
a.doc() != null);
+ // inherited from the base types too (uri from sendDefinition, id and
description from the processors)
+
assertThat(to.stream().map(XmlSchemaModel.Attribute::name).toList()).contains("variableSend",
"uri", "id",
+ "description");
+ }
+
+ @Test
+ void anElementWithItsTypeInline() {
+ // allowableValues of a rest param has no named type in the schema
+ String type = model.typeOf(List.of("rests", "rest", "get", "param",
"allowableValues"));
+ assertThat(type).isNotNull();
+ assertThat(names(model.children(type))).containsExactly("value");
+ }
+
+ @Test
+ void theElementsOpenAroundTheCursor() {
+ List<String> lines = List.of(
+ "<?xml version=\"1.0\"?>",
+ "<!-- <route> in a comment is not open -->",
+ "<camel:routes
xmlns:camel=\"http://camel.apache.org/schema/xml-io\">",
+ " <route id=\"a\">",
+ " <from uri=\"timer:x?a=1&b=2\"/>",
+ " <choice>",
+ " <when><simple><![CDATA[${body} > 1]]></simple>",
+ " <log message=\"a > b\"/>",
+ " </when>",
+ " ");
+ XmlCompletionContext c = XmlCompletionContext.at(lines, 9, 6);
+ assertThat(c.kind()).isEqualTo(XmlCompletionContext.Kind.ELEMENT);
+ assertThat(c.path()).containsExactly("routes", "route", "choice");
+ assertThat(c.open()).isFalse();
+
assertThat(model.typeOf(c.path())).isEqualTo(model.elementType("choice"));
+ // the when above is closed: a sibling, not open
+ assertThat(c.siblings()).containsExactly("when");
+ }
+
+ @Test
+ void theLanguagesGoOnceTheElementHasItsExpression() {
+ List<String> before = keys(at("<route>", " <choice>", " <when>", "
"));
+ assertThat(before).contains("simple", "to", "log");
+ List<String> after = keys(at("<route>", " <choice>", " <when>", "
<simple>${body}</simple>", " "));
+ assertThat(after).contains("to", "log").doesNotContain("simple",
"tokenize", "language");
+ }
+
+ @Test
+ void anElementAttributeOrValueAtTheCursor() {
+ XmlCompletionContext c = at("<routes>", " <route>", "
<camel:setB");
+ assertThat(c.kind()).isEqualTo(XmlCompletionContext.Kind.ELEMENT);
+ assertThat(c.prefix()).isEqualTo("setB");
+ assertThat(c.ns()).isEqualTo("camel:");
+ assertThat(c.open()).isTrue();
+
+ // a start tag over two lines
+ c = at("<route>", " <log message=\"hi\"", " logg");
+ assertThat(c.kind()).isEqualTo(XmlCompletionContext.Kind.ATTRIBUTE);
+ assertThat(c.element()).isEqualTo("log");
+ assertThat(c.prefix()).isEqualTo("logg");
+ assertThat(c.given()).containsExactly("message");
+
+ c = at("<route>", " <log loggingLevel=\"WA");
+ assertThat(c.kind()).isEqualTo(XmlCompletionContext.Kind.VALUE);
+ assertThat(c.attribute()).isEqualTo("loggingLevel");
+ assertThat(c.prefix()).isEqualTo("WA");
+
+ // nothing in text, a comment, an end tag, or right after a value
+ assertThat(at("<route>", " <log message=\"x\"/> hello")).isNull();
+ assertThat(at("<route>", " <!-- <to ")).isNull();
+ assertThat(at("<route>", " </rou")).isNull();
+ assertThat(at("<route>", " <log message=\"x\"")).isNull();
+ }
+
+ private static XmlCompletionContext at(String... lines) {
+ int row = lines.length - 1;
+ return XmlCompletionContext.at(List.of(lines), row,
lines[row].length());
+ }
+
+ @Test
+ void snippets() {
+ assertThat(snippet("to", false)).isEqualTo("<to uri=\"|\"/>");
+ assertThat(snippet("split", true)).isEqualTo("split>|</split>");
+ assertThat(snippet("simple", false)).isEqualTo("<simple>|</simple>");
+ assertThat(snippet("stop", false)).isEqualTo("<stop/>|");
+ assertThat(XmlCompletions.elementSnippet(catalog, model, child("to"),
true, "camel:")).contains("/>");
+ assertThat(XmlCompletions.elementSnippet(catalog, model,
child("split"), false, "camel:"))
+ .isEqualTo("<camel:split>" + XmlCompletions.CARET +
"</camel:split>");
+ }
+
+ private static String snippet(String name, boolean open) {
+ return XmlCompletions.elementSnippet(catalog, model, child(name),
open, "").replace(XmlCompletions.CARET, '|');
+ }
+
+ private static XmlSchemaModel.Child child(String name) {
+ return new XmlSchemaModel.Child(name, model.elementType(name),
model.elementDoc(name));
+ }
+
+ @Test
+ void valuesOfAnAttribute() {
+ List<String> levels = keys(at("<route>", " <log loggingLevel=\""));
+ assertThat(levels).contains("INFO", "WARN", "ERROR");
+ assertThat(keys(at("<route>", " <split
parallelProcessing=\""))).containsExactly("true", "false");
+ // an option of a language
+ assertThat(keys(at("<route>", " <setBody>", " <simple
trim=\""))).containsExactly("true", "false");
+ }
+
+ private static List<String> keys(XmlCompletionContext c) {
+ return XmlCompletions.provide(catalog, c,
List::of).stream().map(AutocompletePopup.CompletionItem::key)
+ .toList();
+ }
+
+ @Test
+ void anElementThenALanguageInIt() throws Exception {
+ SourceViewer viewer = viewer("""
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ \s
+ </route>
+ </routes>
+ """);
+ cursorAt(viewer, 3, "");
+ tab(viewer);
+ type(viewer, "setBody");
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo(" <setBody></setBody>");
+
+ // the cursor is inside the new element: the languages go there
+ tab(viewer);
+ type(viewer, "simple");
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo("
<setBody><simple></simple></setBody>");
+ type(viewer, "Hello");
+ assertThat(line(viewer, 3)).isEqualTo("
<setBody><simple>Hello</simple></setBody>");
+ }
+
+ @Test
+ void anAttributeThenItsValue() throws Exception {
+ SourceViewer viewer = viewer("""
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ <log message="hi" logg/>
+ </route>
+ </routes>
+ """);
+ cursorAt(viewer, 3, "/>");
+ tab(viewer);
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo(" <log message=\"hi\"
loggingLevel=\"\"/>");
+
+ // the values open right away in the quotes
+ type(viewer, "WA");
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo(" <log message=\"hi\"
loggingLevel=\"WARN\"/>");
+ }
+
+ @Test
+ void theUriAttributeStaysWithTheUriCompletion() throws Exception {
+ SourceViewer viewer = viewer("""
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route>
+ <from uri="timer:tick"/>
+ <to uri="sed"/>
+ </route>
+ </routes>
+ """);
+ cursorAt(viewer, 3, "\"/>");
+ tab(viewer);
+ type(viewer, "a");
+ enter(viewer);
+ assertThat(line(viewer, 3)).isEqualTo(" <to uri=\"seda:\"/>");
+ }
+
+ private SourceViewer viewer(String src) throws Exception {
+ Path file = tempDir.resolve("routes.xml");
+ 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("xml");
+ viewer.setSimpleCompletion(assist::provideSimpleCompletions);
+ viewer.setXmlCompletion((c, lines) -> XmlCompletions.provide(catalog,
c, List::of));
+ 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];
+ }
+}