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.gson;
018
019import com.google.gson.JsonArray;
020import com.google.gson.JsonElement;
021import com.google.gson.JsonNull;
022import com.google.gson.JsonObject;
023import com.google.gson.JsonParseException;
024import com.google.gson.JsonPrimitive;
025import com.google.gson.stream.JsonReader;
026import com.google.gson.stream.JsonToken;
027import com.google.gson.stream.JsonWriter;
028import com.google.gson.stream.MalformedJsonException;
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.LoaderOptionSource;
039import org.spongepowered.configurate.loader.ParsingException;
040import org.spongepowered.configurate.serialize.TypeSerializerCollection;
041import org.spongepowered.configurate.util.Strings;
042import org.spongepowered.configurate.util.UnmodifiableCollections;
043
044import java.io.BufferedReader;
045import java.io.IOException;
046import java.io.Writer;
047import java.util.Collections;
048import java.util.Set;
049
050/**
051 * A loader for JSON-formatted configurations, using the GSON library for
052 * parsing and generation.
053 *
054 * @since 4.0.0
055 */
056public final class GsonConfigurationLoader extends AbstractConfigurationLoader<BasicConfigurationNode> {
057
058    private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(
059            Double.class, Float.class, Long.class, Integer.class, Boolean.class, String.class);
060    private static final TypeSerializerCollection GSON_SERIALIZERS = TypeSerializerCollection.defaults().childBuilder()
061            .register(JsonElement.class, JsonElementSerializer.INSTANCE)
062            .build();
063
064    // visible for tests
065    static final ConfigurationOptions DEFAULT_OPTIONS = ConfigurationOptions.defaults()
066            .nativeTypes(NATIVE_TYPES)
067            .serializers(GSON_SERIALIZERS);
068
069    /**
070     * Creates a new {@link GsonConfigurationLoader} builder.
071     *
072     * @return a new builder
073     * @since 4.0.0
074     */
075    public static @NonNull Builder builder() {
076        return new Builder();
077    }
078
079    /**
080     * Get a {@link TypeSerializerCollection} for handling Gson types.
081     *
082     * <p>Currently, this serializer can handle:</p>
083     * <ul>
084     *     <li>{@link JsonElement} and its subtypes: {@link JsonArray}, {@link JsonObject},
085     *          {@link JsonPrimitive}, and {@link JsonNull}</li>
086     * </ul>
087     *
088     * @return gson type serializers
089     * @since 4.1.0
090     */
091    public static TypeSerializerCollection gsonSerializers() {
092        return GSON_SERIALIZERS;
093    }
094
095    /**
096     * Builds a {@link GsonConfigurationLoader}.
097     *
098     * <p>This builder supports the following options:</p>
099     * <dl>
100     *     <dt>&lt;prefix&gt;.gson.lenient</dt>
101     *     <dd>Equivalent to {@link #lenient(boolean)}</dd>
102     *     <dt>&lt;prefix&gt;.gson.indent</dt>
103     *     <dd>Equivalent to {@link #indent(int)}</dd>
104     * </dl>
105     *
106     * @since 4.0.0
107     */
108    public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, GsonConfigurationLoader> {
109        private boolean lenient = true;
110        private int indent = 2;
111
112        Builder() {
113            this.defaultOptions(DEFAULT_OPTIONS);
114            this.from(DEFAULT_OPTIONS_SOURCE);
115        }
116
117        @Override
118        protected void populate(final LoaderOptionSource options) {
119            this.indent = options.getInt(this.indent, "gson", "indent");
120            this.lenient = options.getBoolean(this.lenient, "gson", "lenient");
121        }
122
123        /**
124         * Sets the level of indentation the resultant loader should use.
125         *
126         * @param indent the indent level
127         * @return this builder (for chaining)
128         * @since 4.0.0
129         */
130        public @NonNull Builder indent(final int indent) {
131            this.indent = indent;
132            return this;
133        }
134
135        /**
136         * Gets the level of indentation to be used by the resultant loader.
137         *
138         * @return the indent level
139         * @since 4.0.0
140         */
141        public int indent() {
142            return this.indent;
143        }
144
145        /**
146         * Sets if the resultant loader should parse leniently.
147         *
148         * @param lenient whether the parser should parse leniently
149         * @return this builder (for chaining)
150         * @see JsonReader#setLenient(boolean)
151         * @since 4.0.0
152         */
153        public @NonNull Builder lenient(final boolean lenient) {
154            this.lenient = lenient;
155            return this;
156        }
157
158        /**
159         * Gets if the resultant loader should parse leniently.
160         *
161         * @return whether the parser should parse leniently
162         * @since 4.0.0
163         */
164        public boolean lenient() {
165            return this.lenient;
166        }
167
168        @Override
169        public @NonNull GsonConfigurationLoader build() {
170            this.defaultOptions(o -> o.nativeTypes(NATIVE_TYPES));
171            return new GsonConfigurationLoader(this);
172        }
173    }
174
175    private final boolean lenient;
176    private final String indent;
177
178    GsonConfigurationLoader(final Builder builder) {
179        super(builder, new CommentHandler[] {CommentHandlers.DOUBLE_SLASH, CommentHandlers.SLASH_BLOCK, CommentHandlers.HASH});
180        this.lenient = builder.lenient();
181        this.indent = Strings.repeat(" ", builder.indent());
182    }
183
184    @Override
185    protected void checkCanWrite(final ConfigurationNode node) throws ConfigurateException {
186        if (!this.lenient && !node.isMap()) {
187            throw new ConfigurateException(node, "Non-lenient json generators must have children of map type");
188        }
189    }
190
191    @Override
192    protected void loadInternal(final BasicConfigurationNode node, final BufferedReader reader) throws ParsingException {
193        try {
194            reader.mark(1);
195            if (reader.read() == -1) {
196                return;
197            }
198            reader.reset();
199        } catch (final IOException ex) {
200            throw new ParsingException(node, 0, 0, null, "peeking file size", ex);
201        }
202
203        try (JsonReader parser = new JsonReader(reader)) {
204            parser.setLenient(this.lenient);
205            this.parseValue(parser, node);
206        } catch (final IOException ex) {
207            throw ParsingException.wrap(node, ex);
208        }
209    }
210
211    private void parseValue(final JsonReader parser, final BasicConfigurationNode node) throws ParsingException {
212        final JsonToken token;
213        try {
214            token = parser.peek();
215        } catch (final IOException ex) {
216            throw this.newException(parser, node, ex.getMessage(), ex);
217        }
218
219        try {
220            switch (token) {
221                case BEGIN_OBJECT:
222                    this.parseObject(parser, node);
223                    break;
224                case BEGIN_ARRAY:
225                    this.parseArray(parser, node);
226                    break;
227                case NUMBER:
228                    node.raw(this.readNumber(parser));
229                    break;
230                case STRING:
231                    node.raw(parser.nextString());
232                    break;
233                case BOOLEAN:
234                    node.raw(parser.nextBoolean());
235                    break;
236                case NULL: // Ignored values
237                    parser.nextNull();
238                    node.raw(null);
239                    break;
240                case NAME:
241                    break;
242                default:
243                    throw this.newException(parser, node, "Unsupported token type: " + token, null);
244            }
245        } catch (final JsonParseException | MalformedJsonException ex) {
246            throw this.newException(parser, node, ex.getMessage(), ex.getCause());
247        } catch (final ParsingException ex) {
248            ex.initPath(node::path);
249            throw ex;
250        } catch (final IOException ex) {
251            throw this.newException(parser, node, "An underlying exception occurred", ex);
252        }
253    }
254
255    private ParsingException newException(final JsonReader reader, final ConfigurationNode node, final @Nullable String message,
256            final @Nullable Throwable cause) {
257        return new ParsingException(node, JsonReaderAccess.lineNumber(reader), JsonReaderAccess.column(reader), null, message, cause);
258    }
259
260    private Number readNumber(final JsonReader reader) throws IOException {
261        final String number = reader.nextString();
262        if (number.contains(".")) {
263            return Double.parseDouble(number);
264        }
265        final long nextLong = Long.parseLong(number);
266        final int nextInt = (int) nextLong;
267        if (nextInt == nextLong) {
268            return nextInt;
269        }
270        return nextLong;
271    }
272
273    private void parseArray(final JsonReader parser, final BasicConfigurationNode node) throws IOException {
274        parser.beginArray();
275
276        boolean written = false;
277        @Nullable JsonToken token;
278        while ((token = parser.peek()) != null) {
279            if (token == JsonToken.END_ARRAY) {
280                parser.endArray();
281                // ensure the type is preserved
282                if (!written) {
283                    node.raw(Collections.emptyList());
284                }
285                return;
286            } else {
287                this.parseValue(parser, node.appendListNode());
288                written = true;
289            }
290        }
291        throw this.newException(parser, node, "Reached end of stream with unclosed array!", null);
292    }
293
294    private void parseObject(final JsonReader parser, final BasicConfigurationNode node) throws ParsingException, IOException {
295        parser.beginObject();
296
297        boolean written = false;
298        @Nullable JsonToken token;
299        while ((token = parser.peek()) != null) {
300            switch (token) {
301                case END_OBJECT:
302                case END_DOCUMENT:
303                    parser.endObject();
304                    // ensure the type is preserved
305                    if (!written) {
306                        node.raw(Collections.emptyMap());
307                    }
308                    return;
309                case NAME:
310                    this.parseValue(parser, node.node(parser.nextName()));
311                    written = true;
312                    break;
313                default:
314                    throw new JsonParseException("Received improper object value " + token);
315            }
316        }
317        throw new JsonParseException("Reached end of stream with unclosed object!");
318    }
319
320    @Override
321    protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException {
322        try {
323            try (JsonWriter generator = new JsonWriter(writer)) {
324                generator.setIndent(this.indent);
325                generator.setLenient(this.lenient);
326                node.visit(GsonVisitor.INSTANCE.get(), generator);
327                writer.write(SYSTEM_LINE_SEPARATOR); // Jackson doesn't add a newline at the end of files by default
328            }
329        } catch (final IOException ex) {
330            throw ConfigurateException.wrap(node, ex);
331        }
332    }
333
334    @Override
335    public BasicConfigurationNode createNode(final ConfigurationOptions options) {
336        return BasicConfigurationNode.root(options.nativeTypes(NATIVE_TYPES));
337    }
338
339}