From e2dd9e870f2fd864f0697842a41049cbba513509 Mon Sep 17 00:00:00 2001 From: Gavin Bunney Date: Tue, 6 Jan 2026 12:52:33 -0800 Subject: [PATCH] Fix Gradle 9 compatibility for XML manipulation tasks --- .../palantir/javaformat/gradle/XmlUtils.java | 139 ++++++++-- .../javaformat/gradle/XmlUtilsTest.groovy | 240 ++++++++++++++++++ 2 files changed, 353 insertions(+), 26 deletions(-) create mode 100644 gradle-palantir-java-format/src/test/groovy/com/palantir/javaformat/gradle/XmlUtilsTest.groovy diff --git a/gradle-palantir-java-format/src/main/groovy/com/palantir/javaformat/gradle/XmlUtils.java b/gradle-palantir-java-format/src/main/groovy/com/palantir/javaformat/gradle/XmlUtils.java index 1a6035c4b..cecf98929 100644 --- a/gradle-palantir-java-format/src/main/groovy/com/palantir/javaformat/gradle/XmlUtils.java +++ b/gradle-palantir-java-format/src/main/groovy/com/palantir/javaformat/gradle/XmlUtils.java @@ -16,44 +16,131 @@ package com.palantir.javaformat.gradle; -import com.google.common.collect.ImmutableMap; -import com.google.common.io.Files; import groovy.util.Node; -import groovy.util.XmlNodePrinter; -import groovy.util.XmlParser; -import java.io.BufferedWriter; import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.nio.charset.Charset; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.function.Consumer; -import javax.xml.parsers.ParserConfigurationException; -import org.xml.sax.SAXException; +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 org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.NodeList; public class XmlUtils { public static void updateIdeaXmlFile(File configurationFile, Consumer configure) { - Node rootNode; - if (configurationFile.isFile()) { - try { - rootNode = new XmlParser().parse(configurationFile); - } catch (IOException | SAXException | ParserConfigurationException e) { - throw new RuntimeException("Couldn't parse existing configuration file: " + configurationFile, e); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + + Document doc; + if (configurationFile.isFile()) { + doc = builder.parse(configurationFile); + } else { + doc = builder.newDocument(); + Element root = doc.createElement("project"); + root.setAttribute("version", "4"); + doc.appendChild(root); } - } else { - rootNode = new Node(null, "project", ImmutableMap.of("version", "4")); + + Node groovyNode = convertDomToGroovyNode(doc.getDocumentElement(), null); + configure.accept(groovyNode); + + Document newDoc = convertGroovyNodeToDom(groovyNode); + + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); + + DOMSource source = new DOMSource(newDoc); + StreamResult result = new StreamResult(configurationFile); + transformer.transform(source, result); + } catch (Exception e) { + throw new RuntimeException("Failed to update XML file: " + configurationFile, e); } + } - configure.accept(rootNode); + private static Node convertDomToGroovyNode(org.w3c.dom.Node domNode, Node parent) { + if (domNode.getNodeType() != org.w3c.dom.Node.ELEMENT_NODE) { + return null; + } - try (BufferedWriter writer = Files.newWriter(configurationFile, Charset.defaultCharset()); - PrintWriter printWriter = new PrintWriter(writer)) { - XmlNodePrinter nodePrinter = new XmlNodePrinter(printWriter); - nodePrinter.setPreserveWhitespace(true); - nodePrinter.print(rootNode); - } catch (IOException e) { - throw new RuntimeException("Failed to write back to configuration file: " + configurationFile, e); + Element element = (Element) domNode; + Map attributes = new LinkedHashMap<>(); + + NamedNodeMap attrs = element.getAttributes(); + for (int i = 0; i < attrs.getLength(); i++) { + org.w3c.dom.Node attr = attrs.item(i); + attributes.put(attr.getNodeName(), attr.getNodeValue()); + } + + Node groovyNode = new Node(parent, element.getTagName(), attributes); + + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + org.w3c.dom.Node child = children.item(i); + if (child.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) { + convertDomToGroovyNode(child, groovyNode); + } else if (child.getNodeType() == org.w3c.dom.Node.TEXT_NODE) { + String text = child.getNodeValue(); + if (text != null && !text.trim().isEmpty()) { + groovyNode.setValue(text); + } + } } + + return groovyNode; + } + + @SuppressWarnings("unchecked") + private static Document convertGroovyNodeToDom(Node groovyNode) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.newDocument(); + + Element root = convertNodeToDomElement(doc, groovyNode); + doc.appendChild(root); + + return doc; + } + + @SuppressWarnings("unchecked") + private static Element convertNodeToDomElement(Document doc, Node groovyNode) { + Element element = doc.createElement(groovyNode.name().toString()); + + Map attributes = (Map) groovyNode.attributes(); + attributes.forEach((key, value) -> element.setAttribute(key, String.valueOf(value))); + + Object value = groovyNode.value(); + if (value instanceof Collection children) { + for (Object child : children) { + if (child instanceof Node childNode) { + element.appendChild(convertNodeToDomElement(doc, childNode)); + } else if (child instanceof String childString) { + String text = childString.trim(); + if (!text.isEmpty()) { + element.setTextContent(text); + } + } + } + } else if (value instanceof String strValue) { + String text = strValue.trim(); + if (!text.isEmpty()) { + element.setTextContent(text); + } + } + + return element; } private XmlUtils() {} diff --git a/gradle-palantir-java-format/src/test/groovy/com/palantir/javaformat/gradle/XmlUtilsTest.groovy b/gradle-palantir-java-format/src/test/groovy/com/palantir/javaformat/gradle/XmlUtilsTest.groovy new file mode 100644 index 000000000..021f98c2f --- /dev/null +++ b/gradle-palantir-java-format/src/test/groovy/com/palantir/javaformat/gradle/XmlUtilsTest.groovy @@ -0,0 +1,240 @@ +/* + * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.palantir.javaformat.gradle + +import groovy.util.Node +import spock.lang.Specification +import spock.lang.TempDir + +class XmlUtilsTest extends Specification { + + @TempDir + File tempDir + + void 'creates new XML file with default structure'() { + given: + def xmlFile = new File(tempDir, "test.xml") + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = new Node(root, "component") + component.attributes().put("name", "TestComponent") + def option = new Node(component, "option") + option.attributes().put("name", "enabled") + option.attributes().put("value", "true") + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'updates existing XML file preserving content'() { + given: + def xmlFile = new File(tempDir, "test.xml") + xmlFile.text = '''\ + + + + + + '''.stripIndent() + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = root.children().find { it.@name == "ExistingComponent" } + def newOption = new Node(component, "option") + newOption.attributes().put("name", "new") + newOption.attributes().put("value", "added") + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'preserves nested XML structure'() { + given: + def xmlFile = new File(tempDir, "test.xml") + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = new Node(root, "component") + component.attributes().put("name", "TestSettings") + + def option = new Node(component, "option") + option.attributes().put("name", "classPath") + + def list = new Node(option, "list") + def item1 = new Node(list, "option") + item1.attributes().put("value", "path1") + def item2 = new Node(list, "option") + item2.attributes().put("value", "path2") + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'handles multiple attributes correctly'() { + given: + def xmlFile = new File(tempDir, "test.xml") + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = new Node(root, "component") + component.attributes().put("name", "Settings") + component.attributes().put("enabled", "true") + component.attributes().put("version", "1.0") + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'round-trip conversion preserves structure'() { + given: + def xmlFile = new File(tempDir, "test.xml") + xmlFile.text = '''\ + + + + + + + '''.stripIndent() + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + // No changes, just round-trip + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'modifies node attributes'() { + given: + def xmlFile = new File(tempDir, "test.xml") + xmlFile.text = '''\ + + + + + + '''.stripIndent() + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = root.children().find { it.@name == "Settings" } + def option = component.children().find { it.@name == "enabled" } + option.attributes().put("value", "true") + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } + + void 'removes nodes'() { + given: + def xmlFile = new File(tempDir, "test.xml") + xmlFile.text = '''\ + + + + + + '''.stripIndent() + + when: + XmlUtils.updateIdeaXmlFile(xmlFile) { Node root -> + def component = root.children().find { it.@name == "Settings" } + def toRemove = component.children().find { it.@name == "toRemove" } + component.children().remove(toRemove) + } + + then: + xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\ + + + + + + '''.stripIndent().replaceAll('\\s+', ' ').trim() + } +}