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

paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 684f1900a9 GROOVY-11927: Add toMap() and typed parsing support to 
XmlParser
684f1900a9 is described below

commit 684f1900a949fc9026309d354e9092cb7bb8f328
Author: Paul King <[email protected]>
AuthorDate: Mon Apr 13 10:36:43 2026 +1000

    GROOVY-11927: Add toMap() and typed parsing support to XmlParser
---
 src/main/java/groovy/util/Node.java                |  85 +++++++++
 src/test/groovy/groovy/util/NodeToMapTest.groovy   | 119 +++++++++++++
 subprojects/groovy-xml/build.gradle                |   1 +
 .../src/main/java/groovy/xml/XmlParser.java        | 119 ++++++++++++-
 .../main/java/groovy/xml/XmlRuntimeException.java  |  44 +++++
 .../groovy/xml/extensions/XmlExtensions.java       |  24 +++
 .../org/apache/groovy/xml/util/JacksonHelper.java  |  93 ++++++++++
 .../groovy-xml/src/spec/doc/xml-userguide.adoc     | 137 ++++++++++++++
 .../src/spec/test/UserGuideXmlTypedTest.groovy     | 198 +++++++++++++++++++++
 .../groovy/groovy/xml/XmlParserTypedTest.groovy    | 147 +++++++++++++++
 10 files changed, 959 insertions(+), 8 deletions(-)

diff --git a/src/main/java/groovy/util/Node.java 
b/src/main/java/groovy/util/Node.java
index 9d465735a0..4b701099a6 100644
--- a/src/main/java/groovy/util/Node.java
+++ b/src/main/java/groovy/util/Node.java
@@ -38,6 +38,7 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -870,6 +871,90 @@ public class Node implements Serializable, Cloneable {
         return StringGroovyMethods.toBigInteger((CharSequence)text());
     }
 
