This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch jungle-handle in repository https://gitbox.apache.org/repos/asf/maven.git
commit 572ce40d289cd5906e420cb44683c20b535fd988 Author: Guillaume Nodet <[email protected]> AuthorDate: Tue Mar 24 19:37:54 2026 +0100 Add namespace context to XmlNode for proper prefix resolution on write XmlNode now carries a namespaces() map (prefix → URI) that captures the full namespace context inherited from ancestor elements during parsing. This allows the write side to resolve and auto-declare namespace prefixes for attributes like mvn:combine.children even when the xmlns:mvn declaration was on an ancestor element and not in the local attribute map. - Add namespaces() default method to XmlNode API (returns Map.of()) - Add namespaces field to XmlNode.Builder and Impl record - Update DefaultXmlService.doBuild() to accumulate namespace context - Update writeAttributes() and writeXmlNodeAttributes() (template) to resolve prefixes from namespace context and auto-declare namespaces - Propagate namespaces through merge operations - Add tests for inherited namespace context and orphaned prefix stripping Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../java/org/apache/maven/api/xml/XmlNode.java | 38 +++++++++++++- .../maven/internal/xml/DefaultXmlService.java | 60 ++++++++++++++++++---- .../apache/maven/internal/xml/XmlNodeImplTest.java | 55 ++++++++++++++------ src/mdo/writer-stax.vm | 26 ++++++---- 4 files changed, 144 insertions(+), 35 deletions(-) diff --git a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java index a78357a091..a9634585d5 100644 --- a/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java +++ b/api/maven-api-xml/src/main/java/org/apache/maven/api/xml/XmlNode.java @@ -159,6 +159,24 @@ public interface XmlNode { @Nullable String attribute(@Nonnull String name); + /** + * Returns the namespace context for this node — a map of namespace prefix to URI + * for all namespace bindings in scope, including those declared on this element + * and those inherited from ancestor elements. + * <p> + * This is used by the write side to properly resolve prefixed attributes. + * For example, if an attribute {@code mvn:combine.children} exists on a child element + * but {@code xmlns:mvn} was declared on the root element, this map will contain + * the {@code mvn → http://maven.apache.org/POM/4.0.0} binding. + * + * @return map of namespace prefix to URI, never {@code null} + * @since 4.1.0 + */ + @Nonnull + default Map<String, String> namespaces() { + return Map.of(); + } + /** * Returns an immutable list of all child nodes. * @@ -358,6 +376,7 @@ class Builder { private String namespaceUri; private String prefix; private Map<String, String> attributes; + private Map<String, String> namespaces; private List<XmlNode> children; private Object inputLocation; @@ -421,6 +440,21 @@ public Builder attributes(Map<String, String> attributes) { return this; } + /** + * Sets the namespace context for this node. + * <p> + * This map contains all namespace prefix to URI bindings in scope, + * including inherited ones from ancestor elements. + * + * @param namespaces the map of namespace prefix to URI + * @return this builder instance + * @since 4.1.0 + */ + public Builder namespaces(Map<String, String> namespaces) { + this.namespaces = namespaces; + return this; + } + /** * Sets the child nodes of the XML node. * <p> @@ -454,7 +488,7 @@ public Builder inputLocation(Object inputLocation) { * @throws NullPointerException if name has not been set */ public XmlNode build() { - return new Impl(prefix, namespaceUri, name, value, attributes, children, inputLocation); + return new Impl(prefix, namespaceUri, name, value, attributes, namespaces, children, inputLocation); } private record Impl( @@ -463,6 +497,7 @@ private record Impl( @Nonnull String name, String value, @Nonnull Map<String, String> attributes, + @Nonnull Map<String, String> namespaces, @Nonnull List<XmlNode> children, Object inputLocation) implements XmlNode, Serializable { @@ -473,6 +508,7 @@ private record Impl( namespaceUri = namespaceUri == null ? "" : namespaceUri; name = Objects.requireNonNull(name); attributes = ImmutableCollections.copy(attributes); + namespaces = ImmutableCollections.copy(namespaces); children = ImmutableCollections.copy(children); } diff --git a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java index 434981bca0..326671d1a7 100644 --- a/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java +++ b/impl/maven-xml/src/main/java/org/apache/maven/internal/xml/DefaultXmlService.java @@ -30,6 +30,7 @@ import java.io.Writer; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -68,10 +69,14 @@ public XmlNode doRead(Reader reader, @Nullable XmlService.InputLocationBuilder l @Override public XmlNode doRead(XMLStreamReader parser, @Nullable XmlService.InputLocationBuilder locationBuilder) throws XMLStreamException { - return doBuild(parser, DEFAULT_TRIM, locationBuilder); + return doBuild(parser, DEFAULT_TRIM, locationBuilder, new HashMap<>()); } - private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuilder locationBuilder) + private XmlNode doBuild( + XMLStreamReader parser, + boolean trim, + InputLocationBuilder locationBuilder, + Map<String, String> parentNamespaces) throws XMLStreamException { boolean spacePreserve = false; String lPrefix = null; @@ -80,6 +85,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuild String lValue = null; Object location = null; Map<String, String> attrs = null; + Map<String, String> nsContext = null; List<XmlNode> children = null; int eventType = parser.getEventType(); int lastStartTag = -1; @@ -93,6 +99,15 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuild lNamespaceUri = parser.getNamespaceURI(); lName = parser.getLocalName(); location = locationBuilder != null ? locationBuilder.toInputLocation(parser) : null; + // Build the namespace context: start with inherited, add local declarations + nsContext = new HashMap<>(parentNamespaces); + for (int i = 0; i < namespacesSize; i++) { + String nsPrefix = parser.getNamespacePrefix(i); + String nsUri = parser.getNamespaceURI(i); + if (nsPrefix != null && !nsPrefix.isEmpty()) { + nsContext.put(nsPrefix, nsUri); + } + } int attributesSize = parser.getAttributeCount(); if (attributesSize > 0 || namespacesSize > 0) { attrs = new HashMap<>(); @@ -116,7 +131,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuild if (children == null) { children = new ArrayList<>(); } - XmlNode child = doBuild(parser, trim, locationBuilder); + XmlNode child = doBuild(parser, trim, locationBuilder, nsContext != null ? nsContext : Map.of()); children.add(child); } } else if (eventType == XMLStreamReader.CHARACTERS || eventType == XMLStreamReader.CDATA) { @@ -135,6 +150,7 @@ private XmlNode doBuild(XMLStreamReader parser, boolean trim, InputLocationBuild .name(lName) .value(children == null ? (lValue != null ? lValue : emptyTag ? null : "") : null) .attributes(attrs) + .namespaces(nsContext) .children(children) .inputLocation(location) .build(); @@ -162,7 +178,7 @@ public void doWrite(XmlNode node, Writer writer) throws IOException { private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws XMLStreamException { xmlWriter.writeStartElement(node.prefix(), node.name(), node.namespaceUri()); - writeAttributes(xmlWriter, node.attributes()); + writeAttributes(xmlWriter, node.attributes(), node.namespaces()); for (XmlNode child : node.children()) { writeNode(xmlWriter, child); @@ -176,17 +192,35 @@ private void writeNode(XMLStreamWriter xmlWriter, XmlNode node) throws XMLStream xmlWriter.writeEndElement(); } - private static void writeAttributes(XMLStreamWriter xmlWriter, Map<String, String> attributes) + /** + * Writes XmlNode attributes, properly handling namespace declarations + * ({@code xmlns:prefix}) and prefixed attributes ({@code prefix:localName}). + * The namespace context is used to resolve prefixes when the {@code xmlns:} + * declaration is not present in the attribute map (e.g., it was declared on + * an ancestor element). + * + * @param xmlWriter the StAX writer + * @param attributes the attribute map (may contain xmlns: entries) + * @param namespaces the namespace context (prefix → URI) for resolving prefixed attributes + */ + static void writeAttributes( + XMLStreamWriter xmlWriter, Map<String, String> attributes, Map<String, String> namespaces) throws XMLStreamException { - // Write namespace declarations first, then regular attributes + // Collect which namespace prefixes need to be declared on this element: + // start with those explicitly in attributes (xmlns:prefix), then add + // any prefixes used by attributes that are resolved from the namespace context + Set<String> declaredPrefixes = new HashSet<>(); for (Map.Entry<String, String> attribute : attributes.entrySet()) { String key = attribute.getKey(); if ("xmlns".equals(key)) { xmlWriter.writeDefaultNamespace(attribute.getValue()); } else if (key.startsWith("xmlns:")) { - xmlWriter.writeNamespace(key.substring(6), attribute.getValue()); + String prefix = key.substring(6); + xmlWriter.writeNamespace(prefix, attribute.getValue()); + declaredPrefixes.add(prefix); } } + // Write prefixed attributes, declaring their namespace if needed for (Map.Entry<String, String> attribute : attributes.entrySet()) { String key = attribute.getKey(); String value = attribute.getValue(); @@ -201,13 +235,20 @@ private static void writeAttributes(XMLStreamWriter xmlWriter, Map<String, Strin int colon = key.indexOf(':'); String prefix = key.substring(0, colon); String localName = key.substring(colon + 1); + // Look up namespace URI: first from local xmlns: declarations, then from context String nsUri = attributes.get("xmlns:" + prefix); + if (nsUri == null) { + nsUri = namespaces.get(prefix); + } if (nsUri != null) { + // Declare the namespace if not already declared on this element + if (declaredPrefixes.add(prefix)) { + xmlWriter.writeNamespace(prefix, nsUri); + } xmlWriter.writeAttribute(prefix, nsUri, localName, value); } else { // No namespace declaration found for this prefix; write as unprefixed - // to produce valid XML (the namespace declaration may have been lost - // during consumer POM transformation) + // to produce valid XML xmlWriter.writeAttribute(localName, value); } } else { @@ -405,6 +446,7 @@ public XmlNode doMerge(XmlNode dominant, XmlNode recessive, Boolean childMergeOv .name(dominant.name()) .value(value != null ? value : dominant.value()) .attributes(attrs) + .namespaces(dominant.namespaces()) .children(children) .inputLocation(location) .build(); diff --git a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java index 293a4db1a4..bf1596b86b 100644 --- a/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java +++ b/impl/maven-xml/src/test/java/org/apache/maven/internal/xml/XmlNodeImplTest.java @@ -809,17 +809,12 @@ void testWriteForeignNamespaceAttributeRoundTrip() throws Exception { } /** - * This test reproduces the consumer POM bug from issue #11760. - * In the consumer POM transformation, the model is re-serialized from the - * Model object. Namespace declarations like xmlns:custom on the <project> - * element are lost (they are not part of the Maven model), but prefixed - * attributes like custom:myattr survive in plugin configuration XmlNode trees. - * When these orphaned prefixed attributes are written without their namespace - * declaration, writing them as-is would produce invalid XML ("Undeclared - * namespace prefix"). Instead, the prefix is stripped to produce valid XML. + * Verifies that when a prefixed attribute's xmlns declaration is on a parent + * element, the child node's namespace context (inherited from parsing) allows + * the prefix to be preserved when writing the child alone. */ @Test - void testWriteStripsOrphanedPrefixFromChildElement() throws Exception { + void testWritePreservesPrefixFromInheritedNamespaceContext() throws Exception { String xml = """ <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:custom="http://example.com/custom"> <compilerArgs custom:myattr="value"> @@ -831,23 +826,53 @@ void testWriteStripsOrphanedPrefixFromChildElement() throws Exception { XmlNode node = toXmlNode(xml); // The child element has custom:myattr but NOT xmlns:custom in its own attributes - // (the namespace declaration is on the parent <project> element) XmlNode compilerArgs = node.child("compilerArgs"); assertNotNull(compilerArgs); assertEquals("value", compilerArgs.attribute("custom:myattr")); assertNull(compilerArgs.attribute("xmlns:custom"), "xmlns:custom should be on parent, not child"); + // But the namespace context has the binding from the parent + assertEquals("http://example.com/custom", compilerArgs.namespaces().get("custom")); - // Writing the child alone (as happens during consumer POM serialization) - // must produce valid XML. Since xmlns:custom is not available, the prefix - // is stripped to avoid an "Undeclared namespace prefix" error. + // Writing the child alone should produce valid XML with the namespace declared StringWriter writer = new StringWriter(); XmlService.write(compilerArgs, writer); String output = writer.toString(); XmlNode reRead = toXmlNode(output); assertNotNull(reRead); - // Prefix was stripped, so the attribute is now unprefixed - assertEquals("value", reRead.attribute("myattr")); + // Prefix is preserved because the namespace context provided the URI + assertEquals("value", reRead.attribute("custom:myattr")); + } + + /** + * Verifies that when namespace context is not available (e.g., XmlNode built + * programmatically without namespaces), orphaned prefixes are stripped on write + * to produce valid XML. + */ + @Test + void testWriteStripsOrphanedPrefixWithoutNamespaceContext() throws Exception { + // Build a node with a prefixed attribute but no namespace context at all + XmlNode node = XmlNode.newBuilder() + .name("compilerArgs") + .attributes(Map.of("mvn:combine.children", "append")) + .children(List.of(XmlNode.newBuilder() + .name("arg") + .value("-Xlint:deprecation") + .build())) + .build(); + + assertTrue(node.namespaces().isEmpty(), "No namespace context"); + + StringWriter writer = new StringWriter(); + XmlService.write(node, writer); + String output = writer.toString(); + + assertFalse(output.contains("mvn:combine"), "Output should not contain orphaned mvn: prefix"); + assertTrue(output.contains("combine.children=\"append\""), "Attribute should be written unprefixed"); + + XmlNode reRead = toXmlNode(output); + assertNotNull(reRead); + assertEquals("append", reRead.attribute("combine.children")); } /** diff --git a/src/mdo/writer-stax.vm b/src/mdo/writer-stax.vm index 5c8cb58137..fb2aaf7bd3 100644 --- a/src/mdo/writer-stax.vm +++ b/src/mdo/writer-stax.vm @@ -366,7 +366,7 @@ public class ${className} { private void writeDom(XmlNode dom, XMLStreamWriter serializer) throws IOException, XMLStreamException { if (dom != null) { serializer.writeStartElement(namespace, dom.name()); - writeXmlNodeAttributes(serializer, dom.attributes()); + writeXmlNodeAttributes(serializer, dom.attributes(), dom.namespaces()); for (XmlNode child : dom.children()) { writeDom(child, serializer); } @@ -407,27 +407,29 @@ public class ${className} { /** * Writes XmlNode attributes, properly handling namespace declarations * ({@code xmlns:prefix}) and prefixed attributes ({@code prefix:localName}). + * The namespace context is used to resolve prefixes when the {@code xmlns:} + * declaration is not present in the attribute map. */ - private static void writeXmlNodeAttributes(XMLStreamWriter serializer, Map<String, String> attributes) throws XMLStreamException { - // Write namespace declarations first + private static void writeXmlNodeAttributes(XMLStreamWriter serializer, Map<String, String> attributes, Map<String, String> namespaces) throws XMLStreamException { + // Collect which namespace prefixes need to be declared on this element + Set<String> declaredPrefixes = new HashSet<>(); for (Map.Entry<String, String> attribute : attributes.entrySet()) { String key = attribute.getKey(); if ("xmlns".equals(key)) { serializer.writeDefaultNamespace(attribute.getValue()); } else if (key.startsWith("xmlns:")) { - serializer.writeNamespace(key.substring(6), attribute.getValue()); + String prefix = key.substring(6); + serializer.writeNamespace(prefix, attribute.getValue()); + declaredPrefixes.add(prefix); } } - // Then write regular attributes + // Write prefixed attributes, declaring their namespace if needed for (Map.Entry<String, String> attribute : attributes.entrySet()) { String key = attribute.getKey(); String value = attribute.getValue(); if ("xmlns".equals(key) || key.startsWith("xmlns:")) { continue; // already written above } else if (key.startsWith("xml:")) { - // The xml: prefix is predefined and bound to the XML namespace. - // It must not be declared, but attributes like xml:space still need - // to be written using the proper namespace URI. serializer.writeAttribute( "http://www.w3.org/XML/1998/namespace", key.substring(4), value); } else if (key.contains(":")) { @@ -435,11 +437,15 @@ public class ${className} { String prefix = key.substring(0, colon); String localName = key.substring(colon + 1); String nsUri = attributes.get("xmlns:" + prefix); + if (nsUri == null) { + nsUri = namespaces.get(prefix); + } if (nsUri != null) { + if (declaredPrefixes.add(prefix)) { + serializer.writeNamespace(prefix, nsUri); + } serializer.writeAttribute(prefix, nsUri, localName, value); } else { - // No namespace declaration for this prefix; write as unprefixed - // to produce valid XML serializer.writeAttribute(localName, value); } } else {
