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