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 a78357a09109..a9634585d5d2 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. + *

+ * 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 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 attributes; + private Map namespaces; private List children; private Object inputLocation; @@ -421,6 +440,21 @@ public Builder attributes(Map attributes) { return this; } + /** + * Sets the namespace context for this node. + *

+ * 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 namespaces) { + this.namespaces = namespaces; + return this; + } + /** * Sets the child nodes of the XML node. *

@@ -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 attributes, + @Nonnull Map namespaces, @Nonnull List 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 e97a2213805e..4b0bb6fbf063 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 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 attrs = null; + Map nsContext = null; List 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 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 attributes, Map 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 declaredPrefixes = new HashSet<>(); + for (Map.Entry 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 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.

* The algorithm is as follows: @@ -367,6 +449,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 e9805171948d..4a50e0e6cab2 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 = """ + + + + """; + XmlNode node = toXmlNode(xml); + assertEquals("http://maven.apache.org/POM/4.0.0", node.namespaces().get("mvn")); + } + + @Test + void testParseNamespaceContextMultiplePrefixes() throws Exception { + String xml = """ + + + + """; + 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 = """ + + + + """; + 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 = """ + + + + + + + + """; + 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 = """ + + + + """; + 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 = """ + + + + + + """; + 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 = ""; + 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 = """ + + + + """; + XmlNode node = toXmlNode(xml); + assertThrows( + UnsupportedOperationException.class, () -> node.namespaces().put("foo", "bar")); + } + + // ======================================================================================== + // Namespace context - Writing tests + // ======================================================================================== + + @Test + void testWriteWithNamespaceDeclarationsAndPrefixedAttributes() throws Exception { + String xml = """ + + + -Xlint:deprecation + + + """; + + 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 = """ + + + -Xlint:deprecation + + + """; + + 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 = """ + + + + """; + 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 = """ + content with spaces + """; + 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 = """ + + + + + + """; + 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 = """ + + + dom + + + """; + String recessive = """ + + + rec + + + """; + 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 = """ + + + a + + + """; + String recessive = """ + + + b + + + """; + 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 = """ + + + dom + + + """; + String recessive = """ + + + rec1 + rec2 + + + """; + 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 = """ + + + a + + + """; + String recessive = """ + + + b + + + """; + 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 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 = """ + + + + """; + 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 = """ + + + + text + + + + """; + 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 = """ + + + + """; + 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 = """ + + + + + + + -Xlint + + + + + + + """; + 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 = """ + + + -Xlint:deprecation + + + """; + + String recessive = """ + + + -Xlint:unchecked + + + """; + + 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 = """ + + + -Xlint:deprecation + + + """; + + String recessive = """ + + + -Xlint:unchecked + + + """; + + 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 = """ + + + dom + + + """; + String recessive = """ + + + rec + bonus + + + """; + 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 9f12f7fc4e51..fb2aaf7bd3a2 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 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 attributes, Map namespaces) throws XMLStreamException { + // Collect which namespace prefixes need to be declared on this element + Set declaredPrefixes = new HashSet<>(); + for (Map.Entry 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 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 3795700a2ddf..6a63c6e3d4ef 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 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 attributes, Map namespaces) throws IOException { + // Collect which namespace prefixes need to be declared on this element + Set declaredPrefixes = new HashSet<>(); + for (Map.Entry 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 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);