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 &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;
@@ -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 &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;
@@ -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: &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 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 `&` (`&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