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}