001/* 002 * Configurate 003 * Copyright (C) zml and Configurate contributors 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package ninja.leaping.configurate.xml; 018 019import com.google.common.collect.ImmutableList; 020import com.google.common.collect.ImmutableMap; 021import com.google.common.collect.ImmutableSet; 022import com.google.common.collect.Multimap; 023import com.google.common.collect.MultimapBuilder; 024import com.google.common.math.DoubleMath; 025import ninja.leaping.configurate.ConfigurationNode; 026import ninja.leaping.configurate.ConfigurationOptions; 027import ninja.leaping.configurate.attributed.AttributedConfigurationNode; 028import ninja.leaping.configurate.commented.CommentedConfigurationNode; 029import ninja.leaping.configurate.loader.AbstractConfigurationLoader; 030import ninja.leaping.configurate.loader.CommentHandler; 031import ninja.leaping.configurate.loader.CommentHandlers; 032import org.checkerframework.checker.nullness.qual.NonNull; 033import org.checkerframework.checker.nullness.qual.Nullable; 034import org.w3c.dom.Document; 035import org.w3c.dom.Element; 036import org.w3c.dom.NamedNodeMap; 037import org.w3c.dom.Node; 038import org.w3c.dom.NodeList; 039import org.xml.sax.InputSource; 040import org.xml.sax.SAXException; 041 042import javax.xml.parsers.DocumentBuilder; 043import javax.xml.parsers.DocumentBuilderFactory; 044import javax.xml.parsers.ParserConfigurationException; 045import javax.xml.transform.OutputKeys; 046import javax.xml.transform.Transformer; 047import javax.xml.transform.TransformerConfigurationException; 048import javax.xml.transform.TransformerException; 049import javax.xml.transform.TransformerFactory; 050import javax.xml.transform.dom.DOMSource; 051import javax.xml.transform.stream.StreamResult; 052import javax.xml.validation.Schema; 053import java.io.BufferedReader; 054import java.io.FileNotFoundException; 055import java.io.IOException; 056import java.io.Writer; 057import java.nio.file.NoSuchFileException; 058import java.util.Iterator; 059import java.util.Map; 060import java.util.Objects; 061 062/** 063 * A loader for XML (Extensible Markup Language), using the native javax library for parsing and 064 * generation. 065 */ 066public class XMLConfigurationLoader extends AbstractConfigurationLoader<AttributedConfigurationNode> { 067 /** 068 * The prefix of lines within the header 069 */ 070 private static final String HEADER_PREFIX = "~"; 071 072 private static final String ATTRIBUTE_TYPE = "configurate-type"; 073 074 /** 075 * The user data used to store comments on nodes 076 */ 077 private static final String USER_DATA_COMMENT = "configurate-comment"; 078 079 /** 080 * The property used to mark how many spaces should be used to indent. 081 */ 082 private static final String INDENT_PROPERTY = "{http://xml.apache.org/xslt}indent-amount"; 083 084 /** 085 * Creates a new {@link XMLConfigurationLoader} builder. 086 * 087 * @return A new builder 088 */ 089 @NonNull 090 public static Builder builder() { 091 return new Builder(); 092 } 093 094 /** 095 * Builds a {@link XMLConfigurationLoader}. 096 */ 097 public static class Builder extends AbstractConfigurationLoader.Builder<Builder> { 098 private Schema schema = null; 099 private String defaultTagName = "element"; 100 private int indent = 2; 101 private boolean writeExplicitType = true; 102 private boolean includeXmlDeclaration = true; 103 104 protected Builder() { 105 } 106 107 /** 108 * Sets the level of indentation the resultant loader should use. 109 * 110 * @param indent The indent level 111 * @return This builder (for chaining) 112 */ 113 @NonNull 114 public Builder setIndent(int indent) { 115 this.indent = indent; 116 return this; 117 } 118 119 /** 120 * Gets the level of indentation to be used by the resultant loader. 121 * 122 * @return The indent level 123 */ 124 public int getIndent() { 125 return indent; 126 } 127 128 /** 129 * Sets the {@link Schema} the resultant loader should use. 130 * 131 * @param schema The schema 132 * @return This builder (for chaining) 133 */ 134 @NonNull 135 public Builder setSchema(@Nullable Schema schema) { 136 this.schema = schema; 137 return this; 138 } 139 140 /** 141 * Gets the {@link Schema} to be used by the resultant loader. 142 * 143 * @return The schema 144 */ 145 @Nullable 146 public Schema getSchema() { 147 return schema; 148 } 149 150 /** 151 * Sets the default tag name the resultant loader should use. 152 * 153 * @param defaultTagName The default tag name 154 * @return This builder (for chaining) 155 */ 156 @NonNull 157 public Builder setDefaultTagName(@NonNull String defaultTagName) { 158 this.defaultTagName = defaultTagName; 159 return this; 160 } 161 162 /** 163 * Gets the default tag name to be used by the resultant loader. 164 * 165 * @return The default tag name 166 */ 167 @NonNull 168 public String getDefaultTagName() { 169 return defaultTagName; 170 } 171 172 /** 173 * Sets if the resultant loader should write the explicit type of each node 174 * when saving nodes. 175 * 176 * <p>This is necessary in some cases, as XML has no explicit definition of an array or 177 * list. The loader is able to infer the type in some cases, but this is inaccurate in some 178 * cases, for example lists with only one element.</p> 179 * 180 * @param writeExplicitType If the loader should write explicit types 181 * @return This builder (for chaining) 182 */ 183 @NonNull 184 public Builder setWriteExplicitType(boolean writeExplicitType) { 185 this.writeExplicitType = writeExplicitType; 186 return this; 187 } 188 189 /** 190 * Gets if explicit type attributes should be written by the resultant loader. 191 * 192 * <p>See the method doc for {@link #setWriteExplicitType(boolean)} for a more detailed 193 * explanation.</p> 194 * 195 * @return The default tag name 196 */ 197 public boolean shouldWriteExplicitType() { 198 return writeExplicitType; 199 } 200 201 /** 202 * Sets if the resultant loader should include the XML declaration header when saving. 203 * 204 * @param includeXmlDeclaration If the XML declaration should be included 205 * @return This builder (for chaining) 206 */ 207 @NonNull 208 public Builder setIncludeXmlDeclaration(boolean includeXmlDeclaration) { 209 this.includeXmlDeclaration = includeXmlDeclaration; 210 return this; 211 } 212 213 /** 214 * Gets if the resultant loader should include the XML declaration header when saving. 215 * 216 * @return If the XML declaration should be included 217 */ 218 public boolean shouldIncludeXmlDeclaration() { 219 return includeXmlDeclaration; 220 } 221 222 @NonNull 223 @Override 224 public XMLConfigurationLoader build() { 225 return new XMLConfigurationLoader(this); 226 } 227 } 228 229 private final Schema schema; 230 private final String defaultTagName; 231 private final int indent; 232 private final boolean writeExplicitType; 233 private final boolean includeXmlDeclaration; 234 235 private XMLConfigurationLoader(Builder builder) { 236 super(builder, new CommentHandler[] {CommentHandlers.XML_STYLE}); 237 this.schema = builder.getSchema(); 238 this.defaultTagName = builder.getDefaultTagName(); 239 this.indent = builder.getIndent(); 240 this.writeExplicitType = builder.shouldWriteExplicitType(); 241 this.includeXmlDeclaration = builder.shouldIncludeXmlDeclaration(); 242 } 243 244 private DocumentBuilder newDocumentBuilder() { 245 DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); 246 if (schema != null) { 247 builderFactory.setSchema(schema); 248 } 249 250 try { 251 return builderFactory.newDocumentBuilder(); 252 } catch (ParserConfigurationException e) { 253 throw new RuntimeException(e); 254 } 255 } 256 257 private Transformer newTransformer() { 258 TransformerFactory transformerFactory = TransformerFactory.newInstance(); 259 try { 260 Transformer transformer = transformerFactory.newTransformer(); 261 262 // we write the header ourselves. 263 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); 264 265 if (indent > 0) { 266 transformer.setOutputProperty(OutputKeys.INDENT, "yes"); 267 transformer.setOutputProperty(INDENT_PROPERTY, Integer.toString(indent)); 268 } 269 return transformer; 270 } catch (TransformerConfigurationException e) { 271 throw new RuntimeException(e); 272 } 273 } 274 275 @Override 276 public @NonNull AttributedConfigurationNode load(@NonNull ConfigurationOptions options) throws IOException { 277 if (source == null) { 278 throw new IOException("No source present to read from!"); 279 } 280 try (BufferedReader reader = source.call()) { 281 DocumentBuilder documentBuilder = newDocumentBuilder(); 282 283 Document document; 284 try { 285 document = documentBuilder.parse(new InputSource(reader)); 286 } catch (SAXException e) { 287 throw new IOException(e); 288 } 289 290 NodeList children = document.getChildNodes(); 291 for (int i = 0; i < children.getLength(); ++i) { 292 Node child = children.item(i); 293 System.out.println(child); 294 if (child.getNodeType() == Node.COMMENT_NODE) { 295 options = options.withHeader(unwrapHeader(child.getTextContent().trim())); 296 } else if (child.getNodeType() == Node.ELEMENT_NODE) { 297 AttributedConfigurationNode node = createEmptyNode(options); 298 readElement(child, node); 299 return node; 300 } 301 } 302 // empty document, fall through 303 } catch (FileNotFoundException | NoSuchFileException e) { 304 // Squash -- there's nothing to read 305 } catch (IOException e) { 306 throw e; 307 } catch (Exception e) { 308 throw new IOException(e); 309 } 310 return createEmptyNode(options); 311 } 312 313 /** 314 * Given a single comment node's comment, clear any prefix lines 315 * @param headerContent The content of a header 316 * @return A formatted header, with lines separated by {@link #CONFIGURATE_LINE_SEPARATOR} 317 */ 318 private String unwrapHeader(String headerContent) { 319 if (headerContent.isEmpty()) { 320 return headerContent; 321 } 322 StringBuilder result = new StringBuilder(); 323 for (Iterator<String> it = LINE_SPLITTER.split(headerContent).iterator(); it.hasNext();) { 324 String line = it.next(); 325 final String trimmedLine = line.trim(); 326 if (trimmedLine.startsWith(HEADER_PREFIX)) { 327 line = line.substring(line.indexOf(HEADER_PREFIX) + 1); 328 } 329 330 if (line.startsWith(" ")) { 331 line = line.substring(1); 332 } 333 334 if (it.hasNext() || !line.isEmpty()) { 335 result.append(line); 336 } 337 338 if (it.hasNext()) { 339 result.append(CONFIGURATE_LINE_SEPARATOR); 340 } 341 } 342 return result.toString(); 343 } 344 345 @Override 346 @Deprecated 347 public void loadInternal(AttributedConfigurationNode node, BufferedReader reader) throws IOException { 348 throw new UnsupportedOperationException("XMLConfigurationLoader provides custom loading logic to handle headers"); 349 } 350 351 private enum NodeType { 352 MAP, LIST 353 } 354 355 private void readElement(Node from, AttributedConfigurationNode to) { 356 NodeType type = null; 357 358 // copy the name of the tag 359 to.setTagName(from.getNodeName()); 360 361 String potentialComment = (String) from.getUserData(USER_DATA_COMMENT); 362 if (potentialComment != null) { 363 to.setComment(potentialComment); 364 } 365 366 // copy attributes 367 if (from.hasAttributes()) { 368 NamedNodeMap attributes = from.getAttributes(); 369 for (int i = 0; i < attributes.getLength(); i++) { 370 Node attribute = attributes.item(i); 371 String key = attribute.getNodeName(); 372 String value = attribute.getNodeValue(); 373 374 // read the type of the node 375 if (key.equals(ATTRIBUTE_TYPE)) { 376 if (value.equals("map")) { 377 type = NodeType.MAP; 378 } else if (value.equals("list")) { 379 type = NodeType.LIST; 380 } 381 382 // don't add internal configurate attributes to the node 383 continue; 384 } 385 386 to.addAttribute(key, value); 387 } 388 } 389 390 // read out the child nodes into a multimap 391 Multimap<String, Node> children = MultimapBuilder.linkedHashKeys().arrayListValues().build(); 392 if (from.hasChildNodes()) { 393 StringBuilder comment = new StringBuilder(); 394 NodeList childNodes = from.getChildNodes(); 395 for (int i = 0; i < childNodes.getLength(); i++) { 396 Node child = childNodes.item(i); 397 if (child.getNodeType() == Node.ELEMENT_NODE) { 398 children.put(child.getNodeName(), child); 399 if (comment.length() > 0) { 400 child.setUserData(USER_DATA_COMMENT, comment.toString(), null); 401 comment.setLength(0); 402 } 403 } else if (child.getNodeType() == Node.COMMENT_NODE) { 404 if (comment.length() > 0) { 405 comment.append('\n'); 406 } 407 408 comment.append(child.getTextContent().trim()); 409 } 410 } 411 } 412 413 // if there are no child nodes present, assume it's a scalar value 414 if (children.isEmpty()) { 415 to.setValue(parseValue(from.getTextContent())); 416 return; 417 } 418 419 // if type is null, we need to infer what type the element is 420 if (type == null) { 421 // if there are no duplicate keys, we can infer that it is a map 422 // otherwise, assume it's a list 423 if (children.keys().size() == children.keySet().size()) { 424 type = NodeType.MAP; 425 } else { 426 type = NodeType.LIST; 427 } 428 } 429 430 if (type == NodeType.MAP) { 431 to.setValue(ImmutableMap.of()); 432 } else { 433 to.setValue(ImmutableList.of()); 434 } 435 436 // read out the elements 437 for (Map.Entry<String, Node> entry : children.entries()) { 438 AttributedConfigurationNode child; 439 if (type == NodeType.MAP) { 440 child = to.getNode(entry.getKey()); 441 } else { 442 child = to.appendListNode(); 443 } 444 445 readElement(entry.getValue(), child); 446 } 447 } 448 449 @Override 450 protected void writeHeaderInternal(Writer writer) throws IOException { 451 if (includeXmlDeclaration) { 452 writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); 453 writer.write(SYSTEM_LINE_SEPARATOR); 454 } 455 } 456 457 @Override 458 protected void saveInternal(ConfigurationNode node, Writer writer) throws IOException { 459 DocumentBuilder documentBuilder = newDocumentBuilder(); 460 Document document = documentBuilder.newDocument(); 461 462 Node comment = createCommentNode(document, node); 463 if (comment != null) { 464 document.appendChild(comment); 465 } 466 467 document.appendChild(writeNode(document, node, null)); 468 469 Transformer transformer = newTransformer(); 470 DOMSource source = new DOMSource(document); 471 try { 472 transformer.transform(source, new StreamResult(writer)); 473 } catch (TransformerException e) { 474 throw new IOException(e); 475 } 476 } 477 478 private void appendCommentIfNecessary(Element parent, ConfigurationNode node) { 479 Node possibleComment = createCommentNode(parent.getOwnerDocument(), node); 480 if (possibleComment != null) { 481 parent.appendChild(possibleComment); 482 } 483 } 484 485 @Nullable 486 private Node createCommentNode(Document doc, ConfigurationNode node) { 487 if (node instanceof CommentedConfigurationNode) { 488 String comment = ((CommentedConfigurationNode) node).getComment().orElse(null); 489 if (comment != null) { 490 return doc.createComment(" " + comment.trim() + " "); 491 } 492 } 493 return null; 494 } 495 496 private Element writeNode(Document document, ConfigurationNode node, String forcedTag) { 497 String tag = defaultTagName; 498 Map<String, String> attributes = ImmutableMap.of(); 499 500 if (node instanceof AttributedConfigurationNode) { 501 AttributedConfigurationNode attributedNode = ((AttributedConfigurationNode) node); 502 tag = attributedNode.getTagName(); 503 attributes = attributedNode.getAttributes(); 504 } 505 506 Element element = document.createElement(forcedTag == null ? tag : forcedTag); 507 for (Map.Entry<String, String> attribute : attributes.entrySet()) { 508 element.setAttribute(attribute.getKey(), attribute.getValue()); 509 } 510 511 if (node.isMap()) { 512 for (Map.Entry<Object, ? extends ConfigurationNode> child : node.getChildrenMap().entrySet()) { 513 appendCommentIfNecessary(element, child.getValue()); 514 element.appendChild(writeNode(document, child.getValue(), child.getKey().toString())); 515 } 516 } else if (node.isList()) { 517 if (writeExplicitType) { 518 element.setAttribute(ATTRIBUTE_TYPE, "list"); 519 } 520 for (ConfigurationNode child : node.getChildrenList()) { 521 appendCommentIfNecessary(element, child); 522 element.appendChild(writeNode(document, child, null)); 523 } 524 } else { 525 element.appendChild(document.createTextNode(Objects.toString(node.getValue()))); 526 } 527 528 return element; 529 } 530 531 @NonNull 532 @Override 533 public AttributedConfigurationNode createEmptyNode(@NonNull ConfigurationOptions options) { 534 options = options.withNativeTypes(ImmutableSet.of(Double.class, Long.class, 535 Integer.class, Boolean.class, String.class, Number.class)); 536 return AttributedConfigurationNode.root("root", options); 537 } 538 539 private static Object parseValue(String value) { 540 if (value.equals("true") || value.equals("false")) { 541 return Boolean.parseBoolean(value); 542 } 543 544 try { 545 double doubleValue = Double.parseDouble(value); 546 if (DoubleMath.isMathematicalInteger(doubleValue)) { 547 long longValue = (long) doubleValue; 548 int intValue = (int) longValue; 549 if (longValue == intValue) { 550 return intValue; 551 } else { 552 return longValue; 553 } 554 } 555 return doubleValue; 556 } catch (NumberFormatException e) { 557 return value; 558 } 559 } 560}