Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node> 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<String, String> 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<String, Object> attributes = (Map<String, Object>) 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() {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="TestComponent">
<option name="enabled" value="true"/>
</component>
</project>
'''.stripIndent().replaceAll('\\s+', ' ').trim()
}

void 'updates existing XML file preserving content'() {
given:
def xmlFile = new File(tempDir, "test.xml")
xmlFile.text = '''\
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExistingComponent">
<option name="old" value="data"/>
</component>
</project>
'''.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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="ExistingComponent">
<option name="old" value="data"/>
<option name="new" value="added"/>
</component>
</project>
'''.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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="TestSettings">
<option name="classPath">
<list>
<option value="path1"/>
<option value="path2"/>
</list>
</option>
</component>
</project>
'''.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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component enabled="true" name="Settings" version="1.0"/>
</project>
'''.stripIndent().replaceAll('\\s+', ' ').trim()
}

void 'round-trip conversion preserves structure'() {
given:
def xmlFile = new File(tempDir, "test.xml")
xmlFile.text = '''\
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PalantirJavaFormatSettings">
<option name="enabled" value="true"/>
<option name="implementationClassPath">
<list>
<option value="file:/path/to/jar1.jar"/>
<option value="file:/path/to/jar2.jar"/>
</list>
</option>
</component>
</project>
'''.stripIndent()

when:
XmlUtils.updateIdeaXmlFile(xmlFile) { Node root ->
// No changes, just round-trip
}

then:
xmlFile.text.replaceAll('\\s+', ' ').trim() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="PalantirJavaFormatSettings">
<option name="enabled" value="true"/>
<option name="implementationClassPath">
<list>
<option value="file:/path/to/jar1.jar"/>
<option value="file:/path/to/jar2.jar"/>
</list>
</option>
</component>
</project>
'''.stripIndent().replaceAll('\\s+', ' ').trim()
}

void 'modifies node attributes'() {
given:
def xmlFile = new File(tempDir, "test.xml")
xmlFile.text = '''\
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Settings">
<option name="enabled" value="false"/>
</component>
</project>
'''.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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="Settings">
<option name="enabled" value="true"/>
</component>
</project>
'''.stripIndent().replaceAll('\\s+', ' ').trim()
}

void 'removes nodes'() {
given:
def xmlFile = new File(tempDir, "test.xml")
xmlFile.text = '''\
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Settings">
<option name="toRemove" value="data"/>
<option name="toKeep" value="data"/>
</component>
</project>
'''.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() == '''\
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project version="4">
<component name="Settings">
<option name="toKeep" value="data"/>
</component>
</project>
'''.stripIndent().replaceAll('\\s+', ' ').trim()
}
}