This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-25240
in repository https://gitbox.apache.org/repos/asf/camel.git

commit fea10bfbd4bfc4e7eeac019e0acac42b42a40935
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 23:07:30 2026 +0200

    CAMEL-25240: camel-jbang - Tab completes XML DSL elements, attributes and 
values in the source editor
    
    From the XML schema the catalog serves for the project's Camel version
    (xmlIoSchemaAsXml), read once per catalog:
    - after < or on an empty line: the elements that go inside the parent 
element,
      inserted with their required attributes and end tag (<to uri=""/>,
      <split></split>), the cursor where the user goes on; once an element has 
its
      expression, the other languages are left out;
    - in a start tag: the attributes of the element, required ones first, 
without
      the ones given;
    - in an attribute: its values (enums, true/false from the catalog models,
      property placeholders), opened right after an attribute is chosen.
    The schema leaves out required and the documentation of some inherited
    attributes (uri of to), so the catalog EIP/language model completes them.
    
    The context is read from the text above the cursor (open elements, comments,
    CDATA, self-closing tags, start tags over lines, namespace prefixes), so it
    works while the file does not parse. The uri attribute stays with the uri
    completion.
    
    Help, doc page and a screenshot updated.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[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 a775c0c57404..c01f34755669 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 062f631d5e11..e87e14bd2828 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 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 05a641c5e5d1..292ed662c719 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
@@ -848,6 +848,8 @@ class SourceTab extends AbstractTab {
         // simple expressions in every route file: YAML, Java and XML 
(CAMEL-25219)
         sourceViewer.setSimpleCompletion(
                 routeFile || isCamelSourceFile(filePath) ? 
assist::provideSimpleCompletions : 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 229f7d4f56f3..08aad73a0d22 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
@@ -217,6 +217,8 @@ class SourceViewer {
     private UriCompletion pendingUriCompletion;
     private BiFunction<SimpleCompletionContext, List<String>, 
List<AutocompletePopup.CompletionItem>> simpleCompletion;
     private SimpleCompletion pendingSimpleCompletion;
+    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;
@@ -423,6 +425,19 @@ class SourceViewer {
     private record SimpleCompletion(int row, int endCol, 
SimpleCompletionContext context) {
     }
 
+    /**
+     * Tab completion of the XML DSL (CAMEL-25240): the elements that go 
inside the parent after &lt; 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;
@@ -442,6 +457,7 @@ class SourceViewer {
         routeValidator = null;
         uriCompletion = null;
         simpleCompletion = null;
+        xmlCompletion = null;
     }
 
     void reset() {
@@ -484,6 +500,7 @@ class SourceViewer {
         routeValidator = null;
         uriCompletion = null;
         simpleCompletion = null;
+        xmlCompletion = null;
     }
 
     boolean isMarkdownMode() {
@@ -1195,13 +1212,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();
         }
@@ -1211,14 +1231,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(':');
@@ -1234,14 +1254,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);
@@ -1266,6 +1286,7 @@ class SourceViewer {
             }
             pendingUriCompletion = new UriCompletion(row, col, prefix, suffix);
         }
+        return true;
     }
 
     /**
@@ -1357,6 +1378,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 &lt;
+     * 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;
@@ -1571,6 +1672,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: &lt;to 
uri="|"/&gt;, &lt;split&gt;|&lt;/split&gt;.
+     */
+    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 4e39454073f4..0611160a3957 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 `&` (`&amp;` 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&amp;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];
+    }
+}

Reply via email to