SPIGOT-3247: Comment support for YAML files

By: Wolf2323 <gabrielpatrikurban@gmail.com>
This commit is contained in:
Bukkit/Spigot 2021-12-21 08:35:19 +11:00
parent e61faa55b8
commit ed8a152b3a
11 changed files with 785 additions and 240 deletions

View File

@ -996,4 +996,66 @@ public interface ConfigurationSection {
* @throws IllegalArgumentException Thrown if path is null.
*/
public void addDefault(@NotNull String path, @Nullable Object value);
/**
* Gets the requested comment list by path.
* <p>
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @param path Path of the comments to get.
* @return A unmodifiable list of the requested comments, every entry
* represents one line.
*/
@NotNull
public List<String> getComments(@NotNull String path);
/**
* Gets the requested inline comment list by path.
* <p>
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @param path Path of the comments to get.
* @return A unmodifiable list of the requested comments, every entry
* represents one line.
*/
@NotNull
public List<String> getInlineComments(@NotNull String path);
/**
* Sets the comment list at the specified path.
* <p>
* If value is null, the comments will be removed. A null entry is an empty
* line and an empty String entry is an empty comment line. If the path does
* not exist, no comments will be set. Any existing comments will be
* replaced, regardless of what the new comments are.
* <p>
* Some implementations may have limitations on what persists. See their
* individual javadocs for details.
*
* @param path Path of the comments to set.
* @param comments New comments to set at the path, every entry represents
* one line.
*/
public void setComments(@NotNull String path, @Nullable List<String> comments);
/**
* Sets the inline comment list at the specified path.
* <p>
* If value is null, the comments will be removed. A null entry is an empty
* line and an empty String entry is an empty comment line. If the path does
* not exist, no comment will be set. Any existing comments will be
* replaced, regardless of what the new comments are.
* <p>
* Some implementations may have limitations on what persists. See their
* individual javadocs for details.
*
* @param path Path of the comments to set.
* @param comments New comments to set at the path, every entry represents
* one line.
*/
public void setInlineComments(@NotNull String path, @Nullable List<String> comments);
}

View File

