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