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