diff --git a/build.gradle.kts b/build.gradle.kts index 2ed2db679..dec1027e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { val minecraftlessImplementation by configurations minecraftlessImplementation("com.google.code.gson:gson:2.8.9") + minecraftlessImplementation("commons-io:commons-io:2.11.0") minecraftlessImplementation("org.jetbrains:annotations:24.1.0") testImplementation(platform("org.junit:junit-bom:5.10.2")) diff --git a/platform/fabric/src/main/resources/waila_plugins.json b/platform/fabric/src/main/resources/waila_plugins.json index 344b9133c..043905e95 100644 --- a/platform/fabric/src/main/resources/waila_plugins.json +++ b/platform/fabric/src/main/resources/waila_plugins.json @@ -33,10 +33,5 @@ "initializer": "mcp.mobius.waila.plugin.trenergy.WailaPluginTeamRebornEnergy", "side" : "*", "required" : ["team_reborn_energy"] - }, - - "waila:test" : { - "initializer" : "mcp.mobius.waila.plugin.test.WailaPluginTest", - "defaultEnabled": false } } diff --git a/platform/forge/src/main/resources/waila_plugins.json b/platform/forge/src/main/resources/waila_plugins.json index 60853d570..d3cfe65b7 100644 --- a/platform/forge/src/main/resources/waila_plugins.json +++ b/platform/forge/src/main/resources/waila_plugins.json @@ -11,7 +11,7 @@ "required" : ["minecraft"] }, - "waila:harvest" : { + "waila:harvest": { "initializer": "mcp.mobius.waila.plugin.harvest.WailaPluginHarvest", "side" : "*", "required" : [] @@ -23,14 +23,9 @@ "required" : [] }, - "waila:forge" : { + "waila:forge" : { "initializer": "mcp.mobius.waila.plugin.forge.WailaPluginForge", "side" : "*", "required" : ["forge"] - }, - - "waila:test" : { - "initializer" : "mcp.mobius.waila.plugin.test.WailaPluginTest", - "defaultEnabled": false } } diff --git a/platform/quilt/src/main/resources/waila_plugins.json b/platform/quilt/src/main/resources/waila_plugins.json index 494851968..c3b215b0c 100644 --- a/platform/quilt/src/main/resources/waila_plugins.json +++ b/platform/quilt/src/main/resources/waila_plugins.json @@ -33,10 +33,5 @@ "initializer": "mcp.mobius.waila.plugin.trenergy.WailaPluginTeamRebornEnergy", "side" : "*", "required" : ["team_reborn_energy"] - }, - - "waila:test" : { - "initializer" : "mcp.mobius.waila.plugin.test.WailaPluginTest", - "defaultEnabled": false } } diff --git a/src/api/java/mcp/mobius/waila/api/IJsonConfig.java b/src/api/java/mcp/mobius/waila/api/IJsonConfig.java index d1097deae..da7c32a9c 100644 --- a/src/api/java/mcp/mobius/waila/api/IJsonConfig.java +++ b/src/api/java/mcp/mobius/waila/api/IJsonConfig.java @@ -1,7 +1,12 @@ package mcp.mobius.waila.api; import java.io.File; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.nio.file.Path; +import java.util.List; import java.util.function.ObjIntConsumer; import java.util.function.Supplier; import java.util.function.ToIntFunction; @@ -79,6 +84,37 @@ default void backup() { this.backup(null); } + /** + * Adds comment for this value. + * + * @see Builder1#commenter(Supplier) + */ + @Target({ElementType.TYPE, ElementType.FIELD}) + @Retention(RetentionPolicy.RUNTIME) + @interface Comment { + + /** + * The comment. + */ + String value(); + + } + + /** + * A custom commenter. + */ + interface Commenter { + + /** + * Returns the comment for the specified path. + * + * @param path a list containing the nested path of the entry, empty list means the root object + */ + @Nullable + String getComment(List path); + + } + interface Builder0 { Builder1 file(File file); @@ -93,6 +129,10 @@ interface Builder1 { Builder1 version(int currentVersion, ToIntFunction versionGetter, ObjIntConsumer versionSetter); + Builder1 json5(); + + Builder1 commenter(Supplier commenter); + Builder1 gson(Gson gson); Builder1 factory(Supplier factory); diff --git a/src/main/java/mcp/mobius/waila/Waila.java b/src/main/java/mcp/mobius/waila/Waila.java index aed9e7ef0..dc2e6e1af 100644 --- a/src/main/java/mcp/mobius/waila/Waila.java +++ b/src/main/java/mcp/mobius/waila/Waila.java @@ -8,6 +8,7 @@ import mcp.mobius.waila.api.WailaConstants; import mcp.mobius.waila.api.__internal__.IHarvestService; import mcp.mobius.waila.config.BlacklistConfig; +import mcp.mobius.waila.config.DebugConfig; import mcp.mobius.waila.config.WailaConfig; import mcp.mobius.waila.gui.hud.theme.ThemeDefinition; import mcp.mobius.waila.registry.RegistryFilter; @@ -33,6 +34,8 @@ public abstract class Waila { public static final IJsonConfig CONFIG = IJsonConfig.of(WailaConfig.class) .file(WailaConstants.NAMESPACE + "/" + WailaConstants.WAILA) .version(WailaConstants.CONFIG_VERSION, WailaConfig::getConfigVersion, WailaConfig::setConfigVersion) + .json5() + .commenter(WailaConfig.COMMENTER) .gson(new GsonBuilder() .setPrettyPrinting() .registerTypeAdapter(WailaConfig.Overlay.Color.class, new WailaConfig.Overlay.Color.Adapter()) @@ -44,12 +47,19 @@ public abstract class Waila { public static final IJsonConfig BLACKLIST_CONFIG = IJsonConfig.of(BlacklistConfig.class) .file(WailaConstants.NAMESPACE + "/blacklist") .version(BlacklistConfig.VERSION, BlacklistConfig::getConfigVersion, BlacklistConfig::setConfigVersion) + .json5() + .commenter(() -> BlacklistConfig.COMMENTER) .gson(new GsonBuilder() .setPrettyPrinting() .registerTypeAdapter(BlacklistConfig.class, new BlacklistConfig.Adapter()) .create()) .build(); + public static final IJsonConfig DEBUG_CONFIG = IJsonConfig.of(DebugConfig.class) + .file(WailaConstants.NAMESPACE + "/debug") + .json5() + .build(); + public static ResourceLocation id(String path) { return new ResourceLocation(WailaConstants.NAMESPACE, path); } diff --git a/src/main/java/mcp/mobius/waila/config/BlacklistConfig.java b/src/main/java/mcp/mobius/waila/config/BlacklistConfig.java index 1e2381d4a..35f064abd 100644 --- a/src/main/java/mcp/mobius/waila/config/BlacklistConfig.java +++ b/src/main/java/mcp/mobius/waila/config/BlacklistConfig.java @@ -14,6 +14,7 @@ import com.google.gson.JsonSerializer; import mcp.mobius.waila.Waila; import mcp.mobius.waila.api.IBlacklistConfig; +import mcp.mobius.waila.api.IJsonConfig; import mcp.mobius.waila.api.IRegistryFilter; import mcp.mobius.waila.util.Log; import net.minecraft.core.Registry; @@ -31,11 +32,20 @@ public class BlacklistConfig { private static final String BLACKLIST_TAG = "#" + Waila.id("blacklist"); public static final int VERSION = 0; + public static final IJsonConfig.Commenter COMMENTER = p -> !p.isEmpty() ? null : """ + Run `/waila reload` to apply changes server-wide. + Run `/wailac reload` to apply changes to only your client. + + %s + + The `%s` tag rule can not be removed""" + .formatted(IRegistryFilter.getHeader(), BLACKLIST_TAG); public final LinkedHashSet blocks = new LinkedHashSet<>(); public final LinkedHashSet blockEntityTypes = new LinkedHashSet<>(); public final LinkedHashSet entityTypes = new LinkedHashSet<>(); + @IJsonConfig.Comment("\nThe values below are used internally by WTHIT, you SHOULD NOT modify it!") private int configVersion = 0; public int[] pluginHash = {0, 0, 0}; @@ -118,20 +128,6 @@ public static class Adapter implements JsonSerializer, JsonDese public JsonElement serialize(BlacklistConfig src, Type typeOfSrc, JsonSerializationContext context) { var object = new JsonObject(); - var comments = """ - Run /waila reload to apply changes server-wide. - Run /wailac reload to apply changes to only your client. - - %s - - The %s tag rule can not be removed""" - .formatted(IRegistryFilter.getHeader(), BLACKLIST_TAG) - .split("\n"); - - var commentArray = new JsonArray(); - for (var line : comments) commentArray.add(line); - object.add("_comment", commentArray); - src.addBlacklistTags(); object.add("blocks", context.serialize(src.blocks)); diff --git a/src/main/java/mcp/mobius/waila/config/ConfigEntry.java b/src/main/java/mcp/mobius/waila/config/ConfigEntry.java index 2d9a82767..806d70886 100644 --- a/src/main/java/mcp/mobius/waila/config/ConfigEntry.java +++ b/src/main/java/mcp/mobius/waila/config/ConfigEntry.java @@ -6,6 +6,8 @@ import java.util.function.Function; import com.google.common.base.Preconditions; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; import com.google.gson.JsonPrimitive; import mcp.mobius.waila.api.IPluginInfo; import net.minecraft.resources.ResourceLocation; @@ -19,7 +21,7 @@ public class ConfigEntry { public static final Type DOUBLE = new Type<>((e, d) -> e.getAsDouble(), JsonPrimitive::new); public static final Type STRING = new Type<>((e, d) -> e.getAsString(), JsonPrimitive::new); public static final Type> ENUM = new Type<>((e, d) -> Enum.valueOf(d.getDeclaringClass(), e.getAsString()), e -> new JsonPrimitive(e.name())); - public static final Type PATH = new Type<>((e, d) -> null, e -> null); + public static final Type PATH = new Type<>((e, d) -> d, e -> JsonNull.INSTANCE); private final IPluginInfo origin; private final ResourceLocation id; @@ -136,10 +138,10 @@ private void assertInstance(T value) { public static class Type { - public final BiFunction parser; - public final Function serializer; + public final BiFunction parser; + public final Function serializer; - public Type(BiFunction parser, Function serializer) { + public Type(BiFunction parser, Function serializer) { this.parser = parser; this.serializer = serializer; } diff --git a/src/main/java/mcp/mobius/waila/config/DebugConfig.java b/src/main/java/mcp/mobius/waila/config/DebugConfig.java new file mode 100644 index 000000000..0ea11b7ec --- /dev/null +++ b/src/main/java/mcp/mobius/waila/config/DebugConfig.java @@ -0,0 +1,11 @@ +package mcp.mobius.waila.config; + +import mcp.mobius.waila.api.IJsonConfig; + +@IJsonConfig.Comment("Debug options, restart the game to apply") +public class DebugConfig { + + @IJsonConfig.Comment("Show test plugin on plugin toggle screen") + public boolean showTestPluginToggle = false; + +} diff --git a/src/main/java/mcp/mobius/waila/config/JsonConfig.java b/src/main/java/mcp/mobius/waila/config/JsonConfig.java index 85a0d1630..4525fe5e1 100644 --- a/src/main/java/mcp/mobius/waila/config/JsonConfig.java +++ b/src/main/java/mcp/mobius/waila/config/JsonConfig.java @@ -7,6 +7,7 @@ import java.nio.file.Paths; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.Set; @@ -20,6 +21,8 @@ import com.google.gson.GsonBuilder; import mcp.mobius.waila.Waila; import mcp.mobius.waila.api.IJsonConfig; +import mcp.mobius.waila.config.commenter.AnnotationCommenter; +import mcp.mobius.waila.config.commenter.CommenterFactories; import mcp.mobius.waila.mcless.config.ConfigIo; import mcp.mobius.waila.util.CachedSupplier; import mcp.mobius.waila.util.Log; @@ -39,9 +42,14 @@ public class JsonConfig implements IJsonConfig { private final CachedSupplier getter; @SuppressWarnings("unchecked") - JsonConfig(Path path, Type clazz, Supplier factory, Gson gson, int currentVersion, ToIntFunction versionGetter, ObjIntConsumer versionSetter) { + JsonConfig(Path path, Type type, Supplier factory, boolean json5, Supplier commenter, Gson gson, int currentVersion, ToIntFunction versionGetter, ObjIntConsumer versionSetter) { this.path = path.toAbsolutePath(); - this.io = new ConfigIo<>(LOG::warn, LOG::error, gson, clazz, factory, currentVersion, versionGetter, versionSetter); + + var commenterFactories = new ArrayList>(); + if (type instanceof Class cls) commenterFactories.add(() -> new AnnotationCommenter(cls, gson)); + commenterFactories.add(commenter); + + this.io = new ConfigIo<>(LOG::warn, LOG::error, json5, new CommenterFactories(commenterFactories), gson, type, factory, currentVersion, versionGetter, versionSetter); this.getter = new CachedSupplier<>(() -> io.read(this.path)); INSTANCES.add((JsonConfig) this); @@ -99,7 +107,9 @@ public void backup(@Nullable String cause) { public static class Builder implements Builder0, Builder1 { final Type type; - Path path; + Supplier path; + boolean json5; + Supplier commenter; Gson gson; int currentVersion; ToIntFunction versionGetter; @@ -109,6 +119,8 @@ public static class Builder implements Builder0, Builder1 { @SuppressWarnings("unchecked") public Builder(Type type) { this.type = type; + this.json5 = false; + this.commenter = () -> s -> null; this.gson = DEFAULT_GSON; this.currentVersion = 0; this.versionGetter = t -> 0; @@ -125,19 +137,29 @@ public Builder(Type type) { @Override public Builder1 file(File file) { - this.path = file.toPath(); + this.path = file::toPath; return this; } @Override public Builder1 file(Path path) { - this.path = path; + this.path = () -> path; return this; } @Override public Builder1 file(String fileName) { - this.path = Waila.CONFIG_DIR.resolve(fileName + (fileName.endsWith(".json") ? "" : ".json")); + this.path = () -> { + var path = fileName; + if (json5) { + if (!path.endsWith(".json5")) path += ".json5"; + } else { + if (!path.endsWith(".json")) path += ".json"; + } + + return Waila.CONFIG_DIR.resolve(path); + }; + return this; } @@ -155,6 +177,18 @@ public Builder1 factory(Supplier factory) { return this; } + @Override + public Builder1 json5() { + this.json5 = true; + return this; + } + + @Override + public Builder1 commenter(Supplier commenter) { + this.commenter = commenter; + return this; + } + @Override public Builder1 gson(Gson gson) { this.gson = gson; @@ -164,7 +198,7 @@ public Builder1 gson(Gson gson) { @Override public IJsonConfig build() { Preconditions.checkNotNull(factory, "Default value factory must not be null"); - return new JsonConfig<>(path, type, factory, gson, currentVersion, versionGetter, versionSetter); + return new JsonConfig<>(path.get(), type, factory, json5, commenter, gson, currentVersion, versionGetter, versionSetter); } } diff --git a/src/main/java/mcp/mobius/waila/config/PluginConfig.java b/src/main/java/mcp/mobius/waila/config/PluginConfig.java index 16919ea8e..69629eec3 100644 --- a/src/main/java/mcp/mobius/waila/config/PluginConfig.java +++ b/src/main/java/mcp/mobius/waila/config/PluginConfig.java @@ -7,15 +7,20 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; import com.google.gson.GsonBuilder; -import com.google.gson.JsonPrimitive; +import com.google.gson.JsonElement; import com.google.gson.reflect.TypeToken; import mcp.mobius.waila.Waila; +import mcp.mobius.waila.api.IJsonConfig; import mcp.mobius.waila.api.IPluginConfig; import mcp.mobius.waila.api.WailaConstants; +import mcp.mobius.waila.buildconst.Tl; +import mcp.mobius.waila.config.commenter.CommenterFactories; +import mcp.mobius.waila.config.commenter.LanguageCommenter; import mcp.mobius.waila.mcless.config.ConfigIo; import mcp.mobius.waila.util.Log; import net.minecraft.resources.ResourceLocation; @@ -27,11 +32,57 @@ public enum PluginConfig implements IPluginConfig { private static final Log LOG = Log.create(); - private static final Path PATH = Waila.CONFIG_DIR.resolve(WailaConstants.NAMESPACE + "/" + WailaConstants.WAILA + "_plugins.json"); - private static final ConfigIo>> IO = new ConfigIo<>( + private static final Path PATH = Waila.CONFIG_DIR.resolve(WailaConstants.NAMESPACE + "/" + WailaConstants.WAILA + "_plugins.json5"); + + private static final Supplier COMMENTER = () -> new LanguageCommenter((translation, p) -> { + if (p.size() < 2) return null; + + var namespace = p.get(0); + var path = p.get(1); + var entry = getEntry(ResourceLocation.fromNamespaceAndPath(namespace, path)); + var type = entry.getType(); + + var sb = new StringBuilder(); + + var tlKey = Tl.Config.PLUGIN_ + namespace + "." + path; + sb.append(translation.getOrDefault(tlKey, tlKey)); + + var descKey = tlKey + "_desc"; + if (translation.containsKey(descKey)) sb.append('\n').append(translation.get(descKey)); + + if (type.equals(ConfigEntry.PATH)) { + sb.append("\nCustom config, open the following file\n").append(entry.getDefaultValue()); + return sb.toString(); + } + + if (entry.isServerRequired()) { + sb.append("\nRequire server to have WTHIT installed, if not, will be locked to ").append(entry.getClientOnlyValue()); + } else if (entry.isMerged()) { + sb.append("\nThis value will get merged with the value from the server"); + } else if (entry.isSynced()) { + sb.append("\nThis value will get overridden by the server"); + } + + sb.append("\nDefault value: ").append(entry.getDefaultValue().toString()); + if (type.equals(ConfigEntry.ENUM)) { + sb.append("\nAvailable values: "); + var enums = ((Enum) entry.getDefaultValue()).getDeclaringClass().getEnumConstants(); + sb.append(enums[0].name()); + for (var i = 1; i < enums.length; i++) { + var anEnum = enums[i]; + sb.append(", ").append(anEnum.name()); + } + } + + return sb.toString(); + }); + + private static final ConfigIo>> IO = new ConfigIo<>( LOG::warn, LOG::error, + true, + new CommenterFactories(List.of(COMMENTER)), new GsonBuilder().setPrettyPrinting().create(), - new TypeToken>>() {}.getType(), + new TypeToken>>() {}.getType(), LinkedHashMap::new); private static final Map> CONFIGS = new LinkedHashMap<>(); @@ -81,9 +132,10 @@ public static void set(ResourceLocation key, T value) { } public static void reload() { - if (!Files.exists(PATH)) { - writeConfig(); + if (!Files.exists(PATH) && !IO.migrateJson5(PATH)) { + write(); } + var config = IO.read(PATH); config.forEach((namespace, subMap) -> subMap.forEach((path, value) -> { var entry = (ConfigEntry) CONFIGS.get(new ResourceLocation(namespace, path)); @@ -91,15 +143,13 @@ public static void reload() { entry.setLocalValue(entry.getType().parser.apply(value, entry.getDefaultValue())); } })); - LOG.info("Plugin config reloaded"); - } - public static void save() { - writeConfig(); + write(); + LOG.info("Plugin config reloaded"); } - private static void writeConfig() { - Map> config = new LinkedHashMap<>(); + public static void write() { + var config = new LinkedHashMap>(); for (var entry : CONFIGS.values()) { if (entry.isAlias()) continue; diff --git a/src/main/java/mcp/mobius/waila/config/WailaConfig.java b/src/main/java/mcp/mobius/waila/config/WailaConfig.java index da163102c..b3df8c2d1 100644 --- a/src/main/java/mcp/mobius/waila/config/WailaConfig.java +++ b/src/main/java/mcp/mobius/waila/config/WailaConfig.java @@ -1,8 +1,14 @@ package mcp.mobius.waila.config; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Type; +import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; @@ -12,9 +18,12 @@ import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import mcp.mobius.waila.Waila; +import mcp.mobius.waila.api.IJsonConfig; import mcp.mobius.waila.api.ITheme; import mcp.mobius.waila.api.IWailaConfig; import mcp.mobius.waila.api.WailaConstants; +import mcp.mobius.waila.buildconst.Tl; +import mcp.mobius.waila.config.commenter.LanguageCommenter; import mcp.mobius.waila.gui.hud.theme.ThemeDefinition; import mcp.mobius.waila.util.Log; import mcp.mobius.waila.util.TypeUtil; @@ -25,9 +34,76 @@ public class WailaConfig implements IWailaConfig { private static final Log LOG = Log.create(); + interface Nested {} + + @Retention(RetentionPolicy.RUNTIME) + @interface T { + + String value(); + + } + + public static final Supplier COMMENTER = () -> { + var defaultValue = new WailaConfig(); + + return new LanguageCommenter((translation, path) -> { + if (path.isEmpty()) return null; + + AnnotatedElement element = null; + Object value = defaultValue; + Class parentCls = WailaConfig.class; + for (var part : path) { + try { + var field = parentCls.getDeclaredField(part); + field.setAccessible(true); + value = field.get(value); + + element = field; + parentCls = field.getType(); + } catch (NoSuchFieldException ignored) { + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + if (element == null) return null; + if (value instanceof Nested) return null; + + var sb = new StringBuilder(); + + var tlKey = element.getAnnotation(T.class); + if (tlKey != null) { + sb.append(Objects.requireNonNull(translation.get(tlKey.value()))); + + var descKey = tlKey.value() + "_desc"; + if (translation.containsKey(descKey)) sb.append('\n').append(translation.get(descKey)); + + sb.append('\n'); + } + + if (value instanceof Enum e) { + sb.append("Default value: ").append(e.name()).append("\nAvailable values: "); + var enums = e.getDeclaringClass().getEnumConstants(); + sb.append(enums[0].name()); + for (var i = 1; i < enums.length; i++) { + var anEnum = enums[i]; + sb.append(", ").append(anEnum.name()); + } + } else if (!(value instanceof Map || value instanceof Collection)) { + sb.append("Default value: ").append(value); + } + + return sb.toString(); + }); + }; + private final General general = new General(); private final Overlay overlay = new Overlay(); + + @IJsonConfig.Comment("Text formatters") private final Formatter formatter = new Formatter(); + + @IJsonConfig.Comment("Internal value, DO NOT TOUCH!") private int configVersion = 0; public int getConfigVersion() { @@ -53,16 +129,16 @@ public Formatter getFormatter() { return formatter; } - public static class General implements IWailaConfig.General { + public static class General implements IWailaConfig.General, Nested { - private boolean displayTooltip = true; - private boolean shiftForDetails = false; - private boolean hideShiftText = false; - private DisplayMode displayMode = DisplayMode.TOGGLE; - private boolean hideFromPlayerList = true; - private boolean hideFromDebug = true; - private boolean enableTextToSpeech = false; - private int rateLimit = 250; + private @T(Tl.Config.DISPLAY_TOOLTIP) boolean displayTooltip = true; + private @T(Tl.Config.SNEAKY_DETAILS) boolean shiftForDetails = false; + private @T(Tl.Config.HIDE_SNEAK_TEXT) boolean hideShiftText = false; + private @T(Tl.Config.DISPLAY_MODE) DisplayMode displayMode = DisplayMode.TOGGLE; + private @T(Tl.Config.HIDE_FROM_PLAYERS) boolean hideFromPlayerList = true; + private @T(Tl.Config.HIDE_FROM_DEBUG) boolean hideFromDebug = true; + private @T(Tl.Config.TTS) boolean enableTextToSpeech = false; + private @T(Tl.Config.RATE_LIMIT) int rateLimit = 250; @Override public boolean isDisplayTooltip() { @@ -151,12 +227,13 @@ public int getMaxHeartsPerLine() { } - public static class Overlay implements IWailaConfig.Overlay { + public static class Overlay implements IWailaConfig.Overlay, Nested { private final Position position = new Position(); private final Color color = new Color(); - private float scale = 1.0F; - private int fps = 30; + + private @T(Tl.Config.OVERLAY_SCALE) float scale = 1.0F; + private @T(Tl.Config.OVERLAY_FPS) int fps = 30; @Override public Position getPosition() { @@ -185,13 +262,14 @@ public void setFps(int fps) { this.fps = fps; } - public static class Position implements IWailaConfig.Overlay.Position { + public static class Position implements IWailaConfig.Overlay.Position, Nested { private final Align align = new Align(); private final Align anchor = new Align(); - private int x = 0; - private int y = 0; - private boolean bossBarsOverlap = false; + + private @T(Tl.Config.OVERLAY_POS_X) int x = 0; + private @T(Tl.Config.OVERLAY_POS_Y) int y = 0; + private @T(Tl.Config.BOSS_BARS_OVERLAP) boolean bossBarsOverlap = false; @Override public int getX() { @@ -230,7 +308,7 @@ public void setBossBarsOverlap(boolean bossBarsOverlap) { this.bossBarsOverlap = bossBarsOverlap; } - public static class Align implements IWailaConfig.Overlay.Position.Align { + public static class Align implements IWailaConfig.Overlay.Position.Align, Nested { X x = X.CENTER; Y y = Y.TOP; @@ -258,14 +336,14 @@ public void setY(Y y) { } @SuppressWarnings("removal") - public static class Color implements IWailaConfig.Overlay.Color { + public static class Color implements IWailaConfig.Overlay.Color, Nested { private static final ResourceLocation DEFAULT = Waila.id("vanilla"); - private static boolean warnDeprecatedColorGetter = true; - private int backgroundAlpha = 204; - private ResourceLocation activeTheme = DEFAULT; + private @T(Tl.Config.OVERLAY_BACKGROUND_ALPHA) int backgroundAlpha = 204; + private @T(Tl.Config.OVERLAY_THEME) ResourceLocation activeTheme = DEFAULT; + @IJsonConfig.Comment("Custom Themes") private final Map> themes = new HashMap<>(); private ThemeDefinition getThemeDef() { @@ -376,7 +454,7 @@ public JsonElement serialize(Color src, Type typeOfSrc, JsonSerializationContext } - public static class Formatter implements IWailaConfig.Formatter { + public static class Formatter implements IWailaConfig.Formatter, Nested { private String modName = "§9§o%s"; private String blockName = "§f%s"; diff --git a/src/main/java/mcp/mobius/waila/config/commenter/AnnotationCommenter.java b/src/main/java/mcp/mobius/waila/config/commenter/AnnotationCommenter.java new file mode 100644 index 000000000..5b94f03c0 --- /dev/null +++ b/src/main/java/mcp/mobius/waila/config/commenter/AnnotationCommenter.java @@ -0,0 +1,68 @@ +package mcp.mobius.waila.config.commenter; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import mcp.mobius.waila.api.IJsonConfig; +import org.jetbrains.annotations.Nullable; + +public class AnnotationCommenter implements IJsonConfig.Commenter { + + private final Class cls; + private final Gson gson; + + private final Map, Field[]> fields = new HashMap<>(); + private final Map> possibleFieldNames = new HashMap<>(); + + public AnnotationCommenter(Class cls, Gson gson) { + this.cls = cls; + this.gson = gson; + } + + @Override + public @Nullable String getComment(List path) { + AnnotatedElement element = null; + + if (path.isEmpty()) { + element = cls; + } else { + var parent = cls; + for (var part : path) { + var fields = this.fields.computeIfAbsent(parent, Class::getDeclaredFields); + for (var field : fields) { + var possibleFieldNames = this.possibleFieldNames.computeIfAbsent(field, f -> { + var set = new HashSet(); + set.add(field.getName()); + set.add(gson.fieldNamingStrategy().translateName(field)); + var serializedName = field.getAnnotation(SerializedName.class); + if (serializedName != null) { + set.add(serializedName.value()); + set.addAll(Arrays.asList(serializedName.alternate())); + } + return set; + }); + + if (possibleFieldNames.contains(part)) { + element = field; + parent = field.getType(); + } + } + } + } + + if (element != null) { + var comment = element.getAnnotation(IJsonConfig.Comment.class); + if (comment != null) return comment.value(); + } + + return null; + } + +} diff --git a/src/main/java/mcp/mobius/waila/config/commenter/CommenterFactories.java b/src/main/java/mcp/mobius/waila/config/commenter/CommenterFactories.java new file mode 100644 index 000000000..60ff60175 --- /dev/null +++ b/src/main/java/mcp/mobius/waila/config/commenter/CommenterFactories.java @@ -0,0 +1,40 @@ +package mcp.mobius.waila.config.commenter; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import mcp.mobius.waila.api.IJsonConfig; +import org.jetbrains.annotations.Nullable; + +public class CommenterFactories implements Supplier, @Nullable String>> { + + private final List> factories; + + public CommenterFactories(List> factories) { + this.factories = factories; + } + + @Override + public Function, @Nullable String> get() { + var commenters = factories.stream().map(Supplier::get).toList(); + + return path -> { + StringBuilder builder = null; + + for (var commenter : commenters) { + var comment = commenter.getComment(path); + if (comment == null) continue; + + if (builder == null) { + builder = new StringBuilder(comment); + } else { + builder.append('\n').append(comment); + } + } + + return builder == null ? null : builder.toString(); + }; + } + +} diff --git a/src/main/java/mcp/mobius/waila/config/commenter/LanguageCommenter.java b/src/main/java/mcp/mobius/waila/config/commenter/LanguageCommenter.java new file mode 100644 index 000000000..32bc4d53c --- /dev/null +++ b/src/main/java/mcp/mobius/waila/config/commenter/LanguageCommenter.java @@ -0,0 +1,41 @@ +package mcp.mobius.waila.config.commenter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import mcp.mobius.waila.api.IJsonConfig; +import mcp.mobius.waila.config.PluginConfig; +import net.minecraft.locale.Language; +import org.jetbrains.annotations.Nullable; + +public class LanguageCommenter implements IJsonConfig.Commenter { + + private final Map translation; + private final Impl impl; + + public LanguageCommenter(Impl impl) { + this.impl = impl; + translation = new HashMap<>(); + try (var stream = PluginConfig.class.getResourceAsStream("/assets/waila/lang/en_us.json")) { + Language.loadFromJson(Objects.requireNonNull(stream), translation::put); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public @Nullable String getComment(List path) { + return impl.getComment(translation, path); + } + + public interface Impl { + + @Nullable + String getComment(Map translation, List path); + + } + +} diff --git a/src/main/java/mcp/mobius/waila/gui/screen/PluginConfigScreen.java b/src/main/java/mcp/mobius/waila/gui/screen/PluginConfigScreen.java index 38e04eeac..6a231e459 100644 --- a/src/main/java/mcp/mobius/waila/gui/screen/PluginConfigScreen.java +++ b/src/main/java/mcp/mobius/waila/gui/screen/PluginConfigScreen.java @@ -41,7 +41,7 @@ public class PluginConfigScreen extends ConfigScreen { } public PluginConfigScreen(Screen parent) { - super(parent, Component.translatable(Tl.Gui.Plugin.SETTINGS), PluginConfig::save, PluginConfig::reload); + super(parent, Component.translatable(Tl.Gui.Plugin.SETTINGS), PluginConfig::write, PluginConfig::reload); } @SuppressWarnings("unchecked") @@ -51,7 +51,7 @@ private static void register(ConfigEntry.Type type, ConfigValueFunction> STATES = IJsonConfig.of(new TypeToken>() { - }) + private static final IJsonConfig> STATES = IJsonConfig.of(new TypeToken>() {}) .file(WailaConstants.NAMESPACE + "/category_entries") + .json5() .factory(HashMap::new) + .commenter(() -> p -> !p.isEmpty() ? null : """ + This config controls the category entries collapsed state. + You shouldn't edit this config by hand. + """) .build(); public final Component title; diff --git a/src/main/java/mcp/mobius/waila/plugin/PluginInfo.java b/src/main/java/mcp/mobius/waila/plugin/PluginInfo.java index 45de3d581..490c1a134 100644 --- a/src/main/java/mcp/mobius/waila/plugin/PluginInfo.java +++ b/src/main/java/mcp/mobius/waila/plugin/PluginInfo.java @@ -24,10 +24,10 @@ public class PluginInfo implements IPluginInfo { private static final Log LOG = Log.create(); private static final ResourceLocation CORE = Waila.id("core"); - private static final IJsonConfig> TOGGLE = IJsonConfig.of(new TypeToken>() { - }) + private static final IJsonConfig> TOGGLE = IJsonConfig.of(new TypeToken>() {}) .file(WailaConstants.NAMESPACE + "/" + "plugin_toggle") .factory(LinkedHashMap::new) + .json5() .gson(new GsonBuilder() .setPrettyPrinting() .registerTypeAdapter(ResourceLocation.class, new ResourceLocation.Serializer()) diff --git a/src/main/java/mcp/mobius/waila/plugin/PluginLoader.java b/src/main/java/mcp/mobius/waila/plugin/PluginLoader.java index f3b3b25fd..add07387f 100644 --- a/src/main/java/mcp/mobius/waila/plugin/PluginLoader.java +++ b/src/main/java/mcp/mobius/waila/plugin/PluginLoader.java @@ -15,6 +15,7 @@ import lol.bai.badpackets.api.PacketSender; import mcp.mobius.waila.Waila; import mcp.mobius.waila.api.IPluginInfo; +import mcp.mobius.waila.api.WailaConstants; import mcp.mobius.waila.api.__internal__.Internals; import mcp.mobius.waila.config.JsonConfig; import mcp.mobius.waila.config.PluginConfig; @@ -144,6 +145,10 @@ public final void loadPlugins() { if (!gathered) { gathered = true; gatherPlugins(); + + if (Waila.DEBUG_CONFIG.get().showTestPluginToggle) { + PluginInfo.register(WailaConstants.MOD_ID, Waila.id("test").toString(), IPluginInfo.Side.BOTH, "mcp.mobius.waila.plugin.test.WailaPluginTest", List.of(), false, false); + } } PluginInfo.saveToggleConfig(); diff --git a/src/minecraftless/java/mcp/mobius/waila/mcless/config/ConfigIo.java b/src/minecraftless/java/mcp/mobius/waila/mcless/config/ConfigIo.java index 30a6d939e..e856fe67d 100644 --- a/src/minecraftless/java/mcp/mobius/waila/mcless/config/ConfigIo.java +++ b/src/minecraftless/java/mcp/mobius/waila/mcless/config/ConfigIo.java @@ -9,13 +9,18 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.ObjIntConsumer; import java.util.function.Supplier; import java.util.function.ToIntFunction; import com.google.gson.Gson; +import mcp.mobius.waila.mcless.json5.Json5Writer; +import org.apache.commons.io.FilenameUtils; +import org.jetbrains.annotations.Nullable; public class ConfigIo { @@ -23,6 +28,8 @@ public class ConfigIo { private final Consumer warn; private final BiConsumer error; + private final boolean json5; + private final Supplier, @Nullable String>> commenter; private final Gson gson; private final Type type; private final Supplier factory; @@ -30,9 +37,11 @@ public class ConfigIo { private final ToIntFunction versionGetter; private final ObjIntConsumer versionSetter; - public ConfigIo(Consumer warn, BiConsumer error, Gson gson, Type type, Supplier factory, int currentVersion, ToIntFunction versionGetter, ObjIntConsumer versionSetter) { + public ConfigIo(Consumer warn, BiConsumer error, boolean json5, Supplier, @Nullable String>> commenter, Gson gson, Type type, Supplier factory, int currentVersion, ToIntFunction versionGetter, ObjIntConsumer versionSetter) { this.warn = warn; this.error = error; + this.json5 = json5; + this.commenter = commenter; this.gson = gson; this.type = type; this.factory = factory; @@ -41,14 +50,34 @@ public ConfigIo(Consumer warn, BiConsumer error, Gson this.versionSetter = versionSetter; } - public ConfigIo(Consumer warn, BiConsumer error, Gson gson, Type type, Supplier factory) { - this(warn, error, gson, type, factory, 0, t -> 0, (a, b) -> {}); + public ConfigIo(Consumer warn, BiConsumer error, boolean json5, Supplier, @Nullable String>> commenter, Gson gson, Type type, Supplier factory) { + this(warn, error, json5, commenter, gson, type, factory, 0, t -> 0, (a, b) -> {}); + } + + public boolean migrateJson5(Path path) { + if (!json5) return false; + + var pathString = path.toString(); + if (FilenameUtils.getExtension(pathString).equals("json5")) { + var jsonPath = path.resolveSibling(FilenameUtils.getBaseName(pathString) + ".json"); + if (Files.exists(jsonPath)) try { + Files.copy(jsonPath, path); + Files.delete(jsonPath); + warn.accept("Migrated from " + jsonPath + " to " + path); + return true; + } catch (IOException e) { + error.accept("Failed to move " + jsonPath + " to " + path, e); + } + } + return false; } public T read(Path path) { T config; var init = true; if (!Files.exists(path)) { + if (migrateJson5(path)) return read(path); + var parent = path.getParent(); if (!Files.exists(parent)) { try { @@ -60,7 +89,9 @@ public T read(Path path) { config = factory.get(); } else { try (var reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { - config = gson.fromJson(reader, type); + var jsonReader = gson.newJsonReader(reader); + jsonReader.setLenient(true); + config = gson.fromJson(jsonReader, type); var version = versionGetter.applyAsInt(config); if (version != currentVersion) { var old = Paths.get(path + "_" + DATE_FORMAT.format(new Date())); @@ -105,7 +136,7 @@ public T read(Path path) { public boolean write(Path path, T value) { try (var writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { - writer.write(gson.toJson(value)); + gson.toJson(value, type, json5 ? new Json5Writer(writer, commenter.get()) : gson.newJsonWriter(writer)); return true; } catch (IOException e) { error.accept("Exception when writing config file " + path, e); diff --git a/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Scope.java b/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Scope.java new file mode 100644 index 000000000..8902331ea --- /dev/null +++ b/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Scope.java @@ -0,0 +1,82 @@ +// This file was a part of Quilt Parsers, directly copied with no changes. +// https://github.com/QuiltMC/quilt-parsers/blob/00803c4e70fb0cf93765593eaae5c781b1505bee/json/src/main/java/org/quiltmc/parsers/json/JsonScope.java +// @formatter:off + +/* + * Copyright 2010 Google Inc. + * Copyright 2021-2023 QuiltMC + * + * 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 mcp.mobius.waila.mcless.json5; + +/* + * This is a carbon-copy of Gson's JsonScope. + * You may view the original, including its license header, here: + * https://github.com/google/gson/blob/530cb7447089ccc12dc2009c17f468ddf2cd61ca/gson/src/main/java/com/google/gson/stream/JsonScope.java + */ + +/** + * Lexical scoping elements within a JSON reader or writer. + * + * @author Jesse Wilson + * @since 1.6 + */ +final class Json5Scope { + + /** + * An array with no elements requires no separators or newlines before + * it is closed. + */ + static final int EMPTY_ARRAY = 1; + + /** + * A array with at least one value requires a comma and newline before + * the next element. + */ + static final int NONEMPTY_ARRAY = 2; + + /** + * An object with no name/value pairs requires no separators or newlines + * before it is closed. + */ + static final int EMPTY_OBJECT = 3; + + /** + * An object whose most recent element is a key. The next element must + * be a value. + */ + static final int DANGLING_NAME = 4; + + /** + * An object with at least one name/value pair requires a comma and + * newline before the next element. + */ + static final int NONEMPTY_OBJECT = 5; + + /** + * No object or array has been started. + */ + static final int EMPTY_DOCUMENT = 6; + + /** + * A document with at an array or object. + */ + static final int NONEMPTY_DOCUMENT = 7; + + /** + * A document that's been closed and cannot be accessed. + */ + static final int CLOSED = 8; +} diff --git a/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Writer.java b/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Writer.java new file mode 100644 index 000000000..0dcf34d67 --- /dev/null +++ b/src/minecraftless/java/mcp/mobius/waila/mcless/json5/Json5Writer.java @@ -0,0 +1,644 @@ +// This file was a part of Quilt Parsers, modified as follows: +// - Removed unused members +// - Directly extends GSON's JsonWriter +// - Added generic commenter function +// - Fixed issue with character replacement table (https://github.com/QuiltMC/quilt-parsers/pull/5) +// - Disabled backspace escape on comments +// https://github.com/QuiltMC/quilt-parsers/blob/00803c4e70fb0cf93765593eaae5c781b1505bee/json/src/main/java/org/quiltmc/parsers/json/JsonWriter.java +// @formatter:off + +/* + * Copyright 2010 Google Inc. + * Copyright 2021-2023 QuiltMC + * + * 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 mcp.mobius.waila.mcless.json5; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.Nullable; + +/* + * + * The following changes have been applied to the original GSON code: + * - Lenient mode has been removed + * - Support for JSONC and JSON5 added + * + * You may view the original, including its license header, here: + * https://github.com/google/gson/blob/530cb7447089ccc12dc2009c17f468ddf2cd61ca/gson/src/main/java/com/google/gson/stream/JsonReader.java + */ + +/** + * Writes a JSON5 or strict JSON (RFC 7159) + * encoded value to a stream, one token at a time. The stream includes both + * literal values (strings, numbers, booleans and nulls) as well as the begin + * and end delimiters of objects and arrays. + * + *

Encoding JSON

+ * To encode your data as JSON, create a new {@code JsonWriter}. Each JSON + * document must contain one top-level array or object. Call methods on the + * writer as you walk the structure's contents, nesting arrays and objects as + * necessary: + *
    + *
  • To write arrays, first call {@link #beginArray()}. + * Write each of the array's elements with the appropriate {@link #value} + * methods or by nesting other arrays and objects. Finally close the array + * using {@link #endArray()}. + *
  • To write objects, first call {@link #beginObject()}. + * Write each of the object's properties by alternating calls to + * {@link #name} with the property's value. Write property values with the + * appropriate {@link #value} method or by nesting other objects or arrays. + * Finally close the object using {@link #endObject()}. + *
+ * + *

Example

+ * Suppose we'd like to encode a stream of messages such as the following:
 {@code
+ * [
+ *   {
+ *     id: 912345678901,
+ *     text: "How do I stream JSON in Java?",
+ *     geo: null,
+ *     user: {
+ *       name: "json_newb",
+ *       "followers_count": 41
+ *      }
+ *   },
+ *   {
+ *     id: 912345678902,
+ *     text: "@json_newb just use JsonWriter!",
+ *     geo: [50.454722, -104.606667],
+ *     user: {
+ *       name: "jesse",
+ *       followers_count: 2
+ *     }
+ *   }
+ * ]}
+ * This code encodes the above structure:
   {@code
+ *   public void writeJsonStream(OutputStream out, List messages) throws IOException {
+ *     JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, "UTF-8"));
+ *     writer.setIndent("    ");
+ *     writeMessagesArray(writer, messages);
+ *     writer.close();
+ *   }
+ *
+ *   public void writeMessagesArray(JsonWriter writer, List messages) throws IOException {
+ *     writer.beginArray();
+ *     for (Message message : messages) {
+ *       writeMessage(writer, message);
+ *     }
+ *     writer.endArray();
+ *   }
+ *
+ *   public void writeMessage(JsonWriter writer, Message message) throws IOException {
+ *     writer.beginObject();
+ *     writer.name("id").value(message.getId());
+ *     writer.name("text").value(message.getText());
+ *     if (message.getGeo() != null) {
+ *       writer.name("geo");
+ *       writeDoublesArray(writer, message.getGeo());
+ *     } else {
+ *       writer.name("geo").nullValue();
+ *     }
+ *     writer.name("user");
+ *     writeUser(writer, message.getUser());
+ *     writer.endObject();
+ *   }
+ *
+ *   public void writeUser(JsonWriter writer, User user) throws IOException {
+ *     writer.beginObject();
+ *     writer.name("name").value(user.getName());
+ *     writer.name("followers_count").value(user.getFollowersCount());
+ *     writer.endObject();
+ *   }
+ *
+ *   public void writeDoublesArray(JsonWriter writer, List doubles) throws IOException {
+ *     writer.beginArray();
+ *     for (Double value : doubles) {
+ *       writer.value(value);
+ *     }
+ *     writer.endArray();
+ *   }}
+ * + *

Each {@code JsonWriter} may be used to write a single JSON stream. + * Instances of this class are not thread safe. Calls that would result in a + * malformed JSON string will fail with an {@link IllegalStateException}. + */ +@SuppressWarnings({"RedundantExplicitVariableType", "DataFlowIssue"}) +public final class Json5Writer extends JsonWriter { + /* + * From RFC 7159, "All Unicode characters may be placed within the + * quotation marks except for the characters that must be escaped: + * quotation mark, reverse solidus, and the control characters + * (U+0000 through U+001F)." + * + * We also escape '\u2028' and '\u2029', which JavaScript interprets as + * newline characters. This prevents eval() from failing with a syntax + * error. http://code.google.com/p/google-gson/issues/detail?id=341 + */ + private static final String[] REPLACEMENT_CHARS; + static { + REPLACEMENT_CHARS = new String[128]; + for (int i = 0; i <= 0x1f; i++) { + REPLACEMENT_CHARS[i] = String.format("\\u%04x", i); + } + REPLACEMENT_CHARS['"'] = "\\\""; + REPLACEMENT_CHARS['\\'] = "\\\\"; + REPLACEMENT_CHARS['\t'] = "\\t"; + REPLACEMENT_CHARS['\b'] = "\\b"; + REPLACEMENT_CHARS['\n'] = "\\n"; + REPLACEMENT_CHARS['\r'] = "\\r"; + REPLACEMENT_CHARS['\f'] = "\\f"; + } + + /** The output data, containing at most one top-level array or object. */ + private final Writer out; + + private int[] stack = new int[32]; + private int stackSize = 0; + { + push(Json5Scope.EMPTY_DOCUMENT); + } + + /** + * A string containing a full set of spaces for a single level of + * indentation, or null for no pretty printing. + */ + private final String indent = " "; + + private String deferredName; + private String deferredComment; + + private final Function, @Nullable String> commenter; + private final ArrayList pathNames = new ArrayList<>(32); + private final List pathNamesView = Collections.unmodifiableList(pathNames); + + // API methods + + public Json5Writer(Writer out, Function, @Nullable String> commenter) { + super(Writer.nullWriter()); + Objects.requireNonNull(out, "Writer cannot be null"); + this.out = out; + this.commenter = commenter; + } + + /** + * Encodes the property name. + * + * @param name the name of the forthcoming value. May not be null. + * @return this writer. + */ + public Json5Writer name(String name) throws IOException { + if (name == null) { + throw new NullPointerException("name == null"); + } + if (deferredName != null) { + throw new IllegalStateException(); + } + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + deferredName = name; + pathNames.set(pathNames.size() - 1, name); + comment(commenter.apply(pathNamesView)); + return this; + } + + /** + * Begins encoding a new object. Each call to this method must be paired + * with a call to {@link #endObject}. + * + * @return this writer. + */ + public Json5Writer beginObject() throws IOException { + writeDeferredName(); + return open(Json5Scope.EMPTY_OBJECT, '{'); + } + + /** + * Ends encoding the current object. + * + * @return this writer. + */ + public Json5Writer endObject() throws IOException { + return close(Json5Scope.EMPTY_OBJECT, Json5Scope.NONEMPTY_OBJECT, '}'); + } + + /** + * Begins encoding a new array. Each call to this method must be paired with + * a call to {@link #endArray}. + * + * @return this writer. + */ + public Json5Writer beginArray() throws IOException { + writeDeferredName(); + return open(Json5Scope.EMPTY_ARRAY, '['); + } + + /** + * Ends encoding the current array. + * + * @return this writer. + */ + public Json5Writer endArray() throws IOException { + return close(Json5Scope.EMPTY_ARRAY, Json5Scope.NONEMPTY_ARRAY, ']'); + } + + /** + * Encodes {@code value}. + * + * @param value the literal string value, or null to encode a null literal. + * @return this writer. + */ + public Json5Writer value(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + string(value, true, true); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public Json5Writer value(boolean value) throws IOException { + writeDeferredName(); + beforeValue(); + out.write(value ? "true" : "false"); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public Json5Writer value(@Nullable Boolean value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + out.write(value ? "true" : "false"); + return this; + } + + /** + * Encodes {@code value}. + * + * @param value a finite value. May not be {@link Double#isNaN() NaNs} or + * {@link Double#isInfinite() infinities}. + * @return this writer. + */ + public Json5Writer value(Number value) throws IOException { + if (value == null) { + return nullValue(); + } + + writeDeferredName(); + String string = value.toString(); + beforeValue(); + out.append(string); + return this; + } + + /** + * Encodes {@code value}. + * + * @param value a finite value. May not be {@link Double#isNaN() NaNs} or + * {@link Double#isInfinite() infinities}. + * @return this writer. + */ + public Json5Writer value(double value) throws IOException { + writeDeferredName(); + beforeValue(); + out.append(Double.toString(value)); + return this; + } + + /** + * Encodes {@code value}. + * + * @return this writer. + */ + public Json5Writer value(long value) throws IOException { + writeDeferredName(); + beforeValue(); + out.write(Long.toString(value)); + return this; + } + + /** + * Encodes {@code null}. + * + * @return this writer. + */ + public Json5Writer nullValue() throws IOException { + if (deferredName != null) { + writeDeferredName(); + } + beforeValue(); + out.write("null"); + return this; + } + + /** + * Writes {@code value} directly to the writer without quoting or + * escaping. + * + * @param value the literal string value, or null to encode a null literal. + * @return this writer. + */ + public Json5Writer jsonValue(String value) throws IOException { + if (value == null) { + return nullValue(); + } + writeDeferredName(); + beforeValue(); + out.append(value); + return this; + } + + /** + * Encodes a comment, handling newlines and HTML safety gracefully. + * Silently does nothing when strict JSON mode is enabled. + * @param comment the comment to write, or null to encode nothing. + */ + public Json5Writer comment(String comment) throws IOException { + if (comment == null) { + return this; + } + + if (deferredComment == null) { + deferredComment = comment; + } else { + deferredComment += "\n" + comment; + } + + // Be aggressive about writing comments if we are at the end of the document + if (stackSize == 1 && peek() == Json5Scope.NONEMPTY_DOCUMENT) { + out.append('\n'); + writeDeferredComment(); + } + + return this; + } + + /** + * Ensures all buffered data is written to the underlying {@link Writer} + * and flushes that writer. + */ + public void flush() throws IOException { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + out.flush(); + } + + /** + * Flushes and closes this writer and the underlying {@link Writer}. + * + * @throws IOException if the JSON document is incomplete. + */ + public void close() throws IOException { + out.close(); + + int size = stackSize; + if (size > 1 || size == 1 && stack[size - 1] != Json5Scope.NONEMPTY_DOCUMENT) { + throw new IOException("Incomplete document"); + } + stackSize = 0; + } + + // Implementation methods + // Everything below here should be package-private or private + + /** + * Enters a new scope by appending any necessary whitespace and the given + * bracket. + */ + private Json5Writer open(int empty, char openBracket) throws IOException { + beforeValue(); + pathNames.addLast("NULL"); + push(empty); + out.write(openBracket); + return this; + } + + /** + * Closes the current scope by appending any necessary whitespace and the + * given bracket. + */ + private Json5Writer close(int empty, int nonempty, char closeBracket) + throws IOException { + int context = peek(); + if (context != nonempty && context != empty) { + throw new IllegalStateException("Nesting problem."); + } + if (deferredName != null) { + throw new IllegalStateException("Dangling name: " + deferredName); + } + + stackSize--; + if (!pathNames.isEmpty()) pathNames.removeLast(); + if (context == nonempty) { + commentAndNewline(); + } + out.write(closeBracket); + return this; + } + + private void push(int newTop) { + if (stackSize == stack.length) { + int newLength = stackSize * 2; + stack = Arrays.copyOf(stack, newLength); + pathNames.ensureCapacity(newLength); + } + stack[stackSize++] = newTop; + } + + /** + * Returns the value on the top of the stack. + */ + private int peek() { + if (stackSize == 0) { + throw new IllegalStateException("JsonWriter is closed."); + } + return stack[stackSize - 1]; + } + + /** + * Replace the value on the top of the stack with the given value. + */ + private void replaceTop(int topOfStack) { + stack[stackSize - 1] = topOfStack; + } + + private void writeDeferredName() throws IOException { + if (deferredName != null) { + beforeName(); + boolean quotes = true; + // JSON5 allows bare names... only for keys that are valid EMCA5 identifiers + // luckily, Java identifiers follow the same standard, + // so we can just use the built-in Character.isJavaIdentifierStart/Part methods + if (deferredName.length() > 0) { + if (Character.isJavaIdentifierStart(deferredName.charAt(0))) { + quotes = false; + for (int i = 1; i < deferredName.length(); i++) { + if (!Character.isJavaIdentifierPart(deferredName.charAt(i))) { + quotes = true; + break; + } + } + } + } + + string(deferredName, quotes, true); + deferredName = null; + } + } + + private void writeDeferredComment() throws IOException { + if (deferredComment == null) { + return; + } + + for (String s : deferredComment.split("\n")) { + for (int i = 1, size = stackSize; i < size; i++) { + out.write(indent); + } + out.write("// "); + string(s, false, false); + out.write("\n"); + } + + deferredComment = null; + } + + private void string(String value, boolean quotes, boolean escapeQuotesAndBackspace) throws IOException { + if (quotes) { + out.write('\"'); + } + + int last = 0; + int length = value.length(); + + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + String replacement; + if (c < 128) { + if (!escapeQuotesAndBackspace) { + if (c == '"' || c == '\\') continue; + } + replacement = REPLACEMENT_CHARS[c]; + if (replacement == null) { + continue; + } + } else if (c == '\u2028') { + replacement = "\\u2028"; + } else if (c == '\u2029') { + replacement = "\\u2029"; + } else { + continue; + } + if (last < i) { + out.write(value, last, i - last); + } + out.write(replacement); + last = i + 1; + } + if (last < length) { + out.write(value, last, length - last); + } + + if (quotes) { + out.write('\"'); + } + } + + private void commentAndNewline() throws IOException { + out.write('\n'); + writeDeferredComment(); + + for (int i = 1, size = stackSize; i < size; i++) { + out.write(indent); + } + } + + /** + * Inserts any necessary separators and whitespace before a name. Also + * adjusts the stack to expect the name's value. + */ + private void beforeName() throws IOException { + int context = peek(); + if (context == Json5Scope.NONEMPTY_OBJECT) { // first in object + out.write(','); + } else if (context != Json5Scope.EMPTY_OBJECT) { // not in an object! + throw new IllegalStateException("Nesting problem."); + } + commentAndNewline(); + replaceTop(Json5Scope.DANGLING_NAME); + } + + /** + * Inserts any necessary comments, separators, and whitespace before a literal value, + * inline array, or inline object. Also adjusts the stack to expect either a + * closing bracket or another element. + */ + @SuppressWarnings("fallthrough") + private void beforeValue() throws IOException { + switch (peek()) { + case Json5Scope.NONEMPTY_DOCUMENT: + // TODO: This isn't a JSON5 feature, right? + throw new IllegalStateException( + "JSON must have only one top-level value."); + // fall-through + case Json5Scope.EMPTY_DOCUMENT: // first in document + comment(commenter.apply(List.of())); + writeDeferredComment(); + replaceTop(Json5Scope.NONEMPTY_DOCUMENT); + break; + + case Json5Scope.EMPTY_ARRAY: // first in array + replaceTop(Json5Scope.NONEMPTY_ARRAY); + commentAndNewline(); + break; + + case Json5Scope.NONEMPTY_ARRAY: // another in array + out.append(','); + commentAndNewline(); + break; + + case Json5Scope.DANGLING_NAME: // value for name + out.append(": "); + replaceTop(Json5Scope.NONEMPTY_OBJECT); + break; + + default: + throw new IllegalStateException("Nesting problem."); + } + } +} diff --git a/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/config/ExtraBlacklistConfig.java b/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/config/ExtraBlacklistConfig.java index 146e4645c..2159fa3ff 100644 --- a/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/config/ExtraBlacklistConfig.java +++ b/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/config/ExtraBlacklistConfig.java @@ -11,6 +11,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; +import mcp.mobius.waila.api.IJsonConfig; import mcp.mobius.waila.api.IRegistryFilter; import net.minecraft.core.Registry; import net.minecraft.resources.ResourceLocation; @@ -33,6 +34,17 @@ public View getView() { return view; } + public static IJsonConfig.Commenter commenter(ResourceLocation tag) { + return p -> !p.isEmpty() ? null : """ + Run `/waila reload` to apply changes server-wide. + Run `/wailac reload` to apply changes to only your client. + + %s + + The #%s tag rule can not be removed""" + .formatted(IRegistryFilter.getHeader(), tag); + } + public class View { public final IRegistryFilter blockFilter; @@ -59,20 +71,6 @@ public Adapter(ResourceLocation tagId) { public JsonElement serialize(ExtraBlacklistConfig src, Type typeOfSrc, JsonSerializationContext context) { var object = new JsonObject(); - var comments = """ - Run /waila reload to apply changes server-wide. - Run /wailac reload to apply changes to only your client. - - %s - - The %s tag rule can not be removed""" - .formatted(IRegistryFilter.getHeader(), tagRule) - .split("\n"); - - var commentArray = new JsonArray(); - for (var line : comments) commentArray.add(line); - object.add("_comment", commentArray); - object.add("blocks", context.serialize(src.blocks)); object.add("blockEntityTypes", context.serialize(src.blockEntityTypes)); object.add("entityTypes", context.serialize(src.entityTypes)); diff --git a/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/provider/DataProvider.java b/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/provider/DataProvider.java index 7af31d764..3d24cf821 100644 --- a/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/provider/DataProvider.java +++ b/src/pluginExtra/java/mcp/mobius/waila/plugin/extra/provider/DataProvider.java @@ -52,6 +52,8 @@ protected DataProvider(ResourceLocation id, Class apiType, Class implType, blacklistConfig = IJsonConfig.of(ExtraBlacklistConfig.class) .file(WailaConstants.NAMESPACE + "/extra/" + id.getPath() + "_blacklist") + .json5() + .commenter(() -> ExtraBlacklistConfig.commenter(tagId)) .gson(new GsonBuilder() .setPrettyPrinting() .registerTypeAdapter(ExtraBlacklistConfig.class, new ExtraBlacklistConfig.Adapter(tagId))