package org.netbeans.gradle.project.properties; import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Result; import javax.xml.transform.Source; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.jtrim.collections.CollectionsEx; import org.jtrim.utils.ExceptionHelper; import org.netbeans.api.project.Project; import org.netbeans.gradle.project.api.config.ConfigTree; import org.netbeans.gradle.project.others.ChangeLFPlugin; import org.netbeans.gradle.project.util.NbFileUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; final class ConfigXmlUtils { private static final Logger LOGGER = Logger.getLogger(ConfigXmlUtils.class.getName()); public static final String AUXILIARY_NODE_NAME = "auxiliary"; private static final String XML_ENCODING = "UTF-8"; private static final int FILE_BUFFER_SIZE = 8 * 1024; private static final char ESCAPE_START_CHAR = '_'; private static final char ESCAPE_END_CHAR = '.'; private static final String EMPTY_ESCAPE = ESCAPE_START_CHAR + "" + ESCAPE_END_CHAR; private static final String KEYWORD_PREFIX = "__"; private static final String KEYWORD_VALUE = KEYWORD_PREFIX + "value"; private static final String KEYWORD_HAS_VALUE = KEYWORD_PREFIX + "has-value"; private static final String STR_NO = "no"; private static final String ATTR_PREFIX = "#attr-"; private static String asAttributeName(String keyName) { return ATTR_PREFIX + keyName; } private static boolean containsChar(String str, char ch) { for (int i = 0; i < str.length(); i++) { if (str.charAt(i) == ch) { return true; } } return false; } private static int addEscaped(String str, int offset, StringBuilder result) { int length = str.length(); if (length <= offset) { return 0; } if (str.charAt(offset) == ESCAPE_START_CHAR) { result.append(ESCAPE_START_CHAR); return 1; } int charValue = 0; int skippedCount = 0; boolean hasNumber = false; for (int i = offset; i < length; i++) { char ch = str.charAt(i); skippedCount++; if (ch >= '0' && ch <= '9') { hasNumber = true; charValue = 10 * charValue + (ch - '0'); } else { break; } } if (hasNumber) { result.append((char)charValue); } return skippedCount; } private static String fromElementName(String elementName) { if (!containsChar(elementName, ESCAPE_START_CHAR)) { return elementName; } int nameLength = elementName.length(); StringBuilder result = new StringBuilder(nameLength); int index = 0; while (index < nameLength) { char ch = elementName.charAt(index); if (ch == ESCAPE_START_CHAR) { index++; index += addEscaped(elementName, index, result); } else { result.append(ch); index++; } } return result.toString(); } private static boolean isLowerCaseLetter(char ch) { return ch >= 'a' && ch <= 'z'; } private static boolean isUpperCaseLetter(char ch) { return ch >= 'A' && ch <= 'Z'; } private static boolean isLetter(char ch) { return isLowerCaseLetter(ch) || isUpperCaseLetter(ch); } private static boolean isDigit(char ch) { return ch >= '0' && ch <= '9'; } private static boolean isValidFirstElementChar(char ch) { return isLetter(ch); } private static boolean isValidElementChar(char ch) { if (isValidFirstElementChar(ch)) { return true; } if (isDigit(ch)) { return true; } return ch == '.' || ch == '-'; } private static boolean startsWithXmlReserved(String name) { String reserved = "xml"; if (name.length() < reserved.length()) { return false; } for (int i = 0; i < reserved.length(); i++) { char ch = Character.toLowerCase(name.charAt(i)); if (ch != reserved.charAt(i)) { return false; } } return true; } private static boolean isValidTagNameStart(String name) { if (name.isEmpty()) { return false; } if (startsWithXmlReserved(name)) { return false; } return isValidFirstElementChar(name.charAt(0)); } private static String toElementName(String rawName) { int nameLength = rawName.length(); if (nameLength == 0) { return EMPTY_ESCAPE; } StringBuilder result = new StringBuilder(nameLength); if (!isValidTagNameStart(rawName)) { result.append(EMPTY_ESCAPE); } for (int i = 0; i < nameLength; i++) { char ch = rawName.charAt(i); if (ch == ESCAPE_START_CHAR) { result.append(ESCAPE_START_CHAR); result.append(ESCAPE_START_CHAR); } else if (isValidElementChar(ch)) { result.append(ch); } else { result.append(ESCAPE_START_CHAR); result.append((int)ch); result.append(ESCAPE_END_CHAR); } } return result.toString(); } private static boolean addAttributes(Element element, ConfigTree.Builder result) { NamedNodeMap attributes = element.getAttributes(); if (attributes == null) { return false; } boolean setValue = false; int attributeCount = attributes.getLength(); for (int i = 0; i < attributeCount; i++) { Node attribute = attributes.item(i); String xmlAttrName = attribute.getNodeName(); String attrValue = attribute.getNodeValue(); if (xmlAttrName.startsWith(KEYWORD_PREFIX)) { switch (xmlAttrName) { case KEYWORD_VALUE: result.setValue(attrValue); setValue = true; break; case KEYWORD_HAS_VALUE: if (STR_NO.equals(attrValue)) { result.setValue(null); setValue = true; } break; default: LOGGER.log(Level.WARNING, "Unknown keyword in properties file: {0}", xmlAttrName); break; } } else { String attrName = fromElementName(xmlAttrName); result.addChildBuilder(asAttributeName(attrName)).setValue(attrValue); } } return setValue; } private static int addChildren(Element element, Set<String> excludedNames, ConfigTree.Builder result) { NodeList childNodes = element.getChildNodes(); int addedChildren = 0; int childCount = childNodes.getLength(); for (int i = 0; i < childCount; i++) { Node child = childNodes.item(i); if (child instanceof Element) { Element elementChild = (Element)child; String rawNodeName = elementChild.getNodeName(); if (excludedNames.contains(rawNodeName)) { continue; } String elementKey = fromElementName(elementChild.getNodeName()); ConfigTree.Builder childBuilder = result.addChildBuilder(elementKey); String nodeValue = parseNode(elementChild, Collections.<String>emptySet(), childBuilder); if (nodeValue != null) { childBuilder.setValue(nodeValue); } addedChildren++; } } return addedChildren; } private static String parseNode(Element root, Set<String> excludedNames, ConfigTree.Builder result) { ExceptionHelper.checkNotNullArgument(root, "root"); ExceptionHelper.checkNotNullArgument(result, "result"); boolean setValue = addAttributes(root, result); int addedChildCount = addChildren(root, excludedNames, result); if (!setValue && addedChildCount == 0) { return root.getTextContent(); } else { return null; } } public static ConfigTree.Builder parseDocument(Document document, String... excludedNames) { ConfigTree.Builder result = new ConfigTree.Builder(); Element root = document.getDocumentElement(); if (root != null) { parseNode(root, new HashSet<>(Arrays.asList(excludedNames)), result); } return result; } private static List<KeyValuePair> tryGetAttributeList(ConfigTree tree) { List<KeyValuePair> attributes = null; for (Map.Entry<String, List<ConfigTree>> entry: tree.getChildTrees().entrySet()) { List<ConfigTree> childList = entry.getValue(); if (childList.size() != 1) { continue; } ConfigTree child = childList.get(0); if (!child.getChildTrees().isEmpty()) { continue; } String key = entry.getKey(); if (!key.startsWith(ATTR_PREFIX)) { continue; } if (attributes == null) { attributes = new ArrayList<>(tree.getChildTrees().size()); } // A childless ConfigTree should always have a value, this is // something guaranteed by ConfigTree. String value = child.getValue(""); attributes.add(new KeyValuePair(key, value)); } return attributes; } private static Set<String> addAttributeChildrenToXml( Element parent, ConfigTree tree, final ConfigNodeProperty nodeSorter) { List<KeyValuePair> attributes = tryGetAttributeList(tree); if (attributes == null) { return Collections.emptySet(); } // Despite that attributes are unordered, add them in a deterministic // order, so that any sensible implementation will save them in the // same order every time (avoiding unnecessary differences in the // properties file). Collections.sort(attributes, new Comparator<KeyValuePair>() { @Override public int compare(KeyValuePair o1, KeyValuePair o2) { return nodeSorter.compare(o1.key, o1.key); } }); Set<String> result = CollectionsEx.newHashSet(attributes.size()); for (KeyValuePair keyValue: attributes) { String key = keyValue.key; result.add(key); assert key.startsWith(ATTR_PREFIX); String xmlKey = toElementName(key.substring(ATTR_PREFIX.length())); parent.setAttribute(xmlKey, keyValue.value); } return result; } private static void addTreeToXml( Document document, Element parent, ConfigTree tree, final ConfigNodeProperty nodeProperties) { Map<String, List<ConfigTree>> children = tree.getChildTrees(); String value = tree.getValue(null); boolean ignoreValue = nodeProperties.ignoreValue(); if (ignoreValue) { value = null; } if (value != null) { if (children.isEmpty()) { parent.setTextContent(value); return; } } Set<String> attributeKeys = addAttributeChildrenToXml(parent, tree, nodeProperties); List<NamedNode> childEntries = new ArrayList<>(children.size()); for (Map.Entry<String, List<ConfigTree>> entry: children.entrySet()) { String key = entry.getKey(); if (!attributeKeys.contains(key)) { childEntries.add(new NamedNode(key, entry.getValue())); } } if (childEntries.isEmpty()) { if (value != null) { parent.setTextContent(value); } else if (!ignoreValue) { parent.setAttribute(KEYWORD_HAS_VALUE, STR_NO); } return; } if (value != null) { parent.setAttribute(KEYWORD_VALUE, value); } Collections.sort(childEntries, new Comparator<NamedNode>() { @Override public int compare(NamedNode o1, NamedNode o2) { return nodeProperties.compare(o1.name, o2.name); } }); for (NamedNode child: childEntries) { String xmlKey = toElementName(child.name); ConfigNodeProperty childSorter = nodeProperties.getChildSorter(child.name); for (ConfigTree childTree: child.trees) { Element childElement = document.createElement(xmlKey); parent.appendChild(childElement); ConfigTree adjustedChildTree = childSorter.adjustNodes(childTree); addTreeToXml(document, childElement, adjustedChildTree, childSorter); } } } public static void addTree(Element parent, ConfigTree tree, ConfigNodeProperty nodeProperties) { ExceptionHelper.checkNotNullArgument(parent, "parent"); ExceptionHelper.checkNotNullArgument(tree, "tree"); ExceptionHelper.checkNotNullArgument(nodeProperties, "nodeProperties"); Document document = parent.getOwnerDocument(); Objects.requireNonNull(document, "parent.getOwnerDocument()"); addTreeToXml(document, parent, tree, nodeProperties); } private static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException { return DocumentBuilderFactory.newInstance().newDocumentBuilder(); } public static Document createXml(ConfigTree tree) throws ParserConfigurationException { Document result = newDocumentBuilder().newDocument(); Element root = result.createElement("gradle-project-properties"); result.appendChild(root); String comment = "DO NOT EDIT THIS FILE! - Used by the Gradle plugin of NetBeans."; root.appendChild(result.createComment(comment)); addTree(root, tree, CompatibleRootNodeProperty.INSTANCE); return result; } private static int nullSafeStrCmp(String str1, String str2) { if (str1 == null) { return str2 != null ? -1 : 0; } else if (str2 == null) { return 1; } return str1.compareTo(str2); } public static void addAuxiliary(Document document, Element... auxElements) { Element root = Objects.requireNonNull(document.getDocumentElement(), "document.getDocumentElement()"); if (auxElements.length == 0) { return; } Element[] sortedAuxElements = auxElements.clone(); Arrays.sort(sortedAuxElements, new Comparator<Element>() { @Override public int compare(Element o1, Element o2) { String uri1 = o1.getNamespaceURI(); String uri2 = o2.getNamespaceURI(); int uriCmp = nullSafeStrCmp(uri1, uri2); if (uriCmp != 0) { return uriCmp; } return nullSafeStrCmp(o1.getNodeName(), o2.getNodeName()); } }); Element auxRoot = document.createElement(AUXILIARY_NODE_NAME); root.appendChild(auxRoot); for (Element auxElement: sortedAuxElements) { auxRoot.appendChild(document.importNode(auxElement, true)); } } public static void savePrettyXmlDocument(Document document, Result result) throws IOException { ExceptionHelper.checkNotNullArgument(document, "document"); ExceptionHelper.checkNotNullArgument(result, "result"); Source source = new DOMSource(document); try { Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.ENCODING, XML_ENCODING); transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); try { // If not set, some JDKs might not emit a new line after the XML header. transformer.setOutputProperty("http://www.oracle.com/xml/is-standalone", "yes"); } catch (IllegalArgumentException e) { // Expected to be thrown by JDKs unaffected of this issu } transformer.transform(source, result); } catch (TransformerException ex) { throw new IOException(ex); } } public static ConfigSaveOptions getSaveOptions(Project project, Path output) { String lineSeparator = NbFileUtils.tryGetLineSeparatorForTextFile(output); if (lineSeparator == null) { lineSeparator = ChangeLFPlugin.getPreferredLineSeparator(project); } return new ConfigSaveOptions(lineSeparator); } public static void saveXmlTo(Document document, Path output, ConfigSaveOptions saveOptions) throws IOException { ExceptionHelper.checkNotNullArgument(saveOptions, "saveOptions"); saveXmlTo(document, output, saveOptions.getPreferredLineSeparator()); } public static void saveXmlTo( Document document, Path output, String lineSeparator) throws IOException { ExceptionHelper.checkNotNullArgument(document, "document"); ExceptionHelper.checkNotNullArgument(output, "output"); if (lineSeparator == null) { Result result = new StreamResult(output.toFile()); savePrettyXmlDocument(document, result); } else { StringWriter writer = new StringWriter(FILE_BUFFER_SIZE); Result result = new StreamResult(writer); savePrettyXmlDocument(document, result); String fileOutput = writer.toString(); BufferedReader configContent = new BufferedReader(new StringReader(fileOutput)); StringBuilder newFileStrContent = new StringBuilder(fileOutput.length()); for (String line = configContent.readLine(); line != null; line = configContent.readLine()) { newFileStrContent.append(line); newFileStrContent.append(lineSeparator); } Files.write(output, newFileStrContent.toString().getBytes(XML_ENCODING)); } } private static final class KeyValuePair { public final String key; public final String value; public KeyValuePair(String key, String value) { this.key = key; this.value = value; } } private static final class NamedNode { public final String name; public final List<ConfigTree> trees; public NamedNode(String name, List<ConfigTree> trees) { this.name = name; this.trees = trees; } } private ConfigXmlUtils() { throw new AssertionError(); } }