+    /**
+     * The key used for text content when a node has both attributes and text.
+     *
+     * @since 6.0.0
+     */
+    public static final String TEXT_KEY = "_text";
+
+    /**
+     * Converts this Node tree into a nested {@code Map<String, Object>}.
+     * <p>
+     * Attributes and child elements are merged into a single map keyed by 
name.
+     * If a child element name collides with an attribute name, the child 
element wins.
+     * Leaf child elements (text only, no attributes) become String values.
+     * Non-leaf child elements and leaf elements with attributes become nested 
Maps (recursive).
+     * Repeated same-name sibling elements become Lists.
+     * Elements with both attributes and text content use the {@value 
#TEXT_KEY} key for the text.
+     *
+     * @return a Map representation of this Node
+     * @since 6.0.0
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> toMap() {
+        Map<String, Object> map = new LinkedHashMap<>();
+
+        // copy attributes, tracking which keys came from attributes
+        java.util.Set<String> attributeKeys = new java.util.HashSet<>();
+        for (Map.Entry<?, ?> entry : ((Map<?, ?>) attributes()).entrySet()) {
+            String key = nameAsString(entry.getKey());
+            map.put(key, entry.getValue());
+            attributeKeys.add(key);
+        }
+
+        // process child nodes
+        boolean hasChildElements = false;
+        for (Object child : children()) {
+            if (child instanceof Node childNode) {
+                hasChildElements = true;
+                String key = nameAsString(childNode.name());
+                Object childValue;
+                if (childNode.isLeaf() && childNode.attributes().isEmpty()) {
+                    childValue = childNode.text();
+                } else {
+                    childValue = childNode.toMap();
+                }
+                Object existing = map.get(key);
+                if (existing == null || attributeKeys.remove(key)) {
+                    // first child element with this name (or replacing an 
attribute)
+                    map.put(key, childValue);
+                } else if (existing instanceof List) {
+                    ((List<Object>) existing).add(childValue);
+                } else {
+                    List<Object> list = new ArrayList<>();
+                    list.add(existing);
+                    list.add(childValue);
+                    map.put(key, list);
+                }
+            }
+        }
+
+        // if this node has attributes AND direct text, add _text entry
+        if (!attributes().isEmpty() && !hasChildElements) {
+            String text = text();
+            if (!text.isEmpty()) {
+                map.put(TEXT_KEY, text);
+            }
+        }
+
+        return map;
+    }
+
+    private static String nameAsString(Object name) {
+        if (name instanceof QName qn) {
+            return qn.getLocalPart();
+        }
+        return name.toString();
+    }
+
+    private boolean isLeaf() {
+        for (Object child : children()) {
+            if (child instanceof Node) return false;
+        }
+        return true;
+    }
+
     private boolean textIsEmptyOrNull() {
         String t = text();
         return null == t || t.isEmpty();
diff --git a/src/test/groovy/groovy/util/NodeToMapTest.groovy 
b/src/test/groovy/groovy/util/NodeToMapTest.groovy
new file mode 100644
index 0000000000..40f4ca0238
--- /dev/null
+++ b/src/test/groovy/groovy/util/NodeToMapTest.groovy
@@ -0,0 +1,119 @@
+/*
+ *  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 groovy.util
+
+import org.junit.jupiter.api.Test
+
+class NodeToMapTest {
+
+    @Test
+    void testFlatElements() {
+        def root = new Node(null, 'server', [
+            new Node(null, 'host', 'localhost'),
+            new Node(null, 'port', '8080')
+        ])
+        assert root.toMap() == [host: 'localhost', port: '8080']
+    }
+
+    @Test
+    void testAttributesOnly() {
+        def root = new Node(null, 'server', [host: 'localhost', port: '8080'])
+        assert root.toMap() == [host: 'localhost', port: '8080']
+    }
+
+    @Test
+    void testAttributesAndChildElements() {
+        def root = new Node(null, 'server', [env: 'prod'])
+        new Node(root, 'host', 'localhost')
+        new Node(root, 'port', '8080')
+        assert root.toMap() == [env: 'prod', host: 'localhost', port: '8080']
+    }
+
+    @Test
+    void testChildElementWinsOnCollision() {
+        def root = new Node(null, 'server', [host: 'from-attr'])
+        new Node(root, 'host', 'from-element')
+        def map = root.toMap()
+        assert map.host == 'from-element'
+    }
+
+    @Test
+    void testNestedElements() {
+        def root = new Node(null, 'server')
+        def db = new Node(root, 'database')
+        new Node(db, 'host', 'dbhost')
+        new Node(db, 'port', '5432')
+        assert root.toMap() == [database: [host: 'dbhost', port: '5432']]
+    }
+
+    @Test
+    void testRepeatedElements() {
+        def root = new Node(null, 'server')
+        new Node(root, 'alias', 'a1')
+        new Node(root, 'alias', 'a2')
+        new Node(root, 'alias', 'a3')
+        assert root.toMap() == [alias: ['a1', 'a2', 'a3']]
+    }
+
+    @Test
+    void testRepeatedNestedElements() {
+        def root = new Node(null, 'config')
+        def s1 = new Node(root, 'server', [name: 'web'])
+        new Node(s1, 'port', '80')
+        def s2 = new Node(root, 'server', [name: 'api'])
+        new Node(s2, 'port', '8080')
+        assert root.toMap() == [
+            server: [
+                [name: 'web', port: '80'],
+                [name: 'api', port: '8080']
+            ]
+        ]
+    }
+
+    @Test
+    void testAttributesAndTextContent() {
+        def root = new Node(null, 'price', [currency: 'USD'], '9.99')
+        assert root.toMap() == [currency: 'USD', (Node.TEXT_KEY): '9.99']
+    }
+
+    @Test
+    void testLeafWithAttributesAsChildValue() {
+        def root = new Node(null, 'order')
+        def price = new Node(root, 'price', [currency: 'USD'], '9.99')
+        def map = root.toMap()
+        assert map.price == [currency: 'USD', (Node.TEXT_KEY): '9.99']
+    }
+
+    @Test
+    void testEmptyNode() {
+        def root = new Node(null, 'empty')
+        assert root.toMap() == [:]
+    }
+
+    @Test
+    void testEmptyNodeWithAttributes() {
+        def root = new Node(null, 'empty', [id: '1'])
+        assert root.toMap() == [id: '1']
+    }
+
+    @Test
+    void testTextKeyConstant() {
+        assert Node.TEXT_KEY == '_text'
+    }
+}
diff --git a/subprojects/groovy-xml/build.gradle 
b/subprojects/groovy-xml/build.gradle
index b7a96b8a5b..8b05b0c2c5 100644
--- a/subprojects/groovy-xml/build.gradle
+++ b/subprojects/groovy-xml/build.gradle
@@ -25,6 +25,7 @@ dependencies {
 
     testImplementation projects.groovyTest
     testImplementation "xmlunit:xmlunit:${versions.xmlunit}"
+    testRuntimeOnly 
"com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
     testImplementation("org.spockframework:spock-core:${versions.spock}") {
         exclude group: 'org.apache.groovy'
     }
diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java 
b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
index 6937addc59..047735dd1b 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
@@ -20,6 +20,7 @@ package groovy.xml;
 
 import groovy.namespace.QName;
 import groovy.util.Node;
+import org.apache.groovy.xml.util.JacksonHelper;
 import org.xml.sax.Attributes;
 import org.xml.sax.ContentHandler;
 import org.xml.sax.DTDHandler;
@@ -209,10 +210,12 @@ public class XmlParser implements ContentHandler {
      *                      supplied by the application.
      */
     public Node parse(File file) throws IOException, SAXException {
-        InputSource input = new 
InputSource(Files.newInputStream(file.toPath()));
-        input.setSystemId("file://" + file.getAbsolutePath());
-        getXMLReader().parse(input);
-        return parent;
+        try (InputStream stream = Files.newInputStream(file.toPath())) {
+            InputSource input = new InputSource(stream);
+            input.setSystemId("file://" + file.getAbsolutePath());
+            getXMLReader().parse(input);
+            return parent;
+        }
     }
 
     /**
@@ -228,10 +231,12 @@ public class XmlParser implements ContentHandler {
      *                      supplied by the application.
      */
     public Node parse(Path path) throws IOException, SAXException {
-        InputSource input = new InputSource(Files.newInputStream(path));
-        input.setSystemId("file://" + path.toAbsolutePath());
-        getXMLReader().parse(input);
-        return parent;
+        try (InputStream stream = Files.newInputStream(path)) {
+            InputSource input = new InputSource(stream);
+            input.setSystemId("file://" + path.toAbsolutePath());
+            getXMLReader().parse(input);
+            return parent;
+        }
     }
 
     /**
@@ -322,6 +327,104 @@ public class XmlParser implements ContentHandler {
         return parse(new StringReader(text));
     }
 
+    /**
+     * Parse the content of the specified XML text into a typed object.
+     * Requires jackson-databind on the classpath for type conversion.
+     * Supports {@code @JsonProperty} and {@code @JsonFormat} annotations.
+     *
+     * @param type the target type
+     * @param text the XML text to parse
+     * @param <T>  the target type
+     * @return a typed object
+     * @throws XmlRuntimeException if parsing or conversion fails, or 
jackson-databind is absent
+     * @since 6.0.0
+     */
+    public <T> T parseTextAs(Class<T> type, String text) {
+        return parseAs(type, new StringReader(text));
+    }
+
+    /**
+     * Parse XML from a reader into a typed object.
+     * Requires jackson-databind on the classpath for type conversion.
+     *
+     * @param type   the target type
+     * @param reader the reader of XML
+     * @param <T>    the target type
+     * @return a typed object
+     * @throws XmlRuntimeException if parsing or conversion fails, or 
jackson-databind is absent
+     * @since 6.0.0
+     */
+    public <T> T parseAs(Class<T> type, Reader reader) {
+        try {
+            Node root = parse(reader);
+            return JacksonHelper.convertMapToType(root.toMap(), type);
+        } catch (IOException | SAXException e) {
+            throw new XmlRuntimeException(e);
+        }
+    }
+
+    /**
+     * Parse XML from an input stream into a typed object.
+     * Requires jackson-databind on the classpath for type conversion.
+     *
+     * @param type   the target type
+     * @param stream the input stream of XML
+     * @param <T>    the target type
+     * @return a typed object
+     * @throws XmlRuntimeException if parsing or conversion fails, or 
jackson-databind is absent
+     * @since 6.0.0
+     */
+    public <T> T parseAs(Class<T> type, InputStream stream) {
+        try {
+            Node root = parse(stream);
+            return JacksonHelper.convertMapToType(root.toMap(), type);
+        } catch (IOException | SAXException e) {
+            throw new XmlRuntimeException(e);
+        }
+    }
+
+    /**
+     * Parse XML from a file into a typed object.
+     * Requires jackson-databind on the classpath for type conversion.
+     *
+     * @param type the target type
+     * @param file the XML file
+     * @param <T>  the target type
+     * @return a typed object
+     * @throws IOException if the file cannot be read
+     * @throws XmlRuntimeException if parsing or conversion fails, or 
jackson-databind is absent
+     * @since 6.0.0
+     */
+    public <T> T parseAs(Class<T> type, File file) throws IOException {
+        try {
+            Node root = parse(file);
+            return JacksonHelper.convertMapToType(root.toMap(), type);
+        } catch (SAXException e) {
+            throw new XmlRuntimeException(e);
+        }
+    }
+
+    /**
+     * Parse XML from a path into a typed object.
+     * Requires jackson-databind on the classpath for type conversion.
+     *
+     * @param type the target type
+     * @param path the path to the XML file
+     * @param <T>  the target type
+     * @return a typed object
+     * @throws IOException if the file cannot be read
+     * @throws XmlRuntimeException if parsing or conversion fails, or 
jackson-databind is absent
+     * @since 6.0.0
+     */
+    public <T> T parseAs(Class<T> type, Path path) throws IOException {
+        try {
+            Node root = parse(path);
+            return JacksonHelper.convertMapToType(root.toMap(), type);
+        } catch (SAXException e) {
+            throw new XmlRuntimeException(e);
+        }
+    }
+
     /**
      * Determine if namespace handling is enabled.
      *
diff --git 
a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java 
b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java
new file mode 100644
index 0000000000..2ece5b7d01
--- /dev/null
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java
@@ -0,0 +1,44 @@
+/*
+ *  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 groovy.xml;
+
+import groovy.lang.GroovyRuntimeException;
+import org.apache.groovy.lang.annotation.Incubating;
+
+/**
+ * Represents a runtime exception that occurred when parsing or building XML.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public class XmlRuntimeException extends GroovyRuntimeException {
+    private static final long serialVersionUID = 7858720024082487492L;
+
+    public XmlRuntimeException(String msg) {
+        super(msg);
+    }
+
+    public XmlRuntimeException(Throwable cause) {
+        super(cause);
+    }
+
+    public XmlRuntimeException(String msg, Throwable cause) {
+        super(msg, cause);
+    }
+}
diff --git 
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
 
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
index 393be90731..e01470eaa2 100644
--- 
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
+++ 
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
@@ -19,11 +19,14 @@
 package org.apache.groovy.xml.extensions;
 
 import groovy.xml.XmlUtil;
+import org.codehaus.groovy.runtime.InvokerHelper;
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 
 import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
 /**
  * This class defines all the new XML-related groovy methods which enhance
@@ -72,4 +75,25 @@ public class XmlExtensions {
     public static String serialize(Element element) {
         return XmlUtil.serialize(element).replaceFirst("<\\?xml 
version=\"1.0\".*\\?>", "");
     }
+
+    /**
+     * Enables {@code node as Type} coercion for XML Nodes.
+     * Converts the Node to a Map via {@link groovy.util.Node#toMap()} and then
+     * uses Groovy's standard Map coercion to produce the typed object.
+     * Does not require Jackson on the classpath.
+     *
+     * @param self the Node to convert
+     * @param type the target type
+     * @param <T>  the target type
+     * @return a typed object
+     * @since 6.0.0
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> T asType(groovy.util.Node self, Class<T> type) {
+        if (type == Map.class || type == LinkedHashMap.class) {
+            return (T) self.toMap();
+        }
+        Map<String, Object> map = self.toMap();
+        return (T) InvokerHelper.invokeConstructorOf(type, new Object[]{map});
+    }
 }
\ No newline at end of file
diff --git 
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
 
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
new file mode 100644
index 0000000000..10ce503fd0
--- /dev/null
+++ 
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
@@ -0,0 +1,93 @@
+/*
+ *  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.groovy.xml.util;
+
+import groovy.xml.XmlRuntimeException;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * Internal helper for optional Jackson databinding support.
+ * Uses reflection to avoid a compile-time dependency on jackson-databind.
+ */
+public class JacksonHelper {
+
+    private static final String OBJECT_MAPPER_CLASS = 
"com.fasterxml.jackson.databind.ObjectMapper";
+
+    // Lazily cached mapper and method — initialized on first use, thread-safe 
via volatile + holder
+    private static volatile Object cachedMapper;
+    private static volatile Method cachedConvertValue;
+
+    /**
+     * Converts a Map to a typed object using Jackson's 
ObjectMapper.convertValue.
+     * Requires jackson-databind on the classpath.
+     *
+     * @param map  the source map
+     * @param type the target type
+     * @param <T>  the target type
+     * @return the converted object
+     * @throws XmlRuntimeException if jackson-databind is not available or 
conversion fails
+     */
+    public static <T> T convertMapToType(Map<String, Object> map, Class<T> 
type) {
+        try {
+            if (cachedMapper == null) {
+                initMapper();
+            }
+            return type.cast(cachedConvertValue.invoke(cachedMapper, map, 
type));
+        } catch (XmlRuntimeException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new XmlRuntimeException("Failed to convert XML map to " + 
type.getName(), e);
+        }
+    }
+
+    private static synchronized void initMapper() {
+        if (cachedMapper != null) return;
+        Class<?> omClass = loadOptionalClass(OBJECT_MAPPER_CLASS);
+        if (omClass == null) {
+            throw new XmlRuntimeException(
+                    "Typed XML parsing requires jackson-databind on the 
classpath. "
+                            + "Add com.fasterxml.jackson.core:jackson-databind 
to your dependencies.");
+        }
+        try {
+            cachedMapper = omClass.getDeclaredConstructor().newInstance();
+            cachedConvertValue = omClass.getMethod("convertValue", 
Object.class, Class.class);
+        } catch (Exception e) {
+            throw new XmlRuntimeException("Failed to initialize Jackson 
ObjectMapper", e);
+        }
+    }
+
+    private static Class<?> loadOptionalClass(String className) {
+        ClassLoader[] loaders = {
+                Thread.currentThread().getContextClassLoader(),
+                JacksonHelper.class.getClassLoader(),
+                ClassLoader.getSystemClassLoader()
+        };
+        for (ClassLoader loader : loaders) {
+            if (loader == null) continue;
+            try {
+                return Class.forName(className, false, loader);
+            } catch (ClassNotFoundException ignore) {
+                // try next class loader
+            }
+        }
+        return null;
+    }
+}
diff --git a/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc 
b/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
index 23b596e63a..10b2fa1f5f 100644
--- a/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
+++ b/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
@@ -650,3 +650,140 @@ encoding, indentation, and DOCTYPE handling:
 
include::../test/UserGuideXmlUtilTest.groovy[tags=testSerializeOptions,indent=0]
 ----
 
+== Typed XML Conversion
+
+Groovy 6 adds support for converting XML nodes into typed objects,
+complementing the dynamic GPath-style navigation that `XmlParser` and
+`XmlSlurper` already provide.
+
+=== Converting Nodes to Maps
+
+The `toMap()` method on `Node` converts an XML node tree into a nested
+`Map<String, Object>`. This requires no additional dependencies.
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap,indent=0]
+----
+
+Attributes and child elements are merged into a single map.
+Nested elements become nested maps:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_nested,indent=0]
+----
+
+Attributes are also included:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_attributes,indent=0]
+----
+
+Repeated same-name elements become lists:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_repeated,indent=0]
+----
+
+If a child element name collides with an attribute name, the child element 
wins.
+
+When an element has both attributes and text content, the text is stored
+under the `_text` key (available as the `Node.TEXT_KEY` constant):
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_text_key,indent=0]
+----
+
+=== Type coercion with `as`
+
+Because `toMap()` produces a standard Groovy `Map`, you can use Groovy's
+built-in `as` coercion to convert a node directly to a typed object.
+No additional dependencies are required:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=simple_config_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=as_coercion,indent=0]
+----
+
+You can also coerce to `Map` directly:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=as_map,indent=0]
+----
+
+NOTE: The `as` coercion uses Groovy's standard named-parameter constructor,
+which works well when target properties are `String`-typed. For richer
+type conversion (e.g. `String` to `int`, `boolean`, `BigDecimal`), use
+`parseTextAs` described below.
+
+=== Typed parsing with `parseTextAs`
+
+The `parseTextAs` and `parseAs` methods on `XmlParser` parse XML directly
+into typed objects using Jackson's `ObjectMapper.convertValue`, which
+handles type conversion automatically. This requires `jackson-databind`
+on the classpath -- a clear error is thrown if it is absent.
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=server_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=parseTextAs,indent=0]
+----
+
+Nested objects are supported:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=app_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=parseTextAs_nested,indent=0]
+----
+
+Standard Jackson annotations such as `@JsonProperty` and `@JsonFormat`
+are supported for property name mapping and type conversion.
+
+Overloaded variants of `parseAs` accept a `Reader`, `InputStream`,
+`File`, or `Path` in addition to the `String`-based `parseTextAs`.
+
+=== Full Jackson XML support
+
+The `parseTextAs` method uses `jackson-databind` (not 
`jackson-dataformat-xml`),
+so XML-specific Jackson annotations like `@JacksonXmlText` and
+`@JacksonXmlElementWrapper` are not available through this path.
+
+If you need the full Jackson XML experience, use `jackson-dataformat-xml`
+directly:
+
+[source,groovy]
+----
+@Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml')
+import com.fasterxml.jackson.dataformat.xml.XmlMapper
+
+def config = new XmlMapper().readValue(xmlString, ServerConfig)
+----
+
+Or parse first with `XmlParser` for inspection or validation,
+then re-serialize and hand off to Jackson:
+
+[source,groovy]
+----
+def node = new XmlParser().parseText(xmlString)
+// ... inspect or validate node ...
+def config = new XmlMapper().readValue(XmlUtil.serialize(node), ServerConfig)
+----
diff --git a/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy 
b/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy
new file mode 100644
index 0000000000..910e338e89
--- /dev/null
+++ b/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy
@@ -0,0 +1,198 @@
+/*
+ *  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 groovy.xml
+
+import groovy.util.Node
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the Groovy Xml user guide related to typed XML parsing.
+ */
+class UserGuideXmlTypedTest {
+
+    // tag::server_class[]
+    static class ServerConfig {
+        String host
+        int port
+        boolean debug
+    }
+    // end::server_class[]
+
+    // tag::app_class[]
+    static class AppConfig {
+        String name
+        ServerConfig server
+    }
+    // end::app_class[]
+
+    // tag::simple_config_class[]
+    static class SimpleConfig {
+        String host
+        String port
+    }
+    // end::simple_config_class[]
+
+    @Test
+    void testToMap() {
+        // tag::toMap[]
+        def node = new XmlParser().parseText('''
+            <server>
+                <host>localhost</host>
+                <port>8080</port>
+                <debug>true</debug>
+            </server>'''.stripIndent())
+
+        def map = node.toMap()
+        assert map == [host: 'localhost', port: '8080', debug: 'true']
+        // end::toMap[]
+    }
+
+    @Test
+    void testToMapNested() {
+        // tag::toMap_nested[]
+        def node = new XmlParser().parseText('''
+            <app>
+                <name>myapp</name>
+                <server>
+                    <host>localhost</host>
+                    <port>8080</port>
+                </server>
+            </app>'''.stripIndent())
+
+        def map = node.toMap()
+        assert map == [name: 'myapp', server: [host: 'localhost', port: 
'8080']]
+        // end::toMap_nested[]
+    }
+
+    @Test
+    void testToMapAttributes() {
+        // tag::toMap_attributes[]
+        def node = new XmlParser().parseText('<server host="localhost" 
port="8080"/>')
+
+        def map = node.toMap()
+        assert map == [host: 'localhost', port: '8080']
+        // end::toMap_attributes[]
+    }
+
+    @Test
+    void testToMapRepeatedElements() {
+        // tag::toMap_repeated[]
+        def node = new XmlParser().parseText('''
+            <config>
+                <alias>web1</alias>
+                <alias>web2</alias>
+                <alias>web3</alias>
+            </config>'''.stripIndent())
+
+        def map = node.toMap()
+        assert map == [alias: ['web1', 'web2', 'web3']]
+        // end::toMap_repeated[]
+    }
+
+    @Test
+    void testToMapTextKey() {
+        // tag::toMap_text_key[]
+        def node = new XmlParser().parseText('<price 
currency="USD">9.99</price>')
+
+        def map = node.toMap()
+        assert map == [currency: 'USD', _text: '9.99']
+        assert map[Node.TEXT_KEY] == '9.99'
+        // end::toMap_text_key[]
+    }
+
+    @Test
+    void testAsCoercion() {
+        // tag::as_coercion[]
+        def node = new XmlParser().parseText('''
+            <server>
+                <host>localhost</host>
+                <port>8080</port>
+            </server>'''.stripIndent())
+
+        def config = node as SimpleConfig
+        assert config.host == 'localhost'
+        assert config.port == '8080'
+        // end::as_coercion[]
+    }
+
+    @Test
+    void testAsMap() {
+        // tag::as_map[]
+        def node = new 
XmlParser().parseText('<server><host>localhost</host></server>')
+
+        Map map = node as Map
+        assert map.host == 'localhost'
+        // end::as_map[]
+    }
+
+    @Test
+    void testParseTextAs() {
+        // tag::parseTextAs[]
+        def config = new XmlParser().parseTextAs(ServerConfig, '''
+            <server>
+                <host>localhost</host>
+                <port>8080</port>
+                <debug>true</debug>
+            </server>'''.stripIndent())
+
+        assert config instanceof ServerConfig
+        assert config.host == 'localhost'
+        assert config.port == 8080
+        assert config.debug == true
+        // end::parseTextAs[]
+    }
+
+    @Test
+    void testParseTextAsNested() {
+        // tag::parseTextAs_nested[]
+        def config = new XmlParser().parseTextAs(AppConfig, '''
+            <app>
+                <name>myapp</name>
+                <server>
+                    <host>localhost</host>
+                    <port>9090</port>
+                    <debug>false</debug>
+                </server>
+            </app>'''.stripIndent())
+
+        assert config.name == 'myapp'
+        assert config.server.host == 'localhost'
+        assert config.server.port == 9090
+        // end::parseTextAs_nested[]
+    }
+
+    @Test
+    void testRollYourOwnJacksonXml() {
+        // tag::roll_your_own[]
+        // If you need full Jackson XML support (e.g. @JacksonXmlText,
+        // @JacksonXmlElementWrapper), use jackson-dataformat-xml directly:
+        //
+        // @Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml')
+        // import com.fasterxml.jackson.dataformat.xml.XmlMapper
+        //
+        // def config = new XmlMapper().readValue(xmlString, ServerConfig)
+        //
+        // Or parse first with XmlParser for validation, then re-serialize:
+        //
+        // def node = new XmlParser().parseText(xmlString)
+        // // ... inspect or validate ...
+        // def config = new XmlMapper().readValue(XmlUtil.serialize(node), 
ServerConfig)
+        // end::roll_your_own[]
+    }
+}
diff --git 
a/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy 
b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy
new file mode 100644
index 0000000000..3952c7e6ca
--- /dev/null
+++ 
b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy
@@ -0,0 +1,147 @@
+/*
+ *  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 groovy.xml
+
+import org.junit.jupiter.api.Test
+
+class XmlParserTypedTest {
+
+    static class ServerConfig {
+        String host
+        int port
+        boolean debug
+    }
+
+    static class AppConfig {
+        String name
+        ServerConfig server
+    }
+
+    static class SimpleConfig {
+        String host
+        String port
+    }
+
+    @Test
+    void testParseTextAsSimple() {
+        def config = new XmlParser().parseTextAs(ServerConfig, '''\
+            <server>
+                <host>localhost</host>
+                <port>8080</port>
+                <debug>true</debug>
+            </server>'''.stripIndent())
+        assert config instanceof ServerConfig
+        assert config.host == 'localhost'
+        assert config.port == 8080
+        assert config.debug == true
+    }
+
+    @Test
+    void testParseTextAsNested() {
+        def config = new XmlParser().parseTextAs(AppConfig, '''\
+            <app>
+                <name>myapp</name>
+                <server>
+                    <host>localhost</host>
+                    <port>9090</port>
+                    <debug>false</debug>
+                </server>
+            </app>'''.stripIndent())
+        assert config instanceof AppConfig
+        assert config.name == 'myapp'
+        assert config.server instanceof ServerConfig
+        assert config.server.host == 'localhost'
+        assert config.server.port == 9090
+    }
+
+    @Test
+    void testParseAsFromReader() {
+        def xml = 
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+        def config = new XmlParser().parseAs(ServerConfig, new 
StringReader(xml))
+        assert config.host == 'localhost'
+        assert config.port == 8080
+    }
+
+    @Test
+    void testParseAsFromInputStream() {
+        def xml = 
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+        def config = new XmlParser().parseAs(ServerConfig, new 
ByteArrayInputStream(xml.bytes))
+        assert config.host == 'localhost'
+        assert config.port == 8080
+    }
+
+    @Test
+    void testParseAsFromFile() {
+        def xml = 
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+        def file = File.createTempFile('xmltest', '.xml')
+        file.deleteOnExit()
+        file.text = xml
+        def config = new XmlParser().parseAs(ServerConfig, file)
+        assert config.host == 'localhost'
+        assert config.port == 8080
+    }
+
+    @Test
+    void testParseAsFromPath() {
+        def xml = 
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+        def file = File.createTempFile('xmltest', '.xml')
+        file.deleteOnExit()
+        file.text = xml
+        def config = new XmlParser().parseAs(ServerConfig, file.toPath())
+        assert config.host == 'localhost'
+        assert config.port == 8080
+    }
+
+    @Test
+    void testParseTextAsWithAttributes() {
+        def config = new XmlParser().parseTextAs(ServerConfig, '<server 
host="localhost" port="8080" debug="true"/>')
+        assert config.host == 'localhost'
+        assert config.port == 8080
+        assert config.debug == true
+    }
+
+    @Test
+    void testNodeAsMap() {
+        def node = new 
XmlParser().parseText('<server><host>localhost</host><port>8080</port></server>')
+        def map = node as Map
+        assert map instanceof Map
+        assert map.host == 'localhost'
+        assert map.port == '8080'
+    }
+
+    @Test
+    void testNodeAsTypedObject() {
+        // as coercion works for String-typed properties without Jackson
+        def node = new 
XmlParser().parseText('<server><host>localhost</host><port>8080</port></server>')
+        def config = node as SimpleConfig
+        assert config instanceof SimpleConfig
+        assert config.host == 'localhost'
+        assert config.port == '8080'
+    }
+
+    @Test
+    void testNodeAsTypedObjectWithTypeConversion() {
+        // parseAs uses Jackson for full type conversion (String -> int, 
boolean, etc.)
+        def config = new XmlParser().parseTextAs(ServerConfig, 
'<server><host>localhost</host><port>8080</port><debug>true</debug></server>')
+        assert config instanceof ServerConfig
+        assert config.host == 'localhost'
+        assert config.port == 8080
+        assert config.debug == true
+    }
+}


Reply via email to