Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

YAML comments support #410

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.representer.Representer;

import java.io.BufferedReader;
import java.io.Writer;
Expand Down Expand Up @@ -72,13 +70,16 @@ public static Builder builder() {
* <dl>
* <dt>&lt;prefix&gt;.yaml.node-style</dt>
* <dd>Equivalent to {@link #nodeStyle(NodeStyle)}</dd>
* <dt>&lt;prefix&gt;.yaml.comments-enabled</dt>
* <dd>Equivalent to {@link #commentsEnabled(boolean)}</dd>
* </dl>
*
* @since 4.0.0
*/
public static final class Builder extends AbstractConfigurationLoader.Builder<Builder, YamlConfigurationLoader> {
private final DumperOptions options = new DumperOptions();
private @Nullable NodeStyle style;
private boolean enableComments;

Builder() {
this.indent(4);
Expand All @@ -92,6 +93,7 @@ protected void populate(final LoaderOptionSource options) {
if (declared != null) {
this.style = declared;
}
this.enableComments = options.getBoolean(true, "yaml", "comments-enabled");
}

/**
Expand Down Expand Up @@ -158,34 +160,69 @@ public Builder nodeStyle(final @Nullable NodeStyle style) {
return this.style;
}

/**
* Set whether comment handling is enabled on this loader.
*
* <p>When comment handling is enabled, comments will be read from files
* and written back to files where possible.</p>
*
* <p>The default value is {@code true}</p>
*
* @param enableComments whether comment handling should be enabled
* @return this builder (for chaining)
* @since 4.2.0
*/
public Builder commentsEnabled(final boolean enableComments) {
this.enableComments = enableComments;
return this;
}

/**
* Get whether comment handling is enabled.
*
* @return whether comment handling is enabled
* @see #commentsEnabled(boolean) for details on comment handling
* @since 4.2.0
*/
public boolean commentsEnabled() {
return this.enableComments;
}

@Override
public YamlConfigurationLoader build() {
return new YamlConfigurationLoader(this);
}
}

private final ThreadLocal<YamlConstructor> constructor;
private final ThreadLocal<Yaml> yaml;

private YamlConfigurationLoader(final Builder builder) {
super(builder, new CommentHandler[] {CommentHandlers.HASH});
final LoaderOptions loaderOpts = new LoaderOptions()
.setAcceptTabs(true)
.setProcessComments(false);
.setProcessComments(builder.commentsEnabled());
loaderOpts.setCodePointLimit(Integer.MAX_VALUE);

final DumperOptions opts = builder.options;
opts.setDefaultFlowStyle(NodeStyle.asSnakeYaml(builder.style));
this.yaml = ThreadLocal.withInitial(() -> new Yaml(new Constructor(loaderOpts), new Representer(opts), opts, loaderOpts));
opts.setProcessComments(builder.commentsEnabled());
// the constructor needs ConfigurationOptions, which is only available when called (loadInternal)
this.constructor = ThreadLocal.withInitial(() -> new YamlConstructor(loaderOpts));
this.yaml = ThreadLocal.withInitial(() -> new Yaml(this.constructor.get(), new YamlRepresenter(opts), opts, loaderOpts));
}

@Override
protected void loadInternal(final CommentedConfigurationNode node, final BufferedReader reader) {
node.raw(this.yaml.get().load(reader));
// the constructor needs ConfigurationOptions for the to be created nodes
// and since it's a thread-local, this won't cause any issues
this.constructor.get().options = node.options();
node.from(this.yaml.get().load(reader));
Comment on lines 268 to +272
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a regression here: load returns null when the file is empty, which ConfigurationNode#from can't receive.

}

@Override
protected void saveInternal(final ConfigurationNode node, final Writer writer) {
this.yaml.get().dump(node.raw(), writer);
this.yaml.get().dump(node, writer);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Configurate
* Copyright (C) zml and Configurate contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.spongepowered.configurate.yaml;

import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.spongepowered.configurate.CommentedConfigurationNode;
import org.spongepowered.configurate.ConfigurationNode;
import org.spongepowered.configurate.ConfigurationOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.comments.CommentLine;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.nodes.MappingNode;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.NodeId;
import org.yaml.snakeyaml.nodes.ScalarNode;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

class YamlConstructor extends Constructor {

private static final Pattern LINE_BREAK_PATTERN = Pattern.compile("\\R");

@Nullable ConfigurationOptions options;

YamlConstructor(final LoaderOptions loadingConfig) {
super(loadingConfig);
}

@Override
@EnsuresNonNull("options")
public Object getSingleData(final Class<?> type) {
if (this.options == null) {
throw new IllegalStateException("options must be set before calling load!");
}
return super.getSingleData(type);
}

@Override
protected Object constructObjectNoCheck(final Node yamlNode) {
//noinspection DataFlowIssue guarenteed NonNull by getSingleData, which load(Reader) uses
final CommentedConfigurationNode node = CommentedConfigurationNode.root(this.options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really not a fan of the fact that by creating a new root node here, we effectively construct two nodes per node when loading in a file

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're then mainly talking about the mapping part right? When looking at the differences between a root node and a .node node it looks like only the path and parent differs.
We could add a method that works essentially like attachIfNecessary / parentEnsureAttached, but allows you to set the parent & path as well.


if (yamlNode.getNodeId() == NodeId.mapping) {
// make sure to mark it as a map type, even if the map itself is empty
node.raw(Collections.emptyMap());

((MappingNode) yamlNode).getValue().forEach(tuple -> {
// I don't think it's possible to have a non-scalar node as key
zml2008 marked this conversation as resolved.
Show resolved Hide resolved
final ScalarNode keyNode = (ScalarNode) tuple.getKeyNode();
final Node valueNode = tuple.getValueNode();

// comments are on the key, not the value
node.node(keyNode.getValue())
.from((ConfigurationNode) constructObject(valueNode))
.comment(commentFor(keyNode.getBlockComments()));
});

return node.comment(commentFor(yamlNode.getBlockComments()));
}

final Object raw = super.constructObjectNoCheck(yamlNode);
if (raw instanceof Collection<?>) {
// make sure to mark it as a list type, even if the collection itself is empty
node.raw(Collections.emptyList());

((Collection<?>) raw).forEach(value -> {
node.appendListNode().from((ConfigurationNode) value);
});
} else {
node.raw(raw);
}

return node.comment(commentFor(yamlNode.getBlockComments()));
}

private static @Nullable String commentFor(final @Nullable List<CommentLine> commentLines) {
if (commentLines == null || commentLines.isEmpty()) {
return null;
}
return commentLines.stream()
.map(input -> {
final String lineStripped = removeLineBreaksForLine(input.getValue());
if (!lineStripped.isEmpty() && lineStripped.charAt(0) == ' ') {
return lineStripped.substring(1);
} else {
return lineStripped;
}
})
.collect(Collectors.joining("\n"));
}

private static String removeLineBreaksForLine(final String line) {
return LINE_BREAK_PATTERN.matcher(line).replaceAll("");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Configurate
* Copyright (C) zml and Configurate contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.spongepowered.configurate.yaml;

import static org.spongepowered.configurate.loader.AbstractConfigurationLoader.CONFIGURATE_LINE_PATTERN;

import org.checkerframework.checker.nullness.qual.Nullable;
import org.spongepowered.configurate.CommentedConfigurationNodeIntermediary;
import org.spongepowered.configurate.ConfigurationNode;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.DumperOptions.FlowStyle;
import org.yaml.snakeyaml.comments.CommentLine;
import org.yaml.snakeyaml.comments.CommentType;
import org.yaml.snakeyaml.nodes.MappingNode;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.NodeTuple;
import org.yaml.snakeyaml.nodes.SequenceNode;
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.representer.Represent;
import org.yaml.snakeyaml.representer.Representer;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

final class YamlRepresenter extends Representer {

YamlRepresenter(final DumperOptions options) {
super(options);
multiRepresenters.put(ConfigurationNode.class, new ConfigurationNodeRepresent());
}

private final class ConfigurationNodeRepresent implements Represent {
@Override
public Node representData(final Object nodeObject) {
final ConfigurationNode node = (ConfigurationNode) nodeObject;

final Node yamlNode;
if (node.isMap()) {
final List<NodeTuple> children = new ArrayList<>();
for (Map.Entry<Object, ? extends ConfigurationNode> ent : node.childrenMap().entrySet()) {
// SnakeYAML supports both key and value comments. Add the comments on the key
final Node value = represent(ent.getValue());
final Node key = represent(String.valueOf(ent.getKey()));
key.setBlockComments(value.getBlockComments());
value.setBlockComments(Collections.emptyList());

children.add(new NodeTuple(key, value));
}
yamlNode = new MappingNode(Tag.MAP, children, FlowStyle.AUTO);
} else if (node.isList()) {
final List<Node> children = new ArrayList<>();
for (ConfigurationNode ent : node.childrenList()) {
children.add(represent(ent));
}
yamlNode = new SequenceNode(Tag.SEQ, children, FlowStyle.AUTO);
} else {
yamlNode = represent(node.rawScalar());
}

if (node instanceof CommentedConfigurationNodeIntermediary<?>) {
final @Nullable String nodeComment = ((CommentedConfigurationNodeIntermediary<?>) node).comment();
if (nodeComment != null) {
yamlNode.setBlockComments(
Arrays.stream(CONFIGURATE_LINE_PATTERN.split(nodeComment))
.map(this::commentLineFor)
.collect(Collectors.toList())
);
}
}

return yamlNode;
}

private CommentLine commentLineFor(final String comment) {
// prepend a space before the comment:
// before: #hello
// after: # hello
return new CommentLine(null, null, " " + comment, CommentType.BLOCK);
}
}

}
Loading