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.hocon;
018
019import com.typesafe.config.Config;
020import com.typesafe.config.ConfigException;
021import com.typesafe.config.ConfigFactory;
022import com.typesafe.config.ConfigList;
023import com.typesafe.config.ConfigObject;
024import com.typesafe.config.ConfigOrigin;
025import com.typesafe.config.ConfigOriginFactory;
026import com.typesafe.config.ConfigRenderOptions;
027import com.typesafe.config.ConfigValue;
028import com.typesafe.config.ConfigValueFactory;
029import com.typesafe.config.impl.ConfigNodeComment;
030import net.kyori.option.Option;
031import net.kyori.option.OptionSchema;
032import net.kyori.option.OptionState;
033import org.checkerframework.checker.nullness.qual.Nullable;
034import org.spongepowered.configurate.CommentedConfigurationNode;
035import org.spongepowered.configurate.CommentedConfigurationNodeIntermediary;
036import org.spongepowered.configurate.ConfigurateException;
037import org.spongepowered.configurate.ConfigurationNode;
038import org.spongepowered.configurate.ConfigurationOptions;
039import org.spongepowered.configurate.loader.AbstractConfigurationLoader;
040import org.spongepowered.configurate.loader.CommentHandler;
041import org.spongepowered.configurate.loader.CommentHandlers;
042import org.spongepowered.configurate.loader.ParsingException;
043import org.spongepowered.configurate.util.UnmodifiableCollections;
044
045import java.io.BufferedReader;
046import java.io.IOException;
047import java.io.Writer;
048import java.lang.reflect.Constructor;
049import java.lang.reflect.InvocationTargetException;
050import java.util.ArrayList;
051import java.util.Collections;
052import java.util.List;
053import java.util.Map;
054import java.util.Set;
055import java.util.stream.Collectors;
056
057/**
058 * A loader for HOCON (Hodor)-formatted configurations, using the
059 * <a href="https://github.com/lightbend/config">lightbend config</a> library
060 * for parsing and generation.
061 *
062 * @since 4.0.0
063 */
064public final class HoconConfigurationLoader extends AbstractConfigurationLoader<CommentedConfigurationNode> {
065
066    private static final Set<Class<?>> NATIVE_TYPES = UnmodifiableCollections.toSet(
067            Double.class, Long.class, Integer.class, Boolean.class, String.class, Number.class);
068
069    /**
070     * The default render options used by configurate.
071     */
072    private static final ConfigRenderOptions DEFAULT_RENDER_OPTIONS = ConfigRenderOptions.defaults()
073            .setOriginComments(false)
074            .setJson(false);
075
076    /**
077     * An instance of {@link ConfigOrigin} for configurate.
078     */
079    private static final ConfigOrigin CONFIGURATE_ORIGIN = ConfigOriginFactory.newSimple("configurate-hocon");
080
081    /**
082     * Creates a new {@link HoconConfigurationLoader} builder.
083     *
084     * @return a new builder
085     * @since 4.0.0
086     */
087    public static Builder builder() {
088        return new Builder();
089    }
090
091    /**
092     * Builds a {@link HoconConfigurationLoader}.
093     *
094     * <p>This builder supports the following options:</p>
095     * <ul>
096     *     <li>{@link #HEADER_MODE}</li>
097     *     <li>{@link #PRETTY_PRINTING}</li>
098     *     <li>{@link #INDENT}</li>
099     *     <li>{@link #COMMENTS}</li>
100     *     <li>{@link #JSON_COMPATIBLE}</li>
101     * </ul>
102     *
103     * @since 4.0.0
104     */
105    public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, HoconConfigurationLoader> {
106        private static final OptionSchema.Mutable UNSAFE_SCHEMA = OptionSchema.childSchema(AbstractConfigurationLoader.Builder.SCHEMA);
107
108        /**
109         * A schema of options available to configure the Hocon loader.
110         *
111         * @since 4.2.0
112         */
113        public static final OptionSchema SCHEMA = UNSAFE_SCHEMA.frozenView();
114
115        /**
116         * Set whether output from this loader will be pretty-printed or not.
117         *
118         * @see #prettyPrinting(boolean)
119         * @since 4.2.0
120         */
121        public static final Option<Boolean> PRETTY_PRINTING =
122                UNSAFE_SCHEMA.booleanOption("hocon:pretty-printing", DEFAULT_RENDER_OPTIONS.getFormatted());
123
124        /**
125         * Set the amount of spaces to indent with when {@link #prettyPrinting(boolean)} is on.
126         *
127         * @see #indent(int)
128         * @since 4.2.0
129         */
130        public static final Option<Integer> INDENT = UNSAFE_SCHEMA.intOption("hocon:indent", DEFAULT_RENDER_OPTIONS.getIndent());
131
132        /**
133         * Set whether comments will be emitted.
134         *
135         * @see #emitComments(boolean)
136         * @since 4.2.0
137         */
138        public static final Option<Boolean> COMMENTS = UNSAFE_SCHEMA.booleanOption("hocon:emit-comments", DEFAULT_RENDER_OPTIONS.getComments());
139
140        /**
141         * Set whether JSON compatible output mode will be used.
142         *
143         * @see #emitJsonCompatible(boolean)
144         * @since 4.2.0
145         */
146        public static final Option<Boolean> JSON_COMPATIBLE = UNSAFE_SCHEMA.booleanOption("hocon:json-compatible", DEFAULT_RENDER_OPTIONS.getJson());
147
148        @Override
149        protected OptionSchema optionSchema() {
150            return SCHEMA;
151        }
152
153        /**
154         * Set whether output from this loader will be pretty-printed or not.
155         *
156         * @param prettyPrinting whether to pretty-print
157         * @return this builder
158         * @since 4.0.0
159         */
160        public Builder prettyPrinting(final boolean prettyPrinting) {
161            this.optionStateBuilder().value(PRETTY_PRINTING, prettyPrinting);
162            return this;
163        }
164
165        /**
166         * Set the amount of spaces to indent with when
167         * {@link #prettyPrinting(boolean)} is on.
168         *
169         * <p>Defaults to 4.</p>
170         *
171         * @param indent indent level
172         * @return this builder
173         * @since 4.2.0
174         */
175        public Builder indent(final int indent) {
176            this.optionStateBuilder().value(INDENT, indent);
177            return this;
178        }
179
180        /**
181         * Set whether comments should be emitted.
182         *
183         * <p>Comments will always be loaded from files and
184         * stored in memory.</p>
185         *
186         * @param emitComments whether to emit comments
187         * @return this builder
188         * @since 4.0.0
189         */
190        public Builder emitComments(final boolean emitComments) {
191            this.optionStateBuilder().value(COMMENTS, emitComments);
192            return this;
193        }
194
195        /**
196         * Set whether output generated by this loader should be
197         * json-compatible.
198         *
199         * <p>Whatever format input is received in, this will output
200         * JSON. To be fully spec-compliant, comment output must also
201         * be disabled.</p>
202         *
203         * @param jsonCompatible to emit json-format output
204         * @return this builder
205         * @since 4.0.0
206         */
207        public Builder emitJsonCompatible(final boolean jsonCompatible) {
208            this.optionStateBuilder().value(JSON_COMPATIBLE, jsonCompatible);
209            return this;
210        }
211
212        ConfigRenderOptions renderOptions() {
213            final OptionState opt = this.optionState();
214            return DEFAULT_RENDER_OPTIONS
215                    .setFormatted(opt.value(PRETTY_PRINTING))
216                    .setIndent(opt.value(INDENT))
217                    .setComments(opt.value(COMMENTS))
218                    .setJson(opt.value(JSON_COMPATIBLE));
219        }
220
221        @Override
222        public HoconConfigurationLoader build() {
223            defaultOptions(o -> o.nativeTypes(NATIVE_TYPES));
224            return new HoconConfigurationLoader(this);
225        }
226    }
227
228    private final ConfigRenderOptions render;
229
230    private HoconConfigurationLoader(final Builder build) {
231        super(build, new CommentHandler[] {CommentHandlers.HASH, CommentHandlers.DOUBLE_SLASH});
232        this.render = build.renderOptions();
233    }
234
235    @Override
236    protected void checkCanWrite(final ConfigurationNode node) throws ConfigurateException {
237        if (!node.isMap() && !node.virtual() && node.raw() != null) {
238            throw new ConfigurateException(node, "HOCON can only write nodes that are in map format!");
239        }
240    }
241
242    @Override
243    protected void loadInternal(final CommentedConfigurationNode node, final BufferedReader reader) throws ParsingException {
244        Config hoconConfig;
245        try {
246            hoconConfig = ConfigFactory.parseReader(reader);
247            hoconConfig = hoconConfig.resolve();
248        } catch (final ConfigException ex) {
249            throw new ParsingException(node, ex.origin().lineNumber(), 0, ex.origin().description(), null, ex);
250        }
251
252        for (Map.Entry<String, ConfigValue> ent : hoconConfig.root().entrySet()) {
253            readConfigValue(ent.getValue(), node.node(ent.getKey()));
254        }
255    }
256
257    private static void readConfigValue(final ConfigValue value, final CommentedConfigurationNode node) {
258        if (!value.origin().comments().isEmpty()) {
259            node.comment(value.origin().comments().stream()
260                .map(input -> {
261                    final String lineStripped = input.commentText().replace("\r", "");
262                    if (!lineStripped.isEmpty() && lineStripped.charAt(0) == ' ') {
263                        return lineStripped.substring(1);
264                    } else {
265                        return lineStripped;
266                    }
267                })
268                .collect(Collectors.joining("\n")));
269        }
270
271        switch (value.valueType()) {
272            case OBJECT:
273                final ConfigObject object = (ConfigObject) value;
274                if (object.isEmpty()) {
275                    node.raw(Collections.emptyMap());
276                } else {
277                    for (Map.Entry<String, ConfigValue> ent : object.entrySet()) {
278                        readConfigValue(ent.getValue(), node.node(ent.getKey()));
279                    }
280                }
281                break;
282            case LIST:
283                final ConfigList list = (ConfigList) value;
284                if (list.isEmpty()) {
285                    node.raw(Collections.emptyList());
286                } else {
287                    for (int i = 0; i < list.size(); ++i) {
288                        readConfigValue(list.get(i), node.node(i));
289                    }
290                }
291                break;
292            case NULL:
293                return;
294            default:
295                node.raw(value.unwrapped());
296                break;
297        }
298    }
299
300    @Override
301    protected void saveInternal(final ConfigurationNode node, final Writer writer) throws ConfigurateException {
302        try {
303            if (!node.isMap() && (node.virtual() || node.raw() == null)) {
304                writer.write(SYSTEM_LINE_SEPARATOR);
305                return;
306            }
307            final ConfigValue value = fromValue(node);
308            final String renderedValue = value.render(this.render);
309            writer.write(renderedValue);
310        } catch (final IOException io) {
311            throw new ConfigurateException(node, io);
312        }
313    }
314
315    private static ConfigValue fromValue(final ConfigurationNode node) {
316        ConfigValue ret;
317        if (node.isMap()) {
318            final Map<String, ConfigValue> children = node.options().mapFactory().create();
319            for (Map.Entry<Object, ? extends ConfigurationNode> ent : node.childrenMap().entrySet()) {
320                children.put(String.valueOf(ent.getKey()), fromValue(ent.getValue()));
321            }
322            ret = newConfigObject(children);
323        } else if (node.isList()) {
324            final List<ConfigValue> children = new ArrayList<>();
325            for (ConfigurationNode ent : node.childrenList()) {
326                children.add(fromValue(ent));
327            }
328            ret = newConfigList(children);
329
330        } else {
331            ret = ConfigValueFactory.fromAnyRef(node.rawScalar(), CONFIGURATE_ORIGIN.description());
332        }
333        if (node instanceof CommentedConfigurationNodeIntermediary<?>) {
334            final CommentedConfigurationNodeIntermediary<?> commentedNode = (CommentedConfigurationNodeIntermediary<?>) node;
335            final @Nullable String origComment = commentedNode.comment();
336            if (origComment != null) {
337                final List<ConfigNodeComment> nodes = new ArrayList<>();
338                for (final String line : CONFIGURATE_LINE_PATTERN.split(origComment, -1)) {
339                    if (line.length() != 0 && line.charAt(0) == '#') {
340                        // allow lines that are only the comment character, for box drawing
341                        nodes.add(ConfigNodeComment.hashComment(line));
342                    } else {
343                        nodes.add(ConfigNodeComment.hashComment(' ' + line));
344                    }
345                }
346                ret = ret.withOrigin(ret.origin().withComments(nodes));
347            }
348        }
349        return ret;
350    }
351
352    static ConfigValue newConfigObject(final Map<String, ConfigValue> vals) {
353        try {
354            return CONFIG_OBJECT_CONSTRUCTOR.newInstance(CONFIGURATE_ORIGIN, vals);
355        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
356            throw new RuntimeException(e); // rethrow
357        }
358
359    }
360
361    static ConfigValue newConfigList(final List<ConfigValue> vals) {
362        try {
363            return CONFIG_LIST_CONSTRUCTOR.newInstance(CONFIGURATE_ORIGIN, vals);
364        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
365            throw new RuntimeException(e); // rethrow
366        }
367    }
368
369    @Override
370    public CommentedConfigurationNode createNode(final ConfigurationOptions options) {
371        return CommentedConfigurationNode.root(options.nativeTypes(NATIVE_TYPES));
372    }
373
374    // -- Comment handling -- this might have to be updated as the hocon dep changes
375    // (But tests should detect this breakage)
376    private static final Constructor<? extends ConfigValue> CONFIG_OBJECT_CONSTRUCTOR;
377    private static final Constructor<? extends ConfigValue> CONFIG_LIST_CONSTRUCTOR;
378
379    static {
380        final Class<? extends ConfigValue> objectClass;
381        final Class<? extends ConfigValue> listClass;
382        try {
383            objectClass = Class.forName("com.typesafe.config.impl.SimpleConfigObject").asSubclass(ConfigValue.class);
384            listClass = Class.forName("com.typesafe.config.impl.SimpleConfigList").asSubclass(ConfigValue.class);
385        } catch (final ClassNotFoundException e) {
386            throw new ExceptionInInitializerError(e);
387        }
388
389        try {
390            CONFIG_OBJECT_CONSTRUCTOR = objectClass.getDeclaredConstructor(ConfigOrigin.class, Map.class);
391            CONFIG_OBJECT_CONSTRUCTOR.setAccessible(true);
392            CONFIG_LIST_CONSTRUCTOR = listClass.getDeclaredConstructor(ConfigOrigin.class, List.class);
393            CONFIG_LIST_CONSTRUCTOR.setAccessible(true);
394        } catch (final NoSuchMethodException e) {
395            throw new ExceptionInInitializerError(e);
396        }
397    }
398
399}