From ed8a152b3ae55c5f2b783729ed284d14010df169 Mon Sep 17 00:00:00 2001 From: Bukkit/Spigot Date: Tue, 21 Dec 2021 08:35:19 +1100 Subject: [PATCH] SPIGOT-3247: Comment support for YAML files By: Wolf2323 --- .../configuration/ConfigurationSection.java | 62 ++++ .../bukkit/configuration/MemorySection.java | 104 +++++-- .../bukkit/configuration/SectionPathData.java | 81 ++++++ .../configuration/file/FileConfiguration.java | 16 +- .../file/FileConfigurationOptions.java | 164 ++++++++--- .../configuration/file/YamlConfiguration.java | 266 ++++++++++++------ .../file/YamlConfigurationOptions.java | 24 ++ .../configuration/file/YamlConstructor.java | 5 + .../configuration/file/YamlRepresenter.java | 11 - .../file/FileConfigurationTest.java | 251 ++++++++++++----- .../file/YamlConfigurationTest.java | 41 ++- 11 files changed, 785 insertions(+), 240 deletions(-) create mode 100644 paper-api/src/main/java/org/bukkit/configuration/SectionPathData.java diff --git a/paper-api/src/main/java/org/bukkit/configuration/ConfigurationSection.java b/paper-api/src/main/java/org/bukkit/configuration/ConfigurationSection.java index 715ef162da..b6b00af08f 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/ConfigurationSection.java +++ b/paper-api/src/main/java/org/bukkit/configuration/ConfigurationSection.java @@ -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. + *

