diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java index 22a414439..c55327925 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/DirectoryMerger.java @@ -2,6 +2,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.merge.xmlmerger.XmlMerger; import com.devonfw.tools.ide.util.FilenameUtil; import org.jline.utils.Log; diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/XmlMerger.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/XmlMerger.java similarity index 66% rename from cli/src/main/java/com/devonfw/tools/ide/merge/XmlMerger.java rename to cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/XmlMerger.java index ae25cf5f9..7ba3bf6c5 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/merge/XmlMerger.java +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/XmlMerger.java @@ -1,16 +1,13 @@ -package com.devonfw.tools.ide.merge; - -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; +package com.devonfw.tools.ide.merge.xmlmerger; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.environment.EnvironmentVariables; +import com.devonfw.tools.ide.merge.FileMerger; +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import com.devonfw.tools.ide.merge.xmlmerger.strategy.OverrideStrategy; +import com.devonfw.tools.ide.merge.xmlmerger.strategy.Strategy; +import com.devonfw.tools.ide.merge.xmlmerger.strategy.StrategyFactory; import org.w3c.dom.Attr; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -19,18 +16,25 @@ import org.w3c.dom.NodeList; import org.w3c.dom.Text; -import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.environment.EnvironmentVariables; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; -/** - * Implementation of {@link FileMerger} for XML files. - */ public class XmlMerger extends FileMerger { private static final DocumentBuilder DOCUMENT_BUILDER; private static final TransformerFactory TRANSFORMER_FACTORY; + public static final String MERGE_NS_URI = "https://github.com/devonfw/IDEasy/merge"; + static { try { DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); @@ -42,11 +46,6 @@ public class XmlMerger extends FileMerger { } } - /** - * The constructor. - * - * @param context the {@link #context}. - */ public XmlMerger(IdeContext context) { super(context); @@ -70,55 +69,18 @@ public void merge(Path setup, Path update, EnvironmentVariables resolver, Path w document = load(update); } else { Document updateDocument = load(update); - merge(updateDocument, document, true, true); + merge(updateDocument, document); } } resolve(document, resolver, false, workspace.getFileName()); save(document, workspace); } - private void merge(Document sourceDocument, Document targetDocument, boolean override, boolean add) { - - assert (override || add); - merge(sourceDocument.getDocumentElement(), targetDocument.getDocumentElement(), override, add); - } - - private void merge(Element sourceElement, Element targetElement, boolean override, boolean add) { - - merge(sourceElement.getAttributes(), targetElement, override, add); - NodeList sourceChildNodes = sourceElement.getChildNodes(); - int length = sourceChildNodes.getLength(); - for (int i = 0; i < length; i++) { - Node child = sourceChildNodes.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE) { + public void merge(Document sourceDocument, Document targetDocument) { - } else if (child.getNodeType() == Node.TEXT_NODE) { - - } else if (child.getNodeType() == Node.CDATA_SECTION_NODE) { - - } - } - } - - private void merge(NamedNodeMap sourceAttributes, Element targetElement, boolean override, boolean add) { - - int length = sourceAttributes.getLength(); - for (int i = 0; i < length; i++) { - Attr sourceAttribute = (Attr) sourceAttributes.item(i); - String namespaceURI = sourceAttribute.getNamespaceURI(); - // String localName = sourceAttribute.getLocalName(); - String name = sourceAttribute.getName(); - Attr targetAttribute = targetElement.getAttributeNodeNS(namespaceURI, name); - if (targetAttribute == null) { - if (add) { - // ridiculous but JDK does not provide namespace support by default... - targetElement.setAttributeNS(namespaceURI, name, sourceAttribute.getValue()); - // targetElement.setAttribute(name, sourceAttribute.getValue()); - } - } else if (override) { - targetAttribute.setValue(sourceAttribute.getValue()); - } - } + MergeElement updateRootElement = new MergeElement(sourceDocument.getDocumentElement()); + Strategy strategy = StrategyFactory.createStrategy(updateRootElement.getMergingStrategy(), new ElementMatcher()); + strategy.merge(updateRootElement, targetDocument); } @Override @@ -129,17 +91,15 @@ public void inverseMerge(Path workspace, EnvironmentVariables variables, boolean } Document updateDocument = load(update); Document workspaceDocument = load(workspace); - merge(workspaceDocument, updateDocument, true, addNewProperties); + Strategy strategy = new OverrideStrategy(null); + MergeElement rootElement = new MergeElement(workspaceDocument.getDocumentElement()); + strategy.merge(rootElement, updateDocument); resolve(updateDocument, variables, true, workspace.getFileName()); save(updateDocument, update); this.context.debug("Saved changes in {} to {}", workspace.getFileName(), update); } - /** - * @param file the {@link Path} to load. - * @return the loaded XML {@link Document}. - */ - public static Document load(Path file) { + public Document load(Path file) { try (InputStream in = Files.newInputStream(file)) { return DOCUMENT_BUILDER.parse(in); @@ -148,22 +108,43 @@ public static Document load(Path file) { } } - /** - * @param document the XML {@link Document} to save. - * @param file the {@link Path} to save to. - */ - public static void save(Document document, Path file) { + public void save(Document document, Path file) { ensureParentDirectoryExists(file); try { Transformer transformer = TRANSFORMER_FACTORY.newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty(OutputKeys.STANDALONE, "no"); + + // Remove whitespace from the target document before saving, because if target XML Document is already formatted + // then indent 2 keeps adding empty lines for nothing, and if we don't use indentation then appending/ overriding + // isn't properly formatted. + removeWhitespace(document.getDocumentElement()); + DOMSource source = new DOMSource(document); StreamResult result = new StreamResult(file.toFile()); transformer.transform(source, result); } catch (Exception e) { throw new IllegalStateException("Failed to save XML to file: " + file, e); } + } + private void removeWhitespace(Node node) { + + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.TEXT_NODE) { + if (child.getTextContent().trim().isEmpty()) { + node.removeChild(child); + i--; + } + } else { + removeWhitespace(child); + } + } } private void resolve(Document document, EnvironmentVariables resolver, boolean inverse, Object src) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/ElementMatcher.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/ElementMatcher.java new file mode 100644 index 000000000..95f4d4832 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/ElementMatcher.java @@ -0,0 +1,75 @@ +package com.devonfw.tools.ide.merge.xmlmerger.matcher; + +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javax.xml.namespace.QName; +import java.util.HashMap; +import java.util.Map; + +/** + * The ElementMatcher class is responsible for matching XML elements in a target document based on the provided update elements. + */ +public class ElementMatcher { + + private final Map qNameIdMap; + + public ElementMatcher() { + + qNameIdMap = new HashMap<>(); + } + + /** + * Updates the ID strategy for a given QName (qualified name) of an XML element. + * + * @param qname the QName of the XML element + * @param id the ID value to be used for matching the element + */ + public void updateId(QName qname, String id) { + + qNameIdMap.put(qname, new IdComputer(id)); + } + + /** + * Matches an update element in the target document. + * + * @param updateElement the update element to be matched + * @param targetDocument the target document in which to match the element + * @return the matched MergeElement if found, or {@code null} if not found + */ + public MergeElement matchElement(MergeElement updateElement, Document targetDocument) { + + if (updateElement.isRootElement()) { + Element sourceRoot = updateElement.getElement(); + Element targetRoot = targetDocument.getDocumentElement(); + if (sourceRoot.getNamespaceURI() != null || targetRoot.getNamespaceURI() != null) { + if (!sourceRoot.getNamespaceURI().equals(targetRoot.getNamespaceURI())) { + throw new IllegalStateException("URI of elements don't match. Found " + sourceRoot.getNamespaceURI() + "and " + targetRoot.getNamespaceURI()); + } + } + return new MergeElement(targetRoot); + } + + String id = updateElement.getId(); + if (id.isEmpty()) { + IdComputer idComputer = qNameIdMap.get(updateElement.getQName()); + if (idComputer == null) { + throw new IllegalStateException("no Id value was defined for " + updateElement.getXPath()); + } + Element matchedNode = idComputer.evaluateExpression(updateElement, targetDocument); + if (matchedNode != null) { + return new MergeElement(matchedNode); + } + } else { + updateId(updateElement.getQName(), id); + IdComputer idComputer = qNameIdMap.get(updateElement.getQName()); + Element matchedNode = idComputer.evaluateExpression(updateElement, targetDocument); + if (matchedNode != null) { + return new MergeElement(matchedNode); + } + } + + return null; + } +} \ No newline at end of file diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/IdComputer.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/IdComputer.java new file mode 100644 index 000000000..53ceb8c5b --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/matcher/IdComputer.java @@ -0,0 +1,74 @@ +package com.devonfw.tools.ide.merge.xmlmerger.matcher; + +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +/** + * The IdComputer class is responsible for building XPath expressions and evaluating those expressions to match elements in a target document. + */ +public class IdComputer { + + private final String id; + + private static XPathFactory xPathFactory = XPathFactory.newInstance(); + + public IdComputer(String id) { + + this.id = id; + } + + /** + * Evaluates the XPath expression for the given merge element in the target document. + * + * @param mergeElement the merge element for which to build the XPath expression + * @param targetDocument the target document in which to evaluate the XPath expression + * @return the matched Element if found, or null if not found + */ + + public Element evaluateExpression(MergeElement mergeElement, Document targetDocument) { + + try { + XPath xpath = xPathFactory.newXPath(); + String xpathExpr = buildXPathExpression(mergeElement); + XPathExpression xpathExpression = xpath.compile(xpathExpr); + return (Element) xpathExpression.evaluate(targetDocument, XPathConstants.NODE); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Failed to match " + mergeElement.getXPath(), e); + } + } + + /** + * Builds the XPath expression for the given merge element based on the ID value. + * + * @param mergeElement the merge element for which to build the XPath expression + * @return the XPath expression as a String + */ + private String buildXPathExpression(MergeElement mergeElement) { + + String xPath = mergeElement.getXPath(); + if (id.startsWith(".")) { + return xPath + "/" + id; + } else if (id.startsWith("/")) { + return id; + } else if (id.startsWith("@")) { + String attributeName = id.substring(1); + String attributeValue = mergeElement.getElement().getAttribute(attributeName); + return xPath + String.format("[@%s='%s']", attributeName, attributeValue); + } else if (id.equals("name()")) { + String tagName = mergeElement.getElement().getTagName(); + return xPath + String.format("[name()='%s']", tagName); + } else if (id.equals("text()")) { + String textContent = mergeElement.getElement().getTextContent(); + return xPath + String.format("[text()='%s']", textContent); + } + return null; + } + +} \ No newline at end of file diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeAttribute.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeAttribute.java new file mode 100644 index 000000000..2d6e73455 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeAttribute.java @@ -0,0 +1,55 @@ +package com.devonfw.tools.ide.merge.xmlmerger.model; + +import com.devonfw.tools.ide.merge.xmlmerger.XmlMerger; +import org.w3c.dom.Attr; + +/** + * Represents an attribute of a {@link MergeElement} during the merging process. + */ +public class MergeAttribute { + + /** + * The attribute represented by this MergeAttribute. + */ + private final Attr attr; + + public MergeAttribute(Attr attr) { + + this.attr = attr; + } + + public Attr getAttr() { + + return attr; + } + + public String getName() { + + return attr.getName(); + } + + public String getValue() { + + return attr.getValue(); + } + + /** + * Checks if the attribute is a merge namespace attribute. + * + * @return {@code true} if the attribute is a merge namespace attribute, otherwise {@code false} + */ + public boolean isMergeNSAttr() { + + return XmlMerger.MERGE_NS_URI.equals(attr.getNamespaceURI()) || XmlMerger.MERGE_NS_URI.equals(attr.getValue()); + } + + /** + * Checks if the attribute is a merge namespace id attribute. + * + * @return {@code true} if the attribute is a merge namespace id attribute, otherwise {@code false} + */ + public boolean isMergeNsIdAttr() { + + return isMergeNSAttr() && attr.getLocalName().equals("id"); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeElement.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeElement.java new file mode 100644 index 000000000..804e159d3 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/model/MergeElement.java @@ -0,0 +1,173 @@ +package com.devonfw.tools.ide.merge.xmlmerger.model; + +import com.devonfw.tools.ide.merge.xmlmerger.XmlMerger; +import com.devonfw.tools.ide.merge.xmlmerger.strategy.MergeStrategy; +import org.w3c.dom.*; + +import javax.xml.namespace.QName; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an XML element during the merge process. + */ +public class MergeElement { + + /** + * The XML element represented by this MergeElement. + */ + private final Element element; + + /** + * @param element the XML element + */ + public MergeElement(Element element) { + + this.element = element; + } + + public Element getElement() { + + return element; + } + + /** + * Retrieves the merge strategy associated with this MergeElement. + * + * @return the merge strategy type + */ + public MergeStrategy getMergingStrategy() { + + String strategy = this.element.getAttributeNS(XmlMerger.MERGE_NS_URI, "strategy").toLowerCase(); + if ("combine".equals(strategy)) { + return MergeStrategy.COMBINE; + } else if ("override".equals(strategy)) { + return MergeStrategy.OVERRIDE; + } else if ("keep".equals(strategy)) { + return MergeStrategy.KEEP; + } + + // Inherit merging strategy from parent + Element parent = getParentElement(); + if (parent != null) { + return new MergeElement(parent).getMergingStrategy(); + } + + return MergeStrategy.KEEP; // Default strategy + } + + /** + * Retrieves the value of the merge:id attribute of this MergeElement. + * + * @return the ID attribute value + */ + public String getId() { + + return this.element.getAttributeNS(XmlMerger.MERGE_NS_URI, "id"); + } + + /** + * Retrieves the qualified name (URI + local name) of this MergeElement. + * + * @return the QName + */ + public QName getQName() { + + String namespaceURI = this.element.getNamespaceURI(); + String localName = this.element.getLocalName(); + return new QName(namespaceURI, localName); + } + + /** + * Retrieves the parent element of this MergeElement. + * + * @return the parent element, or {@code null} if there is no parent + */ + private Element getParentElement() { + + Node parentNode = element.getParentNode(); + if (parentNode != null && parentNode.getNodeType() == Node.ELEMENT_NODE) { + return (Element) parentNode; + } + return null; + } + + /** + * Retrieves the attributes of this MergeElement. + * + * @return a list of {@link MergeAttribute} objects representing the attributes, if there are no attributes, the list is empty. + */ + public List getElementAttributes() { + + NamedNodeMap attributes = element.getAttributes(); + List attributeList = new ArrayList<>(); + for (int i = 0; i < attributes.getLength(); i++) { + attributeList.add(new MergeAttribute((Attr) attributes.item(i))); + } + return attributeList; + } + + /** + * Checks if this MergeElement is a root element. + * + * @return {@code true} if this element is a root element, {@code false} otherwise + */ + public boolean isRootElement() { + + return element.getParentNode().getNodeType() == Node.DOCUMENT_NODE; + } + + /** + * Removes merge namespace attributes from this MergeElement. + */ + public void removeMergeNsAttributes() { + + List attributes = getElementAttributes(); + try { + for (MergeAttribute attribute : attributes) { + if (attribute.isMergeNSAttr()) { + element.removeAttributeNode(attribute.getAttr()); + } + } + } catch (DOMException e) { + throw new IllegalStateException("Failed to remove merge namespace attributes for element:" + getXPath(), e); + } + } + + /** + * Retrieves the XPath of this MergeElement with no criterion. E.g. /root/.../element + * + * @return the XPath + */ + public String getXPath() { + + StringBuilder xpath = new StringBuilder(); + Node current = element; + while (current != null && current.getNodeType() == Node.ELEMENT_NODE) { + Element currentElement = (Element) current; + String tagName = currentElement.getTagName(); + xpath.insert(0, "/" + tagName); + current = current.getParentNode(); + } + return xpath.toString(); + } + + /** + * Retrieves the child elements of this MergeElement. + * + * @return a list of {@link MergeElement} objects representing the child elements + */ + public List getChildElements() { + + List childElements = new ArrayList<>(); + NodeList nodeList = element.getChildNodes(); + + for (int i = 0; i < nodeList.getLength(); i++) { + Node node = nodeList.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + childElements.add(new MergeElement((Element) node)); + } + } + return childElements; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/AbstractStrategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/AbstractStrategy.java new file mode 100644 index 000000000..234c9769f --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/AbstractStrategy.java @@ -0,0 +1,85 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeAttribute; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +/** + * Abstract base class for merge strategies. + */ +public abstract class AbstractStrategy implements Strategy { + + /** + * The matcher for matching elements in the XML document. + */ + protected final ElementMatcher elementMatcher; + + /** + * @param elementMatcher the element matcher used for matching elements + */ + public AbstractStrategy(ElementMatcher elementMatcher) { + + this.elementMatcher = elementMatcher; + } + + @Override + public void merge(MergeElement updateElement, Document targetDocument) { + + MergeElement targetElement = elementMatcher.matchElement(updateElement, targetDocument); + if (targetElement != null) { + mergeElement(updateElement, targetElement); + } else { + appendElement(updateElement, targetDocument); + } + } + + /** + * Merges the update element with the target element. + * + * @param sourceElement the source element containing merge annotations + * @param targetElement the target element to be merged into + */ + protected abstract void mergeElement(MergeElement sourceElement, MergeElement targetElement); + + /** + * Appends the update element to the target document. + * + * @param updateElement the element to be appended + * @param targetDocument the target document + */ + protected void appendElement(MergeElement updateElement, Document targetDocument) { + + try { + updateAndRemoveNsAttributes(updateElement); + Element parent = (Element) updateElement.getElement().getParentNode(); + MergeElement matchParent = elementMatcher.matchElement(new MergeElement(parent), targetDocument); + if (matchParent != null) { + Element importedNode = (Element) targetDocument.importNode(updateElement.getElement(), true); + matchParent.getElement().appendChild(importedNode); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to append element: " + updateElement.getXPath(), e); + } + } + + /** + * Updates the {@link ElementMatcher} and removes namespace attributes from the merge element. + * + * @param mergeElement the merge element whose id is to be updated and merge namespace attributes removed. + */ + protected void updateAndRemoveNsAttributes(MergeElement mergeElement) { + + for (MergeAttribute attribute : mergeElement.getElementAttributes()) { + if (attribute.isMergeNsIdAttr()) { + elementMatcher.updateId(mergeElement.getQName(), attribute.getValue()); + } + } + mergeElement.removeMergeNsAttributes(); + + for (MergeElement childElement : mergeElement.getChildElements()) { + updateAndRemoveNsAttributes(childElement); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/CombineStrategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/CombineStrategy.java new file mode 100644 index 000000000..9523662bb --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/CombineStrategy.java @@ -0,0 +1,104 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeAttribute; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.DOMException; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +/** + * Merge Strategy implementation for combining XML elements. + */ +public class CombineStrategy extends AbstractStrategy { + + /** + * @param elementMatcher the element matcher used for matching elements + */ + public CombineStrategy(ElementMatcher elementMatcher) { + + super(elementMatcher); + } + + @Override + protected void mergeElement(MergeElement sourceElement, MergeElement targetElement) { + + combineAttributes(sourceElement, targetElement); + combineChildNodes(sourceElement, targetElement); + } + + /** + * Combines attributes from the update element into the target element. + * + * @param updateElement the element with the new attributes + * @param targetElement the element to receive the new attributes + */ + private void combineAttributes(MergeElement updateElement, MergeElement targetElement) { + + try { + for (MergeAttribute updateAttr : updateElement.getElementAttributes()) { + if (!updateAttr.isMergeNSAttr()) { + String namespaceURI = updateAttr.getAttr().getNamespaceURI(); + String attrName = updateAttr.getAttr().getLocalName(); + targetElement.getElement().setAttributeNS(namespaceURI, attrName, updateAttr.getValue()); + } + } + } catch (DOMException e) { + throw new IllegalStateException("Failed to combine attributes for element: " + updateElement.getXPath(), e); + } + } + + /** + * Combines child nodes from the update element into the target element. + * + * @param updateElement the element with the new child nodes + * @param targetElement the element to receive the new child nodes + */ + private void combineChildNodes(MergeElement updateElement, MergeElement targetElement) { + + try { + NodeList updateChildNodes = updateElement.getElement().getChildNodes(); + for (int i = 0; i < updateChildNodes.getLength(); i++) { + Node updateChild = updateChildNodes.item(i); + if (updateChild.getNodeType() == Node.ELEMENT_NODE) { + MergeElement mergeUpdateChild = new MergeElement((Element) updateChild); + Strategy strategy = StrategyFactory.createStrategy(mergeUpdateChild.getMergingStrategy(), elementMatcher); + strategy.merge(mergeUpdateChild, targetElement.getElement().getOwnerDocument()); + } else if (updateChild.getNodeType() == Node.TEXT_NODE || updateChild.getNodeType() == Node.CDATA_SECTION_NODE) { + if (!updateChild.getTextContent().isBlank()) { + replaceTextNode(targetElement.getElement(), updateChild); + } + } + } + } catch (DOMException e) { + throw new IllegalStateException("Failed to combine child nodes for element: " + updateElement.getXPath(), e); + } + } + + /** + * Replaces the text node in the target element with the text from the update element, otherwise appends it. + * + * @param targetElement the element to be updated + * @param updateChild the new text node + */ + private void replaceTextNode(Element targetElement, Node updateChild) { + + try { + NodeList targetChildNodes = targetElement.getChildNodes(); + for (int i = 0; i < targetChildNodes.getLength(); i++) { + Node targetChild = targetChildNodes.item(i); + if (targetChild.getNodeType() == Node.TEXT_NODE || targetChild.getNodeType() == Node.CDATA_SECTION_NODE) { + if (!targetChild.getTextContent().isBlank()) { + targetChild.setTextContent(updateChild.getTextContent().trim()); + return; + } + } + } + Node importedNode = targetElement.getOwnerDocument().importNode(updateChild, true); + targetElement.appendChild(importedNode); + } catch (DOMException e) { + throw new IllegalStateException("Failed to replace text node for element: " + targetElement.getTagName(), e); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/KeepStrategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/KeepStrategy.java new file mode 100644 index 000000000..a3bb3292c --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/KeepStrategy.java @@ -0,0 +1,24 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; + +/** + * Merge strategy that keeps existing element without any changes. + */ +public class KeepStrategy extends AbstractStrategy { + + /** + * @param elementMatcher the element matcher used for matching elements + */ + public KeepStrategy(ElementMatcher elementMatcher) { + + super(elementMatcher); + } + + @Override + protected void mergeElement(MergeElement sourceElement, MergeElement targetElement) { + + // Do nothing, keep the existing element + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/MergeStrategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/MergeStrategy.java new file mode 100644 index 000000000..20dc79c33 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/MergeStrategy.java @@ -0,0 +1,24 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +/** + * Enum of merge strategies for XML elements. + */ +public enum MergeStrategy { + + /** + * Combines source and target elements. Overrides text nodes and attributes. This process is recursively applied to child elements. If the source element + * exists in the target document, they are combined, otherwise, the source element is appended. + */ + COMBINE, + + /** + * Replaces the target element with the source element, without considering child elements. If the element exists in the target, it is overridden, otherwise, + * it is appended. + */ + OVERRIDE, + + /** + * Keeps the existing target element intact if the source element exists in the target document, otherwise, it is appended. + */ + KEEP, +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/OverrideStrategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/OverrideStrategy.java new file mode 100644 index 000000000..010628138 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/OverrideStrategy.java @@ -0,0 +1,50 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +/** + * Merge strategy that overrides existing elements with new elements. + */ +public class OverrideStrategy extends AbstractStrategy { + + /** + * @param elementMatcher the element matcher used for matching elements + */ + public OverrideStrategy(ElementMatcher elementMatcher) { + + super(elementMatcher); + } + + @Override + protected void mergeElement(MergeElement sourceElement, MergeElement targetElement) { + + overrideElement(sourceElement, targetElement); + } + + /** + * Overrides the target element with the source element. + * + * @param sourceElement the source element to be merged + * @param targetElement the target element to be overridden + */ + private void overrideElement(MergeElement sourceElement, MergeElement targetElement) { + + try { + updateAndRemoveNsAttributes(sourceElement); + Node targetNode = targetElement.getElement(); + Node parentNode = targetNode.getParentNode(); + Document targetDocument = targetNode.getOwnerDocument(); + Node importedNode = targetDocument.importNode(sourceElement.getElement(), true); + if (parentNode.getNodeType() == Node.DOCUMENT_NODE) { + targetDocument.replaceChild(importedNode, targetDocument.getDocumentElement()); + } else { + parentNode.replaceChild(importedNode, targetNode); + } + } catch (Exception e) { + throw new IllegalStateException("Failed to override element: " + sourceElement.getXPath(), e); + } + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/Strategy.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/Strategy.java new file mode 100644 index 000000000..2f352e159 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/Strategy.java @@ -0,0 +1,18 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.model.MergeElement; +import org.w3c.dom.Document; + +/** + * Strategy interface for defining merge strategies. + */ +public interface Strategy { + + /** + * Merges the given update element into the target document. + * + * @param updateElement the element to be merged + * @param targetDocument the target document where the element will be merged + */ + void merge(MergeElement updateElement, Document targetDocument); +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/StrategyFactory.java b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/StrategyFactory.java new file mode 100644 index 000000000..c5d4fe53f --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/merge/xmlmerger/strategy/StrategyFactory.java @@ -0,0 +1,30 @@ +package com.devonfw.tools.ide.merge.xmlmerger.strategy; + +import com.devonfw.tools.ide.merge.xmlmerger.matcher.ElementMatcher; + +/** + * Factory class for creating merge strategies. + */ +public class StrategyFactory { + + /** + * Creates a merge strategy based on the specified merge strategy type. + * + * @param strategy the merge strategy type + * @return the corresponding merge strategy + * @throws IllegalArgumentException if the merge strategy type is unknown + */ + public static Strategy createStrategy(MergeStrategy strategy, ElementMatcher matcher) { + + switch (strategy) { + case COMBINE: + return new CombineStrategy(matcher); + case OVERRIDE: + return new OverrideStrategy(matcher); + case KEEP: + return new KeepStrategy(matcher); + default: + throw new IllegalArgumentException("Unknown merge strategy: " + strategy); + } + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerTest.java b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerTest.java index b2b73b693..5b2bec1bb 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/DirectoryMergerTest.java @@ -93,7 +93,8 @@ public void testConfigurator(@TempDir Path workspaceDir) throws Exception { Properties indent = PropertiesMerger.load(indentFile); assertThat(indent).containsOnly(INDENTATION); assertThat(configFolder.resolve("layout.xml")).hasContent(""" - + + navigator debugger editor diff --git a/cli/src/test/java/com/devonfw/tools/ide/merge/XmlMergerTest.java b/cli/src/test/java/com/devonfw/tools/ide/merge/XmlMergerTest.java new file mode 100644 index 000000000..5752756a9 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/merge/XmlMergerTest.java @@ -0,0 +1,54 @@ +package com.devonfw.tools.ide.merge; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.merge.xmlmerger.XmlMerger; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +class XmlMergerTest extends AbstractIdeContextTest { + + private static final Path TEST_RESOURCES = Path.of("src", "test", "resources", "xmlmerger"); + + private static final String SOURCE_XML = "source.xml"; + + private static final String TARGET_XML = "target.xml"; + + private static final String RESULT_XML = "result.xml"; + + private IdeContext context = newContext(PROJECT_BASIC, null, false); + + private XmlMerger merger = new XmlMerger(context); + + @Test + void testAllCases(@TempDir Path tempDir) throws Exception { + + try(Stream folders = Files.list(TEST_RESOURCES)) { + // arrange + SoftAssertions softly = new SoftAssertions(); + folders.forEach(folder -> { + Path sourcePath = folder.resolve(SOURCE_XML); + Path targetPath = tempDir.resolve(TARGET_XML); + Path resultPath = folder.resolve(RESULT_XML); + try { + Files.copy(folder.resolve(TARGET_XML), targetPath, REPLACE_EXISTING); + // act + merger.merge(null, sourcePath, context.getVariables(), targetPath); + // assert + softly.assertThat(targetPath).hasContent(Files.readString(resultPath)); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + softly.assertAll(); + } + } +} \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/append/result.xml b/cli/src/test/resources/xmlmerger/append/result.xml new file mode 100644 index 000000000..9127f09ff --- /dev/null +++ b/cli/src/test/resources/xmlmerger/append/result.xml @@ -0,0 +1,5 @@ + + + Some text + Some text + diff --git a/cli/src/test/resources/xmlmerger/append/source.xml b/cli/src/test/resources/xmlmerger/append/source.xml new file mode 100644 index 000000000..397beadd3 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/append/source.xml @@ -0,0 +1,4 @@ + + + Some text + diff --git a/cli/src/test/resources/xmlmerger/append/target.xml b/cli/src/test/resources/xmlmerger/append/target.xml new file mode 100644 index 000000000..bef38b3fc --- /dev/null +++ b/cli/src/test/resources/xmlmerger/append/target.xml @@ -0,0 +1,4 @@ + + + Some text + diff --git a/cli/src/test/resources/xmlmerger/combine/result.xml b/cli/src/test/resources/xmlmerger/combine/result.xml new file mode 100644 index 000000000..408608b77 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combine/result.xml @@ -0,0 +1,4 @@ + + + New text + diff --git a/cli/src/test/resources/xmlmerger/combine/source.xml b/cli/src/test/resources/xmlmerger/combine/source.xml new file mode 100644 index 000000000..44d6182ba --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combine/source.xml @@ -0,0 +1,4 @@ + + + New text + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/combine/target.xml b/cli/src/test/resources/xmlmerger/combine/target.xml new file mode 100644 index 000000000..e47b737dd --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combine/target.xml @@ -0,0 +1,4 @@ + + + Old text + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/combineNested/result.xml b/cli/src/test/resources/xmlmerger/combineNested/result.xml new file mode 100644 index 000000000..52581d63f --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combineNested/result.xml @@ -0,0 +1,6 @@ + + + + New text + + diff --git a/cli/src/test/resources/xmlmerger/combineNested/source.xml b/cli/src/test/resources/xmlmerger/combineNested/source.xml new file mode 100644 index 000000000..38c6ae43c --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combineNested/source.xml @@ -0,0 +1,6 @@ + + + + New text + + diff --git a/cli/src/test/resources/xmlmerger/combineNested/target.xml b/cli/src/test/resources/xmlmerger/combineNested/target.xml new file mode 100644 index 000000000..d929ef87e --- /dev/null +++ b/cli/src/test/resources/xmlmerger/combineNested/target.xml @@ -0,0 +1,6 @@ + + + + Old text + + diff --git a/cli/src/test/resources/xmlmerger/id/result.xml b/cli/src/test/resources/xmlmerger/id/result.xml new file mode 100644 index 000000000..20ffd6ae4 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/id/result.xml @@ -0,0 +1,7 @@ + + + New text + New text 1 + id + New text 3 + diff --git a/cli/src/test/resources/xmlmerger/id/source.xml b/cli/src/test/resources/xmlmerger/id/source.xml new file mode 100644 index 000000000..293a2dac2 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/id/source.xml @@ -0,0 +1,7 @@ + + + New text + New text 1 + id + New text 3 + diff --git a/cli/src/test/resources/xmlmerger/id/target.xml b/cli/src/test/resources/xmlmerger/id/target.xml new file mode 100644 index 000000000..d7d241f18 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/id/target.xml @@ -0,0 +1,7 @@ + + + Old text + Old text 1 + id + Old text 3 + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/intellijWorkspace/result.xml b/cli/src/test/resources/xmlmerger/intellijWorkspace/result.xml new file mode 100644 index 000000000..f917da5cf --- /dev/null +++ b/cli/src/test/resources/xmlmerger/intellijWorkspace/result.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/intellijWorkspace/source.xml b/cli/src/test/resources/xmlmerger/intellijWorkspace/source.xml new file mode 100644 index 000000000..cb99e6661 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/intellijWorkspace/source.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/intellijWorkspace/target.xml b/cli/src/test/resources/xmlmerger/intellijWorkspace/target.xml new file mode 100644 index 000000000..73d14a7ac --- /dev/null +++ b/cli/src/test/resources/xmlmerger/intellijWorkspace/target.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cli/src/test/resources/xmlmerger/keep/result.xml b/cli/src/test/resources/xmlmerger/keep/result.xml new file mode 100644 index 000000000..c686c01d5 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/keep/result.xml @@ -0,0 +1,4 @@ + + + Old text + diff --git a/cli/src/test/resources/xmlmerger/keep/source.xml b/cli/src/test/resources/xmlmerger/keep/source.xml new file mode 100644 index 000000000..1e1c5cf18 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/keep/source.xml @@ -0,0 +1,4 @@ + + + New text + diff --git a/cli/src/test/resources/xmlmerger/keep/target.xml b/cli/src/test/resources/xmlmerger/keep/target.xml new file mode 100644 index 000000000..719832744 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/keep/target.xml @@ -0,0 +1,4 @@ + + + Old text + diff --git a/cli/src/test/resources/xmlmerger/override/result.xml b/cli/src/test/resources/xmlmerger/override/result.xml new file mode 100644 index 000000000..9ab0ca3ee --- /dev/null +++ b/cli/src/test/resources/xmlmerger/override/result.xml @@ -0,0 +1,4 @@ + + + New text + diff --git a/cli/src/test/resources/xmlmerger/override/source.xml b/cli/src/test/resources/xmlmerger/override/source.xml new file mode 100644 index 000000000..31e6d3ea5 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/override/source.xml @@ -0,0 +1,4 @@ + + + New text + diff --git a/cli/src/test/resources/xmlmerger/override/target.xml b/cli/src/test/resources/xmlmerger/override/target.xml new file mode 100644 index 000000000..719832744 --- /dev/null +++ b/cli/src/test/resources/xmlmerger/override/target.xml @@ -0,0 +1,4 @@ + + + Old text + diff --git a/cli/src/test/resources/xmlmerger/overrideNested/result.xml b/cli/src/test/resources/xmlmerger/overrideNested/result.xml new file mode 100644 index 000000000..38f0c429b --- /dev/null +++ b/cli/src/test/resources/xmlmerger/overrideNested/result.xml @@ -0,0 +1,6 @@ + + + + New text + + diff --git a/cli/src/test/resources/xmlmerger/overrideNested/source.xml b/cli/src/test/resources/xmlmerger/overrideNested/source.xml new file mode 100644 index 000000000..bba6a718c --- /dev/null +++ b/cli/src/test/resources/xmlmerger/overrideNested/source.xml @@ -0,0 +1,6 @@ + + + + New text + + diff --git a/cli/src/test/resources/xmlmerger/overrideNested/target.xml b/cli/src/test/resources/xmlmerger/overrideNested/target.xml new file mode 100644 index 000000000..d929ef87e --- /dev/null +++ b/cli/src/test/resources/xmlmerger/overrideNested/target.xml @@ -0,0 +1,6 @@ + + + + Old text + +