@ -2,6 +2,7 @@ package org.bukkit.configuration;
import static org.bukkit.util.NumberConversions.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -22,7 +23,7 @@ import org.jetbrains.annotations.Nullable;
* A type of {@link ConfigurationSection} that is stored in memory.
*/
public class MemorySection implements ConfigurationSection {
protected final Map<String, Object> map = new LinkedHashMap<String, Object>();
protected final Map<String, SectionPathData> map = new LinkedHashMap<String, SectionPathData>();
private final Configuration root;
private final ConfigurationSection parent;
private final String path;
@ -217,7 +218,12 @@ public class MemorySection implements ConfigurationSection {
if (value == null) {
map.remove(key);
} else {
map.put(key, value);
SectionPathData entry = map.get(key);
if (entry == null) {
map.put(key, new SectionPathData(value));
} else {
entry.setData(value);
}
}
} else {
section.set(key, value);
@ -263,8 +269,8 @@ public class MemorySection implements ConfigurationSection {
String key = path.substring(i2);
if (section == this) {
Object result = map.get(key);
return (result == null) ? def : result;
SectionPathData result = map.get(key);
return (result == null) ? def : result.getData();
}
return section.get(key, def);
}
@ -296,7 +302,7 @@ public class MemorySection implements ConfigurationSection {
String key = path.substring(i2);
if (section == this) {
ConfigurationSection result = new MemorySection(this, key);
map.put(key, result);
map.put(key, new SectionPathData(result));
return result;
}
return section.createSection(key);
@ -860,11 +866,11 @@ public class MemorySection implements ConfigurationSection {
if (section instanceof MemorySection) {
MemorySection sec = (MemorySection) section;
for (Map.Entry<String, Object> entry : sec.map.entrySet()) {
for (Map.Entry<String, SectionPathData> entry : sec.map.entrySet()) {
output.add(createPath(section, entry.getKey(), this));
if ((deep) && (entry.getValue() instanceof ConfigurationSection)) {
ConfigurationSection subsection = (ConfigurationSection) entry.getValue();
if ((deep) && (entry.getValue().getData() instanceof ConfigurationSection)) {
ConfigurationSection subsection = (ConfigurationSection) entry.getValue().getData();
mapChildrenKeys(output, subsection, deep);
}
}
@ -881,17 +887,17 @@ public class MemorySection implements ConfigurationSection {
if (section instanceof MemorySection) {
MemorySection sec = (MemorySection) section;
for (Map.Entry<String, Object> entry : sec.map.entrySet()) {
for (Map.Entry<String, SectionPathData> entry : sec.map.entrySet()) {
// Because of the copyDefaults call potentially copying out of order, we must remove and then add in our saved order
// This means that default values we haven't set end up getting placed first
// See SPIGOT-4558 for an example using spigot.yml - watch subsections move around to default order
String childPath = createPath(section, entry.getKey(), this);
output.remove(childPath);
output.put(childPath, entry.getValue());
output.put(childPath, entry.getValue().getData());
if (entry.getValue() instanceof ConfigurationSection) {
if (entry.getValue().getData() instanceof ConfigurationSection) {
if (deep) {
mapChildrenValues(output, (ConfigurationSection) entry.getValue(), deep);
mapChildrenValues(output, (ConfigurationSection) entry.getValue().getData(), deep);
}
}
}
@ -942,14 +948,11 @@ public class MemorySection implements ConfigurationSection {
char separator = root.options().pathSeparator();
StringBuilder builder = new StringBuilder();
if (section != null) {
for (ConfigurationSection parent = section; (parent != null) && (parent != relativeTo); parent = parent.getParent()) {
if (builder.length() > 0) {
builder.insert(0, separator);
}
builder.insert(0, parent.getName());
for (ConfigurationSection parent = section; (parent != null) && (parent != relativeTo); parent = parent.getParent()) {
if (builder.length() > 0) {
builder.insert(0, separator);
}
builder.insert(0, parent.getName());
}
if ((key != null) && (key.length() > 0)) {
@ -963,6 +966,69 @@ public class MemorySection implements ConfigurationSection {
return builder.toString();
}
@Override
@NotNull
public List<String> getComments(@NotNull final String path) {
final SectionPathData pathData = getSectionPathData(path);
return pathData == null ? Collections.emptyList() : pathData.getComments();
}
@Override
@NotNull
public List<String> getInlineComments(@NotNull final String path) {
final SectionPathData pathData = getSectionPathData(path);
return pathData == null ? Collections.emptyList() : pathData.getInlineComments();
}
@Override
public void setComments(@NotNull final String path, @Nullable final List<String> comments) {
final SectionPathData pathData = getSectionPathData(path);
if (pathData != null) {
pathData.setComments(comments);
}
}
@Override
public void setInlineComments(@NotNull final String path, @Nullable final List<String> comments) {
final SectionPathData pathData = getSectionPathData(path);
if (pathData != null) {
pathData.setInlineComments(comments);
}
}
@Nullable
private SectionPathData getSectionPathData(@NotNull String path) {
Validate.notNull(path, "Path cannot be null");
Configuration root = getRoot();
if (root == null) {
throw new IllegalStateException("Cannot access section without a root");
}
final char separator = root.options().pathSeparator();
// i1 is the leading (higher) index
// i2 is the trailing (lower) index
int i1 = -1, i2;
ConfigurationSection section = this;
while ((i1 = path.indexOf(separator, i2 = i1 + 1)) != -1) {
section = section.getConfigurationSection(path.substring(i2, i1));
if (section == null) {
return null;
}
}
String key = path.substring(i2);
if (section == this) {
SectionPathData entry = map.get(key);
if (entry != null) {
return entry;
}
} else if (section instanceof MemorySection) {
return ((MemorySection) section).getSectionPathData(key);
}
return null;
}
@Override
public String toString() {
Configuration root = getRoot();

View File

@ -0,0 +1,81 @@
package org.bukkit.configuration;
import java.util.Collections;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
final class SectionPathData {
private Object data;
private List<String> comments;
private List<String> inlineComments;
public SectionPathData(@Nullable Object data) {
this.data = data;
comments = Collections.emptyList();
inlineComments = Collections.emptyList();
}
@Nullable
public Object getData() {
return data;
}
public void setData(@Nullable final Object data) {
this.data = data;
}
/**
* If no comments exist, an empty list will be returned. A null entry in the
* list represents an empty line and an empty String represents an empty
* comment line.
*
* @return A unmodifiable list of the requested comments, every entry
* represents one line.
*/
@NotNull
public List<String> getComments() {
return comments;
}
/**
* Represents the comments on a {@link ConfigurationSection} entry.
*
* A null entry in the List is an empty line and an empty String entry is an
* empty comment line. Any existing comments will be replaced, regardless of
* what the new comments are.
*
* @param comments New comments to set every entry represents one line.
*/
public void setComments(@Nullable final List<String> comments) {
this.comments = (comments == null) ? Collections.emptyList() : Collections.unmodifiableList(comments);
}
/**
* If no comments exist, an empty list will be returned. A null entry in the
* list represents an empty line and an empty String represents an empty
* comment line.
*
* @return A unmodifiable list of the requested comments, every entry
* represents one line.
*/
@NotNull
public List<String> getInlineComments() {
return inlineComments;
}
/**
* Represents the comments on a {@link ConfigurationSection} entry.
*
* A null entry in the List is an empty line and an empty String entry is an
* empty comment line. Any existing comments will be replaced, regardless of
* what the new comments are.
*
* @param inlineComments New comments to set every entry represents one
* line.
*/
public void setInlineComments(@Nullable final List<String> inlineComments) {
this.inlineComments = (inlineComments == null) ? Collections.emptyList() : Collections.unmodifiableList(inlineComments);
}
}

View File

@ -202,17 +202,17 @@ public abstract class FileConfiguration extends MemoryConfiguration {
public abstract void loadFromString(@NotNull String contents) throws InvalidConfigurationException;
/**
* Compiles the header for this {@link FileConfiguration} and returns the
* result.
* <p>
* This will use the header from {@link #options()} -&gt; {@link
* FileConfigurationOptions#header()}, respecting the rules of {@link
* FileConfigurationOptions#copyHeader()} if set.
* @return empty string
*
* @return Compiled header
* @deprecated This method only exists for backwards compatibility. It will
* do nothing and should not be used! Please use
* {@link FileConfigurationOptions#getHeader()} instead.
*/
@NotNull
protected abstract String buildHeader();
@Deprecated
protected String buildHeader() {
return "";
}
@NotNull
@Override

View File

@ -1,5 +1,8 @@
package org.bukkit.configuration.file;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.bukkit.configuration.MemoryConfiguration;
import org.bukkit.configuration.MemoryConfigurationOptions;
import org.jetbrains.annotations.NotNull;
@ -10,8 +13,9 @@ import org.jetbrains.annotations.Nullable;
* FileConfiguration}
*/
public class FileConfigurationOptions extends MemoryConfigurationOptions {
private String header = null;
private boolean copyHeader = true;
private List<String> header = Collections.emptyList();
private List<String> footer = Collections.emptyList();
private boolean parseComments = true;
protected FileConfigurationOptions(@NotNull MemoryConfiguration configuration) {
super(configuration);
@ -46,16 +50,32 @@ public class FileConfigurationOptions extends MemoryConfigurationOptions {
* automatically be applied, but you may include one if you wish for extra
* spacing.
* <p>
* Null is a valid value which will indicate that no header is to be
* applied. The default value is null.
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @return Header
* @return Unmodifiable header, every entry represents one line.
*/
@Nullable
public String header() {
@NotNull
public List<String> getHeader() {
return header;
}
/**
* @return The string header.
*
* @deprecated use getHeader() instead.
*/
@NotNull
@Deprecated
public String header() {
StringBuilder stringHeader = new StringBuilder();
for (String line : header) {
stringHeader.append(line == null ? "\n" : line + "\n");
}
return stringHeader.toString();
}
/**
* Sets the header that will be applied to the top of the saved output.
* <p>
@ -65,63 +85,119 @@ public class FileConfigurationOptions extends MemoryConfigurationOptions {
* automatically be applied, but you may include one if you wish for extra
* spacing.
* <p>
* Null is a valid value which will indicate that no header is to be
* applied.
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @param value New header
* @param value New header, every entry represents one line.
* @return This object, for chaining
*/
@NotNull
public FileConfigurationOptions header(@Nullable String value) {
this.header = value;
public FileConfigurationOptions setHeader(@Nullable List<String> value) {
this.header = (value == null) ? Collections.emptyList() : Collections.unmodifiableList(value);
return this;
}
/**
* Gets whether or not the header should be copied from a default source.
* <p>
* If this is true, if a default {@link FileConfiguration} is passed to
* {@link
* FileConfiguration#setDefaults(org.bukkit.configuration.Configuration)}
* then upon saving it will use the header from that config, instead of
* the one provided here.
* <p>
* If no default is set on the configuration, or the default is not of
* type FileConfiguration, or that config has no header ({@link #header()}
* returns null) then the header specified in this configuration will be
* used.
* <p>
* Defaults to true.
* @param value The string header.
* @return This object, for chaining.
*
* @return Whether or not to copy the header
* @deprecated use setHeader() instead
*/
public boolean copyHeader() {
return copyHeader;
@NotNull
@Deprecated
public FileConfigurationOptions header(@Nullable String value) {
this.header = (value == null) ? Collections.emptyList() : Collections.unmodifiableList(Arrays.asList(value.split("\\n")));
return this;
}
/**
* Sets whether or not the header should be copied from a default source.
* Gets the footer that will be applied to the bottom of the saved output.
* <p>
* If this is true, if a default {@link FileConfiguration} is passed to
* {@link
* FileConfiguration#setDefaults(org.bukkit.configuration.Configuration)}
* then upon saving it will use the header from that config, instead of
* the one provided here.
* This footer will be commented out and applied directly at the bottom of
* the generated output of the {@link FileConfiguration}. It is not required
* to include a newline at the beginning of the footer as it will
* automatically be applied, but you may include one if you wish for extra
* spacing.
* <p>
* If no default is set on the configuration, or the default is not of
* type FileConfiguration, or that config has no header ({@link #header()}
* returns null) then the header specified in this configuration will be
* used.
* <p>
* Defaults to true.
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @param value Whether or not to copy the header
* @return Unmodifiable footer, every entry represents one line.
*/
@NotNull
public List<String> getFooter() {
return footer;
}
/**
* Sets the footer that will be applied to the bottom of the saved output.
* <p>
* This footer will be commented out and applied directly at the bottom of
* the generated output of the {@link FileConfiguration}. It is not required
* to include a newline at the beginning of the footer as it will
* automatically be applied, but you may include one if you wish for extra
* spacing.
* <p>
* If no comments exist, an empty list will be returned. A null entry
* represents an empty line and an empty String represents an empty comment
* line.
*
* @param value New footer, every entry represents one line.
* @return This object, for chaining
*/
@NotNull
public FileConfigurationOptions copyHeader(boolean value) {
copyHeader = value;
public FileConfigurationOptions setFooter(@Nullable List<String> value) {
this.footer = (value == null) ? Collections.emptyList() : Collections.unmodifiableList(value);
return this;
}
/**
* Gets whether or not comments should be loaded and saved.
* <p>
* Defaults to true.
*
* @return Whether or not comments are parsed.
*/
public boolean parseComments() {
return parseComments;
}
/**
* Sets whether or not comments should be loaded and saved.
* <p>
* Defaults to true.
*
* @param value Whether or not comments are parsed.
* @return This object, for chaining
*/
@NotNull
public MemoryConfigurationOptions parseComments(boolean value) {
parseComments = value;
return this;
}
/**
* @return Whether or not comments are parsed.
*
* @deprecated Call {@link #parseComments()} instead.
*/
@Deprecated
public boolean copyHeader() {
return parseComments;
}
/**
* @param value Should comments be parsed.
* @return This object, for chaining
*
* @deprecated Call {@link #parseComments(boolean)} instead.
*/
@NotNull
@Deprecated
public FileConfigurationOptions copyHeader(boolean value) {
parseComments = value;
return this;
}
}

View File

@ -1,9 +1,14 @@
package org.bukkit.configuration.file;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.Reader;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import org.apache.commons.lang.Validate;
@ -11,148 +16,231 @@ import org.bukkit.Bukkit;
import org.bukkit.configuration.Configuration;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import org.jetbrains.annotations.NotNull;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.comments.CommentLine;
import org.yaml.snakeyaml.comments.CommentType;
import org.yaml.snakeyaml.error.YAMLException;
import org.yaml.snakeyaml.representer.Representer;
import org.yaml.snakeyaml.nodes.AnchorNode;
import org.yaml.snakeyaml.nodes.MappingNode;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.NodeTuple;
import org.yaml.snakeyaml.nodes.ScalarNode;
import org.yaml.snakeyaml.nodes.SequenceNode;
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.reader.UnicodeReader;
/**
* An implementation of {@link Configuration} which saves all files in Yaml.
* Note that this implementation is not synchronized.
*/
public class YamlConfiguration extends FileConfiguration {
protected static final String COMMENT_PREFIX = "# ";
protected static final String BLANK_CONFIG = "{}\n";
private final DumperOptions yamlOptions = new DumperOptions();
private final LoaderOptions loaderOptions = new LoaderOptions();
private final Representer yamlRepresenter = new YamlRepresenter();
private final Yaml yaml = new Yaml(new YamlConstructor(), yamlRepresenter, yamlOptions, loaderOptions);
private final DumperOptions yamlDumperOptions;
private final LoaderOptions yamlLoaderOptions;
private final YamlConstructor constructor;
private final YamlRepresenter representer;
private final Yaml yaml;
public YamlConfiguration() {
constructor = new YamlConstructor();
representer = new YamlRepresenter();
representer.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
yamlDumperOptions = new DumperOptions();
yamlDumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
yamlLoaderOptions = new LoaderOptions();
yamlLoaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE); // SPIGOT-5881: Not ideal, but was default pre SnakeYAML 1.26
yaml = new Yaml(constructor, representer, yamlDumperOptions, yamlLoaderOptions);
}
@NotNull
@Override
public String saveToString() {
yamlOptions.setIndent(options().indent());
yamlOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
yamlRepresenter.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
yamlDumperOptions.setIndent(options().indent());
yamlDumperOptions.setProcessComments(options().parseComments());
String header = buildHeader();
String dump = yaml.dump(getValues(false));
MappingNode node = toNodeTree(this);
if (dump.equals(BLANK_CONFIG)) {
dump = "";
node.setBlockComments(getCommentLines(saveHeader(options().getHeader()), CommentType.BLOCK));
node.setEndComments(getCommentLines(options().getFooter(), CommentType.BLOCK));
StringWriter writer = new StringWriter();
if (node.getEndComments().isEmpty() && node.getEndComments().isEmpty() && node.getValue().isEmpty()) {
writer.write("");
} else {
if (node.getValue().isEmpty()) {
node.setFlowStyle(DumperOptions.FlowStyle.FLOW);
}
yaml.serialize(node, writer);
}
return header + dump;
return writer.toString();
}
@Override
public void loadFromString(@NotNull String contents) throws InvalidConfigurationException {
Validate.notNull(contents, "Contents cannot be null");
Validate.notNull(contents, "String cannot be null");
yamlLoaderOptions.setProcessComments(options().parseComments());
Map<?, ?> input;
try {
loaderOptions.setMaxAliasesForCollections(Integer.MAX_VALUE); // SPIGOT-5881: Not ideal, but was default pre SnakeYAML 1.26
input = (Map<?, ?>) yaml.load(contents);
} catch (YAMLException e) {
MappingNode node;
try (Reader reader = new UnicodeReader(new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)))) {
node = (MappingNode) yaml.compose(reader);
} catch (YAMLException | IOException e) {
throw new InvalidConfigurationException(e);
} catch (ClassCastException e) {
throw new InvalidConfigurationException("Top level is not a Map.");
}
String header = parseHeader(contents);
if (header.length() > 0) {
options().header(header);
}
this.map.clear();
if (input != null) {
convertMapsToSections(input, this);
if (node != null) {
adjustNodeComments(node);
options().setHeader(loadHeader(getCommentLines(node.getBlockComments())));
options().setFooter(getCommentLines(node.getEndComments()));
fromNodeTree(node, this);
}
}
protected void convertMapsToSections(@NotNull Map<?, ?> input, @NotNull ConfigurationSection section) {
for (Map.Entry<?, ?> entry : input.entrySet()) {
String key = entry.getKey().toString();
Object value = entry.getValue();
/**
* This method splits the header on the last empty line, and sets the
* comments below this line as comments for the first key on the map object.
*
* @param node The root node of the yaml object
*/
private void adjustNodeComments(final MappingNode node) {
if (node.getBlockComments() == null && !node.getValue().isEmpty()) {
Node firstNode = node.getValue().get(0).getKeyNode();
List<CommentLine> lines = firstNode.getBlockComments();
if (lines != null) {
int index = -1;
for (int i = 0; i < lines.size(); i++) {
if (lines.get(i).getCommentType() == CommentType.BLANK_LINE) {
index = i;
}
}
if (index != -1) {
node.setBlockComments(lines.subList(0, index + 1));
firstNode.setBlockComments(lines.subList(index + 1, lines.size()));
}
}
}
}
if (value instanceof Map) {
convertMapsToSections((Map<?, ?>) value, section.createSection(key));
protected void fromNodeTree(@NotNull MappingNode input, @NotNull ConfigurationSection section) {
for (NodeTuple nodeTuple : input.getValue()) {
ScalarNode key = (ScalarNode) nodeTuple.getKeyNode();
String keyString = key.getValue();
Node value = nodeTuple.getValueNode();
while (value instanceof AnchorNode) {
value = ((AnchorNode) value).getRealNode();
}
if (value instanceof MappingNode && !hasSerializedTypeKey((MappingNode) value)) {
fromNodeTree((MappingNode) value, section.createSection(keyString));
} else {
section.set(key, value);
section.set(keyString, constructor.construct(value));
}
section.setComments(keyString, getCommentLines(key.getBlockComments()));
if (value instanceof MappingNode || value instanceof SequenceNode) {
section.setInlineComments(keyString, getCommentLines(key.getInLineComments()));
} else {
section.setInlineComments(keyString, getCommentLines(value.getInLineComments()));
}
}
}
@NotNull
protected String parseHeader(@NotNull String input) {
String[] lines = input.split("\r?\n", -1);
StringBuilder result = new StringBuilder();
boolean readingHeader = true;
boolean foundHeader = false;
for (int i = 0; (i < lines.length) && (readingHeader); i++) {
String line = lines[i];
if (line.startsWith(COMMENT_PREFIX)) {
if (i > 0) {
result.append("\n");
}
if (line.length() > COMMENT_PREFIX.length()) {
result.append(line.substring(COMMENT_PREFIX.length()));
}
foundHeader = true;
} else if ((foundHeader) && (line.length() == 0)) {
result.append("\n");
} else if (foundHeader) {
readingHeader = false;
private boolean hasSerializedTypeKey(MappingNode node) {
for (NodeTuple nodeTuple : node.getValue()) {
String key = ((ScalarNode) nodeTuple.getKeyNode()).getValue();
if (key.equals(ConfigurationSerialization.SERIALIZED_TYPE_KEY)) {
return true;
}
}
return result.toString();
return false;
}
@NotNull
@Override
protected String buildHeader() {
String header = options().header();
private MappingNode toNodeTree(@NotNull ConfigurationSection section) {
List<NodeTuple> nodeTuples = new ArrayList<>();
for (Map.Entry<String, Object> entry : section.getValues(false).entrySet()) {
ScalarNode key = (ScalarNode) representer.represent(entry.getKey());
Node value;
if (entry.getValue() instanceof ConfigurationSection) {
value = toNodeTree((ConfigurationSection) entry.getValue());
} else {
value = representer.represent(entry.getValue());
}
key.setBlockComments(getCommentLines(section.getComments(entry.getKey()), CommentType.BLOCK));
if (value instanceof MappingNode || value instanceof SequenceNode) {
key.setInLineComments(getCommentLines(section.getInlineComments(entry.getKey()), CommentType.IN_LINE));
} else {
value.setInLineComments(getCommentLines(section.getInlineComments(entry.getKey()), CommentType.IN_LINE));
}
if (options().copyHeader()) {
Configuration def = getDefaults();
nodeTuples.add(new NodeTuple(key, value));
}
if ((def != null) && (def instanceof FileConfiguration)) {
FileConfiguration filedefaults = (FileConfiguration) def;
String defaultsHeader = filedefaults.buildHeader();
return new MappingNode(Tag.MAP, nodeTuples, DumperOptions.FlowStyle.BLOCK);
}
if ((defaultsHeader != null) && (defaultsHeader.length() > 0)) {
return defaultsHeader;
private List<String> getCommentLines(List<CommentLine> comments) {
List<String> lines = new ArrayList<>();
if (comments != null) {
for (CommentLine comment : comments) {
if (comment.getCommentType() == CommentType.BLANK_LINE) {
lines.add(null);
} else {
lines.add(comment.getValue());
}
}
}
return lines;
}
if (header == null) {
return "";
}
StringBuilder builder = new StringBuilder();
String[] lines = header.split("\r?\n", -1);
boolean startedHeader = false;
for (int i = lines.length - 1; i >= 0; i--) {
builder.insert(0, "\n");
if ((startedHeader) || (lines[i].length() != 0)) {
builder.insert(0, lines[i]);
builder.insert(0, COMMENT_PREFIX);
startedHeader = true;
private List<CommentLine> getCommentLines(List<String> comments, CommentType commentType) {
List<CommentLine> lines = new ArrayList<CommentLine>();
for (String comment : comments) {
if (comment == null) {
lines.add(new CommentLine(null, null, "", CommentType.BLANK_LINE));
} else {
lines.add(new CommentLine(null, null, comment, commentType));
}
}
return lines;
}
return builder.toString();
/**
* Removes the empty line at the end of the header that separates the header
* from further comments.
*
* @param header The list of heading comments
* @return The modified list
*/
private List<String> loadHeader(List<String> header) {
ArrayList<String> list = new ArrayList<String>(header);
if (list.size() != 0) {
list.remove(list.size() - 1);
}
return list;
}
/**
* Adds the empty line at the end of the header that separates the header
* from further comments.
*
* @param header The list of heading comments
* @return The modified list
*/
private List<String> saveHeader(List<String> header) {
ArrayList<String> list = new ArrayList<String>(header);
if (list.size() != 0) {
list.add(null);
}
return list;
}
@NotNull

View File

@ -1,5 +1,6 @@
package org.bukkit.configuration.file;
import java.util.List;
import org.apache.commons.lang.Validate;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -37,6 +38,14 @@ public class YamlConfigurationOptions extends FileConfigurationOptions {
@NotNull
@Override
public YamlConfigurationOptions setHeader(@Nullable List<String> value) {
super.setHeader(value);
return this;
}
@NotNull
@Override
@Deprecated
public YamlConfigurationOptions header(@Nullable String value) {
super.header(value);
return this;
@ -44,6 +53,21 @@ public class YamlConfigurationOptions extends FileConfigurationOptions {
@NotNull
@Override
public YamlConfigurationOptions setFooter(@Nullable List<String> value) {
super.setFooter(value);
return this;
}
@NotNull
@Override
public YamlConfigurationOptions parseComments(boolean value) {
super.parseComments(value);
return this;
}
@NotNull
@Override
@Deprecated
public YamlConfigurationOptions copyHeader(boolean value) {
super.copyHeader(value);
return this;

View File

@ -16,6 +16,11 @@ public class YamlConstructor extends SafeConstructor {
this.yamlConstructors.put(Tag.MAP, new ConstructCustomObject());
}
@NotNull
public Object construct(@NotNull Node node) {
return constructObject(node);
}
private class ConstructCustomObject extends ConstructYamlMap {
@Nullable

View File

@ -2,7 +2,6 @@ package org.bukkit.configuration.file;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import org.jetbrains.annotations.NotNull;
@ -12,22 +11,12 @@ import org.yaml.snakeyaml.representer.Representer;
public class YamlRepresenter extends Representer {
public YamlRepresenter() {
this.multiRepresenters.put(ConfigurationSection.class, new RepresentConfigurationSection());
this.multiRepresenters.put(ConfigurationSerializable.class, new RepresentConfigurationSerializable());
// SPIGOT-6234: We could just switch YamlConstructor to extend Constructor rather than SafeConstructor, however there is a very small risk of issues with plugins treating config as untrusted input
// So instead we will just allow future plugins to have their enums extend ConfigurationSerializable
this.multiRepresenters.remove(Enum.class);
}
private class RepresentConfigurationSection extends RepresentMap {
@NotNull
@Override
public Node representData(@NotNull Object data) {
return super.representData(((ConfigurationSection) data).getValues(false));
}
}
private class RepresentConfigurationSerializable extends RepresentMap {
@NotNull

View File

@ -4,6 +4,8 @@ import static org.junit.Assert.*;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.bukkit.configuration.MemoryConfigurationTest;
import org.junit.Rule;
@ -19,9 +21,17 @@ public abstract class FileConfigurationTest extends MemoryConfigurationTest {
public abstract String getTestValuesString();
public abstract String getTestHeaderInput();
public abstract List<String> getTestCommentInput();
public abstract String getTestHeaderResult();
public abstract String getTestCommentResult();
public abstract List<String> getTestHeaderComments();
public abstract String getTestHeaderCommentsResult();
public abstract List<String> getTestKeyComments();
public abstract String getTestHeaderKeyCommentResult();
@Test
public void testSave_File() throws Exception {
@ -127,69 +137,6 @@ public abstract class FileConfigurationTest extends MemoryConfigurationTest {
assertEquals(saved, config.saveToString());
}
@Test
public void testSaveToStringWithHeader() {
FileConfiguration config = getConfig();
config.options().header(getTestHeaderInput());
for (Map.Entry<String, Object> entry : getTestValues().entrySet()) {
config.set(entry.getKey(), entry.getValue());
}
String result = config.saveToString();
String expected = getTestHeaderResult() + "\n" + getTestValuesString();
assertEquals(expected, result);
}
@Test
public void testParseHeader() throws Exception {
FileConfiguration config = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String header = getTestHeaderResult();
String expected = getTestHeaderInput();
config.loadFromString(header + "\n" + saved);
assertEquals(expected, config.options().header());
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(header + "\n" + saved, config.saveToString());
}
@Test
public void testCopyHeader() throws Exception {
FileConfiguration config = getConfig();
FileConfiguration defaults = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String header = getTestHeaderResult();
String expected = getTestHeaderInput();
defaults.loadFromString(header);
config.loadFromString(saved);
config.setDefaults(defaults);
assertNull(config.options().header());
assertEquals(expected, defaults.options().header());
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(header + "\n" + saved, config.saveToString());
config = getConfig();
config.loadFromString(getTestHeaderResult() + saved);
assertEquals(getTestHeaderResult() + saved, config.saveToString());
}
@Test
public void testReloadEmptyConfig() throws Exception {
FileConfiguration config = getConfig();
@ -271,4 +218,178 @@ public abstract class FileConfigurationTest extends MemoryConfigurationTest {
assertFalse(config.contains("test"));
assertFalse(config.getBoolean("test"));
}
@Test
public void testSaveWithComments() {
FileConfiguration config = getConfig();
config.options().parseComments(true);
for (Map.Entry<String, Object> entry : getTestValues().entrySet()) {
config.set(entry.getKey(), entry.getValue());
}
String key = getTestValues().keySet().iterator().next();
config.setComments(key, getTestCommentInput());
String result = config.saveToString();
String expected = getTestCommentResult() + "\n" + getTestValuesString();
assertEquals(expected, result);
}
@Test
public void testSaveWithoutComments() {
FileConfiguration config = getConfig();
config.options().parseComments(false);
for (Map.Entry<String, Object> entry : getTestValues().entrySet()) {
config.set(entry.getKey(), entry.getValue());
}
String key = getTestValues().keySet().iterator().next();
config.setComments(key, getTestCommentInput());
String result = config.saveToString();
String expected = getTestValuesString();
assertEquals(expected, result);
}
@Test
public void testLoadWithComments() throws Exception {
FileConfiguration config = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String comments = getTestCommentResult();
config.options().parseComments(true);
config.loadFromString(comments + "\n" + saved);
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(comments + "\n" + saved, config.saveToString());
}
@Test
public void testLoadWithoutComments() throws Exception {
FileConfiguration config = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String comments = getTestCommentResult();
config.options().parseComments(false);
config.loadFromString(comments + "\n" + saved);
config.options().parseComments(true);
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(saved, config.saveToString());
}
@Test
public void testSaveWithCommentsHeader() {
FileConfiguration config = getConfig();
config.options().parseComments(true);
for (Map.Entry<String, Object> entry : getTestValues().entrySet()) {
config.set(entry.getKey(), entry.getValue());
}
String key = getTestValues().keySet().iterator().next();
config.options().setHeader(getTestHeaderComments());
config.setComments(key, getTestKeyComments());
String result = config.saveToString();
String expected = getTestHeaderKeyCommentResult() + getTestValuesString();
assertEquals(expected, result);
}
@Test
public void testLoadWithCommentsHeader() throws Exception {
FileConfiguration config = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String comments = getTestHeaderKeyCommentResult();
config.options().parseComments(true);
config.loadFromString(comments + saved);
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
String key = getTestValues().keySet().iterator().next();
assertEquals(getTestHeaderComments(), config.options().getHeader());
assertEquals(getTestKeyComments(), config.getComments(key));
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(comments + saved, config.saveToString());
}
@Test
public void testSaveWithCommentsFooter() {
FileConfiguration config = getConfig();
config.options().parseComments(true);
for (Map.Entry<String, Object> entry : getTestValues().entrySet()) {
config.set(entry.getKey(), entry.getValue());
}
config.options().setFooter(getTestHeaderComments());
String result = config.saveToString();
String expected = getTestValuesString() + getTestHeaderCommentsResult();
assertEquals(expected, result);
}
@Test
public void testLoadWithCommentsFooter() throws Exception {
FileConfiguration config = getConfig();
Map<String, Object> values = getTestValues();
String saved = getTestValuesString();
String comments = getTestHeaderCommentsResult();
config.options().parseComments(true);
config.loadFromString(saved + comments);
for (Map.Entry<String, Object> entry : values.entrySet()) {
assertEquals(entry.getValue(), config.get(entry.getKey()));
}
assertEquals(getTestHeaderComments(), config.options().getFooter());
assertEquals(values.keySet(), config.getKeys(true));
assertEquals(saved + comments, config.saveToString());
}
@Test
public void testLoadWithCommentsInline() throws Exception {
FileConfiguration config = getConfig();
config.options().parseComments(true);
config.loadFromString("key1: value1\nkey2: value2 # Test inline\nkey3: value3");
assertEquals(Arrays.asList(" Test inline"), config.getInlineComments("key2"));
}
@Test
public void testSaveWithCommentsInline() {
FileConfiguration config = getConfig();
config.options().parseComments(true);
config.set("key1", "value1");
config.set("key2", "value2");
config.set("key3", "value3");
config.setInlineComments("key2", Arrays.asList(" Test inline"));
String result = config.saveToString();
String expected = "key1: value1\nkey2: value2 # Test inline\nkey3: value3\n";
assertEquals(expected, result);
}
}

View File

@ -1,6 +1,9 @@
package org.bukkit.configuration.file;
import static org.junit.Assert.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.Test;
public class YamlConfigurationTest extends FileConfigurationTest {
@ -11,13 +14,43 @@ public class YamlConfigurationTest extends FileConfigurationTest {
}
@Override
public String getTestHeaderInput() {
return "This is a sample\nheader.\n\nNewline above should be commented.\n\n";
public List<String> getTestCommentInput() {
List<String> comments = new ArrayList<>();
comments.add(" This is a sample");
comments.add(" header.");
comments.add(" Newline above should be commented.");
comments.add("");
comments.add("");
comments.add(null);
comments.add(null);
comments.add(" Comment of first Key");
comments.add(" and a second line.");
return comments;
}
@Override
public String getTestHeaderResult() {
return "# This is a sample\n# header.\n# \n# Newline above should be commented.\n\n";
public String getTestCommentResult() {
return "# This is a sample\n# header.\n# Newline above should be commented.\n#\n#\n\n\n# Comment of first Key\n# and a second line.";
}
@Override
public List<String> getTestHeaderComments() {
return Arrays.asList(" Header", " Second Line");
}
@Override
public String getTestHeaderCommentsResult() {
return "# Header\n# Second Line\n";
}
@Override
public List<String> getTestKeyComments() {
return Arrays.asList(" First key Comment", " Second Line");
}
@Override
public String getTestHeaderKeyCommentResult() {
return "# Header\n# Second Line\n\n# First key Comment\n# Second Line\n";
}
@Override