+ * 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 getComments(@NotNull String path); + + /** + * Gets the requested inline comment list by path. + *

+ * 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 getInlineComments(@NotNull String path); + + /** + * Sets the comment list at the specified path. + *

+ * 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. + *

+ * 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 comments); + + /** + * Sets the inline comment list at the specified path. + *

+ * 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. + *

+ * 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 comments); } diff --git a/paper-api/src/main/java/org/bukkit/configuration/MemorySection.java b/paper-api/src/main/java/org/bukkit/configuration/MemorySection.java index a6ac5d4534..28b171984e 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/MemorySection.java +++ b/paper-api/src/main/java/org/bukkit/configuration/MemorySection.java @@ -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 map = new LinkedHashMap(); + protected final Map map = new LinkedHashMap(); 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 entry : sec.map.entrySet()) { + for (Map.Entry 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 entry : sec.map.entrySet()) { + for (Map.Entry 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 getComments(@NotNull final String path) { + final SectionPathData pathData = getSectionPathData(path); + return pathData == null ? Collections.emptyList() : pathData.getComments(); + } + + @Override + @NotNull + public List 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 comments) { + final SectionPathData pathData = getSectionPathData(path); + if (pathData != null) { + pathData.setComments(comments); + } + } + + @Override + public void setInlineComments(@NotNull final String path, @Nullable final List 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(); diff --git a/paper-api/src/main/java/org/bukkit/configuration/SectionPathData.java b/paper-api/src/main/java/org/bukkit/configuration/SectionPathData.java new file mode 100644 index 0000000000..54647c817b --- /dev/null +++ b/paper-api/src/main/java/org/bukkit/configuration/SectionPathData.java @@ -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 comments; + private List 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 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 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 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 inlineComments) { + this.inlineComments = (inlineComments == null) ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + } +} diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/FileConfiguration.java b/paper-api/src/main/java/org/bukkit/configuration/file/FileConfiguration.java index 581889ffa2..50c58f1585 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/FileConfiguration.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/FileConfiguration.java @@ -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. - *

- * This will use the header from {@link #options()} -> {@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 diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/FileConfigurationOptions.java b/paper-api/src/main/java/org/bukkit/configuration/file/FileConfigurationOptions.java index eaa0afacec..c71f8a7b96 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/FileConfigurationOptions.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/FileConfigurationOptions.java @@ -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 header = Collections.emptyList(); + private List 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. *

- * 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 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. *

@@ -65,63 +85,119 @@ public class FileConfigurationOptions extends MemoryConfigurationOptions { * automatically be applied, but you may include one if you wish for extra * spacing. *

- * 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 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. - *

- * 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. - *

- * 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. - *

- * 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. *

- * 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. *

- * 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. - *

- * 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 getFooter() { + return footer; + } + + /** + * Sets the footer that will be applied to the bottom of the saved output. + *

+ * 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. + *

+ * 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 value) { + this.footer = (value == null) ? Collections.emptyList() : Collections.unmodifiableList(value); + return this; + } + /** + * Gets whether or not comments should be loaded and saved. + *

+ * 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. + *

+ * 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; } } diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfiguration.java b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfiguration.java index 6476f3794b..80048d423d 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfiguration.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfiguration.java @@ -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 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 nodeTuples = new ArrayList<>(); + for (Map.Entry 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 getCommentLines(List comments) { + List 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 getCommentLines(List comments, CommentType commentType) { + List lines = new ArrayList(); + 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 loadHeader(List header) { + ArrayList list = new ArrayList(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 saveHeader(List header) { + ArrayList list = new ArrayList(header); + if (list.size() != 0) { + list.add(null); + } + return list; } @NotNull diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfigurationOptions.java b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfigurationOptions.java index b2bf9785af..d687389990 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfigurationOptions.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConfigurationOptions.java @@ -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 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 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; diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConstructor.java b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConstructor.java index c8466a29a2..ca167df516 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/YamlConstructor.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/YamlConstructor.java @@ -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 diff --git a/paper-api/src/main/java/org/bukkit/configuration/file/YamlRepresenter.java b/paper-api/src/main/java/org/bukkit/configuration/file/YamlRepresenter.java index 9dd3890f97..20e9687647 100644 --- a/paper-api/src/main/java/org/bukkit/configuration/file/YamlRepresenter.java +++ b/paper-api/src/main/java/org/bukkit/configuration/file/YamlRepresenter.java @@ -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 diff --git a/paper-api/src/test/java/org/bukkit/configuration/file/FileConfigurationTest.java b/paper-api/src/test/java/org/bukkit/configuration/file/FileConfigurationTest.java index 4ef7aa9818..87bfa2c1da 100644 --- a/paper-api/src/test/java/org/bukkit/configuration/file/FileConfigurationTest.java +++ b/paper-api/src/test/java/org/bukkit/configuration/file/FileConfigurationTest.java @@ -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 getTestCommentInput(); - public abstract String getTestHeaderResult(); + public abstract String getTestCommentResult(); + + public abstract List getTestHeaderComments(); + + public abstract String getTestHeaderCommentsResult(); + + public abstract List 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 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 values = getTestValues(); - String saved = getTestValuesString(); - String header = getTestHeaderResult(); - String expected = getTestHeaderInput(); - - config.loadFromString(header + "\n" + saved); - - assertEquals(expected, config.options().header()); - - for (Map.Entry 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 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 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 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 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 values = getTestValues(); + String saved = getTestValuesString(); + String comments = getTestCommentResult(); + + config.options().parseComments(true); + config.loadFromString(comments + "\n" + saved); + + for (Map.Entry 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 values = getTestValues(); + String saved = getTestValuesString(); + String comments = getTestCommentResult(); + + config.options().parseComments(false); + config.loadFromString(comments + "\n" + saved); + config.options().parseComments(true); + + for (Map.Entry 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 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 values = getTestValues(); + String saved = getTestValuesString(); + String comments = getTestHeaderKeyCommentResult(); + + config.options().parseComments(true); + config.loadFromString(comments + saved); + + for (Map.Entry 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 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 values = getTestValues(); + String saved = getTestValuesString(); + String comments = getTestHeaderCommentsResult(); + + config.options().parseComments(true); + config.loadFromString(saved + comments); + + for (Map.Entry 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); + } + } diff --git a/paper-api/src/test/java/org/bukkit/configuration/file/YamlConfigurationTest.java b/paper-api/src/test/java/org/bukkit/configuration/file/YamlConfigurationTest.java index cd79a3487f..57e31a2867 100644 --- a/paper-api/src/test/java/org/bukkit/configuration/file/YamlConfigurationTest.java +++ b/paper-api/src/test/java/org/bukkit/configuration/file/YamlConfigurationTest.java @@ -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 getTestCommentInput() { + List 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 getTestHeaderComments() { + return Arrays.asList(" Header", " Second Line"); + } + + @Override + public String getTestHeaderCommentsResult() { + return "# Header\n# Second Line\n"; + } + + @Override + public List 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