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.jackson;
018
019import com.fasterxml.jackson.core.JsonFactory;
020import com.fasterxml.jackson.core.JsonFactoryBuilder;
021import com.fasterxml.jackson.core.JsonGenerator;
022import com.fasterxml.jackson.core.JsonLocation;
023import com.fasterxml.jackson.core.JsonParser;
024import com.fasterxml.jackson.core.JsonToken;
025import com.fasterxml.jackson.core.exc.StreamReadException;
026import com.fasterxml.jackson.core.json.JsonReadFeature;
027import org.checkerframework.checker.nullness.qual.NonNull;
028import org.checkerframework.checker.nullness.qual.Nullable;
029import org.spongepowered.configurate.BasicConfigurationNode;
030import org.spongepowered.configurate.ConfigurateException;
031import org.spongepowered.configurate.ConfigurationNode;
032import org.spongepowered.configurate.ConfigurationOptions;
033import org.spongepowered.configurate.loader.AbstractConfigurationLoader;
034import org.spongepowered.configurate.loader.CommentHandler;
035import org.spongepowered.configurate.loader.CommentHandlers;
036import org.spongepowered.configurate.loader.ParsingException;
037import org.spongepowered.configurate.util.UnmodifiableCollections;
038
039import java.io.BufferedReader;
040import java.io.IOException;
041import java.io.Writer;
042import java.util.Collections;
043import java.util.List;
044import java.util.Map;
045import java.util.Set;
046
047/**
048 * A loader for JSON-formatted configurations, using the jackson library for
049 * parsing and generation.
050 *
051 * @since 4.0.0
052 */
053public final class JacksonConfigurationLoader extends AbstractConfigurationLoader<BasicConfigurationNode> {
054
055    private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(Map.class, List.class, Double.class, Float.class,
056            Long.class, Integer.class, Boolean.class, String.class, byte[].class);
057
058    /**
059     * Creates a new {@link JacksonConfigurationLoader} builder.
060     *
061     * @return a new builder
062     * @since 4.0.0
063     */
064    public static Builder builder() {
065        return new Builder();
066    }
067
068    /**
069     * Builds a {@link JacksonConfigurationLoader}.
070     *
071     * @since 4.0.0
072     */
073    public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, JacksonConfigurationLoader> {
074        private final JsonFactoryBuilder factory = new JsonFactoryBuilder();
075        private int indent = 2;
076        private FieldValueSeparatorStyle fieldValueSeparatorStyle = FieldValueSeparatorStyle.SPACE_AFTER;
077
078        Builder() {
079            this.factory.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
080                    .enable(JsonReadFeature.ALLOW_YAML_COMMENTS)
081                    .enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER)
082                    .enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
083                    .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
084                    .enable(JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS)
085                    .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS);
086        }
087
088        /**
089         * Gets the {@link JsonFactory} used to configure the implementation.
090         *
091         * @return the json factory
092         * @since 4.0.0
093         */
094        public JsonFactoryBuilder factoryBuilder() {
095            return this.factory;
096        }
097
098        /**
099         * Sets the level of indentation the resultant loader should use.
100         *
101         * @param indent the indent level
102         * @return this builder (for chaining)
103         * @since 4.0.0
104         */
105        public Builder indent(final int indent) {
106            this.indent = indent;
107            return this;
108        }
109
110        /**
111         * Gets the level of indentation to be used by the resultant loader.
112         *
113         * @return the indent level
114         * @since 4.0.0
115         */
116        public int indent() {
117            return this.indent;
118        }
119
120        /**
121         * Sets the field value separator style the resultant loader should use.
122         *
123         * @param style the style
124         * @return this builder (for chaining)
125         * @since 4.0.0
126         */
127        public Builder fieldValueSeparatorStyle(final FieldValueSeparatorStyle style) {
128            this.fieldValueSeparatorStyle = style;
129            return this;
130        }
131
132        /**
133         * Gets the field value separator style to be used by the built loader.
134         *
135         * @return the style
136         * @since 4.0.0
137         */
138        public FieldValueSeparatorStyle fieldValueSeparatorStyle() {
139            return this.fieldValueSeparatorStyle;
140        }
141
142        @Override
143        public JacksonConfigurationLoader build() {
144            defaultOptions(o -> o.nativeTypes(NATIVE_TYPES));
145            return new JacksonConfigurationLoader(this);
146        }
147    }
148
149    private final JsonFactory factory;
150    private final int indent;
151    private final FieldValueSeparatorStyle fieldValueSeparatorStyle;
152
153    private JacksonConfigurationLoader(final Builder builder) {
154        super(builder, new CommentHandler[]{CommentHandlers.DOUBLE_SLASH, CommentHandlers.SLASH_BLOCK, CommentHandlers.HASH});
155        this.factory = builder.factoryBuilder().build();
156        this.factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE);
157        this.indent = builder.indent();
158        this.fieldValueSeparatorStyle = builder.fieldValueSeparatorStyle();
159    }
160
161    private static final int MAX_CTX_LENGTH = 80;
162
163    @Override
164    protected void loadInternal(final BasicConfigurationNode node, final BufferedReader reader) throws ParsingException {
165        try (JsonParser parser = this.factory.createParser(reader)) {
166            parser.nextToken();
167            parseValue(parser, node);
168        } catch (final StreamReadException ex) {
169            throw newException(node, ex.getLocation(), ex.getRequestPayloadAsString(), ex.getMessage(), ex.getCause());
170        } catch (final IOException ex) {
171            throw ParsingException.wrap(node, ex);
172        }
173    }
174
175    private static void parseValue(final JsonParser parser, final ConfigurationNode node) throws IOException {
176        try {
177            final JsonToken token = parser.getCurrentToken();
178            switch (token) {
179                case START_OBJECT:
180                    parseObject(parser, node);
181                    break;
182                case START_ARRAY:
183                    parseArray(parser, node);
184                    break;
185                case VALUE_NUMBER_FLOAT:
186                    final double doubleVal = parser.getDoubleValue();
187                    if ((float) doubleVal != doubleVal) {
188                        node.raw(parser.getDoubleValue());
189                    } else {
190                        node.raw(parser.getFloatValue());
191                    }
192                    break;
193                case VALUE_NUMBER_INT:
194                    final long longVal = parser.getLongValue();
195                    if ((int) longVal != longVal) {
196                        node.raw(parser.getLongValue());
197                    } else {
198                        node.raw(parser.getIntValue());
199                    }
200                    break;
201                case VALUE_STRING:
202                    node.raw(parser.getText());
203                    break;
204                case VALUE_TRUE:
205                case VALUE_FALSE:
206                    node.raw(parser.getBooleanValue());
207                    break;
208                case VALUE_NULL: // Ignored values
209                case FIELD_NAME:
210                    break;
211                default:
212                    final JsonLocation loc = parser.getTokenLocation();
213                    throw new ParsingException(node, loc.getLineNr(), loc.getColumnNr(), parser.getText(), "Unsupported token type: " + token, null);
214            }
215        } catch (final StreamReadException ex) {
216            throw newException(node, ex.getLocation(), ex.getRequestPayloadAsString(), ex.getMessage(), ex.getCause());
217        }
218    }
219
220    private static void parseArray(final JsonParser parser, final ConfigurationNode node) throws IOException {
221        boolean written = false;
222        JsonToken token;
223        while ((token = parser.nextToken()) != null) {
224            if (token == JsonToken.END_ARRAY) { // ensure the type is preserved
225                if (!written) {
226                    node.raw(Collections.emptyList());
227                }
228                return;
229            } else {
230                parseValue(parser, node.appendListNode());
231                written = true;
232            }
233        }
234        throw newException(node, parser.getCurrentLocation(), null, "Reached end of stream with unclosed array!", null);
235    }
236
237    private static void parseObject(final JsonParser parser, final ConfigurationNode node) throws IOException {
238        boolean written = false;
239        JsonToken token;
240        while ((token = parser.nextToken()) != null) {
241            if (token == JsonToken.END_OBJECT) { // ensure the type is preserved
242                if (!written) {
243                    node.raw(Collections.emptyMap());
244                }
245                return;
246            } else {
247                parseValue(parser, node.node(parser.getCurrentName()));
248                written = true;
249            }
250        }
251        throw newException(node, parser.getCurrentLocation(), null, "Reached end of stream with unclosed object!", null);
252    }
253
254    @Override
255    protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException {
256        try (JsonGenerator generator = this.factory.createGenerator(writer)) {
257            generator.setPrettyPrinter(new ConfiguratePrettyPrinter(this.indent, this.fieldValueSeparatorStyle));
258            node.visit(JacksonVisitor.INSTANCE.get(), generator);
259            writer.write(SYSTEM_LINE_SEPARATOR); // Jackson doesn't add a newline at the end of files by default
260        } catch (final IOException ex) {
261            throw ConfigurateException.wrap(node, ex);
262        }
263    }
264
265    @Override
266    public BasicConfigurationNode createNode(final @NonNull ConfigurationOptions options) {
267        return BasicConfigurationNode.root(options.nativeTypes(NATIVE_TYPES));
268    }
269
270    private static ParsingException newException(final ConfigurationNode node,
271            final JsonLocation position,
272            final @Nullable String content,
273            final @Nullable String message,
274            final @Nullable Throwable cause) {
275        @Nullable String context = content == null ? null : content.substring((int) position.getCharOffset());
276        if (context != null) {
277            int nextLine = context.indexOf('\n');
278            if (nextLine == -1) {
279                nextLine = context.length();
280            } else if (context.charAt(nextLine - 1) == '\r') {
281                nextLine--;
282            }
283
284            if (nextLine > MAX_CTX_LENGTH) {
285                context = context.substring(0, MAX_CTX_LENGTH) + "...";
286            } else {
287                context = context.substring(0, nextLine);
288            }
289        }
290        // no newline: set to length
291        // too long: truncate
292        // otherwise: trim to position of next newline
293        return new ParsingException(node, position.getLineNr(), position.getColumnNr(), context, message, cause);
294    }
295
296}