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 org.spongepowered.configurate.xml;
018
019import net.kyori.option.Option;
020import net.kyori.option.OptionSchema;
021import org.checkerframework.checker.nullness.qual.NonNull;
022import org.checkerframework.checker.nullness.qual.Nullable;
023import org.spongepowered.configurate.AttributedConfigurationNode;
024import org.spongepowered.configurate.CommentedConfigurationNodeIntermediary;
025import org.spongepowered.configurate.ConfigurateException;
026import org.spongepowered.configurate.ConfigurationNode;
027import org.spongepowered.configurate.ConfigurationOptions;
028import org.spongepowered.configurate.loader.AbstractConfigurationLoader;
029import org.spongepowered.configurate.loader.CommentHandler;
030import org.spongepowered.configurate.loader.CommentHandlers;
031import org.spongepowered.configurate.loader.ParsingException;
032import org.spongepowered.configurate.util.UnmodifiableCollections;
033import org.w3c.dom.Document;
034import org.w3c.dom.Element;
035import org.w3c.dom.NamedNodeMap;
036import org.w3c.dom.Node;
037import org.w3c.dom.NodeList;
038import org.xml.sax.InputSource;
039import org.xml.sax.SAXException;
040import org.xml.sax.SAXParseException;
041
042import java.io.BufferedReader;
043import java.io.FileNotFoundException;
044import java.io.IOException;
045import java.io.Writer;
046import java.nio.file.NoSuchFileException;
047import java.util.ArrayList;
048import java.util.Collection;
049import java.util.Collections;
050import java.util.LinkedHashMap;
051import java.util.Map;
052import java.util.Objects;
053import java.util.Set;
054import java.util.stream.Collectors;
055
056import javax.xml.XMLConstants;
057import javax.xml.parsers.DocumentBuilder;
058import javax.xml.parsers.DocumentBuilderFactory;
059import javax.xml.parsers.ParserConfigurationException;
060import javax.xml.transform.OutputKeys;
061import javax.xml.transform.Transformer;
062import javax.xml.transform.TransformerConfigurationException;
063import javax.xml.transform.TransformerException;
064import javax.xml.transform.TransformerFactory;
065import javax.xml.transform.dom.DOMSource;
066import javax.xml.transform.stream.StreamResult;
067import javax.xml.validation.Schema;
068
069/**
070 * A loader for XML (Extensible Markup Language), using the native javax library
071 * for parsing and generation.
072 *
073 * @since 4.0.0
074 */
075public final class XmlConfigurationLoader extends AbstractConfigurationLoader<AttributedConfigurationNode> {
076
077    private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(Double.class, Long.class,
078            Integer.class, Boolean.class, String.class, Number.class);
079
080    /**
081     * The prefix of lines within the header.
082     */
083    private static final String HEADER_PREFIX = "~";
084
085    private static final String ATTRIBUTE_TYPE = "configurate-type";
086
087    /**
088     * The user data used to store comments on nodes.
089     */
090    private static final String USER_DATA_COMMENT = "configurate-comment";
091
092    /**
093     * The property used to mark how many spaces should be used to indent.
094     */
095    private static final String INDENT_PROPERTY = "{http://xml.apache.org/xslt}indent-amount";
096
097    private static final String FEATURE_EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities";
098
099    private static final String FEATURE_EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities";
100
101    private static final String FEATURE_LOAD_EXTERNAL_DTD = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
102
103    /**
104     * Creates a new {@link XmlConfigurationLoader} builder.
105     *
106     * @return a new builder
107     * @since 4.0.0
108     */
109    public static @NonNull Builder builder() {
110        return new Builder();
111    }
112
113    /**
114     * Builds a {@link XmlConfigurationLoader}.
115     *
116     * <p>This builder supports the following options:</p>
117     * <ul>
118     *     <li>{@link #DEFAULT_TAG_NAME}</li>
119     *     <li>{@link #INDENT}</li>
120     *     <li>{@link #WRITE_EXPLICIT_TYPE}</li>
121     *     <li>{@link #RESOLVE_EXTERNAL_CONTENT}</li>
122     *     <li>{@link #INCLUDE_XML_DECLARATION}</li>
123     * </ul>
124     *
125     * @since 4.0.0
126     */
127    public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, XmlConfigurationLoader> {
128
129        private static final OptionSchema.Mutable UNSAFE_SCHEMA = OptionSchema.childSchema(AbstractConfigurationLoader.Builder.SCHEMA);
130        /**
131         * A schema of options available to configure the XML loader.
132         *
133         * @since 4.2.0
134         */
135        public static final OptionSchema SCHEMA = UNSAFE_SCHEMA.frozenView();
136        /**
137         * The default tag name the created loader should use.
138         *
139         * @see #defaultTagName(String)
140         * @since 4.2.0
141         */
142        public static final Option<String> DEFAULT_TAG_NAME = UNSAFE_SCHEMA.stringOption("xml:default_tag_name", "element");
143
144        /**
145         * The size of indent (in spaces) the created loader should use.
146         *
147         * @see #indent(int)
148         * @since 4.2.0
149         */
150        public static final Option<Integer> INDENT = UNSAFE_SCHEMA.intOption("xml:indent", 2);
151        /**
152         * Whether explicit node types should be written out (to disambiguate
153         * between maps and lists).
154         *
155         * @see #writesExplicitType(boolean)
156         * @since 4.2.0
157         */
158        public static final Option<Boolean> WRITE_EXPLICIT_TYPE = UNSAFE_SCHEMA.booleanOption("xml:writes_explicit_type", true);
159        /**
160         * Whether externally included content should be resolved in documents
161         * read by the created loader.
162         *
163         * @see #resolvesExternalContent(boolean)
164         * @since 4.2.0
165         */
166        public static final Option<Boolean> RESOLVE_EXTERNAL_CONTENT = UNSAFE_SCHEMA.booleanOption("xml:resolve_external_content", false);
167        /**
168         * Whether an XML declaration should be included as the first line of
169         * emitted documents from the created loader.
170         *
171         * @see #includesXmlDeclaration(boolean)
172         * @since 4.2.0
173         */
174        public static final Option<Boolean> INCLUDE_XML_DECLARATION = UNSAFE_SCHEMA.booleanOption("xml:include_xml_declaration", true);
175
176        private @Nullable Schema schema;
177
178        @Override
179        protected OptionSchema optionSchema() {
180            return SCHEMA;
181        }
182
183        /**
184         * Sets the level of indentation the resultant loader should use.
185         *
186         * @param indent the indent level
187         * @return this builder (for chaining)
188         * @since 4.0.0
189         */
190        public @NonNull Builder indent(final int indent) {
191            this.optionStateBuilder().value(INDENT, indent);
192            return this;
193        }
194
195        /**
196         * Gets the level of indentation to be used by the resultant loader.
197         *
198         * @return the indent level
199         * @since 4.0.0
200         */
201        public int indent() {
202            return this.optionState().value(INDENT);
203        }
204
205        /**
206         * Sets the {@link Schema} the resultant loader should use.
207         *
208         * @param schema the schema
209         * @return this builder (for chaining)
210         * @since 4.0.0
211         */
212        public Builder schema(final @Nullable Schema schema) {
213            this.schema = schema;
214            return this;
215        }
216
217        /**
218         * Gets the {@link Schema} to be used by the resultant loader.
219         *
220         * @return the schema
221         * @since 4.0.0
222         */
223        public @Nullable Schema schema() {
224            return this.schema;
225        }
226
227        /**
228         * Sets the default tag name the resultant loader should use.
229         *
230         * @param defaultTagName the default tag name
231         * @return this builder (for chaining)
232         * @since 4.0.0
233         */
234        public Builder defaultTagName(final String defaultTagName) {
235            this.optionStateBuilder().value(DEFAULT_TAG_NAME, defaultTagName);
236            return this;
237        }
238
239        /**
240         * Gets the default tag name to be used by the resultant loader.
241         *
242         * @return the default tag name
243         * @since 4.0.0
244         */
245        public @NonNull String defaultTagName() {
246            return this.optionState().value(DEFAULT_TAG_NAME);
247        }
248
249        /**
250         * Sets if the resultant loader should write the explicit type of each
251         * node when saving nodes.
252         *
253         * <p>This is necessary in some cases, as XML has no explicit definition
254         * of an array or list. The loader is able to infer the type in some
255         * cases, but this is inaccurate in some cases, for example lists with
256         * only one element.</p>
257         *
258         * @param writeExplicitType if the loader should write explicit types
259         * @return this builder (for chaining)
260         * @since 4.0.0
261         */
262        public Builder writesExplicitType(final boolean writeExplicitType) {
263            this.optionStateBuilder().value(WRITE_EXPLICIT_TYPE, writeExplicitType);
264            return this;
265        }
266
267        /**
268         * Gets if explicit type attributes should be written by the loader.
269         *
270         * <p>See the method doc at {@link #writesExplicitType(boolean)} for
271         * a more detailed explanation.</p>
272         *
273         * @return the default tag name
274         * @since 4.0.0
275         */
276        public boolean writesExplicitType() {
277            return this.optionState().value(WRITE_EXPLICIT_TYPE);
278        }
279
280        /**
281         * Sets if the resultant loader should include the XML declaration
282         * header when saving.
283         *
284         * @param includeXmlDeclaration if the XML declaration should be
285         *                              included
286         * @return this builder (for chaining)
287         * @since 4.0.0
288         */
289        public Builder includesXmlDeclaration(final boolean includeXmlDeclaration) {
290            this.optionStateBuilder().value(INCLUDE_XML_DECLARATION, includeXmlDeclaration);
291            return this;
292        }
293
294        /**
295         * Gets if the resultant loader should include the XML declaration
296         * header when saving.
297         *
298         * @return if the XML declaration should be included
299         * @since 4.0.0
300         */
301        public boolean includesXmlDeclaration() {
302            return this.optionState().value(INCLUDE_XML_DECLARATION);
303        }
304
305        /**
306         * Sets whether external content should be resolved when loading data.
307         *
308         * <p>Resolving this content could result in network requests being
309         * made, and will allow configuration files to access arbitrary URLs
310         * This setting should only be enabled with caution.
311         *
312         * <p>Additionally, through use of features such as entity expansion and
313         * XInclude, documents can be crafted that will grow exponentially
314         * when parsed, requiring an amount of memory to store that may be
315         * greater than what is available for the JVM.
316         *
317         * <p>By default, this is false.
318         *
319         * @param resolvesExternalContent whether to resolve external entities
320         * @return this builder
321         * @since 4.0.0
322         */
323        public Builder resolvesExternalContent(final boolean resolvesExternalContent) {
324            this.optionStateBuilder().value(RESOLVE_EXTERNAL_CONTENT, resolvesExternalContent);
325            return this;
326        }
327
328        /**
329         * Get whether external content should be resolved.
330         *
331         * @return value, defaulting to false
332         * @since 4.0.0
333         */
334        public boolean resolvesExternalContent() {
335            return this.optionState().value(RESOLVE_EXTERNAL_CONTENT);
336        }
337
338        @Override
339        public XmlConfigurationLoader build() {
340            this.defaultOptions(o -> o.nativeTypes(NATIVE_TYPES));
341            return new XmlConfigurationLoader(this);
342        }
343    }
344
345    private final @Nullable Schema schema;
346    private final String defaultTagName;
347    private final int indent;
348    private final boolean writeExplicitType;
349    private final boolean includeXmlDeclaration;
350    private final boolean resolvesExternalContent;
351
352    private XmlConfigurationLoader(final Builder builder) {
353        super(builder, new CommentHandler[] {CommentHandlers.XML_STYLE});
354        this.schema = builder.schema();
355        this.defaultTagName = builder.defaultTagName();
356        this.indent = builder.indent();
357        this.writeExplicitType = builder.writesExplicitType();
358        this.includeXmlDeclaration = builder.includesXmlDeclaration();
359        this.resolvesExternalContent = builder.resolvesExternalContent();
360    }
361
362    private DocumentBuilder newDocumentBuilder() throws ConfigurateException {
363        final DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
364        if (this.schema != null) {
365            builderFactory.setSchema(this.schema);
366        }
367        if (!this.resolvesExternalContent) {
368            // Settings based on https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
369            try {
370                builderFactory.setFeature(FEATURE_EXTERNAL_GENERAL_ENTITIES, false);
371                builderFactory.setFeature(FEATURE_EXTERNAL_PARAMETER_ENTITIES, false);
372                builderFactory.setFeature(FEATURE_LOAD_EXTERNAL_DTD, false);
373            } catch (final ParserConfigurationException e) {
374                throw new ConfigurateException(e);
375            }
376            builderFactory.setXIncludeAware(false);
377            builderFactory.setExpandEntityReferences(false);
378        }
379
380        try {
381            return builderFactory.newDocumentBuilder();
382        } catch (final ParserConfigurationException e) {
383            throw new ConfigurateException(e);
384        }
385    }
386
387    private Transformer newTransformer() throws ConfigurateException {
388        final TransformerFactory transformerFactory = TransformerFactory.newInstance();
389        if (!this.resolvesExternalContent) {
390            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
391            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
392        }
393        try {
394            final Transformer transformer = transformerFactory.newTransformer();
395
396            // we write the header ourselves.
397            transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
398
399            if (this.indent > 0) {
400                transformer.setOutputProperty(OutputKeys.INDENT, "yes");
401                transformer.setOutputProperty(INDENT_PROPERTY, Integer.toString(this.indent));
402            }
403            return transformer;
404        } catch (final TransformerConfigurationException e) {
405            throw new ConfigurateException(e);
406        }
407    }
408
409    @Override
410    public @NonNull AttributedConfigurationNode load(@NonNull ConfigurationOptions options) throws ParsingException {
411        if (this.source == null) {
412            throw new ParsingException(-1, -1, "", "No source present to read from!", null);
413        }
414        try (BufferedReader reader = this.source.call()) {
415            final DocumentBuilder documentBuilder = this.newDocumentBuilder();
416
417            final Document document;
418            try {
419                document = documentBuilder.parse(new InputSource(reader));
420            } catch (final SAXParseException ex) {
421                throw new ParsingException(ex.getLineNumber(), ex.getColumnNumber(), "", ex.getMessage(), ex.getCause());
422            } catch (final SAXException e) {
423                throw new ParsingException(-1, -1, null, null, e);
424            }
425
426            final NodeList children = document.getChildNodes();
427            for (int i = 0; i < children.getLength(); ++i) {
428                final Node child = children.item(i);
429                if (child.getNodeType() == Node.COMMENT_NODE) {
430                    options = options.header(this.unwrapHeader(child.getTextContent().trim()));
431                } else if (child.getNodeType() == Node.ELEMENT_NODE) {
432                    final AttributedConfigurationNode node = this.createNode(options);
433                    this.readElement(child, node);
434                    return node;
435                }
436            }
437            // empty document, fall through
438        } catch (final FileNotFoundException | NoSuchFileException e) {
439            // Squash -- there's nothing to read
440        } catch (final ParsingException ex) {
441            throw ex;
442        } catch (final Exception e) {
443            throw new ParsingException(-1, -1, "", null, e);
444        }
445        return this.createNode(options);
446    }
447
448    /**
449     * Given a single comment node's comment, clear any prefix lines.
450     *
451     * @param headerContent the content of a header
452     * @return a formatted header, with lines separated by {@link #CONFIGURATE_LINE_SEPARATOR}
453     */
454    private String unwrapHeader(final String headerContent) {
455        if (headerContent.isEmpty()) {
456            return headerContent;
457        }
458        // TODO: 4.0 may have changed behaviour here when moving away from Guava
459        return CONFIGURATE_LINE_PATTERN.splitAsStream(headerContent)
460                .map(line -> {
461                    final String trimmedLine = line.trim();
462                    if (trimmedLine.startsWith(HEADER_PREFIX)) {
463                        line = line.substring(line.indexOf(HEADER_PREFIX) + 1);
464                    }
465
466                    if (line.length() > 0 && line.charAt(0) == ' ') {
467                        line = line.substring(1);
468                    }
469                    return line;
470                }).filter(line -> !line.isEmpty())
471                .collect(Collectors.joining(CONFIGURATE_LINE_SEPARATOR));
472    }
473
474    @Override
475    protected void loadInternal(final AttributedConfigurationNode node, final BufferedReader reader) {
476        throw new UnsupportedOperationException("XMLConfigurationLoader provides custom loading logic to handle headers");
477    }
478
479    private enum NodeType {
480        MAP, LIST
481    }
482
483    private void readElement(final Node from, final AttributedConfigurationNode to) {
484        @Nullable NodeType type = null;
485
486        // copy the name of the tag
487        to.tagName(from.getNodeName());
488
489        final String potentialComment = (String) from.getUserData(USER_DATA_COMMENT);
490        if (potentialComment != null) {
491            to.comment(potentialComment);
492        }
493
494        // copy attributes
495        if (from.hasAttributes()) {
496            final NamedNodeMap attributes = from.getAttributes();
497            for (int i = 0; i < attributes.getLength(); i++) {
498                final Node attribute = attributes.item(i);
499                final String key = attribute.getNodeName();
500                final String value = attribute.getNodeValue();
501
502                // read the type of the node
503                if (key.equals(ATTRIBUTE_TYPE)) {
504                    if (value.equals("map")) {
505                        type = NodeType.MAP;
506                    } else if (value.equals("list")) {
507                        type = NodeType.LIST;
508                    }
509
510                    // don't add internal configurate attributes to the node
511                    continue;
512                }
513
514                to.addAttribute(key, value);
515            }
516        }
517
518        // read out the child nodes into a multimap
519        final Map<String, Collection<Node>> children = new LinkedHashMap<>();
520        if (from.hasChildNodes()) {
521            final StringBuilder comment = new StringBuilder();
522            final NodeList childNodes = from.getChildNodes();
523            for (int i = 0; i < childNodes.getLength(); i++) {
524                final Node child = childNodes.item(i);
525                if (child.getNodeType() == Node.ELEMENT_NODE) {
526                    children.computeIfAbsent(child.getNodeName(), $ -> new ArrayList<>()).add(child);
527                    if (comment.length() > 0) {
528                        child.setUserData(USER_DATA_COMMENT, comment.toString(), null);
529                        comment.setLength(0);
530                    }
531                } else if (child.getNodeType() == Node.COMMENT_NODE) {
532                    if (comment.length() > 0) {
533                        comment.append('\n');
534                    }
535
536                    comment.append(child.getTextContent().trim());
537                }
538            }
539        }
540
541        // if there are no child nodes present, assume it's a scalar value
542        if (children.isEmpty()) {
543            to.raw(parseValue(from.getTextContent()));
544            return;
545        }
546
547        // if type is null, we need to infer what type the element is
548        if (type == null) {
549            // if there are no duplicate keys, we can infer that it is a map
550            // otherwise, assume it's a list
551            type = NodeType.MAP;
552            for (final Collection<Node> child : children.values()) {
553                if (child.size() > 1) {
554                    type = NodeType.LIST;
555                    break;
556                }
557            }
558        }
559
560        if (type == NodeType.MAP) {
561            to.raw(Collections.emptyMap());
562        } else {
563            to.raw(Collections.emptyList());
564        }
565
566        // read out the elements
567        for (final Map.Entry<String, Collection<Node>> entry : children.entrySet()) {
568            AttributedConfigurationNode child;
569            if (type == NodeType.MAP) {
570                child = to.node(entry.getKey());
571                this.readElement(entry.getValue().iterator().next(), child);
572            } else {
573                for (final Node element : entry.getValue()) {
574                    child = to.appendListNode();
575                    this.readElement(element, child);
576                }
577            }
578        }
579    }
580
581    @Override
582    protected void writeHeaderInternal(final Writer writer) throws IOException {
583        if (this.includeXmlDeclaration) {
584            writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
585            writer.write(SYSTEM_LINE_SEPARATOR);
586        }
587    }
588
589    @Override
590    protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException {
591        final DocumentBuilder documentBuilder = this.newDocumentBuilder();
592        final Document document = documentBuilder.newDocument();
593
594        final @Nullable Node comment = this.createCommentNode(document, node);
595        if (comment != null) {
596            document.appendChild(comment);
597        }
598
599        document.appendChild(this.writeNode(document, node, null));
600
601        final Transformer transformer = this.newTransformer();
602        final DOMSource source = new DOMSource(document);
603        try {
604            transformer.transform(source, new StreamResult(writer));
605        } catch (final TransformerException e) {
606            throw new ConfigurateException(node, e);
607        }
608    }
609
610    private void appendCommentIfNecessary(final Element parent, final ConfigurationNode node) {
611        final @Nullable Node possibleComment = this.createCommentNode(parent.getOwnerDocument(), node);
612        if (possibleComment != null) {
613            parent.appendChild(possibleComment);
614        }
615    }
616
617    private @Nullable Node createCommentNode(final Document doc, final ConfigurationNode node) {
618        if (node instanceof CommentedConfigurationNodeIntermediary<?>) {
619            final @Nullable String comment = ((CommentedConfigurationNodeIntermediary<?>) node).comment();
620            if (comment != null) {
621                return doc.createComment(" " + comment.trim() + " ");
622            }
623        }
624        return null;
625    }
626
627    private Element writeNode(final Document document, final ConfigurationNode node, final @Nullable String forcedTag) {
628        String tag = this.defaultTagName;
629        Map<String, String> attributes = Collections.emptyMap();
630
631        if (node instanceof AttributedConfigurationNode) {
632            final AttributedConfigurationNode attributedNode = (AttributedConfigurationNode) node;
633            tag = attributedNode.tagName();
634            attributes = attributedNode.attributes();
635        }
636
637        final Element element = document.createElement(forcedTag == null ? tag : forcedTag);
638        for (final Map.Entry<String, String> attribute : attributes.entrySet()) {
639            element.setAttribute(attribute.getKey(), attribute.getValue());
640        }
641
642        if (node.isMap()) {
643            for (final Map.Entry<Object, ? extends ConfigurationNode> child : node.childrenMap().entrySet()) {
644                this.appendCommentIfNecessary(element, child.getValue());
645                element.appendChild(this.writeNode(document, child.getValue(), child.getKey().toString()));
646            }
647        } else if (node.isList()) {
648            if (this.writeExplicitType) {
649                element.setAttribute(ATTRIBUTE_TYPE, "list");
650            }
651            for (final ConfigurationNode child : node.childrenList()) {
652                this.appendCommentIfNecessary(element, child);
653                element.appendChild(this.writeNode(document, child, null));
654            }
655        } else {
656            element.appendChild(document.createTextNode(Objects.toString(node.rawScalar())));
657        }
658
659        return element;
660    }
661
662    @Override
663    public AttributedConfigurationNode createNode(ConfigurationOptions options) {
664        options = options.nativeTypes(NATIVE_TYPES);
665        return AttributedConfigurationNode.root("root", options);
666    }
667
668    private static Object parseValue(final String value) {
669        if (value.equals("true") || value.equals("false")) {
670            return Boolean.parseBoolean(value);
671        }
672
673        try {
674            final double doubleValue = Double.parseDouble(value);
675            if (isInteger(doubleValue)) {
676                final long longValue = Long.parseLong(value); // prevent losing precision
677                final int intValue = (int) longValue;
678                if (longValue == intValue) {
679                    return intValue;
680                } else {
681                    return longValue;
682                }
683            }
684            return doubleValue;
685        } catch (final NumberFormatException e) {
686            return value;
687        }
688    }
689
690    private static boolean isInteger(final double value) {
691        return !Double.isNaN(value) && Double.isFinite(value) && value == Math.rint(value);
692    }
693
694}