diff options
9 files changed, 260 insertions, 37 deletions
diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java index 470eba0..d94280f 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigClassHandler.java @@ -55,10 +55,24 @@ public interface ConfigClassHandler<T> { boolean supportsAutoGen(); /** + * Safely loads the config class using the provided serializer. + * @return if the config was loaded successfully + */ + boolean load(); + + /** + * Safely saves the config class using the provided serializer. + */ + void save(); + + /** * The serializer for this config class. * Manages saving and loading of the config with fields * annotated with {@link SerialEntry}. + * + * @deprecated use {@link #load()} and {@link #save()} instead. */ + @Deprecated ConfigSerializer<T> serializer(); /** diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java index 13d6e08..4ac988c 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/ConfigSerializer.java @@ -1,9 +1,11 @@ package dev.isxander.yacl3.config.v2.api; +import java.util.Map; + /** * The base class for config serializers, * offering a method to save and load. - * @param <T> + * @param <T> the config class to be (de)serialized */ public abstract class ConfigSerializer<T> { protected final ConfigClassHandler<T> config; @@ -20,7 +22,43 @@ public abstract class ConfigSerializer<T> { public abstract void save(); /** + * Loads all fields into the config class. + * @param bufferAccessMap a map of the field accesses. instead of directly setting the field with + * {@link ConfigField#access()}, use this parameter. This loads into a temporary object, + * and the class handler handles pushing these changes to the instance. + * @return the result of the load + */ + public LoadResult loadSafely(Map<ConfigField<?>, FieldAccess<?>> bufferAccessMap) { + this.load(); + return LoadResult.NO_CHANGE; + } + + /** * Loads all fields in the config class. + * + * @deprecated use {@link #loadSafely(Map)} instead. */ - public abstract void load(); + @Deprecated + public void load() { + throw new IllegalArgumentException("load() is deprecated, use loadSafely() instead."); + } + + public enum LoadResult { + /** + * Indicates that the config was loaded successfully and the temporary object should be applied. + */ + SUCCESS, + /** + * Indicates that the config was not loaded successfully and the load should be abandoned. + */ + FAILURE, + /** + * Indicates that the config has not changed after a load and the temporary object should be ignored. + */ + NO_CHANGE, + /** + * Indicates the config was loaded successfully, but the config should be re-saved straight away. + */ + DIRTY + } } diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java index d87283a..94bf785 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialEntry.java @@ -25,4 +25,15 @@ public @interface SerialEntry { * If empty, the serializer will not add a comment. */ String comment() default ""; + + /** + * Whether the field is required in the loaded config to be valid. + * If it's not, the config will be marked as dirty and re-saved with the default value. + */ + boolean required() default true; + + /** + * Whether the field can be null. + */ + boolean nullable() default false; } diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java index 6337565..cf6abfc 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/SerialField.java @@ -9,4 +9,8 @@ public interface SerialField { String serialName(); Optional<String> comment(); + + boolean required(); + + boolean nullable(); } diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java b/common/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java index 49b3999..33003d7 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/api/serializer/GsonConfigSerializerBuilder.java @@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder; import dev.isxander.yacl3.config.ConfigEntry; import dev.isxander.yacl3.config.v2.api.ConfigClassHandler; import dev.isxander.yacl3.config.v2.api.ConfigSerializer; +import dev.isxander.yacl3.config.v2.api.SerialEntry; import dev.isxander.yacl3.config.v2.impl.serializer.GsonConfigSerializer; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; @@ -55,7 +56,6 @@ public interface GsonConfigSerializerBuilder<T> { * <li>null serialization</li> * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li> * </ul> - * Still respects the exclusion strategy to only serialize {@link ConfigEntry} * but these can be added to with setExclusionStrategies. * * @param gson gson instance to be converted to a builder @@ -72,13 +72,22 @@ public interface GsonConfigSerializerBuilder<T> { * <li>null serialization</li> * <li>{@link Component}, {@link Style} and {@link Color} type adapters</li> * </ul> + * For example, if you wanted to revert YACL's lower_camel_case naming policy, + * you could do the following: + * <pre> + * {@code + * GsonConfigSerializerBuilder.create(config) + * .appendGsonBuilder(builder -> builder.setFieldNamingPolicy(FieldNamingPolicy.IDENTITY)) + * } + * </pre> * * @param gsonBuilder the function to apply to the builder */ GsonConfigSerializerBuilder<T> appendGsonBuilder(UnaryOperator<GsonBuilder> gsonBuilder); /** - * Writes the json under JSON5 spec, allowing comments. + * Writes the json under JSON5 spec, allowing the use of {@link SerialEntry#comment()}. + * If enabling this option it's recommended to use the file extension {@code .json5}. * * @param json5 whether to write under JSON5 spec * @return this builder diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java index dd14ed0..c363a7d 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigClassHandlerImpl.java @@ -7,43 +7,50 @@ import dev.isxander.yacl3.config.v2.api.autogen.OptionAccess; import dev.isxander.yacl3.config.v2.impl.autogen.OptionFactoryRegistry; import dev.isxander.yacl3.config.v2.impl.autogen.OptionAccessImpl; import dev.isxander.yacl3.config.v2.impl.autogen.YACLAutoGenException; +import dev.isxander.yacl3.impl.utils.YACLConstants; import dev.isxander.yacl3.platform.YACLPlatform; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import org.apache.commons.lang3.Validate; import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.AbstractMap; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> { private final Class<T> configClass; private final ResourceLocation id; private final boolean supportsAutoGen; private final ConfigSerializer<T> serializer; - private final ConfigField<?>[] fields; + private final ConfigFieldImpl<?>[] fields; - private final T instance, defaults; + private T instance; + private final T defaults; + private final Constructor<T> noArgsConstructor; public ConfigClassHandlerImpl(Class<T> configClass, ResourceLocation id, Function<ConfigClassHandler<T>, ConfigSerializer<T>> serializerFactory) { this.configClass = configClass; this.id = id; - this.supportsAutoGen = YACLPlatform.getEnvironment().isClient(); + this.supportsAutoGen = id != null && YACLPlatform.getEnvironment().isClient(); try { - Constructor<T> constructor = configClass.getDeclaredConstructor(); - this.instance = constructor.newInstance(); - this.defaults = constructor.newInstance(); - } catch (Exception e) { - throw new YACLAutoGenException("Failed to create instance of config class '%s' with no-args constructor.".formatted(configClass.getName()), e); + noArgsConstructor = configClass.getDeclaredConstructor(); + } catch (NoSuchMethodException e) { + throw new YACLAutoGenException("Failed to find no-args constructor for config class %s.".formatted(configClass.getName()), e); } + this.instance = createNewObject(); + this.defaults = createNewObject(); this.fields = Arrays.stream(configClass.getDeclaredFields()) .peek(field -> field.setAccessible(true)) .filter(field -> field.isAnnotationPresent(SerialEntry.class) || field.isAnnotationPresent(AutoGen.class)) .map(field -> new ConfigFieldImpl<>(new ReflectionFieldAccess<>(field, instance), new ReflectionFieldAccess<>(field, defaults), this, field.getAnnotation(SerialEntry.class), field.getAnnotation(AutoGen.class))) - .toArray(ConfigField[]::new); + .toArray(ConfigFieldImpl[]::new); this.serializer = serializerFactory.apply(this); } @@ -63,7 +70,7 @@ public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> { } @Override - public ConfigField<?>[] fields() { + public ConfigFieldImpl<?>[] fields() { return this.fields; } @@ -83,6 +90,12 @@ public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> { throw new YACLAutoGenException("Auto GUI generation is not supported for this config class. You either need to enable it in the builder or you are attempting to create a GUI in a dedicated server environment."); } + boolean hasAutoGenFields = Arrays.stream(fields()).anyMatch(field -> field.autoGen().isPresent()); + + if (!hasAutoGenFields) { + throw new YACLAutoGenException("No fields in this config class are annotated with @AutoGen. You must annotate at least one field with @AutoGen to generate a GUI."); + } + OptionAccessImpl storage = new OptionAccessImpl(); Map<String, CategoryAndGroups> categories = new LinkedHashMap<>(); for (ConfigField<?> configField : fields()) { @@ -134,6 +147,71 @@ public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> { return this.serializer; } + @Override + public boolean load() { + // create a new instance to load into + T newInstance = createNewObject(); + + // create field accesses for the new object + Map<ConfigFieldImpl<?>, ReflectionFieldAccess<?>> accessBufferImpl = Arrays.stream(fields()) + .map(field -> new AbstractMap.SimpleImmutableEntry<>( + field, + new ReflectionFieldAccess<>(field.access().field(), newInstance) + )) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + // convert the map into API safe field accesses + Map<ConfigField<?>, FieldAccess<?>> accessBuffer = accessBufferImpl.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // attempt to load the config + ConfigSerializer.LoadResult loadResult = ConfigSerializer.LoadResult.FAILURE; + Throwable error = null; + try { + loadResult = this.serializer().loadSafely(accessBuffer); + } catch (Throwable e) { + // handle any errors later in the loadResult switch case + error = e; + } + + switch (loadResult) { + case DIRTY: + case SUCCESS: + // replace the instance with the newly created one + this.instance = newInstance; + for (ConfigFieldImpl<?> field : fields()) { + // update the field accesses to point to the correct object + ((ConfigFieldImpl<Object>) field).setFieldAccess((ReflectionFieldAccess<Object>) accessBufferImpl.get(field)); + } + + if (loadResult == ConfigSerializer.LoadResult.DIRTY) { + // if the load result is dirty, we need to save the config again + this.save(); + } + case NO_CHANGE: + return true; + case FAILURE: + YACLConstants.LOGGER.error( + "Unsuccessful load of config class '{}'. The load will be abandoned and config remains unchanged.", + configClass.getSimpleName(), error + ); + } + + return false; + } + + @Override + public void save() { + serializer().save(); + } + + private T createNewObject() { + try { + return noArgsConstructor.newInstance(); + } catch (Exception e) { + throw new YACLAutoGenException("Failed to create instance of config class '%s' with no-args constructor.".formatted(configClass.getName()), e); + } + } + public static class BuilderImpl<T> implements Builder<T> { private final Class<T> configClass; private ResourceLocation id; @@ -157,6 +235,9 @@ public class ConfigClassHandlerImpl<T> implements ConfigClassHandler<T> { @Override public ConfigClassHandler<T> build() { + Validate.notNull(serializerFactory, "serializerFactory must not be null"); + Validate.notNull(configClass, "configClass must not be null"); + return new ConfigClassHandlerImpl<>(configClass, id, serializerFactory); } } diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java index 0c879a2..aeed5ac 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/ConfigFieldImpl.java @@ -8,13 +8,13 @@ import org.jetbrains.annotations.Nullable; import java.util.Optional; public class ConfigFieldImpl<T> implements ConfigField<T> { - private final FieldAccess<T> field; - private final ReadOnlyFieldAccess<T> defaultField; + private ReflectionFieldAccess<T> field; + private final ReflectionFieldAccess<T> defaultField; private final ConfigClassHandler<?> parent; private final Optional<SerialField> serial; private final Optional<AutoGenField> autoGen; - public ConfigFieldImpl(FieldAccess<T> field, ReadOnlyFieldAccess<T> defaultField, ConfigClassHandler<?> parent, @Nullable SerialEntry config, @Nullable AutoGen autoGen) { + public ConfigFieldImpl(ReflectionFieldAccess<T> field, ReflectionFieldAccess<T> defaultField, ConfigClassHandler<?> parent, @Nullable SerialEntry config, @Nullable AutoGen autoGen) { this.field = field; this.defaultField = defaultField; this.parent = parent; @@ -23,7 +23,9 @@ public class ConfigFieldImpl<T> implements ConfigField<T> { ? Optional.of( new SerialFieldImpl( "".equals(config.value()) ? field.name() : config.value(), - "".equals(config.comment()) ? Optional.empty() : Optional.of(config.comment()) + "".equals(config.comment()) ? Optional.empty() : Optional.of(config.comment()), + config.required(), + config.nullable() ) ) : Optional.empty(); @@ -38,12 +40,16 @@ public class ConfigFieldImpl<T> implements ConfigField<T> { } @Override - public FieldAccess<T> access() { + public ReflectionFieldAccess<T> access() { return field; } + public void setFieldAccess(ReflectionFieldAccess<T> field) { + this.field = field; + } + @Override - public ReadOnlyFieldAccess<T> defaultAccess() { + public ReflectionFieldAccess<T> defaultAccess() { return defaultField; } @@ -62,7 +68,7 @@ public class ConfigFieldImpl<T> implements ConfigField<T> { return this.autoGen; } - private record SerialFieldImpl(String serialName, Optional<String> comment) implements SerialField { + private record SerialFieldImpl(String serialName, Optional<String> comment, boolean required, boolean nullable) implements SerialField { } private record AutoGenFieldImpl<T>(String category, Optional<String> group) implements AutoGenField { } diff --git a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java index d308c23..a60bcb1 100644 --- a/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java +++ b/common/src/main/java/dev/isxander/yacl3/config/v2/impl/serializer/GsonConfigSerializer.java @@ -11,6 +11,7 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.world.item.Item; +import org.jetbrains.annotations.ApiStatus; import org.quiltmc.parsers.json.JsonReader; import org.quiltmc.parsers.json.JsonWriter; import org.quiltmc.parsers.json.gson.GsonReader; @@ -25,6 +26,7 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.function.UnaryOperator; import java.util.stream.Collectors; @@ -48,12 +50,12 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { try (StringWriter stringWriter = new StringWriter()) { JsonWriter jsonWriter = json5 ? JsonWriter.json5(stringWriter) : JsonWriter.json(stringWriter); GsonWriter gsonWriter = new GsonWriter(jsonWriter); + jsonWriter.beginObject(); + for (ConfigField<?> field : config.fields()) { SerialField serial = field.serial().orElse(null); - if (serial == null) { - continue; - } + if (serial == null) continue; if (!json5 && serial.comment().isPresent() && YACLPlatform.isDevelopmentEnv()) { YACLConstants.LOGGER.warn("Found comment in config field '{}', but json5 is not enabled. Enable it with `.setJson5(true)` on the `GsonConfigSerializerBuilder`. Comments will not be serialized. This warning is only visible in development environments.", serial.serialName()); @@ -61,13 +63,24 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { jsonWriter.comment(serial.comment().orElse(null)); jsonWriter.name(serial.serialName()); + + JsonElement element; try { - gson.toJson(field.access().get(), field.access().type(), gsonWriter); + element = gson.toJsonTree(field.access().get(), field.access().type()); } catch (Exception e) { - YACLConstants.LOGGER.error("Failed to serialize config field '{}'.", serial.serialName(), e); + YACLConstants.LOGGER.error("Failed to serialize config field '{}'. Serializing as null.", serial.serialName(), e); jsonWriter.nullValue(); + continue; + } + + try { + gson.toJson(element, gsonWriter); + } catch (Exception e) { + YACLConstants.LOGGER.error("Failed to serialize config field '{}'. Due to the error state this JSON writer cannot continue safely and the save will be abandoned.", serial.serialName(), e); + return; } } + jsonWriter.endObject(); jsonWriter.flush(); @@ -79,46 +92,85 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { } @Override - public void load() { + public LoadResult loadSafely(Map<ConfigField<?>, FieldAccess<?>> bufferAccessMap) { if (!Files.exists(path)) { YACLConstants.LOGGER.info("Config file '{}' does not exist. Creating it with default values.", path); save(); - return; + return LoadResult.NO_CHANGE; } YACLConstants.LOGGER.info("Deserializing {} from '{}'", config.configClass().getSimpleName(), path); + Map<String, ConfigField<?>> fieldMap = Arrays.stream(config.fields()) + .filter(field -> field.serial().isPresent()) + .collect(Collectors.toMap(f -> f.serial().orElseThrow().serialName(), Function.identity())); + Set<String> missingFields = fieldMap.keySet(); + boolean dirty = false; + try (JsonReader jsonReader = json5 ? JsonReader.json5(path) : JsonReader.json(path)) { GsonReader gsonReader = new GsonReader(jsonReader); - Map<String, ConfigField<?>> fieldMap = Arrays.stream(config.fields()) - .filter(field -> field.serial().isPresent()) - .collect(Collectors.toMap(f -> f.serial().orElseThrow().serialName(), Function.identity())); - jsonReader.beginObject(); while (jsonReader.hasNext()) { String name = jsonReader.nextName(); ConfigField<?> field = fieldMap.get(name); + missingFields.remove(name); + if (field == null) { - YACLConstants.LOGGER.warn("Found unknown config field '{}' in '{}'.", name, path); + YACLConstants.LOGGER.warn("Found unknown config field '{}'.", name); jsonReader.skipValue(); continue; } + FieldAccess<?> bufferAccess = bufferAccessMap.get(field); + SerialField serial = field.serial().orElse(null); + if (serial == null) continue; + + JsonElement element; try { - field.access().set(gson.fromJson(gsonReader, field.access().type())); + element = gson.fromJson(gsonReader, JsonElement.class); } catch (Exception e) { - YACLConstants.LOGGER.error("Failed to deserialize config field '{}'.", name, e); - jsonReader.skipValue(); + YACLConstants.LOGGER.error("Failed to deserialize config field '{}'. Due to the error state this JSON reader cannot be re-used and loading will be aborted.", name, e); + return LoadResult.FAILURE; + } + + if (element.isJsonNull() && !serial.nullable()) { + YACLConstants.LOGGER.warn("Found null value in non-nullable config field '{}'. Leaving field as default and marking as dirty.", name); + dirty = true; + continue; + } + + try { + bufferAccess.set(gson.fromJson(element, bufferAccess.type())); + } catch (Exception e) { + YACLConstants.LOGGER.error("Failed to deserialize config field '{}'. Leaving as default.", name, e); } } jsonReader.endObject(); } catch (IOException e) { - YACLConstants.LOGGER.error("Failed to deserialize config class '{}'.", config.configClass().getSimpleName(), e); + YACLConstants.LOGGER.error("Failed to deserialize config class.", e); + return LoadResult.FAILURE; } + if (!missingFields.isEmpty()) { + for (String missingField : missingFields) { + if (fieldMap.get(missingField).serial().orElseThrow().required()) { + dirty = true; + YACLConstants.LOGGER.warn("Missing required config field '{}''. Re-saving as default.", missingField); + } + } + } + + return dirty ? LoadResult.DIRTY : LoadResult.SUCCESS; + } + + @Override + @Deprecated + public void load() { + YACLConstants.LOGGER.warn("Calling ConfigSerializer#load() directly is deprecated. Please use ConfigClassHandler#load() instead."); + config.load(); } public static class ColorTypeAdapter implements JsonSerializer<Color>, JsonDeserializer<Color> { @@ -145,6 +197,7 @@ public class GsonConfigSerializer<T> extends ConfigSerializer<T> { } } + @ApiStatus.Internal public static class Builder<T> implements GsonConfigSerializerBuilder<T> { private final ConfigClassHandler<T> config; private Path path; diff --git a/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java b/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java index c8981d4..31942be 100644 --- a/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java +++ b/test-common/src/main/java/dev/isxander/yacl3/test/GuiTest.java @@ -47,6 +47,13 @@ public class GuiTest { Minecraft.getInstance().setScreen(AutogenConfigTest.INSTANCE.generateGui().generateScreen(screen)); }) .build()) + .option(ButtonOption.createBuilder() + .name(Component.literal("Skyblocker test")) + .action((screen, opt) -> { + SkyblockerConfig.HANDLER.serializer().load(); + Minecraft.getInstance().setScreen(SkyblockerConfig.HANDLER.generateGui().generateScreen(screen)); + }) + .build()) .group(OptionGroup.createBuilder() .name(Component.literal("Wiki")) .option(ButtonOption.createBuilder() |