This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new cecd7eefcb Fix consumer POM serialization of prefixed XML attributes
(fixes #11760)
cecd7eefcb is described below
commit cecd7eefcb14bd10dde22720fe5f9dee12608548
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue May 19 17:15:59 2026 +0200
Fix consumer POM serialization of prefixed XML attributes (fixes #11760)
During consumer POM transformation, namespace declarations like xmlns:mvn on
<project> are lost (not part of the Maven model), but prefixed attributes
like
mvn:combine.children on XmlNode configuration trees survive, producing
invalid
XML with undeclared namespace prefixes.
Fix by adding namespace context tracking to XmlNode: the parser now
accumulates
namespace declarations and propagates them to child nodes. The StAX and XPP3
writers resolve prefixed attributes against local declarations first, then
the
inherited namespace context, auto-declaring namespaces as needed. Orphaned
prefixes (no declaration, no context) are stripped as a last resort to
ensure
valid XML output.
Changes:
- Add XmlNode.namespaces() returning inherited prefix-to-URI bindings
- Accumulate and propagate namespace context during parsing in
DefaultXmlService
- Fix writer-stax.vm and writer.vm to resolve and declare namespaces
properly
- Preserve dominant node's namespace context during merge
- Add 28 tests covering parsing, writing, merging, and consumer POM
simulation
---
.../java/org/apache/maven/api/xml/XmlNode.java | 38 +-
.../maven/internal/xml/DefaultXmlService.java | 141 +++-
.../apache/maven/internal/xml/XmlNodeImplTest.java | 799 +++++++++++++++++++++
src/mdo/writer-stax.vm | 59 +-
src/mdo/writer.vm | 54 +-
5 files changed, 1050 insertions(+), 41 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 3516960d76..085bf46806 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,18 +69,23 @@ 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;
- String lNamespaceUri = null;
- String lName = null;
- String lValue = null;
+ String elementPrefix = null;
+ String elementNamespaceUri = null;
+ String elementName = null;
+ String elementValue = 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;
@@ -87,54 +93,67 @@ private XmlNode doBuild(XMLStreamReader parser, boolean
trim, InputLocationBuild
if (eventType == XMLStreamReader.START_ELEMENT) {
lastStartTag = parser.getLocation().getLineNumber() * 1000
+ parser.getLocation().getColumnNumber();
- if (lName == null) {
+ // The first START_ELEMENT we encounter is "this" element;
+ // subsequent START_ELEMENTs are children, handled in the else
branch.
+ if (elementName == null) {
int namespacesSize = parser.getNamespaceCount();
- lPrefix = parser.getPrefix();
- lNamespaceUri = parser.getNamespaceURI();
- lName = parser.getLocalName();
+ elementPrefix = parser.getPrefix();
+ elementNamespaceUri = parser.getNamespaceURI();
+ elementName = parser.getLocalName();
location = locationBuilder != null ?
locationBuilder.toInputLocation(parser) : null;
+ // Build the namespace context: start with inherited, add
local declarations.
+ // The default namespace (empty prefix) is excluded
because per the XML namespace
+ // spec (Section 6.2), default namespace declarations do
NOT apply to attributes.
+ nsContext = new HashMap<>(parentNamespaces);
int attributesSize = parser.getAttributeCount();
if (attributesSize > 0 || namespacesSize > 0) {
attrs = new HashMap<>();
for (int i = 0; i < namespacesSize; i++) {
String nsPrefix = parser.getNamespacePrefix(i);
String nsUri = parser.getNamespaceURI(i);
- attrs.put(nsPrefix != null && !nsPrefix.isEmpty()
? "xmlns:" + nsPrefix : "xmlns", nsUri);
+ if (nsPrefix != null && !nsPrefix.isEmpty()) {
+ nsContext.put(nsPrefix, nsUri);
+ attrs.put("xmlns:" + nsPrefix, nsUri);
+ } else {
+ attrs.put("xmlns", nsUri);
+ }
}
for (int i = 0; i < attributesSize; i++) {
- String aName = parser.getAttributeLocalName(i);
- String aValue = parser.getAttributeValue(i);
- String aPrefix = parser.getAttributePrefix(i);
- if (aPrefix != null && !aPrefix.isEmpty()) {
- aName = aPrefix + ":" + aName;
+ String attrName = parser.getAttributeLocalName(i);
+ String attrValue = parser.getAttributeValue(i);
+ String attrPrefix = parser.getAttributePrefix(i);
+ if (attrPrefix != null && !attrPrefix.isEmpty()) {
+ attrName = attrPrefix + ":" + attrName;
}
- attrs.put(aName, aValue);
- spacePreserve = spacePreserve ||
("xml:space".equals(aName) && "preserve".equals(aValue));
+ attrs.put(attrName, attrValue);
+ spacePreserve =
+ spacePreserve ||
("xml:space".equals(attrName) && "preserve".equals(attrValue));
}
}
} else {
if (children == null) {
children = new ArrayList<>();
}
- XmlNode child = doBuild(parser, trim, locationBuilder);
+ XmlNode child = doBuild(parser, trim, locationBuilder,
nsContext);
children.add(child);
}
} else if (eventType == XMLStreamReader.CHARACTERS || eventType ==
XMLStreamReader.CDATA) {
String text = parser.getText();
- lValue = lValue != null ? lValue + text : text;
+ elementValue = elementValue != null ? elementValue + text :
text;
} else if (eventType == XMLStreamReader.END_ELEMENT) {
boolean emptyTag = lastStartTag
== parser.getLocation().getLineNumber() * 1000
+ parser.getLocation().getColumnNumber();
- if (lValue != null && trim && !spacePreserve) {
- lValue = lValue.trim();
+ if (elementValue != null && trim && !spacePreserve) {
+ elementValue = elementValue.trim();
}
return XmlNode.newBuilder()
- .prefix(lPrefix)
- .namespaceUri(lNamespaceUri)
- .name(lName)
- .value(children == null ? (lValue != null ? lValue :
emptyTag ? null : "") : null)
+ .prefix(elementPrefix)
+ .namespaceUri(elementNamespaceUri)
+ .name(elementName)
+ .value(children == null ? (elementValue != null ?
elementValue : emptyTag ? null : "") : null)
.attributes(attrs)
+ .namespaces(nsContext)
.children(children)
.inputLocation(location)
.build();
@@ -162,9 +181,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());
- for (Map.Entry<String, String> attr : node.attributes().entrySet()) {
- xmlWriter.writeAttribute(attr.getKey(), attr.getValue());
- }
+ writeAttributes(xmlWriter, node.attributes(), node.namespaces());
for (XmlNode child : node.children()) {
writeNode(xmlWriter, child);
@@ -178,6 +195,71 @@ private void writeNode(XMLStreamWriter xmlWriter, XmlNode
node) throws XMLStream
xmlWriter.writeEndElement();
}
+ /**
+ * 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
+ */
+ private static void writeAttributes(
+ XMLStreamWriter xmlWriter, Map<String, String> attributes,
Map<String, String> namespaces)
+ throws XMLStreamException {
+ // 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:")) {
+ 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();
+ 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.
+
xmlWriter.writeAttribute("http://www.w3.org/XML/1998/namespace",
key.substring(4), value);
+ } else if (key.contains(":")) {
+ 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
+ xmlWriter.writeAttribute(localName, value);
+ }
+ } else {
+ xmlWriter.writeAttribute(key, value);
+ }
+ }
+ }
+
/**
* Merges one DOM into another, given a specific algorithm and possible
override points for that algorithm.<p>
* The algorithm is as follows:
@@ -368,6 +450,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 e980517194..4a50e0e6ca 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
@@ -24,6 +24,7 @@
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
+import java.io.StringWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -36,10 +37,13 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class XmlNodeImplTest {
@@ -715,6 +719,801 @@ public Object toInputLocation(XMLStreamReader parser) {
}
}
+ //
========================================================================================
+ // Namespace context - Parsing tests
+ //
========================================================================================
+
+ @Test
+ void testParseNamespaceContextSinglePrefixOnRoot() throws Exception {
+ String xml = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child/>
+ </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ assertEquals("http://maven.apache.org/POM/4.0.0",
node.namespaces().get("mvn"));
+ }
+
+ @Test
+ void testParseNamespaceContextMultiplePrefixes() throws Exception {
+ String xml = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0"
+ xmlns:custom="http://example.com/custom"
+ xmlns:other="http://example.com/other">
+ <child/>
+ </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ assertEquals(3, node.namespaces().size());
+ assertEquals("http://maven.apache.org/POM/4.0.0",
node.namespaces().get("mvn"));
+ assertEquals("http://example.com/custom",
node.namespaces().get("custom"));
+ assertEquals("http://example.com/other",
node.namespaces().get("other"));
+ }
+
+ @Test
+ void testParseNamespaceContextInheritedByChild() throws Exception {
+ String xml = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child mvn:combine.children="append"/>
+ </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ XmlNode child = node.child("child");
+ assertNotNull(child);
+ // Child inherits parent's namespace context
+ assertEquals("http://maven.apache.org/POM/4.0.0",
child.namespaces().get("mvn"));
+ // Child does NOT have xmlns:mvn in its own attributes
+ assertNull(child.attribute("xmlns:mvn"));
+ }
+
+ @Test
+ void testParseNamespaceContextInheritedAcrossThreeLevels() throws
Exception {
+ String xml = """
+ <root xmlns:a="http://example.com/a">
+ <level1 xmlns:b="http://example.com/b">
+ <level2 a:x="1" b:y="2">
+ <leaf/>
+ </level2>
+ </level1>
+ </root>
+ """;
+ XmlNode root = toXmlNode(xml);
+ XmlNode level1 = root.child("level1");
+ XmlNode level2 = level1.child("level2");
+ XmlNode leaf = level2.child("leaf");
+
+ // root has only "a"
+ assertEquals("http://example.com/a", root.namespaces().get("a"));
+ assertNull(root.namespaces().get("b"));
+
+ // level1 has both "a" (inherited) and "b" (own)
+ assertEquals("http://example.com/a", level1.namespaces().get("a"));
+ assertEquals("http://example.com/b", level1.namespaces().get("b"));
+
+ // level2 inherits both
+ assertEquals("http://example.com/a", level2.namespaces().get("a"));
+ assertEquals("http://example.com/b", level2.namespaces().get("b"));
+
+ // leaf also inherits both
+ assertEquals("http://example.com/a", leaf.namespaces().get("a"));
+ assertEquals("http://example.com/b", leaf.namespaces().get("b"));
+ }
+
+ @Test
+ void testParseDefaultNamespaceNotInNamespacesMap() throws Exception {
+ String xml = """
+ <root xmlns="http://maven.apache.org/POM/4.0.0">
+ <child/>
+ </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ // Default namespace (no prefix) should NOT be in the namespaces map
+ // since namespaces() tracks prefix→URI bindings for resolving
prefixed attributes
+ assertNull(node.namespaces().get(""));
+ assertNull(node.namespaces().get("xmlns"));
+ // The default namespace is stored as an attribute instead
+ assertEquals("http://maven.apache.org/POM/4.0.0",
node.attribute("xmlns"));
+ }
+
+ @Test
+ void testParseNamespaceContextChildOverridesPrefix() throws Exception {
+ String xml = """
+ <root xmlns:ns="http://example.com/original">
+ <child xmlns:ns="http://example.com/overridden"
ns:attr="val">
+ <grandchild ns:attr2="val2"/>
+ </child>
+ </root>
+ """;
+ XmlNode root = toXmlNode(xml);
+ XmlNode child = root.child("child");
+ XmlNode grandchild = child.child("grandchild");
+
+ // Root has original binding
+ assertEquals("http://example.com/original",
root.namespaces().get("ns"));
+ // Child overrides
+ assertEquals("http://example.com/overridden",
child.namespaces().get("ns"));
+ // Grandchild inherits the overridden version
+ assertEquals("http://example.com/overridden",
grandchild.namespaces().get("ns"));
+ }
+
+ @Test
+ void testParseNoNamespaceDeclarationsProducesEmptyMap() throws Exception {
+ String xml = "<root><child attr=\"value\"/></root>";
+ XmlNode root = toXmlNode(xml);
+ assertTrue(root.namespaces().isEmpty());
+ XmlNode child = root.child("child");
+ assertNotNull(child);
+ assertTrue(child.namespaces().isEmpty());
+ }
+
+ @Test
+ void testParseNamespacesMapIsImmutable() throws Exception {
+ String xml = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child/>
+ </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ assertThrows(
+ UnsupportedOperationException.class, () ->
node.namespaces().put("foo", "bar"));
+ }
+
+ //
========================================================================================
+ // Namespace context - Writing tests
+ //
========================================================================================
+
+ @Test
+ void testWriteWithNamespaceDeclarationsAndPrefixedAttributes() throws
Exception {
+ String xml = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <compilerArgs mvn:combine.children="append">
+ <arg>-Xlint:deprecation</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ XmlNode node = toXmlNode(xml);
+ assertEquals("http://maven.apache.org/POM/4.0.0",
node.attribute("xmlns:mvn"));
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ XmlNode reRead = toXmlNode(output);
+ assertNotNull(reRead);
+ }
+
+ @Test
+ void testWriteStripsOrphanedPrefixOnAttributes() throws Exception {
+ 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();
+
+ 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"));
+ }
+
+ @Test
+ void testWriteForeignNamespaceAttributeRoundTrip() throws Exception {
+ XmlNode node = XmlNode.newBuilder()
+ .name("compilerArgs")
+ .attributes(Map.of(
+ "xmlns:custom", "http://example.com/custom",
+ "custom:myattr", "value"))
+ .children(List.of(XmlNode.newBuilder()
+ .name("arg")
+ .value("-Xlint:deprecation")
+ .build()))
+ .build();
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ XmlNode reRead = toXmlNode(output);
+ assertNotNull(reRead);
+ assertEquals("value", reRead.attribute("custom:myattr"));
+ assertEquals("http://example.com/custom",
reRead.attribute("xmlns:custom"));
+ }
+
+ @Test
+ 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">
+ <arg>-Xlint:deprecation</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ XmlNode node = toXmlNode(xml);
+ 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");
+ assertEquals("http://example.com/custom",
compilerArgs.namespaces().get("custom"));
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(compilerArgs, writer);
+ String output = writer.toString();
+
+ XmlNode reRead = toXmlNode(output);
+ assertNotNull(reRead);
+ assertEquals("value", reRead.attribute("custom:myattr"));
+ }
+
+ @Test
+ void testWriteStripsOrphanedPrefixWithoutNamespaceContext() throws
Exception {
+ 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"));
+ }
+
+ @Test
+ void testWriteMultiplePrefixedAttributesFromDifferentNamespaces() throws
Exception {
+ String xml = """
+ <root xmlns:a="http://example.com/a"
xmlns:b="http://example.com/b">
+ <child a:x="1" b:y="2"/>
+ </root>
+ """;
+ XmlNode root = toXmlNode(xml);
+ XmlNode child = root.child("child");
+ assertNotNull(child);
+
+ // Write only the child (which has prefixed attrs but no local xmlns:)
+ StringWriter writer = new StringWriter();
+ XmlService.write(child, writer);
+ String output = writer.toString();
+
+ // Both namespace declarations should be auto-declared
+ assertTrue(output.contains("xmlns:a="), "Should auto-declare xmlns:a");
+ assertTrue(output.contains("xmlns:b="), "Should auto-declare xmlns:b");
+
+ // Round-trip should preserve attributes
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("1", reRead.attribute("a:x"));
+ assertEquals("2", reRead.attribute("b:y"));
+ }
+
+ @Test
+ void testWriteLocalXmlnsOverridesNamespaceContext() throws Exception {
+ // Build a node where the local attribute has xmlns:ns with one URI
+ // but the namespace context has a different URI for the same prefix.
+ // The local declaration should win.
+ XmlNode node = XmlNode.newBuilder()
+ .name("elem")
+ .attributes(Map.of(
+ "xmlns:ns", "http://example.com/local",
+ "ns:attr", "value"))
+ .namespaces(Map.of("ns", "http://example.com/context"))
+ .build();
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ // The local xmlns:ns should be used, not the one from context
+ assertTrue(output.contains("http://example.com/local"), "Local xmlns:
should take precedence");
+
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("value", reRead.attribute("ns:attr"));
+ assertEquals("http://example.com/local", reRead.attribute("xmlns:ns"));
+ }
+
+ @Test
+ void testWriteXmlSpaceAttributeRoundTrip() throws Exception {
+ String xml = """
+ <root xml:space="preserve"> content with spaces </root>
+ """;
+ XmlNode node = toXmlNode(xml);
+ assertEquals("preserve", node.attribute("xml:space"));
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ // xml: prefix should be handled without explicit declaration
+ assertFalse(output.contains("xmlns:xml"), "xml: prefix must not be
declared");
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("preserve", reRead.attribute("xml:space"));
+ assertEquals(" content with spaces ", reRead.value());
+ }
+
+ @Test
+ void testWriteUnprefixedAttributeUnchanged() throws Exception {
+ XmlNode node = XmlNode.newBuilder()
+ .name("elem")
+ .attributes(Map.of("simple", "value", "another", "val2"))
+ .build();
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("value", reRead.attribute("simple"));
+ assertEquals("val2", reRead.attribute("another"));
+ }
+
+ @Test
+ void testWriteNamespaceNotDeclaredTwice() throws Exception {
+ // When xmlns:mvn is both in attributes AND namespace context,
+ // it should only be declared once
+ XmlNode node = XmlNode.newBuilder()
+ .name("elem")
+ .attributes(Map.of(
+ "xmlns:mvn", "http://maven.apache.org/POM/4.0.0",
+ "mvn:combine.children", "append"))
+ .namespaces(Map.of("mvn", "http://maven.apache.org/POM/4.0.0"))
+ .build();
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ // Count occurrences of xmlns:mvn - should be exactly 1
+ int count = 0;
+ int idx = 0;
+ while ((idx = output.indexOf("xmlns:mvn", idx)) != -1) {
+ count++;
+ idx += "xmlns:mvn".length();
+ }
+ assertEquals(1, count, "xmlns:mvn should be declared exactly once");
+
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("append", reRead.attribute("mvn:combine.children"));
+ }
+
+ @Test
+ void testWriteChildInheritsContextAndWritesStandalone() throws Exception {
+ // Parse a 3-level structure, then write the grandchild standalone
+ String xml = """
+ <root xmlns:a="http://example.com/a">
+ <mid xmlns:b="http://example.com/b">
+ <leaf a:x="1" b:y="2" plain="3"/>
+ </mid>
+ </root>
+ """;
+ XmlNode root = toXmlNode(xml);
+ XmlNode leaf = root.child("mid").child("leaf");
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(leaf, writer);
+ String output = writer.toString();
+
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("1", reRead.attribute("a:x"));
+ assertEquals("2", reRead.attribute("b:y"));
+ assertEquals("3", reRead.attribute("plain"));
+ }
+
+ //
========================================================================================
+ // Namespace context - Merge tests
+ //
========================================================================================
+
+ @Test
+ void testMergePreservesDominantNamespaces() throws Exception {
+ String dominant = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child mvn:combine.children="append">
+ <item>dom</item>
+ </child>
+ </root>
+ """;
+ String recessive = """
+ <root>
+ <child>
+ <item>rec</item>
+ </child>
+ </root>
+ """;
+ XmlNode merged = XmlService.merge(toXmlNode(dominant),
toXmlNode(recessive));
+
+ // The merged root should keep dominant's namespace context
+ assertEquals("http://maven.apache.org/POM/4.0.0",
merged.namespaces().get("mvn"));
+
+ // The merged child should also have the namespace context
+ XmlNode child = merged.child("child");
+ assertNotNull(child);
+ assertEquals("http://maven.apache.org/POM/4.0.0",
child.namespaces().get("mvn"));
+ }
+
+ @Test
+ void testMergeCombineChildrenAppendPreservesNamespaces() throws Exception {
+ String dominant = """
+ <root xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <items combine.children="append">
+ <item>a</item>
+ </items>
+ </root>
+ """;
+ String recessive = """
+ <root xmlns="http://maven.apache.org/POM/4.0.0">
+ <items>
+ <item>b</item>
+ </items>
+ </root>
+ """;
+ XmlNode merged = XmlService.merge(toXmlNode(dominant),
toXmlNode(recessive));
+ XmlNode items = merged.child("items");
+
+ assertEquals(2, items.children().size(), "append should merge
children");
+ // Namespace context should be preserved on the merged element
+ assertEquals("http://maven.apache.org/POM/4.0.0",
items.namespaces().get("mvn"));
+ }
+
+ @Test
+ void testMergeCombineSelfOverridePreservesNamespaces() throws Exception {
+ String dominant = """
+ <root xmlns:ns="http://example.com/ns">
+ <child combine.self="override" ns:attr="dominant">
+ <item>dom</item>
+ </child>
+ </root>
+ """;
+ String recessive = """
+ <root>
+ <child>
+ <item>rec1</item>
+ <item>rec2</item>
+ </child>
+ </root>
+ """;
+ XmlNode merged = XmlService.merge(toXmlNode(dominant),
toXmlNode(recessive));
+ XmlNode child = merged.child("child");
+
+ // override means dominant completely replaces recessive
+ assertEquals(1, child.children().size());
+ assertEquals("dom", child.children().get(0).value());
+ // Namespace context preserved
+ assertEquals("http://example.com/ns", child.namespaces().get("ns"));
+ }
+
+ @Test
+ void testMergedNodeWriteProducesValidXml() throws Exception {
+ String dominant = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child mvn:combine.children="append">
+ <item>a</item>
+ </child>
+ </root>
+ """;
+ String recessive = """
+ <root>
+ <child>
+ <item>b</item>
+ </child>
+ </root>
+ """;
+ XmlNode merged = XmlService.merge(toXmlNode(dominant),
toXmlNode(recessive));
+
+ // Write the merged child alone - it should produce valid XML
+ // because it has the namespace context from the dominant
+ XmlNode child = merged.child("child");
+ StringWriter writer = new StringWriter();
+ XmlService.write(child, writer);
+ String output = writer.toString();
+
+ // mvn:combine.children should be preserved with namespace declaration
+ assertTrue(output.contains("mvn:combine.children"), "Prefix should be
preserved from context");
+ assertTrue(output.contains("xmlns:mvn="), "Namespace should be
auto-declared");
+
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("append", reRead.attribute("mvn:combine.children"));
+ }
+
+ //
========================================================================================
+ // Namespace context - Builder tests
+ //
========================================================================================
+
+ @Test
+ void testBuilderWithExplicitNamespaces() throws Exception {
+ XmlNode node = XmlNode.newBuilder()
+ .name("elem")
+ .attributes(Map.of("ns:attr", "value"))
+ .namespaces(Map.of("ns", "http://example.com/ns"))
+ .build();
+
+ assertEquals("http://example.com/ns", node.namespaces().get("ns"));
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(node, writer);
+ String output = writer.toString();
+
+ assertTrue(output.contains("xmlns:ns="), "Namespace should be
auto-declared from builder context");
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("value", reRead.attribute("ns:attr"));
+ }
+
+ @Test
+ void testBuilderWithNullNamespacesDefaultsToEmpty() {
+ XmlNode node = XmlNode.newBuilder().name("elem").build();
+ assertNotNull(node.namespaces());
+ assertTrue(node.namespaces().isEmpty());
+ }
+
+ @Test
+ void testBuilderNamespacesAreImmutable() {
+ Map<String, String> mutableNs = new HashMap<>(Map.of("ns",
"http://example.com"));
+ XmlNode node =
XmlNode.newBuilder().name("elem").namespaces(mutableNs).build();
+
+ // Mutating the original map should not affect the node
+ mutableNs.put("other", "http://other.com");
+ assertNull(node.namespaces().get("other"));
+
+ // The namespaces map itself should be immutable
+ assertThrows(
+ UnsupportedOperationException.class, () ->
node.namespaces().put("foo", "bar"));
+ }
+
+ @Test
+ void testDefaultNamespacesMethodReturnsEmptyMap() {
+ // XmlNode built with newInstance (which doesn't set namespaces)
+ // should return empty map from the default namespaces() method
+ XmlNode node = XmlNode.newInstance("test");
+ assertNotNull(node.namespaces());
+ assertTrue(node.namespaces().isEmpty());
+ }
+
+ //
========================================================================================
+ // Namespace context - Round-trip fidelity tests
+ //
========================================================================================
+
+ @Test
+ void testRoundTripPreservesNamespaceContext() throws Exception {
+ String xml = """
+ <root xmlns:a="http://example.com/a"
xmlns:b="http://example.com/b">
+ <child a:x="1" b:y="2"/>
+ </root>
+ """;
+ XmlNode original = toXmlNode(xml);
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(original, writer);
+ XmlNode reRead = toXmlNode(writer.toString());
+
+ // Root namespace context should be preserved
+ assertEquals(original.namespaces().get("a"),
reRead.namespaces().get("a"));
+ assertEquals(original.namespaces().get("b"),
reRead.namespaces().get("b"));
+
+ // Child namespace context should be preserved
+ XmlNode origChild = original.child("child");
+ XmlNode reReadChild = reRead.child("child");
+ assertEquals(origChild.namespaces().get("a"),
reReadChild.namespaces().get("a"));
+ assertEquals(origChild.namespaces().get("b"),
reReadChild.namespaces().get("b"));
+ }
+
+ @Test
+ void testRoundTripDeepNestedStructure() throws Exception {
+ String xml = """
+ <root xmlns:ns="http://example.com/ns">
+ <level1>
+ <level2>
+ <level3 ns:deep="value">text</level3>
+ </level2>
+ </level1>
+ </root>
+ """;
+ XmlNode original = toXmlNode(xml);
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(original, writer);
+ XmlNode reRead = toXmlNode(writer.toString());
+
+ XmlNode level3 =
reRead.child("level1").child("level2").child("level3");
+ assertEquals("value", level3.attribute("ns:deep"));
+ assertEquals("text", level3.value());
+ assertEquals("http://example.com/ns", level3.namespaces().get("ns"));
+ }
+
+ @Test
+ void testRoundTripWithOverriddenNamespace() throws Exception {
+ String xml = """
+ <root xmlns:ns="http://example.com/v1">
+ <child xmlns:ns="http://example.com/v2" ns:attr="val"/>
+ </root>
+ """;
+ XmlNode original = toXmlNode(xml);
+ XmlNode child = original.child("child");
+ assertEquals("http://example.com/v2", child.namespaces().get("ns"));
+
+ // Write and re-read just the child
+ StringWriter writer = new StringWriter();
+ XmlService.write(child, writer);
+ XmlNode reRead = toXmlNode(writer.toString());
+
+ assertEquals("val", reRead.attribute("ns:attr"));
+ assertEquals("http://example.com/v2", reRead.namespaces().get("ns"));
+ }
+
+ //
========================================================================================
+ // Namespace context - Consumer POM simulation tests
+ //
========================================================================================
+
+ @Test
+ void testConsumerPomScenarioPrefixFromContext() throws Exception {
+ // Simulate: parse a full POM with xmlns:mvn on project,
mvn:combine.children on child
+ String xml = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <build>
+ <plugins>
+ <plugin>
+ <configuration>
+ <compilerArgs
mvn:combine.children="append">
+ <arg>-Xlint</arg>
+ </compilerArgs>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+ </project>
+ """;
+ XmlNode project = toXmlNode(xml);
+ XmlNode compilerArgs = project.child("build")
+ .child("plugins")
+ .child("plugin")
+ .child("configuration")
+ .child("compilerArgs");
+ assertNotNull(compilerArgs);
+ assertEquals("append", compilerArgs.attribute("mvn:combine.children"));
+ assertEquals(
+ "http://maven.apache.org/POM/4.0.0",
compilerArgs.namespaces().get("mvn"));
+
+ // Simulate consumer POM: write only the configuration subtree
+ XmlNode config =
project.child("build").child("plugins").child("plugin").child("configuration");
+ StringWriter writer = new StringWriter();
+ XmlService.write(config, writer);
+ String output = writer.toString();
+
+ // Should produce valid XML with auto-declared xmlns:mvn
+ XmlNode reRead = toXmlNode(output);
+ XmlNode reReadArgs = reRead.child("compilerArgs");
+ assertEquals("append", reReadArgs.attribute("mvn:combine.children"));
+ }
+
+ @Test
+ void testConsumerPomScenarioNoContextFallback() throws Exception {
+ // Simulate: programmatically-built XmlNode without namespace context
+ // (as might happen if someone builds configuration in code)
+ XmlNode config = XmlNode.newBuilder()
+ .name("configuration")
+ .children(List.of(XmlNode.newBuilder()
+ .name("compilerArgs")
+ .attributes(Map.of("mvn:combine.children", "append"))
+ .children(List.of(
+
XmlNode.newBuilder().name("arg").value("-Xlint").build()))
+ .build()))
+ .build();
+
+ StringWriter writer = new StringWriter();
+ XmlService.write(config, writer);
+ String output = writer.toString();
+
+ // Without namespace context, prefix should be stripped
+ assertFalse(output.contains("mvn:"), "No mvn: prefix without context");
+ XmlNode reRead = toXmlNode(output);
+ assertEquals("append",
reRead.child("compilerArgs").attribute("combine.children"));
+ }
+
+ //
========================================================================================
+ // Namespace context - Merge directive interaction tests
+ //
========================================================================================
+
+ @Test
+ void testPrefixedCombineChildrenDoesNotMerge() throws Exception {
+ String dominant = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <compilerArgs mvn:combine.children="append">
+ <arg>-Xlint:deprecation</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ String recessive = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0">
+ <compilerArgs>
+ <arg>-Xlint:unchecked</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ XmlNode dominantNode = toXmlNode(dominant);
+ XmlNode recessiveNode = toXmlNode(recessive);
+ XmlNode merged = XmlService.merge(dominantNode, recessiveNode);
+
+ XmlNode compilerArgs = merged.child("compilerArgs");
+ assertNotNull(compilerArgs);
+ assertEquals(
+ 1,
+ compilerArgs.children().size(),
+ "mvn:combine.children should not trigger append; only
unprefixed combine.children works");
+ }
+
+ @Test
+ void testUnprefixedCombineChildrenStillWorks() throws Exception {
+ String dominant = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0">
+ <compilerArgs combine.children="append">
+ <arg>-Xlint:deprecation</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ String recessive = """
+ <project xmlns="http://maven.apache.org/POM/4.0.0">
+ <compilerArgs>
+ <arg>-Xlint:unchecked</arg>
+ </compilerArgs>
+ </project>
+ """;
+
+ XmlNode dominantNode = toXmlNode(dominant);
+ XmlNode recessiveNode = toXmlNode(recessive);
+ XmlNode merged = XmlService.merge(dominantNode, recessiveNode);
+
+ XmlNode compilerArgs = merged.child("compilerArgs");
+ assertNotNull(compilerArgs);
+ assertEquals(2, compilerArgs.children().size(), "Unprefixed
combine.children=append should work");
+ }
+
+ @Test
+ void testPrefixedCombineSelfDoesNotOverride() throws Exception {
+ String dominant = """
+ <root xmlns:mvn="http://maven.apache.org/POM/4.0.0">
+ <child mvn:combine.self="override">
+ <item>dom</item>
+ </child>
+ </root>
+ """;
+ String recessive = """
+ <root>
+ <child>
+ <item>rec</item>
+ <extra>bonus</extra>
+ </child>
+ </root>
+ """;
+ XmlNode merged = XmlService.merge(toXmlNode(dominant),
toXmlNode(recessive));
+ XmlNode child = merged.child("child");
+
+ // mvn:combine.self should NOT trigger override (only unprefixed
combine.self works)
+ // Default merge behavior merges children by name
+ assertEquals("dom", child.child("item").value());
+ // The "extra" child from recessive should survive since combine.self
wasn't triggered
+ assertNotNull(child.child("extra"), "Recessive children should survive
since mvn:combine.self is ignored");
+ }
+
public static Xpp3Dom build(Reader reader) throws XmlPullParserException,
IOException {
try (Reader closeMe = reader) {
return new Xpp3Dom(XmlNodeBuilder.build(reader, true, null));
diff --git a/src/mdo/writer-stax.vm b/src/mdo/writer-stax.vm
index 9f12f7fc4e..fb2aaf7bd3 100644
--- a/src/mdo/writer-stax.vm
+++ b/src/mdo/writer-stax.vm
@@ -366,14 +366,7 @@ public class ${className} {
private void writeDom(XmlNode dom, XMLStreamWriter serializer) throws
IOException, XMLStreamException {
if (dom != null) {
serializer.writeStartElement(namespace, dom.name());
- for (Map.Entry<String, String> attr : dom.attributes().entrySet())
{
- if (attr.getKey().startsWith("xml:")) {
-
serializer.writeAttribute("http://www.w3.org/XML/1998/namespace",
- attr.getKey().substring(4), attr.getValue());
- } else {
- serializer.writeAttribute(attr.getKey(), attr.getValue());
- }
- }
+ writeXmlNodeAttributes(serializer, dom.attributes(),
dom.namespaces());
for (XmlNode child : dom.children()) {
writeDom(child, serializer);
}
@@ -410,6 +403,56 @@ public class ${className} {
serializer.writeAttribute(attrName, value);
}
}
+
+ /**
+ * 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, 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:")) {
+ String prefix = key.substring(6);
+ serializer.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();
+ if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
+ continue; // already written above
+ } else if (key.startsWith("xml:")) {
+ serializer.writeAttribute(
+ "http://www.w3.org/XML/1998/namespace",
key.substring(4), value);
+ } else if (key.contains(":")) {
+ int colon = key.indexOf(':');
+ 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 {
+ serializer.writeAttribute(localName, value);
+ }
+ } else {
+ serializer.writeAttribute(key, value);
+ }
+ }
+ }
#if ( $locationTracking )
/**
diff --git a/src/mdo/writer.vm b/src/mdo/writer.vm
index 3795700a2d..6a63c6e3d4 100644
--- a/src/mdo/writer.vm
+++ b/src/mdo/writer.vm
@@ -252,9 +252,7 @@ public class ${className} {
private void writeDom(XmlNode dom, XmlSerializer serializer) throws
IOException {
if (dom != null) {
serializer.startTag(NAMESPACE, dom.getName());
- for (Map.Entry<String, String> attr :
dom.getAttributes().entrySet()) {
- serializer.attribute(NAMESPACE, attr.getKey(),
attr.getValue());
- }
+ writeXmlNodeAttributes(serializer, dom.getAttributes(),
dom.namespaces());
for (XmlNode child : dom.getChildren()) {
writeDom(child, serializer);
}
@@ -266,6 +264,56 @@ 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(XmlSerializer serializer,
Map<String, String> attributes, Map<String, String> namespaces) throws
IOException {
+ // 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.setPrefix("", attribute.getValue());
+ } else if (key.startsWith("xmlns:")) {
+ String prefix = key.substring(6);
+ serializer.setPrefix(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();
+ if ("xmlns".equals(key) || key.startsWith("xmlns:")) {
+ continue; // already handled above
+ } else if (key.startsWith("xml:")) {
+ serializer.attribute("http://www.w3.org/XML/1998/namespace",
key.substring(4), value);
+ } else if (key.contains(":")) {
+ int colon = key.indexOf(':');
+ 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.setPrefix(prefix, nsUri);
+ }
+ serializer.attribute(nsUri, localName, value);
+ } else {
+ // No namespace declaration for this prefix; write as
unprefixed
+ serializer.attribute(NAMESPACE, localName, value);
+ }
+ } else {
+ serializer.attribute(NAMESPACE, key, value);
+ }
+ }
+ }
+
private void writeTag(String tagName, String defaultValue, String value,
XmlSerializer serializer) throws IOException {
if (value != null && !Objects.equals(defaultValue, value)) {
serializer.startTag(NAMESPACE,
tagName).text(value).endTag(NAMESPACE, tagName);