You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/03/24 18:38:10 UTC
(maven) 01/01: Add namespace context to XmlNode for proper prefix resolution on write
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 <gn...@gmail.com>
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 <no...@anthropic.com>
---
.../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 {