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