From 399ff5f8b7b97de73e39ece22c95d07c598db7b2 Mon Sep 17 00:00:00 2001 From: Spoocy99 <76441091+Spoocy99@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:11:25 +0200 Subject: [PATCH 1/2] Refactored config system --- bukkit-utils/pom.xml | 2 +- .../bukkit/serializers/BukkitSerializers.java | 125 ++++ .../serializers/InventorySerializer.java | 56 +- bukkit-utils/src/main/java/module-info.java | 2 +- common-utils/pom.xml | 2 +- .../collections/NormalizedArrayList.java | 2 + .../utils/common/collections/SortedArray.java | 159 ---- .../common/collections/SortedArrayList.java | 222 ++++++ .../common/exceptions/WrappedException.java | 52 +- .../log/factory/DefaultLoggerFactory.java | 3 +- .../log/factory/Slf4jLoggerFactory.java | 14 +- .../dev/spoocy/utils/common/misc/Args.java | 27 +- .../spoocy/utils/common/misc/FileUtils.java | 419 ++++++++--- .../spoocy/utils/common/misc/Reference.java | 3 + .../utils/common/scheduler/IScheduler.java | 117 --- .../utils/common/scheduler/JavaScheduler.java | 87 ++- .../utils/common/scheduler/Scheduler.java | 169 +++-- .../utils/common/scheduler/Schedulers.java | 36 + ...cheduledTask.java => CompletableTask.java} | 14 +- .../utils/common/scheduler/task/Task.java | 38 +- .../spoocy/utils/common/text/StringUtils.java | 77 +- .../spoocy/utils/common/version/Version.java | 2 + common-utils/src/main/java/module-info.java | 4 +- .../collections/SortedArrayListTest.java | 79 ++ .../exceptions/WrappedExceptionTest.java | 59 ++ config-utils-yaml/pom.xml | 38 - .../dev/spoocy/utils/config/YamlConfig.java | 8 - .../src/main/java/module-info.java | 10 - config-utils/pom.xml | 9 +- .../utils/config/BaseResourceResolver.java | 246 ++++++ .../dev/spoocy/utils/config/CommentType.java | 11 - .../dev/spoocy/utils/config/Commentable.java | 95 +-- .../java/dev/spoocy/utils/config/Config.java | 285 +------ .../spoocy/utils/config/ConfigProvider.java | 20 + .../spoocy/utils/config/ConfigSection.java | 107 +++ .../dev/spoocy/utils/config/Document.java | 403 +--------- .../dev/spoocy/utils/config/FileManager.java | 82 -- .../java/dev/spoocy/utils/config/Json.java | 19 - .../dev/spoocy/utils/config/Readable.java | 425 ++++++++++- .../spoocy/utils/config/ResourceProvider.java | 21 + .../spoocy/utils/config/ResourceResolver.java | 74 ++ .../dev/spoocy/utils/config/Resources.java | 130 ++++ .../utils/config/SafeResourceResolver.java | 41 + .../dev/spoocy/utils/config/SectionArray.java | 15 - .../dev/spoocy/utils/config/Writeable.java | 17 +- .../config/components/AbstractConfig.java | 466 ++---------- .../config/components/AbstractDataHolder.java | 356 +++++++++ .../config/components/AbstractDocument.java | 39 - .../utils/config/components/ConfigNode.java | 123 +++ .../utils/config/components/DocumentFile.java | 48 -- .../utils/config/components/FileDocument.java | 398 ++++++++++ .../config/components/MemorySection.java | 409 ++++++++++ .../config/constructor/BaseConstructor.java | 75 ++ .../utils/config/constructor/Construct.java | 22 + .../utils/config/constructor/Constructor.java | 16 + .../constructor/SerializerAssignable.java | 18 + .../constructor/SerializingConstructor.java | 183 +++++ .../utils/config/io/AbstractResource.java | 78 ++ .../utils/config/io/ClassPathResource.java | 183 +++++ .../utils/config/io/FileSystemResource.java | 169 +++++ .../utils/config/io/InputStreamSource.java | 28 + .../spoocy/utils/config/io/PathResource.java | 139 ++++ .../utils/config/io/ResolvableResource.java | 259 +++++++ .../dev/spoocy/utils/config/io/Resource.java | 142 ++++ .../spoocy/utils/config/io/UrlResource.java | 187 +++++ .../utils/config/io/WriteableResource.java | 31 + .../config/loader/AnnotatedConfigLoader.java | 704 ++++++++++++++++++ .../utils/config/loader/ConfigIgnore.java | 17 + .../utils/config/loader/ConfigLoader.java | 73 ++ .../utils/config/loader/ConfigProperty.java | 43 ++ .../utils/config/loader/ConfigSource.java | 66 ++ .../utils/config/loader/JsonConfigLoader.java | 112 +++ .../spoocy/utils/config/loader/PostLoad.java | 39 + .../utils/config/loader/PostLoadResult.java | 23 + .../spoocy/utils/config/loader/PreLoad.java | 29 + .../utils/config/loader/YamlConfigLoader.java | 229 ++++++ .../utils/config/loader/YamlProcessor.java | 118 +++ .../spoocy/utils/config/misc/SectionList.java | 47 -- .../config/representer/BaseRepresenter.java | 65 ++ .../utils/config/representer/Represent.java | 23 + .../utils/config/representer/Representer.java | 22 + .../representer/SerializingRepresenter.java | 116 +++ .../config/serializer/BaseSerializers.java | 60 ++ .../config/serializer/ConfigSerializable.java | 14 +- ...gSerializer.java => NamedSerializers.java} | 181 ++--- .../config/serializer/SerializableAs.java | 2 +- .../utils/config/serializer/Serializers.java | 69 ++ .../spoocy/utils/config/serializer/Tag.java | 170 +++++ .../serializer/impl/AtomicsSerializer.java | 4 +- .../serializer/impl/DurationSerializer.java | 4 +- .../serializer/impl/EnumSerializer.java | 58 -- .../serializer/impl/JavaSerializer.java | 4 +- .../utils/config/types/ConfigSettings.java | 103 +++ .../spoocy/utils/config/types/JsonConfig.java | 300 +------- .../utils/config/types/JsonSettings.java | 42 ++ .../spoocy/utils/config/types/MapConfig.java | 126 ---- .../utils/config/types/MemoryConfig.java | 37 + .../spoocy/utils/config/types/YamlConfig.java | 130 ++++ .../utils/config/types/YamlSettings.java | 104 +++ .../utils/config/update/ConfigMigration.java | 126 ++++ .../utils/config/update/ConfigUpdater.java | 44 ++ .../config/update/ConfigUpdaterChain.java | 346 +++++++++ .../config/update/PathVersionResolver.java | 46 ++ .../utils/config/update/VersionMatcher.java | 161 ++++ .../utils/config/update/VersionResolver.java | 41 + .../config/update/base/BaseMigration.java | 96 +++ .../update/base/ResourceBasedMigration.java | 89 +++ .../config/update/match/ExactMatcher.java | 47 ++ .../config/update/match/GreaterMatcher.java | 46 ++ .../config/update/match/LowerMatcher.java | 46 ++ .../config/update/match/WildcardMatcher.java | 67 ++ .../migrations/MissingFieldsMigration.java | 141 ++++ .../migrations/TransformationMigration.java | 261 +++++++ .../migrations/ValidationMigration.java | 261 +++++++ config-utils/src/main/java/module-info.java | 14 +- .../src/test/java/EnumSerializerTest.java | 62 -- .../config/AbstractAnnotatedConfigTest.java | 215 ++++++ .../config/BaseResourceResolverTest.java | 225 ++++++ .../config/SafeResourceResolverTest.java | 92 +++ .../config/io/ClassPathResourceTest.java | 111 +++ .../config/io/FileSystemResourceTest.java | 165 ++++ .../utils/config/io/PathResourceTest.java | 207 +++++ .../spoocy/utils/config/io/ResourceTest.java | 41 + .../loader/AnnotatedConfigLoaderTest.java | 108 +++ .../serializer}/AtomicsSerializerTest.java | 19 +- .../serializer}/JavaSerializerTest.java | 2 + .../config/serializer}/SerializerTest.java | 76 +- .../utils/config/types/JsonConfigTest.java | 269 +++++++ .../config/types/SerializableExample.java | 55 ++ .../utils/config/types/YamlConfigTest.java | 208 ++++++ .../config/update/ConfigUpdaterChainTest.java | 242 ++++++ .../config/update/ConfigUpdaterTestBase.java | 65 ++ .../update/MissingFieldsMigrationTest.java | 157 ++++ .../update/TransformationMigrationTest.java | 182 +++++ .../update/ValidationMigrationTest.java | 151 ++++ .../config/update/base/BaseMigrationTest.java | 91 +++ .../base/ResourceBasedMigrationTest.java | 83 +++ .../spoocy/utils/config/io/example.properties | 1 + .../dev/spoocy/utils/config/io/example.xml | 5 + .../config/loader/annotation/example.yml | 9 + .../config/loader/annotation/serializable.yml | 5 + .../spoocy/utils/config/types/example.json | 10 + .../dev/spoocy/utils/config/types/example.yml | 6 + .../update/defaults-without-version.yml | 6 + .../spoocy/utils/config/update/defaults.yml | 7 + pom.xml | 3 +- reflection-utils/pom.xml | 2 +- .../spoocy/utils/reflection/Reflection.java | 130 ++-- .../src/main/java/module-info.java | 2 +- security-utils/pom.xml | 2 +- .../utils/security/SecurityManager.java | 6 +- .../dev/spoocy/utils/security/TestResult.java | 23 +- security-utils/src/main/java/module-info.java | 2 +- 153 files changed, 12423 insertions(+), 2783 deletions(-) create mode 100644 bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java delete mode 100644 common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArray.java create mode 100644 common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArrayList.java delete mode 100644 common-utils/src/main/java/dev/spoocy/utils/common/scheduler/IScheduler.java create mode 100644 common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Schedulers.java rename common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/{ScheduledTask.java => CompletableTask.java} (93%) create mode 100644 common-utils/src/test/java/dev/spoocy/utils/common/collections/SortedArrayListTest.java create mode 100644 common-utils/src/test/java/dev/spoocy/utils/common/exceptions/WrappedExceptionTest.java delete mode 100644 config-utils-yaml/pom.xml delete mode 100644 config-utils-yaml/src/main/java/dev/spoocy/utils/config/YamlConfig.java delete mode 100644 config-utils-yaml/src/main/java/module-info.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/CommentType.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/ConfigProvider.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/FileManager.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/Json.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/ResourceProvider.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/Resources.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/SectionArray.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDocument.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/DocumentFile.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/AbstractResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/InputStreamSource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/PathResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/ResolvableResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/Resource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/io/WriteableResource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/misc/SectionList.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java rename config-utils/src/main/java/dev/spoocy/utils/config/serializer/{ConfigSerializer.java => NamedSerializers.java} (57%) create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/EnumSerializer.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/MapConfig.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigMigration.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdater.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdaterChain.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/PathVersionResolver.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/VersionMatcher.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/VersionResolver.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/base/BaseMigration.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/base/ResourceBasedMigration.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/match/ExactMatcher.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/match/GreaterMatcher.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/match/LowerMatcher.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/match/WildcardMatcher.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/MissingFieldsMigration.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/TransformationMigration.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/ValidationMigration.java delete mode 100644 config-utils/src/test/java/EnumSerializerTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java rename config-utils/src/test/java/{ => dev/spoocy/utils/config/serializer}/AtomicsSerializerTest.java (84%) rename config-utils/src/test/java/{ => dev/spoocy/utils/config/serializer}/JavaSerializerTest.java (98%) rename config-utils/src/test/java/{ => dev/spoocy/utils/config/serializer}/SerializerTest.java (57%) create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterChainTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/TransformationMigrationTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/ValidationMigrationTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/base/BaseMigrationTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/update/base/ResourceBasedMigrationTest.java create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/io/example.properties create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/io/example.xml create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/example.yml create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults-without-version.yml create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml diff --git a/bukkit-utils/pom.xml b/bukkit-utils/pom.xml index 2c244ea..d268ad9 100644 --- a/bukkit-utils/pom.xml +++ b/bukkit-utils/pom.xml @@ -7,7 +7,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 bukkit-utils diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java new file mode 100644 index 0000000..5939d3c --- /dev/null +++ b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java @@ -0,0 +1,125 @@ +package dev.spoocy.utils.bukkit.serializers; + +import com.mojang.authlib.GameProfile; +import dev.spoocy.utils.config.serializer.ConfigSerializable; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import dev.spoocy.utils.config.serializer.Serializer; +import org.bukkit.Color; +import org.bukkit.FireworkEffect; +import org.bukkit.Location; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.block.banner.Pattern; +import org.bukkit.block.spawner.SpawnRule; +import org.bukkit.configuration.serialization.ConfigurationSerializable; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.potion.PotionEffect; +import org.bukkit.util.BlockVector; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class BukkitSerializers extends NamedSerializers { + + private static final BukkitSerializers INSTANCE = new BukkitSerializers(); + + private final Map, Serializer> assignable = new ConcurrentHashMap<>(); + + private BukkitSerializers() { + super(); + this.register(GameProfile.class, GameProfileSerializer.INSTANCE); + this.registerAssignable(Inventory.class, InventorySerializer.INSTANCE); + this.registerBukkit(Vector.class); + this.registerBukkit(BlockVector.class); + this.registerBukkit(ItemStack.class); + this.registerBukkit(Color.class); + this.registerBukkit(PotionEffect.class); + this.registerBukkit(FireworkEffect.class); + this.registerBukkit(Pattern.class); + this.registerBukkit(Location.class); + try { + this.registerBukkit(AttributeModifier.class); + } catch (Throwable ignored) {} + try { + this.registerBukkit(BoundingBox.class); + } catch (Throwable ignored) {} + try { + this.registerBukkit(SpawnRule.class); + } catch (Throwable ignored) {} + } + + public static @NotNull BukkitSerializers getInstance() { + return INSTANCE; + } + + public static @Nullable Serializer resolveSerializer(@NotNull Class clazz) { + return INSTANCE.resolve(clazz); + } + + public static void registerConfigSerializable(@NotNull Class clazz) { + INSTANCE.register(clazz); + } + + public static void registerBukkitSerializer(@NotNull Class clazz) { + INSTANCE.registerBukkit(clazz); + } + + public static void registerAssignableSerializer(@NotNull Class clazz, @NotNull Serializer serializer) { + INSTANCE.registerAssignable(clazz, serializer); + } + + @Override + public void register(@NotNull Class clazz) { + super.register(clazz); + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable Serializer resolve(@NotNull Class clazz) { + Serializer serializer = super.resolve(clazz); + if (serializer != null) return serializer; + + Class matchedType = null; + Serializer matchedSerializer = null; + + for (Map.Entry, Serializer> entry : this.assignable.entrySet()) { + Class assignableType = entry.getKey(); + if (!assignableType.isAssignableFrom(clazz)) { + continue; + } + + // Prefer the most specific assignable type when multiple serializers match. + if (matchedType == null || matchedType.isAssignableFrom(assignableType)) { + matchedType = assignableType; + matchedSerializer = entry.getValue(); + } + } + + return (Serializer) matchedSerializer; + + } + + public void registerBukkit(@NotNull Class clazz) { + String name = getBukkitName(clazz); + this.registerName(clazz, name); + this.register(clazz, new BukkitSerializer<>(clazz)); + } + + public void registerAssignable(@NotNull Class clazz, @NotNull Serializer serializer) { + this.assignable.put(clazz, serializer); + } + + private String getBukkitName(@NotNull Class clazz) { + return ConfigurationSerialization.getAlias(clazz); + } + +} diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java index 9c3dd24..952a7df 100644 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java +++ b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java @@ -4,6 +4,7 @@ import dev.spoocy.utils.common.exceptions.WrappedException; import dev.spoocy.utils.config.serializer.Serializer; import org.bukkit.Bukkit; +import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.io.BukkitObjectInputStream; @@ -32,16 +33,16 @@ private InventorySerializer() { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream); - dataOutput.writeInt(object.getSize()); - - for (int i = 0; i < object.getSize(); i++) { - dataOutput.writeObject(object.getItem(i)); - } - + dataOutput.writeObject(object.getContents()); dataOutput.close(); + String base64 = Base64Coder.encodeLines(outputStream.toByteArray()); - return Map.of("base64", base64); + return Map.of( + "type", object.getType().name(), + "size", object.getSize(), + "base64", base64 + ); } catch (Throwable e) { WrappedException.rethrow(e); } @@ -51,23 +52,48 @@ private InventorySerializer() { @Override public @NotNull Inventory deserialize(@NotNull Map map) { - if(!map.containsKey("base64")) { + if (!map.containsKey("type")) { + throw new IllegalArgumentException("Map does not contain key 'type'."); + } + if (!map.containsKey("size")) { + throw new IllegalArgumentException("Map does not contain key 'size'."); + } + if (!map.containsKey("base64")) { throw new IllegalArgumentException("Map does not contain key 'base64'."); } try { + String typeName = (String) map.get("type"); + int size = ((Number) map.get("size")).intValue(); String base64 = (String) map.get("base64"); - byte[] data = Base64Coder.decode(base64); - ByteArrayInputStream inputStream = new ByteArrayInputStream(data); + + InventoryType type = InventoryType.valueOf(typeName); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64Coder.decodeLines(base64)); BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream); - Inventory inventory = Bukkit.getServer().createInventory(null, dataInput.readInt()); + ItemStack[] contents = (ItemStack[]) dataInput.readObject(); + dataInput.close(); + + Inventory inventory; + if (type == InventoryType.CHEST) { + if (size <= 0 || size > 54 || size % 9 != 0) { + throw new IllegalArgumentException("Invalid chest inventory size: " + size); + } - for (int i = 0; i < inventory.getSize(); i++) { - inventory.setItem(i, (ItemStack) dataInput.readObject()); + inventory = Bukkit.createInventory(null, size); + } else { + inventory = Bukkit.createInventory(null, type); } - dataInput.close(); + + if (contents.length != inventory.getSize()) { + ItemStack[] resized = new ItemStack[inventory.getSize()]; + System.arraycopy(contents, 0, resized, 0, Math.min(contents.length, resized.length)); + contents = resized; + } + + inventory.setContents(contents); return inventory; - } catch (Throwable e) { + } catch (Exception e) { WrappedException.rethrow(e); } diff --git a/bukkit-utils/src/main/java/module-info.java b/bukkit-utils/src/main/java/module-info.java index c52d3b2..208d563 100644 --- a/bukkit-utils/src/main/java/module-info.java +++ b/bukkit-utils/src/main/java/module-info.java @@ -3,7 +3,7 @@ */ module dev.spoocy.utils.bukkit { - requires org.jetbrains.annotations; + requires static org.jetbrains.annotations; requires dev.spoocy.utils.common; requires dev.spoocy.utils.config; requires org.bukkit; diff --git a/common-utils/pom.xml b/common-utils/pom.xml index 4486957..8aa7e8e 100644 --- a/common-utils/pom.xml +++ b/common-utils/pom.xml @@ -7,7 +7,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 common-utils diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/collections/NormalizedArrayList.java b/common-utils/src/main/java/dev/spoocy/utils/common/collections/NormalizedArrayList.java index 646d276..7aa0336 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/collections/NormalizedArrayList.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/collections/NormalizedArrayList.java @@ -5,6 +5,8 @@ import java.util.function.UnaryOperator; /** + * A custom implementation of ArrayList that normalizes the index to be 1-based instead of 0-based. + * * @author Spoocy99 | GitHub: Spoocy99 */ diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArray.java b/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArray.java deleted file mode 100644 index 408fc44..0000000 --- a/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArray.java +++ /dev/null @@ -1,159 +0,0 @@ -package dev.spoocy.utils.common.collections; - -import org.jetbrains.annotations.NotNull; - -import java.util.*; -import java.util.Objects; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class SortedArray> implements Collection { - - private volatile List list; - - public SortedArray() { - this.list = new ArrayList<>(); - } - - public SortedArray(@NotNull Collection wrapped) { - this.list = new ArrayList<>(wrapped); - Collections.sort(list); - } - - public int size() { - return list.size(); - } - - @Override - public boolean isEmpty() { - return list.isEmpty(); - } - - @Override - public Iterator iterator() { - return list.iterator(); - } - - @Override - public boolean contains(@NotNull Object value) { - return list.contains(value); - } - - @Override - public synchronized boolean add(@NotNull T value) { - List copy = new ArrayList<>(); - boolean inserted = false; - - for (T element : list) { - if (!inserted && value.compareTo(element) < 0) { - copy.add(value); - inserted = true; - } - copy.add(element); - } - - if (!inserted) { - copy.add(value); - } - - list = copy; - return true; - } - - @Override - public synchronized boolean remove(@NotNull Object value) { - List copy = new ArrayList<>(); - boolean result = false; - - for (T element : list) { - if (!Objects.equals(value, element)) { - copy.add(element); - } else { - result = true; - } - } - - list = copy; - return result; - } - - @Override - public boolean containsAll(@NotNull Collection values) { - return new HashSet<>(list).containsAll(values); - } - - @Override - public synchronized boolean addAll(@NotNull Collection values) { - - if (values.isEmpty()) { - return false; - } - - List copy = new ArrayList<>(list); - - copy.addAll(values); - Collections.sort(copy); - - list = copy; - return true; - } - - @Override - public boolean removeAll(@NotNull Collection values) { - if (values.isEmpty()) { - return false; - } - - List copy = new ArrayList<>(list); - copy.removeAll(values); - - list = copy; - return true; - } - - @Override - public boolean retainAll(@NotNull Collection values) { - if (values.isEmpty()) return false; - - List copy = new ArrayList<>(list); - copy.removeAll(values); - - list = copy; - return true; - } - - @Override - public void clear() { - list = new ArrayList<>(); - } - - @Override - public Object[] toArray() { - return list.toArray(); - } - - @NotNull - @Override - public T1[] toArray(@NotNull T1[] a) { - return list.toArray(a); - } - - @Override - public String toString() { - return list.toString(); - } - - public T get(int index) { - return list.get(index); - } - - public synchronized void remove(int index) { - List copy = new ArrayList<>(this.list); - - copy.remove(index); - list = copy; - } - -} diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArrayList.java b/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArrayList.java new file mode 100644 index 0000000..a49565b --- /dev/null +++ b/common-utils/src/main/java/dev/spoocy/utils/common/collections/SortedArrayList.java @@ -0,0 +1,222 @@ +package dev.spoocy.utils.common.collections; + +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SortedArrayList> implements List { + + private volatile List list; + + private static > int findInsertIndex(@NotNull List values, @NotNull E value) { + int index = Collections.binarySearch(values, value); + return index >= 0 ? index : -index - 1; + } + + public SortedArrayList() { + this.list = new ArrayList<>(); + } + + public SortedArrayList(@NotNull Collection wrapped) { + this.list = new ArrayList<>(wrapped); + Collections.sort(list); + } + + @Override + public int size() { + return this.list.size(); + } + + @Override + public boolean isEmpty() { + return this.list.isEmpty(); + } + + @Override + public boolean contains(@NotNull Object value) { + return this.list.contains(value); + } + + @Override + public synchronized boolean add(@NotNull T value) { + List copy = new ArrayList<>(this.list); + copy.add(findInsertIndex(copy, value), value); + this.list = copy; + return true; + } + + @Override + public synchronized boolean remove(@NotNull Object value) { + List copy = new ArrayList<>(this.list); + int index = copy.indexOf(value); + + if (index < 0) { + return false; + } + + copy.remove(index); + this.list = copy; + return true; + } + + @Override + public boolean containsAll(@NotNull Collection values) { + return new HashSet<>(this.list).containsAll(values); + } + + @Override + public synchronized boolean addAll(@NotNull Collection values) { + + if (values.isEmpty()) { + return false; + } + + List copy = new ArrayList<>(list); + + copy.addAll(values); + Collections.sort(copy); + + list = copy; + return true; + } + + @Override + public synchronized boolean addAll(int index, @NotNull Collection c) { + if (index < 0 || index > this.list.size()) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + this.list.size()); + } + return this.addAll(c); + } + + @Override + public synchronized boolean removeAll(@NotNull Collection values) { + if (values.isEmpty()) { + return false; + } + + List copy = new ArrayList<>(this.list); + boolean changed = copy.removeAll(values); + if (!changed) { + return false; + } + + this.list = copy; + return true; + } + + @Override + public synchronized boolean retainAll(@NotNull Collection values) { + if (values.isEmpty() && this.list.isEmpty()) { + return false; + } + + List copy = new ArrayList<>(this.list); + boolean changed = copy.retainAll(values); + if (!changed) { + return false; + } + + this.list = copy; + return true; + } + + @Override + public synchronized void clear() { + this.list = new ArrayList<>(); + } + + @Override + public Object @NotNull [] toArray() { + return this.list.toArray(); + } + + @NotNull + @Override + public T1 @NotNull [] toArray(@NotNull T1[] a) { + return this.list.toArray(a); + } + + @Override + public String toString() { + return this.list.toString(); + } + + @Override + public T get(int index) { + return this.list.get(index); + } + + @Override + public T set(int index, T element) { + throw new UnsupportedOperationException("set(index, element) is not supported by SortedArrayList"); + } + + @Override + public void add(int index, T element) { + throw new UnsupportedOperationException("add(index, element) is not supported by SortedArrayList"); + } + + @Override + public synchronized T remove(int index) { + List copy = new ArrayList<>(this.list); + T removed = copy.remove(index); + this.list = copy; + return removed; + } + + @Override + public int indexOf(Object o) { + return this.list.indexOf(o); + } + + @Override + public int lastIndexOf(Object o) { + return this.list.lastIndexOf(o); + } + + @Override + public @NotNull ListIterator listIterator() { + List snapshot = List.copyOf(this.list); + return snapshot.listIterator(); + } + + @Override + public @NotNull ListIterator listIterator(int index) { + List snapshot = List.copyOf(this.list); + return snapshot.listIterator(index); + } + + @Override + public @NotNull List subList(int fromIndex, int toIndex) { + List snapshot = List.copyOf(this.list); + return snapshot.subList(fromIndex, toIndex); + } + + @Override + public @NotNull Iterator iterator() { + List snapshot = List.copyOf(this.list); + return snapshot.iterator(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof List)) { + return false; + } + List other = (List) o; + return this.list.equals(other); + } + + @Override + public int hashCode() { + return this.list.hashCode(); + } + +} diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/exceptions/WrappedException.java b/common-utils/src/main/java/dev/spoocy/utils/common/exceptions/WrappedException.java index 94b0851..38491c3 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/exceptions/WrappedException.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/exceptions/WrappedException.java @@ -1,23 +1,56 @@ package dev.spoocy.utils.common.exceptions; +import dev.spoocy.utils.common.misc.Args; import org.jetbrains.annotations.NotNull; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; + /** + * A wrapper exception that can be used to wrap checked exceptions in a runtime exception. + * * @author Spoocy99 | GitHub: Spoocy99 */ public class WrappedException extends RuntimeException { - public WrappedException(Throwable cause) { + private static final long serialVersionUID = 1L; + + public WrappedException(@NotNull Throwable cause) { super(cause); } - public WrappedException(String message, Throwable cause) { + public WrappedException(@NotNull String message, @NotNull Throwable cause) { super(message, cause); } - public static WrappedException wrap(@NotNull Throwable throwable) { - return new WrappedException(throwable); + public static RuntimeException wrap(@NotNull Throwable throwable) { + Throwable checked = Args.notNull(throwable, "throwable"); + + if (checked instanceof Error) { + throw (Error) checked; + } + + if (checked instanceof RuntimeException) { + return (RuntimeException) checked; + } + + if (checked instanceof IOException) { + return wrapIO((IOException) checked); + } + + return new WrappedException(checked); + } + + public static UncheckedIOException wrapIO(@NotNull IOException exception) { + return new UncheckedIOException(Args.notNull(exception, "exception")); + } + + @Override + public String getMessage() { + Throwable cause = this.getCause(); + return cause != null ? cause.getMessage() : super.getMessage(); } @Override @@ -26,14 +59,7 @@ public synchronized Throwable fillInStackTrace() { } public static void rethrow(@NotNull Throwable throwable) { - if (throwable instanceof Error) { - throw (Error) throwable; - } - - if (throwable instanceof RuntimeException) { - throw (RuntimeException) throwable; - } - - throw new WrappedException(throwable); + Throwable checked = Objects.requireNonNull(throwable, "throwable"); + throw wrap(checked); } } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/DefaultLoggerFactory.java b/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/DefaultLoggerFactory.java index 6776fdc..cbb940a 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/DefaultLoggerFactory.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/DefaultLoggerFactory.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** @@ -14,8 +15,8 @@ public abstract class DefaultLoggerFactory implements ILoggerFactory { + private final Map loggers = new ConcurrentHashMap<>(); private LogLevel level; - private final ConcurrentHashMap loggers = new ConcurrentHashMap<>(); public DefaultLoggerFactory() { this.level = LogLevel.DEFAULT_LEVEL; diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/Slf4jLoggerFactory.java b/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/Slf4jLoggerFactory.java index b82ab4d..07256b3 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/Slf4jLoggerFactory.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/log/factory/Slf4jLoggerFactory.java @@ -8,6 +8,7 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.LoggerFactory; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** @@ -16,14 +17,17 @@ public class Slf4jLoggerFactory implements ILoggerFactory { - protected final ConcurrentHashMap loggers = new ConcurrentHashMap<>(); + protected final Map loggers = new ConcurrentHashMap<>(); - public Slf4jLoggerFactory() { } + public Slf4jLoggerFactory() { + } @Override public @NotNull ILogger getOrCreateLogger(@Nullable String name) { - return loggers.computeIfAbsent(name, log -> new Slf4jLogger( - LoggerFactory.getLogger(check(name)) + final String finalName = check(name); + + return loggers.computeIfAbsent(finalName, log -> new Slf4jLogger( + LoggerFactory.getLogger(finalName) )); } @@ -33,7 +37,7 @@ public ILoggerFactory setLevel(@NotNull LogLevel level) { return this; } - private String check(String caller) { + private String check(@Nullable String caller) { return caller != null ? caller : "Logger"; } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/misc/Args.java b/common-utils/src/main/java/dev/spoocy/utils/common/misc/Args.java index 86f4eb3..01c2468 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/misc/Args.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/misc/Args.java @@ -1,5 +1,8 @@ package dev.spoocy.utils.common.misc; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.lang.reflect.Array; import java.util.Collection; import java.util.Map; @@ -357,6 +360,18 @@ public static boolean isEmpty(final Object object) { return false; } + public static void noNullElements(@Nullable T[] array, @NotNull String name) { + if (array == null) { + throw nullPointerException(name); + } + + for (int i = 0; i < array.length; i++) { + if (array[i] == null) { + throw illegalArgumentException("%s must not contain null elements, but element at index %d is null", name, i); + } + } + } + private static IllegalArgumentException illegalArgumentException(final String format, final Object... args) { return new IllegalArgumentException(String.format(format, args)); } @@ -369,8 +384,18 @@ private static NullPointerException nullPointerException(final String name) { return new NullPointerException(name + " must not be null"); } + public static T[] combineArgs(@NotNull T first, @NotNull T[] second) { + notNull(first, "first"); + notNull(second, "second"); + + @SuppressWarnings("unchecked") + T[] combined = (T[]) Array.newInstance(first.getClass(), second.length + 1); + combined[0] = first; + System.arraycopy(second, 0, combined, 1, second.length); + return combined; + } + private Args() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java b/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java index cfd0774..4eea2e8 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java @@ -7,13 +7,12 @@ import org.jetbrains.annotations.Nullable; import java.io.*; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; -import java.net.URLConnection; +import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.util.ArrayDeque; import java.util.Collections; +import java.util.Deque; import java.util.List; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -24,24 +23,29 @@ public final class FileUtils { - public static String getFileName(@NotNull File file) { - return getFileName(file.getName()); - } - - public static String getFileName(@NotNull Path path) { - return getFileName(path.toString()); - } + private static final char PACKAGE_SEPARATOR = '.'; + private static final char PATH_SEPARATOR = '/'; + private static final char FOLDER_SEPARATOR_CHAR = '/'; + private static final String FOLDER_SEPARATOR = String.valueOf(FOLDER_SEPARATOR_CHAR); + private static final char WINDOWS_FOLDER_SEPARATOR_CHAR = '\\'; + private static final String WINDOWS_FOLDER_SEPARATOR = String.valueOf(WINDOWS_FOLDER_SEPARATOR_CHAR); + private static final String DOUBLE_BACKSLASHES = "\\\\"; + private static final String CURRENT_PATH = "."; + private static final String TOP_PATH = ".."; + private static final String DOT_CHAR = "."; public static String getPath(@NotNull String path, char separator) { return path.replace(File.separatorChar, separator); } public static String getPath(@NotNull Path path, char separator) { - return path.toString().replace(File.separatorChar, separator); + return path.toString() + .replace(File.separatorChar, separator); } public static String getPath(@NotNull File file, char separator) { - return file.getPath().replace(File.separatorChar, separator); + return file.getPath() + .replace(File.separatorChar, separator); } public static String removePath(@NotNull String path, @NotNull String separator) { @@ -49,30 +53,43 @@ public static String removePath(@NotNull String path, @NotNull String separator) return path; } return StringUtils.getAfterLast(path, separator); - } + } - public static String removePath(@NotNull String path) { + public static String removePath(@NotNull String path) { return removePath(getPath(path, '/'), "/"); - } + } - public static String getFileName(@NotNull String path) { - path = removePath(path); - int extension = path.lastIndexOf('.'); - return extension == -1 ? path : path.substring(0, extension); - } + public static String getFileName(@NotNull File file, boolean withExtension) { + return getFileName(file.getName(), withExtension); + } - public static String getFileExtension(@NotNull File file) { - return getFileExtension(file.getName()); - } + public static String getFileName(@NotNull Path path, boolean withExtension) { + return getFileName(path.toString(), withExtension); + } - public static String getFileExtension(@NotNull Path path) { - return getFileExtension(path.toString()); - } + public static String getFileName(@NotNull String path, boolean withExtension) { + path = removePath(path); + + if (withExtension) { + return path; + } + + int extension = path.lastIndexOf('.'); + return extension == -1 ? path : path.substring(0, extension); + } + + public static String getFileExtension(@NotNull File file) { + return getFileExtension(file.getName()); + } - public static String getFileExtension(@NotNull String path) { + public static String getFileExtension(@NotNull Path path) { + return getFileExtension(path.toString()); + } + + public static String getFileExtension(@NotNull String path) { path = removePath(path); return StringUtils.getAfterLast(path, "."); - } + } public static InputStream createInputStream(@NotNull Path path) throws IOException { return Files.newInputStream(path); @@ -99,111 +116,117 @@ public static Writer createWriter(@NotNull File file) throws IOException { } public static void createDirectory(@NotNull Path path) throws IOException { - if (!Files.notExists(path)) return; + if (!Files.notExists(path)) return; Files.createDirectories(path); - } + } public static void createFile(@NotNull File file) throws IOException { - if (file.exists()) return; + if (file.exists()) return; - if (file.isDirectory()) { - file.mkdirs(); - } + if (file.isDirectory()) { + file.mkdirs(); + return; + } - file.createNewFile(); - } + file.createNewFile(); + } - public static void createFile(@NotNull Path path) throws IOException { - if (!Files.notExists(path)) return; + public static void createFile(@NotNull Path path) throws IOException { + if (!Files.notExists(path)) return; if (path.getParent() != null) { Files.createDirectories(path.getParent()); } Files.createFile(path); - } + } - public static void deleteFile(@NotNull Path file) throws IOException { + public static void deleteFile(@NotNull Path file) throws IOException { Files.deleteIfExists(file); - } + } public static void copy(@NotNull Path from, @NotNull Path to) throws IOException { - copy(from, to, new byte[8192]); - } + copy(from, to, new byte[8192]); + } - public static void copy(@NotNull Path from, @NotNull Path to, byte @NotNull [] buffer) throws IOException { - if (Files.notExists(to)) { - createDirectory(to.getParent()); - } + public static void copy(@NotNull Path from, @NotNull Path to, byte @NotNull [] buffer) throws IOException { + if (Files.notExists(to)) { + createDirectory(to.getParent()); + } InputStream stream = Files.newInputStream(from); OutputStream target = Files.newOutputStream(to); copy(stream, target, buffer); - } + } + + public static void copy(byte[] input, @NotNull OutputStream output) throws IOException { + output.write(input); + output.flush(); + } public static void copy(@NotNull InputStream input, @NotNull OutputStream output) throws IOException { - copy(input, output, new byte[8192]); - } + copy(input, output, new byte[8192]); + } - public static void copy(@NotNull InputStream input, @NotNull OutputStream output, byte[] buffer) throws IOException { - copy(input, output, buffer, null); - } + public static void copy(@NotNull InputStream input, @NotNull OutputStream output, byte[] buffer) + throws IOException { + copy(input, output, buffer, null); + } - public static void copy(@NotNull InputStream inputStream, @NotNull OutputStream outputStream, byte[] buffer, @Nullable Consumer lengthInputListener) throws IOException { - int len; - while ((len = inputStream.read(buffer, 0, buffer.length)) != -1) { + public static void copy( + @NotNull InputStream inputStream, + @NotNull OutputStream outputStream, + byte[] buffer, + @Nullable Consumer lengthInputListener + ) throws IOException { + int len; + while ((len = inputStream.read(buffer, 0, buffer.length)) != -1) { - if (lengthInputListener != null) { - lengthInputListener.accept(len); - } + if (lengthInputListener != null) { + lengthInputListener.accept(len); + } - outputStream.write(buffer, 0, len); - outputStream.flush(); - } + outputStream.write(buffer, 0, len); + outputStream.flush(); + } - } + } /** * Saves the input stream to the given path. * - * @param path the path to save the file to - * @param in the input stream to save + * @param path the path to save the file to + * @param in the input stream to save * @param replace whether to replace the file if it already exists + * * @return true if the file was saved / overwritten successfully, false otherwise * * @throws IOException if an error occurs while saving the file */ - public static boolean save(@NotNull String path, @NotNull InputStream in, boolean replace) throws IOException { - File outFile = new File(path); - int lastIndex = path.lastIndexOf(47); - - File outDir = new File(path.substring(0, lastIndex >= 0 ? lastIndex : 0)); - if (!outDir.exists()) { - outDir.mkdirs(); - } - - if (outFile.exists() && !replace) { - return false; + public static boolean save(@NotNull Path path, @NotNull InputStream in, boolean replace) throws IOException { + if (Files.exists(path)) { + if (!replace) { + return false; + } + deleteFile(path); } - OutputStream out = createOutputStream(outFile); - byte[] buf = new byte[1024]; - - int len; - while((len = in.read(buf)) > 0) { - out.write(buf, 0, len); + createDirectory(path.getParent()); + try (OutputStream out = Files.newOutputStream(path)) { + copy(in, out); } - out.close(); - in.close(); return true; } - public static String getJarFile(Class clazz) { - return clazz.getProtectionDomain().getCodeSource().getLocation().getFile(); + public static String getJarFile(@NotNull Class clazz) { + return clazz.getProtectionDomain() + .getCodeSource() + .getLocation() + .getFile(); } - public static String getJarPath(Class clazz) { + public static String getJarPath(@NotNull Class clazz) { String jarPath = null; try { @@ -214,32 +237,18 @@ public static String getJarPath(Class clazz) { .getPath() .replaceAll(" ", "%20"); } catch (URISyntaxException e) { - ILogger.forThisClass().error("Failed to get jar path. ", e); + ILogger.forThisClass() + .error("Failed to get jar path. ", e); } return "jar:file:" + jarPath; } - public static List getResources(Class loader, String directory) throws IOException { - List result; - - String jarPath = getJarPath(loader); - URI uri = URI.create(jarPath); - - try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) { - result = Files.walk(fs.getPath(directory)) - .filter(Files::isRegularFile) - .collect(Collectors.toList()); - } - - return result; - - } - @Nullable public static InputStream getResource(@NotNull Class loader, @NotNull String resourcePath) { try { - URL url = loader.getClassLoader().getResource(resourcePath); + URL url = loader.getClassLoader() + .getResource(resourcePath); if (url == null) { return null; @@ -253,8 +262,28 @@ public static InputStream getResource(@NotNull Class loader, @NotNull String } } - public static void saveResource(@NotNull Class loader, @NotNull String resourcePath, @NotNull String path, boolean replace) { - if(resourcePath == null || resourcePath.isEmpty()) { + public static List listResources(@NotNull Class loader, @NotNull String directory) throws IOException { + List result; + + String jarPath = getJarPath(loader); + URI uri = URI.create(jarPath); + + try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) { + result = Files.walk(fs.getPath(directory)) + .filter(Files::isRegularFile) + .collect(Collectors.toList()); + } + + return result; + } + + public static void saveResource( + @NotNull Class loader, + @NotNull String resourcePath, + @NotNull String path, + boolean replace + ) throws IOException { + if (resourcePath.isEmpty()) { throw new IllegalArgumentException("ResourcePath cannot be null or empty"); } @@ -265,11 +294,179 @@ public static void saveResource(@NotNull Class loader, @NotNull String resour throw new IllegalArgumentException("The embedded resource '" + resourcePath + "' cannot be found."); } + save(Path.of(path), in, replace); + } + + public static void createParentDirs(@NotNull File file) { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + } + + public static byte[] copyToByteArray(@NotNull InputStream inputStream) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + copy(inputStream, output); + return output.toByteArray(); + } + + public static String copyToString(@NotNull InputStreamReader inputStreamReader) throws IOException { + StringBuilder sb = new StringBuilder(); + char[] buffer = new char[8192]; + int read; + + while ((read = inputStreamReader.read(buffer)) != -1) { + sb.append(buffer, 0, read); + } + + return sb.toString(); + } + + public static URI toURI(@NotNull String path) throws URISyntaxException { + return new URI(cleanPath(path)); + } + + public static URL toURL(@NotNull String path) throws MalformedURLException { try { - save(path, in, replace); - } catch (IOException e) { - throw WrappedException.wrap(e); + return toURI(cleanPath(path)).toURL(); + } catch (URISyntaxException | IllegalArgumentException ex) { + return new URL(path); } } + public static boolean isFileURL(@NotNull URL url) { + return url.getProtocol() + .equals("file"); + } + + public static boolean isJarURL(@NotNull URL url) { + return url.getProtocol() + .equals("jar"); + } + + public static String applyRelativePath(@NotNull String path, @NotNull String relativePath) { + int separatorIndex = path.lastIndexOf(FOLDER_SEPARATOR_CHAR); + + if (separatorIndex != -1) { + String newPath = path.substring(0, separatorIndex); + if (!relativePath.startsWith(FOLDER_SEPARATOR)) { + newPath += FOLDER_SEPARATOR_CHAR; + } + + return newPath + relativePath; + } + + return relativePath; + } + + public static URL toRelativeURL(URL root, String relativePath) throws MalformedURLException { + relativePath = StringUtils.replace(relativePath, "#", "%23"); + return new URL(root, FileUtils.cleanPath(FileUtils.applyRelativePath(root.toString(), relativePath))); + } + + public static String classPackageAsResourcePath(@Nullable Class clazz) { + if (clazz == null) { + return ""; + } + String className = clazz.getName(); + + int packageEndIndex = className.lastIndexOf(PACKAGE_SEPARATOR); + if (packageEndIndex == -1) { + return ""; + } + + String packageName = className.substring(0, packageEndIndex); + return packageName.replace(PACKAGE_SEPARATOR, PATH_SEPARATOR); + } + + public static String cleanPath(@NotNull String path) { + if (StringUtils.isNullOrEmpty(path)) { + return path; + } + + String normalizedPath; + + if (path.indexOf(WINDOWS_FOLDER_SEPARATOR_CHAR) != -1) { + normalizedPath = StringUtils.replace(path, DOUBLE_BACKSLASHES, FOLDER_SEPARATOR); + normalizedPath = StringUtils.replace(normalizedPath, WINDOWS_FOLDER_SEPARATOR, FOLDER_SEPARATOR); + } else { + normalizedPath = path; + } + + + String pathToUse = normalizedPath; + + // path doesn't contain "." or ".." so skip + if (!pathToUse.contains(DOT_CHAR)) { + return pathToUse; + } + + // We need to parse the path element by element, and deal with special folders based on their names + int prefixIndex = pathToUse.indexOf(':'); + String prefix = ""; + + if (prefixIndex != -1) { + prefix = pathToUse.substring(0, prefixIndex + 1); + + if (prefix.contains(FOLDER_SEPARATOR)) { + prefix = ""; + } else { + pathToUse = pathToUse.substring(prefixIndex + 1); + } + + } + + if (pathToUse.startsWith(FOLDER_SEPARATOR)) { + prefix = prefix + FOLDER_SEPARATOR; + pathToUse = pathToUse.substring(1); + } + + String[] pathArray = StringUtils.tokenizeToStringArray(pathToUse, FOLDER_SEPARATOR); + + // we never require more elements than pathArray and in the common case the same number + Deque pathElements = new ArrayDeque<>(pathArray.length); + int tops = 0; + + for (int i = pathArray.length - 1; i >= 0; i--) { + String element = pathArray[i]; + + if (CURRENT_PATH.equals(element)) { + continue; + } + + if (TOP_PATH.equals(element)) { + // Registering top path found + tops++; + continue; + } + + + if (tops > 0) { + // Merging path element with registered top path + tops--; + continue; + } + // Normal path element found + pathElements.addFirst(element); + } + + // If nothing needs to be retained, return the normalized path + if (pathArray.length == pathElements.size()) { + return normalizedPath; + } + + // Remaining top path need to be retained. Adding such number of top paths to the start of the path + for (int i = 0; i < tops; i++) { + pathElements.addFirst(TOP_PATH); + } + + // If the path is now empty, there was only references to current path + if (pathElements.size() == 1 && pathElements.getLast() + .isEmpty() && !prefix.endsWith(FOLDER_SEPARATOR)) { + pathElements.addFirst(CURRENT_PATH); + } + + String joined = StringUtils.collectionToDelimitedString(pathElements, FOLDER_SEPARATOR); + return (prefix.isEmpty() ? joined : prefix + joined); + } } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/misc/Reference.java b/common-utils/src/main/java/dev/spoocy/utils/common/misc/Reference.java index a6a0684..bc42691 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/misc/Reference.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/misc/Reference.java @@ -6,6 +6,9 @@ import java.util.function.Supplier; /** + * A simple wrapper class that can hold a value or a supplier for lazy initialization. + * The value is only computed when the get() method is called for the first time, and then cached for subsequent calls. + * * @author Spoocy99 | GitHub: Spoocy99 */ diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/IScheduler.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/IScheduler.java deleted file mode 100644 index e0a2c46..0000000 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/IScheduler.java +++ /dev/null @@ -1,117 +0,0 @@ -package dev.spoocy.utils.common.scheduler; - -import dev.spoocy.utils.common.scheduler.task.Task; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.Callable; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public interface IScheduler { - - /** - * Executes the given runnable synchronously. - * - * @param runnable the runnable to execute - */ - void executeSync(@NotNull Runnable runnable); - - /** - * Executes the given runnable asynchronously. - * - * @param runnable the runnable to execute - */ - void executeAsync(@NotNull Runnable runnable); - - /** - * Schedules a runnable to be executed synchronously. - * - * @param runnable the runnable to execute - * @param the type of the value returned by the runnable - * @return the task representing the runnable - */ - Task runSync(@NotNull Runnable runnable); - - /** - * Schedules a runnable to be executed asynchronously. - * - * @param runnable the runnable to execute - * @param the type of the value returned by the runnable - * @return the task representing the runnable - */ - Task runAsync(@NotNull Runnable runnable); - - /** - * Schedules a runnable to be executed asynchronously after a delay. - * - * @param runnable the runnable to execute - * @param delay the delay before execution - * @param unit the unit of the delay - * @param the type of the value returned by the runnable - * @return the task representing the runnable - */ - Task runDelayed(@NotNull Runnable runnable, long delay, @NotNull TimeUnit unit); - - /** - * Schedules a callable to be executed synchronously. - * - * @param callable the callable to execute - * @param the type of the value returned by the callable - * @return the task representing the callable - */ - Task runSyncCallable(@NotNull Callable callable); - - /** - * Schedules a callable to be executed asynchronously. - * - * @param callable the callable to execute - * @param the type of the value returned by the callable - * @return the task representing the callable - */ - Task runAsyncCallable(@NotNull Callable callable); - - /** - * Schedules a callable to be executed asynchronously after a delay. - * - * @param callable the callable to execute - * @param delay the delay before execution - * @param unit the unit of the delay - * @param the type of the value returned by the callable - * @return the task representing the callable - */ - Task runDelayedCallable(@NotNull Callable callable, long delay, @NotNull TimeUnit unit); - - /** - * Schedules a supplier to be executed synchronously. - * - * @param supplier the supplier to execute - * @param the type of the value returned by the supplier - * @return the task representing the supplier - */ - Task runSyncSupplier(@NotNull Supplier supplier); - - /** - * Schedules a supplier to be executed asynchronously. - * - * @param supplier the supplier to execute - * @param the type of the value returned by the supplier - * @return the task representing the supplier - */ - Task runAsyncSupplier(@NotNull Supplier supplier); - - /** - * Schedules a supplier to be executed asynchronously after a delay. - * - * @param supplier the supplier to execute - * @param delay the delay before execution - * @param unit the unit of the delay - * @param the type of the value returned by the supplier - * @return the task representing the supplier - */ - Task runDelayedSupplier(@NotNull Supplier supplier, long delay, @NotNull TimeUnit unit); - -} diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java index de593c8..fa86461 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java @@ -1,6 +1,6 @@ package dev.spoocy.utils.common.scheduler; -import dev.spoocy.utils.common.scheduler.task.ScheduledTask; +import dev.spoocy.utils.common.scheduler.task.CompletableTask; import dev.spoocy.utils.common.scheduler.task.Task; import org.jetbrains.annotations.NotNull; @@ -11,10 +11,20 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public class JavaScheduler implements IScheduler { +public class JavaScheduler implements Scheduler, AutoCloseable { - private static final ExecutorService EXECUTOR_SERVICE = Executors.newCachedThreadPool(); - private static final ScheduledExecutorService SCHEDULED_EXECUTOR_SERVICE = Executors.newScheduledThreadPool(1); + private final ExecutorService executorService; + private final ScheduledExecutorService scheduledExecutorService; + private volatile boolean shutdown = false; + + public JavaScheduler(int corePoolSize) { + this(Executors.newCachedThreadPool(), Executors.newScheduledThreadPool(corePoolSize)); + } + + public JavaScheduler(ExecutorService executorService, ScheduledExecutorService scheduledExecutorService) { + this.executorService = executorService; + this.scheduledExecutorService = scheduledExecutorService; + } @Override public void executeSync(@NotNull Runnable runnable) { @@ -23,7 +33,8 @@ public void executeSync(@NotNull Runnable runnable) { @Override public void executeAsync(@NotNull Runnable runnable) { - EXECUTOR_SERVICE.execute(runnable); + checkShutdown(); + executorService.execute(runnable); } @Override @@ -81,7 +92,7 @@ public Task runDelayedSupplier(@NotNull Supplier supplier, long delay, } private Task callSync(@NotNull Callable callable) { - Task task = ScheduledTask.create(); + CompletableTask task = CompletableTask.empty(); try { task.complete(callable.call()); @@ -93,23 +104,25 @@ private Task callSync(@NotNull Callable callable) { } private Task callAsync(@NotNull Callable callable) { - Task task = ScheduledTask.create(); + checkShutdown(); + CompletableTask task = CompletableTask.empty(); - EXECUTOR_SERVICE.execute( ()-> { - try { - task.complete(callable.call()); - } catch (Throwable ex) { - task.fail(ex); - } - }); + executorService.execute(() -> { + try { + task.complete(callable.call()); + } catch (Throwable ex) { + task.fail(ex); + } + }); - return task; + return task; } private Task callAsyncDelayed(@NotNull Callable callable, long delay, TimeUnit unit) { - Task task = ScheduledTask.create(); + checkShutdown(); + CompletableTask task = CompletableTask.empty(); - SCHEDULED_EXECUTOR_SERVICE.schedule(() -> { + scheduledExecutorService.schedule(() -> { try { task.complete(callable.call()); } catch (Throwable ex) { @@ -120,4 +133,44 @@ private Task callAsyncDelayed(@NotNull Callable callable, long delay, return task; } + private void checkShutdown() { + if (shutdown) { + throw new IllegalStateException("JavaScheduler has been shut down"); + } + } + + @Override + public void close() throws Exception { + if (shutdown) { + return; + } + + shutdown = true; + + // Shutdown executor service + executorService.shutdown(); + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + + // Shutdown scheduled executor service + scheduledExecutorService.shutdown(); + if (!scheduledExecutorService.awaitTermination(10, TimeUnit.SECONDS)) { + scheduledExecutorService.shutdownNow(); + } + } + + /** + * Shuts down the scheduler immediately without waiting for termination. + * Useful when graceful shutdown is not required. + */ + public void shutdown() { + if (shutdown) { + return; + } + + shutdown = true; + executorService.shutdownNow(); + scheduledExecutorService.shutdownNow(); + } } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Scheduler.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Scheduler.java index 35d96b1..0999e7e 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Scheduler.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Scheduler.java @@ -1,12 +1,9 @@ package dev.spoocy.utils.common.scheduler; -import dev.spoocy.utils.common.log.ILogger; import dev.spoocy.utils.common.scheduler.task.Task; import org.jetbrains.annotations.NotNull; import java.util.concurrent.Callable; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @@ -14,69 +11,107 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public class Scheduler { +public interface Scheduler { + + /** + * Executes the given runnable synchronously. + * + * @param runnable the runnable to execute + */ + void executeSync(@NotNull Runnable runnable); + + /** + * Executes the given runnable asynchronously. + * + * @param runnable the runnable to execute + */ + void executeAsync(@NotNull Runnable runnable); + + /** + * Schedules a runnable to be executed synchronously. + * + * @param runnable the runnable to execute + * @param the type of the value returned by the runnable + * @return the task representing the runnable + */ + Task runSync(@NotNull Runnable runnable); + + /** + * Schedules a runnable to be executed asynchronously. + * + * @param runnable the runnable to execute + * @param the type of the value returned by the runnable + * @return the task representing the runnable + */ + Task runAsync(@NotNull Runnable runnable); + + /** + * Schedules a runnable to be executed asynchronously after a delay. + * + * @param runnable the runnable to execute + * @param delay the delay before execution + * @param unit the unit of the delay + * @param the type of the value returned by the runnable + * @return the task representing the runnable + */ + Task runDelayed(@NotNull Runnable runnable, long delay, @NotNull TimeUnit unit); + + /** + * Schedules a callable to be executed synchronously. + * + * @param callable the callable to execute + * @param the type of the value returned by the callable + * @return the task representing the callable + */ + Task runSyncCallable(@NotNull Callable callable); + + /** + * Schedules a callable to be executed asynchronously. + * + * @param callable the callable to execute + * @param the type of the value returned by the callable + * @return the task representing the callable + */ + Task runAsyncCallable(@NotNull Callable callable); + + /** + * Schedules a callable to be executed asynchronously after a delay. + * + * @param callable the callable to execute + * @param delay the delay before execution + * @param unit the unit of the delay + * @param the type of the value returned by the callable + * @return the task representing the callable + */ + Task runDelayedCallable(@NotNull Callable callable, long delay, @NotNull TimeUnit unit); + + /** + * Schedules a supplier to be executed synchronously. + * + * @param supplier the supplier to execute + * @param the type of the value returned by the supplier + * @return the task representing the supplier + */ + Task runSyncSupplier(@NotNull Supplier supplier); + + /** + * Schedules a supplier to be executed asynchronously. + * + * @param supplier the supplier to execute + * @param the type of the value returned by the supplier + * @return the task representing the supplier + */ + Task runAsyncSupplier(@NotNull Supplier supplier); + + /** + * Schedules a supplier to be executed asynchronously after a delay. + * + * @param supplier the supplier to execute + * @param delay the delay before execution + * @param unit the unit of the delay + * @param the type of the value returned by the supplier + * @return the task representing the supplier + */ + Task runDelayedSupplier(@NotNull Supplier supplier, long delay, @NotNull TimeUnit unit); - private static IScheduler scheduler; - private static final ILogger logger = ILogger.forThisClass(); - - private static IScheduler getScheduler() { - if (scheduler == null) { - logger.debug("Scheduler is not set, using JavaScheduler."); - setScheduler(new JavaScheduler()); - } - return scheduler; - } - - public static void setScheduler(IScheduler scheduler) { - Scheduler.scheduler = scheduler; - } - - public static void executeSync(Runnable runnable) { - getScheduler().executeSync(runnable); - } - - public static void executeAsync(Runnable runnable) { - getScheduler().executeAsync(runnable); - } - - public static Task runSync(Runnable runnable) { - return getScheduler().runSync(runnable); - } - - public static Task runAsync(Runnable runnable) { - return getScheduler().runAsync(runnable); - } - - public static Task runDelayed(Runnable runnable, long delay, TimeUnit unit) { - return getScheduler().runDelayed(runnable, delay, unit); - } - - public static Task runSyncCallable(Callable callable) { - return getScheduler().runSyncCallable(callable); - } - - public static Task runAsyncCallable(Callable callable) { - return getScheduler().runAsyncCallable(callable); - } - - public static Task runDelayedCallable(Callable callable, long delay, TimeUnit unit) { - return getScheduler().runDelayedCallable(callable, delay, unit); - } - - public static Task callSyncSupplier(Supplier supplier) { - return getScheduler().runSyncSupplier(supplier); - } - - public static Task callAsyncSupplier(Supplier supplier) { - return getScheduler().runAsyncSupplier(supplier); - } - - public static Task callDelayedSupplier(Supplier supplier, long delay, TimeUnit unit) { - return getScheduler().runDelayedSupplier(supplier, delay, unit); - } - - @NotNull - public static ScheduledExecutorService newScheduledThreadPool(int i) { - return Executors.newScheduledThreadPool(i); - } } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Schedulers.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Schedulers.java new file mode 100644 index 0000000..639c48b --- /dev/null +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/Schedulers.java @@ -0,0 +1,36 @@ +package dev.spoocy.utils.common.scheduler; + +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Factory and helper methods for creating scheduler instances. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public final class Schedulers { + + private Schedulers() { + throw new UnsupportedOperationException("This utility class cannot be instantiated"); + } + + @NotNull + public static Scheduler javaScheduler(int corePoolSize) { + return new JavaScheduler(corePoolSize); + } + + @NotNull + public static Scheduler javaScheduler(@NotNull ExecutorService executorService, + @NotNull ScheduledExecutorService scheduledExecutorService) { + return new JavaScheduler(executorService, scheduledExecutorService); + } + + @NotNull + public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { + return Executors.newScheduledThreadPool(corePoolSize); + } +} + diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/ScheduledTask.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/CompletableTask.java similarity index 93% rename from common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/ScheduledTask.java rename to common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/CompletableTask.java index 554bc48..6d959de 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/ScheduledTask.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/CompletableTask.java @@ -12,17 +12,17 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public class ScheduledTask implements Task, Callable, Future { +public class CompletableTask implements Task, Callable, Future { - public static ScheduledTask create() { - return new ScheduledTask<>(); + public static CompletableTask empty() { + return new CompletableTask<>(); } private final List> results; private final CompletableFuture future; private Throwable exceptionWhileExecution; - private ScheduledTask() { + private CompletableTask() { this.future = new CompletableFuture<>(); this.results = new CopyOnWriteArrayList<>(); } @@ -127,7 +127,6 @@ public void onException(@NotNull Task task, Throwable throwable) { }); } - @Override public void complete(@Nullable V value) { future.complete(value); @@ -139,7 +138,6 @@ public void complete(@Nullable V value) { results.forEach(result -> result.onSuccess(this, value)); } - @Override public void fail(@NotNull Throwable throwable) { this.exceptionWhileExecution = throwable; this.future.completeExceptionally(throwable); @@ -160,12 +158,12 @@ public boolean cancel(boolean mayInterruptIfRunning) { return false; } - public ScheduledTask clearResults() { + public CompletableTask clearResults() { this.results.clear(); return this; } - public ScheduledTask addResult(@NotNull TaskResult result) { + public CompletableTask addResult(@NotNull TaskResult result) { check(result); results.add(result); return this; diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/Task.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/Task.java index 6266944..4d6dc15 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/Task.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/task/Task.java @@ -1,12 +1,17 @@ package dev.spoocy.utils.common.scheduler.task; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.function.BiConsumer; import java.util.function.Consumer; /** + * Represents a unit of work that can be executed, tracked, and monitored for its completion status. + * This interface provides methods to check the task's state (e.g., completed, cancelled, or failed) + * and to attach callback mechanisms for responding to various outcomes of the task execution. + * + * @param the type of the result produced by the task + * * @author Spoocy99 | GitHub: Spoocy99 */ @@ -40,7 +45,7 @@ public interface Task { * * @return this task for chaining */ - Task onSuccess(Runnable runnable); + Task onSuccess(@NotNull Runnable runnable); /** * Executes the given consumer when the task is successfully completed. @@ -67,7 +72,7 @@ public interface Task { * * @return this task for chaining */ - Task onCancelled(Runnable runnable); + Task onCancelled(@NotNull Runnable runnable); /** * Executes the given consumer when the task is cancelled. @@ -76,7 +81,7 @@ public interface Task { * * @return this task for chaining */ - Task onCancelled(@NotNull Consumer> consumer); + Task onCancelled(@NotNull Consumer> consumer); /** * Executes the given runnable when the task is cancelled. @@ -85,7 +90,7 @@ public interface Task { * * @return this task for chaining */ - Task onException(Runnable runnable); + Task onException(@NotNull Runnable runnable); /** * Executes the given consumer when the task fails to complete. @@ -105,27 +110,4 @@ public interface Task { */ Task onException(@NotNull BiConsumer, ? super Throwable> consumer); - /** - * Completes the task with the given value. - * - * @param value the value to complete the task with - */ - void complete(@Nullable V value); - - /** - * Fails the task with the given exception. - * - * @param throwable the exception to fail the task with - */ - void fail(@NotNull Throwable throwable); - - /** - * Cancels the task. - * - * @param mayInterruptIfRunning whether the task should be interrupted if it is running - * - * @return {@code true} if the task is now cancelled, {@code false} otherwise - */ - boolean cancel(boolean mayInterruptIfRunning); - } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/text/StringUtils.java b/common-utils/src/main/java/dev/spoocy/utils/common/text/StringUtils.java index 65bfbb7..70e2b23 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/text/StringUtils.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/text/StringUtils.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Deque; import java.util.List; import java.util.Optional; @@ -16,6 +17,14 @@ public static boolean isNullOrEmpty(@Nullable String s) { return s == null || s.isEmpty(); } + public static boolean isNullOrEmpty(@Nullable CharSequence s) { + return s == null || s.length() == 0; + } + + public static boolean isBlank(@Nullable String s) { + return s == null || s.trim().isEmpty(); + } + public static String[] splitToLines(@NotNull String s) { return s.split("\n"); } @@ -47,19 +56,65 @@ public static String getAfterLast(@NotNull String s, @NotNull String separator) .orElse(""); } - public static String join(@NotNull List strings, @NotNull String separator) { - return join(strings.toArray(new String[0]), separator); - } + public static String nullSafe(@Nullable String nullable, @NotNull String replacement) { + return nullable == null ? replacement : nullable; + } + + public static String replace(@Nullable String input, @Nullable String pattern, @Nullable String replacement) { + if (!isNullOrEmpty(input) || !isNullOrEmpty(pattern) || replacement == null) { + return input; + } + int index = input.indexOf(pattern); + + if (index == -1) { + return input; + } - public static String join(@NotNull String[] strings, @NotNull String separator) { - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < strings.length; i++) { - builder.append(strings[i]); - if (i < strings.length - 1) { - builder.append(separator); - } + int capacity = input.length(); + if (replacement.length() > pattern.length()) { + capacity += 16; } - return builder.toString(); + + StringBuilder sb = new StringBuilder(capacity); + + int pos = 0; + int patLen = pattern.length(); + + while (index >= 0) { + sb.append(input, pos, index); + sb.append(replacement); + pos = index + patLen; + index = input.indexOf(pattern, pos); + } + + sb.append(input, pos, input.length()); + return sb.toString(); } + public static String[] tokenizeToStringArray(@Nullable String input, @NotNull String token) { + if (isNullOrEmpty(input)) { + return new String[0]; + } + + String[] tokens = input.split(token); + List result = new java.util.ArrayList<>(); + for (String t : tokens) { + if (!isNullOrEmpty(t)) { + result.add(t); + } + } + + return result.toArray(new String[0]); + } + + public static String collectionToDelimitedString(@NotNull Deque elements, @NotNull String appender) { + StringBuilder sb = new StringBuilder(); + for (String element : elements) { + sb.append(element).append(appender); + } + if (sb.length() > 0) { + sb.setLength(sb.length() - appender.length()); + } + return sb.toString(); + } } diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/version/Version.java b/common-utils/src/main/java/dev/spoocy/utils/common/version/Version.java index 02bd2c6..0316886 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/version/Version.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/version/Version.java @@ -15,6 +15,8 @@ public interface Version extends Comparable, Serializable { + Version ZERO = new SimpleVersion(0, 0, 0, null, null); + /** * @return The major version */ diff --git a/common-utils/src/main/java/module-info.java b/common-utils/src/main/java/module-info.java index 5ae097b..029e72f 100644 --- a/common-utils/src/main/java/module-info.java +++ b/common-utils/src/main/java/module-info.java @@ -3,8 +3,8 @@ */ module dev.spoocy.utils.common { - requires org.jetbrains.annotations; - requires org.slf4j; + requires static org.jetbrains.annotations; + requires static org.slf4j; requires java.logging; exports dev.spoocy.utils.common.cache; diff --git a/common-utils/src/test/java/dev/spoocy/utils/common/collections/SortedArrayListTest.java b/common-utils/src/test/java/dev/spoocy/utils/common/collections/SortedArrayListTest.java new file mode 100644 index 0000000..a645677 --- /dev/null +++ b/common-utils/src/test/java/dev/spoocy/utils/common/collections/SortedArrayListTest.java @@ -0,0 +1,79 @@ +package dev.spoocy.utils.common.collections; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.*; + +public class SortedArrayListTest { + + @Test + public void addKeepsElementsSorted() { + SortedArrayList list = new SortedArrayList<>(); + + list.add(5); + list.add(1); + list.add(3); + + assertEquals(Arrays.asList(1, 3, 5), list); + } + + @Test + public void removeObjectRemovesOnlyFirstMatch() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(1, 2, 2, 3)); + + boolean removed = list.remove(Integer.valueOf(2)); + + assertTrue(removed); + assertEquals(Arrays.asList(1, 2, 3), list); + } + + @Test + public void removeByIndexReturnsRemovedElement() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(1, 2, 3)); + + Integer removed = list.remove(2); + + assertEquals(3, removed); + assertEquals(Arrays.asList(1, 2), list); + } + + @Test + public void retainAllRetainsMatchingElements() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(1, 2, 3, 4)); + + boolean changed = list.retainAll(Arrays.asList(2, 4, 8)); + + assertTrue(changed); + assertEquals(Arrays.asList(2, 4), list); + } + + @Test + public void positionalMutatorsAreUnsupported() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(1, 2, 3)); + + assertThrows(UnsupportedOperationException.class, () -> list.set(1, 10)); + assertThrows(UnsupportedOperationException.class, () -> list.add(1, 10)); + } + + @Test + public void iteratorIsReadOnlySnapshot() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(1, 2, 3)); + Iterator iterator = list.iterator(); + + assertEquals(1, iterator.next()); + assertThrows(UnsupportedOperationException.class, iterator::remove); + } + + @Test + public void addAllAtIndexValidatesBoundsAndKeepsSortedOrder() { + SortedArrayList list = new SortedArrayList<>(Arrays.asList(2, 4)); + + assertTrue(list.addAll(1, Arrays.asList(3, 1))); + assertEquals(Arrays.asList(1, 2, 3, 4), list); + assertThrows(IndexOutOfBoundsException.class, () -> list.addAll(9, Arrays.asList(5))); + } +} + diff --git a/common-utils/src/test/java/dev/spoocy/utils/common/exceptions/WrappedExceptionTest.java b/common-utils/src/test/java/dev/spoocy/utils/common/exceptions/WrappedExceptionTest.java new file mode 100644 index 0000000..0d7a539 --- /dev/null +++ b/common-utils/src/test/java/dev/spoocy/utils/common/exceptions/WrappedExceptionTest.java @@ -0,0 +1,59 @@ +package dev.spoocy.utils.common.exceptions; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WrappedExceptionTest { + + @Test + void wrapKeepsRuntimeExceptionsUntouched() { + RuntimeException runtimeException = new IllegalStateException("boom"); + + RuntimeException wrapped = WrappedException.wrap(runtimeException); + + assertSame(runtimeException, wrapped); + } + + @Test + void wrapConvertsIOExceptionToUncheckedIOException() { + IOException ioException = new IOException("io"); + + RuntimeException wrapped = WrappedException.wrap(ioException); + + assertSame(UncheckedIOException.class, wrapped.getClass()); + assertSame(ioException, wrapped.getCause()); + } + + @Test + void wrapRethrowsErrors() { + AssertionError error = new AssertionError("fatal"); + + AssertionError thrown = assertThrows(AssertionError.class, () -> WrappedException.wrap(error)); + + assertSame(error, thrown); + } + + @Test + void rethrowConvertsIOExceptionToUncheckedIOException() { + IOException ioException = new IOException("io"); + + UncheckedIOException thrown = assertThrows(UncheckedIOException.class, () -> WrappedException.rethrow(ioException)); + + assertSame(ioException, thrown.getCause()); + } + + @Test + void rethrowWrapsCheckedExceptions() { + Exception checked = new Exception("checked"); + + WrappedException thrown = assertThrows(WrappedException.class, () -> WrappedException.rethrow(checked)); + + assertSame(checked, thrown.getCause()); + } +} + diff --git a/config-utils-yaml/pom.xml b/config-utils-yaml/pom.xml deleted file mode 100644 index 95af7ee..0000000 --- a/config-utils-yaml/pom.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - 4.0.0 - - dev.spoocy.utils - root - 1.0.12 - - - config-utils-yaml - - - - - org.jetbrains - annotations - - - - dev.spoocy.utils - common-utils - - - - dev.spoocy.utils - config-utils - - - - org.yaml - snakeyaml - - - - - \ No newline at end of file diff --git a/config-utils-yaml/src/main/java/dev/spoocy/utils/config/YamlConfig.java b/config-utils-yaml/src/main/java/dev/spoocy/utils/config/YamlConfig.java deleted file mode 100644 index 252dcdb..0000000 --- a/config-utils-yaml/src/main/java/dev/spoocy/utils/config/YamlConfig.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.spoocy.utils.config; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class YamlConfig { -} diff --git a/config-utils-yaml/src/main/java/module-info.java b/config-utils-yaml/src/main/java/module-info.java deleted file mode 100644 index 1ae31d4..0000000 --- a/config-utils-yaml/src/main/java/module-info.java +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -module dev.spoocy.utils.config { - requires org.jetbrains.annotations; - requires org.yaml.snakeyaml; - - exports dev.spoocy.utils.config; -} \ No newline at end of file diff --git a/config-utils/pom.xml b/config-utils/pom.xml index 1829653..3122ab2 100644 --- a/config-utils/pom.xml +++ b/config-utils/pom.xml @@ -7,7 +7,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 config-utils @@ -32,6 +32,13 @@ org.json json + true + + + + org.yaml + snakeyaml + true diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java new file mode 100644 index 0000000..39ef309 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java @@ -0,0 +1,246 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.io.ClassPathResource; +import dev.spoocy.utils.config.io.FileSystemResource; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.loader.AnnotatedConfigLoader; +import dev.spoocy.utils.config.loader.ConfigLoader; +import dev.spoocy.utils.config.loader.ConfigSource; +import dev.spoocy.utils.config.representer.Representer; +import dev.spoocy.utils.config.update.ConfigUpdater; +import dev.spoocy.utils.reflection.Reflection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class BaseResourceResolver implements ResourceResolver { + + private static final AnnotatedConfigLoader CONFIG_LOADER = new AnnotatedConfigLoader(); + private static final String CLASSPATH_FILE_PREFIX = "classpath:"; + private static final String EXTERNAL_FILE_PREFIX = "file:"; + + private final Map> loaders = new HashMap<>(); + + @Nullable + private final ClassLoader classLoader; + + public BaseResourceResolver(@Nullable ClassLoader classLoader, @NotNull ConfigLoader... loader) { + this.classLoader = classLoader; + + for (ConfigLoader configLoader : loader) { + registerLoader(configLoader); + } + } + + public void registerLoader(@NotNull ConfigLoader loader) { + Args.notNull(loader, "loader"); + + for (String extension : loader.getSupportedExtensions()) { + this.loaders.put(extension.toLowerCase(), loader); + } + } + + protected Config createEmpty(@NotNull Resource resource) { + return requireLoader(resource).createEmpty(); + } + + @Override + public @NotNull Resource resolve(@NotNull String location) { + Args.notNull(location, "location"); + + if (location.startsWith(CLASSPATH_FILE_PREFIX)) { + String path = location.substring(CLASSPATH_FILE_PREFIX.length()); + return new ClassPathResource(path, this.classLoader); + } + + if (location.startsWith(EXTERNAL_FILE_PREFIX)) { + String path = location.substring(EXTERNAL_FILE_PREFIX.length()); + return new FileSystemResource(path); + } + + return Resources.fromPath(location); + } + + @Nullable + protected ConfigLoader resolveLoader(@NotNull Resource resource) { + String filename = resource.getFilename(); + String extension = filename == null ? "" : FileUtils.getFileExtension(filename); + return resolveLoaderByExtension(extension); + } + + @Nullable + protected ConfigLoader resolveLoaderByExtension(@NotNull String extension) { + return this.loaders.get(extension.toLowerCase()); + } + + @NotNull + protected ConfigLoader requireLoader(@NotNull Resource resource) { + ConfigLoader loader = resolveLoader(resource); + if (loader == null) { + throw new IllegalArgumentException("No config loader for resource " + resource); + } + return loader; + } + + @Override + public @NotNull Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { + Args.notNull(resource, "resource"); + Args.notNull(constructor, "constructor"); + + ConfigLoader loader = requireLoader(resource); + return loader.load(resource, constructor); + } + + @Override + public @NotNull Config loadConfig( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull ConfigUpdater updater + ) throws IOException { + Config config = loadConfig(resource, constructor); + updater.run(config); + return config; + } + + @Override + public @NotNull Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor) + throws IOException { + return loadConfig(resource, constructor).withRelation(resource); + } + + @Override + public @NotNull Document loadDocument( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull ConfigUpdater updater + ) throws IOException { + Document document = loadDocument(resource, constructor); + + if (updater.run(document) > 0) { + + Resource relation = document.getRelation(); + + if (relation instanceof WriteableResource) { + document.save((WriteableResource) relation); + } + + } + + return document; + } + + @Override + public @NotNull T load(@NotNull Class type, @NotNull Readable readable) { + return CONFIG_LOADER.load(type, readable); + } + + @Override + public @NotNull T load(@NotNull Class type, @NotNull Constructor constructor) throws IOException { + Args.notNull(type, "type"); + Args.notNull(constructor, "constructor"); + + ConfigSource source = requireSource(type); + Resource resource = resolveSourceResource(type, source); + return load(type, constructor, resource, source); + } + + @Override + public @NotNull T load(@NotNull Class type, @NotNull Constructor constructor, @NotNull Resource resource) + throws IOException { + Args.notNull(type, "type"); + Args.notNull(constructor, "constructor"); + Args.notNull(resource, "resource"); + + ConfigSource source = Reflection.getAnnotation(type, ConfigSource.class, false); + return load(type, constructor, resource, source); + } + + @NotNull + private T load( + @NotNull Class type, + @NotNull Constructor constructor, + @NotNull Resource resource, + @Nullable ConfigSource source + ) throws IOException { + Document document = loadAnnotatedDocument(resource, constructor, source); + AnnotatedConfigLoader.LoadResult result = CONFIG_LOADER.loadResult(type, document); + + if (result.mutations() > 0) { + Resource relation = document.getRelation(); + if (relation instanceof WriteableResource) { + document.save((WriteableResource) relation); + } + } + + return result.instance(); + } + + @NotNull + private Document loadAnnotatedDocument( + @NotNull Resource resource, + @NotNull Constructor constructor, + @Nullable ConfigSource source + ) throws IOException { + if (source != null && source.allowMissingResource() && !resource.exists()) { + return createEmpty(resource).withRelation(resource); + } + + return loadDocument(resource, constructor); + } + + @Override + public void save(@NotNull Object instance, @NotNull Representer representer) throws IOException { + Args.notNull(instance, "instance"); + Args.notNull(representer, "representer"); + + ConfigSource source = requireSource(instance.getClass()); + Resource resource = resolveSourceResource(instance.getClass(), source); + save(instance, representer, resource); + } + + @Override + public void save(@NotNull Object instance, @NotNull Representer representer, @NotNull Resource resource) throws IOException { + Args.notNull(instance, "instance"); + Args.notNull(resource, "resource"); + + Document document = createEmpty(resource).withRelation(resource); + Resource relation = document.getRelation(); + + if (!(relation instanceof WriteableResource)) { + throw new IllegalArgumentException("Resource is not writeable: " + resource); + } + + CONFIG_LOADER.write(instance, document); + document.save((WriteableResource) relation, representer); + } + + @NotNull + private static ConfigSource requireSource(@NotNull Class type) { + ConfigSource source = Reflection.getAnnotation(type, ConfigSource.class, false); + if (source == null) { + throw new IllegalArgumentException("Missing @ConfigSource for class: " + type.getName()); + } + return source; + } + + @NotNull + private Resource resolveSourceResource(@NotNull Class type, @NotNull ConfigSource source) { + String location = source.value().trim(); + if (location.isEmpty()) { + throw new IllegalArgumentException("Missing @ConfigSource(value) for class: " + type.getName()); + } + + return resolve(location); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/CommentType.java b/config-utils/src/main/java/dev/spoocy/utils/config/CommentType.java deleted file mode 100644 index e31cb5e..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/CommentType.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.spoocy.utils.config; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public enum CommentType { - HEADER, - PATH, - INLINE, -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java index 884c613..f8a5f59 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java @@ -3,7 +3,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Collection; import java.util.List; /** @@ -13,49 +12,18 @@ public interface Commentable { /** - * Checks if this config supports comments. - * - * @return {@code true} if this config supports comments, {@code false} otherwise. - */ - default boolean isCommentable() { - return false; - } - - /** - * Gets the comments of the specified type and path. - * - * @param type the type of comments to get. - * @param path the path to get the comments of, or an empty string for header comments. - * - * @return a list of comments for the specified type and path, or an empty list if there are no comments for the specified type and path. - * - * @throws UnsupportedOperationException if this config does not support comments. - */ - default List getComments(@NotNull CommentType type, @NotNull String path) { - throw new UnsupportedOperationException("This config does not support comments."); - } - - /** - * Sets the comments of the specified type and path. - * - * @param type the type of comments to set. - * @param path the path to set the comments of, or an empty string for header comments. - * @param comments the comments to set for the specified type and path, or {@code null} to remove all comments for the specified type and path. + * Gets the header comments of this config. * - * @throws UnsupportedOperationException if this config does not support comments. + * @return a list of header comments, or an empty list if there are no header comments. */ - default void setComments(@NotNull CommentType type, @NotNull String path, @Nullable Collection comments) { - throw new UnsupportedOperationException("This config does not support comments."); - } + List getHeaderComments(); /** - * Gets the header comments of this config. + * Gets the footer comments of this config. * - * @return a list of header comments, or an empty list if there are no header comments. + * @return a list of footer comments, or an empty list if there are no footer comments. */ - default List getHeaderComments() { - return getComments(CommentType.HEADER, ""); - } + List getFooterComments(); /** * Gets the comments of the specified path. @@ -64,9 +32,7 @@ default List getHeaderComments() { * * @return a list of comments for the specified path, or an empty list if there are no comments for the specified path. */ - default List getComments(@NotNull final String path) { - return getComments(CommentType.PATH, path); - } + List getComments(@NotNull final String path); /** * Gets the inline comments of the specified path. @@ -75,18 +41,21 @@ default List getComments(@NotNull final String path) { * * @return a list of inline comments for the specified path, or an empty list if there are no inline comments for the specified path. */ - default List getInlineComments(@NotNull final String path) { - return getComments(CommentType.INLINE, path); - } + List getInlineComments(@NotNull final String path); /** * Sets the header comments of this config. * * @param comments the header comments to set, or {@code null} to remove all header comments. */ - default void setHeaderComments(@Nullable List comments) { - setComments(CommentType.HEADER, "", comments); - } + void setHeaderComments(@Nullable List comments); + + /** + * Sets the footer comments of this config. + * + * @param comments the footer comments to set, or {@code null} to remove all footer comments. + */ + void setFooterComments(@Nullable List comments); /** * Sets the comments of the specified path. @@ -94,9 +63,7 @@ default void setHeaderComments(@Nullable List comments) { * @param path the path to set the comments of. * @param comments the comments to set for the specified path, or {@code null} to remove all comments for the specified path. */ - default void setComments(@NotNull String path, @Nullable List comments) { - setComments(CommentType.PATH, path, comments); - } + void setComments(@NotNull String path, @Nullable List comments); /** * Sets the inline comments of the specified path. @@ -104,25 +71,26 @@ default void setComments(@NotNull String path, @Nullable List comments) * @param path the path to set the inline comments of. * @param comments the inline comments to set for the specified path, or {@code null} to remove all inline comments for the specified path. */ - default void setInlineComments(@NotNull String path, @Nullable List comments) { - setComments(CommentType.INLINE, path, comments); - } + void setInlineComments(@NotNull String path, @Nullable List comments); /** - * Sets the header comments of this config. - * - * @param comments the header comments to set, or an empty array to remove all header comments. - */ + * @see #setHeaderComments(List) + */ default void setHeaderComments(@NotNull String... comments) { setHeaderComments(comments == null || comments.length == 0 ? null : List.of(comments)); } /** - * Sets the comments of the specified path. - * - * @param path the path to set the comments of. - * @param comments the comments to set for the specified path, or an empty array to remove all comments for the specified path. + * @see #setHeaderComments(String...) + */ + default void setFooterComments(@NotNull String... comments) { + setFooterComments(comments == null || comments.length == 0 + ? null : List.of(comments)); + } + + /** + * @see #setComments(String, List) */ default void setComments(@NotNull final String path, @NotNull String... comments) { setComments(path, comments == null || comments.length == 0 @@ -130,10 +98,7 @@ default void setComments(@NotNull final String path, @NotNull String... comments } /** - * Sets the inline comments of the specified path. - * - * @param path the path to set the inline comments of. - * @param comments the inline comments to set for the specified path, or an empty array to remove all inline comments for the specified path. + * @see #setInlineComments(String, List) */ default void setInlineComments(@NotNull final String path, @NotNull String... comments) { setInlineComments(path, comments == null || comments.length == 0 diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Config.java b/config-utils/src/main/java/dev/spoocy/utils/config/Config.java index a39811d..c954df3 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Config.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Config.java @@ -1,293 +1,80 @@ package dev.spoocy.utils.config; -import dev.spoocy.utils.common.log.ILogger; -import dev.spoocy.utils.common.misc.FileUtils; -import dev.spoocy.utils.config.components.DocumentFile; -import dev.spoocy.utils.config.types.JsonConfig; -import dev.spoocy.utils.config.misc.SectionList; -import dev.spoocy.utils.reflection.Reflection; -import org.jetbrains.annotations.CheckReturnValue; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.representer.Representer; +import dev.spoocy.utils.config.types.ConfigSettings; import org.jetbrains.annotations.NotNull; -import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.io.Writer; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.function.BiConsumer; /** * @author Spoocy99 | GitHub: Spoocy99 */ -public interface Config extends Writeable { +public interface Config extends ConfigSection { /** - * Creates an empty instance of the default file type. + * Retrieves the {@link ConfigSettings} associated with this config. * - * @return an empty config + * @return the config settings */ - static Config create() { - return new JsonConfig(); - } + @NotNull + ConfigSettings settings(); /** - * Creates an empty instance of the given file type. + * Serializes the config into a string representation using the default representer. * - * @param configClass the class of the Config to create an instance of + * @return the string representation of the config * - * @return an empty config + * @see ConfigSettings#representer(Representer) */ - static Config create(@NotNull Class configClass) { - try { - return (Config) Reflection.getConstructor(configClass).invoke(); - } catch (Throwable e) { - throw new UnsupportedOperationException("Unable to create empty instance of " + configClass.getName() + ".", e); - } + @NotNull + default String saveToString() { + return saveToString(this.settings().representer()); } /** - * Reads the file at the given {@link Path} and creates an instance of the given file type. - * - * @param configClass the class of the Config to create an instance of - * @param path the path to the file to read + * Serializes the config to a string representation using a custom representer. * - * @return an instance of the given file type + * @param representer the representer to use for converting values to config nodes * - * @see #readPath(Class, Path) + * @return the string representation of the config */ - static Config readPath(@NotNull Class configClass, @NotNull String path) { - return readPath(configClass, Paths.get(path)); - } + @NotNull + String saveToString(@NotNull Representer representer); /** - * Reads the file at the given {@link Path} and creates an instance of the given file type. + * Saves the configuration to the specified writable resource using the default representer for serialization. * - * @param configClass the class of the Config to create an instance of - * @param path the path to the file to read + * @param file the writable resource where the configuration should be saved; must not be null * - * @return an instance of the given file type + * @throws IOException if an error occurs while writing to the resource * - * @see #readFile(Class, File) + * @see ConfigSettings#representer(Representer) */ - static Config readPath(@NotNull Class configClass, @NotNull Path path) { - return readFile(configClass, path.toFile()); + default void save(@NotNull WriteableResource file) throws IOException { + save(file, this.settings().representer()); } /** - * Reads the file and creates an instance of the given file type. + * Saves the configuration to the specified writable resource using the provided representer for serialization. * - * @param configClass the class of the Config to create an instance of - * @param file the file to read + * @param file the writable resource where the configuration should be saved; must not be null + * @param representer the representer used to serialize the configuration; must not be null * - * @return an instance of the given file type - * - * @throws UnsupportedOperationException if the config cannot read files + * @throws IOException if an error occurs while writing to the resource */ - static Config readFile(@NotNull Class configClass, @NotNull File file) { - try { - return (Config) Reflection.getConstructor(configClass, File.class).invoke(file); - } catch (Throwable e) { - throw new UnsupportedOperationException("Unable to create instance of " + configClass.getName() + " using file.", e); - } - } + void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException; /** - * Reads the {@link InputStream} and creates an instance of the given file type. - * - * @param configClass the class of the Config to create an instance of - * @param stream the input stream to read + * Creates a new {@link Document} instance with the specified relation. * - * @return an instance of the given file type + * @param resource the resource to the related config file * - * @throws UnsupportedOperationException if the config cannot read the InputStreams + * @return a new config instance with the specified relation */ - static @NotNull Config readInputStream(@NotNull Class configClass, @NotNull InputStream stream) { - try { - return (Config) Reflection.getConstructor(configClass, InputStream.class).invoke(stream); - } catch (Throwable e) { - throw new UnsupportedOperationException("Unable to create instance of " + configClass.getName() + " using InputStream.", e); - } - } + @NotNull + Document withRelation(@NotNull Resource resource); - /** - * Reads the object and creates an instance of the given file type. - * - * @param configClass the class of the Config to create an instance of - * @param read the object to read - * - * @return an instance of the given file type - * - * @throws UnsupportedOperationException if the config cannot read the object - */ - static Config readObject(@NotNull Class configClass, @NotNull Object read) { - try { - return (Config) Reflection.getConstructor(configClass, Object.class).invoke(read); - } catch (Throwable e) { - throw new UnsupportedOperationException("Unable to create instance of " + configClass.getName() + " using object.", e); - } - } - - /** - * Creates a new Document instance. - * - * @param config the document to watch - * @param file the file to watch - * - * @return the new WatchedDocument - */ - static Document createDocument(@NotNull Config config, @NotNull File file) { - return new DocumentFile(config, file); - } - - /** - * Creates a new Document instance. - * - * @param config the document to watch - * @param path the path to watch - * - * @return the new WatchedDocument - */ - static Document createDocument(@NotNull Config config, @NotNull Path path) { - return new DocumentFile(config, path); - } - - /** - * Converts the current Config to a Document. - * - * @param file the file to watch - * - * @return the new WatchedFile - */ - default Document withPath(@NotNull File file) { - return createDocument(this, file); - } - - /** - * Converts the current Config to a Document. - * - * @param path the path to the document to watch - * - * @return the new WatchedFile - */ - @CheckReturnValue - default Document withPath(@NotNull Path path) { - return createDocument(this, path); - } - - /** - * Copies the config to a new JSONConfig instance. - * - * @return the Document - */ - default Config copyToJson() { - return new JsonConfig(values()); - } - - /** - * Executes the given consumer for each value in the config. - * - * @param consumer the consumer to execute with the key and the value - */ - default void forEachValue(@NotNull BiConsumer consumer) { - values().forEach(consumer); - } - - /** - * Saves the config to the given path. - * - * @param path the path to save the document to - * - * @throws IOException if an error occurs while saving the config - */ - default void save(@NotNull String path) throws IOException { - File file = Paths.get(path).toFile(); - save(file); - } - - /** - * Saves the config to the given path. - * - * @param path the path to save the document to - * - * @return true if the document was saved successfully - */ - default boolean saveSafely(@NotNull String path) { - try { - save(path); - return true; - } catch (IOException e) { - ILogger.forThisClass().error("An error occurred while saving config at " + path, e); - return false; - } - } - - /** - * Saves the config to the given file. - * - * @param file the file to save the document to - * - * @throws IOException if an error occurs while saving the document - */ - default void save(@NotNull File file) throws IOException { - FileUtils.createFile(file); - Writer writer = FileUtils.createWriter(file); - write(writer); - writer.flush(); - writer.close(); - } - - /** - * Saves the config to the given file. - * - * @param file the file to save the document to - * - * @return true if the document was saved successfully - */ - default boolean saveSafely(@NotNull File file) { - try { - save(file); - return true; - } catch (IOException e) { - ILogger.forThisClass().error("An error occurred while saving config at " + file, e); - return false; - } - } - - /** - * Gets the parent config. If the document has no parent, the config itself is returned. - * - * @return the parent config - */ - Config getParent(); - - /** - * Writes the content to the given writer. - * - * @param writer the writer to write the content to - * - * @throws IOException if an error occurs while writing the content - */ - void write(@NotNull Writer writer) throws IOException; - - /** - * Gets the section at the given path. - * - * @param path the path to the section - * - * @return the section as a Document - */ - @Override - Config getSection(@NotNull String path); - - /** - * Gets an array of all sections at the given path. - * - * @param path the path to the section array - * - * @return the section array - */ - @Override - SectionList getSectionArray(@NotNull String path); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ConfigProvider.java b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigProvider.java new file mode 100644 index 0000000..9b7299f --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigProvider.java @@ -0,0 +1,20 @@ +package dev.spoocy.utils.config; + +import org.jetbrains.annotations.NotNull; + +/** + * Interface for resolving configurations. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@FunctionalInterface +public interface ConfigProvider { + + /** + * Provides a configuration for further processing or usage. + * + * @return a non-null {@link Config} object representing the provided configuration + */ + @NotNull + Config provide(); +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java new file mode 100644 index 0000000..5ff6c1b --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java @@ -0,0 +1,107 @@ +package dev.spoocy.utils.config; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface ConfigSection extends Writeable, Readable { + + /** + * Gets the name of this section. + * + * @return The name of this section. + */ + @NotNull + String getName(); + + /** + * Gets the path of this section. + * + * @return The path of this section. + */ + @NotNull + String getWorkingPath(); + + /** + * Gets the root config of this section. + * + * @return The root config of this section. + */ + @NotNull + Config getRoot(); + + /** + * Gets the parent section of this section. + * + * @return The parent section of this section, or {@code null} if this section is the root. + */ + @Nullable + ConfigSection getParent(); + + /** + * Determines whether the specified path represents a valid section. + * + * @param path The path to check for being a section. Must not be null. + * @return {@code true} if the specified path corresponds to a section, {@code false} otherwise. + */ + boolean isSection(@NotNull String path); + + /** + * Gets the section at the specified path. + * + * @param path The path of the section to get, relative to this section. + * + * @return The section at the specified path, or {@code null} if there is no section at the specified path. + */ + @Nullable + ConfigSection getSection(@NotNull String path); + + /** + * Retrieves the section at the specified path or an empty section if no section exists at the path. + * + * @param path The path to retrieve the section from. Must not be null. + * @return The section at the specified path if it exists, or an empty section otherwise. Never null. + */ + @NotNull + ConfigSection getOrCreateSection(@NotNull String path); + + /** + * Creates a new section at the specified path. + * + * @param path The path of the section to create, relative to this section. + * + * @return The newly created section. + * + * @throws IllegalArgumentException if there is already a object at the specified path. + */ + @NotNull + ConfigSection createSection(@NotNull String path); + + /** + * Creates a new section at the specified path with the provided data. + * + * @param path The path where the new section should be created, relative to the current section. + * @param map The data to initialize the newly created section with. + * + * @return The newly created section. + * + * @throws IllegalArgumentException if there is already an object at the specified path. + */ + @NotNull + ConfigSection createSection(@NotNull String path, @NotNull Map map); + + /** + * Gets the list of sections at the specified path. + * + * @param path The path of the sections to get, relative to this section. + * + * @return The list of sections at the specified path, or an empty list if there are no sections at the specified path. + */ + List getSectionList(@NotNull String path); +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Document.java b/config-utils/src/main/java/dev/spoocy/utils/config/Document.java index c2acaab..fbbc37c 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Document.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Document.java @@ -1,19 +1,11 @@ package dev.spoocy.utils.config; -import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.common.log.ILogger; -import dev.spoocy.utils.common.scheduler.Scheduler; -import dev.spoocy.utils.common.scheduler.task.Task; -import dev.spoocy.utils.config.misc.SectionList; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.representer.Representer; +import dev.spoocy.utils.config.types.ConfigSettings; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import java.io.File; import java.io.IOException; -import java.io.Writer; -import java.nio.file.Path; -import java.time.OffsetDateTime; -import java.util.*; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -21,399 +13,20 @@ public interface Document extends Config { - static Document readPath(@NotNull Class documentClass, @NotNull Path path) { - return Config.readPath(documentClass, path).withPath(path); - } - - static Document readFile(@NotNull Class documentClass, @NotNull File file) { - return Config.readFile(documentClass, file).withPath(file); - } - - /** - * Gets the configuration this document is associated with. - * - * @return the document - */ - @NotNull - Config getConfig(); - /** - * Gets the file this document is associated with. + * Gets the file associated with this document. * * @return the file */ @NotNull - File getFile(); - - /** - * Gets the path of the file this document is associated with. - * - * @return the path of the file - */ - @NotNull - Path getPath(); + Resource getRelation(); /** - * Rereads the file and loads the configuration. - */ - void reload(); - - /** - * Saves the file to the given location. + * Saves the file to the documents location. * * @throws IOException if an error occurs while saving the file + * + * @see ConfigSettings#representer(Representer) */ void save() throws IOException; - - /** - * Saves the file to the watched location. - * - * @return {@code true} if the save operation was successful, otherwise {@code false} - */ - boolean saveSafely(); - - /** - * Saves the file to the given location asynchronously. - * - * @return a task for handling the result of the save operation - */ - Task saveAsync(); - - @Override - default Config getParent() { - return getConfig().getParent(); - } - - @Override - default void write(@NotNull Writer writer) throws IOException { - getConfig().write(writer); - } - - @Override - default Config getSection(@NotNull String path) { - return getConfig().getSection(path); - } - - @Override - default SectionList getSectionArray(@NotNull String path) { - return getConfig().getSectionArray(path); - } - - @Override - default void setReadOnly() { - getConfig().setReadOnly(); - } - - @Override - default boolean isReadonly() { - return getConfig().isReadonly(); - } - - @Override - default void set(@NotNull String path, @Nullable Object value) { - getConfig().set(path, value); - } - - @Override - default void remove(@NotNull String path) { - getConfig().remove(path); - } - - @Override - default void clear() { - getConfig().clear(); - } - - @Override - default void opposite(@NotNull String path) { - getConfig().opposite(path); - } - - @Override - default void multiply(@NotNull String path, double value) { - getConfig().multiply(path, value); - } - - @Override - default void divide(@NotNull String path, double value) { - getConfig().divide(path, value); - } - - @Override - default void add(@NotNull String path, double value) { - getConfig().add(path, value); - } - - @Override - default void subtract(@NotNull String path, double value) { - getConfig().subtract(path, value); - } - - @Override - default boolean isString(@NotNull String path) { - return getConfig().isString(path); - } - - @Override - default boolean isInt(@NotNull String path) { - return getConfig().isInt(path); - } - - @Override - default boolean isDouble(@NotNull String path) { - return getConfig().isDouble(path); - } - - @Override - default boolean isFloat(@NotNull String path) { - return getConfig().isFloat(path); - } - - @Override - default boolean isLong(@NotNull String path) { - return getConfig().isLong(path); - } - - @Override - default boolean isBoolean(@NotNull String path) { - return getConfig().isBoolean(path); - } - - @Override - @Nullable - default Object getObject(@NotNull String path) { - return getConfig().getObject(path); - } - - @Override - default Object getObject(@NotNull String path, @Nullable Object defaultValue) { - return getConfig().getObject(path, defaultValue); - } - - @Override - default T get(@NotNull String path, @NotNull Class clazz) { - return getConfig().get(path, clazz); - } - - @Override - default T get(@NotNull String path, @Nullable T defaultValue) { - return getConfig().get(path, defaultValue); - } - - @Override - default T getSerializable(@NotNull String path, @NotNull T defaultValue) { - return getConfig().getSerializable(path, defaultValue); - } - - @Override - default @Nullable T getSerializable(@NotNull String path, @NotNull Class clazz) { - return getConfig().getSerializable(path, clazz); - } - - @Override - default String getString(@NotNull String path) { - return getConfig().getString(path); - } - - @Override - default String getString(@NotNull String path, @NotNull String defaultValue) { - return getConfig().getString(path, defaultValue); - } - - @Override - default @NotNull List getStringList(@NotNull String path) { - return getConfig().getStringList(path); - } - - @Override - default int getInt(@NotNull String path) { - return getConfig().getInt(path); - } - - @Override - default int getInt(@NotNull String path, int defaultValue) { - return getConfig().getInt(path, defaultValue); - } - - @NotNull - @Override - default List getIntegerList(@NotNull String path) { - return getConfig().getIntegerList(path); - } - - @Override - default double getDouble(@NotNull String path) { - return getConfig().getDouble(path); - } - - @Override - default double getDouble(@NotNull String path, double defaultValue) { - return getConfig().getDouble(path, defaultValue); - } - - @NotNull - @Override - default List getDoubleList(@NotNull String path) { - return getConfig().getDoubleList(path); - } - - @Override - default float getFloat(@NotNull String path) { - return getConfig().getFloat(path); - } - - @Override - default float getFloat(@NotNull String path, float defaultValue) { - return getConfig().getFloat(path, defaultValue); - } - - @NotNull - @Override - default List getFloatList(@NotNull String path) { - return getConfig().getFloatList(path); - } - - @Override - default long getLong(@NotNull String path) { - return getConfig().getLong(path); - } - - @Override - default long getLong(@NotNull String path, long defaultValue) { - return getConfig().getLong(path, defaultValue); - } - - @NotNull - @Override - default List getLongList(@NotNull String path) { - return getConfig().getLongList(path); - } - - @Override - default boolean getBoolean(@NotNull String path) { - return getConfig().getBoolean(path); - } - - @Override - default boolean getBoolean(@NotNull String path, boolean defaultValue) { - return getConfig().getBoolean(path, defaultValue); - } - - @NotNull - @Override - default List getBooleanList(@NotNull String path) { - return getConfig().getBooleanList(path); - } - - @Override - default Class getClass(@NotNull String path) { - return getConfig().getClass(path); - } - - @Override - default Class getClass(@NotNull String path, @Nullable Class defaultValue) { - return getConfig().getClass(path, defaultValue); - } - - @Override - default > T getEnum(@NotNull String path, @NotNull Class clazz) { - return getConfig().getEnum(path, clazz); - } - - @Override - default > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - return getConfig().getEnum(path, clazz, defaultValue); - } - - @Override - default UUID getUUID(@NotNull String path) { - return getConfig().getUUID(path); - } - - @Override - default UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { - return getConfig().getUUID(path, defaultValue); - } - - @Override - default Date getDate(@NotNull String path) { - return getConfig().getDate(path); - } - - @Override - default Date getDate(@NotNull String path, @Nullable Date defaultValue) { - return getConfig().getDate(path, defaultValue); - } - - @Override - default OffsetDateTime getOffsetDateTime(@NotNull String path) { - return getConfig().getOffsetDateTime(path); - } - - @Override - default OffsetDateTime getOffsetDateTime(@NotNull String path, @Nullable OffsetDateTime defaultValue) { - return getConfig().getOffsetDateTime(path, defaultValue); - } - - @Override - default Version getVersion(@NotNull String path) { - return getConfig().getVersion(path); - } - - @Override - default Version getVersion(@NotNull String path, @Nullable Version defaultValue) { - return getConfig().getVersion(path, defaultValue); - } - - @Override - default boolean isSet(@NotNull String path) { - return getConfig().isSet(path); - } - - @Override - default boolean isOf(@NotNull String path, @NotNull Class clazz) { - return getConfig().isOf(path, clazz); - } - - @Override - default boolean isList(@NotNull String path) { - return getConfig().isList(path); - } - - @Override - @Nullable - default List getList(@NotNull String path) { - return getConfig().getList(path); - } - - @Override - @Nullable - default List getList(@NotNull String path, @Nullable List defaultValue) { - return getConfig().getList(path, defaultValue); - } - - @Override - default List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { - return getConfig().getList(path, clazz, defaultValue); - } - - @Override - default Collection keys() { - return getConfig().keys(); - } - - @Override - default Map values() { - return getConfig().values(); - } - - @Override - default Map valuesAsString() { - return getConfig().valuesAsString(); - } - - @Override - default String toJson() { - return getConfig().toJson(); - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/FileManager.java b/config-utils/src/main/java/dev/spoocy/utils/config/FileManager.java deleted file mode 100644 index c386a7e..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/FileManager.java +++ /dev/null @@ -1,82 +0,0 @@ -package dev.spoocy.utils.config; - -import dev.spoocy.utils.config.types.JsonConfig; -import dev.spoocy.utils.common.misc.FileUtils; -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.nio.file.Path; -import java.util.HashMap; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public final class FileManager { - - private static final HashMap> types = new HashMap<>(); - - static { - registerType("json", JsonConfig.class); - } - - private FileManager() { } - - /** - * Registers a file type with the given extension. - * - * @param extension the extension of the file type - * - * @return the class of the file type - * - * @throws IllegalArgumentException if the extension can not be resolved - */ - @NotNull - public static Class resolveType(@NotNull String extension) { - extension = extension.toLowerCase(); - Class resolved = types.get(extension); - - if (resolved == null) { - throw new IllegalArgumentException( - String.format("Unable to resolve file type for extension: '%s'", extension) - ); - } - - return resolved; - } - - /** - * Reads the given file as a {@link Document}. - * - * @param file the file to read - * - * @return the document - */ - public static synchronized Document getFile(@NotNull File file) { - String extension = FileUtils.getFileExtension(file); - return Document.readFile(resolveType(extension), file); - } - - /** - * Reads the file at a given path as a {@link Document}. - * - * @param path the path to the file - * - * @return the document - */ - public static synchronized Document getFile(@NotNull Path path) { - String extension = FileUtils.getFileExtension(path); - return Document.readPath(resolveType(extension), path); - } - - /** - * Registers a file type with an extension. - * - * @param extension the extension of the file type - * @param documentClass the class of the file - */ - public static void registerType(@NotNull String extension, Class documentClass) { - types.put(extension.toLowerCase(), documentClass); - } - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Json.java b/config-utils/src/main/java/dev/spoocy/utils/config/Json.java deleted file mode 100644 index 61c00a8..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Json.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.spoocy.utils.config; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public interface Json { - - /** An empty JSON object. */ - Json EMPTY = () -> "{}"; - - /** - * Convert this object to a JSON string. - * - * @return the JSON string - */ - String toJson(); - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java index 5e5a500..38c7624 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java @@ -1,11 +1,9 @@ package dev.spoocy.utils.config; import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.config.misc.SectionList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.time.OffsetDateTime; import java.util.*; import java.util.List; @@ -13,78 +11,433 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public interface Readable extends Json { +public interface Readable { - Readable getSection(@NotNull String path); - SectionList getSectionArray(@NotNull String path); + /** + * Gets the value at the specified path as an Object. + * + * @param path the path to the value + * + * @return the value at the specified path as an Object, or {@code null} if the path does not exist or is not set + */ + @Nullable + Object getObject(@NotNull String path); - @Nullable Object getObject(@NotNull String path); + /** + * Gets the value at the specified path as an Object, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as an Object, or {@code defaultValue} if the path does not exist or is not set + */ + @Nullable Object getObject(@NotNull String path, @Nullable Object defaultValue); + /** + * Gets the value at the specified path as an instance of the specified class. + * + * @param path the path to the value + * @param clazz the class to convert the value to + * + * @return the value at the specified path as an instance of the specified class, + * or {@code null} if the path does not exist, is not set, or cannot be converted to an instance of the specified class + */ T get(@NotNull String path, @NotNull Class clazz); - T get(@NotNull String path, @Nullable T defaultValue); - boolean isOf(@NotNull String path, @NotNull Class clazz); - T getSerializable(@NotNull String path, @Nullable T defaultValue); - @Nullable T getSerializable(@NotNull String path, @NotNull Class clazz); + /** + * Gets the value at the specified path as an instance of the specified class, + * or returns a default value if the path does not exist, is not set, or cannot be converted to an instance of the specified class. + * + * @param path the path to the value + * @param clazz the class to convert the value to + * @param defaultValue the default value to return if the path does not exist, is not set, or cannot be converted to an instance of the specified class + * + * @return the value at the specified path as an instance of the specified class, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to an instance of the specified class + */ + T get(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue); + /** + * Checks if the value at the specified path is an instance of the specified class. + * + * @param path the path to the value + * @param clazz the class to check the value against + * + * @return {@code true} if the value at the specified path is an instance of the specified class, {@code false} otherwise + */ + boolean is(@NotNull String path, @NotNull Class clazz); + + /** + * Retrieves a serializable object from the specified path. If the retrieved value is null or + * not serializable to the expected type, the provided default value will be returned. + * + * @param The type of the serializable object to retrieve. + * @param path The path to the value in the configuration. Must not be null. + * @param clazz The class type to which the object should be cast. Must not be null. + * @param defaultValue The default value to return if the retrieved object is null or cannot be serialized to the expected type. Can be null. + * + * @return The serializable object at the specified path, or the default value if the object + * is null or cannot be serialized to the expected type. + */ + T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue); + + /** + * Retrieves a serializable object from the specified path in the configuration. + * + * @param path the path to the object in the configuration (not null) + * @param clazz the class type to which the object should be cast (not null) + * @param the type of the object to be retrieved + * + * @return the deserialized object of the specified type, or {@code null} if the object + * is not found, cannot be cast to the specified type, or deserialization fails + */ + @Nullable + T getSerializable(@NotNull String path, @NotNull Class clazz); + + /** + * Checks if the value at the specified path is a {@link String}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a String, {@code false} otherwise + */ boolean isString(@NotNull String path); - String getString(@NotNull String path); - String getString(@NotNull String path, @NotNull String defaultValue); - @NotNull List getStringList(@NotNull String path); + /** + * Gets the value at the specified path as a String, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as a String, or {@code defaultValue} if the path does not exist or is not set + */ + String getString(@NotNull String path, @Nullable String defaultValue); + + @NotNull + default String getString(@NotNull String path) { + return getString(path, ""); + } + + /** + * Checks if the value at the specified path is an {@link Integer}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is an Integer, {@code false} otherwise + */ boolean isInt(@NotNull String path); - int getInt(@NotNull String path); + + /** + * Gets the value at the specified path as an int, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as an int, or {@code defaultValue} if the path does not exist or is not set + */ int getInt(@NotNull String path, int defaultValue); - @NotNull List getIntegerList(@NotNull String path); + default int getInt(@NotNull String path) { + return getInt(path, 0); + } + + /** + * Checks if the value at the specified path is a {@link Double}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a Double, {@code false} otherwise + */ boolean isDouble(@NotNull String path); - double getDouble(@NotNull String path); + + /** + * Gets the value at the specified path as a double, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as a double, or {@code defaultValue} if the path does not exist or is not set + */ double getDouble(@NotNull String path, double defaultValue); - @NotNull List getDoubleList(@NotNull String path); + default double getDouble(@NotNull String path) { + return getDouble(path, 0.0); + } + + /** + * Checks if the value at the specified path is a {@link Float}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a Float, {@code false} otherwise + */ boolean isFloat(@NotNull String path); - float getFloat(@NotNull String path); + + /** + * Gets the value at the specified path as a float, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as a float, or {@code defaultValue} if the path does not exist or is not set + */ float getFloat(@NotNull String path, float defaultValue); - @NotNull List getFloatList(@NotNull String path); + default float getFloat(@NotNull String path) { + return getFloat(path, 0.0f); + } + + /** + * Checks if the value at the specified path is a {@link Long}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a Long, {@code false} otherwise + */ boolean isLong(@NotNull String path); - long getLong(@NotNull String path); + + /** + * Gets the value at the specified path as a long, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the value at the specified path as a long, or {@code defaultValue} if the path does not exist or is not set + */ long getLong(@NotNull String path, long defaultValue); - @NotNull List getLongList(@NotNull String path); + default long getLong(@NotNull String path) { + return getLong(path, 0L); + } + + /** + * Checks if the value at the specified path is a {@link Boolean}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a Boolean, {@code false} otherwise + */ boolean isBoolean(@NotNull String path); - boolean getBoolean(@NotNull String path); + + /** + * Gets the value at the specified path as a boolean, + * or returns a default value if the path does not exist or is not set. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist or is not set + * + * @return the boolean value at the specified path, or {@code defaultValue} if the path does not exist or is not set + */ boolean getBoolean(@NotNull String path, boolean defaultValue); - @NotNull List getBooleanList(@NotNull String path); - Class getClass(@NotNull String path); + default boolean getBoolean(@NotNull String path) { + return getBoolean(path, false); + } + + + /** + * Gets the value at the specified path as a Class, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to a Class. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist, is not set, + * or cannot be converted to a Class + * + * @return the value at the specified path as a Class, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to a Class + */ Class getClass(@NotNull String path, @Nullable Class defaultValue); - > T getEnum(@NotNull String path, @NotNull Class clazz); + default Class getClass(@NotNull String path) { + return getClass(path, null); + } + + /** + * Gets the value at the specified path as an Enum, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to an Enum. + * + * @param path the path to the value + * @param clazz the class of the Enum to convert the value to + * @param defaultValue the default value to return if the path does not exist, is not set, or cannot be converted to an Enum + * + * @return the value at the specified path as an Enum, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to an Enum + */ > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue); - UUID getUUID(@NotNull String path); - UUID getUUID(@NotNull String path, @Nullable UUID defaultValue); + default > T getEnum(@NotNull String path, @NotNull Class clazz) { + return getEnum(path, clazz, null); + } - Date getDate(@NotNull String path); - Date getDate(@NotNull String path, @Nullable Date defaultValue); + /** + * Gets the value at the specified path as a UUID, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to a UUID. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist, is not set, or cannot be converted to a UUID + * + * @return the value at the specified path as a UUID, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to a UUID + */ + UUID getUUID(@NotNull String path, @Nullable UUID defaultValue); - OffsetDateTime getOffsetDateTime(@NotNull String path); - OffsetDateTime getOffsetDateTime(@NotNull String path, @Nullable OffsetDateTime defaultValue); + default UUID getUUID(@NotNull String path) { + return getUUID(path, null); + } - Version getVersion(@NotNull String path); Version getVersion(@NotNull String path, @Nullable Version defaultValue); + default Version getVersion(@NotNull String path) { + return getVersion(path, null); + } + + /** + * Checks if the value at the specified path is {@link Set} + * + * @param path the path to check + * + * @return {@code true} if the value at the specified path is set, {@code false} otherwise + */ boolean isSet(@NotNull String path); + /** + * Checks if the value at the specified path is a {@link List}. + * + * @param path the path to the value + * + * @return {@code true} if the value at the specified path is a List, {@code false} otherwise + */ boolean isList(@NotNull String path); - @Nullable List getList(@NotNull String path); + + /** + * Gets the value at the specified path as a List, + * or returns {@code null} if the path does not exist, is not set, + * or cannot be converted to a List. + * + * @param path the path to the value + * + * @return the value at the specified path as a List, or {@code null} if the path does not exist, is not set, or cannot be converted to a List + */ + @Nullable + List getList(@NotNull String path); + + /** + * Gets the value at the specified path as a List, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to a List. + * + * @param path the path to the value + * @param defaultValue the default value to return if the path does not exist, is not set, or cannot be converted to a List + * + * @return the value at the specified path as a List, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to a List + */ List getList(@NotNull String path, @Nullable List defaultValue); + + /** + * Gets the value at the specified path as a List of the specified class, + * or returns {@code null} if the path does not exist, is not set, + * or cannot be converted to a List of the specified class. + * + * @param path the path to the value + * @param clazz the class to convert the values in the list to + * + * @return the value at the specified path as a List of the specified class, or {@code null} if the path does not exist, is not set, or cannot be converted to a List of the specified class + */ List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue); - Collection keys(); - Map values(); - Map valuesAsString(); + @NotNull + List getStringList(@NotNull String path); + + @NotNull + List getBooleanList(@NotNull String path); + + @NotNull + List getIntegerList(@NotNull String path); + + @NotNull + List getDoubleList(@NotNull String path); + + @NotNull + List getFloatList(@NotNull String path); + + @NotNull + List getLongList(@NotNull String path); + + @NotNull + List getByteList(@NotNull String path); + + @NotNull + List getCharacterList(@NotNull String path); + + @NotNull + List getShortList(@NotNull String path); + + /** + * Gets the value at the specified path as a List of the specified class, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to a List of the specified class. + * + * @param path the path to the value + * + * @return the value at the specified path as a List of the specified class, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to a List of the specified class + */ + List> getMapList(@NotNull String path); + + /** + * Gets the value at the specified path as a List of the specified class, + * or returns a default value if the path does not exist, is not set, + * or cannot be converted to a List of the specified class. + * + * @param path the path to the value + * + * @return the value at the specified path as a List of the specified class, or {@code defaultValue} if the path does not exist, is not set, or cannot be converted to a List of the specified class + */ + List getSectionList(@NotNull String path); + + /** + * Gets a Collection of all keys in this config, including nested keys. + * + * @return a collection of all keys in this config, including nested keys. + * + * @see #keys(boolean) + */ + default Collection keys() { + return keys(true); + } + + /** + * Gets a Collection of all keys in this config, including nested keys. + * + * @param deep whether to include nested keys in the collection + * + * @return a collection of all keys in this config, including nested keys if {@code deep} is {@code true}. + */ + Collection keys(boolean deep); + + /** + * Gets a Map of all keys and values in this config, including nested keys and values. + * + * @return a map of all keys and values in this config, including nested keys and values. + * + * @see #values(boolean) + */ + default Map values() { + return values(true); + } + + /** + * Gets a Map of all keys and values in this config. + * + * @param deep whether to include nested keys and values in the map + * + * @return a map of all keys and values in this config, including nested keys and values if {@code deep} is {@code true}. + */ + Map values(boolean deep); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ResourceProvider.java b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceProvider.java new file mode 100644 index 0000000..6fa798e --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceProvider.java @@ -0,0 +1,21 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.io.Resource; +import org.jetbrains.annotations.NotNull; + +/** + * Interface for resolving resources. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@FunctionalInterface +public interface ResourceProvider { + + /** + * Provides a resource for further processing or usage. + * + * @return a non-null {@link Resource} object representing the provided resource + */ + @NotNull + Resource provide(); +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java new file mode 100644 index 0000000..d87d893 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java @@ -0,0 +1,74 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.representer.Representer; +import dev.spoocy.utils.config.update.ConfigUpdater; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface ResourceResolver { + + BaseResourceResolver DEFAULT = new BaseResourceResolver(Resources.class.getClassLoader(), + YamlConfigLoader.INSTANCE, + JsonConfigLoader.INSTANCE + ); + + SafeResourceResolver SAFE = new SafeResourceResolver(Resources.class.getClassLoader(), + YamlConfigLoader.INSTANCE, + JsonConfigLoader.INSTANCE + ); + + static BaseResourceResolver defaultResolver() { + return DEFAULT; + } + + static SafeResourceResolver safeResolver() { + return SAFE; + } + + /** + * Resolves a location string to a concrete {@link Resource}. + * + * @param path the string representation of the resource path; must not be null + * + * @return the resolved Resource instance; never null + */ + @NotNull + Resource resolve(@NotNull String path); + + @NotNull + Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException; + + @NotNull + Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor, @NotNull ConfigUpdater updater) + throws IOException; + + @NotNull + Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException; + + @NotNull + Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor, @NotNull ConfigUpdater updater) + throws IOException; + + @NotNull + T load(@NotNull Class type, @NotNull Readable readable); + + @NotNull + T load(@NotNull Class type, @NotNull Constructor constructor) throws IOException; + + @NotNull + T load(@NotNull Class type, @NotNull Constructor constructor, @NotNull Resource resource) throws IOException; + + void save(@NotNull Object instance, @NotNull Representer representer) throws IOException; + + void save(@NotNull Object instance, @NotNull Representer representer, @NotNull Resource resource) throws IOException; + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java b/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java new file mode 100644 index 0000000..4b2e8a2 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java @@ -0,0 +1,130 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.io.ClassPathResource; +import dev.spoocy.utils.config.io.FileSystemResource; +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.UrlResource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Utility entry points for creating resources. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public final class Resources { + + @NotNull + public static YamlConfigLoader yaml() { + return YamlConfigLoader.INSTANCE; + } + + @NotNull + public static JsonConfigLoader json() { + return JsonConfigLoader.INSTANCE; + } + + @NotNull + public static PathResource fromPath(@NotNull String path, @NotNull String... paths) { + Args.notNull(path, "path"); + Args.notNull(paths, "paths"); + return fromPath(Paths.get(path, paths)); + } + + @NotNull + public static PathResource fromPath(@NotNull Path path) { + Args.notNull(path, "path"); + return new PathResource(path); + } + + @NotNull + public static PathResource fromPath(@NotNull String path) { + Args.notNull(path, "path"); + return fromPath(Paths.get(path)); + } + + @NotNull + public static PathResource fromPath(@NotNull URI path) { + Args.notNull(path, "path"); + return fromPath(Paths.get(path)); + } + + @NotNull + public static FileSystemResource fromFile(@NotNull String path) { + Args.notNull(path, "path"); + return new FileSystemResource(path); + } + + @NotNull + public static FileSystemResource fromFile(@NotNull File file) { + Args.notNull(file, "file"); + return new FileSystemResource(file); + } + + @NotNull + public static FileSystemResource fromFile(@NotNull Path path) { + Args.notNull(path, "path"); + return new FileSystemResource(path); + } + + @NotNull + public static FileSystemResource fromFile(@NotNull FileSystem fileSystem, @NotNull String path) { + Args.notNull(fileSystem, "fileSystem"); + Args.notNull(path, "path"); + return new FileSystemResource(fileSystem, path); + } + + @NotNull + public static ClassPathResource fromJar(@NotNull String path, Class clazz) { + Args.notNull(path, "path"); + return new ClassPathResource(path, clazz); + } + + @NotNull + public static ClassPathResource fromJar(@NotNull String path, ClassLoader loader) { + Args.notNull(path, "path"); + return new ClassPathResource(path, loader); + } + + @NotNull + public static ClassPathResource fromJar(@NotNull String path) { + Args.notNull(path, "path"); + return fromJar(path, (ClassLoader) null); + } + + @NotNull + public static UrlResource fromUrl(@NotNull URL url) { + Args.notNull(url, "url"); + return new UrlResource(url); + } + + @NotNull + public static UrlResource fromUri(@NotNull URI uri) throws IOException { + Args.notNull(uri, "uri"); + return new UrlResource(uri); + } + + public static void copy(@NotNull Resource from, @NotNull WriteableResource to) throws IOException { + Args.notNull(from, "from resource"); + Args.notNull(to, "to resource"); + FileUtils.copy(from.getInputStream(), to.getOutputStream()); + } + + private Resources() { + throw new UnsupportedOperationException("Cannot instantiate utility class: " + Resources.class.getName()); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java new file mode 100644 index 0000000..69e4eb9 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java @@ -0,0 +1,41 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.ConfigLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SafeResourceResolver extends BaseResourceResolver { + + public SafeResourceResolver( + @Nullable ClassLoader classLoader, + @NotNull ConfigLoader... loader + ) { + super(classLoader, loader); + } + + @Override + public @NotNull Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { + Args.notNull(resource, "resource"); + Args.notNull(constructor, "constructor"); + + ConfigLoader loader = resolveLoader(resource); + if (loader == null) { + throw new IOException("No config loader for resource " + resource); + } + + if(!resource.exists()) { + return loader.createEmpty(); + } + + return loader.load(resource, constructor); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/SectionArray.java b/config-utils/src/main/java/dev/spoocy/utils/config/SectionArray.java deleted file mode 100644 index fa04ff1..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/SectionArray.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.spoocy.utils.config; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public interface SectionArray extends Iterable { - - int length(); - - T get(int index); - - T[] toArray(); - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java index 4c8c95e..e5dd3c3 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java @@ -1,6 +1,5 @@ package dev.spoocy.utils.config; -import dev.spoocy.utils.config.misc.SectionList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -8,11 +7,7 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public interface Writeable extends Readable, Commentable { - - void setReadOnly(); - - boolean isReadonly(); +public interface Writeable extends Commentable { void set(@NotNull String path, @Nullable Object value); @@ -21,15 +16,13 @@ public interface Writeable extends Readable, Commentable { void clear(); void opposite(@NotNull String path); + void multiply(@NotNull String path, double value); + void divide(@NotNull String path, double value); - void add(@NotNull String path, double value); - void subtract(@NotNull String path, double value); - @Override - Readable getSection(@NotNull String path); + void add(@NotNull String path, double value); - @Override - SectionList getSectionArray(@NotNull String path); + void subtract(@NotNull String path, double value); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java index 2c255fc..3dbe06b 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java @@ -1,459 +1,89 @@ package dev.spoocy.utils.config.components; -import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.common.log.ILogger; -import dev.spoocy.utils.common.misc.NumberConversion; +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.misc.FileUtils; import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.serializer.ConfigSerializer; -import dev.spoocy.utils.config.serializer.Serializer; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.Document; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.representer.Representer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.time.OffsetDateTime; -import java.util.*; -import java.util.function.Function; +import java.io.*; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** * @author Spoocy99 | GitHub: Spoocy99 */ -public abstract class AbstractConfig implements Config { +public abstract class AbstractConfig extends MemorySection implements Config { - private boolean readonly = false; - private final Config parent; + protected List header = Collections.emptyList(); + protected List footer = Collections.emptyList(); public AbstractConfig() { - this.parent = this; - } - - public AbstractConfig(@NotNull Config parent) { - this.parent = parent; - } - - @Override - public Config getParent() { - return this.parent; - } - - @Override - public void setReadOnly() { - this.readonly = true; - } - - @Override - public boolean isReadonly() { - return this.readonly; - } - - @Override - public void opposite(@NotNull String path) { - set(path, !getBoolean(path)); - } - - @Override - public void multiply(@NotNull String path, double value) { - set(path, getDouble(path) * value); - } - - @Override - public void divide(@NotNull String path, double value) { - set(path, getDouble(path) / value); - } - - @Override - public void add(@NotNull String path, double value) { - set(path, getDouble(path) + value); - } - - @Override - public void subtract(@NotNull String path, double value) { - set(path, getDouble(path) - value); - } - - @Override - public void set(@NotNull String path, @Nullable Object value) { - if(isReadonly()) throw new UnsupportedOperationException("Config is readonly!"); - - if(value == null) { - remove(path); - return; - } - - @SuppressWarnings("unchecked") - Serializer serializer = (Serializer) ConfigSerializer.resolve(value.getClass()); - - if(serializer != null) { - value = serializer.serialize(value); - - } else if (value instanceof byte[]) { - value = Base64.getEncoder().encodeToString((byte[]) value); - - } else if (value instanceof Enum) { - Enum type = (Enum) value; - value = type.name(); - } - - set0(path, value); - } - protected abstract void set0(@NotNull String path, @Nullable Object value); - - @Override - @Nullable - public Object getObject(@NotNull String path) { - return get0(path); - } - protected abstract @Nullable Object get0(@NotNull String path); - - @Override - public Object getObject(@NotNull String path, @Nullable Object defaultValue) { - Object value = getObject(path); - return value != null ? value : defaultValue; - } - - @Override - public T get(@NotNull String path, @NotNull Class clazz) { - Serializer serializer = ConfigSerializer.resolve(clazz); - - if(serializer != null) { - Config config = getSection(path); - - if(!config.keys().isEmpty()) { - - try { - return serializer.deserialize(config.values()); - } catch (Throwable e) { - ILogger.forThisClass().error("Failed to deserialize object of type " + clazz + " at path " + path, e); - } - - } - - } - - Object value = this.getObject(path); - if(value == null) { - return null; - } - - if(Number.class.isAssignableFrom(clazz)) { - return NumberConversion.convert(value, clazz); - } - return clazz.isInstance(value) ? clazz.cast(value) : null; - } - - @Override - public T get(@NotNull String path, @Nullable T defaultValue) { - if(defaultValue == null) return null; - - Class clazz = (Class) defaultValue.getClass(); - T value = get(path, clazz); - return value != null ? value : defaultValue; - } - - @Override - public boolean isOf(@NotNull String path, @NotNull Class clazz) { - Object object = getObject(path, clazz); - return clazz.isInstance(object); - } - - @Override - public T getSerializable(@NotNull String path, @Nullable T defaultValue) { - if(defaultValue == null) return null; - - T value = getSerializable(path, (Class) defaultValue.getClass()); - return value != null ? value : defaultValue; - } - - @Override - @Nullable - public T getSerializable(@NotNull String path, @NotNull Class clazz) { - if(!isSet(path)) { - return null; - } - return get(path, clazz); - } - - @Override - public boolean isString(@NotNull String path) { - return getObject(path) instanceof String; - } - - @Override - public @NotNull String getString(@NotNull String path) { - return getString(path, ""); - } - - @Override - public String getString(@NotNull String path, @NotNull String defaultValue) { - Object value = this.getObject(path, defaultValue); - return value != null ? value.toString() : defaultValue; - } - - @Override - public @NotNull List getStringList(@NotNull String path) { - List list = this.getList(path); - return map(list, Object::toString); - } - - @Override - public boolean isInt(@NotNull String path) { - return getObject(path) instanceof Integer; - } - - @Override - public int getInt(@NotNull String path) { - return getInt(path, 0); - } - - @Override - public int getInt(@NotNull String path, int defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toInt(value) : defaultValue; - } - - @Override - public @NotNull List getIntegerList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toInt); + super(); } @Override - public boolean isDouble(@NotNull String path) { - return getObject(path) instanceof Double; - } - - @Override - public double getDouble(@NotNull String path) { - return getDouble(path, 0.0); - } - - @Override - public double getDouble(@NotNull String path, double defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toDouble(value) : defaultValue; - } - - @Override - public @NotNull List getDoubleList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toDouble); - } - - @Override - public boolean isFloat(@NotNull String path) { - return getObject(path) instanceof Float; - } - - @Override - public float getFloat(@NotNull String path) { - return getFloat(path, 0f); - } - - @Override - public float getFloat(@NotNull String path, float defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toFloat(value) : defaultValue; - } - - @Override - public @NotNull List getFloatList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toFloat); - } - - @Override - public boolean isLong(@NotNull String path) { - return getObject(path) instanceof Long; - } - - @Override - public long getLong(@NotNull String path) { - return getLong(path, 0L); - } - - @Override - public long getLong(@NotNull String path, long defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toLong(value) : defaultValue; - } - - @Override - public @NotNull List getLongList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toLong); - } - - @Override - public boolean isBoolean(@NotNull String path) { - Object value = getObject(path, Boolean.class); - return value instanceof Boolean; - } - - @Override - public boolean getBoolean(@NotNull String path) { - return getBoolean(path, false); - } - - @Override - public boolean getBoolean(@NotNull String path, boolean defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Boolean ? (boolean) value : defaultValue; - } - - @Override - public @NotNull List getBooleanList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toBoolean); - } + public void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException { + Args.notNull(file, "File cannot be null"); + Args.notNull(representer, "Representer cannot be null"); +// if (file.exists() && !file.isWritable()) { +// throw new IOException("Cannot write to file: " + file.getFile().getPath()); +// } - @Override - public Class getClass(@NotNull String path) { - return getClass(path, null); - } - - @Override - public Class getClass(@NotNull String path, @Nullable Class defaultValue) { - try { - return Class.forName(getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public > T getEnum(@NotNull String path, @NotNull Class clazz) { - return getEnum(path, clazz, null); - } - - @Override - public > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - try { - return Enum.valueOf(clazz, getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public UUID getUUID(@NotNull String path) { - return getUUID(path, null); - } - - @Override - public UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { - try { - return UUID.fromString(getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public Date getDate(@NotNull String path) { - return getDate(path, null); - } - - @Override - public Date getDate(@NotNull String path, @Nullable Date defaultValue) { - try { - return Date.from(getOffsetDateTime(path).toInstant()); - } catch (Exception e) { - return defaultValue; - } - } - - @Override - public OffsetDateTime getOffsetDateTime(@NotNull String path) { - return getOffsetDateTime(path, null); - } + // Ensure parent directories exist before attempting to write. + // If the parent directories cannot be created due to permissions or other I/O issues, this + // will throw an IOException which we propagate to the caller. + FileUtils.createParentDirs(file.getFile()); - @Override - public OffsetDateTime getOffsetDateTime(@NotNull String path, @Nullable OffsetDateTime defaultValue) { - try { - return OffsetDateTime.parse(getString(path)); - } catch (Exception e) { - return defaultValue; + // Attempt to open the output stream and write. If the file cannot be written + // due to permissions or other I/O issues, the underlying calls will throw + // an IOException which we propagate to the caller. + try (Writer writer = new OutputStreamWriter(file.getOutputStream())) { + writer.write(saveToString(representer)); } } @Override - public Version getVersion(@NotNull String path) { - return getVersion(path, null); + public @NotNull Document withRelation(@NotNull Resource resource) { + return new FileDocument(this, resource); } @Override - public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { - try { - return Version.parse(getString(path)); - } catch (Exception e) { - return defaultValue; - } + public List getFooterComments() { + return this.footer; } @Override - public boolean isList(@NotNull String path) { - Object value = getObject(path); - return value instanceof List; + public List getHeaderComments() { + return this.header; } @Override - @Nullable - public List getList(@NotNull String path) { - return getList(path, null); + public void setHeaderComments(@Nullable List comments) { + this.header = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); } @Override - public List getList(@NotNull String path, @Nullable List defaultValue) { - Object value = getObject(path, defaultValue); - return (List) (value instanceof List ? value : defaultValue); + public void setFooterComments(@Nullable List comments) { + this.footer = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); } - @Override - public List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { - List list = new ArrayList<>(); - List value = getList(path, new ArrayList<>()); - - if(value == null || value.isEmpty()) { - return list; - } - - Serializer serializer = ConfigSerializer.resolve(clazz); - if(serializer != null) { - return map(list, object -> serializer.deserializeSafely((Map) object)); - } - - for(Object object : value) { - if(clazz.isInstance(object)) { - list.add(clazz.cast(object)); - } + protected static Map serializedValues( + @NotNull ConfigSection section, + @NotNull Representer representer + ) { + if (section instanceof MemorySection) { + return representer.represent((MemorySection) section); } - - return list; - } - - @Override - public Map valuesAsString() { - Map values = new HashMap<>(); - this.values().forEach((key, value) -> values.put(key, String.valueOf(value))); - return values; + return new LinkedHashMap<>(); } - private List map(@Nullable List list, @NotNull Function mapper) { - if(list == null) return Collections.emptyList(); - - List mapped = new ArrayList<>(); - for(Object object : list) { - - T value = null; - - try { - value = mapper.apply(object); - } catch (Throwable ignored) { } - - if(value != null) { - mapped.add(value); - } - } - - return mapped; - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java new file mode 100644 index 0000000..ff3e660 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java @@ -0,0 +1,356 @@ +package dev.spoocy.utils.config.components; + +import dev.spoocy.utils.common.misc.NumberConversion; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.serializer.Serializer; +import dev.spoocy.utils.config.serializer.Serializers; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.Function; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class AbstractDataHolder implements ConfigSection { + + private Serializers serializers() { + return this.getRoot().settings().serializers(); + } + + @Override + public void opposite(@NotNull String path) { + set(path, !getBoolean(path)); + } + + @Override + public void multiply(@NotNull String path, double value) { + set(path, getDouble(path) * value); + } + + @Override + public void divide(@NotNull String path, double value) { + set(path, getDouble(path) / value); + } + + @Override + public void add(@NotNull String path, double value) { + set(path, getDouble(path) + value); + } + + @Override + public void subtract(@NotNull String path, double value) { + set(path, getDouble(path) - value); + } + + @Override + public Object getObject(@NotNull String path, @Nullable Object defaultValue) { + Object value = getObject(path); + return value != null ? value : defaultValue; + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz) { + Object value = this.getObject(path); + if (value == null) { + return null; + } + + if (Number.class.isAssignableFrom(clazz)) { + return NumberConversion.convert(value, clazz); + } + return clazz.isInstance(value) ? clazz.cast(value) : null; + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + T value = get(path, clazz); + return value != null ? value : defaultValue; + } + + @Override + public boolean is(@NotNull String path, @NotNull Class clazz) { + Object object = getObject(path, clazz); + return clazz.isInstance(object); + } + + @Override + public T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + T value = getSerializable(path, clazz); + return value != null ? value : defaultValue; + } + + @Override + @Nullable + public T getSerializable(@NotNull String path, @NotNull Class clazz) { + Object value = getObject(path); + if (value == null) { + return null; + } + + if (clazz.isInstance(value)) { + return clazz.cast(value); + } + + Serializers serializers = this.serializers(); + + if (value instanceof ConfigSection) { + Serializer serializer = serializers.resolve(clazz); + if (serializer != null) { + return serializer.deserializeSafely(((ConfigSection) value).values(false)); + } + } + + if (value instanceof Map) { + Serializer serializer = serializers.resolve(clazz); + if (serializer != null) { + return serializer.deserializeSafely((Map) value); + } + } + + return null; + } + + @Override + public boolean isString(@NotNull String path) { + return getObject(path) instanceof String; + } + + @Override + public String getString(@NotNull String path, @Nullable String defaultValue) { + Object value = this.getObject(path, defaultValue); + return value != null ? value.toString() : defaultValue; + } + + @Override + public boolean isInt(@NotNull String path) { + return getObject(path) instanceof Integer; + } + + @Override + public int getInt(@NotNull String path, int defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toInt(value) : defaultValue; + } + + @Override + public boolean isDouble(@NotNull String path) { + return getObject(path) instanceof Double; + } + + @Override + public double getDouble(@NotNull String path, double defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toDouble(value) : defaultValue; + } + + @Override + public boolean isFloat(@NotNull String path) { + return getObject(path) instanceof Float; + } + + @Override + public float getFloat(@NotNull String path, float defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toFloat(value) : defaultValue; + } + + @Override + public boolean isLong(@NotNull String path) { + return getObject(path) instanceof Long; + } + + @Override + public long getLong(@NotNull String path, long defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toLong(value) : defaultValue; + } + + @Override + public boolean isBoolean(@NotNull String path) { + Object value = getObject(path, Boolean.class); + return value instanceof Boolean; + } + + @Override + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Boolean ? (boolean) value : defaultValue; + } + + @Override + public Class getClass(@NotNull String path, @Nullable Class defaultValue) { + try { + return Class.forName(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + try { + return Enum.valueOf(clazz, getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { + try { + return UUID.fromString(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { + try { + return Version.parse(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public boolean isList(@NotNull String path) { + Object value = getObject(path); + return value instanceof List; + } + + @Override + @Nullable + public List getList(@NotNull String path) { + return getList(path, null); + } + + @Override + public List getList(@NotNull String path, @Nullable List defaultValue) { + Object value = getObject(path, defaultValue); + return (List) (value instanceof List ? value : defaultValue); + } + + @Override + public List> getMapList(@NotNull String path) { + List list = getList(path, new ArrayList<>()); + List> mapList = new ArrayList<>(); + + if (list == null || list.isEmpty()) { + return mapList; + } + + for (Object object : list) { + if (object instanceof Map) { + mapList.add((Map) object); + } + } + + return mapList; + } + + @Override + public List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { + List list = new ArrayList<>(); + List value = getList(path, new ArrayList<>()); + + if (value == null || value.isEmpty()) { + return list; + } + + Serializer serializer = this.serializers().resolve(clazz); + if (serializer != null) { + return map(list, object -> serializer.deserializeSafely((Map) object)); + } + + for (Object object : value) { + if (clazz.isInstance(object)) { + list.add(clazz.cast(object)); + } + } + + return list; + } + + @Override + public @NotNull List getStringList(@NotNull String path) { + List list = this.getList(path); + return map(list, Object::toString); + } + + @Override + public @NotNull List getBooleanList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toBoolean); + } + + @Override + public @NotNull List getIntegerList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toInt); + } + + @Override + public @NotNull List getDoubleList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toDouble); + } + + @Override + public @NotNull List getLongList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toLong); + } + + @Override + public @NotNull List getFloatList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toFloat); + } + + @Override + public @NotNull List getByteList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toByte); + } + + @Override + public @NotNull List getCharacterList(@NotNull String path) { + List list = this.getList(path); + return map(list, object -> { + String string = object.toString(); + return string.isEmpty() ? null : string.charAt(0); + }); + } + + @Override + public @NotNull List getShortList(@NotNull String path) { + List list = this.getList(path); + return map(list, NumberConversion::toShort); + } + + private List map(@Nullable List list, @NotNull Function mapper) { + if (list == null) return Collections.emptyList(); + + List mapped = new ArrayList<>(); + for (Object object : list) { + + T value = null; + + try { + value = mapper.apply(object); + } catch (Throwable ignored) { + } + + if (value != null) { + mapped.add(value); + } + } + + return mapped; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDocument.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDocument.java deleted file mode 100644 index b519c1d..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDocument.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.spoocy.utils.config.components; - -import dev.spoocy.utils.common.log.ILogger; -import dev.spoocy.utils.common.scheduler.Scheduler; -import dev.spoocy.utils.common.scheduler.task.Task; -import dev.spoocy.utils.config.Document; - -import java.io.IOException; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public abstract class AbstractDocument implements Document { - - @Override - public void save() throws IOException { - getConfig().save(getFile()); - } - - @Override - public boolean saveSafely() { - try { - save(getFile()); - return true; - } catch (IOException e) { - ILogger.forThisClass().error("An error occurred while saving document at " + getFile().getPath(), e); - return false; - } - } - - @Override - public Task saveAsync() { - return Scheduler.runAsyncCallable(() -> { - save(); - return null; - }); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java new file mode 100644 index 0000000..fedecf8 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java @@ -0,0 +1,123 @@ +package dev.spoocy.utils.config.components; + +import dev.spoocy.utils.config.serializer.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigNode { + + @NotNull + private Tag tag; + + @Nullable + private Class type; + + @Nullable + private Object data; + + @NotNull + private List comments; + + @NotNull + private List inlineComments; + + public ConfigNode(@Nullable Object data) { + this(data, null, null); + } + + public ConfigNode( + @Nullable Object data, + @Nullable List comments, + @Nullable List inlineComments + ) { + this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + + setData(data); + } + + @NotNull + public Tag getTag() { + return this.tag; + } + + @Nullable + public Object getData() { + return this.data; + } + + @Nullable + public Class getType() { + return this.type; + } + + public void setData(@Nullable Object data) { + this.data = data; + this.type = data == null ? null : data.getClass(); + this.tag = resolveTag(data); + } + + @NotNull + private static Tag resolveTag(@Nullable Object data) { + if (data == null) { + return Tag.NULL; + } + + if (data instanceof MemorySection) { + return Tag.SECTION; + } + + if (data instanceof java.util.Map) { + return Tag.MAP; + } + + if (data instanceof java.util.Collection || data.getClass().isArray()) { + return Tag.SEQ; + } + + if (data instanceof Boolean) { + return Tag.BOOL; + } + + if (data instanceof Byte || data instanceof Short || data instanceof Integer || data instanceof Long || data instanceof BigInteger) { + return Tag.INT; + } + + if (data instanceof Float || data instanceof Double || data instanceof BigDecimal) { + return Tag.FLOAT; + } + + if (data instanceof CharSequence || data instanceof Character) { + return Tag.STR; + } + + return new Tag(data.getClass()); + } + + @NotNull + public List getComments() { + return this.comments; + } + + public void setComments(@Nullable List comments) { + this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + } + + @NotNull + public List getInlineComments() { + return this.inlineComments; + } + + public void setInlineComments(@Nullable List inlineComments) { + this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/DocumentFile.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/DocumentFile.java deleted file mode 100644 index 9b798c7..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/DocumentFile.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.spoocy.utils.config.components; - -import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.Document; -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.nio.file.Path; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class DocumentFile extends AbstractDocument { - - private Config document; - private final File file; - - public DocumentFile(@NotNull Config document, @NotNull Path path) { - this.document = document; - this.file = path.toFile(); - } - - public DocumentFile(@NotNull Config document, @NotNull File file) { - this.document = document; - this.file = file; - } - - @Override - public @NotNull Config getConfig() { - return this.document; - } - - @Override - public @NotNull File getFile() { - return this.file; - } - - @Override - public @NotNull Path getPath() { - return this.file.toPath(); - } - - @Override - public void reload() { - this.document = Config.readFile(this.document.getClass(), this.file); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java new file mode 100644 index 0000000..19d233a --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java @@ -0,0 +1,398 @@ +package dev.spoocy.utils.config.components; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.Document; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.types.ConfigSettings; +import dev.spoocy.utils.config.representer.Representer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class FileDocument implements Document { + + private final Resource relation; + private final Config config; + + public FileDocument(@NotNull Config config, @NotNull Resource relation) { + this.config = config; + this.relation = relation; + } + + @Override + public @NotNull Resource getRelation() { + return this.relation; + } + + @Override + public void save() throws IOException { + if (this.relation instanceof WriteableResource) { + this.save((WriteableResource) this.relation, this.settings().representer()); + return; + } + + throw new IOException("Relation is not writable."); + } + + @Override + public @NotNull ConfigSettings settings() { + return this.config.settings(); + } + + @Override + public @NotNull String saveToString(@NotNull Representer representer) { + return this.config.saveToString(representer); + } + + @Override + public void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException { + this.config.save(file, representer); + } + + @Override + public @NotNull Document withRelation(@NotNull Resource resource) { + return this.config.withRelation(resource); + } + + @Override + public @NotNull String getName() { + return this.config.getName(); + } + + @Override + public @NotNull String getWorkingPath() { + return this.config.getWorkingPath(); + } + + @Override + public @NotNull Config getRoot() { + return this.config.getRoot(); + } + + @Override + public @Nullable ConfigSection getParent() { + return this.config.getParent(); + } + + @Override + public boolean isSection(@NotNull String path) { + return this.config.isSection(path); + } + + @Override + public @Nullable ConfigSection getSection(@NotNull String path) { + return this.config.getSection(path); + } + + @Override + public @NotNull ConfigSection getOrCreateSection(@NotNull String path) { + return this.config.getOrCreateSection(path); + } + + @Override + public @NotNull ConfigSection createSection(@NotNull String path) { + return this.config.createSection(path); + } + + @Override + public @NotNull ConfigSection createSection(@NotNull String path, @NotNull Map map) { + return this.config.createSection(path, map); + } + + @Override + public @Nullable Object getObject(@NotNull String path) { + return this.config.getObject(path); + } + + @Override + public @Nullable Object getObject(@NotNull String path, @Nullable Object defaultValue) { + return this.config.getObject(path, defaultValue); + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz) { + return this.config.get(path, clazz); + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + return this.config.get(path, clazz, defaultValue); + } + + @Override + public boolean is(@NotNull String path, @NotNull Class clazz) { + return this.config.is(path, clazz); + } + + @Override + public T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + return this.config.getSerializable(path, clazz, defaultValue); + } + + @Override + public @Nullable T getSerializable(@NotNull String path, @NotNull Class clazz) { + return this.config.getSerializable(path, clazz); + } + + @Override + public boolean isString(@NotNull String path) { + return this.config.isString(path); + } + + @Override + public @NotNull String getString(@NotNull String path, @Nullable String defaultValue) { + return this.config.getString(path, defaultValue); + } + + @Override + public boolean isInt(@NotNull String path) { + return this.config.isInt(path); + } + + @Override + public int getInt(@NotNull String path, int defaultValue) { + return this.config.getInt(path, defaultValue); + } + + @Override + public boolean isDouble(@NotNull String path) { + return this.config.isDouble(path); + } + + @Override + public double getDouble(@NotNull String path, double defaultValue) { + return this.config.getDouble(path, defaultValue); + } + + @Override + public boolean isFloat(@NotNull String path) { + return this.config.isFloat(path); + } + + @Override + public float getFloat(@NotNull String path, float defaultValue) { + return this.config.getFloat(path, defaultValue); + } + + @Override + public boolean isLong(@NotNull String path) { + return this.config.isLong(path); + } + + @Override + public long getLong(@NotNull String path, long defaultValue) { + return this.config.getLong(path, defaultValue); + } + + @Override + public boolean isBoolean(@NotNull String path) { + return this.config.isBoolean(path); + } + + @Override + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + return this.config.getBoolean(path, defaultValue); + } + + @Override + public Class getClass(@NotNull String path, @Nullable Class defaultValue) { + return this.config.getClass(path, defaultValue); + } + + @Override + public > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + return this.config.getEnum(path, clazz, defaultValue); + } + + @Override + public UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { + return this.config.getUUID(path, defaultValue); + } + + @Override + public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { + return this.config.getVersion(path, defaultValue); + } + + @Override + public boolean isSet(@NotNull String path) { + return this.config.isSet(path); + } + + @Override + public boolean isList(@NotNull String path) { + return this.config.isList(path); + } + + @Override + public @Nullable List getList(@NotNull String path) { + return this.config.getList(path); + } + + @Override + public List getList(@NotNull String path, @Nullable List defaultValue) { + return this.config.getList(path, defaultValue); + } + + @Override + public List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { + return this.config.getList(path, clazz, defaultValue); + } + + @Override + public @NotNull List getStringList(@NotNull String path) { + return this.config.getStringList(path); + } + + @Override + public @NotNull List getBooleanList(@NotNull String path) { + return this.config.getBooleanList(path); + } + + @Override + public @NotNull List getIntegerList(@NotNull String path) { + return this.config.getIntegerList(path); + } + + @Override + public @NotNull List getDoubleList(@NotNull String path) { + return this.config.getDoubleList(path); + } + + @Override + public @NotNull List getFloatList(@NotNull String path) { + return this.config.getFloatList(path); + } + + @Override + public @NotNull List getLongList(@NotNull String path) { + return this.config.getLongList(path); + } + + @Override + public @NotNull List getByteList(@NotNull String path) { + return this.config.getByteList(path); + } + + @Override + public @NotNull List getCharacterList(@NotNull String path) { + return this.config.getCharacterList(path); + } + + @Override + public @NotNull List getShortList(@NotNull String path) { + return this.config.getShortList(path); + } + + @Override + public List> getMapList(@NotNull String path) { + return this.config.getMapList(path); + } + + @Override + public List getSectionList(@NotNull String path) { + return this.config.getSectionList(path); + } + + @Override + public Collection keys(boolean deep) { + return this.config.keys(deep); + } + + @Override + public Map values(boolean deep) { + return this.config.values(deep); + } + + @Override + public void set(@NotNull String path, @Nullable Object value) { + this.config.set(path, value); + } + + @Override + public void remove(@NotNull String path) { + this.config.remove(path); + } + + @Override + public void clear() { + this.config.clear(); + } + + @Override + public void opposite(@NotNull String path) { + this.config.opposite(path); + } + + @Override + public void multiply(@NotNull String path, double value) { + this.config.multiply(path, value); + } + + @Override + public void divide(@NotNull String path, double value) { + this.config.divide(path, value); + } + + @Override + public void add(@NotNull String path, double value) { + this.config.add(path, value); + } + + @Override + public void subtract(@NotNull String path, double value) { + this.config.subtract(path, value); + } + + @Override + public List getHeaderComments() { + return this.config.getHeaderComments(); + } + + @Override + public List getFooterComments() { + return this.config.getFooterComments(); + } + + @Override + public List getComments(@NotNull String path) { + return this.config.getComments(path); + } + + @Override + public List getInlineComments(@NotNull String path) { + return this.config.getInlineComments(path); + } + + @Override + public void setHeaderComments(@Nullable List comments) { + this.config.setHeaderComments(comments); + } + + @Override + public void setFooterComments(@Nullable List comments) { + this.config.setFooterComments(comments); + } + + @Override + public void setComments(@NotNull String path, @Nullable List comments) { + this.config.setComments(path, comments); + } + + @Override + public void setInlineComments(@NotNull String path, @Nullable List comments) { + this.config.setInlineComments(path, comments); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java new file mode 100644 index 0000000..4b084f7 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java @@ -0,0 +1,409 @@ +package dev.spoocy.utils.config.components; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.tuple.Pair; +import dev.spoocy.utils.config.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class MemorySection extends AbstractDataHolder implements ConfigSection { + + protected final Map map = new LinkedHashMap<>(); + private final Config root; + private final ConfigSection parent; + private final String path; + private final String fullPath; + + public MemorySection() { + if (!(this instanceof Config)) { + throw new IllegalStateException("MemorySection must be a Config if no parent is provided"); + } + this.root = (Config) this; + this.parent = null; + this.path = ""; + this.fullPath = ""; + } + + public MemorySection(@NotNull ConfigSection parent, @NotNull String path) { + this.parent = Args.notNull(parent, "parent"); + this.path = Args.notNull(path, "path"); + this.root = parent.getRoot(); + this.fullPath = createPath(parent, path); + } + + @Override + public @NotNull String getName() { + return this.path; + } + + @Override + public @NotNull String getWorkingPath() { + return this.fullPath; + } + + @Override + public @NotNull Config getRoot() { + return this.root; + } + + @Override + public @Nullable ConfigSection getParent() { + return this.parent; + } + + public List> entries() { + List> entries = new ArrayList<>(); + + for (Map.Entry entry : this.map.entrySet()) { + entries.add(new Pair<>(entry.getKey(), entry.getValue())); + } + + return entries; + } + + @Nullable + private ConfigNode getConfigValue(@NotNull String path) { + PathContext context = this.resolvePath(path); + + if (context.hasNextPath()) { + ConfigSection section = getSection(context.key); + + if (section instanceof MemorySection) { + return ((MemorySection) section).getConfigValue(context.nextPath); + } + + return null; + } + + return this.map.get(context.key); + } + + @Override + public List getHeaderComments() { + return this.root.getHeaderComments(); + } + + @Override + public List getFooterComments() { + return this.root.getFooterComments(); + } + + @Override + public void setHeaderComments(@Nullable List comments) { + this.root.setHeaderComments(comments); + } + + @Override + public void setFooterComments(@Nullable List comments) { + this.root.setFooterComments(comments); + } + + @Override + public List getComments(@NotNull String path) { + ConfigNode value = getConfigValue(path); + return value == null ? Collections.emptyList() : value.getComments(); + } + + @Override + public List getInlineComments(@NotNull String path) { + ConfigNode value = getConfigValue(path); + return value == null ? Collections.emptyList() : value.getInlineComments(); + } + + @Override + public void setComments(@NotNull String path, @Nullable List comments) { + ConfigNode value = getConfigValue(path); + + if (value == null) { + return; + } + + value.setComments(comments); + } + + @Override + public void setInlineComments(@NotNull String path, @Nullable List comments) { + ConfigNode value = getConfigValue(path); + + if (value == null) { + return; + } + + value.setInlineComments(comments); + } + + + @Override + public void set(@NotNull String path, @Nullable Object value) { + PathContext context = this.resolvePath(path); + + if (context.hasNextPath()) { + ConfigSection section = getSection(context.key); + + if (section == null) { + + if (value == null) { + // Value should be removed, but the section doesn't exist, so it is effectively removed already + return; + } + + section = createSection(context.key); + } + + section.set(context.nextPath, value); + return; + } + + if (value == null) { + this.map.remove(context.key); + return; + } + + ConfigNode configValue = this.map.get(context.key); + + if (configValue == null) { + this.map.put(context.key, new ConfigNode(value)); + return; + } + + configValue.setData(value); + } + + @Override + public void remove(@NotNull String path) { + PathContext context = this.resolvePath(path); + + if (context.hasNextPath()) { + ConfigSection section = getSection(context.key); + + if (section == null) { + return; + } + + section.remove(context.nextPath); + return; + } + + this.map.remove(context.key); + } + + @Override + public void clear() { + this.map.clear(); + } + + @Override + public boolean isSet(@NotNull String path) { + return getConfigValue(path) != null; + } + + @Override + public List getSectionList(@NotNull String path) { + List> list = this.getMapList(path); + final List sections = new ArrayList<>(); + + for (Map map : list) { + ConfigSection section = new MemorySection(this, path); + + for (Map.Entry entry : map.entrySet()) { + setSectionValue(section, entry.getKey(), entry.getValue()); + } + sections.add(section); + } + + return sections; + } + + private void setSectionValue(@NotNull ConfigSection section, @NotNull String key, @Nullable Object value) { + if (value instanceof Map) { + ConfigSection child = section.createSection(key); + + for (Map.Entry entry : ((Map) value).entrySet()) { + if (entry.getKey() instanceof String) { + setSectionValue(child, (String) entry.getKey(), entry.getValue()); + } + } + return; + } + + section.set(key, value); + } + + @Override + public @Nullable Object getObject(@NotNull String path) { + ConfigNode value = getConfigValue(path); + return value == null ? null : value.getData(); + } + + @Override + public Collection keys(boolean deep) { + Set keys = new LinkedHashSet<>(); + + for (String key : this.map.keySet()) { + keys.add(key); + + if (deep) { + ConfigSection section = getSection(key); + + if (section != null) { + for (String subKey : section.keys(true)) { + keys.add(key + root.settings() + .pathSeparator() + subKey); + } + } + } + } + + return keys; + } + + @Override + public Map values(boolean deep) { + Map values = new LinkedHashMap<>(); + + for (Map.Entry entry : this.map.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue() + .getData(); + + values.put(key, value); + + if (deep) { + ConfigSection section = getSection(key); + + if (section != null) { + for (Map.Entry subEntry : section.values(true) + .entrySet()) { + values.put(key + root.settings() + .pathSeparator() + subEntry.getKey(), subEntry.getValue()); + } + } + } + } + + return values; + } + + @Override + public boolean isSection(@NotNull String path) { + Object value = getObject(path); + return value instanceof ConfigSection; + } + + @Override + public @Nullable ConfigSection getSection(@NotNull String path) { + Object value = getObject(path); + + if (value instanceof ConfigSection) { + return (ConfigSection) value; + } + + return null; + } + + @Override + public @NotNull ConfigSection getOrCreateSection(@NotNull String path) { + ConfigSection section = getSection(path); + + if (section == null) { + return createSection(path); + } + + return section; + } + + @Override + public @NotNull ConfigSection createSection(@NotNull String path) { + if (isSet(path)) { + throw new IllegalArgumentException("Cannot create section at path '" + path + "' because an object already exists."); + } + + ConfigSection section = new MemorySection(this, path); + set(path, section); + return section; + } + + @Override + public @NotNull ConfigSection createSection(@NotNull String path, @NotNull Map map) { + ConfigSection section = createSection(path); + + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw new IllegalArgumentException("Only String keys are supported in section maps"); + } + + section.set((String) entry.getKey(), entry.getValue()); + } + + return section; + } + + private PathContext resolvePath(@NotNull String path) { + final char sectionSeparator = this.root.settings().pathSeparator(); + int separatorIndex = path.indexOf(sectionSeparator); + + if (separatorIndex == -1) { + return new PathContext(path, null); + } + + return new PathContext( + path.substring(0, separatorIndex), + path.substring(separatorIndex + 1) + ); + } + + private static final class PathContext { + + private final String key; + private final String nextPath; + + private PathContext(@NotNull String key, @Nullable String nextPath) { + this.key = key; + this.nextPath = nextPath; + } + + private boolean hasNextPath() { + return this.nextPath != null; + } + } + + @NotNull + private static String createPath(@NotNull ConfigSection section, @Nullable String key) { + return createPath(section, key, section.getRoot()); + } + + @NotNull + private static String createPath(@NotNull ConfigSection section, @Nullable String key, @Nullable ConfigSection relativeTo) { + Args.notNull(section, "Cannot create path without a section"); + + Config root = section.getRoot(); + + char separator = root.settings().pathSeparator(); + + StringBuilder builder = new StringBuilder(); + + for (ConfigSection parent = section; parent != relativeTo; parent = parent.getParent()) { + if (builder.length() > 0) { + builder.insert(0, separator); + } + builder.insert(0, parent.getName()); + } + + if ((key != null) && (!key.isEmpty())) { + if (builder.length() > 0) { + builder.append(separator); + } + + builder.append(key); + } + + return builder.toString(); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java new file mode 100644 index 0000000..7d50ae4 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java @@ -0,0 +1,75 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.common.tuple.Pair; +import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.serializer.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class BaseConstructor implements Constructor { + + private final Map constructs = new HashMap<>(); + + public BaseConstructor() { + + } + + protected void setConstruct(@NotNull Tag tag, @NotNull Construct construct) { + this.constructs.put(tag, construct); + } + + protected void setConstruct(@NotNull Class clazz, @NotNull Construct construct) { + setConstruct(new Tag(clazz), construct); + } + + @Override + public void constructMappings(@NotNull MemorySection section, Map mapping) { + flattenMappings(section); + } + + @Nullable + protected Construct getConstruct(@NotNull Tag tag) { + return this.constructs.get(tag); + } + + protected void flattenMappings(@NotNull MemorySection section) { + List> nodes = section.entries(); + + for (Pair pair : nodes) { + ConfigNode node = pair.second(); + + if (node.getTag() == Tag.SECTION && node.getData() instanceof MemorySection) { + MemorySection data = (MemorySection) node.getData(); + flattenMappings(data); + } + + Object value = construct(node); + if (value != null) { + node.setData(value); + } + } + } + + protected Object construct(@Nullable ConfigNode node) { + if (node == null) { + return null; + } + + Tag tag = node.getTag(); + Construct construct = getConstruct(tag); + + if (construct != null) { + return construct.construct(node); + } + return null; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java new file mode 100644 index 0000000..c858cda --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java @@ -0,0 +1,22 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.components.ConfigNode; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +@FunctionalInterface +public interface Construct { + + /** + * Constructs and returns an object based on the provided ConfigNode. + * + * @param node the ConfigNode containing the necessary data for the construction process; must not be null + * + * @return the constructed object based on the data and structure of the given ConfigNode + */ + Object construct(@NotNull ConfigNode node); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java new file mode 100644 index 0000000..8c7fa35 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java @@ -0,0 +1,16 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.components.MemorySection; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface Constructor { + + void constructMappings(@NotNull MemorySection section, Map mapping); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java new file mode 100644 index 0000000..41367f1 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java @@ -0,0 +1,18 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.serializer.Serializers; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface SerializerAssignable { + + /** + * Retrieves the registered {@link Serializers} instance. + * + * @return the registered Serializers instance + */ + Serializers getSerializers(); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java new file mode 100644 index 0000000..18e6344 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java @@ -0,0 +1,183 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import dev.spoocy.utils.config.serializer.Serializer; +import dev.spoocy.utils.config.serializer.Serializers; +import dev.spoocy.utils.config.serializer.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +public class SerializingConstructor extends BaseConstructor implements SerializerAssignable { + + public static final SerializingConstructor DEFAULT_INSTANCE = new SerializingConstructor(NamedSerializers.DEFAULT_INSTANCE); + + @NotNull + private final Serializers serializers; + + public SerializingConstructor(@NotNull Serializers serializers) { + this.serializers = Args.notNull(serializers, "serializers"); + + setConstruct(Tag.SECTION, this::constructSection); + setConstruct(Tag.MAP, this::constructMap); + setConstruct(Tag.SEQ, this::constructList); + } + + @Override + public @NotNull Serializers getSerializers() { + return serializers; + } + + private boolean shouldDeserialize(@NotNull Map map) { + Object type = map.get(NamedSerializers.SERIALIZED_TYPE_KEY); + return type instanceof String; + } + + @Override + public void constructMappings(@NotNull MemorySection section, @NotNull Map mapping) { + for (Map.Entry entry : mapping.entrySet()) { + if (!(entry.getKey() instanceof String)) { + continue; + } + + String key = (String) entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof Map && !shouldDeserialize((Map) value)) { + ConfigSection child = section.createSection(key); + this.constructMappings((MemorySection) child, castMap((Map) value)); + continue; + } + + section.set(key, normalize(value)); + } + + super.constructMappings(section, mapping); + } + + @Nullable + @Override + protected Object construct(@Nullable ConfigNode node) { + Object value = super.construct(node); + if (value != null) { + return value; + } + + if (node == null) { + return null; + } + + Class type = node.getType(); + if (type != null && node.getData() instanceof Map) { + Serializer serializer = serializers.resolve(type); + if (serializer != null) { + Object deserialized = serializer.deserializeSafely(castStringMap((Map) node.getData())); + if (deserialized != null) { + return deserialized; + } + } + } + + return node.getData(); + } + + @Nullable + private Object constructSection(@NotNull ConfigNode node) { + Object data = node.getData(); + if (data instanceof MemorySection) { + super.flattenMappings((MemorySection) data); + } + return data; + } + + @Nullable + private Object constructMap(@NotNull ConfigNode node) { + if (!(node.getData() instanceof Map)) { + return node.getData(); + } + + Map map = (Map) node.getData(); + if (shouldDeserialize(map)) { + Object deserialized = serializers.deserialize(castStringMap(map)); + if (deserialized != null) { + return deserialized; + } + } + + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(entry.getKey(), normalize(entry.getValue())); + } + + return copy; + } + + @Nullable + private Object constructList(@NotNull ConfigNode node) { + Object data = node.getData(); + if (!(data instanceof List)) { + return data; + } + + List represented = new ArrayList<>(((List) data).size()); + for (Object element : (List) data) { + represented.add(normalize(element)); + } + + return represented; + } + + @Nullable + private Object normalize(@Nullable Object value) { + if (value instanceof Map) { + Map map = (Map) value; + + if (shouldDeserialize(map)) { + Object deserialized = serializers.deserialize(castStringMap(map)); + if (deserialized != null) { + return deserialized; + } + } + + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(entry.getKey(), normalize(entry.getValue())); + } + return copy; + } + + if (value instanceof List) { + List represented = new ArrayList<>(((List) value).size()); + for (Object element : (List) value) { + represented.add(normalize(element)); + } + return represented; + } + + return value; + } + + @NotNull + private static Map castMap(@NotNull Map map) { + Map typed = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + typed.put(entry.getKey(), entry.getValue()); + } + return typed; + } + + @NotNull + private static Map castStringMap(@NotNull Map map) { + Map typed = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + typed.put(String.valueOf(entry.getKey()), entry.getValue()); + } + return typed; + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/AbstractResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/AbstractResource.java new file mode 100644 index 0000000..4152e4a --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/AbstractResource.java @@ -0,0 +1,78 @@ +package dev.spoocy.utils.config.io; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class AbstractResource implements Resource { + + @Override + public URL getURL() throws IOException { + throw new FileNotFoundException(getDescription() + " cannot be resolved to URL"); + } + + @Override + public URI getURI() throws IOException { + URL url = getURL(); + + try { + return url.toURI(); + } catch (URISyntaxException ex) { + throw new IOException("Invalid URI syntax: " + url, ex); + } + } + + @Override + public long contentLength() throws IOException { + try (InputStream is = getInputStream()) { + long size = 0; + byte[] buf = new byte[256]; + int read; + + while ((read = is.read(buf)) != -1) { + size += read; + } + + return size; + } + } + + + @Override + public long lastModified() throws IOException { + File fileToCheck = getFileForLastModifiedCheck(); + + long lastModified = fileToCheck.lastModified(); + + if (lastModified == 0L && !fileToCheck.exists()) { + throw new FileNotFoundException("Resource cannot be resolved: " + getDescription()); + } + + return lastModified; + } + + protected File getFileForLastModifiedCheck() throws IOException { + return getFile(); + } + + protected abstract String getDescription(); + + @Override + public int hashCode() { + return this.getDescription() + .hashCode(); + } + + @Override + public String toString() { + return getDescription(); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java new file mode 100644 index 0000000..d5974fe --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java @@ -0,0 +1,183 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; + +/** + * Classpath based {@link Resource} implementation. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ClassPathResource extends ResolvableResource { + + @NotNull + private final String path; + + @NotNull + private final String absolutePath; + + @Nullable + private final ClassLoader classLoader; + + @Nullable + private final Class clazz; + + public ClassPathResource(@NotNull String path, @Nullable Class clazz) { + this.path = FileUtils.cleanPath(path); + + String absolutePath = this.path; + if (clazz != null && !absolutePath.startsWith("/")) { + absolutePath = FileUtils.classPackageAsResourcePath(clazz) + "/" + absolutePath; + } else if (absolutePath.startsWith("/")) { + absolutePath = absolutePath.substring(1); + } + + this.absolutePath = absolutePath; + this.classLoader = null; + this.clazz = clazz; + } + + public ClassPathResource(@NotNull String path, @Nullable ClassLoader classLoader) { + + String cleaned = FileUtils.cleanPath(path); + if (cleaned.startsWith("/")) { + cleaned = cleaned.substring(1); + } + + this.path = cleaned; + this.absolutePath = cleaned; + this.classLoader = classLoader != null ? classLoader : getDefaultClassLoader(); + this.clazz = null; + } + + @Nullable + private URL resolveURL() { + try { + if (this.clazz != null) { + return this.clazz.getResource(this.path); + } + + if (this.classLoader != null) { + return this.classLoader.getResource(this.absolutePath); + } + + return ClassLoader.getSystemResource(this.absolutePath); + } catch (IllegalArgumentException ex) { + return null; + } + } + + @Override + protected String getDescription() { + return "ClassPath Resource [" + this.path + "]"; + } + + @Override + public boolean exists() { + return resolveURL() != null; + } + + @Override + public URL getURL() throws IOException { + URL url = this.resolveURL(); + + if (url == null) { + throw new FileNotFoundException("Resource not found: " + this.path); + } + + return url; + } + + @Override + public URI getURI() throws IOException { + try { + return getURL().toURI(); + } catch (URISyntaxException e) { + throw new IOException("Invalid URI syntax: " + getURL(), e); + } + } + + @Override + public boolean isFile() { + URL url = this.resolveURL(); + return url != null && FileUtils.isFileURL(url); + } + + @Override + public File getFile() throws IOException { + throw new FileNotFoundException("Classpath resource cannot be resolved to absolute file path: " + this.path); + } + + @Override + public @NotNull String getFilename() { + return FileUtils.getFileName(this.path, true); + } + + @Override + public Path getPath() { + return Path.of(this.absolutePath); + } + + @Override + public boolean isReadable() { + try { + URL url = this.resolveURL(); + return url != null && super.isReadable(url); + } catch (IOException e) { + return false; + } + } + + @Override + public @NotNull InputStream getInputStream() throws IOException { + InputStream in; + + if (this.clazz != null) { + in = this.clazz.getResourceAsStream(this.path.startsWith("/") ? this.path : "/" + this.path); + + } else if (this.classLoader != null) { + in = this.classLoader.getResourceAsStream(this.path.startsWith("/") ? this.path.substring(1) : this.path); + + } else { + in = getDefaultClassLoader().getResourceAsStream(this.path.startsWith("/") ? this.path.substring(1) : this.path); + } + + if (in == null) { + throw new FileNotFoundException("Resource not found: " + this.path); + } + + return in; + } + + @Override + public Resource createRelative(@NotNull String relativePath) { + String pathToUse = FileUtils.applyRelativePath(this.path, relativePath); + return (this.clazz != null ? new ClassPathResource(pathToUse, this.clazz) : + new ClassPathResource(pathToUse, this.classLoader)); + } + + @Override + protected boolean shouldApplyCaches() { + return false; + } + + @Override + public int hashCode() { + return this.absolutePath.hashCode(); + } + + private static ClassLoader getDefaultClassLoader() { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = ClassPathResource.class.getClassLoader(); + } + return cl; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java new file mode 100644 index 0000000..9aadad4 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java @@ -0,0 +1,169 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; + +/** + * File system based {@link Resource} implementation. + */ +public class FileSystemResource extends AbstractResource implements WriteableResource { + + @NotNull + private final String path; + + @NotNull + private final Path filePath; + + @Nullable + private final File file; + + public FileSystemResource(@NotNull String path) { + this.path = FileUtils.cleanPath(path); + this.file = new File(path); + this.filePath = this.file.toPath(); + } + + public FileSystemResource(@NotNull File file) { + this.path = FileUtils.cleanPath(file.getPath()); + this.file = file; + this.filePath = file.toPath(); + } + + public FileSystemResource(@NotNull Path filePath) { + this.path = FileUtils.cleanPath(filePath.toString()); + this.file = null; + this.filePath = filePath; + } + + public FileSystemResource(@NotNull FileSystem fileSystem, @NotNull String path) { + this.path = FileUtils.cleanPath(path); + this.file = null; + this.filePath = fileSystem.getPath(this.path).normalize(); + } + + @Override + protected String getDescription() { + return "File System Resource [" + this.path + "]"; + } + + @Override + public boolean exists() { + return (this.file != null ? this.file.exists() : Files.exists(this.filePath)); + } + + @Override + public boolean isFile() { + return true; + } + + @Override + public @Nullable String getFilename() { + String filename = this.filePath.getFileName() != null ? this.filePath.getFileName() + .toString() : null; + return filename != null && !filename.isEmpty() ? filename : null; + } + + @Override + public File getFile() { + return this.file != null ? this.file : this.filePath.toFile(); + } + + @Override + public Path getPath() { + return this.filePath; + } + + @Override + public boolean isReadable() { + return this.file != null + ? this.file.canRead() && !this.file.isDirectory() + : Files.isReadable(this.filePath) && !Files.isDirectory(this.filePath); + } + + @Override + public boolean isWritable() { + return this.file != null + ? this.file.canWrite() && !this.file.isDirectory() + : Files.isWritable(this.filePath) && !Files.isDirectory(this.filePath); + } + + @Override + public @NotNull InputStream getInputStream() throws IOException { + // Directories cannot be opened as input streams on many platforms (Windows throws + // AccessDeniedException). Match test expectations by reporting a FileNotFoundException + // for directory resources instead of letting platform-specific IOExceptions escape. + if (Files.isDirectory(this.filePath)) { + throw new FileNotFoundException(getDescription() + " is a directory"); + } + + try { + return FileUtils.createInputStream(this.filePath); + } catch (NoSuchFileException ex) { + throw new FileNotFoundException(ex.getMessage()); + } + } + + @Override + public long contentLength() throws IOException { + // For file system resources prefer to return the file size directly instead of + // opening an InputStream. This avoids platform-specific errors when paths point + // to directories (Windows does not allow opening a directory as a stream). + if (this.file != null) { + if (!this.file.exists()) { + throw new FileNotFoundException("Resource cannot be resolved: " + getDescription()); + } + + // Return length for files, and for directories File.length() is the expected + // value used by tests (typically 0). + return this.file.length(); + } + + if (Files.notExists(this.filePath)) { + throw new FileNotFoundException("Resource cannot be resolved: " + getDescription()); + } + + if (Files.isDirectory(this.filePath)) { + // Directory size: tests expect the directory's file length (usually 0). We cannot + // use Files.size on directories reliably on all platforms, so return 0L. + return 0L; + } + + try { + return Files.size(this.filePath); + } catch (NoSuchFileException ex) { + throw new FileNotFoundException(ex.getMessage()); + } + } + + @Override + public Resource createRelative(@NotNull String relativePath) { + String pathToUse = FileUtils.applyRelativePath(this.path, relativePath); + return (this.file != null ? new FileSystemResource(pathToUse) : + new FileSystemResource(this.filePath.getFileSystem(), pathToUse)); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (Files.isDirectory(this.filePath)) { + throw new FileNotFoundException(getDescription() + " is a directory"); + } + + try { + return FileUtils.createOutputStream(this.filePath); + } catch (NoSuchFileException ex) { + throw new FileNotFoundException(ex.getMessage()); + } + } + + @Override + public int hashCode() { + return this.path.hashCode(); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/InputStreamSource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/InputStreamSource.java new file mode 100644 index 0000000..4671148 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/InputStreamSource.java @@ -0,0 +1,28 @@ +package dev.spoocy.utils.config.io; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Interface for objects that are sources for an {@link InputStream}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public interface InputStreamSource { + + /** + * Return an {@link InputStream} for the content of an underlying resource. + *

+ * Every call will create a fresh stream. + * + * @return the input stream for the underlying resource + * + * @throws java.io.FileNotFoundException if the underlying resource does not exist + * @throws IOException if the content stream could not be opened + */ + @NotNull + InputStream getInputStream() throws IOException; + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/PathResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/PathResource.java new file mode 100644 index 0000000..874eb4c --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/PathResource.java @@ -0,0 +1,139 @@ +package dev.spoocy.utils.config.io; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.URI; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class PathResource implements WriteableResource { + + private final Path path; + + public PathResource(@NotNull Path path) { + this.path = path.normalize(); + } + + @Override + public boolean exists() { + return Files.exists(this.path); + } + + @Override + public boolean isReadable() { + return (Files.isReadable(this.path) && !Files.isDirectory(this.path)); + } + + @Override + public URL getURL() throws IOException { + return this.path.toUri().toURL(); + } + + @Override + public URI getURI() throws IOException { + return this.path.toUri(); + } + + @Override + public boolean isFile() { + return true; + } + + @Override + public @Nullable String getFilename() { + return this.path.getFileName().toString(); + } + + @Override + public File getFile() { + return this.path.toFile(); + } + + @Override + public Path getPath() { + return this.path; + } + + @Override + public @NotNull InputStream getInputStream() throws IOException { + if (!exists()) { + throw new FileNotFoundException(getPath() + " does not exist."); + } + + if (Files.isDirectory(this.path)) { + throw new FileNotFoundException(getPath() + " is a directory."); + } + + return Files.newInputStream(this.path); + } + + @Override + public byte[] getContentAsByteArray() throws IOException { + try { + return Files.readAllBytes(this.path); + } + catch (NoSuchFileException ex) { + throw new FileNotFoundException(ex.getMessage()); + } + } + + @Override + public String getContentAsString(@NotNull Charset charset) throws IOException { + try { + return Files.readString(this.path, charset); + } + catch (NoSuchFileException ex) { + throw new FileNotFoundException(ex.getMessage()); + } + } + + @Override + public long contentLength() throws IOException { + return Files.size(this.path); + } + + @Override + public long lastModified() throws IOException { + return Files.getLastModifiedTime(this.path).toMillis(); + } + + @Override + public Resource createRelative(@NotNull String relativePath) { + return new PathResource(this.path.resolve(relativePath)); + } + + @Override + public boolean isWritable() { + return (Files.isWritable(this.path) && !Files.isDirectory(this.path)); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (Files.isDirectory(this.path)) { + throw new FileNotFoundException(getPath() + " is a directory."); + } + + return Files.newOutputStream(this.path); + } + + @Override + public int hashCode() { + return this.path.hashCode(); + } + + @Override + public String toString() { + return "Path resource [" + this.path.toAbsolutePath() + "]"; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/ResolvableResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/ResolvableResource.java new file mode 100644 index 0000000..04ce092 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/ResolvableResource.java @@ -0,0 +1,259 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ResolvableResource extends AbstractResource { + + @Override + public boolean exists() { + try { + URL url = getURL(); + + if (FileUtils.isFileURL(url)) { + // Proceed with file system resolution + return getFile().exists(); + } + + // Try a URL connection content-length header + URLConnection con = url.openConnection(); + applyCaches(con); + + HttpURLConnection httpCon = (con instanceof HttpURLConnection ? (HttpURLConnection) con : null); + + if (httpCon != null) { + httpCon.setRequestMethod("HEAD"); + int code = httpCon.getResponseCode(); + + if (code == HttpURLConnection.HTTP_OK) { + return true; + } + + if (code == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + + if (code == HttpURLConnection.HTTP_BAD_METHOD) { + con = url.openConnection(); + applyCaches(con); + if (con instanceof HttpURLConnection) { + HttpURLConnection newHttpCon = (HttpURLConnection) con; + code = newHttpCon.getResponseCode(); + + if (code == HttpURLConnection.HTTP_OK) { + return true; + } + + if (code == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + + httpCon = newHttpCon; + } + } + } + + if (con instanceof JarURLConnection) { + JarURLConnection jarCon = (JarURLConnection) con; + JarFile jarFile = jarCon.getJarFile(); + + try { + return (jarCon.getEntryName() == null || jarCon.getJarEntry() != null); + + } finally { + if (!jarCon.getUseCaches()) { + jarFile.close(); + } + } + + } else if (con.getContentLengthLong() > 0) { + return true; + } + + if (httpCon != null) { + // HTTP response code is not OK or NOT_FOUND, and content length is not positive - consider that the resource does not exist. + httpCon.disconnect(); + return false; + } + + // try to read from the stream + getInputStream().close(); + return true; + + } catch (IOException ex) { + // Consider that the resource does not exist when we can't read from the stream or when there is a URL connection issue. + return false; + } + } + + @Override + public long contentLength() throws IOException { + URL url = getURL(); + + if (FileUtils.isFileURL(url)) { + + // Proceed with file system resolution + File file = getFile(); + long length = file.length(); + if (length == 0L && !file.exists()) { + throw new FileNotFoundException("Resource cannot be resolved: " + url); + } + return length; + } + + // Try a URL connection content-length header + URLConnection con = url.openConnection(); + applyCaches(con); + + if (con instanceof HttpURLConnection) { + ((HttpURLConnection) con).setRequestMethod("HEAD"); + } + + long length = con.getContentLengthLong(); + + if (length <= 0 + && con instanceof HttpURLConnection + && ((HttpURLConnection) con).getResponseCode() == HttpURLConnection.HTTP_BAD_METHOD + ) { + con = url.openConnection(); + applyCaches(con); + length = con.getContentLengthLong(); + } + return length; + } + + @Override + public boolean isReadable() { + try { + return isReadable(getURL()); + } catch (IOException ex) { + return false; + } + } + + protected boolean isReadable(URL url) throws IOException { + URLConnection con = url.openConnection(); + applyCaches(con); + + if (con instanceof HttpURLConnection) { + HttpURLConnection httpCon = (HttpURLConnection) con; + + httpCon.setRequestMethod("HEAD"); + int code = httpCon.getResponseCode(); + + if (code == HttpURLConnection.HTTP_BAD_METHOD) { + con = url.openConnection(); + applyCaches(con); + + if (!(con instanceof HttpURLConnection)) { + return false; + } + + HttpURLConnection newHttpCon = (HttpURLConnection) con; + code = newHttpCon.getResponseCode(); + + if (code != HttpURLConnection.HTTP_OK) { + newHttpCon.disconnect(); + return false; + } + } else if (code != HttpURLConnection.HTTP_OK) { + httpCon.disconnect(); + return false; + } + } else if (con instanceof JarURLConnection) { + JarEntry jarEntry = ((JarURLConnection) con).getJarEntry(); + return jarEntry != null && !jarEntry.isDirectory(); + } + + long contentLength = con.getContentLengthLong(); + + if (contentLength > 0) { + return true; + } + + if (contentLength == 0) { + // Empty file or directory -> not readable + return false; + } + + getInputStream().close(); + return true; + } + + @Override + public long lastModified() throws IOException { + URL url = getURL(); + boolean fileCheck = false; + + if (FileUtils.isFileURL(url) || FileUtils.isJarURL(url)) { + + // Proceed with file system resolution + fileCheck = true; + + try { + File fileToCheck = getFileForLastModifiedCheck(); + long lastModified = fileToCheck.lastModified(); + + if (lastModified > 0L || fileToCheck.exists()) { + return lastModified; + } + } catch (FileNotFoundException ex) { + // Ignore - probably a JAR resource, not resolvable in the file system + } + } + + // Try a URL connection last-modified header + URLConnection con = url.openConnection(); + applyCaches(con); + + if (con instanceof HttpURLConnection) { + ((HttpURLConnection) con).setRequestMethod("HEAD"); + } + + long lastModified = con.getLastModified(); + + if (lastModified == 0) { + + if (con instanceof HttpURLConnection && ((HttpURLConnection) con).getResponseCode() == HttpURLConnection.HTTP_BAD_METHOD) { + + con = url.openConnection(); + applyCaches(con); + lastModified = con.getLastModified(); + } + + if (fileCheck && con.getContentLengthLong() <= 0) { + throw new FileNotFoundException("Resource cannot be resolved: " + url); + } + } + + return lastModified; + } + + protected void applyCaches(@NotNull URLConnection connection) { + + if (!(connection instanceof JarURLConnection)) { + connection.setUseCaches(shouldApplyCaches()); + } + + if (connection instanceof HttpURLConnection) { + connection.setUseCaches(shouldApplyCaches()); + } + } + + protected abstract boolean shouldApplyCaches(); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/Resource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/Resource.java new file mode 100644 index 0000000..54c7de9 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/Resource.java @@ -0,0 +1,142 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.URI; +import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.charset.Charset; +import java.nio.file.Path; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface Resource extends InputStreamSource { + + /** + * Determine whether this resource actually exists in physical form. + * + * @return {@code true} if the resource exists, {@code false} otherwise + */ + boolean exists(); + + /** + * @return a URL handle for this resource. + * + * @throws IOException if the resource cannot be resolved as URL + */ + URL getURL() throws IOException; + + /** + * @return a URI handle for this resource. + * + * @throws IOException if the resource cannot be resolved as URI + */ + URI getURI() throws IOException; + + /** + * @return {@code true} if the resource represents a file, {@code false} otherwise + */ + boolean isFile(); + + /** + * @return a filename for this resource + */ + @Nullable + String getFilename(); + + /** + * Return a File handle for this resource. + *

+ * This only works for files in the default file system. + * + * @throws UnsupportedOperationException if the resource is a file but cannot be exposed as a File + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file + * @throws IOException in case of general resolution/reading failures + */ + File getFile() throws IOException; + + /** + * @return an IO Path handle for this resource. + * + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file + * @throws IOException in case of general resolution/reading failures + */ + Path getPath() throws IOException; + + /** + * Indicate whether this resource is readable. + * + * @return {@code true} if the resource is readable, {@code false} otherwise + */ + boolean isReadable(); + + /** + * Return an {@link InputStream} for reading the underlying resource. + * + * @return the InputStream to read from + * + * @throws IOException if the stream could not be opened + * @see #isReadable() + */ + @Override + @NotNull InputStream getInputStream() throws IOException; + + /** + * @return the contents of this resource as byte array + * + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file + * @throws IOException in case of general resolution/reading failures + */ + default byte[] getContentAsByteArray() throws IOException { + return FileUtils.copyToByteArray(getInputStream()); + } + + /** + * Return the contents of this resource as a string, using the specified charset. + * + * @param charset the charset to use for decoding + * + * @return the contents of this resource as a {@code String} + * + * @throws java.io.FileNotFoundException if the resource cannot be resolved as + */ + default String getContentAsString(@NotNull Charset charset) throws IOException { + return FileUtils.copyToString(new InputStreamReader(getInputStream(), charset)); + } + + /** + * Determine the content length for this resource. + * + * @return the content length (or -1 if undetermined) + * + * @throws IOException if the resource cannot be resolved + */ + long contentLength() throws IOException; + + /** + * Determine the last-modified timestamp for this resource. + * + * @return the last-modified timestamp (or 0 if not known) + * + * @throws IOException if the resource cannot be resolved + */ + long lastModified() throws IOException; + + /** + * Create a resource relative to this resource. + * + * @param relativePath the relative path (relative to this resource) + * + * @return the resource handle for the relative resource + * + * @throws IOException if the relative resource cannot be determined + */ + Resource createRelative(@NotNull String relativePath) throws IOException; + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java new file mode 100644 index 0000000..0f25294 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java @@ -0,0 +1,187 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.common.text.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; + +/** + * URL based {@link Resource} implementation. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class UrlResource extends ResolvableResource { + + public static UrlResource of(@NotNull URI uri) throws UncheckedIOException { + try { + return new UrlResource(uri); + } + catch (MalformedURLException ex) { + throw new UncheckedIOException(ex); + } + } + + @NotNull + private final URL url; + + @Nullable + private final URI uri; + + @Nullable + private volatile String cleanedUrl; + + @Nullable + private volatile Boolean useCaches; + + public UrlResource(@NotNull URL url) { + this.uri = null; + this.url = url; + } + + public UrlResource(@NotNull URI uri) throws MalformedURLException { + this.uri = uri; + this.url = uri.toURL(); + } + + public UrlResource(@NotNull String path) throws MalformedURLException { + String cleanedPath = FileUtils.cleanPath(path); + URI uri; + URL url; + + try { + uri = FileUtils.toURI(cleanedPath); + url = uri.toURL(); + } + catch (URISyntaxException | IllegalArgumentException ex) { + uri = null; + url = FileUtils.toURL(path); + } + + this.uri = uri; + this.url = url; + this.cleanedUrl = cleanedPath; + } + + + @Override + protected String getDescription() { + return "URL Resource [" + StringUtils.nullSafe(this.cleanedUrl, this.url.toString()) + "]"; + } + + @Override + public URL getURL() { + return this.url; + } + + @Override + public URI getURI() throws IOException { + if (this.uri != null) { + return this.uri; + } + else { + return super.getURI(); + } + } + + @Override + public boolean isFile() { + if (this.uri == null) { + return false; + } + return this.uri.getScheme().equals("file"); + } + + @Override + public File getFile() throws IOException { + if (this.uri != null && this.uri.getScheme().equals("file")) { + return new File(this.uri); + } + throw new FileNotFoundException("Resource cannot be resolved: " + this.url); + } + + @Override + public @NotNull String getFilename() { + return FileUtils.getFileName(this.url.getPath(), true); + } + + @Override + public Path getPath() throws IOException { + if (this.uri != null) { + return Path.of(this.uri); + } + throw new FileNotFoundException("Resource cannot be resolved: " + this.url); + } + + @Override + public @NotNull InputStream getInputStream() throws IOException { + URLConnection con = this.url.openConnection(); + applyCaches(con); + + final HttpURLConnection httpCon = (con instanceof HttpURLConnection) ? (HttpURLConnection) con : null; + + InputStream in = con.getInputStream(); + + if (httpCon == null) { + // For non-HTTP connections return the raw stream + return in; + } + + // For HTTP connections return a wrapper stream that disconnects when closed + return new FilterInputStream(in) { + @Override + public void close() throws IOException { + super.close(); + try { + httpCon.disconnect(); + } catch (Exception ignored) { + // ignore + } + } + }; + } + + @Override + public Resource createRelative(@NotNull String relativePath) throws MalformedURLException { + UrlResource resource = new UrlResource(createRelativeURL(relativePath)); + resource.useCaches = this.useCaches; + return resource; + } + + protected URL createRelativeURL(String relativePath) throws MalformedURLException { + if (relativePath.startsWith("/")) { + relativePath = relativePath.substring(1); + } + return FileUtils.toRelativeURL(this.url, relativePath); + } + + @Override + protected void applyCaches(@NotNull URLConnection connection) { + super.applyCaches(connection); + + String userInfo = this.url.getUserInfo(); + if (userInfo != null) { + String encodedCredentials = Base64.getEncoder().encodeToString(userInfo.getBytes(StandardCharsets.UTF_8)); + connection.setRequestProperty("Authorization", "Basic " + encodedCredentials); + } + } + + @Override + protected boolean shouldApplyCaches() { + if (this.useCaches != null) { + return this.useCaches; + } + return false; + } + + @Override + public int hashCode() { + return this.url.hashCode(); + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/WriteableResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/WriteableResource.java new file mode 100644 index 0000000..0bf7dab --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/WriteableResource.java @@ -0,0 +1,31 @@ +package dev.spoocy.utils.config.io; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface WriteableResource extends Resource { + + /** + * Indicate whether the contents of this resource can be written via {@link #getOutputStream()}. + * + * @return {@code true} if the contents of this resource can be written, {@code false} otherwise + */ + boolean isWritable(); + + /** + * Return an {@link OutputStream} for writing to the underlying resource. + * + * @return the OutputStream to write to + * + * @throws IOException if the stream could not be opened + * @throws UnsupportedOperationException if the resource cannot be written to (i.e. is not {@link #isWritable() writable}) + * + * @see #isWritable() + */ + OutputStream getOutputStream() throws IOException; + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java new file mode 100644 index 0000000..8891798 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java @@ -0,0 +1,704 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.*; +import dev.spoocy.utils.config.Readable; +import dev.spoocy.utils.reflection.ClassWalker; +import dev.spoocy.utils.reflection.Reflection; +import dev.spoocy.utils.reflection.accessor.Accessor; +import dev.spoocy.utils.reflection.accessor.ConstructorAccessor; +import dev.spoocy.utils.reflection.accessor.FieldAccessor; +import dev.spoocy.utils.reflection.accessor.MethodAccessor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Binds config values to annotated classes using reflection. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class AnnotatedConfigLoader { + + private static final ConcurrentMap, BoundType> TYPES = new ConcurrentHashMap<>(); + + public AnnotatedConfigLoader() { + } + + public T load(@NotNull Class type, @NotNull Readable readable) { + return loadResult(type, readable).instance(); + } + + @NotNull + public LoadResult loadResult(@NotNull Class type, @NotNull Readable readable) { + Args.notNull(type, "type"); + return bind(describe(type), readable); + } + + @SuppressWarnings("unchecked") + public int write(@NotNull T instance, @NotNull Writeable writeable) { + Args.notNull(instance, "instance"); + Args.notNull(writeable, "writeable"); + + BoundType boundType = describe((Class) instance.getClass()); + return writeFieldsToConfig(boundType, instance, writeable, resolveBasePath(boundType.source())); + } + + @NotNull + private LoadResult bind(@NotNull BoundType boundType, @NotNull Readable readable) { + Args.notNull(boundType, "boundType"); + Args.notNull(readable, "readable"); + + T instance = createInstance(boundType); + String basePath = resolveBasePath(boundType.source()); + int mutations = 0; + + invokePreHooks(boundType.preHooks(), instance, readable); + + for (BoundField field : boundType.fields()) { + ConfigProperty property = field.property(); + String rawPath = resolvePropertyPath(field); + String fullPath = combinePath(basePath, rawPath); + boolean shouldPersistField = shouldSaveDefault(boundType.source(), property); + + Object current = field.get(instance); + Object loaded = resolveValue(readable, fullPath, field, current); + + if (loaded == null) { + if (shouldPersistField && current != null && !readable.isSet(fullPath) && readable instanceof Writeable) { + ((Writeable) readable).set(fullPath, current); + mutations++; + } + } else { + field.set(instance, loaded); + } + + if (shouldPersistField) { + mutations += applyPropertyComments(readable, fullPath, property); + } + } + + mutations += applySourceComments(boundType.source(), readable, basePath); + + PostLoadResult postLoadResult = invokePostHooks(boundType.postHooks(), instance, readable); + if (postLoadResult == PostLoadResult.SAVE && readable instanceof Writeable) { + mutations += writeFieldsToConfig(boundType, instance, (Writeable) readable, basePath); + } + + return new LoadResult<>(instance, mutations); + } + + /** + * Writes all bound field values of {@code instance} into {@code writeable} and returns + * the number of fields written (each counts as one mutation). + */ + private static int writeFieldsToConfig( + @NotNull BoundType boundType, + @NotNull T instance, + @NotNull Writeable writeable, + @NotNull String basePath + ) { + int count = 0; + for (BoundField field : boundType.fields()) { + if (!shouldWriteField(boundType.source(), field.property())) { + continue; + } + + String fullPath = combinePath(basePath, resolvePropertyPath(field)); + Object value = field.get(instance); + if (value != null) { + writeable.set(fullPath, value); + count++; + } + } + return count; + } + + @NotNull + @SuppressWarnings("unchecked") + private static BoundType describe(@NotNull Class type) { + return (BoundType) TYPES.computeIfAbsent(type, AnnotatedConfigLoader::createBoundType); + } + + @NotNull + private static BoundType createBoundType(@NotNull Class type) { + return new BoundType<>( + type, + Reflection.getAnnotation(type, ConfigSource.class, false), + resolveConstructor(type), + List.copyOf(resolveFields(type)), + List.copyOf(resolveHooks(type, PreLoad.class)), + List.copyOf(resolveHooks(type, PostLoad.class)) + ); + } + + public static final class LoadResult { + + private final T instance; + private final int mutations; + + private LoadResult(@NotNull T instance, int mutations) { + this.instance = instance; + this.mutations = mutations; + } + + @NotNull + public T instance() { + return this.instance; + } + + public int mutations() { + return this.mutations; + } + } + + @NotNull + private static ConstructorAccessor resolveConstructor(@NotNull Class type) { + ConstructorAccessor accessor = Reflection.builder() + .forClass(type) + .privateMembers() + .buildAccess() + .constructor(); + + if (accessor == null) { + throw new IllegalArgumentException("Class must have a no-args constructor: " + type.getName()); + } + + return accessor; + } + + @NotNull + private static List resolveFields(@NotNull Class type) { + List fields = new ArrayList<>(); + + for (Class current : ClassWalker.walk(type)) { + if (current == Object.class || current.isInterface()) { + continue; + } + + for (Field field : current.getDeclaredFields()) { + BoundField boundField = BoundField.of(field); + if (boundField != null) { + fields.add(boundField); + } + } + } + + return fields; + } + + @NotNull + private static List resolveHooks( + @NotNull Class type, + @NotNull Class annotation + ) { + List hooks = new ArrayList<>(); + + for (Class current : ClassWalker.walk(type)) { + if (current == Object.class || current.isInterface()) { + continue; + } + + for (Method method : current.getDeclaredMethods()) { + BoundHook hook = BoundHook.of(method, annotation); + if (hook != null) { + hooks.add(hook); + } + } + } + + return hooks; + } + + private static void invokePreHooks( + @NotNull List hooks, + @NotNull Object instance, + @NotNull Readable readable + ) { + for (BoundHook hook : hooks) { + hook.invoke(instance, readable); + } + } + + @NotNull + private static PostLoadResult invokePostHooks( + @NotNull List hooks, + @NotNull Object instance, + @NotNull Readable readable + ) { + PostLoadResult result = PostLoadResult.NONE; + for (BoundHook hook : hooks) { + PostLoadResult hookResult = hook.invoke(instance, readable); + if (hookResult.ordinal() > result.ordinal()) { + result = hookResult; + } + } + return result; + } + + @NotNull + private static String resolveBasePath(@Nullable ConfigSource source) { + return source == null ? "" : source.section() + .trim(); + } + + private static boolean shouldSaveDefaults(@Nullable ConfigSource source) { + return source != null && source.saveDefaults(); + } + + private static boolean shouldSaveDefault(@Nullable ConfigSource source, @Nullable ConfigProperty property) { + if (property != null) { + return property.saveDefault() && shouldSaveDefaults(source); + } + return shouldSaveDefaults(source); + } + + private static boolean shouldWriteField(@Nullable ConfigSource source, @Nullable ConfigProperty property) { + return shouldSaveDefault(source, property); + } + + @NotNull + private static String resolvePropertyPath(@NotNull BoundField field) { + ConfigProperty property = field.property(); + if (property == null || property.value() + .trim() + .isEmpty()) { + return field.name(); + } + return property.value() + .trim(); + } + + @NotNull + private static String combinePath(@NotNull String basePath, @NotNull String path) { + if (basePath.isEmpty()) { + return path; + } + if (path.isEmpty()) { + return basePath; + } + return basePath + '.' + path; + } + + @NotNull + private static T createInstance(@NotNull BoundType boundType) { + try { + return boundType.type() + .cast(boundType.constructor() + .invoke()); + } catch (Exception e) { + throw new IllegalArgumentException("Class must have a no-args constructor: " + boundType.type() + .getName(), e); + } + } + + private static int applySourceComments( + @Nullable ConfigSource source, + @NotNull Readable readable, + @NotNull String basePath + ) { + if (!(readable instanceof Commentable)) { + return 0; + } + + if (source == null) { + return 0; + } + + Commentable commentable = (Commentable) readable; + int mutations = 0; + + mutations += updateComments(commentable.getHeaderComments(), toComments(source.headerComments()), commentable::setHeaderComments); + mutations += updateComments(commentable.getFooterComments(), toComments(source.footerComments()), commentable::setFooterComments); + + if (!basePath.isEmpty() && ensurePathExists(readable, basePath)) { + mutations += updateComments(commentable.getComments(basePath), toComments(source.comments()), comments -> commentable.setComments(basePath, comments)); + mutations += updateComments(commentable.getInlineComments(basePath), toComments(source.inlineComments()), comments -> commentable.setInlineComments(basePath, comments)); + } + + return mutations; + } + + private static int applyPropertyComments( + @NotNull Readable readable, + @NotNull String fullPath, + @Nullable ConfigProperty property + ) { + if (!(readable instanceof Commentable) || property == null || !ensurePathExists(readable, fullPath)) { + return 0; + } + + Commentable commentable = (Commentable) readable; + int mutations = 0; + + mutations += updateComments(commentable.getComments(fullPath), toComments(property.comments()), comments -> commentable.setComments(fullPath, comments)); + mutations += updateComments(commentable.getInlineComments(fullPath), toComments(property.inlineComments()), comments -> commentable.setInlineComments(fullPath, comments)); + + return mutations; + } + + private static boolean ensurePathExists(@NotNull Readable readable, @NotNull String path) { + if (path.isEmpty() || readable.isSet(path)) { + return true; + } + + if (!(readable instanceof ConfigSection)) { + return false; + } + + ConfigSection root = (ConfigSection) readable; + char separator = root.getRoot() + .settings() + .pathSeparator(); + String[] parts = path.split(java.util.regex.Pattern.quote(String.valueOf(separator))); + ConfigSection current = root; + + for (int index = 0; index < parts.length - 1; index++) { + String part = parts[index]; + if (part.isEmpty()) { + continue; + } + + ConfigSection next = current.getSection(part); + if (next == null) { + if (current.isSet(part)) { + return false; + } + next = current.createSection(part); + } + current = next; + } + + return readable.isSet(path); + } + + @NotNull + private static List toComments(@NotNull String[] comments) { + if (comments.length == 0) { + return Collections.emptyList(); + } + + List lines = new ArrayList<>(comments.length); + Collections.addAll(lines, comments); + return Collections.unmodifiableList(lines); + } + + private static int updateComments( + @NotNull List current, + @NotNull List desired, + @NotNull CommentSetter setter + ) { + if (desired.isEmpty() || Objects.equals(current, desired)) { + return 0; + } + + setter.apply(desired); + return 1; + } + + @FunctionalInterface + private interface CommentSetter { + + void apply(@NotNull List comments); + } + + @Nullable + @SuppressWarnings("unchecked") + private static Object resolveValue( + @NotNull Readable readable, + @NotNull String path, + @NotNull BoundField field, + @Nullable Object defaultValue + ) { + Class fieldType = field.type(); + if (!readable.isSet(path)) { + return null; + } + + if (fieldType == String.class) { + return readable.getString(path, defaultValue instanceof String ? (String) defaultValue : ""); + } + if (fieldType == int.class || fieldType == Integer.class) { + int fallback = defaultValue instanceof Number ? ((Number) defaultValue).intValue() : 0; + return readable.getInt(path, fallback); + } + if (fieldType == long.class || fieldType == Long.class) { + long fallback = defaultValue instanceof Number ? ((Number) defaultValue).longValue() : 0L; + return readable.getLong(path, fallback); + } + if (fieldType == double.class || fieldType == Double.class) { + double fallback = defaultValue instanceof Number ? ((Number) defaultValue).doubleValue() : 0.0D; + return readable.getDouble(path, fallback); + } + if (fieldType == float.class || fieldType == Float.class) { + float fallback = defaultValue instanceof Number ? ((Number) defaultValue).floatValue() : 0.0F; + return readable.getFloat(path, fallback); + } + if (fieldType == boolean.class || fieldType == Boolean.class) { + boolean fallback = defaultValue instanceof Boolean && (boolean) defaultValue; + return readable.getBoolean(path, fallback); + } + if (fieldType == UUID.class) { + return readable.getUUID(path, (UUID) defaultValue); + } + if (fieldType == Version.class) { + return readable.getVersion(path, (Version) defaultValue); + } + if (fieldType.isEnum()) { + return resolveEnum(readable, path, fieldType, defaultValue); + } + if (Collection.class.isAssignableFrom(fieldType)) { + Class genericType = field.collectionElementType(); + + if (genericType == null) { + throw new IllegalArgumentException( + "Unsupported collection field without resolvable generic type: " + field.name() + + " (path='" + path + "', type=" + fieldType.getName() + ')' + ); + } + + if (List.class.isAssignableFrom(fieldType)) { + return readable.getList(path, genericType, List.of()); + } + + if (Set.class.isAssignableFrom(fieldType)) { + Collection collection = readable.getList(path, genericType, List.of()); + return Set.copyOf(collection); + } + } + + Object value = readable.get(path, fieldType); + if (value != null) { + return value; + } + + return readable.getSerializable(path, (Class) fieldType); + } + + @Nullable + @SuppressWarnings("unchecked") + private static > E resolveEnum( + @NotNull Readable readable, + @NotNull String path, + @NotNull Class enumType, + @Nullable Object defaultValue + ) { + Class castedType = (Class) enumType; + E fallback = castedType.isInstance(defaultValue) ? castedType.cast(defaultValue) : null; + return readable.getEnum(path, castedType, fallback); + } + + private static final class BoundType { + + private final Class type; + private final ConfigSource source; + private final ConstructorAccessor constructor; + private final List fields; + private final List preHooks; + private final List postHooks; + + private BoundType( + @NotNull Class type, + @Nullable ConfigSource source, + @NotNull ConstructorAccessor constructor, + @NotNull List fields, + @NotNull List preHooks, + @NotNull List postHooks + ) { + this.type = type; + this.source = source; + this.constructor = constructor; + this.fields = fields; + this.preHooks = preHooks; + this.postHooks = postHooks; + } + + @NotNull + private Class type() { + return this.type; + } + + @Nullable + private ConfigSource source() { + return this.source; + } + + @NotNull + private ConstructorAccessor constructor() { + return this.constructor; + } + + @NotNull + private List fields() { + return this.fields; + } + + @NotNull + private List preHooks() { + return this.preHooks; + } + + @NotNull + private List postHooks() { + return this.postHooks; + } + } + + private static final class BoundHook { + + private final MethodAccessor accessor; + private final Class parameterType; + private final boolean returnsPostLoadResult; + + @Nullable + private static BoundHook of( + @NotNull Method method, + @NotNull Class annotation + ) { + if (!method.isAnnotationPresent(annotation)) { + return null; + } + + if (Modifier.isStatic(method.getModifiers())) { + throw new IllegalArgumentException("Hook method must not be static: " + method); + } + + Class[] parameters = method.getParameterTypes(); + if (parameters.length > 1) { + throw new IllegalArgumentException("Hook method must have zero or one parameter: " + method); + } + + Class returnType = method.getReturnType(); + boolean returnsPostLoadResult = false; + if (annotation == PostLoad.class) { + if (returnType == PostLoadResult.class) { + returnsPostLoadResult = true; + } else if (returnType != void.class) { + throw new IllegalArgumentException("@PostLoad hook method must return void or PostLoadResult: " + method); + } + } else if (returnType != void.class) { + throw new IllegalArgumentException("Hook method must return void: " + method); + } + + Class parameterType = parameters.length == 0 ? null : parameters[0]; + return new BoundHook(Accessor.getMethod(method), parameterType, returnsPostLoadResult); + } + + private BoundHook( + @NotNull MethodAccessor accessor, + @Nullable Class parameterType, + boolean returnsPostLoadResult + ) { + this.accessor = accessor; + this.parameterType = parameterType; + this.returnsPostLoadResult = returnsPostLoadResult; + } + + @NotNull + private PostLoadResult invoke(@NotNull Object instance, @NotNull Readable readable) { + Object result; + + if (this.parameterType == null) { + result = this.accessor.invoke(instance); + } else { + if (!this.parameterType.isInstance(readable)) { + throw new IllegalArgumentException("Hook parameter type is not compatible with readable instance: " + this.accessor.getMethod()); + } + result = this.accessor.invoke(instance, readable); + } + + if (this.returnsPostLoadResult && result instanceof PostLoadResult) { + return (PostLoadResult) result; + } + + return PostLoadResult.NONE; + } + } + + private static final class BoundField { + + private final FieldAccessor accessor; + private final String name; + private final Class type; + private final Class collectionElementType; + private final ConfigProperty property; + + @Nullable + private static BoundField of(@NotNull Field field) { + if (field.isAnnotationPresent(ConfigIgnore.class)) { + return null; + } + + int modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers)) { + return null; + } + + FieldAccessor accessor = Accessor.getField(field); + return new BoundField( + accessor, + field.getName(), + field.getType(), + Reflection.resolveCollectionElementType(field), + field.getAnnotation(ConfigProperty.class) + ); + } + + private BoundField( + @NotNull FieldAccessor accessor, + @NotNull String name, + @NotNull Class type, + @Nullable Class collectionElementType, + @Nullable ConfigProperty property + ) { + this.accessor = accessor; + this.name = name; + this.type = type; + this.collectionElementType = collectionElementType; + this.property = property; + } + + @NotNull + private String name() { + return this.name; + } + + @NotNull + private Class type() { + return this.type; + } + + @Nullable + private Class collectionElementType() { + return this.collectionElementType; + } + + @Nullable + private ConfigProperty property() { + return this.property; + } + + @Nullable + private Object get(@NotNull Object instance) { + return this.accessor.get(instance); + } + + private void set(@NotNull Object instance, @NotNull Object value) { + try { + this.accessor.set(instance, value); + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to set field '" + this.name + "' with value type " + value.getClass() + .getName(), ex); + } + } + } + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java new file mode 100644 index 0000000..23079ce --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java @@ -0,0 +1,17 @@ +package dev.spoocy.utils.config.loader; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field to be ignored by the annotated config loader. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ConfigIgnore { +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java new file mode 100644 index 0000000..7e33a79 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java @@ -0,0 +1,73 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.types.ConfigSettings; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.function.Consumer; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface ConfigLoader { + + /** + * @return The file extensions supported by this loader + */ + String[] getSupportedExtensions(); + + /** + * Creates a new empty configuration instance of type C with default settings applied. + * + * @return A new empty configuration instance of type C. + */ + default C createEmpty() { + return createEmpty(s -> {}); + } + + /** + * Creates a new empty configuration instance of type C and applies the specified settings editor + * to configure its settings before being returned. + * + * @param settingsEditor A consumer that allows modifying the configuration settings of type S. + * Must not be null. + * + * @return A new empty configuration instance of type C. + */ + C createEmpty(@NotNull Consumer settingsEditor); + + /** + * Loads a configuration from the given resource using the specified constructor. + * + * @param resource The resource to load the configuration from. Must not be null. + * @param constructor The constructor to use for initializing the configuration. Must not be null. + * + * @return The loaded configuration instance. + * + * @throws IOException If an I/O error occurs while reading the resource. + */ + default C load(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { + return this.load(resource, constructor, s -> {}); + } + + /** + * Loads a configuration from the provided resource using the specified constructor + * and applies the given settings editor to modify configuration settings before loading. + * + * @param resource The resource to load the configuration from. Must not be null and must exist. + * @param constructor The constructor to use for initializing the configuration structure. Must not be null. + * @param settingsEditor A consumer function that applies modifications to the configuration settings. Must not be null. + * + * @return The loaded configuration instance of type C. + * + * @throws IOException If an I/O error occurs while reading the resource. + */ + C load(@NotNull Resource resource, @NotNull Constructor constructor, @NotNull Consumer settingsEditor) throws IOException; + + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java new file mode 100644 index 0000000..2525c09 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java @@ -0,0 +1,43 @@ +package dev.spoocy.utils.config.loader; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares how a field is mapped to a config path. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ConfigProperty { + + /** + * Path relative to the source section. If empty, the field name is used. + */ + @NotNull + String value() default ""; + + /** + * Overrides source-level saveDefaults behavior for this field. + */ + boolean saveDefault() default true; + + /** + * Block comments written above the resolved config path. + */ + @NotNull + String[] comments() default {}; + + /** + * Inline comments written next to the resolved config path. + */ + @NotNull + String[] inlineComments() default {}; + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java new file mode 100644 index 0000000..805538c --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java @@ -0,0 +1,66 @@ +package dev.spoocy.utils.config.loader; + +import org.jetbrains.annotations.NotNull; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Declares where an annotated config class should be loaded from. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ConfigSource { + + /** + * Resource location (e.g. classpath:config.yml, file:C:/config.yml). + */ + @NotNull + String value() default ""; + + /** + * Optional base section used for all bound properties. + */ + @NotNull + String section() default ""; + + /** + * Persist missing values from field defaults back to the document. + */ + boolean saveDefaults() default false; + + /** + * Allows loading from a non-existing resource by starting with an empty document. + */ + boolean allowMissingResource() default true; + + /** + * Header comments written at the start of the document. + */ + @NotNull + String[] headerComments() default {}; + + /** + * Footer comments written at the end of the document. + */ + @NotNull + String[] footerComments() default {}; + + /** + * Block comments applied to the configured {@link #section() Section}. + */ + @NotNull + String[] comments() default {}; + + /** + * Inline comments applied to the configured {@link #section() Section}. + */ + @NotNull + String[] inlineComments() default {}; + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java new file mode 100644 index 0000000..87f8548 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java @@ -0,0 +1,112 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.constructor.SerializerAssignable; +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.types.ConfigSettings; +import dev.spoocy.utils.config.types.JsonConfig; +import dev.spoocy.utils.config.types.JsonSettings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class JsonConfigLoader implements ConfigLoader { + + public static final JsonConfigLoader INSTANCE = new JsonConfigLoader(); + + private JsonConfigLoader() { } + + @Override + public String[] getSupportedExtensions() { + return new String[]{"json"}; + } + + @Override + public JsonConfig createEmpty(@NotNull Consumer settingsEditor) { + return new JsonConfig(settingsEditor); + } + + @Override + public JsonConfig load( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull Consumer settingsEditor + ) throws IOException { + checkDependency(); + + JsonConfig config = new JsonConfig(); + + + // apply settings + config.settings().constructor(constructor); + if(constructor instanceof SerializerAssignable) { + config.settings().serializers(((SerializerAssignable) constructor).getSerializers()); + } + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + + String data = reader.lines().collect(Collectors.joining(System.lineSeparator())); + + if (data.trim().isEmpty()) { + return config; + } + + JSONObject json = new JSONObject(data); + Map map = new LinkedHashMap<>(toMap(json)); + constructor.constructMappings(config, map); + + return config; + } + } + + private static void checkDependency() { + try { + Class.forName("org.json.JSONObject"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("org.json:json could not be found in the classpath."); + } + } + + private static Map toMap(@NotNull JSONObject json) { + Map map = new LinkedHashMap<>(); + for (String key : json.keySet()) { + Object val = json.get(key); + map.put(key, toObject(val)); + } + return map; + } + + private static Object toObject(@Nullable Object val) { + if (val instanceof JSONObject) { + return toMap((JSONObject) val); + } + + if (val instanceof JSONArray) { + JSONArray arr = (JSONArray) val; + List list = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + Object element = toObject(arr.get(i)); + list.add(element); + } + return list; + } + + if (val == JSONObject.NULL) { + return null; + } + return val; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java new file mode 100644 index 0000000..042a953 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java @@ -0,0 +1,39 @@ +package dev.spoocy.utils.config.loader; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method to be invoked after annotated field binding has finished. + *

+ * The method may optionally receive the {@link dev.spoocy.utils.config.Readable} instance that was + * used during binding as its single parameter. + *

+ * Supported signatures: + *

    + *
  • {@code void method()}
  • + *
  • {@code void method(Readable readable)}
  • + *
  • {@code PostLoadResult method()}
  • + *
  • {@code PostLoadResult method(Readable readable)}
  • + *
+ * + * Return type behaviour: + *
    + *
  • {@code void} – no special action after the hook returns.
  • + *
  • {@link PostLoadResult#NONE} – same as {@code void}, no action taken.
  • + *
  • {@link PostLoadResult#SAVE} – every bound field of the class is written back to the + * config, overwriting its values, and the document is saved.
  • + *
+ * + * Any other return type will cause an {@link IllegalArgumentException} at startup. + * + * @author Spoocy99 | GitHub: Spoocy99 + * @see PostLoadResult + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface PostLoad { +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java new file mode 100644 index 0000000..25e7ba9 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java @@ -0,0 +1,23 @@ +package dev.spoocy.utils.config.loader; + +/** + * Return value for {@link PostLoad} hook methods. + *

+ * Returning {@link #SAVE} signals the config loader to overwrite every bound config property + * with the current instance field values and persist the document. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public enum PostLoadResult { + + /** + * No special action – identical to returning {@code void}. + */ + NONE, + + /** + * Write all bound class attributes back to the config and save the document. + */ + SAVE +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java new file mode 100644 index 0000000..c35ec19 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java @@ -0,0 +1,29 @@ +package dev.spoocy.utils.config.loader; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method to be invoked before annotated field binding starts. + *

+ * The method may optionally receive the {@link dev.spoocy.utils.config.Readable} instance that will be + * used during binding as its single parameter. + *

+ * Supported signatures: + *

    + *
  • {@code void method()}
  • + *
  • {@code void method(Readable readable)}
  • + *
+ * + * Return type: must always be {@code void}. + * Any other return type will cause an {@link IllegalArgumentException} at startup. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface PreLoad { +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java new file mode 100644 index 0000000..80e9df7 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java @@ -0,0 +1,229 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.constructor.SerializerAssignable; +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.types.YamlConfig; +import dev.spoocy.utils.config.types.YamlSettings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.yaml.snakeyaml.comments.CommentLine; +import org.yaml.snakeyaml.comments.CommentType; +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.reader.UnicodeReader; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InvalidObjectException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import dev.spoocy.utils.config.ConfigSection; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class YamlConfigLoader implements ConfigLoader { + + public static final YamlConfigLoader INSTANCE = new YamlConfigLoader(); + + private final YamlProcessor processor = new YamlProcessor(); + + private YamlConfigLoader() { } + + @Override + public String[] getSupportedExtensions() { + return new String[]{"yml", "yaml"}; + } + + @Override + public YamlConfig createEmpty(@NotNull Consumer settingsEditor) { + return new YamlConfig(settingsEditor); + } + + @Override + public YamlConfig load( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull Consumer settingsEditor + ) throws IOException{ + checkDependency(); + + YamlConfig config = createEmpty(settingsEditor); + + // apply settings + config.settings().constructor(constructor); + if(constructor instanceof SerializerAssignable) { + config.settings().serializers(((SerializerAssignable) constructor).getSerializers()); + } + + String contents = resource.getContentAsString(StandardCharsets.UTF_8); + loadFromString(config, contents, constructor); + return config; + } + + private static void checkDependency() { + try { + Class.forName("org.yaml.snakeyaml.Yaml"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("SnakeYAML could not be found in the classpath."); + } + } + + public void loadFromString(@NotNull YamlConfig config, @NotNull String contents) throws IOException { + loadFromString(config, contents, SerializingConstructor.DEFAULT_INSTANCE); + } + + private void loadFromString( + @NotNull YamlConfig config, + @NotNull String contents, + @NotNull Constructor constructor + ) throws IOException { + this.processor.applyOptions(config.settings()); + + config.clear(); + config.setHeaderComments(List.of()); + config.setFooterComments(List.of()); + + MappingNode node = composeRootNode(this.processor, contents); + if (node != null) { + adjustNodeComments(node); + config.setHeaderComments(loadHeader(getCommentLines(node.getBlockComments()))); + config.setFooterComments(getCommentLines(node.getEndComments())); + } + + Object loaded = this.processor.loadFromString(contents); + if (!(loaded instanceof Map)) { + return; + } + + Map mapping = new LinkedHashMap<>(); + mapping.putAll((Map) loaded); + constructor.constructMappings(config, mapping); + + if (node != null) { + applyNodeComments(node, config); + } + } + + @Nullable + private MappingNode composeRootNode(@NotNull YamlProcessor processor, @NotNull String contents) throws IOException { + if (contents.trim().isEmpty()) { + return null; + } + + Node rawNode; + try (Reader reader = new UnicodeReader(new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)))) { + rawNode = processor.yaml.compose(reader); + } + + if (rawNode == null) { + return null; + } + + if (!(rawNode instanceof MappingNode)) { + throw new InvalidObjectException("Top level is not a Map."); + } + + return (MappingNode) rawNode; + } + + private void adjustNodeComments(@NotNull MappingNode node) { + if ((node.getBlockComments() == null || node.getBlockComments().isEmpty()) && !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())); + } + } + } + } + + private void applyNodeComments(@NotNull MappingNode input, @NotNull ConfigSection section) { + for (NodeTuple tuple : input.getValue()) { + Node keyNode = tuple.getKeyNode(); + Node valueNode = tuple.getValueNode(); + + if (!(keyNode instanceof ScalarNode)) { + continue; + } + + String key = ((ScalarNode) keyNode).getValue(); + + while (valueNode instanceof AnchorNode) { + valueNode = ((AnchorNode) valueNode).getRealNode(); + } + + section.setComments(key, getCommentLines(keyNode.getBlockComments())); + if (valueNode instanceof MappingNode || valueNode instanceof SequenceNode) { + section.setInlineComments(key, getCommentLines(keyNode.getInLineComments())); + } else { + section.setInlineComments(key, getCommentLines(valueNode.getInLineComments())); + } + + if (valueNode instanceof MappingNode) { + ConfigSection child = section.getSection(key); + if (child != null) { + applyNodeComments((MappingNode) valueNode, child); + } + } + } + } + + @NotNull + private List getCommentLines(@Nullable List comments) { + List lines = new ArrayList<>(); + if (comments == null) { + return lines; + } + + for (CommentLine comment : comments) { + if (comment.getCommentType() == CommentType.BLANK_LINE) { + lines.add(null); + continue; + } + + String line = comment.getValue(); + lines.add(line.startsWith(" ") ? line.substring(1) : line); + } + + return lines; + } + + @NotNull + private List loadHeader(@NotNull List header) { + LinkedList list = new LinkedList<>(header); + + if (!list.isEmpty()) { + list.removeLast(); + } + + while (!list.isEmpty() && list.peek() == null) { + list.remove(); + } + + return list; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java new file mode 100644 index 0000000..ca10f33 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java @@ -0,0 +1,118 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.types.YamlSettings; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.BaseConstructor; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.representer.Representer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.io.Writer; +import java.util.Collections; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class YamlProcessor { + + protected final DumperOptions dumperOptions; + protected final LoaderOptions loaderOptions; + protected final BaseConstructor constructor; + protected final Representer representer; + protected final Yaml yaml; + + public YamlProcessor() { + this.dumperOptions = createDumperOptions(); + this.loaderOptions = createLoaderOptions(); + this.constructor = createConstructor(this.loaderOptions); + this.representer = createRepresenter(this.dumperOptions); + this.yaml = createYaml(this.constructor, this.representer, this.loaderOptions, this.dumperOptions); + } + + protected DumperOptions createDumperOptions() { + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + return options; + } + + protected LoaderOptions createLoaderOptions() { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setMaxAliasesForCollections(Integer.MAX_VALUE); + options.setCodePointLimit(Integer.MAX_VALUE); + options.setNestingDepthLimit(100); + return options; + } + + protected BaseConstructor createConstructor(@NotNull LoaderOptions loaderOptions) { + return new SafeConstructor(loaderOptions); + } + + protected Representer createRepresenter(@NotNull DumperOptions dumperOptions) { + return new Representer(dumperOptions); + } + + protected Yaml createYaml( + @NotNull BaseConstructor constructor, + @NotNull Representer representer, + @NotNull LoaderOptions loaderOptions, + @NotNull DumperOptions dumperOptions + ) { + return new Yaml(constructor, representer, dumperOptions, loaderOptions); + } + + public void applyOptions(@NotNull YamlSettings settings) { + this.dumperOptions.setPrettyFlow(settings.prettyFlow()); + this.dumperOptions.setIndent(settings.indent()); + this.dumperOptions.setWidth(settings.width()); + this.dumperOptions.setProcessComments(settings.comments()); + this.loaderOptions.setProcessComments(settings.comments()); + } + + public void serialize(@NotNull Node node, @NotNull Writer writer) { + this.yaml.serialize(node, writer); + } + + @NotNull + public Node represent(@Nullable Object data) { + return this.representer.represent(data); + } + + @NotNull + public Map load(@NotNull Resource resource) throws IOException { + try (InputStream is = resource.getInputStream()) { + Object data = this.yaml.load(is); + return castMapping(data); + } + } + + @Nullable + public Object loadFromString(@NotNull String contents) { + if (contents.trim().isEmpty()) { + return null; + } + return this.yaml.load(new StringReader(contents)); + } + + @NotNull + private static Map castMapping(@Nullable Object data) { + if (!(data instanceof Map)) { + return Collections.emptyMap(); + } + + Map map = new java.util.LinkedHashMap<>(); + for (Map.Entry entry : ((Map) data).entrySet()) { + map.put(String.valueOf(entry.getKey()), entry.getValue()); + } + return map; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/misc/SectionList.java b/config-utils/src/main/java/dev/spoocy/utils/config/misc/SectionList.java deleted file mode 100644 index ec3fbd4..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/misc/SectionList.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.spoocy.utils.config.misc; - -import dev.spoocy.utils.config.Readable; -import dev.spoocy.utils.config.SectionArray; -import org.jetbrains.annotations.NotNull; - -import java.util.Iterator; -import java.util.List; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class SectionList implements SectionArray { - - private final List values; - - @SafeVarargs - public SectionList(@NotNull T... values) { - this.values = List.of(values); - } - - public SectionList(@NotNull List values) { - this.values = values; - } - - @Override - public int length() { - return this.values.size(); - } - - @Override - public T get(int index) { - return this.values.get(index); - } - - @Override - public T[] toArray() { - return (T[]) this.values.toArray(); - } - - @NotNull - @Override - public Iterator iterator() { - return this.values.iterator(); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java new file mode 100644 index 0000000..65bda95 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java @@ -0,0 +1,65 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.common.tuple.Pair; +import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.serializer.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class BaseRepresenter implements Representer { + + private final Map represents = new HashMap<>(); + + public BaseRepresenter() { + + } + + protected void setRepresent(@NotNull Tag tag, @NotNull Represent represent) { + this.represents.put(tag, represent); + } + + protected void setRepresent(@NotNull Class clazz, @NotNull Represent represent) { + setRepresent(new Tag(clazz), represent); + } + + @Override + public @NotNull Map represent(@NotNull MemorySection section) { + Map represented = new LinkedHashMap<>(); + + for (Pair pair : section.entries()) { + represented.put(pair.first(), represent(pair.second())); + } + + return represented; + } + + @Nullable + protected Represent getRepresent(@NotNull Tag tag) { + return this.represents.get(tag); + } + + @Nullable + @Override + public Object represent(@Nullable ConfigNode data) { + if (data == null) { + return null; + } + + Represent represent = getRepresent(data.getTag()); + if (represent != null) { + return represent.represent(data); + } + + return null; + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java new file mode 100644 index 0000000..accf9d4 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java @@ -0,0 +1,23 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.components.ConfigNode; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +@FunctionalInterface +public interface Represent { + + /** + * Represents and returns a serializable object based on the provided ConfigNode. + * + * @param node the ConfigNode containing data to represent; must not be null + * + * @return represented data + */ + Object represent(@NotNull ConfigNode node); + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java new file mode 100644 index 0000000..b57fcc8 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java @@ -0,0 +1,22 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.components.MemorySection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface Representer { + + @NotNull + Map represent(@NotNull MemorySection section); + + @Nullable + Object represent(@Nullable ConfigNode data); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java new file mode 100644 index 0000000..cc7a3be --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java @@ -0,0 +1,116 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import dev.spoocy.utils.config.serializer.Serializer; +import dev.spoocy.utils.config.serializer.Serializers; +import dev.spoocy.utils.config.serializer.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Default representation strategy that converts config sections and nodes into serializable data. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class SerializingRepresenter extends BaseRepresenter { + + public static final SerializingRepresenter DEFAULT_INSTANCE = new SerializingRepresenter(NamedSerializers.DEFAULT_INSTANCE); + + private final Serializers serializers; + + public SerializingRepresenter(@NotNull Serializers serializers) { + this.serializers = Args.notNull(serializers, "serializers"); + + setRepresent(Tag.SECTION, this::representSection); + setRepresent(Tag.MAP, this::representMap); + setRepresent(Tag.SEQ, this::representList); + } + + @Override + public @Nullable Object represent(@Nullable ConfigNode data) { + Object represented = super.represent(data); + if (represented != null) { + return represented; + } + + if (data == null) { + return null; + } + + Object nodeData = data.getData(); + Tag tag = data.getTag(); + + // For typed objects (those with a custom class tag), attempt serialization + Class type = data.getType(); + if (type != null && !tag.isStandard()) { + @SuppressWarnings("unchecked") + Map serialized = serializeIfSupported(nodeData, (Class) type); + if (serialized != null) { + return serialized; + } + } + + // Return the data as-is for scalars and other types + return nodeData; + } + + @Nullable + private Object representSection(@NotNull ConfigNode node) { + Object data = node.getData(); + if (data instanceof MemorySection) { + return represent((MemorySection) data); + } + return data; + } + + @Nullable + private Object representMap(@NotNull ConfigNode node) { + Object data = node.getData(); + if (!(data instanceof Map)) { + return data; + } + + Map represented = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) data).entrySet()) { + Object value = entry.getValue(); + if (value instanceof ConfigNode) { + value = represent((ConfigNode) value); + } + + represented.put(entry.getKey(), value); + } + + return represented; + } + + @Nullable + private Object representList(@NotNull ConfigNode node) { + Object data = node.getData(); + if (!(data instanceof List)) { + return data; + } + + List represented = new ArrayList<>(((List) data).size()); + for (Object element : (List) data) { + if (element instanceof ConfigNode) { + represented.add(represent((ConfigNode) element)); + } else { + represented.add(element); + } + } + + return represented; + } + + + @Nullable + private Map serializeIfSupported(@NotNull T data, @NotNull Class clazz) { + Serializer serializer = this.serializers.resolve(clazz); + return serializer == null ? null : serializer.serializeSafely(data); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java new file mode 100644 index 0000000..7a1b179 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java @@ -0,0 +1,60 @@ +package dev.spoocy.utils.config.serializer; + +import dev.spoocy.utils.config.serializer.impl.AtomicsSerializer; +import dev.spoocy.utils.config.serializer.impl.DurationSerializer; +import dev.spoocy.utils.config.serializer.impl.JavaSerializer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.Serializable; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class BaseSerializers implements Serializers { + + public static final String SERIALIZED_TYPE_KEY = "=="; + private static final Map, Serializer> DEFAULT_SERIALIZERS = new ConcurrentHashMap<>(); + + static { + registerDefault(Duration.class, DurationSerializer.INSTANCE); + registerDefault(AtomicInteger.class, new AtomicsSerializer<>(AtomicInteger.class)); + registerDefault(AtomicLong.class, new AtomicsSerializer<>(AtomicLong.class)); + registerDefault(AtomicBoolean.class, new AtomicsSerializer<>(AtomicBoolean.class)); + } + + public static void registerDefault(@NotNull Class clazz, @NotNull Serializer serializer) { + DEFAULT_SERIALIZERS.put(clazz, serializer); + } + + private final Map, Serializer> serializers = new ConcurrentHashMap<>(); + + protected BaseSerializers() { } + + @Override + public void register(@NotNull Class clazz, @NotNull Serializer serializer) { + this.serializers.put(clazz, serializer); + } + + public void registerJavaSerializer(@NotNull Class clazz) { + this.register(clazz, JavaSerializer.create(clazz)); + } + + @Override + public @Nullable Serializer resolve(@NotNull Class clazz) { + Serializer serializer = this.serializers.get(clazz); + if (serializer != null) { + return (Serializer) serializer; + } + + return (Serializer) DEFAULT_SERIALIZERS.get(clazz); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java index 5069ace..ead4fb0 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java @@ -1,13 +1,21 @@ package dev.spoocy.utils.config.serializer; import java.util.Map; + /** * Interface for serializable configuration objects. - * Instead of using {@link ConfigSerializer}. *

- * When implementing this interface, the class must - * contain a public static method `deserialize(Map map)` that returns a + * When implementing this interface, the class is expected to + * contain a public static method {@code deserialize(Map map)} that returns a * new instance of the class from a Map. + *

+ * {@code
+ * public static MyClass deserialize(Map map) {
+ *      // Deserialize the map into an instance of MyClass
+ *      return new MyClass(...);
+ * }
+ *
+ * }
  *
  * @author Spoocy99 | GitHub: Spoocy99
  */
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java
similarity index 57%
rename from config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializer.java
rename to config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java
index 6e8540d..ceebcaf 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializer.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java
@@ -2,17 +2,12 @@
 
 import dev.spoocy.utils.common.cache.Cache;
 import dev.spoocy.utils.common.cache.Caches;
-import dev.spoocy.utils.config.serializer.impl.AtomicsSerializer;
-import dev.spoocy.utils.config.serializer.impl.DurationSerializer;
-import dev.spoocy.utils.config.serializer.impl.EnumSerializer;
-import dev.spoocy.utils.config.serializer.impl.JavaSerializer;
 import dev.spoocy.utils.reflection.Reflection;
 import dev.spoocy.utils.reflection.accessor.ClassAccess;
 import dev.spoocy.utils.reflection.accessor.MethodAccessor;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
-import java.io.Serializable;
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -20,50 +15,48 @@
  * @author Spoocy99 | GitHub: Spoocy99
  */
 
-public class ConfigSerializer {
+public class NamedSerializers extends BaseSerializers {
 
-    public static final String SERIALIZED_TYPE_KEY = "==";
-    private static final Map, Serializer> DIRECT_SERIALIZERS = new ConcurrentHashMap<>();
-    private static final Map> NAMES = new ConcurrentHashMap<>();
-
-    static {
-        registerSerializer(java.time.Duration.class, DurationSerializer.INSTANCE);
-        registerSerializer(java.util.concurrent.atomic.AtomicInteger.class, new AtomicsSerializer<>(java.util.concurrent.atomic.AtomicInteger.class));
-        registerSerializer(java.util.concurrent.atomic.AtomicLong.class, new AtomicsSerializer<>(java.util.concurrent.atomic.AtomicLong.class));
-        registerSerializer(java.util.concurrent.atomic.AtomicBoolean.class, new AtomicsSerializer<>(java.util.concurrent.atomic.AtomicBoolean.class));
-    }
+    public static final NamedSerializers DEFAULT_INSTANCE = new NamedSerializers();
 
     /**
-     * Singleton instance of ClassSerializer for handling a specific class type.
+     * Get the serialization name for a given {@link ConfigSerializable} class.
      *
-     * @param serializer the ClassSerializer instance
+     * @param clazz the class to get the serialization name for
+     *
+     * @return the serialization name
      */
-    public static  void registerSerializer(@NotNull Class clazz, @NotNull Serializer serializer) {
-        DIRECT_SERIALIZERS.put(clazz, serializer);
+    @NotNull
+    public static String getSerializeName(@NotNull Class clazz) {
+        DelegateDeserialization delegate = clazz.getAnnotation(DelegateDeserialization.class);
+        if (delegate != null && delegate.value() != clazz) {
+            return getSerializeName(delegate.value());
+        }
+
+        SerializableAs alias = clazz.getAnnotation(SerializableAs.class);
+        if (alias != null) {
+            return alias.value();
+        }
+
+        return clazz.getName();
     }
 
-    /**
-     * Register a Java {@link Serializable} class for serialization/deserialization using Java's built-in serialization.
-     *
-     * @param clazz the class to register
-     *
-     * @see JavaSerializer
-     */
-    public static  void useJavaSerializer(@NotNull Class clazz) {
-        registerSerializer(clazz, JavaSerializer.create(clazz));
+    private final Map> names = new ConcurrentHashMap<>();
+
+    public NamedSerializers() {
+        super();
     }
 
-    /**
-     * {@link ConfigSerializable} Class to be registered for serialization/deserialization.
-     *
-     * @param clazz the class to register
-     */
-    public static void register(@NotNull Class clazz) {
+    public void register(@NotNull Class clazz) {
         String name = getSerializeName(clazz);
-        if (NAMES.containsKey(name)) {
-            throw new IllegalArgumentException("Class with name '" + name + "' is already registered for serialization: " + NAMES.get(name).getName());
+        this.registerName(clazz, name);
+    }
+
+    protected void registerName(@NotNull Class clazz, @NotNull String name) {
+        if (this.names.containsKey(name)) {
+            throw new IllegalArgumentException("Class with name '" + name + "' is already registered for serialization: " + this.names.get(name).getName());
         }
-        NAMES.put(name, clazz);
+        this.names.put(name, clazz);
     }
 
     /**
@@ -74,58 +67,49 @@ public static void register(@NotNull Class clazz)
      * @return the registered class, or null if not found
      */
     @Nullable
-    public static Class getSerializableClassByName(@NotNull String name) {
-        return NAMES.get(name);
+    public Class getSerializableClassByName(@NotNull String name) {
+        return this.names.get(name);
     }
 
-    /**
-     * Resolve an appropriate {@link Serializer} for the given class.
-     *
-     * @param clazz the class to find a serializer for
-     * @return the resolved ObjectSerializer, or null if none found
-     *
-     * @param  the type of the object to be serialized/deserialized
-     */
     @Nullable
-    public static  Serializer resolve(@NotNull Class clazz) {
-        Serializer serializer = DIRECT_SERIALIZERS.get(clazz);
+    public  Serializer resolve(@NotNull Class clazz) {
+        Serializer serializer = super.resolve(clazz);
 
-        // direct serializer
         if(serializer != null) {
-            return (Serializer) serializer;
+            // direct serializer found
+
+            return (Serializer) serializer;
+        }
+
+        if(ConfigSerializable.class.isAssignableFrom(clazz)) {
+            // implements ConfigSerializable
 
-        // implements ConfigSerializable
-        } else if(ConfigSerializable.class.isAssignableFrom(clazz)) {
             Class csClass = (Class) clazz;
-            return (Serializer) ClassSerializer.create(csClass);
+            return (Serializer) ClassSerializer.create(csClass);
 
-        // is Enum
-        } else if (clazz.isEnum()) {
-            return EnumSerializer.create((Class) clazz);
         }
 
         return null;
     }
 
-    /**
-     * Serialize a {@link ConfigSerializable} object to a map.
-     *
-     *
-     * @param map the map to deserialize
-     *
-     * @return the deserialized object, or null if deserialization fails
-     *
-     * @throws IllegalArgumentException if deserialization fails
-     */
-    @Nullable
-    public static ConfigSerializable deserialize(@NotNull Map map) {
+    @Override
+    public @NotNull Map serialize(@NotNull Object object) {
+        Serializer serializer = resolve(object.getClass());
+        if (serializer == null) {
+            throw new IllegalArgumentException("No serializer found for class: " + object.getClass().getName());
+        }
+        return serializer.serialize(object);
+    }
+
+    @Override
+    public @Nullable Object deserialize(@NotNull Map map) {
         Object typeObj = map.get(SERIALIZED_TYPE_KEY);
         if (!(typeObj instanceof String)) {
             return null;
         }
 
         String typeName = (String) typeObj;
-        Class clazz = getSerializableClassByName(typeName);
+        Class clazz = getSerializableClassByName(typeName);
         if (clazz == null) {
             return null;
         }
@@ -136,34 +120,12 @@ public static ConfigSerializable deserialize(@NotNull Map map) {
         }
 
         try {
-            return (ConfigSerializable) serializer.deserialize(map);
+            return serializer.deserialize(map);
         } catch (Exception e) {
             return null;
         }
     }
 
-    /**
-     * Get the serialization name for a given {@link ConfigSerializable} class.
-     *
-     * @param clazz the class to get the serialization name for
-     *
-     * @return the serialization name
-     */
-    @NotNull
-    public static String getSerializeName(@NotNull Class clazz) {
-        DelegateDeserialization delegate = clazz.getAnnotation(DelegateDeserialization.class);
-        if (delegate != null && delegate.value() != clazz) {
-            return getSerializeName(delegate.value());
-        }
-
-        SerializableAs alias = clazz.getAnnotation(SerializableAs.class);
-        if (alias != null) {
-            return alias.value();
-        }
-
-        return clazz.getName();
-    }
-
     /**
      * Serializer implementation for classes implementing {@link ConfigSerializable}.
      */
@@ -176,20 +138,6 @@ public static  ClassSerializer create(@NotNull
             return (ClassSerializer) CLASS_CACHE.computeIfAbsent(clazz, c -> new ClassSerializer<>(clazz));
         }
 
-        @Nullable
-        private MethodAccessor findMethod(String methodName) {
-            return METHOD_CACHE.computeIfAbsent(
-                this.clazz + "#" + methodName,
-                    key -> this.access.method(
-                            Reflection.method()
-                                    .requireStatic()
-                                    .name(methodName)
-                                    .parameterCount(1)
-                                    .parameterType(0, Map.class)
-                                    .build()
-                    ));
-        }
-
         private final Class clazz;
         private final ClassAccess access;
 
@@ -204,7 +152,7 @@ private ClassSerializer(@NotNull Class clazz) {
         @Override
         public @NotNull Map serialize(@NotNull O object) {
             try {
-                Map map = object.serialize();
+                Map map = new HashMap<>(object.serialize());
                 map.put(SERIALIZED_TYPE_KEY, getSerializeName(object.getClass()));
                 return map;
             } catch (Exception e) {
@@ -235,6 +183,21 @@ private ClassSerializer(@NotNull Class clazz) {
 
             return (O) result;
         }
+
+        @Nullable
+        private MethodAccessor findMethod(@NotNull String methodName) {
+            return METHOD_CACHE.computeIfAbsent(
+                this.clazz + "#" + methodName,
+                    key -> this.access.method(
+                            Reflection.method()
+                                    .requireStatic()
+                                    .name(methodName)
+                                    .parameterCount(1)
+                                    .parameterType(0, Map.class)
+                                    .build()
+                    ));
+        }
+
     }
 
 }
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java
index 8a2b6c6..cac53fc 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java
@@ -32,6 +32,6 @@
      * @return Name to serialize the class as.
      */
     @NotNull
-    public String value();
+    String value();
 
 }
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java
new file mode 100644
index 0000000..0aa2ae3
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java
@@ -0,0 +1,69 @@
+package dev.spoocy.utils.config.serializer;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+
+/**
+ * Defines a contract for managing serializers for various object types,
+ * facilitating their conversion to and from map representations. Implementations
+ * may support dynamic registration, resolution, serialization, and deserialization
+ * processes, allowing efficient mapping of object states to maps.
+ *
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+public interface Serializers {
+
+    /**
+     * Registers a serializer for a specific class type. The provided serializer
+     * will be used to serialize and deserialize instances of the specified class.
+     *
+     * @param clazz      the class type to register the serializer for, must not be null
+     * @param serializer the serializer implementation for the specified class type, must not be null
+     * @param         the type of the class
+     */
+     void register(@NotNull Class clazz, @NotNull Serializer serializer);
+
+    /**
+     * Resolves and retrieves the serializer associated with the specified class type.
+     * If no serializer is registered for the given class, this method returns null.
+     *
+     * @param    the type of the class for which the serializer is being resolved
+     * @param clazz the class for which the serializer is being resolved, must not be null
+     *
+     * @return the serializer registered for the specified class, or null if no serializer is found
+     */
+    @Nullable
+     Serializer resolve(@NotNull Class clazz);
+    /**
+     * Serializes the given object into a map representation. The resulting map
+     * contains key-value pairs that represent the state and structure of the object,
+     * allowing it to be easily stored, transferred, or reconstructed.
+     *
+     * @param object the object to serialize, must not be null
+     *
+     * @return a map containing the serialized representation of the object
+     *
+     * @throws IllegalArgumentException if the object cannot be serialized
+     *                                  due to unsupported type or invalid state
+     */
+    @NotNull
+    Map serialize(@NotNull Object object);
+
+    /**
+     * Deserializes a map into an object. The map is expected to contain serialized data
+     * and associated type information to recreate the original object.
+     *
+     * @param map the map containing serialized data and type information, must not be null
+     *
+     * @return the deserialized object reconstructed from the map
+     *
+     * @throws IllegalArgumentException if the map does not contain valid type information
+     *                                  or is incompatible with the expected deserialization process
+     */
+    @Nullable
+    Object deserialize(@NotNull Map map);
+
+
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java
new file mode 100644
index 0000000..96b363d
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java
@@ -0,0 +1,170 @@
+package dev.spoocy.utils.config.serializer;
+
+import dev.spoocy.utils.common.misc.Args;
+import org.jetbrains.annotations.NotNull;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public final class Tag {
+
+    public static final Set STANDARD_TAGS = new HashSet<>(9);
+    private static final Map>> COMPATIBILITY_MAP = new HashMap<>();
+
+    public static final String PREFIX = "tag:dev.spoocy.utils.config:";
+    public static final Tag SECTION = forName("section");
+    public static final Tag NULL = forName("null");
+    public static final Tag INT = forName("int");
+    public static final Tag FLOAT = forName("float");
+    public static final Tag TIMESTAMP = forName("timestamp");
+    public static final Tag BOOL = forName("bool");
+    public static final Tag STR = forName("str");
+    public static final Tag SEQ = forName("seq");
+    public static final Tag MAP = forName("map");
+
+    private static Tag forName(@NotNull String tagName) {
+        Tag tag = new Tag(PREFIX + tagName);
+        STANDARD_TAGS.add(tag);
+        return tag;
+    }
+
+    static {
+        COMPATIBILITY_MAP.put(
+                FLOAT,
+                Set.of(
+                        Double.class,
+                        Float.class,
+                        BigDecimal.class
+                )
+        );
+
+        COMPATIBILITY_MAP.put(INT, Set.of(
+                Integer.class,
+                Long.class,
+                BigInteger.class
+        ));
+
+        Set> timestampSet = new HashSet<>();
+        timestampSet.add(Date.class);
+
+        try {
+            timestampSet.add(Class.forName("java.sql.Date"));
+            timestampSet.add(Class.forName("java.sql.Timestamp"));
+        } catch (ClassNotFoundException ignored) { }
+
+        COMPATIBILITY_MAP.put(TIMESTAMP, timestampSet);
+    }
+
+    private final String value;
+    private final boolean secondary;
+
+    public Tag(@NotNull String tag) {
+        this.value = Args.notNullOrEmpty(tag, "tag");
+
+        if (tag.trim().length() != tag.length()) {
+            throw new IllegalArgumentException("Tag must not contain leading or trailing spaces.");
+        }
+
+        this.secondary = !tag.startsWith(PREFIX);
+    }
+
+    public Tag(@NotNull Class clazz) {
+        Args.notNull(clazz, "class");
+        this.value = PREFIX + clazz.getName();
+        this.secondary = false;
+    }
+
+    public boolean isStandard() {
+        return STANDARD_TAGS.contains(this);
+    }
+
+    public boolean isSecondary() {
+        return this.secondary;
+    }
+
+    public String getValue() {
+        return this.value;
+    }
+
+    public boolean startsWith(String prefix) {
+        return this.value.startsWith(prefix);
+    }
+
+    public String getClassName() {
+        if (secondary) {
+            throw new IllegalStateException("Cannot get class name from a secondary tag");
+        }
+
+        String name = value.substring(Tag.PREFIX.length());
+        return URLDecoder.decode(name, StandardCharsets.UTF_8);
+    }
+
+    @Override
+    public String toString() {
+        return this.value;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+
+        if (obj instanceof Tag) {
+            Tag other = (Tag) obj;
+            return this.value.equals(other.value);
+        }
+
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return value.hashCode();
+    }
+
+    /**
+     * Java has more than 1 class compatible with a language-independent tag (!!int, !!float,
+     * !!timestamp etc)
+     *
+     * @param clazz - Class to check compatibility
+     *
+     * @return true when the Class can be represented by this language-independent tag
+     */
+    public boolean isCompatible(@NotNull Class clazz) {
+        Set> set = COMPATIBILITY_MAP.get(this);
+
+        if (set != null) {
+            return set.contains(clazz);
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Check whether this tag matches the global tag for the Class
+     *
+     * @param clazz - Class to check
+     *
+     * @return true when this tag can be used as a global tag for the Class during serialisation
+     */
+    public boolean matches(@NotNull Class clazz) {
+        return value.equals(Tag.PREFIX + clazz.getName());
+    }
+
+    /**
+     * Check if the that is global and not standard to provide it to TagInspector for verification.
+     *
+     * @return true when the tag must be verified to avoid remote code invocation
+     */
+    public boolean isCustomGlobal() {
+        return !secondary && !STANDARD_TAGS.contains(this);
+    }
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java
index f969be1..3b3a1ff 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java
@@ -1,6 +1,6 @@
 package dev.spoocy.utils.config.serializer.impl;
 
-import dev.spoocy.utils.config.serializer.ConfigSerializer;
+import dev.spoocy.utils.config.serializer.NamedSerializers;
 import dev.spoocy.utils.config.serializer.Serializer;
 import org.jetbrains.annotations.NotNull;
 
@@ -46,7 +46,7 @@ public AtomicsSerializer(@NotNull Class clazz) {
                 throw new IllegalArgumentException("Unsupported atomic type: " + clazz.getName());
             }
 
-            map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, clazz.getName());
+            map.put(NamedSerializers.SERIALIZED_TYPE_KEY, clazz.getName());
             return map;
         } catch (Exception e) {
             throw new IllegalArgumentException("Failed to serialize object of type: " + object.getClass().getName(), e);
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java
index 0f32707..e7afb45 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java
@@ -1,6 +1,6 @@
 package dev.spoocy.utils.config.serializer.impl;
 
-import dev.spoocy.utils.config.serializer.ConfigSerializer;
+import dev.spoocy.utils.config.serializer.NamedSerializers;
 import dev.spoocy.utils.config.serializer.Serializer;
 import org.jetbrains.annotations.NotNull;
 
@@ -25,7 +25,7 @@ private DurationSerializer() {}
     @Override
     public @NotNull Map serialize(@NotNull Duration object) {
         return Map.of(
-                ConfigSerializer.SERIALIZED_TYPE_KEY, Duration.class.getName(),
+                NamedSerializers.SERIALIZED_TYPE_KEY, Duration.class.getName(),
                 SERIALIZED_DURATION_SECONDS_KEY, object.getSeconds(),
                 SERIALIZED_DURATION_NANOS_KEY, object.getNano()
         );
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/EnumSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/EnumSerializer.java
deleted file mode 100644
index 252825d..0000000
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/EnumSerializer.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package dev.spoocy.utils.config.serializer.impl;
-
-import dev.spoocy.utils.common.cache.Cache;
-import dev.spoocy.utils.common.cache.Caches;
-import dev.spoocy.utils.config.serializer.ConfigSerializer;
-import dev.spoocy.utils.config.serializer.Serializer;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Serializer implementation for Enum types.
- *
- * @author Spoocy99 | GitHub: Spoocy99
- */
-public class EnumSerializer> implements Serializer {
-
-    private static final Cache, EnumSerializer> CLASS_CACHE = Caches.createLRUCache(50);
-
-    public static > EnumSerializer create(@NotNull Class clazz) {
-        return (EnumSerializer) CLASS_CACHE.computeIfAbsent(clazz, c -> new EnumSerializer<>(clazz));
-    }
-
-    public static final String SERIALIZED_ENUM_NAME_KEY = "value";
-    private final Class clazz;
-
-    private EnumSerializer(@NotNull Class clazz) {
-        this.clazz = clazz;
-    }
-
-    @Override
-    public @NotNull Map serialize(@NotNull O object) {
-        try {
-            Map map = new HashMap<>();
-            map.put(SERIALIZED_ENUM_NAME_KEY, object.name());
-            map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, object.getClass().getName());
-            return map;
-        } catch (Exception e) {
-            throw new IllegalArgumentException("Failed to serialize object of type: " + object.getClass().getName(), e);
-        }
-    }
-
-    @Override
-    public @NotNull O deserialize(@NotNull Map map) {
-        Object nameObj = map.get(SERIALIZED_ENUM_NAME_KEY);
-        if (!(nameObj instanceof String)) {
-            throw new IllegalArgumentException("Serialized enum name is missing or not a string!");
-        }
-
-        String name = (String) nameObj;
-        try {
-            return Enum.valueOf(clazz, name);
-        } catch (IllegalArgumentException e) {
-            throw new IllegalArgumentException("No enum constant " + clazz.getName() + "." + name, e);
-        }
-    }
-}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java
index e0d3423..3ebe90d 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java
@@ -2,7 +2,7 @@
 
 import dev.spoocy.utils.common.cache.Cache;
 import dev.spoocy.utils.common.cache.Caches;
-import dev.spoocy.utils.config.serializer.ConfigSerializer;
+import dev.spoocy.utils.config.serializer.NamedSerializers;
 import dev.spoocy.utils.config.serializer.Serializer;
 import org.jetbrains.annotations.NotNull;
 
@@ -41,7 +41,7 @@ private JavaSerializer(@NotNull Class clazz) {
             String data = Base64.getEncoder().encodeToString(baos.toByteArray());
 
             return Map.of(
-                    ConfigSerializer.SERIALIZED_TYPE_KEY, clazz.getName(),
+                    NamedSerializers.SERIALIZED_TYPE_KEY, clazz.getName(),
                     SERIALIZED_DATA_KEY, data
             );
 
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java
new file mode 100644
index 0000000..5631dad
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java
@@ -0,0 +1,103 @@
+package dev.spoocy.utils.config.types;
+
+import dev.spoocy.utils.config.Config;
+import dev.spoocy.utils.config.constructor.Constructor;
+import dev.spoocy.utils.config.constructor.SerializingConstructor;
+import dev.spoocy.utils.config.representer.Representer;
+import dev.spoocy.utils.config.representer.SerializingRepresenter;
+import dev.spoocy.utils.config.serializer.NamedSerializers;
+import dev.spoocy.utils.config.serializer.Serializers;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public class ConfigSettings {
+
+    protected final Config config;
+    protected Constructor constructor = SerializingConstructor.DEFAULT_INSTANCE;
+    protected Representer representer = SerializingRepresenter.DEFAULT_INSTANCE;
+    protected char pathSeparator = '.';
+    protected Serializers resolvingSerializers = NamedSerializers.DEFAULT_INSTANCE;
+    protected List header = Collections.emptyList();
+    protected List footer = Collections.emptyList();
+
+
+    public ConfigSettings(@NotNull Config config) {
+        this.config = config;
+    }
+
+    @NotNull
+    public Config configuration() {
+        return this.config;
+    }
+
+    @NotNull
+    public Constructor constructor() {
+        return this.constructor;
+    }
+
+    @NotNull
+    public ConfigSettings constructor(@NotNull Constructor constructor) {
+        this.constructor = constructor;
+        return this;
+    }
+
+    @NotNull
+    public Representer representer() {
+        return this.representer;
+    }
+
+    @NotNull
+    public ConfigSettings representer(@NotNull Representer representer) {
+        this.representer = representer;
+        return this;
+    }
+
+    public char pathSeparator() {
+        return this.pathSeparator;
+    }
+
+    @NotNull
+    public ConfigSettings pathSeparator(char value) {
+        this.pathSeparator = value;
+        return this;
+    }
+
+    @NotNull
+    public Serializers serializers() {
+        return this.resolvingSerializers;
+    }
+
+    @NotNull
+    public ConfigSettings serializers(@NotNull Serializers serializers) {
+        this.resolvingSerializers = serializers;
+        return this;
+    }
+
+    @NotNull
+    public List header() {
+        return this.header;
+    }
+
+    @NotNull
+    public ConfigSettings header(@NotNull List header) {
+        this.header = Collections.unmodifiableList(header);
+        return this;
+    }
+
+    @NotNull
+    public List footer() {
+        return this.footer;
+    }
+
+    @NotNull
+    public ConfigSettings footer(@NotNull List footer) {
+        this.footer = Collections.unmodifiableList(footer);
+        return this;
+    }
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java
index 4088454..b71f14b 100644
--- a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java
@@ -1,311 +1,41 @@
 package dev.spoocy.utils.config.types;
 
-import dev.spoocy.utils.config.Config;
 import dev.spoocy.utils.config.components.AbstractConfig;
-import dev.spoocy.utils.config.misc.SectionList;
-import dev.spoocy.utils.common.log.ILogger;
-import dev.spoocy.utils.common.text.StringUtils;
+import dev.spoocy.utils.config.representer.SerializingRepresenter;
+import dev.spoocy.utils.config.representer.Representer;
 import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.json.JSONArray;
 import org.json.JSONObject;
 
-import java.io.*;
-import java.util.*;
-import java.util.stream.Collectors;
+import java.util.function.Consumer;
 
 /**
+ * Configuration implementation backed by a JSON object.
+ *
  * @author Spoocy99 | GitHub: Spoocy99
  */
 
 public class JsonConfig extends AbstractConfig {
 
-    private final JSONObject json;
+    protected final JsonSettings settings;
 
     public JsonConfig() {
-        super();
-        this.json = new JSONObject();
-    }
-
-    public JsonConfig(@NotNull JSONObject json) {
-        super();
-        this.json = json;
+        this(s -> {});
     }
 
-    public JsonConfig(@NotNull Map values) {
+    public JsonConfig(@NotNull Consumer settingsEditor) {
         super();
-        this.json = new JSONObject();
-
-        for (Map.Entry entry : values.entrySet()) {
-            this.set(entry.getKey(), entry.getValue());
-        }
-    }
-
-    public JsonConfig(@NotNull Config root) {
-        super(root);
-        this.json = new JSONObject();
-    }
-
-    public JsonConfig(@NotNull JSONObject json, @NotNull Config root) {
-        super(root);
-        this.json = json;
-    }
-
-    public JsonConfig(@NotNull String content) {
-        super();
-        this.json = new JSONObject(content);
-    }
-
-    public JsonConfig(@NotNull File file) {
-        super();
-        String content = "{}";
-
-        try {
-            content = getContent(new FileReader(file));
-        } catch (IOException ignored) {}
-
-        this.json = new JSONObject(content);
-    }
-
-    public JsonConfig(@NotNull InputStream inputStream) {
-        super();
-        String content = getContent(new InputStreamReader(inputStream));
-        this.json = new JSONObject(content);
-    }
-
-    @Override
-    public Config getSection(@NotNull String path) {
-        JSONObject section;
-
-        try {
-            section = json.getJSONObject(path);
-        } catch (Exception e) {
-            section = new JSONObject();
-            this.json.put(path, section);
-        }
-
-        return new JsonConfig(section, this);
-    }
-
-    @Override
-    public SectionList getSectionArray(@NotNull String path) {
-        try {
-            JSONArray array = json.getJSONArray(path);
-            List sections = new ArrayList<>();
-
-            for(Object object : array) {
-                if(object instanceof JSONObject) {
-                    sections.add(new JsonConfig((JSONObject) object, this));
-                }
-            }
-            return new SectionList<>(sections);
-        } catch (Exception e) {
-            return new SectionList<>();
-        }
-    }
-
-    @Override
-    public void write(@NotNull Writer writer) throws IOException {
-        String data = this.saveToString();
-        writer.write(data);
-    }
-
-    @Override
-    protected  void set0(@NotNull String path, @Nullable Object value) {
-        this.json.put(path, value);
-    }
-
-    @Override
-    protected @Nullable Object get0(@NotNull String path) {
-        try {
-            return this.json.get(path);
-        } catch (Throwable ignored) {
-            return null;
-        }
-    }
-
-    @Override
-    public void remove(@NotNull String path) {
-        this.json.remove(path);
-    }
-
-    @Override
-    public void clear() {
-        this.keys().forEach(this::remove);
-    }
-
-    @Override
-    public String getString(@NotNull String path, @NotNull String defaultValue) {
-        try {
-            return json.getString(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
-    }
-
-    @Override
-    public @NotNull List getStringList(@NotNull String path) {
-        try {
-            JSONArray array = json.getJSONArray(path);
-            return array.toList().stream().map(Object::toString).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
+        this.settings = new JsonSettings(this);
+        settingsEditor.accept(this.settings);
     }
 
     @Override
-    public int getInt(@NotNull String path, int defaultValue) {
-        try {
-            return json.getInt(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
+    public @NotNull ConfigSettings settings() {
+        return this.settings;
     }
 
     @Override
-    public @NotNull List getIntegerList(@NotNull String path) {
-        try {
-            JSONArray array = json.getJSONArray(path);
-            return array.toList().stream().map(o -> Integer.parseInt(o.toString())).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
-    }
-
-    @Override
-    public double getDouble(@NotNull String path, double defaultValue) {
-        try {
-            return json.getDouble(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
-    }
-
-    @Override
-    public @NotNull List getDoubleList(@NotNull String path) {
-        try {
-            JSONArray array = json.getJSONArray(path);
-            return array.toList().stream().map(o -> Double.parseDouble(o.toString())).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
-    }
-
-    @Override
-    public float getFloat(@NotNull String path, float defaultValue) {
-        try {
-            return json.getFloat(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
-    }
-
-    @Override
-    public @NotNull List getFloatList(@NotNull String path) {
-        try {
-            JSONArray array = json.getJSONArray(path);
-            return array.toList().stream().map(o -> Float.parseFloat(o.toString())).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
-    }
-
-    @Override
-    public long getLong(@NotNull String path, long defaultValue) {
-        try {
-            return this.json.getLong(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
-    }
-
-    @Override
-    public @NotNull List getLongList(@NotNull String path) {
-        try {
-            JSONArray array = this.json.getJSONArray(path);
-            return array.toList().stream().map(o -> Long.parseLong(o.toString())).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
-    }
-
-    @Override
-    public boolean getBoolean(@NotNull String path, boolean defaultValue) {
-        try {
-            return this.json.getBoolean(path);
-        } catch (Exception e) {
-            return defaultValue;
-        }
-    }
-
-    @Override
-    public @NotNull List getBooleanList(@NotNull String path) {
-        try {
-            JSONArray array = this.json.getJSONArray(path);
-            return array.toList().stream().map(o -> Boolean.parseBoolean(o.toString())).collect(Collectors.toList());
-        } catch (Exception e) {
-            return Collections.emptyList();
-        }
-    }
-
-    @Override
-    public boolean isSet(@NotNull String path) {
-        return json.has(path);
-    }
-
-    @Override
-    public boolean isList(@NotNull String path) {
-        try {
-            return this.json.getJSONArray(path) != null;
-        } catch (Exception e) {
-            return false;
-        }
-    }
-
-    @Override
-    public Collection keys() {
-        return this.json.keySet();
-    }
-
-    @Override
-    public Map values() {
-        return this.json.toMap();
-    }
-
-    public String saveToString() {
-        return this.json.toString(2);
-    }
-
-    @Override
-    public String toString() {
-        return this.saveToString();
-    }
-
-    @Override
-    public String toJson() {
-        return this.saveToString();
-    }
-
-    private String getContent(@NotNull Reader reader) {
-        String content = null;
-
-        try (BufferedReader bufferedReader = new BufferedReader(reader)) {
-            content = bufferedReader.lines().collect(Collectors.joining("\n"));
-        } catch (IOException e) {
-            ILogger.forThisClass().error("Failed to read content", e);
-        }
-        if (StringUtils.isNullOrEmpty(content)) {
-            content = "{}";
-        }
-        return content;
-    }
-
-    private String listToString(@NotNull List list) {
-        StringBuilder builder = new StringBuilder().append("[");
-        list.forEach(element -> builder.append("\"").append(element.toString()).append("\"").append(","));
-        builder.append("]");
-        if (builder.toString().contains(","))
-            builder.replace(builder.lastIndexOf(","), builder.lastIndexOf(",") + 1, "");
-        return builder.toString();
+    public @NotNull String saveToString(@NotNull Representer representer) {
+        JSONObject json = new JSONObject(serializedValues(this, representer));
+        return json.toString(2);
     }
 }
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java
new file mode 100644
index 0000000..2d35ab9
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java
@@ -0,0 +1,42 @@
+package dev.spoocy.utils.config.types;
+
+import dev.spoocy.utils.config.Config;
+import dev.spoocy.utils.config.constructor.Constructor;
+import dev.spoocy.utils.config.representer.Representer;
+import dev.spoocy.utils.config.serializer.Serializers;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public class JsonSettings extends ConfigSettings {
+
+    public JsonSettings(@NotNull Config config) {
+        super(config);
+    }
+
+    @Override
+    public @NotNull JsonSettings constructor(@NotNull Constructor constructor) {
+        super.constructor(constructor);
+        return this;
+    }
+
+    @Override
+    public @NotNull JsonSettings representer(@NotNull Representer representer) {
+        super.representer(representer);
+        return this;
+    }
+
+    @Override
+    public @NotNull JsonSettings pathSeparator(char value) {
+        super.pathSeparator(value);
+        return this;
+    }
+
+    @Override
+    public @NotNull ConfigSettings serializers(@NotNull Serializers serializers) {
+        super.serializers(serializers);
+        return this;
+    }
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/MapConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/MapConfig.java
deleted file mode 100644
index dd1ac38..0000000
--- a/config-utils/src/main/java/dev/spoocy/utils/config/types/MapConfig.java
+++ /dev/null
@@ -1,126 +0,0 @@
-package dev.spoocy.utils.config.types;
-
-import dev.spoocy.utils.config.Config;
-import dev.spoocy.utils.config.components.AbstractConfig;
-import dev.spoocy.utils.config.misc.SectionList;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.io.IOException;
-import java.io.Writer;
-import java.util.*;
-
-/**
- * @author Spoocy99 | GitHub: Spoocy99
- */
-
-public class MapConfig extends AbstractConfig {
-
-    private final Map values;
-
-    public MapConfig() {
-        super();
-        this.values = new LinkedHashMap<>();
-    }
-
-    public MapConfig(@NotNull Map values) {
-        super();
-        this.values = values;
-    }
-
-    public MapConfig(@NotNull Map values, Config parent) {
-        super(parent);
-        this.values = values;
-    }
-
-    @Override
-    protected void set0(@NotNull String path, @Nullable Object value) {
-        this.values.put(path, value);
-
-    }
-
-    @Override
-    protected @Nullable Object get0(@NotNull String path) {
-        return this.values.get(path);
-    }
-
-    @Override
-    public void remove(@NotNull String path) {
-        this.values.remove(path);
-    }
-
-    @Override
-    public void clear() {
-        keys().forEach(this.values::remove);
-    }
-
-    @Override
-    public Config getSection(@NotNull String path) {
-		Object value = this.values.computeIfAbsent(path, key -> new HashMap<>());
-
-        if(value instanceof Map) {
-            return new MapConfig((Map) value, this);
-        }
-
-        if(value instanceof Config) {
-            return (Config) value;
-        }
-
-        throw new UnsupportedOperationException(path + " is not a Section in MapDocument.");
-    }
-
-    @Override
-    public SectionList getSectionArray(@NotNull String path) {
-		List sections = new ArrayList<>();
-		Object value = values.get(path);
-
-        if(!(value instanceof List)) {
-            return new SectionList<>();
-        }
-
-        List list = (List) value;
-        for (Object object : list) {
-
-            if (object instanceof Map) {
-                sections.add(new MapConfig((Map) object, this));
-            }
-
-            if (object instanceof Config) {
-                sections.add((Config) object);
-            }
-
-        }
-
-        return new SectionList<>(sections);
-    }
-
-    @Override
-    public boolean isSet(@NotNull String path) {
-        return this.values.containsKey(path);
-    }
-
-    @Override
-    public Collection keys() {
-        return this.values.keySet();
-    }
-
-    @Override
-    public Map values() {
-        return new LinkedHashMap<>(values);
-    }
-
-    @Override
-    public void write(@NotNull Writer writer) throws IOException {
-        new JsonConfig(values).write(writer);
-    }
-
-    @Override
-    public String toJson() {
-        return new JsonConfig(values).toJson();
-    }
-
-    @Override
-    public String toString() {
-        return this.toJson();
-    }
-}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java
new file mode 100644
index 0000000..fe9bdc0
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java
@@ -0,0 +1,37 @@
+package dev.spoocy.utils.config.types;
+
+import dev.spoocy.utils.config.components.AbstractConfig;
+import dev.spoocy.utils.config.io.WriteableResource;
+import dev.spoocy.utils.config.representer.Representer;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.IOException;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public class MemoryConfig extends AbstractConfig {
+
+    private final ConfigSettings settings;
+
+    public MemoryConfig() {
+        super();
+        this.settings = new ConfigSettings(this);
+    }
+
+    @Override
+    public @NotNull ConfigSettings settings() {
+        return this.settings;
+    }
+
+    @Override
+    public @NotNull String saveToString(@NotNull Representer representer) {
+        throw new UnsupportedOperationException("Saving to string is not supported for MemoryConfig");
+    }
+
+    @Override
+    public void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException {
+        throw new UnsupportedOperationException("Saving to string is not supported for MemoryConfig");
+    }
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java
new file mode 100644
index 0000000..3439660
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java
@@ -0,0 +1,130 @@
+package dev.spoocy.utils.config.types;
+
+
+import dev.spoocy.utils.config.components.AbstractConfig;
+import dev.spoocy.utils.config.components.MemorySection;
+import dev.spoocy.utils.common.tuple.Pair;
+import dev.spoocy.utils.config.components.ConfigNode;
+import dev.spoocy.utils.config.io.WriteableResource;
+import dev.spoocy.utils.config.loader.YamlProcessor;
+import dev.spoocy.utils.config.representer.SerializingRepresenter;
+import dev.spoocy.utils.config.representer.Representer;
+import org.jetbrains.annotations.NotNull;
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.comments.CommentLine;
+import org.yaml.snakeyaml.comments.CommentType;
+import org.yaml.snakeyaml.nodes.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.function.Consumer;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public class YamlConfig extends AbstractConfig {
+
+    protected final YamlSettings settings;
+
+    public YamlConfig() {
+        this(s -> {});
+    }
+
+    public YamlConfig(@NotNull Consumer settingsEditor) {
+        super();
+        this.settings = new YamlSettings(this);
+        settingsEditor.accept(this.settings);
+    }
+
+    @Override
+    public @NotNull YamlSettings settings() {
+        return this.settings;
+    }
+
+    @Override
+    public @NotNull String saveToString(@NotNull Representer representer) {
+        YamlProcessor processor = this.settings.processor();
+        processor.applyOptions(this.settings);
+
+        MappingNode node = toNodeTree(this, processor, representer);
+
+        node.setBlockComments(getCommentLines(saveHeader(this.header), CommentType.BLOCK));
+        node.setEndComments(getCommentLines(this.footer, CommentType.BLOCK));
+
+        StringWriter writer = new StringWriter();
+        if (node.getBlockComments().isEmpty() && node.getEndComments().isEmpty() && node.getValue().isEmpty()) {
+            writer.write("");
+        } else {
+
+            if (node.getValue().isEmpty()) {
+                node.setFlowStyle(DumperOptions.FlowStyle.FLOW);
+            }
+
+            processor.serialize(node, writer);
+        }
+        return writer.toString();
+    }
+
+    private MappingNode toNodeTree(@NotNull MemorySection section, @NotNull YamlProcessor processor, @NotNull Representer representer) {
+        List nodeTuples = new ArrayList<>();
+
+        for (Pair entry : section.entries()) {
+            String key = entry.first();
+            ConfigNode node = entry.second();
+
+            Node keyNode = processor.represent(key);
+            Node valueNode;
+
+            Object nodeData = node.getData();
+            dev.spoocy.utils.config.serializer.Tag tag = node.getTag();
+            
+            if (tag == dev.spoocy.utils.config.serializer.Tag.SECTION && nodeData instanceof MemorySection) {
+                // Recursively handle subsections
+                valueNode = toNodeTree((MemorySection) nodeData, processor, representer);
+            } else {
+                // Use representer for non-section values
+                Object represented = representer.represent(node);
+                valueNode = processor.represent(represented);
+            }
+
+            keyNode.setBlockComments(getCommentLines(section.getComments(key), CommentType.BLOCK));
+            if (valueNode instanceof MappingNode || valueNode instanceof SequenceNode) {
+                keyNode.setInLineComments(getCommentLines(section.getInlineComments(key), CommentType.IN_LINE));
+            } else {
+                valueNode.setInLineComments(getCommentLines(section.getInlineComments(key), CommentType.IN_LINE));
+            }
+
+            nodeTuples.add(new NodeTuple(keyNode, valueNode));
+        }
+
+        return new MappingNode(Tag.MAP, nodeTuples, DumperOptions.FlowStyle.BLOCK);
+    }
+
+    private List getCommentLines(@NotNull List comments, @NotNull CommentType commentType) {
+        List lines = new ArrayList<>();
+
+        for (String comment : comments) {
+            if (comment == null) {
+                lines.add(new CommentLine(null, null, "", CommentType.BLANK_LINE));
+                continue;
+            }
+
+            String line = comment;
+            line = line.isEmpty() ? line : " " + line;
+            lines.add(new CommentLine(null, null, line, commentType));
+        }
+        return lines;
+    }
+
+    private List saveHeader(@NotNull List header) {
+        LinkedList list = new LinkedList<>(header);
+
+        if (!list.isEmpty()) {
+            list.add(null);
+        }
+
+        return list;
+    }
+
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java
new file mode 100644
index 0000000..c8496c8
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java
@@ -0,0 +1,104 @@
+package dev.spoocy.utils.config.types;
+
+import dev.spoocy.utils.config.Config;
+import dev.spoocy.utils.config.constructor.Constructor;
+import dev.spoocy.utils.config.loader.YamlProcessor;
+import dev.spoocy.utils.config.representer.Representer;
+import dev.spoocy.utils.config.serializer.Serializers;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * @author Spoocy99 | GitHub: Spoocy99
+ */
+
+public class YamlSettings extends ConfigSettings {
+
+    protected YamlProcessor processor;
+    protected int indent = 2;
+    protected int width;
+    protected boolean prettyFlow = false;
+    protected boolean comments = true;
+
+    public YamlSettings(@NotNull Config config) {
+        super(config);
+        this.processor = new YamlProcessor();
+    }
+
+    @Override
+    public @NotNull YamlSettings constructor(@NotNull Constructor constructor) {
+        super.constructor(constructor);
+        return this;
+    }
+
+    @Override
+    public @NotNull YamlSettings representer(@NotNull Representer representer) {
+        super.representer(representer);
+        return this;
+    }
+
+    @Override
+    public @NotNull YamlSettings pathSeparator(char value) {
+        super.pathSeparator(value);
+        return this;
+    }
+
+    @Override
+    public @NotNull YamlSettings serializers(@NotNull Serializers serializers) {
+        super.serializers(serializers);
+        return this;
+    }
+
+    public YamlProcessor processor() {
+        if(this.processor == null) {
+            this.processor = new YamlProcessor();
+        }
+        return this.processor;
+    }
+
+    public YamlSettings processor(@NotNull YamlProcessor processor) {
+        this.processor = processor;
+        return this;
+    }
+
+    public int indent() {
+        return this.indent;
+    }
+
+    @NotNull
+    public YamlSettings indent(int value) {
+        this.indent = value;
+        return this;
+    }
+
+    public int width() {
+        return this.width;
+    }
+
+    @NotNull
+    public YamlSettings width(int value) {
+        this.width = value;
+        return this;
+    }
+
+    public boolean prettyFlow() {
+        return this.prettyFlow;
+    }
+
+    @NotNull
+    public YamlSettings prettyFlow(boolean value) {
+        this.prettyFlow = value;
+        return this;
+    }
+
+    public boolean comments() {
+        return this.comments;
+    }
+
+    @NotNull
+    public YamlSettings comments(boolean value) {
+        this.comments = value;
+        return this;
+    }
+
+
+}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigMigration.java
new file mode 100644
index 0000000..6b7d2ab
--- /dev/null
+++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigMigration.java
@@ -0,0 +1,126 @@
+package dev.spoocy.utils.config.update;
+
+import dev.spoocy.utils.common.version.Version;
+import dev.spoocy.utils.config.ConfigSection;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Defines the contract for configuration migrations that update a configuration from one schema version to another.
+ *
+ * 

Configuration migrations are used to evolve configuration schemas over time. Each migration specifies + * the source version range it applies to (via {@link #fromVersion()}) and the target version it updates to + * (via {@link #toVersion()}). Migrations can be chained in a {@link ConfigUpdaterChain} to progressively + * update configurations across multiple schema versions.

+ * + *

Migration Lifecycle

+ *
    + *
  1. A migration is created with source and target versions
  2. + *
  3. The version matcher ({@link #fromVersion()}) is checked against the current config version
  4. + *
  5. If the matcher returns true, {@link #apply(ConfigSection)} is invoked to perform the update
  6. + *
  7. If successful, the configuration is marked as being at the target version
  8. + *
+ * + *

Implementation Guidelines

+ *
    + *
  • Atomicity: Migrations should be atomic - either fully apply or not at all
  • + *
  • Idempotence: Migrations should be safe to apply multiple times (when practical)
  • + *
  • Backward Compatibility: Avoid breaking existing configurations unnecessarily
  • + *
  • Error Handling: Throw exceptions on failure rather than silently degrading
  • + *
+ * + *

Example Implementation

+ *
{@code
+ * public class RenameFieldMigration implements ConfigMigration {
+ *     private final String oldKey;
+ *     private final String newKey;
+ *
+ *     public RenameFieldMigration(String oldKey, String newKey) {
+ *         this.oldKey = oldKey;
+ *         this.newKey = newKey;
+ *     }
+ *
+ *     @Override
+ *     public VersionMatcher fromVersion() {
+ *         return VersionMatcher.atLeast(Version.of("1.0.0"));
+ *     }
+ *
+ *     @Override
+ *     public Version toVersion() {
+ *         return Version.of("1.1.0");
+ *     }
+ *
+ *     @Override
+ *     public boolean apply(ConfigSection config) {
+ *         if (config.isSet(oldKey) && !config.isSet(newKey)) {
+ *             Object value = config.getObject(oldKey);
+ *             config.set(newKey, value);
+ *             config.remove(oldKey);
+ *             return true;
+ *         }
+ *         return false;
+ *     }
+ * }
+ * }
+ * + * @see ConfigUpdaterChain + * @see VersionMatcher + * @author Spoocy99 | GitHub: Spoocy99 + */ +public interface ConfigMigration { + + /** + * Returns the version matcher that determines if this migration applies to a configuration. + * + *

The matcher checks the current configuration version and returns {@code true} if this migration + * should be applied, {@code false} otherwise.

+ * + *

If this method returns null, the migration is considered to apply to any version. This is equivalent + * to {@link VersionMatcher#ANY}.

+ * + * @return the version matcher, or {@code null} to apply to any version + */ + @Nullable + default VersionMatcher fromVersion() { + return null; + } + + /** + * Retrieves the target version to which the configuration is updated after this migration. + * + *

This version should be higher than the source version(s) specified by {@link #fromVersion()}. + * In a migration chain, this becomes the current version for the next migration to check.

+ * + * @return the target {@link Version} that this migration updates to, never null + */ + @NotNull + Version toVersion(); + + /** + * Applies this migration to the provided configuration section. + * + *

This method performs the actual transformation, which may include: + *

    + *
  • Renaming, adding, or removing configuration fields
  • + *
  • Converting data types or restructuring the configuration tree
  • + *
  • Validating or normalizing configuration values
  • + *
+ *

+ * + *

Contract: + *

    + *
  • The method must not be null
  • + *
  • Changes are made in-place on the provided ConfigSection
  • + *
  • Return {@code true} if any changes were made, {@code false} if the config was already in the target state
  • + *
  • Throw an exception on error (do not silently fail)
  • + *
+ *

+ * + * @param config the configuration section to be updated + * @return {@code true} if the migration was applied and made changes, {@code false} otherwise + * @throws IllegalArgumentException if the configuration is invalid for this migration + * @throws IllegalStateException if the migration fails for any reason + */ + boolean apply(@NotNull ConfigSection config); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdater.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdater.java new file mode 100644 index 0000000..9daddd4 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdater.java @@ -0,0 +1,44 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; + +/** + * Applies configuration migrations from one schema version to the next. + * Implementations are responsible for finding and applying the appropriate migration sequence. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public interface ConfigUpdater { + + /** + * Convenience factory method to create a new ConfigUpdaterChain builder. + * + * @return a new builder for ConfigUpdaterChain + */ + static ConfigUpdaterChain.Builder chain() { + return ConfigUpdaterChain.builder(); + } + + /** + * Retrieves a collection of all possible configuration migrations that can be applied. + * + * @return an immutable collection of available migrations (never null) + */ + @NotNull + Collection getPossibleMigrations(); + + /** + * Executes the update process on the provided configuration section. + * Applies migrations in sequence until the configuration is at the latest version. + * + * @param config the configuration section to update (not null) + * + * @return the number of migrations applied during this run + */ + int run(@NotNull ConfigSection config); + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdaterChain.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdaterChain.java new file mode 100644 index 0000000..d51e051 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/ConfigUpdaterChain.java @@ -0,0 +1,346 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Applies one-step config migrations in sequence based on schema versions. + * Maintains a registry of available migrations and executes them in dependency order + * until the configuration reaches the latest schema version. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ConfigUpdaterChain implements ConfigUpdater { + + /** + * Creates a new builder for configuring a ConfigUpdaterChain. + * + * @return a new builder + */ + @NotNull + public static Builder builder() { + return new Builder(); + } + + public static final String DEFAULT_VERSION_PATH = "config-version"; + private static final Version DEFAULT_FALLBACK_VERSION = Version.ZERO; + private static final int MAX_CHAIN_LENGTH = 1000; + + @NotNull + private final VersionResolver versionResolver; + + @NotNull + private final Map migrationMap; + + @NotNull + private final Version targetVersion; + + public ConfigUpdaterChain( + @NotNull VersionResolver versionResolver, + @NotNull Map migrations, + @NotNull Version targetVersion + ) { + this.versionResolver = Args.notNull(versionResolver, "versionResolver"); + this.targetVersion = Args.notNull(targetVersion, "targetVersion"); + this.migrationMap = migrationMap(Args.notNull(migrations, "migrations")); + } + + @Override + @NotNull + public Collection getPossibleMigrations() { + return Collections.unmodifiableCollection(this.migrationMap.values()); + } + + private Map migrationMap(@NotNull Map migrations) { + final Map migrationMap = new HashMap<>(migrations.size()); + for (Map.Entry entry : migrations.entrySet()) { + + ConfigMigration migration = entry.getKey(); + VersionMatcher matcher = entry.getValue(); + + if (migration == null) { + throw new IllegalArgumentException("Migrations cannot be null"); + } + + if(matcher == null) { + matcher = migration.fromVersion(); + } + + if(matcher == null) { + throw new IllegalArgumentException("Version matcher cannot be null. Register directly or overwrite #fromVersion()"); + } + + if (migrationMap.containsKey(matcher)) { + throw new IllegalArgumentException("Duplicate matcher: " + matcher.describe()); + } + + migrationMap.put(matcher, migration); + } + return migrationMap; + } + + @Override + public int run(@NotNull ConfigSection config) { + Args.notNull(config, "config"); + + Version currentVersion = this.versionResolver.resolve(config); + int appliedCount = 0; + + for (int iteration = 0; iteration < MAX_CHAIN_LENGTH; iteration++) { + + // Stop if we've reached or exceeded the target version + if (currentVersion.compareTo(this.targetVersion) >= 0) { + return appliedCount; + } + + ConfigMigration next = findNext(currentVersion); + + // no applicable migration found, stop the chain + if (next == null) { + return appliedCount; + } + + // Apply the migration and update the version if successful + boolean applied = next.apply(config); + if (!applied) { + return appliedCount; + } + + Version nextVersion = Args.notNull(next.toVersion(), "migration target version"); + appliedCount++; + this.versionResolver.apply(config, nextVersion); + + // Stop safely if a migration does not advance the schema version. + if (nextVersion.compareTo(currentVersion) <= 0) { + return appliedCount; + } + + currentVersion = nextVersion; + } + + throw new IllegalStateException( + "Config update chain exceeded maximum iterations (" + MAX_CHAIN_LENGTH + ")" + ); + } + + /** + * Gets the target version that this updater aims to reach. + * + * @return the target schema version + */ + @NotNull + public Version getTargetVersion() { + return this.targetVersion; + } + + @Nullable + private ConfigMigration findNext(@NotNull Version currentVersion) { + // check exact first + for (Map.Entry entry : this.migrationMap.entrySet()) { + + VersionMatcher matcher = entry.getKey(); + if(!matcher.isExact()) continue; + + if (matcher.matches(currentVersion)) { + return entry.getValue(); + } + + } + + // check not exact after + for (Map.Entry entry : this.migrationMap.entrySet()) { + VersionMatcher matcher = entry.getKey(); + if(matcher.isExact()) continue; + + if (matcher.matches(currentVersion)) { + return entry.getValue(); + } + } + + + return null; + } + + /** + * Builder for constructing ConfigUpdaterChain instances with a fluent API. + */ + public static final class Builder { + + private static final VersionResolver DEFAULT_RESOLVER = new PathVersionResolver(DEFAULT_VERSION_PATH, DEFAULT_FALLBACK_VERSION); + + @NotNull + private final Map migrations = new HashMap<>(); + + @NotNull + private VersionResolver versionResolver = DEFAULT_RESOLVER; + + @Nullable + private Version targetVersion; + + /** + * Adds a migration to this chain in execution order. + * + * @param migration migration to add + * + * @return this builder + */ + @NotNull + public Builder apply(@NotNull ConfigMigration migration) { + Args.notNull(migration, "migration"); + this.migrations.put(migration, null); + return this; + } + + @NotNull + public Builder apply(@NotNull VersionMatcher matcher, @NotNull ConfigMigration migration) { + Args.notNull(migration, "migration"); + this.migrations.put(migration, matcher); + return this; + } + + @NotNull + public Builder applyWhen(@NotNull String pattern, @NotNull ConfigMigration migration) { + return apply(VersionMatcher.parse(pattern), migration); + } + + @NotNull + public Builder applyExact(@NotNull Version exact, @NotNull ConfigMigration migration) { + return apply(VersionMatcher.exact(exact), migration); + } + + @NotNull + public Builder applyAbove(@NotNull Version above, @NotNull ConfigMigration migration) { + return apply(VersionMatcher.above(above), migration); + } + + @NotNull + public Builder applyBelow(@NotNull Version below, @NotNull ConfigMigration migration) { + return apply(VersionMatcher.below(below), migration); + } + + /** + * Sets a custom resolver for reading and writing schema versions. + * + * @param versionResolver custom resolver + * + * @return this builder + */ + @NotNull + public Builder versionResolver(@NotNull VersionResolver versionResolver) { + this.versionResolver = Args.notNull(versionResolver, "versionResolver"); + return this; + } + + /** + * Sets the config path where the schema version is stored. + * Used by the default resolver when no custom resolver is configured. + * + * @param versionPath version key path + * + * @return this builder + */ + @NotNull + public Builder versionPath(@NotNull String versionPath, @NotNull Version fallback) { + String path = Args.notNullOrEmpty(versionPath, "versionPath").trim(); + Args.notEmpty(fallback, "fallback"); + this.versionResolver = new PathVersionResolver(path, fallback); + return this; + } + + /** + * Sets the target version that the updater should reach. + * If not specified, the target will be the version of the last migration in the chain. + * + * @param targetVersion the target schema version + * + * @return this builder + */ + @NotNull + public Builder targetVersion(@NotNull Version targetVersion) { + this.targetVersion = Args.notNull(targetVersion, "targetVersion"); + return this; + } + + /** + * Builds and returns the ConfigUpdaterChain instance. + * + * @return a new ConfigUpdaterChain + * + * @throws IllegalStateException if no migrations are configured + */ + @NotNull + public ConfigUpdaterChain build() { + if (this.migrations.isEmpty()) { + throw new IllegalStateException("Cannot build ConfigUpdaterChain without migrations"); + } + + VersionResolver resolver = this.versionResolver; + + Version chainTargetVersion = this.targetVersion; + if (chainTargetVersion == null) { + chainTargetVersion = findHighestTargetVersion(this.migrations.keySet()); + } + + return new ConfigUpdaterChain(resolver, this.migrations, chainTargetVersion); + } + + @NotNull + private static Version findHighestTargetVersion(@NotNull Collection migrations) { + Version highest = null; + + for (ConfigMigration migration : migrations) { + Version toVersion = Args.notNull(migration.toVersion(), "migration target version"); + if (highest == null || toVersion.compareTo(highest) > 0) { + highest = toVersion; + } + } + + if (highest == null) { + throw new IllegalStateException("Cannot determine target version from empty migration list"); + } + + return highest; + } + } + + private static final class PathVersionResolver implements VersionResolver { + + @NotNull + private final String versionPath; + + @Nullable + private final Version fallbackVersion; + + private PathVersionResolver(@NotNull String versionPath, @Nullable Version fallbackVersion) { + this.versionPath = versionPath; + this.fallbackVersion = fallbackVersion; + } + + @Override + @NotNull + public Version resolve(@NotNull ConfigSection config) { + Version resolved = config.getVersion(this.versionPath, null); + if (resolved != null) { + return resolved; + } + + if (this.fallbackVersion != null) { + return this.fallbackVersion; + } + + return DEFAULT_FALLBACK_VERSION; + } + + @Override + public void apply(@NotNull ConfigSection config, @NotNull Version version) { + config.set(this.versionPath, Args.notNull(version, "version") + .formatFull()); + } + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/PathVersionResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/PathVersionResolver.java new file mode 100644 index 0000000..0fb2d22 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/PathVersionResolver.java @@ -0,0 +1,46 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class PathVersionResolver implements VersionResolver { + + @NotNull + private final String versionPath; + + @Nullable + private final Version fallbackVersion; + + public PathVersionResolver(@NotNull String versionPath, @Nullable Version fallbackVersion) { + this.versionPath = versionPath; + this.fallbackVersion = fallbackVersion; + } + + @Override + @NotNull + public Version resolve(@NotNull ConfigSection config) { + Version resolved = config.getVersion(this.versionPath, null); + if (resolved != null) { + return resolved; + } + + if (this.fallbackVersion != null) { + return this.fallbackVersion; + } + + throw new IllegalStateException("Version not found at path '" + this.versionPath + "' and no fallback version provided"); + } + + @Override + public void apply(@NotNull ConfigSection config, @NotNull Version version) { + config.set(this.versionPath, Args.notNull(version, "version") + .formatFull()); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionMatcher.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionMatcher.java new file mode 100644 index 0000000..313803d --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionMatcher.java @@ -0,0 +1,161 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.update.match.ExactMatcher; +import dev.spoocy.utils.config.update.match.GreaterMatcher; +import dev.spoocy.utils.config.update.match.LowerMatcher; +import dev.spoocy.utils.config.update.match.WildcardMatcher; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface VersionMatcher { + + /** + * Determines whether the given version matches specific criteria. + * + * @param version The version to be checked; must not be null. + * + * @return {@code true} if the version matches the criteria defined by this matcher, {@code false} otherwise. + */ + boolean matches(@NotNull Version version); + + /** + * Indicates whether this version matcher represents an exact version match. + * + * @return {@code true} if this matcher represents a specific, exact version; {@code false} otherwise. + */ + boolean isExact(); + + /** + * Provides a textual description of the version matcher criteria. + * + * @return A non-null string representing the description of the version matcher's behavior. + */ + @NotNull + String describe(); + + /** + * A predefined implementation of the {@link VersionMatcher} interface that matches any version. + */ + VersionMatcher ANY = new VersionMatcher() { + + @Override + public boolean matches(@NotNull Version version) { + return true; + } + + @Override + public boolean isExact() { + return false; + } + + @Override + public @NotNull String describe() { + return "Any Version"; + } + }; + + static boolean equalsVersion(@NotNull Version version, @NotNull Version other) { + return version.compareTo(other) == 0; + } + + /** + * Creates a matcher for a single exact version. + * + * @param version exact version to match + * + * @return exact matcher + */ + @NotNull + static VersionMatcher exact(@NotNull Version version) { + return new ExactMatcher(version); + } + + /** + * Creates a matcher that matches versions greater than the specified version. + * + * @param version The version to compare against; must not be null. + * + * @return A version matcher that matches versions greater than the specified version. + */ + @NotNull + static VersionMatcher above(@NotNull Version version) { + return new GreaterMatcher(version); + } + + /** + * Creates a matcher that matches versions lower than the specified version. + * + * @param version The version to compare against; must not be null. + * + * @return A version matcher that matches versions lower than the specified version. + */ + @NotNull + static VersionMatcher below(@NotNull Version version) { + return new LowerMatcher(version); + } + + /** + * Creates a matcher from a human-friendly pattern. + * Supported patterns: + * - exact version, e.g. {@code 1.2.0} + * - wildcard major/minor/build, e.g. {@code 1.x.x}, {@code 1.2.x} + * - any version: {@code *}, {@code x}, {@code any} + * + * @param pattern version pattern + * + * @return parsed matcher + */ + @NotNull + static VersionMatcher parse(@NotNull String pattern) { + String normalized = pattern.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("pattern cannot be empty"); + } + + String lower = normalized.toLowerCase(); + if ("*".equals(lower) || "x".equals(lower) || "any".equals(lower)) { + return ANY; + } + + String[] parts = normalized.split("\\."); + if (parts.length == 0 || parts.length > 3) { + throw new IllegalArgumentException("Invalid version pattern: " + pattern); + } + + boolean containsWildcard = false; + for (String part : parts) { + String lowerPart = part.toLowerCase(); + if ("x".equals(lowerPart) || "*".equals(lowerPart)) { + containsWildcard = true; + break; + } + } + + if (!containsWildcard) { + return exact(Version.parse(normalized)); + } + + if ("x".equalsIgnoreCase(parts[0]) || "*".equals(parts[0])) { + throw new IllegalArgumentException("Major version cannot be a wildcard: " + pattern); + } + + int major = Integer.parseInt(parts[0]); + Integer minor = null; + Integer build = null; + + if (parts.length > 1 && !"x".equalsIgnoreCase(parts[1]) && !"*".equals(parts[1])) { + minor = Integer.parseInt(parts[1]); + } + + if (parts.length > 2 && !"x".equalsIgnoreCase(parts[2]) && !"*".equals(parts[2])) { + build = Integer.parseInt(parts[2]); + } + + return new WildcardMatcher(major, minor, build, normalized); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionResolver.java new file mode 100644 index 0000000..7c72a11 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/VersionResolver.java @@ -0,0 +1,41 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; + +/** + * Resolves the target version from the defaults configuration for a config update process. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface VersionResolver { + + /** + * Resolves the target version based on the provided default configuration section. + * This method extracts and determines the version information necessary for + * configuration updates or validations. + * + * @param defaultsConfig the default configuration section to be analyzed for + * extracting versioning details. Must not be null. + * + * @return the resolved {@link Version} object derived from the given default + * configuration. Will never return null. + */ + @NotNull + Version resolve(@NotNull ConfigSection defaultsConfig); + + /** + * Applies a specific version to the given default configuration section. + * This method ensures that the provided configuration section is updated + * or validated against the specified version. + * + * @param defaultsConfig the default configuration section to which the version + * will be applied. Must not be null. + * @param version the version to apply to the given default configuration + * section. Must not be null. + */ + void apply(@NotNull ConfigSection defaultsConfig, @NotNull Version version); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/base/BaseMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/base/BaseMigration.java new file mode 100644 index 0000000..caa2a3d --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/base/BaseMigration.java @@ -0,0 +1,96 @@ +package dev.spoocy.utils.config.update.base; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.ConfigMigration; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Abstract base class for configuration migrations implementing the {@link ConfigMigration} interface. + * + * @see ConfigMigration + * @see VersionMatcher + * @author Spoocy99 | GitHub: Spoocy99 + */ +public abstract class BaseMigration implements ConfigMigration { + + @NotNull + private final VersionMatcher matcher; + + @Nullable + private final Version toVersion; + + /** + * Constructs a new BaseMigration instance with the specified version matcher and target version. + * + *

The version matcher determines whether this migration should be applied to a given configuration. + * If the matcher is null, the migration will apply to any configuration version (using {@link VersionMatcher#ANY}).

+ * + *

The target version indicates the schema version that the configuration will be updated to. + * If null, subclasses must override {@link #toVersion()} for lazy version resolution.

+ * + * @param matcher the version matcher to determine applicability, or {@code null} to apply to any version + * @param toVersion the target version after migration, or {@code null} for lazy resolution via {@link #toVersion()} + */ + public BaseMigration( + @Nullable VersionMatcher matcher, + @Nullable Version toVersion + ) { + this.matcher = matcher == null ? VersionMatcher.ANY : matcher; + this.toVersion = toVersion; + } + + /** + * Convenience constructor for migrations that apply from one exact source version. + * + *

This constructor is a shorthand for creating migrations that apply to a specific version. + * If {@code fromVersion} is null, the migration applies to any configuration version.

+ * + * @param fromVersion exact source version, or {@code null} to apply to any version + * @param toVersion target version after migration + */ + public BaseMigration(@Nullable Version fromVersion, @Nullable Version toVersion) { + this(fromVersion == null ? VersionMatcher.ANY : VersionMatcher.exact(fromVersion), toVersion); + } + + @Override + public VersionMatcher fromVersion() { + return this.matcher; + } + + /** + * Returns the target version that this migration updates to. + * + *

If a non-null target version was provided in the constructor, it is returned directly. + * Otherwise, subclasses must override this method to provide lazy version resolution (e.g., by + * reading the version from a defaults resource).

+ * + * @return the target version + * @throws IllegalStateException if no target version is available and this method has not been overridden + */ + @Override + @NotNull + public Version toVersion() { + if (this.toVersion == null) { + throw new IllegalStateException("toVersion() must be overridden by subclasses that use lazy version resolution"); + } + return this.toVersion; + } + + @Override + public abstract boolean apply(@NotNull ConfigSection config); + + /** + * Returns the matcher used to determine this migration's applicability. + * + * @return version matcher for this migration + */ + @NotNull + protected VersionMatcher matcher() { + return this.matcher; + } + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/base/ResourceBasedMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/base/ResourceBasedMigration.java new file mode 100644 index 0000000..6180e66 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/base/ResourceBasedMigration.java @@ -0,0 +1,89 @@ +package dev.spoocy.utils.config.update.base; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ConfigProvider; +import dev.spoocy.utils.config.ResourceProvider; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Abstract base class for migrations that load configuration from classpath resources. + * + * @author Spoocy99 | GitHub: Spoocy99 + * @see ResourceProvider + */ +public abstract class ResourceBasedMigration extends BaseMigration { + + @NotNull + private final ConfigProvider configProvider; + + @Nullable + private volatile Config cachedConfig; + + /** + * Constructs a new ResourceBasedMigration with the specified parameters. + * + * @param matcher the version matcher to determine applicability, or {@code null} for any version + * @param toVersion the target version after migration, or {@code null} for lazy resolution + * @param configProvider the config provider to use for loading configurations, must not be null + * + * @throws IllegalArgumentException if resourceProvider is null + */ + public ResourceBasedMigration( + @Nullable VersionMatcher matcher, + @Nullable Version toVersion, + @NotNull ConfigProvider configProvider + ) { + super(matcher, toVersion); + this.configProvider = configProvider; + } + + /** + * Returns the resource provider used by this migration. + * + * @return the resource provider + */ + @NotNull + public ConfigProvider getConfigProvider() { + return this.configProvider; + } + + /** + * Loads the configuration from the resource with automatic caching. + * + *

The configuration is loaded only once on first invocation and then cached for subsequent calls. + * This caching is thread-safe and uses synchronized double-checked locking to minimize contention.

+ * + * @return the loaded configuration + * + * @throws IllegalStateException if the resource fails to load + */ + @NotNull + protected Config loadResource() { + Config cached = this.cachedConfig; + if (cached != null) { + return cached; + } + + synchronized (this) { + Config synchronizedCached = this.cachedConfig; + if (synchronizedCached != null) { + return synchronizedCached; + } + + Config loaded; + + try { + loaded = this.configProvider.provide(); + } catch (Exception e) { + throw new IllegalStateException("Failed to load configuration resource for migration: " + e.getMessage(), e); + } + + this.cachedConfig = loaded; + return loaded; + } + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/match/ExactMatcher.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/ExactMatcher.java new file mode 100644 index 0000000..392696f --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/ExactMatcher.java @@ -0,0 +1,47 @@ +package dev.spoocy.utils.config.update.match; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ExactMatcher implements VersionMatcher { + + private final Version version; + + public ExactMatcher(@NotNull Version version) { + this.version = version; + } + + @Override + public boolean matches(@NotNull Version version) { + return this.version.compareTo(version) == 0; + } + + @Override + public boolean isExact() { + return true; + } + + @Override + public @NotNull String describe() { + return this.version.formatFull(); + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof ExactMatcher)) return false; + ExactMatcher other = (ExactMatcher) object; + return VersionMatcher.equalsVersion(this.version, other.version); + } + + @Override + public int hashCode() { + return this.version.formatFull().hashCode(); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/match/GreaterMatcher.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/GreaterMatcher.java new file mode 100644 index 0000000..2920b49 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/GreaterMatcher.java @@ -0,0 +1,46 @@ +package dev.spoocy.utils.config.update.match; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class GreaterMatcher implements VersionMatcher { + + private final Version version; + + public GreaterMatcher(@NotNull Version version) { + this.version = version; + } + + @Override + public boolean matches(@NotNull Version version) { + return version.compareTo(this.version) > 0; + } + + @Override + public boolean isExact() { + return false; + } + + @Override + public @NotNull String describe() { + return "greater than " + version; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof GreaterMatcher)) return false; + GreaterMatcher other = (GreaterMatcher) object; + return VersionMatcher.equalsVersion(this.version, other.version); + } + + @Override + public int hashCode() { + return this.version.formatFull().hashCode(); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/match/LowerMatcher.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/LowerMatcher.java new file mode 100644 index 0000000..78ea7ca --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/LowerMatcher.java @@ -0,0 +1,46 @@ +package dev.spoocy.utils.config.update.match; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class LowerMatcher implements VersionMatcher { + + private final Version version; + + public LowerMatcher(@NotNull Version version) { + this.version = version; + } + + @Override + public boolean matches(@NotNull Version version) { + return version.compareTo(this.version) < 0; + } + + @Override + public boolean isExact() { + return false; + } + + @Override + public @NotNull String describe() { + return "greater than " + version; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof LowerMatcher)) return false; + LowerMatcher other = (LowerMatcher) object; + return VersionMatcher.equalsVersion(this.version, other.version); + } + + @Override + public int hashCode() { + return this.version.formatFull().hashCode(); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/match/WildcardMatcher.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/WildcardMatcher.java new file mode 100644 index 0000000..d70e968 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/match/WildcardMatcher.java @@ -0,0 +1,67 @@ +package dev.spoocy.utils.config.update.match; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class WildcardMatcher implements VersionMatcher { + + private final int major; + @Nullable + private final Integer minor; + @Nullable + private final Integer build; + private final String pattern; + + public WildcardMatcher(int major, @Nullable Integer minor, @Nullable Integer build, @NotNull String pattern) { + this.major = major; + this.minor = minor; + this.build = build; + this.pattern = pattern; + } + + @Override + public boolean matches(@NotNull Version version) { + if (version.getMajor() != this.major) { + return false; + } + + if (this.minor != null && version.getMinor() != this.minor) { + return false; + } + + return this.build == null || version.getBuild() == this.build; + } + + @Override + public boolean isExact() { + return false; + } + + @Override + public @NotNull String describe() { + return this.pattern; + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (!(object instanceof WildcardMatcher)) return false; + WildcardMatcher other = (WildcardMatcher) object; + return this.major == other.major + && Objects.equals(this.minor, other.minor) + && Objects.equals(this.build, other.build); + } + + @Override + public int hashCode() { + return Objects.hash(this.major, this.minor, this.build); + } + } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/MissingFieldsMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/MissingFieldsMigration.java new file mode 100644 index 0000000..6cc6f03 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/MissingFieldsMigration.java @@ -0,0 +1,141 @@ +package dev.spoocy.utils.config.update.migrations; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ConfigProvider; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.ResourceProvider; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.update.ConfigUpdaterChain; +import dev.spoocy.utils.config.update.VersionMatcher; +import dev.spoocy.utils.config.update.VersionResolver; +import dev.spoocy.utils.config.update.base.ResourceBasedMigration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Adds missing values from a classpath defaults file to an existing config. + *

+ * This migration loads default values from a resource file and merges them into the target + * configuration, adding only the fields that are missing. Existing values are never overwritten, + * making this migration safe for incremental updates.

+ * + * @see ResourceBasedMigration + * @see VersionResolver + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class MissingFieldsMigration extends ResourceBasedMigration { + + @NotNull + private final VersionResolver versionResolver; + + @Nullable + private volatile Version cachedResolvedVersion; + + public MissingFieldsMigration( + @NotNull Config config, + @Nullable VersionMatcher matcher, + @NotNull VersionResolver versionResolver + ) { + this(() -> config, matcher, versionResolver); + } + + /** + * Creates a new MissingFieldsMigration that fills in missing configuration values. + * + * @param configProvider the provider for loading the defaults resource + * @param matcher the version matcher to determine applicability, or {@code null} to match any version + * @param versionResolver the resolver for determining the target version from the defaults resource + * @throws IllegalArgumentException if any required parameter is null + */ + public MissingFieldsMigration( + @NotNull ConfigProvider configProvider, + @Nullable VersionMatcher matcher, + @NotNull VersionResolver versionResolver + ) { + super(matcher, null, configProvider); + this.versionResolver = Args.notNull(versionResolver, "versionResolver"); + } + + /** + * Resolves the target version from the defaults resource using the configured version resolver. + * + *

The result is cached after the first call for performance. The lazy resolution allows + * the version to be determined from the configuration content itself.

+ * + * @return the resolved target version + * @throws IllegalStateException if version resolution fails + */ + @Override + @NotNull + public Version toVersion() { + Version cached = this.cachedResolvedVersion; + if (cached != null) { + return cached; + } + + synchronized (this) { + Version synchronizedCached = this.cachedResolvedVersion; + if (synchronizedCached != null) { + return synchronizedCached; + } + + Version resolved = this.versionResolver.resolve(loadResource()); + this.cachedResolvedVersion = resolved; + return resolved; + } + } + + /** + * Applies the migration by merging missing fields from defaults into the target configuration. + * + *

This method safely adds only missing configuration fields. Fields that exist in the target + * configuration are never overwritten, and incompatible structure merges are skipped.

+ * + * @param config the configuration to migrate + * @return {@code true} if any fields were added + * @throws IllegalStateException if the defaults resource fails to load or merge fails unexpectedly + */ + @Override + public boolean apply(@NotNull ConfigSection config) { + Args.notNull(config, "config"); + try { + mergeMissing(loadResource(), config); + return true; + } catch (Exception e) { + throw new IllegalStateException("Failed to apply defaults migration", e); + } + } + + /** + * Recursively merges missing fields from defaults into the target configuration. + * + *

This method iterates through all paths in the defaults configuration and adds any that + * are missing from the target. It gracefully handles structural incompatibilities by catching + * and ignoring {@link IllegalArgumentException} when a path cannot be set (e.g., when the target + * contains a scalar value where the defaults expect a section).

+ * + * @param defaults the default configuration section with fallback values + * @param target the target configuration section to populate with missing fields + */ + private void mergeMissing(@NotNull ConfigSection defaults, @NotNull ConfigSection target) { + for (String path : defaults.keys(true)) { + if (target.isSet(path)) { + continue; + } + + Object value = defaults.getObject(path); + if (value == null || value instanceof ConfigSection) { + continue; + } + + // Skip incompatible trees (e.g. target contains scalar where defaults expects a section). + try { + target.set(path, value); + } catch (IllegalArgumentException ignored) { + } + } + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/TransformationMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/TransformationMigration.java new file mode 100644 index 0000000..cdbdb6c --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/TransformationMigration.java @@ -0,0 +1,261 @@ +package dev.spoocy.utils.config.update.migrations; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.VersionMatcher; +import dev.spoocy.utils.config.update.base.BaseMigration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Applies transformations to configuration values for schema migration. + * + *

This migration provides a flexible way to restructure configurations by supporting: + *

    + *
  • Key renaming with conflict detection
  • + *
  • Key removal for deprecated fields
  • + *
  • Value transformation using custom functions
  • + *
  • Custom transformations via the {@link Transformation} interface
  • + *
+ *

+ * + *

Transformations are applied in the order they are added. Each transformation is independent + * and failures in one transformation do not affect the execution of subsequent transformations.

+ * + *

Example Usage

+ *
{@code
+ * TransformationMigration migration = new TransformationMigration(
+ *     Version.of("1.0.0"),
+ *     Version.of("1.1.0")
+ * )
+ * .renameKey("old-key", "new-key")
+ * .removeKey("deprecated-key")
+ * .transformValue("port", val -> Integer.parseInt(val.toString()))
+ * .addTransformation(new CustomTransformation());
+ * }
+ * + * @see Transformation + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class TransformationMigration extends BaseMigration { + + @NotNull + private final List transformations; + + public TransformationMigration( + @Nullable Version fromVersion, + @NotNull Version toVersion + ) { + this(fromVersion == null ? null : VersionMatcher.exact(fromVersion), toVersion); + } + + public TransformationMigration( + @Nullable VersionMatcher matcher, + @NotNull Version toVersion + ) { + super(matcher, toVersion); + this.transformations = new ArrayList<>(); + } + + /** + * Renames a configuration key from oldPath to newPath. + * + *

If the old path does not exist, the transformation is skipped. If the new path already + * exists, the transformation is also skipped to avoid unintended overwrites.

+ * + * @param oldPath the current configuration path + * @param newPath the new configuration path + * @return this migration for chaining + * @throws IllegalArgumentException if oldPath or newPath is null + */ + @NotNull + public TransformationMigration renameKey(@NotNull String oldPath, @NotNull String newPath) { + Args.notNull(oldPath, "oldPath"); + Args.notNull(newPath, "newPath"); + return addTransformation(new RenameTransformation(oldPath, newPath)); + } + + /** + * Removes a configuration key from the config. + * + *

If the path does not exist, the transformation is silently skipped.

+ * + * @param path the configuration path to remove + * @return this migration for chaining + * @throws IllegalArgumentException if path is null + */ + @NotNull + public TransformationMigration removeKey(@NotNull String path) { + Args.notNull(path, "path"); + return addTransformation(new RemoveTransformation(path)); + } + + /** + * Transforms a value at a specific path using a transformation function. + * + *

If the path does not exist, the transformation is skipped. The transformer function + * can access the current value and return a new value. If the transformation throws an exception, + * it will be wrapped in an {@link IllegalStateException}.

+ * + * @param path the configuration path to transform + * @param transformer the function to transform the value + * @return this migration for chaining + * @throws IllegalArgumentException if path or transformer is null + */ + @NotNull + public TransformationMigration transformValue( + @NotNull String path, + @NotNull Function transformer + ) { + Args.notNull(path, "path"); + Args.notNull(transformer, "transformer"); + return addTransformation(new ValueTransformation(path, transformer)); + } + + /** + * Adds a custom transformation to be applied during migration. + * + *

Custom transformations are applied in the order they are added. This method can be used + * to extend the migration with application-specific transformation logic.

+ * + * @param transformation the custom transformation to add + * @return this migration for chaining + * @throws IllegalArgumentException if transformation is null + */ + @NotNull + public TransformationMigration addTransformation(@NotNull Transformation transformation) { + Args.notNull(transformation, "transformation"); + this.transformations.add(transformation); + return this; + } + + /** + * Applies all registered transformations to the configuration in order. + * + * @param config the configuration section to transform + * @return {@code true} if at least one transformation was applied, {@code false} otherwise + * @throws IllegalStateException if a transformation fails unexpectedly + */ + @Override + public boolean apply(@NotNull ConfigSection config) { + Args.notNull(config, "config"); + int appliedCount = 0; + for (Transformation transformation : this.transformations) { + if (transformation.apply(config)) { + appliedCount++; + } + } + return appliedCount > 0; + } + + /** + * Interface for configuration transformations. + * + *

Implementations should be idempotent when possible and should gracefully handle + * cases where the expected configuration paths do not exist.

+ */ + public interface Transformation { + /** + * Applies the transformation to the config. + * + * @param config the configuration section + * @return true if the transformation was applied, false otherwise + */ + boolean apply(@NotNull ConfigSection config); + } + + /** + * Transformation that renames a configuration key. + * + *

The value at oldPath is moved to newPath. If either path does not exist or newPath + * already exists, the transformation is skipped to prevent data loss.

+ */ + private static final class RenameTransformation implements Transformation { + private final String oldPath; + private final String newPath; + + RenameTransformation(@NotNull String oldPath, @NotNull String newPath) { + this.oldPath = oldPath; + this.newPath = newPath; + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + if (!config.isSet(this.oldPath)) { + return false; + } + + if (config.isSet(this.newPath)) { + return false; + } + + Object value = config.getObject(this.oldPath); + config.set(this.newPath, value); + config.remove(this.oldPath); + return true; + } + } + + /** + * Transformation that removes a configuration key. + * + *

If the path does not exist, the transformation is silently skipped.

+ */ + private static final class RemoveTransformation implements Transformation { + private final String path; + + RemoveTransformation(@NotNull String path) { + this.path = path; + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + if (config.isSet(this.path)) { + config.remove(this.path); + return true; + } + return false; + } + } + + /** + * Transformation that converts a value using a custom transformation function. + * + *

The transformer function receives the current value and should return the transformed value. + * If the transformation throws an exception, it is wrapped in an IllegalStateException.

+ */ + private static final class ValueTransformation implements Transformation { + private final String path; + private final Function transformer; + + ValueTransformation(@NotNull String path, @NotNull Function transformer) { + this.path = path; + this.transformer = transformer; + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + if (!config.isSet(this.path)) { + return false; + } + + try { + Object value = config.getObject(this.path); + Object transformed = this.transformer.apply(value); + config.set(this.path, transformed); + return true; + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to transform value at path '" + this.path + "'", + exception + ); + } + } + } +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/ValidationMigration.java b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/ValidationMigration.java new file mode 100644 index 0000000..45ee77f --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/update/migrations/ValidationMigration.java @@ -0,0 +1,261 @@ +package dev.spoocy.utils.config.update.migrations; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.VersionMatcher; +import dev.spoocy.utils.config.update.base.BaseMigration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Validates and transforms configuration values for schema migration. + * + *

This migration provides a flexible way to enforce configuration constraints and normalize values: + *

    + *
  • Custom validation logic on specific paths
  • + *
  • Requirement enforcement with default values
  • + *
  • Enum-like validation for allowed values
  • + *
  • Custom validators via the {@link Validator} interface
  • + *
+ *

+ * + *

All validators are executed sequentially. If any validator throws an exception, the migration + * fails and subsequent validators are not executed. Validators can safely modify the configuration + * as part of their validation logic (e.g., setting default values).

+ * + *

Example Usage

+ *
{@code
+ * ValidationMigration migration = new ValidationMigration(
+ *     VersionMatcher.atLeast(Version.of("1.0.0")),
+ *     Version.of("1.1.0")
+ * )
+ * .requirePath("name", "DefaultName")
+ * .validateAllowed("level", "DEBUG", "INFO", "WARN", "ERROR")
+ * .validatePath("port", config -> {
+ *     int port = config.getInt("port");
+ *     if (port < 1 || port > 65535) {
+ *         throw new IllegalStateException("Port must be between 1 and 65535");
+ *     }
+ * })
+ * .addValidator(new CustomValidator());
+ * }
+ * + * @see Validator + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ValidationMigration extends BaseMigration { + + @NotNull + private final List validators; + + public ValidationMigration( + @Nullable Version fromVersion, + @NotNull Version toVersion + ) { + this(fromVersion == null ? null : VersionMatcher.exact(fromVersion), toVersion); + } + + public ValidationMigration( + @Nullable VersionMatcher matcher, + @NotNull Version toVersion + ) { + super(matcher, toVersion); + this.validators = new ArrayList<>(); + } + + /** + * Adds a validator that checks and/or transforms a configuration. + * + *

Validators are invoked in the order they are added. If a validator throws an exception, + * the migration fails immediately.

+ * + * @param validator the validator to add + * @return this migration for chaining + * @throws IllegalArgumentException if validator is null + */ + @NotNull + public ValidationMigration addValidator(@NotNull Validator validator) { + Args.notNull(validator, "validator"); + this.validators.add(validator); + return this; + } + + /** + * Adds a validator that applies custom logic to a specific configuration path. + * + *

The validator is only invoked if the path exists. The consumer receives a reference to the + * configuration and can inspect or modify it as needed.

+ * + * @param path the configuration path to validate + * @param validator the validation logic + * @return this migration for chaining + * @throws IllegalArgumentException if path or validator is null + */ + @NotNull + public ValidationMigration validatePath(@NotNull String path, @NotNull Consumer validator) { + Args.notNull(path, "path"); + Args.notNull(validator, "validator"); + return addValidator(new PathValidator(path, validator)); + } + + /** + * Adds a validator that ensures a required path exists, setting a default if missing. + * + *

If the path does not exist in the configuration, it will be created with the provided + * default value. This is useful for ensuring backward compatibility when new required fields + * are added to the configuration schema.

+ * + * @param path the required configuration path + * @param defaultValue the value to set if the path is missing + * @return this migration for chaining + * @throws IllegalArgumentException if path is null + */ + @NotNull + public ValidationMigration requirePath(@NotNull String path, @NotNull Object defaultValue) { + Args.notNull(path, "path"); + return addValidator(new RequiredPathValidator(path, defaultValue)); + } + + /** + * Adds a validator that ensures a configuration value is one of the allowed values. + * + *

If the value at the specified path is not in the list of allowed values, an + * {@link IllegalStateException} is thrown with details about the allowed values.

+ * + * @param path the configuration path to validate + * @param allowedValues the allowed values for this path + * @return this migration for chaining + * @throws IllegalArgumentException if path is null, allowedValues is null, or allowedValues is empty + */ + @NotNull + public ValidationMigration validateAllowed(@NotNull String path, @NotNull Object... allowedValues) { + Args.notNull(path, "path"); + Args.notNull(allowedValues, "allowedValues"); + if (allowedValues.length == 0) { + throw new IllegalArgumentException("allowedValues cannot be empty"); + } + return addValidator(new AllowedValuesValidator(path, allowedValues)); + } + + /** + * Applies all registered validators to the configuration in order. + * + * @param config the configuration section to validate + * @return {@code true} if there are any validators registered, {@code false} otherwise + * @throws IllegalStateException if any validator fails + */ + @Override + public boolean apply(@NotNull ConfigSection config) { + Args.notNull(config, "config"); + for (Validator validator : this.validators) { + validator.validate(config); + } + return !this.validators.isEmpty(); + } + + /** + * Interface for configuration validators. + * + *

Implementations should validate the configuration and may modify it as part of the validation + * process (e.g., setting defaults or normalizing values). Validators should throw exceptions to signal + * validation failure and prevent further migration steps.

+ */ + public interface Validator { + /** + * Validates the given configuration. + * + * @param config the configuration to validate + * @throws IllegalStateException if validation fails + */ + void validate(@NotNull ConfigSection config); + } + + /** + * Validator that applies custom logic to a specific configuration path. + * + *

The validator is only invoked if the path exists in the configuration. This is useful + * for applying custom validation or transformation logic to specific configuration paths.

+ */ + private static final class PathValidator implements Validator { + private final String path; + private final Consumer validator; + + PathValidator(@NotNull String path, @NotNull Consumer validator) { + this.path = path; + this.validator = validator; + } + + @Override + public void validate(@NotNull ConfigSection config) { + if (config.isSet(this.path)) { + this.validator.accept(config); + } + } + } + + /** + * Validator that ensures a required path exists with a default value. + * + *

If the path does not exist, it is automatically created with the provided default value. + * This validator is idempotent - repeated invocations on the same configuration will have no + * additional effect after the first application.

+ */ + private static final class RequiredPathValidator implements Validator { + private final String path; + private final Object defaultValue; + + RequiredPathValidator(@NotNull String path, @NotNull Object defaultValue) { + this.path = path; + this.defaultValue = defaultValue; + } + + @Override + public void validate(@NotNull ConfigSection config) { + if (!config.isSet(this.path)) { + config.set(this.path, this.defaultValue); + } + } + } + + /** + * Validator that ensures a configuration value is one of a set of allowed values. + * + *

If the path does not exist in the configuration, validation passes. If the value exists + * but is not in the list of allowed values, an {@link IllegalStateException} is thrown with + * a detailed error message listing the allowed values.

+ */ + private static final class AllowedValuesValidator implements Validator { + private final String path; + private final Object[] allowedValues; + + AllowedValuesValidator(@NotNull String path, @NotNull Object[] allowedValues) { + this.path = path; + this.allowedValues = allowedValues; + } + + @Override + public void validate(@NotNull ConfigSection config) { + if (!config.isSet(this.path)) { + return; + } + + Object value = config.getObject(this.path); + for (Object allowed : this.allowedValues) { + if (allowed != null && allowed.equals(value)) { + return; + } + } + + throw new IllegalStateException( + "Configuration value at '" + this.path + "' is '" + value + "', " + + "but must be one of the allowed values: " + java.util.Arrays.toString(this.allowedValues) + ); + } + } +} + diff --git a/config-utils/src/main/java/module-info.java b/config-utils/src/main/java/module-info.java index f0b6a1f..1820c0b 100644 --- a/config-utils/src/main/java/module-info.java +++ b/config-utils/src/main/java/module-info.java @@ -3,15 +3,23 @@ */ module dev.spoocy.utils.config { - requires org.jetbrains.annotations; - requires org.json; + requires static org.jetbrains.annotations; requires dev.spoocy.utils.common; requires dev.spoocy.utils.reflection; + requires static org.json; + requires static org.yaml.snakeyaml; exports dev.spoocy.utils.config; exports dev.spoocy.utils.config.types; - exports dev.spoocy.utils.config.misc; exports dev.spoocy.utils.config.serializer; exports dev.spoocy.utils.config.serializer.impl; exports dev.spoocy.utils.config.components; + exports dev.spoocy.utils.config.io; + exports dev.spoocy.utils.config.loader; + exports dev.spoocy.utils.config.update; + exports dev.spoocy.utils.config.update.base; + exports dev.spoocy.utils.config.update.migrations; + exports dev.spoocy.utils.config.update.match; + exports dev.spoocy.utils.config.representer; + exports dev.spoocy.utils.config.constructor; } \ No newline at end of file diff --git a/config-utils/src/test/java/EnumSerializerTest.java b/config-utils/src/test/java/EnumSerializerTest.java deleted file mode 100644 index 03911aa..0000000 --- a/config-utils/src/test/java/EnumSerializerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -import dev.spoocy.utils.config.serializer.ConfigSerializer; -import dev.spoocy.utils.config.serializer.impl.EnumSerializer; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class EnumSerializerTest { - - private enum Color { - RED, GREEN, BLUE - } - - @Test - void serializeEnum() { - EnumSerializer serializer = EnumSerializer.create(Color.class); - Color value = Color.RED; - - Map map = serializer.serialize(value); - - assertEquals("RED", map.get(EnumSerializer.SERIALIZED_ENUM_NAME_KEY)); - assertEquals(Color.class.getName(), map.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); - } - - @Test - void deserializeEnum() { - EnumSerializer serializer = EnumSerializer.create(Color.class); - Map map = new HashMap<>(); - map.put(EnumSerializer.SERIALIZED_ENUM_NAME_KEY, "GREEN"); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, Color.class.getName()); - - Color result = serializer.deserialize(map); - - assertEquals(Color.GREEN, result); - } - - @Test - void deserializeMissingValueThrows() { - EnumSerializer serializer = EnumSerializer.create(Color.class); - Map map = new HashMap<>(); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, Color.class.getName()); - - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); - } - - @Test - void deserializeUnknownConstantThrows() { - EnumSerializer serializer = EnumSerializer.create(Color.class); - Map map = new HashMap<>(); - map.put(EnumSerializer.SERIALIZED_ENUM_NAME_KEY, "UNKNOWN"); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, Color.class.getName()); - - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); - } -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java new file mode 100644 index 0000000..e626c73 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java @@ -0,0 +1,215 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.loader.*; +import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import dev.spoocy.utils.config.types.SerializableExample; +import dev.spoocy.utils.config.types.YamlConfig; +import org.junit.jupiter.api.BeforeAll; + +import java.util.List; +import java.util.Set; + +/** + * Shared fixtures for annotated config binding/resolver tests. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public abstract class AbstractAnnotatedConfigTest extends ResourceTest { + + @BeforeAll + static void setupSerializers() { + String name = NamedSerializers.getSerializeName(SerializableExample.class); + if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { + NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); + } + } + + public static final class TestResourceResolver extends BaseResourceResolver { + + public TestResourceResolver(ClassLoader classLoader) { + super(classLoader); + registerLoader(JsonConfigLoader.INSTANCE); + registerLoader(YamlConfigLoader.INSTANCE); + } + } + + public enum Mode { + EASY, + HARD + } + + @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/example.yml") + public static class BasicSettings { + + @ConfigProperty("database.host") + public String host = "localhost"; + + @ConfigProperty("database.port") + public int port = 3306; + + @ConfigProperty("feature.enabled") + public boolean enabled; + + @ConfigProperty("feature.mode") + public Mode mode = Mode.EASY; + + public String name = "fallback"; + + @ConfigIgnore + public String ignored = "keep-me"; + } + + @ConfigSource(section = "database") + public static class DatabaseSettings { + + public String host; + public int port; + } + + @ConfigSource(saveDefaults = true) + public static class DefaultsSettings { + + public String present; + + @ConfigProperty("missing.number") + public int missingNumber = 42; + } + + @ConfigSource(saveDefaults = true, allowMissingResource = true) + public static class AllowMissingDefaultsSettings { + + @ConfigProperty("database.host") + public String host = "127.0.0.1"; + + @ConfigProperty(value = "database.port", saveDefault = false, comments = {"Port should stay unmanaged"}) + public int port = 3306; + + @ConfigProperty("feature.enabled") + public boolean enabled = true; + } + + @ConfigSource(saveDefaults = true) + public static class SelectiveDefaultsSettings { + + @ConfigProperty("database.host") + public String host = "localhost"; + + @ConfigProperty(value = "database.port", saveDefault = false) + public int port = 3306; + + @ConfigProperty("database.user") + public String user = "root"; + } + + @ConfigSource( + section = "database", + saveDefaults = true, + headerComments = {"Generated config", "Managed by annotations"}, + footerComments = {"End of config"}, + comments = {"Database connection settings"} + ) + public static class CommentedSettings { + + @ConfigProperty(value = "host", comments = {"Database host"}, inlineComments = {"required"}) + public String host = "localhost"; + + @ConfigProperty(value = "port", comments = {"Database port"}) + public int port = 3306; + } + + public static class BasePrivateSettings { + + @ConfigProperty("database.host") + public String host = "localhost"; + } + + @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/example.yml") + public static class PrivateInheritedSettings extends BasePrivateSettings { + + @ConfigProperty("database.port") + public int port = 3306; + + private PrivateInheritedSettings() { + } + } + + @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/serializable.yml") + public static class SerializableFromResource { + + @ConfigProperty("serializable") + public SerializableExample serializable; + } + + public static class SerializableFromReadable { + + @ConfigProperty("serializable") + public SerializableExample serializable; + } + + public static class HookedSettings { + + @ConfigProperty("message") + public String message = "default"; + + public boolean preCalled; + public boolean postCalled; + + @PreLoad + private void beforeLoad(YamlConfig config) { + this.preCalled = true; + if (!config.isSet("message")) { + this.message = "from-pre"; + } + } + + @PostLoad + private void afterLoad() { + this.postCalled = true; + this.message = this.message + "-post"; + } + } + + @ConfigSource(saveDefaults = true) + public static class PostLoadMutationSettings { + + @ConfigProperty("message") + public String message = "default"; + + public boolean postCalled; + + @PostLoad + private PostLoadResult afterLoad() { + this.postCalled = true; + this.message = this.message + "-modified"; + return PostLoadResult.SAVE; + } + } + + @ConfigSource(saveDefaults = true) + public static class SelectivePostLoadMutationSettings { + + @ConfigProperty("message") + public String message = "default"; + + @ConfigProperty(value = "runtime.only", saveDefault = false) + public String runtimeOnly = "runtime"; + + @PostLoad + private PostLoadResult afterLoad() { + this.message = this.message + "-saved"; + this.runtimeOnly = this.runtimeOnly + "-changed"; + return PostLoadResult.SAVE; + } + } + + public static class CollectionSettings { + + @ConfigProperty("flags") + public List flags = List.of(); + + @ConfigProperty("ids") + public Set ids = Set.of(); + } +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java new file mode 100644 index 0000000..fba3960 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java @@ -0,0 +1,225 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.types.YamlConfig; +import dev.spoocy.utils.reflection.Reflection; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class BaseResourceResolverTest extends AbstractAnnotatedConfigTest { + + private abstract static class ResolverCase { + protected final TestResourceResolver resolver = new TestResourceResolver(BaseResourceResolverTest.class.getClassLoader()); + + protected YamlConfig loadYaml(Path path) throws IOException { + return (YamlConfig) this.resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); + } + } + + @Nested + class SourceResolution extends ResolverCase { + + @Test + void loadFromClassPathSourceAnnotation() throws IOException { + BasicSettings settings = this.resolver.load(BasicSettings.class, SerializingConstructor.DEFAULT_INSTANCE); + + assertEquals("db.local", settings.host); + assertEquals(3307, settings.port); + assertTrue(settings.enabled); + assertEquals(Mode.HARD, settings.mode); + assertEquals("ExamplePlugin", settings.name); + assertEquals("keep-me", settings.ignored); + } + + @Test + void loadFromExplicitResourceAndSection() throws IOException { + String file = resourcesPath("loader/annotation/example.yml"); + DatabaseSettings settings = this.resolver.load( + DatabaseSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromFile(file) + ); + + assertEquals("db.local", settings.host); + assertEquals(3307, settings.port); + } + + @Test + void loadsInheritedFieldsWithPrivateConstructor() throws IOException { + PrivateInheritedSettings settings = this.resolver.load(PrivateInheritedSettings.class, SerializingConstructor.DEFAULT_INSTANCE); + + assertEquals("db.local", Reflection.getField(PrivateInheritedSettings.class, "host", String.class).get(settings)); + assertEquals(3307, settings.port); + } + + @Test + void loadsSerializableFromClasspathYaml() throws IOException { + SerializableFromResource settings = this.resolver.load(SerializableFromResource.class, SerializingConstructor.DEFAULT_INSTANCE); + + assertNotNull(settings.serializable); + assertEquals("example", settings.serializable.getName()); + assertEquals(42, settings.serializable.getValue()); + } + } + + @Nested + class MutationPersistence extends ResolverCase { + + @Test + void writesMissingDefaultsWhenEnabled(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("annotated.yml"); + Files.writeString(target, "present: hello\n", StandardCharsets.UTF_8); + + DefaultsSettings settings = this.resolver.load( + DefaultsSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + assertEquals("hello", settings.present); + assertEquals(42, settings.missingNumber); + + YamlConfig written = loadYaml(target); + assertEquals(42, written.getInt("missing.number")); + } + + @Test + void allowsMissingSourceResourceWhenEnabled(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("missing-allowed.yml"); + + AllowMissingDefaultsSettings settings = this.resolver.load( + AllowMissingDefaultsSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + + assertEquals("127.0.0.1", settings.host); + assertEquals(3306, settings.port); + assertTrue(settings.enabled); + assertTrue(Files.exists(target)); + + YamlConfig written = loadYaml(target); + assertEquals("127.0.0.1", written.getString("database.host", "")); + assertTrue(written.getBoolean("feature.enabled")); + assertFalse(written.isSet("database.port")); + } + + @Test + void persistsOnlySaveDefaultFieldsWhenConfigIsPartial(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("partial.yml"); + Files.writeString(target, "database:\n host: db.local\n", StandardCharsets.UTF_8); + + SelectiveDefaultsSettings settings = this.resolver.load( + SelectiveDefaultsSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + + assertEquals("db.local", settings.host); + assertEquals(3306, settings.port); + assertEquals("root", settings.user); + + YamlConfig written = loadYaml(target); + assertEquals("db.local", written.getString("database.host", "")); + assertEquals("root", written.getString("database.user", "")); + assertFalse(written.isSet("database.port")); + } + + @Test + void doesNotPersistCommentsForUnmanagedFields(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("comments-unmanaged.yml"); + Files.writeString(target, "database:\n host: db.local\n port: 5500\n", StandardCharsets.UTF_8); + + SelectiveDefaultsSettings settings = this.resolver.load( + SelectiveDefaultsSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + + assertEquals(5500, settings.port); + YamlConfig written = loadYaml(target); + assertIterableEquals(List.of(), written.getComments("database.port")); + assertFalse(Files.readString(target, StandardCharsets.UTF_8).contains("Port should stay unmanaged")); + } + + @Test + void writesConfiguredComments(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("comments.yml"); + Files.writeString(target, "database:\n host: db.local\n", StandardCharsets.UTF_8); + + CommentedSettings settings = this.resolver.load( + CommentedSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + assertEquals("db.local", settings.host); + assertEquals(3306, settings.port); + + YamlConfig written = loadYaml(target); + String yaml = Files.readString(target, StandardCharsets.UTF_8); + assertIterableEquals(List.of("Generated config", "Managed by annotations"), written.getHeaderComments()); + assertIterableEquals(List.of("End of config"), written.getFooterComments()); + assertIterableEquals(List.of("Database connection settings"), written.getComments("database")); + assertIterableEquals(List.of("Database host"), written.getComments("database.host")); + assertIterableEquals(List.of("required"), written.getInlineComments("database.host")); + assertIterableEquals(List.of("Database port"), written.getComments("database.port")); + assertEquals(3306, written.getInt("database.port")); + assertTrue(yaml.contains("# Generated config")); + assertTrue(yaml.contains("# Database connection settings")); + assertTrue(yaml.contains("host: db.local # required")); + } + + @Test + void postLoadResultSaveWritesInstanceFieldsBack(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("post-load-save.yml"); + Files.writeString(target, "message: hello\n", StandardCharsets.UTF_8); + + PostLoadMutationSettings settings = this.resolver.load( + PostLoadMutationSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + assertTrue(settings.postCalled); + assertEquals("hello-modified", settings.message); + + YamlConfig written = loadYaml(target); + assertEquals("hello-modified", written.getString("message", "")); + assertTrue(Files.readString(target, StandardCharsets.UTF_8).contains("hello-modified")); + } + + @Test + void postLoadSaveSkipsFieldsWithSaveDefaultDisabled(@TempDir Path tempDir) throws IOException { + Path target = tempDir.resolve("post-load-selective.yml"); + Files.writeString(target, "message: hello\n", StandardCharsets.UTF_8); + + SelectivePostLoadMutationSettings settings = this.resolver.load( + SelectivePostLoadMutationSettings.class, + SerializingConstructor.DEFAULT_INSTANCE, + Resources.fromPath(target) + ); + + assertEquals("hello-saved", settings.message); + assertEquals("runtime-changed", settings.runtimeOnly); + + YamlConfig written = loadYaml(target); + assertEquals("hello-saved", written.getString("message", "")); + assertFalse(written.isSet("runtime.only")); + } + } +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java new file mode 100644 index 0000000..950d30b --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java @@ -0,0 +1,92 @@ + +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.types.JsonConfig; +import dev.spoocy.utils.config.types.YamlConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class SafeResourceResolverTest { + + private final SafeResourceResolver resolver = ResourceResolver.safeResolver(); + + @Nested + class LoadConfig { + + @Test + void returnsEmptyConfigWhenResourceDoesNotExist(@TempDir Path tempDir) throws IOException { + Path path = tempDir.resolve("missing.yml"); + + Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); + + YamlConfig yaml = assertInstanceOf(YamlConfig.class, loaded); + assertEquals(0, yaml.values(true).size()); + } + + @Test + void loadsExistingYamlResource(@TempDir Path tempDir) throws IOException { + Path path = tempDir.resolve("config.YML"); + Files.writeString(path, "key: value\n", StandardCharsets.UTF_8); + + Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); + + YamlConfig yaml = assertInstanceOf(YamlConfig.class, loaded); + assertEquals("value", yaml.getString("key")); + } + + @Test + void loadsExistingJsonResource(@TempDir Path tempDir) throws IOException { + Path path = tempDir.resolve("config.json"); + Files.writeString(path, "{\"key\":\"value\"}", StandardCharsets.UTF_8); + + Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); + + JsonConfig json = assertInstanceOf(JsonConfig.class, loaded); + assertEquals("value", json.getString("key")); + } + + @Test + void throwsWhenNoLoaderMatchesResource(@TempDir Path tempDir) { + SafeResourceResolver noLoaderResolver = new SafeResourceResolver(SafeResourceResolverTest.class.getClassLoader()); + Path path = tempDir.resolve("config.yml"); + + IOException exception = assertThrows( + IOException.class, + () -> noLoaderResolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE) + ); + + assertTrue(exception.getMessage().contains("No config loader")); + } + + @Test + void validatesArguments(@TempDir Path tempDir) { + Path path = tempDir.resolve("config.yml"); + + assertThrows( + NullPointerException.class, + () -> resolver.loadConfig(null, SerializingConstructor.DEFAULT_INSTANCE) + ); + assertThrows( + NullPointerException.class, + () -> resolver.loadConfig(Resources.fromPath(path), null) + ); + } + } +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java new file mode 100644 index 0000000..121c77e --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java @@ -0,0 +1,111 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ClassPathResourceTest extends ResourceTest { + + private static final String EXISTING_RESOURCE = "example.properties"; + private static final String EXISTING_RESOURCE_CLASSPATH = "dev/spoocy/utils/config/io/example.properties"; + private static final String NON_EXISTING_RESOURCE = "does-not-exist.properties"; + + @Nested + class Creation { + + @Test + void createFromClass() throws IOException { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE, ClassPathResourceTest.class); + String expected = ClassPathResourceTest.class.getPackage().getName().replace('.', '/') + "/" + EXISTING_RESOURCE; + // Path.of will normalize separators for the current OS, so compare using that representation + assertEquals(Path.of(expected).toString(), resource.getPath().toString()); + } + + @Test + void createFromClassLoader() throws IOException { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + assertEquals(Path.of(EXISTING_RESOURCE_CLASSPATH).toString(), resource.getPath().toString()); + } + + } + + @Nested + class Existing { + + @Test + void exists() { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + assertTrue(resource.exists()); + } + + @Test + void readable() { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + assertTrue(resource.isReadable()); + } + + @Test + void inputStream() throws IOException { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + byte[] bytes = FileUtils.copyToByteArray(resource.getInputStream()); + assertTrue(bytes.length > 0); + } + + @Test + void filename() { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + assertEquals(EXISTING_RESOURCE, resource.getFilename()); + } + + @Test + void urlContainsResourceName() throws IOException { + ClassPathResource resource = new ClassPathResource(EXISTING_RESOURCE_CLASSPATH, ClassPathResourceTest.class.getClassLoader()); + URL url = resource.getURL(); + assertNotNull(url); + assertTrue(url.toString().contains(EXISTING_RESOURCE)); + } + + } + + @Nested + class NonExisting { + + @Test + void exists() { + ClassPathResource resource = new ClassPathResource(NON_EXISTING_RESOURCE, ClassPathResourceTest.class); + assertFalse(resource.exists()); + } + + @Test + void readable() { + ClassPathResource resource = new ClassPathResource(NON_EXISTING_RESOURCE, ClassPathResourceTest.class); + assertFalse(resource.isReadable()); + } + + @Test + void inputStream() { + ClassPathResource resource = new ClassPathResource(NON_EXISTING_RESOURCE, ClassPathResourceTest.class); + assertThrows(FileNotFoundException.class, resource::getInputStream); + } + + @Test + void filename() { + ClassPathResource resource = new ClassPathResource(NON_EXISTING_RESOURCE, ClassPathResourceTest.class); + assertEquals(NON_EXISTING_RESOURCE, resource.getFilename()); + } + + } + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java new file mode 100644 index 0000000..6e9fc27 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java @@ -0,0 +1,165 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class FileSystemResourceTest extends ResourceTest { + + private static final String EXISTING_DIR = resourcesPath("io"); + private static final String EXISTING_FILE = resourcesPath("io/example.properties"); + private static final String NON_EXISTING_FILE = resourcesPath("io/does-not-exist.properties"); + + @Nested + class Dir { + + @Test + void exists() { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + assertTrue(resource.exists()); + } + + @Test + void readable() { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + assertFalse(resource.isReadable()); + } + + @Test + void inputStream() { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + assertThrows(FileNotFoundException.class, resource::getInputStream); + } + + @Test + void contentLength() throws IOException { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + File file = new File(EXISTING_DIR); + assertEquals(file.length(), resource.contentLength()); + } + + @Test + void writeable() { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + assertFalse(resource.isWritable()); + } + + @Test + void outputStream() { + FileSystemResource resource = new FileSystemResource(EXISTING_DIR); + assertThrows(FileNotFoundException.class, resource::getOutputStream); + } + + } + + @Nested + class Existing { + + @Test + void exists() { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + assertTrue(resource.exists()); + } + + @Test + void readable() { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + assertTrue(resource.isReadable()); + } + + @Test + void inputStream() throws IOException { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + byte[] bytes = FileUtils.copyToByteArray(resource.getInputStream()); + assertTrue(bytes.length > 0); + } + + @Test + void contentLength() throws IOException { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + File file = new File(EXISTING_FILE); + assertEquals(file.length(), resource.contentLength()); + } + + @Test + void lastModified() throws IOException { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + File file = new File(EXISTING_FILE); + assertEquals(file.lastModified() / 1000, resource.lastModified() / 1000); + } + + @Test + void writeable() { + FileSystemResource resource = new FileSystemResource(EXISTING_FILE); + assertTrue(resource.isWritable()); + } + + @Test + void outputStream(@TempDir Path temporaryFolder) throws IOException { + FileSystemResource resource = new FileSystemResource(temporaryFolder.resolve("test")); + FileUtils.copy("test".getBytes(StandardCharsets.UTF_8), resource.getOutputStream()); + assertEquals(4L, resource.contentLength()); + } + + } + + @Nested + class NonExisting { + + @Test + void exists() { + FileSystemResource resource = new FileSystemResource(NON_EXISTING_FILE); + assertFalse(resource.exists()); + } + + @Test + void readable() { + FileSystemResource resource = new FileSystemResource(NON_EXISTING_FILE); + assertFalse(resource.isReadable()); + } + + @Test + void inputStream() { + FileSystemResource resource = new FileSystemResource(NON_EXISTING_FILE); + assertThrows(FileNotFoundException.class, resource::getInputStream); + } + + @Test + void contentLength() throws IOException { + FileSystemResource resource = new FileSystemResource(NON_EXISTING_FILE); + assertThrows(FileNotFoundException.class, resource::contentLength); + } + + @Test + void writeable() { + FileSystemResource resource = new FileSystemResource(NON_EXISTING_FILE); + assertFalse(resource.isWritable()); + } + + @Test + void outputStream(@TempDir Path temporaryFolder) throws IOException { + File file = temporaryFolder.resolve("test").toFile(); + file.delete(); + + FileSystemResource resource = new FileSystemResource(file.toPath()); + FileUtils.copy("test".getBytes(), resource.getOutputStream()); + assertEquals(4L, resource.contentLength()); + } + + } + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java new file mode 100644 index 0000000..6dde913 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java @@ -0,0 +1,207 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.Resources; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class PathResourceTest extends ResourceTest { + + private static final String EXISTING_DIR = + resourcesPath("io"); + + private static final String EXISTING_FILE = + resourcesPath("io/example.properties"); + + private static final String NON_EXISTING_FILE = + resourcesPath("io/definitely-does-not-exist.properties"); + + @Nested + class Creation { + + @Test + void createFromPath() throws IOException { + Path path = Paths.get(EXISTING_FILE); + PathResource resource = Resources.fromPath(path); + assertEquals( + path.toString(), + resource.getPath().toString() + ); + } + + @Test + void createFromString() throws IOException { + PathResource resource = Resources.fromPath(EXISTING_FILE); + assertEquals( + EXISTING_FILE, + resource.getPath().toString() + ); + } + + @Test + void createFromUri() throws IOException { + File file = new File(EXISTING_FILE); + PathResource resource = Resources.fromPath(file.toURI()); + assertEquals( + file.getAbsoluteFile().toString(), + resource.getPath().toString() + ); + } + + } + + @Nested + class Dir { + + @Test + void exists() { + PathResource resource = Resources.fromPath(EXISTING_DIR); + assertTrue(resource.exists()); + } + + @Test + void readable() { + PathResource resource = Resources.fromPath(EXISTING_DIR); + assertFalse(resource.isReadable()); + } + + @Test + void inputStream() { + PathResource resource = Resources.fromPath(EXISTING_DIR); + assertThrows(FileNotFoundException.class, resource::getInputStream); + } + + @Test + void contentLength() throws IOException { + PathResource resource = Resources.fromPath(EXISTING_DIR); + File file = new File(EXISTING_DIR); + assertEquals(file.length(), resource.contentLength()); + } + + @Test + void writeable() { + PathResource resource = Resources.fromPath(EXISTING_DIR); + assertFalse(resource.isWritable()); + } + + @Test + void outputStream() { + PathResource resource = Resources.fromPath(EXISTING_DIR); + assertThrows(FileNotFoundException.class, resource::getOutputStream); + } + + } + + @Nested + class Existing { + + @Test + void exists() { + PathResource resource = Resources.fromPath(EXISTING_FILE); + assertTrue(resource.exists()); + } + + @Test + void readable() { + PathResource resource = Resources.fromPath(EXISTING_FILE); + assertTrue(resource.isReadable()); + } + + @Test + void inputStream() throws IOException { + PathResource resource = Resources.fromPath(EXISTING_FILE); + byte[] bytes = FileUtils.copyToByteArray(resource.getInputStream()); + assertTrue(bytes.length > 0); + } + + @Test + void contentLength() throws IOException { + PathResource resource = Resources.fromPath(EXISTING_FILE); + File file = new File(EXISTING_FILE); + assertEquals(file.length(), resource.contentLength()); + } + + @Test + void lastModified() throws IOException { + PathResource resource = Resources.fromPath(EXISTING_DIR); + File file = new File(EXISTING_DIR); + assertEquals(file.lastModified() / 1000, resource.lastModified() / 1000); + } + + @Test + void writeable() { + PathResource resource = Resources.fromPath(EXISTING_FILE); + assertTrue(resource.isWritable()); + } + + @Test + void outputStream(@TempDir Path temporaryFolder) throws IOException { + PathResource resource = new PathResource(temporaryFolder.resolve("test")); + FileUtils.copy("test".getBytes(StandardCharsets.UTF_8), resource.getOutputStream()); + assertEquals(4L, resource.contentLength()); + } + + } + + @Nested + class NonExisting { + + @Test + void exists() { + PathResource resource = Resources.fromPath(NON_EXISTING_FILE); + assertFalse(resource.exists()); + } + + @Test + void readable() { + PathResource resource = Resources.fromPath(NON_EXISTING_FILE); + assertFalse(resource.isReadable()); + } + + @Test + void inputStream() { + PathResource resource = Resources.fromPath(NON_EXISTING_FILE); + assertThrows(FileNotFoundException.class, resource::getInputStream); + } + + @Test + void contentLength() throws IOException { + PathResource resource = Resources.fromPath(NON_EXISTING_FILE); + assertThrows(NoSuchFileException.class, resource::contentLength); + } + + @Test + void writeable() { + PathResource resource = Resources.fromPath(NON_EXISTING_FILE); + assertFalse(resource.isWritable()); + } + + @Test + void outputStream(@TempDir Path temporaryFolder) throws IOException { + File file = temporaryFolder.resolve("test").toFile(); + file.delete(); + + PathResource resource = new PathResource(file.toPath()); + FileUtils.copy("test".getBytes(), resource.getOutputStream()); + assertEquals(4L, resource.contentLength()); + } + + } + + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java new file mode 100644 index 0000000..abd96e0 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java @@ -0,0 +1,41 @@ +package dev.spoocy.utils.config.io; + +import dev.spoocy.utils.config.BaseResourceResolver; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ResourceResolver; +import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ResourceTest { + + public static final String TEST_RESOURCES_DIR = "src/test/resources/dev/spoocy/utils/config"; + private static final ResourceResolver RESOLVER = createResolver(); + + private static ResourceResolver createResolver() { + BaseResourceResolver resolver = new BaseResourceResolver(ResourceTest.class.getClassLoader()); + resolver.registerLoader(JsonConfigLoader.INSTANCE); + resolver.registerLoader(YamlConfigLoader.INSTANCE); + return resolver; + } + + public static String resourcesPath(@NotNull String string) { + return (TEST_RESOURCES_DIR + '/' + string).replace('/', File.separatorChar); + } + + public static Config loadDefault(@NotNull Resource resource) { + try { + return RESOLVER.loadConfig(resource, SerializingConstructor.DEFAULT_INSTANCE); + } catch (Exception e) { + throw new RuntimeException("Failed to load resource: " + resource, e); + } + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java new file mode 100644 index 0000000..593a468 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java @@ -0,0 +1,108 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.AbstractAnnotatedConfigTest; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import dev.spoocy.utils.config.types.SerializableExample; +import dev.spoocy.utils.config.types.YamlConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class AnnotatedConfigLoaderTest extends AbstractAnnotatedConfigTest { + + private abstract static class LoaderCase { + protected final AnnotatedConfigLoader loader = new AnnotatedConfigLoader(); + } + + @Nested + class SerializableBinding extends LoaderCase { + + @Test + void loadsSerializableFromReadableMapUsingGetSerializable() { + YamlConfig config = new YamlConfig(); + + Map serialized = new LinkedHashMap<>(); + serialized.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); + serialized.put("name", "example"); + serialized.put("value", 42); + config.set("serializable", serialized); + + SerializableFromReadable settings = this.loader.load(SerializableFromReadable.class, config); + + assertNotNull(settings.serializable); + assertEquals("example", settings.serializable.getName()); + assertEquals(42, settings.serializable.getValue()); + } + } + + @Nested + class HookBinding extends LoaderCase { + + @Test + void invokesPreAndPostLoadHooks() { + YamlConfig config = new YamlConfig(); + + HookedSettings withoutConfigValue = this.loader.load(HookedSettings.class, config); + assertTrue(withoutConfigValue.preCalled); + assertTrue(withoutConfigValue.postCalled); + assertEquals("from-pre-post", withoutConfigValue.message); + + config.set("message", "from-config"); + HookedSettings withConfigValue = this.loader.load(HookedSettings.class, config); + assertTrue(withConfigValue.preCalled); + assertTrue(withConfigValue.postCalled); + assertEquals("from-config-post", withConfigValue.message); + } + } + + @Nested + class CollectionBinding extends LoaderCase { + + @Test + void loadsTypedCollectionFieldsFromReadable() { + YamlConfig config = new YamlConfig(); + config.set("flags", List.of("one", "two")); + config.set("ids", List.of(7, 11)); + + CollectionSettings settings = this.loader.load(CollectionSettings.class, config); + + assertIterableEquals(List.of("one", "two"), settings.flags); + assertEquals(Set.of(7, 11), settings.ids); + } + } + + @Nested + class WriteBinding extends LoaderCase { + + @Test + void writeOnlyPersistsFieldsMarkedForDefaultSaving() { + SelectiveDefaultsSettings settings = new SelectiveDefaultsSettings(); + settings.host = "db.remote"; + settings.port = 1234; + settings.user = "admin"; + + YamlConfig target = new YamlConfig(); + int mutations = this.loader.write(settings, target); + + assertEquals(2, mutations); + assertEquals("db.remote", target.getString("database.host", "")); + assertEquals("admin", target.getString("database.user", "")); + assertFalse(target.isSet("database.port")); + } + } + +} + diff --git a/config-utils/src/test/java/AtomicsSerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java similarity index 84% rename from config-utils/src/test/java/AtomicsSerializerTest.java rename to config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java index 15ceab2..a641a54 100644 --- a/config-utils/src/test/java/AtomicsSerializerTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java @@ -1,4 +1,5 @@ -import dev.spoocy.utils.config.serializer.ConfigSerializer; +package dev.spoocy.utils.config.serializer; + import dev.spoocy.utils.config.serializer.impl.AtomicsSerializer; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ void serializeAtomicInteger() { Map map = serializer.serialize(value); assertEquals(42, ((Number) map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)).intValue()); - assertEquals(AtomicInteger.class.getName(), map.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); + assertEquals(AtomicInteger.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); } @Test @@ -33,7 +34,7 @@ void deserializeAtomicInteger() { AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicInteger.class); Map map = new HashMap<>(); map.put("value", Integer.valueOf(7)); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); + map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); AtomicInteger result = serializer.deserialize(map); @@ -48,7 +49,7 @@ void serializeAtomicLong() { Map map = serializer.serialize(value); assertEquals(123456789L, ((Number) map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)).longValue()); - assertEquals(AtomicLong.class.getName(), map.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); + assertEquals(AtomicLong.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); } @Test @@ -56,7 +57,7 @@ void deserializeAtomicLong() { AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicLong.class); Map map = new HashMap<>(); map.put(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY, Long.valueOf(99L)); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, AtomicLong.class.getName()); + map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicLong.class.getName()); AtomicLong result = serializer.deserialize(map); @@ -71,7 +72,7 @@ void serializeAtomicBoolean() { Map map = serializer.serialize(value); assertEquals(Boolean.TRUE, map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)); - assertEquals(AtomicBoolean.class.getName(), map.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); + assertEquals(AtomicBoolean.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); } @Test @@ -79,7 +80,7 @@ void deserializeAtomicBoolean() { AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicBoolean.class); Map map = new HashMap<>(); map.put(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY, Boolean.FALSE); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, AtomicBoolean.class.getName()); + map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicBoolean.class.getName()); AtomicBoolean result = serializer.deserialize(map); @@ -96,7 +97,7 @@ void serializeUnsupportedTypeThrows() { void deserializeMissingValueThrows() { AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicInteger.class); Map map = new HashMap<>(); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); + map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); } @@ -106,7 +107,7 @@ void deserializeUnsupportedTypeThrows() { AtomicsSerializer serializer = new AtomicsSerializer<>(String.class); Map map = new HashMap<>(); map.put("value", "x"); - map.put(ConfigSerializer.SERIALIZED_TYPE_KEY, String.class.getName()); + map.put(NamedSerializers.SERIALIZED_TYPE_KEY, String.class.getName()); assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); } diff --git a/config-utils/src/test/java/JavaSerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java similarity index 98% rename from config-utils/src/test/java/JavaSerializerTest.java rename to config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java index af62ee6..f90df7d 100644 --- a/config-utils/src/test/java/JavaSerializerTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java @@ -1,3 +1,5 @@ +package dev.spoocy.utils.config.serializer; + import dev.spoocy.utils.config.serializer.impl.JavaSerializer; import org.junit.jupiter.api.Test; diff --git a/config-utils/src/test/java/SerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java similarity index 57% rename from config-utils/src/test/java/SerializerTest.java rename to config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java index 45566be..70ed2d2 100644 --- a/config-utils/src/test/java/SerializerTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java @@ -1,11 +1,13 @@ -import dev.spoocy.utils.config.serializer.ConfigSerializable; -import dev.spoocy.utils.config.serializer.ConfigSerializer; -import dev.spoocy.utils.config.serializer.Serializer; +package dev.spoocy.utils.config.serializer; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.jetbrains.annotations.NotNull; +import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -19,6 +21,18 @@ public class SerializerTest { + private static class LocalSerializers extends BaseSerializers { + @Override + public @NotNull Map serialize(@NotNull Object object) { + throw new UnsupportedOperationException("Not needed in this test"); + } + + @Override + public Object deserialize(@NotNull Map map) { + throw new UnsupportedOperationException("Not needed in this test"); + } + } + // Simple ConfigSerializable implementation used in tests public static class Person implements ConfigSerializable { private final String name; @@ -61,26 +75,28 @@ public int hashCode() { } } + private static final NamedSerializers serializers = NamedSerializers.DEFAULT_INSTANCE; + @BeforeAll static void registerSerializableClass() { // Ensure Person class is registered before tests - ConfigSerializer.register(Person.class); + serializers.register(Person.class); } @Test void registerAndDeserializeUsingConfigSerializer() { // obtain serializer via resolve and serialize an instance - Serializer serializer = ConfigSerializer.resolve(Person.class); + Serializer serializer = serializers.resolve(Person.class); assertNotNull(serializer); Person original = new Person("Alice", 30); Map serialized = serializer.serialize(original); // ensure the serializer included the serialized type key - assertEquals(ConfigSerializer.getSerializeName(Person.class), serialized.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); + assertEquals(NamedSerializers.getSerializeName(Person.class), serialized.get(NamedSerializers.SERIALIZED_TYPE_KEY)); // use ConfigSerializer.deserialize to reconstruct the object - ConfigSerializable cs = ConfigSerializer.deserialize(serialized); + ConfigSerializable cs = serializer.deserialize(serialized); assertNotNull(cs); assertTrue(cs instanceof Person); @@ -90,7 +106,7 @@ void registerAndDeserializeUsingConfigSerializer() { @Test void duplicateRegisterThrows() { - assertThrows(IllegalArgumentException.class, () -> ConfigSerializer.register(Person.class)); + assertThrows(IllegalArgumentException.class, () -> serializers.register(Person.class)); } @Test @@ -98,31 +114,61 @@ void deserializeMissingOrUnknownTypeReturnsNull() { // missing serialized type key Map missing = new HashMap<>(); missing.put("foo", "bar"); - assertNull(ConfigSerializer.deserialize(missing)); + assertNull(serializers.deserialize(missing)); // unknown type name Map unknown = new HashMap<>(); - unknown.put(ConfigSerializer.SERIALIZED_TYPE_KEY, "non.existent.ClassName"); - assertNull(ConfigSerializer.deserialize(unknown)); + unknown.put(NamedSerializers.SERIALIZED_TYPE_KEY, "non.existent.ClassName"); + assertNull(serializers.deserialize(unknown)); // serialized type key present but not a string Map wrongType = new HashMap<>(); - wrongType.put(ConfigSerializer.SERIALIZED_TYPE_KEY, 123); - assertNull(ConfigSerializer.deserialize(wrongType)); + wrongType.put(NamedSerializers.SERIALIZED_TYPE_KEY, 123); + assertNull(serializers.deserialize(wrongType)); } @Test void resolveReturnsClassSerializerForConfigSerializable() { // ensure resolver returns a serializer for classes implementing ConfigSerializable - Serializer serializer = ConfigSerializer.resolve(Person.class); + Serializer serializer = serializers.resolve(Person.class); assertNotNull(serializer); Person p = new Person("Bob", 25); Map map = serializer.serialize(p); // serialized map must contain the type key and the values - assertEquals(ConfigSerializer.getSerializeName(Person.class), map.get(ConfigSerializer.SERIALIZED_TYPE_KEY)); + assertEquals(NamedSerializers.getSerializeName(Person.class), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); assertEquals("Bob", map.get("name")); assertEquals(25, ((Number) map.get("age")).intValue()); } + + @Test + void baseSerializersProvideStaticDefaultsForAllSubclasses() { + LocalSerializers first = new LocalSerializers(); + LocalSerializers second = new LocalSerializers(); + + assertNotNull(first.resolve(Duration.class)); + assertNotNull(second.resolve(Duration.class)); + assertNotNull(first.resolve(AtomicInteger.class)); + assertNotNull(second.resolve(AtomicInteger.class)); + } + + @Test + void localRegistrationOverridesBaseDefault() { + LocalSerializers serializers = new LocalSerializers(); + Serializer custom = new Serializer<>() { + @Override + public @NotNull Map serialize(@NotNull Duration object) { + return Map.of("custom", object.toString()); + } + + @Override + public @NotNull Duration deserialize(@NotNull Map map) { + return Duration.parse((String) map.get("custom")); + } + }; + + serializers.register(Duration.class, custom); + assertSame(custom, serializers.resolve(Duration.class)); + } } diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java new file mode 100644 index 0000000..a582b98 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java @@ -0,0 +1,269 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import dev.spoocy.utils.config.ConfigSection; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class JsonConfigTest extends ResourceTest { + + private static final String EXAMPLE_FILE = resourcesPath("types/example.json"); + + @BeforeAll + static void setupSerializers() { + String name = NamedSerializers.getSerializeName(SerializableExample.class); + if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { + NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); + } + } + + @Nested + class Creation { + + @Test + void createEmpty() { + JsonConfig config = new JsonConfig(); + assertEquals(0, config.values(true).size()); + } + + @Test + void createWithValues() { + JsonConfig config = new JsonConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + assertEquals(3, config.values(true).size()); + assertEquals("value1", config.getString("key1")); + assertEquals(123, config.getInt("key2")); + assertEquals(SerializableExample.EXAMPLE, config.get("key3", SerializableExample.class)); + } + + } + + @Nested + class Load { + + @Test + void loadEmpty(@TempDir Path temporaryFolder) throws IOException { + Path filePath = temporaryFolder.resolve("empty.json"); + Files.createFile(filePath); + + Resource resource = new PathResource(filePath); + JsonConfig config = (JsonConfig) loadDefault(resource); + + assertEquals(0, config.values(true).size()); + } + + @Test + void loadWithValues() throws IOException { + Resource file = Resources.fromFile(EXAMPLE_FILE); + JsonConfig config = (JsonConfig) loadDefault(file); + + assertEquals(3, config.values(true).size()); + assertEquals("bar", config.getString("foo")); + assertEquals(123, config.getInt("key2")); + + SerializableExample expected = config.get("serializable", SerializableExample.class); + assertEquals("example", expected.getName()); + assertEquals(42, expected.getValue()); + } + + @Test + void loadObjectNodeAsSection(@TempDir Path temporaryFolder) throws IOException { + Path filePath = temporaryFolder.resolve("object-node.json"); + Files.writeString(filePath, "{\n \"database\": {\n \"host\": \"localhost\"\n }\n}"); + + JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); + + assertTrue(config.isSection("database")); + assertInstanceOf(ConfigSection.class, config.getObject("database")); + assertEquals("localhost", config.getString("database.host")); + } + + @Test + void loadArrayObjectNodesAsLinkedHashMap(@TempDir Path temporaryFolder) throws IOException { + Path filePath = temporaryFolder.resolve("array-node.json"); + Files.writeString(filePath, "{\n \"servers\": [\n { \"name\": \"alpha\" },\n { \"name\": \"beta\" }\n ]\n}"); + + JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); + + List rawList = config.getList("servers"); + assertNotNull(rawList); + assertEquals(2, rawList.size()); + assertInstanceOf(LinkedHashMap.class, rawList.get(0)); + + // Section conversion happens in getSectionList, not during JSON array parsing. + List sectionList = config.getSectionList("servers"); + assertEquals(2, sectionList.size()); + assertEquals("alpha", sectionList.get(0).getString("name")); + assertEquals("beta", sectionList.get(1).getString("name")); + } + + @Test + void loadNestedObjectInsideArrayAsSectionInSectionList(@TempDir Path temporaryFolder) throws IOException { + Path filePath = temporaryFolder.resolve("formats.json"); + Files.writeString(filePath, + "{\n" + + " \"formats\": [\n" + + " {\n" + + " \"name\": \"digital\",\n" + + " \"units\": {\n" + + " \"seconds\": {\n" + + " \"text\": \"\",\n" + + " \"display-when-zero\": true\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"); + + JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); + + List formats = config.getSectionList("formats"); + assertEquals(1, formats.size()); + assertTrue(formats.get(0).isSection("units")); + assertEquals("", formats.get(0).getString("units.seconds.text")); + assertTrue(formats.get(0).getBoolean("units.seconds.display-when-zero")); + } + + } + + @Nested + class Save { + + @Test + void saveToString() { + JsonConfig config = new JsonConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + String json = config.saveToString(); + assertNotNull(json); + assertTrue(json.contains("\"key1\": \"value1\"")); + assertTrue(json.contains("\"key2\": 123")); + assertTrue(json.contains("\"==\":")); + assertTrue(json.contains("\"name\": \"example\"")); + assertTrue(json.contains("\"value\": 42")); + } + + @Test + void write(@TempDir Path temporaryFolder) throws IOException { + JsonConfig config = new JsonConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + Path filePath = temporaryFolder.resolve("config.json"); + PathResource resource = new PathResource(filePath); + config.save(resource); + + JsonConfig loadedConfig = (JsonConfig) loadDefault(resource); + assertEquals(3, loadedConfig.values(true).size()); + assertEquals("value1", loadedConfig.getString("key1")); + assertEquals(123, loadedConfig.getInt("key2")); + + SerializableExample expected = loadedConfig.get("key3", SerializableExample.class); + assertEquals("example", expected.getName()); + assertEquals(42, expected.getValue()); + + } + + } + + @Nested + class Advanced { + + @Test + void nestedSectionsAndKeys() { + JsonConfig config = new JsonConfig(); + config.set("parent.child.key", "value"); + + assertTrue(config.isSet("parent.child.key")); + assertEquals("value", config.getString("parent.child.key")); + + ConfigSection parent = config.getSection("parent"); + assertNotNull(parent); + assertEquals("value", parent.getString("child.key")); + } + + @Test + void listsAndSectionArrays() { + JsonConfig config = new JsonConfig(); + + List list = Arrays.asList("a", "b", "c"); + config.set("strings", list); + + assertTrue(config.isList("strings")); + assertEquals(list, config.getStringList("strings")); + + Map m1 = new LinkedHashMap<>(); + m1.put("k", "v"); + Map m2 = new LinkedHashMap<>(); + m2.put("k", "w"); + + config.set("sections", Arrays.asList(m1, m2)); + List sections = config.getSectionList("sections"); + assertEquals(2, sections.size()); + assertEquals("v", sections.get(0).getString("k")); + + List> mapList = config.getMapList("sections"); + assertEquals("v", mapList.get(0).get("k")); + } + + @Test + void serializableFromMap() { + JsonConfig config = new JsonConfig(); + + Map serialMap = new LinkedHashMap<>(); + serialMap.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); + serialMap.put("name", "example"); + serialMap.put("value", 42); + + config.set("serialMap", serialMap); + + SerializableExample se = config.getSerializable("serialMap", SerializableExample.class); + assertNotNull(se); + assertEquals("example", se.getName()); + assertEquals(42, se.getValue()); + } + + @Test + void removeAndKeysValuesSeparator() { + JsonConfig config = new JsonConfig(); + config.set("toRemove", "x"); + assertTrue(config.isSet("toRemove")); + config.remove("toRemove"); + assertFalse(config.isSet("toRemove")); + + // nested key and path separator behavior + config.set("a.b.c", "d"); + Collection keysDefault = config.keys(true); + assertTrue(keysDefault.contains("a.b.c")); + + config.settings().pathSeparator('/'); + Collection keysAlt = config.keys(true); + assertTrue(keysAlt.contains("a/b/c")); + } + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java new file mode 100644 index 0000000..3a69305 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java @@ -0,0 +1,55 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.serializer.ConfigSerializable; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SerializableExample implements ConfigSerializable { + + public static final SerializableExample EXAMPLE = new SerializableExample("example", 42); + + private final String name; + private final int value; + + public SerializableExample(@NotNull String name, int value) { + this.name = name; + this.value = value; + } + + @NotNull + public String getName() { + return this.name; + } + + public int getValue() { + return this.value; + } + + @Override + public Map serialize() { + return Map.of( + "name", this.name, + "value", this.value + ); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + SerializableExample that = (SerializableExample) obj; + return value == that.value && name.equals(that.name); + } + + public static SerializableExample deserialize(@NotNull Map map) { + String name = (String) map.get("name"); + int value = (int) map.get("value"); + return new SerializableExample(name, value); + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java new file mode 100644 index 0000000..db97858 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java @@ -0,0 +1,208 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.serializer.NamedSerializers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import dev.spoocy.utils.config.ConfigSection; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class YamlConfigTest extends ResourceTest { + + private static final String EXAMPLE_FILE = resourcesPath("types/example.yml"); + + @BeforeAll + static void setupSerializers() { + String name = NamedSerializers.getSerializeName(SerializableExample.class); + if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { + NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); + } + } + + @Nested + class Creation { + + @Test + void createEmpty() { + YamlConfig config = new YamlConfig(); + assertEquals(0, config.values(true).size()); + } + + @Test + void createWithValues() { + YamlConfig config = new YamlConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + assertEquals(3, config.values(true).size()); + assertEquals("value1", config.getString("key1")); + assertEquals(123, config.getInt("key2")); + assertEquals(SerializableExample.EXAMPLE, config.get("key3", SerializableExample.class)); + } + + } + + @Nested + class Load { + + @Test + void loadEmpty() throws IOException { + YamlConfig config = new YamlConfig(); + YamlConfigLoader.INSTANCE.loadFromString(config, ""); + + assertEquals(0, config.values(true).size()); + } + + @Test + void loadWithValues() throws IOException { + Resource file = Resources.fromFile(EXAMPLE_FILE); + YamlConfig config = (YamlConfig) loadDefault(file); + + assertEquals(3, config.values(true).size()); + assertEquals("bar", config.getString("foo")); + assertEquals(123, config.getInt("key2")); + + SerializableExample expected = config.get("serializable", SerializableExample.class); + assertEquals("example", expected.getName()); + assertEquals(42, expected.getValue()); + } + + } + + @Nested + class Save { + + @Test + void saveToString() { + YamlConfig config = new YamlConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + String yaml = config.saveToString(); + assertNotNull(yaml); + assertTrue(yaml.contains("key1: value1")); + assertTrue(yaml.contains("key2: 123")); + assertTrue(yaml.contains("key3:")); + assertTrue(yaml.contains("name: example")); + assertTrue(yaml.contains("value: 42")); + } + + @Test + void write(@TempDir Path temporaryFolder) throws IOException { + YamlConfig config = new YamlConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", SerializableExample.EXAMPLE); + + Path filePath = temporaryFolder.resolve("config.yml"); + PathResource resource = new PathResource(filePath); + config.save(resource); + + YamlConfig loadedConfig = (YamlConfig) loadDefault(resource); + assertEquals(3, loadedConfig.values(true).size()); + assertEquals("value1", loadedConfig.getString("key1")); + assertEquals(123, loadedConfig.getInt("key2")); + + SerializableExample expected = loadedConfig.get("key3", SerializableExample.class); + assertEquals("example", expected.getName()); + assertEquals(42, expected.getValue()); + + } + + } + + @Nested + class Advanced { + + @Test + void nestedSectionsAndKeys() { + YamlConfig config = new YamlConfig(); + config.set("parent.child.key", "value"); + + assertTrue(config.isSet("parent.child.key")); + assertEquals("value", config.getString("parent.child.key")); + + ConfigSection parent = config.getSection("parent"); + assertNotNull(parent); + assertEquals("value", parent.getString("child.key")); + } + + @Test + void listsAndSectionArrays() { + YamlConfig config = new YamlConfig(); + + List list = Arrays.asList("a", "b", "c"); + config.set("strings", list); + + assertTrue(config.isList("strings")); + assertEquals(list, config.getStringList("strings")); + + Map m1 = new LinkedHashMap<>(); + m1.put("k", "v"); + Map m2 = new LinkedHashMap<>(); + m2.put("k", "w"); + + config.set("sections", Arrays.asList(m1, m2)); + List sections = config.getSectionList("sections"); + assertEquals(2, sections.size()); + assertEquals("v", sections.get(0).getString("k")); + + List> mapList = config.getMapList("sections"); + assertEquals("v", mapList.get(0).get("k")); + } + + @Test + void serializableFromMap() { + YamlConfig config = new YamlConfig(); + + Map serialMap = new LinkedHashMap<>(); + serialMap.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); + serialMap.put("name", "example"); + serialMap.put("value", 42); + + config.set("serialMap", serialMap); + + SerializableExample se = config.getSerializable("serialMap", SerializableExample.class); + assertNotNull(se); + assertEquals("example", se.getName()); + assertEquals(42, se.getValue()); + } + + @Test + void removeAndKeysValuesSeparator() { + YamlConfig config = new YamlConfig(); + config.set("toRemove", "x"); + assertTrue(config.isSet("toRemove")); + config.remove("toRemove"); + assertFalse(config.isSet("toRemove")); + + // nested key and path separator behavior + config.set("a.b.c", "d"); + Collection keysDefault = config.keys(true); + assertTrue(keysDefault.contains("a.b.c")); + + config.settings().pathSeparator('/'); + Collection keysAlt = config.keys(true); + assertTrue(keysAlt.contains("a/b/c")); + } + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterChainTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterChainTest.java new file mode 100644 index 0000000..7b2c792 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterChainTest.java @@ -0,0 +1,242 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link ConfigUpdaterChain}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +class ConfigUpdaterChainTest extends ConfigUpdaterTestBase { + + @Nested + class Builder { + + @Test + void buildWithoutMigrationsFails() { + assertThrows(IllegalStateException.class, () -> ConfigUpdater.chain().build()); + } + + @Test + void buildDefaultsTargetToHighestMigrationVersion() { + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), true)) + .apply(new TrackingMigration(VersionMatcher.exact(version("1.1.0")), version("2.0.0"), true)) + .build(); + + assertEquals("2.0.0", chain.getTargetVersion().formatFull()); + } + + @Test + void buildRejectsDuplicateMatchers() { + TrackingMigration first = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), true); + TrackingMigration second = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.2.0"), true); + + assertThrows(IllegalArgumentException.class, () -> ConfigUpdater.chain() + .apply(VersionMatcher.exact(version("1.0.0")), first) + .apply(VersionMatcher.exact(version("1.0.0")), second) + .build()); + } + + @Test + void getPossibleMigrationsIsUnmodifiable() { + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(new TrackingMigration(VersionMatcher.ANY, version("1.0.0"), true)) + .targetVersion(version("1.0.0")) + .build(); + + Collection possible = chain.getPossibleMigrations(); + assertThrows(UnsupportedOperationException.class, possible::clear); + } + } + + @Nested + class Run { + + @Test + void runsSequentialMigrationsUntilTargetVersion() { + TrackingMigration first = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), true); + TrackingMigration second = new TrackingMigration(VersionMatcher.exact(version("1.1.0")), version("1.2.0"), true); + + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(first) + .apply(second) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertEquals(2, chain.run(config)); + assertEquals("1.2.0", config.getString("config-version")); + assertEquals(1, first.applyCalls.get()); + assertEquals(1, second.applyCalls.get()); + } + + @Test + void prefersExactMatcherBeforeNonExactMatcher() { + TrackingMigration exact = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), true); + TrackingMigration any = new TrackingMigration(VersionMatcher.ANY, version("5.0.0"), true); + + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(any) + .apply(exact) + .targetVersion(version("1.1.0")) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertEquals(1, chain.run(config)); + assertEquals(1, exact.applyCalls.get()); + assertEquals(0, any.applyCalls.get()); + assertEquals("1.1.0", config.getString("config-version")); + } + + @Test + void stopsWhenNoMigrationMatchesCurrentVersion() { + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(new TrackingMigration(VersionMatcher.exact(version("2.0.0")), version("2.1.0"), true)) + .targetVersion(version("3.0.0")) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertEquals(0, chain.run(config)); + assertEquals("1.0.0", config.getString("config-version")); + } + + @Test + void stopsWhenMigrationReturnsFalse() { + TrackingMigration migration = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), false); + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(migration) + .targetVersion(version("2.0.0")) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertEquals(0, chain.run(config)); + assertEquals(1, migration.applyCalls.get()); + assertEquals("1.0.0", config.getString("config-version")); + } + + @Test + void stopsWhenMigrationDoesNotAdvanceVersion() { + TrackingMigration stagnant = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.0.0"), true); + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(stagnant) + .targetVersion(version("3.0.0")) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertEquals(1, chain.run(config)); + assertEquals(1, stagnant.applyCalls.get()); + assertEquals("1.0.0", config.getString("config-version")); + } + + @Test + void usesVersionPathFallbackWhenVersionIsMissing() { + ConfigUpdaterChain chain = ConfigUpdater.chain() + .versionPath("schema-version", version("0.5.0")) + .apply(new TrackingMigration(VersionMatcher.exact(version("0.5.0")), version("1.0.0"), true)) + .targetVersion(version("1.0.0")) + .build(); + + ConfigSection config = memoryConfig(); + + assertEquals(1, chain.run(config)); + assertEquals("1.0.0", config.getString("schema-version")); + } + + @Test + void throwsWhenAppliedMigrationHasNullTargetVersion() { + ConfigMigration invalidMigration = mock(ConfigMigration.class); + when(invalidMigration.fromVersion()).thenReturn(VersionMatcher.exact(version("1.0.0"))); + when(invalidMigration.apply(any(ConfigSection.class))).thenReturn(true); + when(invalidMigration.toVersion()).thenReturn(null); + + ConfigUpdaterChain chain = ConfigUpdater.chain() + .apply(invalidMigration) + .targetVersion(version("2.0.0")) + .build(); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + + assertThrows(NullPointerException.class, () -> chain.run(config)); + } + } + + @Nested + class Constructor { + + @Test + void constructorUsesMigrationMatcherWhenExplicitMatcherIsNull() { + TrackingMigration migration = new TrackingMigration(VersionMatcher.exact(version("1.0.0")), version("1.1.0"), true); + + Map migrations = new LinkedHashMap<>(); + migrations.put(migration, null); + + ConfigUpdaterChain chain = new ConfigUpdaterChain( + pathVersionResolver("config-version", version("0.0.0")), + migrations, + version("1.1.0") + ); + + ConfigSection config = memoryConfig(); + config.set("config-version", "1.0.0"); + assertEquals(1, chain.run(config)); + } + } + + private static final class TrackingMigration implements ConfigMigration { + + private final VersionMatcher matcher; + private final Version toVersion; + private final boolean applyResult; + private final AtomicInteger applyCalls = new AtomicInteger(); + + private TrackingMigration(@NotNull VersionMatcher matcher, @NotNull Version toVersion, boolean applyResult) { + this.matcher = matcher; + this.toVersion = toVersion; + this.applyResult = applyResult; + } + + @Override + public VersionMatcher fromVersion() { + return this.matcher; + } + + @Override + public @NotNull Version toVersion() { + return this.toVersion; + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + this.applyCalls.incrementAndGet(); + return this.applyResult; + } + } + + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java new file mode 100644 index 0000000..a0a44f2 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java @@ -0,0 +1,65 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.*; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.types.MemoryConfig; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Base class for configuration updater tests. + * Provides common utilities for setting up and testing config updates. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public abstract class ConfigUpdaterTestBase extends ResourceTest { + + protected static final String UPDATE_RESOURCES_PREFIX = "update/"; + + @NotNull + protected static Version version(@NotNull String version) { + return Version.parse(version); + } + + @NotNull + protected static MemoryConfig memoryConfig() { + return new MemoryConfig(); + } + + @NotNull + protected static Resource updateResource(@NotNull String resourceName) { + return Resources.fromJar("dev/spoocy/utils/config/" + UPDATE_RESOURCES_PREFIX + resourceName); + } + + @NotNull + protected static VersionResolver pathVersionResolver(@NotNull String path, @NotNull Version fallback) { + return new VersionResolver() { + @Override + public @NotNull Version resolve(@NotNull dev.spoocy.utils.config.ConfigSection config) { + Version resolved = config.getVersion(path, null); + return resolved == null ? fallback : resolved; + } + + @Override + public void apply(@NotNull dev.spoocy.utils.config.ConfigSection config, @NotNull Version version) { + config.set(path, version.formatFull()); + } + }; + } + + /** + * Helper method to write YAML content to a temporary file. + */ + protected Path createConfigFile(@NotNull Path tempDir, @NotNull String filename, @NotNull String content) throws IOException { + Path file = tempDir.resolve(filename); + Files.writeString(file, content, StandardCharsets.UTF_8); + return file; + } +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java new file mode 100644 index 0000000..898136a --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java @@ -0,0 +1,157 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ConfigProvider; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.migrations.MissingFieldsMigration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link MissingFieldsMigration}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class MissingFieldsMigrationTest extends ConfigUpdaterTestBase { + + private static final VersionResolver DEFAULTS_VERSION_RESOLVER = pathVersionResolver("config-version", Version.ZERO); + + @Nested + class Apply extends MissingFieldsContext { + + @Test + void addsOnlyMissingLeafValues() { + Config defaults = memoryConfig(); + defaults.set("config-version", "2.0.0"); + defaults.set("database.host", "db.local"); + defaults.set("database.port", 3306); + defaults.set("feature.enabled", true); + + ConfigSection target = memoryConfig(); + target.set("database.host", "custom.host"); + + boolean changed = migration(() -> defaults).apply(target); + + assertTrue(changed); + assertEquals("custom.host", target.getString("database.host")); + assertEquals(3306, target.getInt("database.port")); + assertTrue(target.getBoolean("feature.enabled")); + } + + @Test + void skipsIncompatibleTreeMerges() { + Config defaults = memoryConfig(); + defaults.set("database.host", "db.local"); + + ConfigSection target = memoryConfig(); + target.set("database", "flat-value"); + + assertTrue(migration(() -> defaults).apply(target)); + assertEquals("flat-value", target.getString("database")); + assertFalse(target.isSet("database.host")); + } + + @Test + void wrapsProviderFailuresInIllegalStateException() { + ConfigSection target = memoryConfig(); + + MissingFieldsMigration migration = migration(() -> { + throw new RuntimeException("boom"); + }); + + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> migration.apply(target)); + assertTrue(exception.getMessage().contains("Failed to apply defaults migration")); + assertNotNull(exception.getCause()); + } + } + + @Nested + class ToVersion extends MissingFieldsContext { + + @Test + void resolvesVersionFromDefaults() { + Config defaults = memoryConfig(); + defaults.set("config-version", "1.5.0"); + + Version version = migration(() -> defaults).toVersion(); + assertEquals("1.5.0", version.formatFull()); + } + + @Test + void cachesResolvedVersionAndLoadedConfig() { + AtomicInteger provideCalls = new AtomicInteger(); + AtomicInteger resolveCalls = new AtomicInteger(); + + Config defaults = memoryConfig(); + defaults.set("config-version", "2.1.0"); + + ConfigProvider provider = () -> { + provideCalls.incrementAndGet(); + return defaults; + }; + + VersionResolver resolver = new VersionResolver() { + @Override + public Version resolve(ConfigSection config) { + resolveCalls.incrementAndGet(); + return DEFAULTS_VERSION_RESOLVER.resolve(config); + } + + @Override + public void apply(ConfigSection config, Version version) { + DEFAULTS_VERSION_RESOLVER.apply(config, version); + } + }; + + MissingFieldsMigration migration = new MissingFieldsMigration(provider, VersionMatcher.ANY, resolver); + Version first = migration.toVersion(); + Version second = migration.toVersion(); + + assertEquals("2.1.0", first.formatFull()); + assertSame(first, second); + assertEquals(1, provideCalls.get()); + assertEquals(1, resolveCalls.get()); + } + } + + @Nested + class Guards extends MissingFieldsContext { + + @Test + void rejectsNullVersionResolver() { + assertThrows(NullPointerException.class, + () -> new MissingFieldsMigration(memoryConfig(), VersionMatcher.ANY, nullValue())); + } + + @Test + void rejectsNullTargetConfig() { + MissingFieldsMigration migration = migration(memoryConfig()); + assertThrows(NullPointerException.class, () -> migration.apply(nullValue())); + } + } + + @SuppressWarnings("unchecked") + private static T nullValue() { + return (T) null; + } + + private abstract static class MissingFieldsContext { + + protected MissingFieldsMigration migration(Config defaults) { + return new MissingFieldsMigration(defaults, VersionMatcher.ANY, DEFAULTS_VERSION_RESOLVER); + } + + protected MissingFieldsMigration migration(ConfigProvider provider) { + return new MissingFieldsMigration(provider, VersionMatcher.ANY, DEFAULTS_VERSION_RESOLVER); + } + } + + + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/TransformationMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/TransformationMigrationTest.java new file mode 100644 index 0000000..3f0b572 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/TransformationMigrationTest.java @@ -0,0 +1,182 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.migrations.TransformationMigration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link TransformationMigration}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class TransformationMigrationTest extends ConfigUpdaterTestBase { + + @Nested + class RenameKey extends TransformationContext { + + @Test + void renamesWhenOldPathExistsAndNewPathIsMissing() { + ConfigSection config = memoryConfig(); + config.set("old.path", "value"); + + assertTrue(migration().renameKey("old.path", "new.path").apply(config)); + assertFalse(config.isSet("old.path")); + assertEquals("value", config.getString("new.path")); + } + + @Test + void skipsWhenOldPathDoesNotExist() { + ConfigSection config = memoryConfig(); + + assertFalse(migration().renameKey("missing", "target").apply(config)); + assertFalse(config.isSet("target")); + } + + @Test + void skipsWhenNewPathAlreadyExists() { + ConfigSection config = memoryConfig(); + config.set("old.path", "old"); + config.set("new.path", "new"); + + assertFalse(migration().renameKey("old.path", "new.path").apply(config)); + assertEquals("old", config.getString("old.path")); + assertEquals("new", config.getString("new.path")); + } + } + + @Nested + class RemoveKey extends TransformationContext { + + @Test + void removesExistingPath() { + ConfigSection config = memoryConfig(); + config.set("legacy", true); + + assertTrue(migration().removeKey("legacy").apply(config)); + assertFalse(config.isSet("legacy")); + } + + @Test + void skipsMissingPath() { + assertFalse(migration().removeKey("missing").apply(memoryConfig())); + } + } + + @Nested + class TransformValue extends TransformationContext { + + @Test + void transformsExistingValue() { + ConfigSection config = memoryConfig(); + config.set("threads", "4"); + + assertTrue(migration().transformValue("threads", value -> Integer.parseInt(value.toString()) * 2).apply(config)); + assertEquals(8, config.getInt("threads")); + } + + @Test + void skipsMissingPath() { + assertFalse(migration().transformValue("missing", value -> value).apply(memoryConfig())); + } + + @Test + void wrapsTransformerExceptions() { + ConfigSection config = memoryConfig(); + config.set("value", "x"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> migration().transformValue("value", value -> { + throw new IllegalArgumentException("boom"); + }).apply(config)); + + assertTrue(exception.getMessage().contains("Failed to transform value at path 'value'")); + assertNotNull(exception.getCause()); + } + } + + @Nested + class CustomTransformations extends TransformationContext { + + @Test + void appliesCustomTransformationsInOrder() { + ConfigSection config = memoryConfig(); + List order = new ArrayList<>(); + + TransformationMigration migration = migration() + .addTransformation(section -> { + order.add("first"); + section.set("value", "A"); + return true; + }) + .addTransformation(section -> { + order.add("second"); + section.set("value", section.getString("value") + "B"); + return true; + }); + + assertTrue(migration.apply(config)); + assertEquals(List.of("first", "second"), order); + assertEquals("AB", config.getString("value")); + } + + @Test + void returnsFalseWhenNoTransformationsApply() { + assertFalse(migration().apply(memoryConfig())); + } + + @Test + void stopsExecutionWhenTransformationThrows() { + AtomicInteger calls = new AtomicInteger(); + TransformationMigration migration = migration() + .addTransformation(section -> { + calls.incrementAndGet(); + throw new IllegalStateException("fail"); + }) + .addTransformation(section -> { + calls.incrementAndGet(); + return true; + }); + + assertThrows(IllegalStateException.class, () -> migration.apply(memoryConfig())); + assertEquals(1, calls.get()); + } + } + + @Nested + class Guards extends TransformationContext { + + @Test + void rejectsNullArguments() { + TransformationMigration migration = migration(); + + assertThrows(NullPointerException.class, () -> migration.renameKey(nullValue(), "target")); + assertThrows(NullPointerException.class, () -> migration.renameKey("source", nullValue())); + assertThrows(NullPointerException.class, () -> migration.removeKey(nullValue())); + assertThrows(NullPointerException.class, () -> migration.transformValue(nullValue(), value -> value)); + assertThrows(NullPointerException.class, () -> migration.transformValue("path", nullValue())); + assertThrows(NullPointerException.class, () -> migration.addTransformation(nullValue())); + } + } + + @SuppressWarnings("unchecked") + private static T nullValue() { + return (T) null; + } + + private abstract static class TransformationContext { + + protected TransformationMigration migration() { + return new TransformationMigration(version("1.0.0"), version("2.0.0")); + } + } + + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/ValidationMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/ValidationMigrationTest.java new file mode 100644 index 0000000..d63bc78 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/ValidationMigrationTest.java @@ -0,0 +1,151 @@ +package dev.spoocy.utils.config.update; + +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.migrations.ValidationMigration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ValidationMigration}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ValidationMigrationTest extends ConfigUpdaterTestBase { + + @Nested + class Apply extends ValidationContext { + + @Test + void returnsFalseWhenNoValidatorsAreRegistered() { + assertFalse(migration().apply(memoryConfig())); + } + + @Test + void returnsTrueWhenAtLeastOneValidatorExists() { + ValidationMigration migration = migration().addValidator(config -> { + }); + assertTrue(migration.apply(memoryConfig())); + } + + @Test + void stopsValidationWhenValidatorThrows() { + AtomicInteger calls = new AtomicInteger(); + ValidationMigration migration = migration() + .addValidator(config -> { + calls.incrementAndGet(); + throw new IllegalStateException("invalid"); + }) + .addValidator(config -> calls.incrementAndGet()); + + assertThrows(IllegalStateException.class, () -> migration.apply(memoryConfig())); + assertEquals(1, calls.get()); + } + } + + @Nested + class ValidatePath extends ValidationContext { + + @Test + void runsValidatorOnlyWhenPathExists() { + ConfigSection config = memoryConfig(); + AtomicInteger calls = new AtomicInteger(); + + ValidationMigration migration = migration().validatePath("feature.enabled", section -> calls.incrementAndGet()); + migration.apply(config); + assertEquals(0, calls.get()); + + config.set("feature.enabled", true); + migration.apply(config); + assertEquals(1, calls.get()); + } + } + + @Nested + class RequirePath extends ValidationContext { + + @Test + void setsDefaultWhenPathIsMissing() { + ConfigSection config = memoryConfig(); + assertTrue(migration().requirePath("database.port", 3306).apply(config)); + assertEquals(3306, config.getInt("database.port")); + } + + @Test + void doesNotOverwriteExistingValue() { + ConfigSection config = memoryConfig(); + config.set("database.port", 25565); + + migration().requirePath("database.port", 3306).apply(config); + assertEquals(25565, config.getInt("database.port")); + } + } + + @Nested + class ValidateAllowed extends ValidationContext { + + @Test + void acceptsAllowedValue() { + ConfigSection config = memoryConfig(); + config.set("mode", "INFO"); + + assertTrue(migration().validateAllowed("mode", "INFO", "WARN").apply(config)); + } + + @Test + void ignoresMissingPath() { + assertTrue(migration().validateAllowed("mode", "INFO", "WARN").apply(memoryConfig())); + } + + @Test + void rejectsDisallowedValue() { + ConfigSection config = memoryConfig(); + config.set("mode", "DEBUG"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> migration().validateAllowed("mode", "INFO", "WARN").apply(config)); + + assertTrue(exception.getMessage().contains("must be one of the allowed values")); + } + + @Test + void rejectsEmptyAllowedValues() { + assertThrows(IllegalArgumentException.class, () -> migration().validateAllowed("mode")); + } + } + + @Nested + class Guards extends ValidationContext { + + @Test + void rejectsNullArguments() { + ValidationMigration migration = migration(); + + assertThrows(NullPointerException.class, () -> migration.addValidator(nullValue())); + assertThrows(NullPointerException.class, () -> migration.validatePath(nullValue(), section -> { + })); + assertThrows(NullPointerException.class, () -> migration.validatePath("path", nullValue())); + assertThrows(NullPointerException.class, () -> migration.requirePath(nullValue(), "value")); + assertThrows(NullPointerException.class, () -> migration.validateAllowed(nullValue(), "value")); + assertThrows(NullPointerException.class, () -> migration.validateAllowed("path", nullValue())); + } + } + + @SuppressWarnings("unchecked") + private static T nullValue() { + return (T) null; + } + + private abstract static class ValidationContext { + + protected ValidationMigration migration() { + return new ValidationMigration(version("1.0.0"), version("1.1.0")); + } + } + + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/base/BaseMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/base/BaseMigrationTest.java new file mode 100644 index 0000000..031c3e9 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/base/BaseMigrationTest.java @@ -0,0 +1,91 @@ +package dev.spoocy.utils.config.update.base; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.ConfigUpdaterTestBase; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link BaseMigration}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +class BaseMigrationTest extends ConfigUpdaterTestBase { + + @Nested + class FromVersion { + + @Test + void defaultsToAnyMatcherWhenNullMatcherIsProvided() { + TestMigration migration = new TestMigration((VersionMatcher) null, version("1.1.0")); + assertSame(VersionMatcher.ANY, migration.fromVersion()); + } + + @Test + void wrapsExplicitFromVersionInExactMatcher() { + TestMigration migration = new TestMigration(version("1.0.0"), version("1.1.0")); + VersionMatcher matcher = migration.fromVersion(); + + assertNotNull(matcher); + assertTrue(matcher.matches(version("1.0.0"))); + assertFalse(matcher.matches(version("1.0.1"))); + assertTrue(matcher.isExact()); + } + } + + @Nested + class ToVersion { + + @Test + void returnsConstructorTargetVersion() { + TestMigration migration = new TestMigration(VersionMatcher.ANY, version("2.0.0")); + assertEquals("2.0.0", migration.toVersion().formatFull()); + } + + @Test + void throwsWhenNoTargetVersionIsAvailable() { + TestMigration migration = new TestMigration(VersionMatcher.ANY, null); + assertThrows(IllegalStateException.class, migration::toVersion); + } + + @Test + void supportsLazyVersionResolutionWhenOverridden() { + LazyVersionMigration migration = new LazyVersionMigration(); + assertEquals("3.0.0", migration.toVersion().formatFull()); + } + } + + private static class TestMigration extends BaseMigration { + + private TestMigration(VersionMatcher matcher, Version toVersion) { + super(matcher, toVersion); + } + + private TestMigration(Version fromVersion, Version toVersion) { + super(fromVersion, toVersion); + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + return false; + } + } + + private static final class LazyVersionMigration extends TestMigration { + + private LazyVersionMigration() { + super(VersionMatcher.ANY, null); + } + + @Override + public @NotNull Version toVersion() { + return version("3.0.0"); + } + } +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/base/ResourceBasedMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/base/ResourceBasedMigrationTest.java new file mode 100644 index 0000000..78078b3 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/base/ResourceBasedMigrationTest.java @@ -0,0 +1,83 @@ +package dev.spoocy.utils.config.update.base; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.ConfigProvider; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.update.ConfigUpdaterTestBase; +import dev.spoocy.utils.config.update.VersionMatcher; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ResourceBasedMigration}. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ +class ResourceBasedMigrationTest extends ConfigUpdaterTestBase { + + @Nested + class ResourceLoading { + + @Test + void returnsConfiguredConfigProvider() { + ConfigProvider provider = ResourceBasedMigrationTest::memoryConfig; + TestResourceMigration migration = new TestResourceMigration(provider); + + assertSame(provider, migration.getConfigProvider()); + } + + @Test + void cachesLoadedResourceAfterFirstCall() { + AtomicInteger provideCalls = new AtomicInteger(); + Config defaults = memoryConfig(); + ConfigProvider provider = () -> { + provideCalls.incrementAndGet(); + return defaults; + }; + + TestResourceMigration migration = new TestResourceMigration(provider); + + Config first = migration.exposedLoadResource(); + Config second = migration.exposedLoadResource(); + + assertSame(first, second); + assertEquals(1, provideCalls.get()); + } + + @Test + void wrapsProviderFailuresInIllegalStateException() { + TestResourceMigration migration = new TestResourceMigration(() -> { + throw new RuntimeException("broken"); + }); + + IllegalStateException exception = assertThrows(IllegalStateException.class, migration::exposedLoadResource); + assertNotNull(exception.getCause()); + assertTrue(exception.getMessage().contains("Failed to load configuration resource for migration")); + } + } + + private static final class TestResourceMigration extends ResourceBasedMigration { + + private TestResourceMigration(ConfigProvider configProvider) { + super(VersionMatcher.ANY, Version.parse("1.0.0"), configProvider); + } + + @Override + public boolean apply(@NotNull ConfigSection config) { + return false; + } + + private Config exposedLoadResource() { + return loadResource(); + } + } + +} + + diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.properties b/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.properties new file mode 100644 index 0000000..7b89edb --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.properties @@ -0,0 +1 @@ +key=value diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.xml b/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.xml new file mode 100644 index 0000000..b9d4450 --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/io/example.xml @@ -0,0 +1,5 @@ + + + + bar + \ No newline at end of file diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/example.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/example.yml new file mode 100644 index 0000000..9bd5c64 --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/example.yml @@ -0,0 +1,9 @@ +config-version: 1.0.0 +database: + host: db.local + port: 3307 +feature: + enabled: true + mode: HARD +name: ExamplePlugin + diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml new file mode 100644 index 0000000..35e0fdb --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml @@ -0,0 +1,5 @@ +serializable: + ==: dev.spoocy.utils.config.types.SerializableExample + name: example + value: 42 + diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json new file mode 100644 index 0000000..706964e --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json @@ -0,0 +1,10 @@ +{ + "foo": "bar", + "key2": 123, + "serializable": { + "==": "dev.spoocy.utils.config.types.SerializableExample", + "name": "example", + "value": 42 + } +} + diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml new file mode 100644 index 0000000..53e31a6 --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml @@ -0,0 +1,6 @@ +foo: bar +key2: 123 +serializable: + ==: "dev.spoocy.utils.config.types.SerializableExample" + name: "example" + value: 42 \ No newline at end of file diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults-without-version.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults-without-version.yml new file mode 100644 index 0000000..233fa3c --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults-without-version.yml @@ -0,0 +1,6 @@ +database: + host: fallback.local + port: 5432 +feature: + enabled: true + diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml new file mode 100644 index 0000000..14983e4 --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml @@ -0,0 +1,7 @@ +config-version: 2.0.0 +database: + host: defaults.local + port: 3306 +feature: + enabled: true + diff --git a/pom.xml b/pom.xml index fabb7fa..5dc40e9 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 pom @@ -15,7 +15,6 @@ config-utils reflection-utils security-utils - config-utils-yaml diff --git a/reflection-utils/pom.xml b/reflection-utils/pom.xml index 0aabbc6..688af16 100644 --- a/reflection-utils/pom.xml +++ b/reflection-utils/pom.xml @@ -6,7 +6,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 reflection-utils diff --git a/reflection-utils/src/main/java/dev/spoocy/utils/reflection/Reflection.java b/reflection-utils/src/main/java/dev/spoocy/utils/reflection/Reflection.java index 0265e8d..cb544e0 100644 --- a/reflection-utils/src/main/java/dev/spoocy/utils/reflection/Reflection.java +++ b/reflection-utils/src/main/java/dev/spoocy/utils/reflection/Reflection.java @@ -11,6 +11,10 @@ import org.jetbrains.annotations.Nullable; import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; import java.util.Set; /** @@ -34,10 +38,8 @@ public static FieldBuilder field() { /** * Gets a public constructor accessor for the specified class and parameter types. * - * @param clazz - * the class to get the constructor from - * @param parameters - * the parameter types of the constructor + * @param clazz the class to get the constructor from + * @param parameters the parameter types of the constructor * * @return the constructor accessor. */ @@ -53,19 +55,19 @@ public static ConstructorAccessor getConstructor(@NotNull Class clazz, @NotNu * Gets a field accessor for the specified class, field name, and field type. * Either fieldName or fieldType can be null, but not both. * - * @param clazz - * the class to get the field from - * @param fieldName - * the name of the field (can be null) - * @param fieldType - * the type of the field (can be null) + * @param clazz the class to get the field from + * @param fieldName the name of the field (can be null) + * @param fieldType the type of the field (can be null) * * @return the field accessor. * - * @throws IllegalArgumentException - * if both fieldName and fieldType are null + * @throws IllegalArgumentException if both fieldName and fieldType are null */ - public static FieldAccessor getField(@NotNull Class clazz, @Nullable String fieldName, @Nullable Class fieldType) { + public static FieldAccessor getField( + @NotNull Class clazz, + @Nullable String fieldName, + @Nullable Class fieldType + ) { FieldBuilder builder = field(); if (fieldName != null) { @@ -87,16 +89,17 @@ public static FieldAccessor getField(@NotNull Class clazz, @Nullable String f * Gets a method accessor for the specified class, method name, and parameter types. * Method name can be null, in which case the first method matching the parameter types will be returned. * - * @param clazz - * the class to get the method from - * @param methodName - * the name of the method (can be null) - * @param args - * the parameter types of the method + * @param clazz the class to get the method from + * @param methodName the name of the method (can be null) + * @param args the parameter types of the method * * @return the method accessor. */ - public static MethodAccessor getMethod(@NotNull Class clazz, @Nullable String methodName, @NotNull Object... args) { + public static MethodAccessor getMethod( + @NotNull Class clazz, + @Nullable String methodName, + @NotNull Object... args + ) { Class[] parameterTypes = new Class[args != null ? args.length : 0]; if (args != null && args.length > 0) { @@ -121,7 +124,7 @@ public static MethodAccessor getMethod(@NotNull Class clazz, @Nullable String if (parameterTypes.length > 0) { builder.parameterCount(parameterTypes.length); - for(int i = 0; i < parameterTypes.length; i++) { + for (int i = 0; i < parameterTypes.length; i++) { builder.parameterType(i, parameterTypes[i]); } } @@ -136,16 +139,17 @@ public static MethodAccessor getMethod(@NotNull Class clazz, @Nullable String /** * Checks if the given class has the specified annotation or any of its superclasses (if inheritance is true). * - * @param clazz - * the class to check - * @param annotation - * the annotation to look for - * @param inheritance - * whether to check superclasses for the annotation + * @param clazz the class to check + * @param annotation the annotation to look for + * @param inheritance whether to check superclasses for the annotation * * @return true if the annotation is present, false otherwise */ - public static boolean hasAnnotation(@NotNull Class clazz, @NotNull Class annotation, boolean inheritance) { + public static boolean hasAnnotation( + @NotNull Class clazz, + @NotNull Class annotation, + boolean inheritance + ) { if (clazz.isAnnotationPresent(annotation)) { return true; } @@ -160,17 +164,18 @@ public static boolean hasAnnotation(@NotNull Class clazz, @NotNull Class A getAnnotation(@NotNull Class clazz, @NotNull Class annotation, boolean inheritance) { + public static A getAnnotation( + @NotNull Class clazz, + @NotNull Class annotation, + boolean inheritance + ) { if (clazz.isAnnotationPresent(annotation)) { return clazz.getAnnotation(annotation); } @@ -185,15 +190,12 @@ public static A getAnnotation(@NotNull Class clazz, @N /** * Gets the enum constant of the specified enum class with the specified name. * - * @param enumClass - * the enum class - * @param name - * the name of the enum constant + * @param enumClass the enum class + * @param name the name of the enum constant * * @return the enum constant with the specified name. * - * @throws IllegalArgumentException - * if the specified enum class has no constant with the specified name + * @throws IllegalArgumentException if the specified enum class has no constant with the specified name */ public static > E getEnumValue(@NotNull Class enumClass, @NotNull String name) { return Enum.valueOf(enumClass, name); @@ -207,10 +209,54 @@ public static > E getEnumValue(@NotNull Class enumClass, @N * @return a set of all enum constants of the specified enum class. */ public static > Set getEnumValues(@NotNull Class enumClass) { - return Collector.of(enumClass.getEnumConstants()).asSet(); + return Collector.of(enumClass.getEnumConstants()) + .asSet(); + } + + /** + * Resolves the element type of a collection field. + * If the provided field represents a collection type (e.g., List, Set) with a parameterized type, + * this method attempts to determine and return the element type of the collection. If the field is + * not a collection or its type does not define a parameterized type, the method returns {@code null}. + * + * @param field the field to inspect, must not be null + * + * @return the class representing the element type of the collection, or {@code null} if it + * cannot be determined or the field is not a collection type + */ + @Nullable + public static Class resolveCollectionElementType(@NotNull Field field) { + if (!Collection.class.isAssignableFrom(field.getType())) { + return null; + } + + Type declared = field.getGenericType(); + if (!(declared instanceof ParameterizedType)) { + return null; + } + + Type[] typeArguments = ((ParameterizedType) declared).getActualTypeArguments(); + if (typeArguments.length != 1) { + return null; + } + + Type elementType = typeArguments[0]; + if (elementType instanceof Class) { + return (Class) elementType; + } + + if (elementType instanceof ParameterizedType) { + Type rawType = ((ParameterizedType) elementType).getRawType(); + if (rawType instanceof Class) { + return (Class) rawType; + } + } + + return null; } private Reflection() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } + } diff --git a/reflection-utils/src/main/java/module-info.java b/reflection-utils/src/main/java/module-info.java index eda343d..2f24c89 100644 --- a/reflection-utils/src/main/java/module-info.java +++ b/reflection-utils/src/main/java/module-info.java @@ -3,7 +3,7 @@ */ module dev.spoocy.utils.reflection { - requires org.jetbrains.annotations; + requires static org.jetbrains.annotations; requires dev.spoocy.utils.common; exports dev.spoocy.utils.reflection; diff --git a/security-utils/pom.xml b/security-utils/pom.xml index d1c7ab1..192f7b5 100644 --- a/security-utils/pom.xml +++ b/security-utils/pom.xml @@ -6,7 +6,7 @@ dev.spoocy.utils root - 1.0.12 + 1.0.13 security-utils diff --git a/security-utils/src/main/java/dev/spoocy/utils/security/SecurityManager.java b/security-utils/src/main/java/dev/spoocy/utils/security/SecurityManager.java index 4a6d69d..383a03f 100644 --- a/security-utils/src/main/java/dev/spoocy/utils/security/SecurityManager.java +++ b/security-utils/src/main/java/dev/spoocy/utils/security/SecurityManager.java @@ -1,6 +1,6 @@ package dev.spoocy.utils.security; -import dev.spoocy.utils.common.collections.SortedArray; +import dev.spoocy.utils.common.collections.SortedArrayList; import dev.spoocy.utils.reflection.Reflection; import dev.spoocy.utils.reflection.accessor.MethodAccessor; import dev.spoocy.utils.security.report.SecurityReport; @@ -20,7 +20,7 @@ public class SecurityManager { - private final Collection registeredTests = new SortedArray<>(); + private final Collection registeredTests = new SortedArrayList<>(); private final Report globalReport; @@ -151,7 +151,7 @@ public TestResult run() { try { result = (TestResult) this.test.invoke(instance); } catch (Exception e) { - result = new TestResult(this.resultOnException, "Could not complete test: " + e.getMessage()); + result = new TestResult(this.resultOnException, e, "Could not complete test: " + e.getMessage()); } return result; diff --git a/security-utils/src/main/java/dev/spoocy/utils/security/TestResult.java b/security-utils/src/main/java/dev/spoocy/utils/security/TestResult.java index 447f24d..69a3405 100644 --- a/security-utils/src/main/java/dev/spoocy/utils/security/TestResult.java +++ b/security-utils/src/main/java/dev/spoocy/utils/security/TestResult.java @@ -16,13 +16,23 @@ public class TestResult { public static final TestResult SKIPPED = new TestResult(CheckResult.SKIPPED, "Test skipped."); private final CheckResult result; + private final Exception exception; private final String[] messages; public TestResult( @NotNull CheckResult result, @Nullable String... messages + ) { + this(result, null, messages); + } + + public TestResult( + @NotNull CheckResult result, + @Nullable Exception exception, + @Nullable String... messages ) { this.result = result; + this.exception = exception; this.messages = (messages == null || messages.length == 0) ? new String[] {"NONE"} : Arrays.copyOf(messages, messages.length); for (int i = 0; i < this.messages.length; i++) { @@ -33,12 +43,17 @@ public TestResult( } @NotNull - public String[] getMessages() { - return messages; + public CheckResult getResult() { + return this.result; + } + + @Nullable + public Exception getException() { + return this.exception; } @NotNull - public CheckResult getResult() { - return result; + public String[] getMessages() { + return this.messages; } } diff --git a/security-utils/src/main/java/module-info.java b/security-utils/src/main/java/module-info.java index 7ee20ce..dd29c49 100644 --- a/security-utils/src/main/java/module-info.java +++ b/security-utils/src/main/java/module-info.java @@ -3,7 +3,7 @@ */ module dev.spoocy.utils.security { - requires org.jetbrains.annotations; + requires static org.jetbrains.annotations; requires dev.spoocy.utils.common; requires dev.spoocy.utils.reflection; From ded61d791cc227c863118d899ff1d95a943ae49d Mon Sep 17 00:00:00 2001 From: Spoocy99 <76441091+Spoocy99@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:05:08 +0200 Subject: [PATCH 2/2] Reworked Config Structure and Serializers via Nodes --- bukkit-utils/pom.xml | 2 + .../bukkit/serializers/BukkitSerializer.java | 39 - .../bukkit/serializers/BukkitSerializers.java | 125 -- .../serializers/GameProfileSerializer.java | 49 +- .../serializers/InventorySerializer.java | 102 -- .../serializers/ItemStackSerializer.java | 68 - .../spoocy/utils/common/misc/ClassFinder.java | 5 + .../spoocy/utils/common/misc/FileUtils.java | 7 + .../utils/common/scheduler/JavaScheduler.java | 7 +- .../src/test/java/ClassFinderTest.java | 3 + config-utils/pom.xml | 2 + .../{components => }/AbstractConfig.java | 69 +- .../utils/config/BaseResourceResolver.java | 197 +-- .../dev/spoocy/utils/config/Commentable.java | 4 + .../java/dev/spoocy/utils/config/Config.java | 40 +- .../spoocy/utils/config/ConfigSection.java | 49 +- .../dev/spoocy/utils/config/Document.java | 24 +- .../config/{components => }/FileDocument.java | 49 +- .../spoocy/utils/config/MemorySection.java | 708 ++++++++ .../dev/spoocy/utils/config/Readable.java | 33 +- .../spoocy/utils/config/ResourceResolver.java | 78 +- .../dev/spoocy/utils/config/Resources.java | 5 +- .../utils/config/SafeResourceResolver.java | 41 - .../java/dev/spoocy/utils/config/Tag.java | 178 ++ .../dev/spoocy/utils/config/TagProcessor.java | 14 + .../dev/spoocy/utils/config/Writeable.java | 2 +- .../spoocy/utils/config/bean/BoundField.java | 172 ++ .../spoocy/utils/config/bean/BoundHook.java | 87 + .../spoocy/utils/config/bean/ConfigBean.java | 258 +++ .../utils/config/bean/ConfigBeanLoader.java | 300 ++++ .../config/{loader => bean}/ConfigIgnore.java | 2 +- .../{loader => bean}/ConfigProperty.java | 2 +- .../config/{loader => bean}/ConfigSource.java | 17 +- .../config/bean/DefaultPropertyLoader.java | 439 +++++ .../utils/config/bean/LoadStrategy.java | 15 + .../config/{loader => bean}/PostLoad.java | 2 +- .../{loader => bean}/PostLoadResult.java | 2 +- .../config/{loader => bean}/PreLoad.java | 2 +- .../utils/config/bean/PropertyLoader.java | 14 + .../config/components/AbstractDataHolder.java | 356 ---- .../utils/config/components/ConfigNode.java | 123 -- .../config/components/MemorySection.java | 409 ----- .../config/constructor/BaseConstructor.java | 127 +- .../utils/config/constructor/Construct.java | 13 +- .../utils/config/constructor/Constructor.java | 16 +- .../constructor/DefaultNodeConstructor.java | 106 ++ .../config/constructor/NodeConstructor.java | 17 + .../config/constructor/SafeConstructor.java | 172 ++ .../constructor/SerializerAssignable.java | 18 - .../constructor/SerializingConstructor.java | 183 -- .../utils/config/io/ClassPathResource.java | 5 + .../utils/config/io/FileSystemResource.java | 5 + .../spoocy/utils/config/io/UrlResource.java | 5 + .../config/loader/AnnotatedConfigLoader.java | 704 -------- .../utils/config/loader/ConfigLoader.java | 1 - .../utils/config/loader/JsonConfigLoader.java | 53 +- .../utils/config/loader/JsonProcessor.java | 56 + .../utils/config/loader/YamlConfigLoader.java | 179 +- .../utils/config/loader/YamlProcessor.java | 169 +- .../utils/config/nodes/CollectionNode.java | 28 + .../spoocy/utils/config/nodes/ConfigData.java | 57 + .../spoocy/utils/config/nodes/MemoryData.java | 53 + .../dev/spoocy/utils/config/nodes/Node.java | 72 + .../spoocy/utils/config/nodes/NodeTree.java | 132 ++ .../spoocy/utils/config/nodes/NodeTuple.java | 54 + .../spoocy/utils/config/nodes/NodeType.java | 31 + .../spoocy/utils/config/nodes/ScalarNode.java | 57 + .../utils/config/nodes/SequenceNode.java | 61 + .../config/representer/BaseRepresenter.java | 203 ++- .../utils/config/representer/Represent.java | 13 +- .../utils/config/representer/Representer.java | 15 +- .../config/representer/SafeRepresenter.java | 275 +++ .../representer/SerializingRepresenter.java | 116 -- .../config/serializer/BaseSerializers.java | 60 - .../config/serializer/ConfigSerializable.java | 26 - .../serializer/DelegateDeserialization.java | 29 - .../config/serializer/NamedSerializers.java | 203 --- .../config/serializer/SerializableAs.java | 37 - .../utils/config/serializer/Serializer.java | 52 - .../utils/config/serializer/Serializers.java | 69 - .../spoocy/utils/config/serializer/Tag.java | 170 -- .../serializer/impl/AtomicsSerializer.java | 84 - .../serializer/impl/DurationSerializer.java | 41 - .../serializer/impl/JavaSerializer.java | 74 - .../utils/config/types/ConfigSettings.java | 68 +- .../spoocy/utils/config/types/JsonConfig.java | 10 +- .../utils/config/types/JsonSettings.java | 21 - .../utils/config/types/MemoryConfig.java | 9 +- .../spoocy/utils/config/types/YamlConfig.java | 70 +- .../utils/config/types/YamlSettings.java | 21 - config-utils/src/main/java/module-info.java | 5 +- .../config/AbstractAnnotatedConfigTest.java | 215 --- .../config/BaseResourceResolverTest.java | 253 +-- .../dev/spoocy/utils/config/ResourceTest.java | 37 + .../config/SafeResourceResolverTest.java | 92 - .../utils/config/bean/ConfigBeanLoadTest.java | 144 ++ .../utils/config/bean/ConfigBeanSaveTest.java | 109 ++ .../config/bean/ConfigBeanStrategiesTest.java | 153 ++ .../utils/config/bean/ConfigBeanTest.java | 48 + .../config/constructor/ConstructorTest.java | 429 +++++ .../config/io/ClassPathResourceTest.java | 1 + .../config/io/FileSystemResourceTest.java | 1 + .../utils/config/io/PathResourceTest.java | 1 + .../spoocy/utils/config/io/ResourceTest.java | 41 - .../loader/AnnotatedConfigLoaderTest.java | 108 -- .../config/representer/RepresenterTest.java | 390 +++++ .../serializer/AtomicsSerializerTest.java | 115 -- .../config/serializer/JavaSerializerTest.java | 89 - .../config/serializer/SerializerTest.java | 174 -- .../config/types/ConfigSettingsTest.java | 31 + .../spoocy/utils/config/types/ConfigTest.java | 1486 +++++++++++++++++ .../utils/config/types/JsonConfigTest.java | 285 +--- .../utils/config/types/MemoryConfigTest.java | 40 + .../config/types/SerializableExample.java | 55 - .../utils/config/types/YamlConfigTest.java | 249 +-- .../config/update/ConfigUpdaterTestBase.java | 2 +- .../update/MissingFieldsMigrationTest.java | 12 +- .../config/loader/annotation/serializable.yml | 2 +- .../spoocy/utils/config/types/example.json | 17 +- .../dev/spoocy/utils/config/types/example.yml | 16 +- .../utils/config/types/map-example.json | 30 + .../spoocy/utils/config/types/map-example.yml | 17 + .../spoocy/utils/config/update/defaults.yml | 4 + pom.xml | 2 +- 124 files changed, 7319 insertions(+), 5474 deletions(-) delete mode 100644 bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializer.java delete mode 100644 bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java delete mode 100644 bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java delete mode 100644 bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/ItemStackSerializer.java rename config-utils/src/main/java/dev/spoocy/utils/config/{components => }/AbstractConfig.java (60%) rename config-utils/src/main/java/dev/spoocy/utils/config/{components => }/FileDocument.java (90%) create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/MemorySection.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/Tag.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/TagProcessor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundField.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundHook.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBean.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBeanLoader.java rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/ConfigIgnore.java (90%) rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/ConfigProperty.java (95%) rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/ConfigSource.java (78%) create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/DefaultPropertyLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/LoadStrategy.java rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/PostLoad.java (96%) rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/PostLoadResult.java (92%) rename config-utils/src/main/java/dev/spoocy/utils/config/{loader => bean}/PreLoad.java (95%) create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/bean/PropertyLoader.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/DefaultNodeConstructor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/NodeConstructor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/SafeConstructor.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonProcessor.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/CollectionNode.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/ConfigData.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/MemoryData.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/Node.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTree.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTuple.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeType.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/ScalarNode.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/nodes/SequenceNode.java create mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/SafeRepresenter.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/DelegateDeserialization.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializer.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java delete mode 100644 config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/ResourceTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanLoadTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanSaveTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanStrategiesTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/constructor/ConstructorTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/representer/RepresenterTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigSettingsTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigTest.java create mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/MemoryConfigTest.java delete mode 100644 config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.json create mode 100644 config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.yml diff --git a/bukkit-utils/pom.xml b/bukkit-utils/pom.xml index d268ad9..579dd63 100644 --- a/bukkit-utils/pom.xml +++ b/bukkit-utils/pom.xml @@ -66,12 +66,14 @@ io.papermc.paper paper-api 1.21.11-R0.1-SNAPSHOT + provided com.mojang authlib 3.16.29 + provided diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializer.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializer.java deleted file mode 100644 index 2903814..0000000 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializer.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.spoocy.utils.bukkit.serializers; - -import dev.spoocy.utils.config.serializer.Serializer; -import org.bukkit.configuration.serialization.ConfigurationSerializable; -import org.bukkit.configuration.serialization.ConfigurationSerialization; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class BukkitSerializer implements Serializer { - - private final Class clazz; - - public BukkitSerializer(@NotNull Class clazz) { - this.clazz = clazz; - } - - @Override - public @NotNull Map serialize(@NotNull V object) { - return object.serialize(); - } - - @Override - public @NotNull V deserialize(@NotNull Map map) { - try { - ConfigurationSerializable obj = ConfigurationSerialization.deserializeObject(map, clazz); - if (obj == null) { - throw new IllegalArgumentException("Deserialized object is null."); - } - return (V) obj; - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Failed to deserialize object: " + e.getMessage()); - } - } -} diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java deleted file mode 100644 index 5939d3c..0000000 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/BukkitSerializers.java +++ /dev/null @@ -1,125 +0,0 @@ -package dev.spoocy.utils.bukkit.serializers; - -import com.mojang.authlib.GameProfile; -import dev.spoocy.utils.config.serializer.ConfigSerializable; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import org.bukkit.Color; -import org.bukkit.FireworkEffect; -import org.bukkit.Location; -import org.bukkit.attribute.AttributeModifier; -import org.bukkit.block.banner.Pattern; -import org.bukkit.block.spawner.SpawnRule; -import org.bukkit.configuration.serialization.ConfigurationSerializable; -import org.bukkit.configuration.serialization.ConfigurationSerialization; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.potion.PotionEffect; -import org.bukkit.util.BlockVector; -import org.bukkit.util.BoundingBox; -import org.bukkit.util.Vector; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class BukkitSerializers extends NamedSerializers { - - private static final BukkitSerializers INSTANCE = new BukkitSerializers(); - - private final Map, Serializer> assignable = new ConcurrentHashMap<>(); - - private BukkitSerializers() { - super(); - this.register(GameProfile.class, GameProfileSerializer.INSTANCE); - this.registerAssignable(Inventory.class, InventorySerializer.INSTANCE); - this.registerBukkit(Vector.class); - this.registerBukkit(BlockVector.class); - this.registerBukkit(ItemStack.class); - this.registerBukkit(Color.class); - this.registerBukkit(PotionEffect.class); - this.registerBukkit(FireworkEffect.class); - this.registerBukkit(Pattern.class); - this.registerBukkit(Location.class); - try { - this.registerBukkit(AttributeModifier.class); - } catch (Throwable ignored) {} - try { - this.registerBukkit(BoundingBox.class); - } catch (Throwable ignored) {} - try { - this.registerBukkit(SpawnRule.class); - } catch (Throwable ignored) {} - } - - public static @NotNull BukkitSerializers getInstance() { - return INSTANCE; - } - - public static @Nullable Serializer resolveSerializer(@NotNull Class clazz) { - return INSTANCE.resolve(clazz); - } - - public static void registerConfigSerializable(@NotNull Class clazz) { - INSTANCE.register(clazz); - } - - public static void registerBukkitSerializer(@NotNull Class clazz) { - INSTANCE.registerBukkit(clazz); - } - - public static void registerAssignableSerializer(@NotNull Class clazz, @NotNull Serializer serializer) { - INSTANCE.registerAssignable(clazz, serializer); - } - - @Override - public void register(@NotNull Class clazz) { - super.register(clazz); - } - - @Override - @SuppressWarnings("unchecked") - public @Nullable Serializer resolve(@NotNull Class clazz) { - Serializer serializer = super.resolve(clazz); - if (serializer != null) return serializer; - - Class matchedType = null; - Serializer matchedSerializer = null; - - for (Map.Entry, Serializer> entry : this.assignable.entrySet()) { - Class assignableType = entry.getKey(); - if (!assignableType.isAssignableFrom(clazz)) { - continue; - } - - // Prefer the most specific assignable type when multiple serializers match. - if (matchedType == null || matchedType.isAssignableFrom(assignableType)) { - matchedType = assignableType; - matchedSerializer = entry.getValue(); - } - } - - return (Serializer) matchedSerializer; - - } - - public void registerBukkit(@NotNull Class clazz) { - String name = getBukkitName(clazz); - this.registerName(clazz, name); - this.register(clazz, new BukkitSerializer<>(clazz)); - } - - public void registerAssignable(@NotNull Class clazz, @NotNull Serializer serializer) { - this.assignable.put(clazz, serializer); - } - - private String getBukkitName(@NotNull Class clazz) { - return ConfigurationSerialization.getAlias(clazz); - } - -} diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/GameProfileSerializer.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/GameProfileSerializer.java index 5be5a93..8c87dfb 100644 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/GameProfileSerializer.java +++ b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/GameProfileSerializer.java @@ -4,7 +4,6 @@ import com.mojang.authlib.GameProfile; import com.mojang.authlib.properties.Property; import com.mojang.authlib.properties.PropertyMap; -import dev.spoocy.utils.config.serializer.Serializer; import org.jetbrains.annotations.NotNull; import java.lang.reflect.Type; @@ -16,58 +15,12 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public class GameProfileSerializer implements Serializer, JsonSerializer, JsonDeserializer { +public class GameProfileSerializer implements JsonSerializer, JsonDeserializer { public static final GameProfileSerializer INSTANCE = new GameProfileSerializer(); private GameProfileSerializer() { } - @Override - public @NotNull Map serialize(@NotNull GameProfile object) { - Map result = new HashMap<>(); - - if (object.getId() != null) { - result.put("id", object.getId()); - } - - if (object.getName() != null) { - result.put("name", object.getName()); - } - - if (!object.getProperties().isEmpty()) { - Map properties = new HashMap<>(); - for (Map.Entry entry : object.getProperties().entries()) { - properties.put(entry.getKey(), entry.getValue().getValue()); - } - result.put("properties", properties); - } - - return result; - } - - @Override - public @NotNull GameProfile deserialize(@NotNull Map map) { - UUID id = (UUID) map.get("id"); - String name = (String) map.get("name"); - GameProfile profile = new GameProfile(id, name); - - if (map.containsKey("properties")) { - Map properties = null; - - try { - properties = (Map) map.get("properties"); - } catch (IllegalArgumentException ignored) { } - - if(properties == null) { - for (Map.Entry entry : properties.entrySet()) { - profile.getProperties().put(entry.getKey(), new Property(entry.getKey(), (String) entry.getValue())); - } - } - } - - return profile; - } - @Override public JsonElement serialize(GameProfile gameProfile, Type type, JsonSerializationContext jsonSerializationContext) { JsonObject result = new JsonObject(); diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java deleted file mode 100644 index 952a7df..0000000 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java +++ /dev/null @@ -1,102 +0,0 @@ -package dev.spoocy.utils.bukkit.serializers; - -import dev.spoocy.utils.bukkit.biz.source_code.base64Coder.Base64Coder; -import dev.spoocy.utils.common.exceptions.WrappedException; -import dev.spoocy.utils.config.serializer.Serializer; -import org.bukkit.Bukkit; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; -import org.bukkit.util.io.BukkitObjectInputStream; -import org.bukkit.util.io.BukkitObjectOutputStream; -import org.jetbrains.annotations.NotNull; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.util.Map; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class InventorySerializer implements Serializer { - - public static final InventorySerializer INSTANCE = new InventorySerializer(); - - private InventorySerializer() { - super(); - } - - @Override - public @NotNull Map serialize(@NotNull Inventory object) { - try { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream); - - dataOutput.writeObject(object.getContents()); - dataOutput.close(); - - String base64 = Base64Coder.encodeLines(outputStream.toByteArray()); - - return Map.of( - "type", object.getType().name(), - "size", object.getSize(), - "base64", base64 - ); - } catch (Throwable e) { - WrappedException.rethrow(e); - } - - throw new IllegalStateException("No Handle"); - } - - @Override - public @NotNull Inventory deserialize(@NotNull Map map) { - if (!map.containsKey("type")) { - throw new IllegalArgumentException("Map does not contain key 'type'."); - } - if (!map.containsKey("size")) { - throw new IllegalArgumentException("Map does not contain key 'size'."); - } - if (!map.containsKey("base64")) { - throw new IllegalArgumentException("Map does not contain key 'base64'."); - } - - try { - String typeName = (String) map.get("type"); - int size = ((Number) map.get("size")).intValue(); - String base64 = (String) map.get("base64"); - - InventoryType type = InventoryType.valueOf(typeName); - - ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64Coder.decodeLines(base64)); - BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream); - ItemStack[] contents = (ItemStack[]) dataInput.readObject(); - dataInput.close(); - - Inventory inventory; - if (type == InventoryType.CHEST) { - if (size <= 0 || size > 54 || size % 9 != 0) { - throw new IllegalArgumentException("Invalid chest inventory size: " + size); - } - - inventory = Bukkit.createInventory(null, size); - } else { - inventory = Bukkit.createInventory(null, type); - } - - if (contents.length != inventory.getSize()) { - ItemStack[] resized = new ItemStack[inventory.getSize()]; - System.arraycopy(contents, 0, resized, 0, Math.min(contents.length, resized.length)); - contents = resized; - } - - inventory.setContents(contents); - return inventory; - } catch (Exception e) { - WrappedException.rethrow(e); - } - - throw new IllegalStateException("No Handle"); - } -} diff --git a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/ItemStackSerializer.java b/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/ItemStackSerializer.java deleted file mode 100644 index 32db923..0000000 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/ItemStackSerializer.java +++ /dev/null @@ -1,68 +0,0 @@ -package dev.spoocy.utils.bukkit.serializers; - -import dev.spoocy.utils.bukkit.biz.source_code.base64Coder.Base64Coder; -import dev.spoocy.utils.common.exceptions.WrappedException; -import dev.spoocy.utils.config.serializer.Serializer; -import org.bukkit.inventory.ItemStack; -import org.bukkit.util.io.BukkitObjectInputStream; -import org.bukkit.util.io.BukkitObjectOutputStream; -import org.jetbrains.annotations.NotNull; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.util.Map; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class ItemStackSerializer implements Serializer { - - public static final ItemStackSerializer INSTANCE = new ItemStackSerializer(); - - private ItemStackSerializer() { - super(); - } - - @Override - public @NotNull Map serialize(@NotNull ItemStack object) { - try { - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream); - - dataOutput.writeObject(object); - dataOutput.close(); - - String base64 = Base64Coder.encodeLines(outputStream.toByteArray()); - - return Map.of("base64", base64); - } catch (Throwable e) { - WrappedException.rethrow(e); - } - - throw new IllegalStateException("No Handle"); - } - - @Override - public @NotNull ItemStack deserialize(@NotNull Map map) { - if(!map.containsKey("base64")) { - throw new IllegalArgumentException("Map does not contain key 'base64'."); - } - - try { - - String base64 = (String) map.get("base64"); - - ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64Coder.decodeLines(base64)); - BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream); - ItemStack item = (ItemStack) dataInput.readObject(); - dataInput.close(); - - return item; - } catch (Exception e) { - WrappedException.rethrow(e); - } - - throw new IllegalStateException("No Handle"); - } -} diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/misc/ClassFinder.java b/common-utils/src/main/java/dev/spoocy/utils/common/misc/ClassFinder.java index 8dfc499..c12ce68 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/misc/ClassFinder.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/misc/ClassFinder.java @@ -1,5 +1,8 @@ package dev.spoocy.utils.common.misc; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + /** * @author Spoocy99 | GitHub: Spoocy99 */ @@ -8,10 +11,12 @@ public class ClassFinder extends SecurityManager { private static final ClassFinder INSTANCE = new ClassFinder(); + @NotNull public static String callingClassName() { return callingClassName(2); } + @NotNull public static String callingClassName(final int depth) { StackTraceElement[] elements = Thread.currentThread().getStackTrace(); if (elements.length <= depth) { diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java b/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java index 4eea2e8..b7e10b9 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/misc/FileUtils.java @@ -364,6 +364,13 @@ public static URL toRelativeURL(URL root, String relativePath) throws MalformedU return new URL(root, FileUtils.cleanPath(FileUtils.applyRelativePath(root.toString(), relativePath))); } + /** + * Converts the package name of a given class into a path-like resource representation. + * If the class is null or does not belong to any package, an empty string will be returned. + * + * @param clazz the class whose package name will be converted; can be null + * @return the package name as a resource path with path separators, or an empty string if the class is null or not in a package + */ public static String classPackageAsResourcePath(@Nullable Class clazz) { if (clazz == null) { return ""; diff --git a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java index fa86461..169a766 100644 --- a/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java +++ b/common-utils/src/main/java/dev/spoocy/utils/common/scheduler/JavaScheduler.java @@ -1,5 +1,6 @@ package dev.spoocy.utils.common.scheduler; +import dev.spoocy.utils.common.misc.Args; import dev.spoocy.utils.common.scheduler.task.CompletableTask; import dev.spoocy.utils.common.scheduler.task.Task; import org.jetbrains.annotations.NotNull; @@ -21,9 +22,9 @@ public JavaScheduler(int corePoolSize) { this(Executors.newCachedThreadPool(), Executors.newScheduledThreadPool(corePoolSize)); } - public JavaScheduler(ExecutorService executorService, ScheduledExecutorService scheduledExecutorService) { - this.executorService = executorService; - this.scheduledExecutorService = scheduledExecutorService; + public JavaScheduler(@NotNull ExecutorService executorService, @NotNull ScheduledExecutorService scheduledExecutorService) { + this.executorService = Args.notNull(executorService, "executorService"); + this.scheduledExecutorService = Args.notNull(scheduledExecutorService, "scheduledExecutorService"); } @Override diff --git a/common-utils/src/test/java/ClassFinderTest.java b/common-utils/src/test/java/ClassFinderTest.java index de3ba6a..25dc578 100644 --- a/common-utils/src/test/java/ClassFinderTest.java +++ b/common-utils/src/test/java/ClassFinderTest.java @@ -1,4 +1,5 @@ import dev.spoocy.utils.common.misc.ClassFinder; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -18,6 +19,7 @@ void testCallingClassName() { } // Helper method to add an extra stack frame + @NotNull private String helperCallingClassName() { return ClassFinder.callingClassName(); } @@ -28,6 +30,7 @@ void testCallingClassNameWithDepth() { assertEquals(this.getClass().getName(), className); } + @NotNull private String helperCallingClassName(int depth) { return ClassFinder.callingClassName(depth); } diff --git a/config-utils/pom.xml b/config-utils/pom.xml index 3122ab2..b9592cf 100644 --- a/config-utils/pom.xml +++ b/config-utils/pom.xml @@ -33,12 +33,14 @@ org.json json true + compile org.yaml snakeyaml true + compile diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java similarity index 60% rename from config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java rename to config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java index 3dbe06b..25fc1b0 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java @@ -1,21 +1,16 @@ -package dev.spoocy.utils.config.components; +package dev.spoocy.utils.config; import dev.spoocy.utils.common.misc.Args; import dev.spoocy.utils.common.misc.FileUtils; -import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.ConfigSection; -import dev.spoocy.utils.config.Document; import dev.spoocy.utils.config.io.Resource; import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.nodes.*; import dev.spoocy.utils.config.representer.Representer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.*; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -57,12 +52,12 @@ public void save(@NotNull WriteableResource file, @NotNull Representer represent } @Override - public List getFooterComments() { + public @NotNull List getFooterComments() { return this.footer; } @Override - public List getHeaderComments() { + public @NotNull List getHeaderComments() { return this.header; } @@ -76,14 +71,54 @@ public void setFooterComments(@Nullable List comments) { this.footer = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); } - protected static Map serializedValues( - @NotNull ConfigSection section, - @NotNull Representer representer - ) { - if (section instanceof MemorySection) { - return representer.represent((MemorySection) section); + @NotNull + protected Map representAsMap(@NotNull Representer representer) { + NodeTree tree = representer.createTree(this); + return unpackMap(tree); + } + + protected Object unpack(@NotNull Node node) { + if (node instanceof NodeTree) { + return unpackMap((NodeTree) node); + } + + if (node instanceof SequenceNode) { + return unpackSequence((SequenceNode) node); + } + + if (node instanceof ScalarNode) { + return unpackScalar((ScalarNode) node); } - return new LinkedHashMap<>(); + + throw new IllegalStateException("Unsupported node type: " + node.getClass().getName()); + } + + protected Map unpackMap(@NotNull NodeTree tree) { + Map values = new LinkedHashMap<>(); + + for (NodeTuple tuple : tree) { + Object key = unpack(tuple.getKeyNode()); + Object value = unpack(tuple.getValueNode()); + values.put(key, value); + } + + return values; + } + + protected Object unpackSequence(@NotNull SequenceNode node) { + List values = new LinkedList<>(); + + for (Node item : node) { + Object value = unpack(item); + values.add(value); + } + + return values; + } + + @Nullable + protected Object unpackScalar(@NotNull ScalarNode node) { + return node.getData(); } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java index 39ef309..b1840df 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java @@ -2,21 +2,13 @@ import dev.spoocy.utils.common.misc.Args; import dev.spoocy.utils.common.misc.FileUtils; -import dev.spoocy.utils.config.constructor.Constructor; import dev.spoocy.utils.config.io.ClassPathResource; import dev.spoocy.utils.config.io.FileSystemResource; import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.io.WriteableResource; -import dev.spoocy.utils.config.loader.AnnotatedConfigLoader; import dev.spoocy.utils.config.loader.ConfigLoader; -import dev.spoocy.utils.config.loader.ConfigSource; -import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.update.ConfigUpdater; -import dev.spoocy.utils.reflection.Reflection; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -26,7 +18,6 @@ public class BaseResourceResolver implements ResourceResolver { - private static final AnnotatedConfigLoader CONFIG_LOADER = new AnnotatedConfigLoader(); private static final String CLASSPATH_FILE_PREFIX = "classpath:"; private static final String EXTERNAL_FILE_PREFIX = "file:"; @@ -51,12 +42,28 @@ public void registerLoader(@NotNull ConfigLoader loader) { } } - protected Config createEmpty(@NotNull Resource resource) { + @Override + public @NotNull Config createEmpty(@NotNull Resource resource) { return requireLoader(resource).createEmpty(); } + @Override + public @Nullable ClassLoader getClassLoader() { + return this.classLoader; + } + @Override public @NotNull Resource resolve(@NotNull String location) { + Resource resource = getByPrefix(location); + if (resource != null) { + return resource; + } + + return Resources.fromPath(location); + } + + @Nullable + protected Resource getByPrefix(@NotNull String location) { Args.notNull(location, "location"); if (location.startsWith(CLASSPATH_FILE_PREFIX)) { @@ -68,179 +75,27 @@ protected Config createEmpty(@NotNull Resource resource) { String path = location.substring(EXTERNAL_FILE_PREFIX.length()); return new FileSystemResource(path); } - - return Resources.fromPath(location); + return null; } @Nullable - protected ConfigLoader resolveLoader(@NotNull Resource resource) { + protected ConfigLoader resolveLoaderByExtension(@NotNull String extension) { + return this.loaders.get(extension.toLowerCase()); + } + + @Override + public @Nullable ConfigLoader resolveLoader(@NotNull Resource resource) { String filename = resource.getFilename(); String extension = filename == null ? "" : FileUtils.getFileExtension(filename); return resolveLoaderByExtension(extension); } - @Nullable - protected ConfigLoader resolveLoaderByExtension(@NotNull String extension) { - return this.loaders.get(extension.toLowerCase()); - } - - @NotNull - protected ConfigLoader requireLoader(@NotNull Resource resource) { + @Override + public @NotNull ConfigLoader requireLoader(@NotNull Resource resource) { ConfigLoader loader = resolveLoader(resource); if (loader == null) { throw new IllegalArgumentException("No config loader for resource " + resource); } return loader; } - - @Override - public @NotNull Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { - Args.notNull(resource, "resource"); - Args.notNull(constructor, "constructor"); - - ConfigLoader loader = requireLoader(resource); - return loader.load(resource, constructor); - } - - @Override - public @NotNull Config loadConfig( - @NotNull Resource resource, - @NotNull Constructor constructor, - @NotNull ConfigUpdater updater - ) throws IOException { - Config config = loadConfig(resource, constructor); - updater.run(config); - return config; - } - - @Override - public @NotNull Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor) - throws IOException { - return loadConfig(resource, constructor).withRelation(resource); - } - - @Override - public @NotNull Document loadDocument( - @NotNull Resource resource, - @NotNull Constructor constructor, - @NotNull ConfigUpdater updater - ) throws IOException { - Document document = loadDocument(resource, constructor); - - if (updater.run(document) > 0) { - - Resource relation = document.getRelation(); - - if (relation instanceof WriteableResource) { - document.save((WriteableResource) relation); - } - - } - - return document; - } - - @Override - public @NotNull T load(@NotNull Class type, @NotNull Readable readable) { - return CONFIG_LOADER.load(type, readable); - } - - @Override - public @NotNull T load(@NotNull Class type, @NotNull Constructor constructor) throws IOException { - Args.notNull(type, "type"); - Args.notNull(constructor, "constructor"); - - ConfigSource source = requireSource(type); - Resource resource = resolveSourceResource(type, source); - return load(type, constructor, resource, source); - } - - @Override - public @NotNull T load(@NotNull Class type, @NotNull Constructor constructor, @NotNull Resource resource) - throws IOException { - Args.notNull(type, "type"); - Args.notNull(constructor, "constructor"); - Args.notNull(resource, "resource"); - - ConfigSource source = Reflection.getAnnotation(type, ConfigSource.class, false); - return load(type, constructor, resource, source); - } - - @NotNull - private T load( - @NotNull Class type, - @NotNull Constructor constructor, - @NotNull Resource resource, - @Nullable ConfigSource source - ) throws IOException { - Document document = loadAnnotatedDocument(resource, constructor, source); - AnnotatedConfigLoader.LoadResult result = CONFIG_LOADER.loadResult(type, document); - - if (result.mutations() > 0) { - Resource relation = document.getRelation(); - if (relation instanceof WriteableResource) { - document.save((WriteableResource) relation); - } - } - - return result.instance(); - } - - @NotNull - private Document loadAnnotatedDocument( - @NotNull Resource resource, - @NotNull Constructor constructor, - @Nullable ConfigSource source - ) throws IOException { - if (source != null && source.allowMissingResource() && !resource.exists()) { - return createEmpty(resource).withRelation(resource); - } - - return loadDocument(resource, constructor); - } - - @Override - public void save(@NotNull Object instance, @NotNull Representer representer) throws IOException { - Args.notNull(instance, "instance"); - Args.notNull(representer, "representer"); - - ConfigSource source = requireSource(instance.getClass()); - Resource resource = resolveSourceResource(instance.getClass(), source); - save(instance, representer, resource); - } - - @Override - public void save(@NotNull Object instance, @NotNull Representer representer, @NotNull Resource resource) throws IOException { - Args.notNull(instance, "instance"); - Args.notNull(resource, "resource"); - - Document document = createEmpty(resource).withRelation(resource); - Resource relation = document.getRelation(); - - if (!(relation instanceof WriteableResource)) { - throw new IllegalArgumentException("Resource is not writeable: " + resource); - } - - CONFIG_LOADER.write(instance, document); - document.save((WriteableResource) relation, representer); - } - - @NotNull - private static ConfigSource requireSource(@NotNull Class type) { - ConfigSource source = Reflection.getAnnotation(type, ConfigSource.class, false); - if (source == null) { - throw new IllegalArgumentException("Missing @ConfigSource for class: " + type.getName()); - } - return source; - } - - @NotNull - private Resource resolveSourceResource(@NotNull Class type, @NotNull ConfigSource source) { - String location = source.value().trim(); - if (location.isEmpty()) { - throw new IllegalArgumentException("Missing @ConfigSource(value) for class: " + type.getName()); - } - - return resolve(location); - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java index f8a5f59..64edf4d 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Commentable.java @@ -16,6 +16,7 @@ public interface Commentable { * * @return a list of header comments, or an empty list if there are no header comments. */ + @NotNull List getHeaderComments(); /** @@ -23,6 +24,7 @@ public interface Commentable { * * @return a list of footer comments, or an empty list if there are no footer comments. */ + @NotNull List getFooterComments(); /** @@ -32,6 +34,7 @@ public interface Commentable { * * @return a list of comments for the specified path, or an empty list if there are no comments for the specified path. */ + @NotNull List getComments(@NotNull final String path); /** @@ -41,6 +44,7 @@ public interface Commentable { * * @return a list of inline comments for the specified path, or an empty list if there are no inline comments for the specified path. */ + @NotNull List getInlineComments(@NotNull final String path); /** diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Config.java b/config-utils/src/main/java/dev/spoocy/utils/config/Config.java index c954df3..ca79e37 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Config.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Config.java @@ -23,47 +23,23 @@ public interface Config extends ConfigSection { ConfigSettings settings(); /** - * Serializes the config into a string representation using the default representer. + * Serializes the configuration data into a string format using the provided representer. * - * @return the string representation of the config + * @param representer the representer used to serialize the configuration data * - * @see ConfigSettings#representer(Representer) - */ - @NotNull - default String saveToString() { - return saveToString(this.settings().representer()); - } - - /** - * Serializes the config to a string representation using a custom representer. - * - * @param representer the representer to use for converting values to config nodes - * - * @return the string representation of the config + * @return the serialized string representation of the configuration data */ @NotNull String saveToString(@NotNull Representer representer); /** - * Saves the configuration to the specified writable resource using the default representer for serialization. - * - * @param file the writable resource where the configuration should be saved; must not be null - * - * @throws IOException if an error occurs while writing to the resource - * - * @see ConfigSettings#representer(Representer) - */ - default void save(@NotNull WriteableResource file) throws IOException { - save(file, this.settings().representer()); - } - - /** - * Saves the configuration to the specified writable resource using the provided representer for serialization. + * Saves the configuration data to the specified writable resource using the provided representer. + * This method serializes the configuration into a specific format and writes it to the provided file. * - * @param file the writable resource where the configuration should be saved; must not be null - * @param representer the representer used to serialize the configuration; must not be null + * @param file the writable resource where the configuration data will be saved + * @param representer the representer used for serializing the configuration data * - * @throws IOException if an error occurs while writing to the resource + * @throws IOException if an I/O error occurs during the save operation */ void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java index 5ff6c1b..419bf12 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java @@ -20,14 +20,6 @@ public interface ConfigSection extends Writeable, Readable { @NotNull String getName(); - /** - * Gets the path of this section. - * - * @return The path of this section. - */ - @NotNull - String getWorkingPath(); - /** * Gets the root config of this section. * @@ -46,9 +38,12 @@ public interface ConfigSection extends Writeable, Readable { /** * Determines whether the specified path represents a valid section. + * A section can exist even when a certain path has a value. * * @param path The path to check for being a section. Must not be null. * @return {@code true} if the specified path corresponds to a section, {@code false} otherwise. + * + * {@link #is(String, Class)} */ boolean isSection(@NotNull String path); @@ -58,15 +53,41 @@ public interface ConfigSection extends Writeable, Readable { * @param path The path of the section to get, relative to this section. * * @return The section at the specified path, or {@code null} if there is no section at the specified path. + * + * @throws IllegalArgumentException if there is a value at the specified path that is not a section. */ - @Nullable + @NotNull ConfigSection getSection(@NotNull String path); /** - * Retrieves the section at the specified path or an empty section if no section exists at the path. + * Gets the section at the specified path if it exists, + * or {@code null} if it does not exist. + * + * @param path The path of the section to get, relative to this section. + * + * @return The section at the specified path, or {@code null}. + */ + @Nullable + ConfigSection getSectionIfExists(@NotNull String path); + + /** + * Gets the section at the specified path if it exists, + * or an empty section if it does not exist. * - * @param path The path to retrieve the section from. Must not be null. - * @return The section at the specified path if it exists, or an empty section otherwise. Never null. + * @param path The path of the section to get, relative to this section. + * + * @return The section at the specified path, or an empty section. + */ + @NotNull + ConfigSection getSectionOrEmpty(@NotNull String path); + + /** + * Gets the section at the specified path, + * or creates a new section if it does not exist. + * + * @param path The path of the section to get or create, relative to this section. + * + * @return The section at the specified path, or a newly created section. */ @NotNull ConfigSection getOrCreateSection(@NotNull String path); @@ -77,8 +98,6 @@ public interface ConfigSection extends Writeable, Readable { * @param path The path of the section to create, relative to this section. * * @return The newly created section. - * - * @throws IllegalArgumentException if there is already a object at the specified path. */ @NotNull ConfigSection createSection(@NotNull String path); @@ -90,8 +109,6 @@ public interface ConfigSection extends Writeable, Readable { * @param map The data to initialize the newly created section with. * * @return The newly created section. - * - * @throws IllegalArgumentException if there is already an object at the specified path. */ @NotNull ConfigSection createSection(@NotNull String path, @NotNull Map map); diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Document.java b/config-utils/src/main/java/dev/spoocy/utils/config/Document.java index fbbc37c..a45d7dd 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Document.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Document.java @@ -2,7 +2,6 @@ import dev.spoocy.utils.config.io.Resource; import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.types.ConfigSettings; import org.jetbrains.annotations.NotNull; import java.io.IOException; @@ -22,11 +21,24 @@ public interface Document extends Config { Resource getRelation(); /** - * Saves the file to the documents location. + * Returns a new {@link Config} instance without the associated relation. + * This method is used to create a standalone configuration, + * independent of any linked or related resources. * - * @throws IOException if an error occurs while saving the file - * - * @see ConfigSettings#representer(Representer) + * @return a new {@link Config} instance without an associated relation */ - void save() throws IOException; + @NotNull + Config withoutRelation(); + + /** + * Saves the current document state using the provided {@code Representer}. + *

+ * This method serializes the document into a suitable format as defined + * by the {@code Representer} implementation and writes it to the associated resource. + * + * @param representer the representer used to serialize the document data + * + * @throws IOException if an I/O error occurs during the save operation + */ + void save(@NotNull Representer representer) throws IOException; } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java b/config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java similarity index 90% rename from config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java rename to config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java index 19d233a..141c43b 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/FileDocument.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java @@ -1,9 +1,6 @@ -package dev.spoocy.utils.config.components; +package dev.spoocy.utils.config; import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.ConfigSection; -import dev.spoocy.utils.config.Document; import dev.spoocy.utils.config.io.Resource; import dev.spoocy.utils.config.io.WriteableResource; import dev.spoocy.utils.config.types.ConfigSettings; @@ -37,9 +34,14 @@ public FileDocument(@NotNull Config config, @NotNull Resource relation) { } @Override - public void save() throws IOException { + public @NotNull Config withoutRelation() { + return this.config; + } + + @Override + public void save(@NotNull Representer representer) throws IOException { if (this.relation instanceof WriteableResource) { - this.save((WriteableResource) this.relation, this.settings().representer()); + this.save((WriteableResource) this.relation, representer); return; } @@ -71,11 +73,6 @@ public void save(@NotNull WriteableResource file, @NotNull Representer represent return this.config.getName(); } - @Override - public @NotNull String getWorkingPath() { - return this.config.getWorkingPath(); - } - @Override public @NotNull Config getRoot() { return this.config.getRoot(); @@ -92,15 +89,25 @@ public boolean isSection(@NotNull String path) { } @Override - public @Nullable ConfigSection getSection(@NotNull String path) { + public @NotNull ConfigSection getSection(@NotNull String path) { return this.config.getSection(path); } + @Override + public @Nullable ConfigSection getSectionIfExists(@NotNull String path) { + return this.config.getSectionIfExists(path); + } + @Override public @NotNull ConfigSection getOrCreateSection(@NotNull String path) { return this.config.getOrCreateSection(path); } + @Override + public @NotNull ConfigSection getSectionOrEmpty(@NotNull String path) { + return this.config.getSectionOrEmpty(path); + } + @Override public @NotNull ConfigSection createSection(@NotNull String path) { return this.config.createSection(path); @@ -136,16 +143,6 @@ public boolean is(@NotNull String path, @NotNull Class clazz) { return this.config.is(path, clazz); } - @Override - public T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - return this.config.getSerializable(path, clazz, defaultValue); - } - - @Override - public @Nullable T getSerializable(@NotNull String path, @NotNull Class clazz) { - return this.config.getSerializable(path, clazz); - } - @Override public boolean isString(@NotNull String path) { return this.config.isString(path); @@ -357,22 +354,22 @@ public void subtract(@NotNull String path, double value) { } @Override - public List getHeaderComments() { + public @NotNull List getHeaderComments() { return this.config.getHeaderComments(); } @Override - public List getFooterComments() { + public @NotNull List getFooterComments() { return this.config.getFooterComments(); } @Override - public List getComments(@NotNull String path) { + public @NotNull List getComments(@NotNull String path) { return this.config.getComments(path); } @Override - public List getInlineComments(@NotNull String path) { + public @NotNull List getInlineComments(@NotNull String path) { return this.config.getInlineComments(path); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/MemorySection.java b/config-utils/src/main/java/dev/spoocy/utils/config/MemorySection.java new file mode 100644 index 0000000..bd0e582 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/MemorySection.java @@ -0,0 +1,708 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.common.misc.NumberConversion; +import dev.spoocy.utils.common.tuple.Pair; +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.nodes.ConfigData; +import dev.spoocy.utils.config.nodes.MemoryData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class MemorySection extends ConfigData implements ConfigSection { + + protected final Map dataMap = new LinkedHashMap<>(); + private final Config root; + private final ConfigSection parent; + private final String path; + + public MemorySection() { + super(null, null); + + if (!(this instanceof Config)) { + throw new IllegalStateException("MemorySection must be a Config if no parent is provided"); + } + + this.root = (Config) this; + this.parent = null; + this.path = ""; + } + + public MemorySection(@NotNull ConfigSection parent, @NotNull String path) { + super(null, null); + + this.parent = Args.notNull(parent, "parent"); + this.path = Args.notNull(path, "path"); + this.root = parent.getRoot(); + } + + @Override + public @NotNull String getName() { + return this.path; + } + + @Override + public @NotNull Config getRoot() { + return this.root; + } + + @Override + public @Nullable ConfigSection getParent() { + return this.parent; + } + + private @NotNull String pathSeparator() { + return String.valueOf(this.root.settings().pathSeparator()); + } + + private @NotNull String[] splitPath(@NotNull String path) { + return path.split(Pattern.quote(pathSeparator())); + } + + public List> entries() { + List> entries = new ArrayList<>(); + + for (Map.Entry entry : this.dataMap.entrySet()) { + entries.add(new Pair<>(entry.getKey(), entry.getValue())); + } + + return entries; + } + + @Override + public @NotNull List getHeaderComments() { + return this.root.getHeaderComments(); + } + + @Override + public @NotNull List getFooterComments() { + return this.root.getFooterComments(); + } + + @Override + public void setHeaderComments(@Nullable List comments) { + this.root.setHeaderComments(comments); + } + + @Override + public void setFooterComments(@Nullable List comments) { + this.root.setFooterComments(comments); + } + + @Override + public @NotNull List getComments() { + return super.getComments(); + } + + @Override + public @NotNull List getInlineComments() { + return super.getInlineComments(); + } + + @Override + public void setComments(@Nullable List comments) { + super.setComments(comments); + } + + @Override + public void setInlineComments(@Nullable List inlineComments) { + super.setInlineComments(inlineComments); + } + + @Override + public @NotNull List getComments(@NotNull String path) { + ConfigData data = getMapData(path); + return data != null ? data.getComments() : Collections.emptyList(); + } + + @Override + public @NotNull List getInlineComments(@NotNull String path) { + ConfigData data = getMapData(path); + return data != null ? data.getInlineComments() : Collections.emptyList(); + } + + @Override + public void setComments(@NotNull String path, @Nullable List comments) { + ConfigData data = getMapData(path); + if (data != null) { + data.setComments(comments); + } + } + + @Override + public void setInlineComments(@NotNull String path, @Nullable List comments) { + ConfigData data = getMapData(path); + if (data != null) { + data.setInlineComments(comments); + } + } + + @Nullable + private ConfigData getMapData(@NotNull String path) { + String[] parts = splitPath(path); + + if(parts.length == 1) { + return this.dataMap.get(path); + } + + ConfigData sectionData = this.dataMap.get(parts[0]); + if(sectionData instanceof MemorySection) { + String subPath = path.substring(parts[0].length() + 1); + return ((MemorySection) sectionData).getMapData(subPath); + } + + return null; + } + + @Override + public boolean isSection(@NotNull String path) { + return getMapData(path) instanceof MemorySection; + } + + @Override + public @NotNull MemorySection getSection(@NotNull String path) { + ConfigData data = getMapData(path); + if (data instanceof MemorySection) { + return (MemorySection) data; + } + + throw new IllegalArgumentException("Path '" + path + "' is not a section"); + } + + @Override + public @Nullable ConfigSection getSectionIfExists(@NotNull String path) { + try { + return getSection(path); + } catch (IllegalArgumentException ex) { + return null; + } + } + + @Override + public @NotNull ConfigSection getSectionOrEmpty(@NotNull String path) { + try { + return getSection(path); + } catch (IllegalArgumentException ex) { + return new MemorySection(this, path); + } + } + + @Override + public @NotNull ConfigSection getOrCreateSection(@NotNull String path) { + try { + return getSection(path); + } catch (IllegalArgumentException ex) { + return createSection(path); + } + } + + @Override + public @NotNull MemorySection createSection(@NotNull String path) { + String[] parts = splitPath(path); + ConfigData current = this.dataMap.get(parts[0]); + + if(parts.length == 1) { + + MemorySection section = new MemorySection(this, path); + this.dataMap.put(path, section); + return section; + } + + MemorySection next; + + if(current instanceof MemorySection) { + next = (MemorySection) current; + } else { + next = new MemorySection(this, parts[0]); + this.dataMap.put(parts[0], next); + } + + String subPath = path.substring(parts[0].length() + 1); + return next.createSection(subPath); + } + + @Override + public @NotNull MemorySection createSection(@NotNull String path, @NotNull Map map) { + MemorySection section = createSection(path); + section.applyMap(map); + return section; + } + + public void applyMap(@NotNull Map map) { + for (Map.Entry entry : map.entrySet()) { + + Object keyObj = entry.getKey(); + + if(keyObj == null) { + continue; + } + + String key = keyObj.toString(); + Object value = entry.getValue(); + + if(value instanceof Map) { + this.createSection(key, (Map) value); + continue; + } + + this.set(key, value); + } + } + + @Override + public boolean isSet(@NotNull String path) { + String[] parts = splitPath(path); + ConfigData current = this.dataMap.get(parts[0]); + + if(parts.length == 1) { + return current instanceof MemoryData; + } + + if(current instanceof MemorySection) { + String subPath = path.substring(parts[0].length() + 1); + return ((MemorySection) current).isSet(subPath); + } + + return false; + } + + @Override + public void set(@NotNull String path, @Nullable Object value) { + String[] parts = splitPath(path); + ConfigData current = this.dataMap.get(parts[0]); + + if(parts.length == 1) { + + if(!(current instanceof MemoryData)) { + current = new MemoryData(null, null); + this.dataMap.put(path, current); + } + + ((MemoryData) current).setData(value); + return; + } + + if(!(current instanceof MemorySection)) { + current = new MemorySection(this, parts[0]); + this.dataMap.put(parts[0], current); + } + + String subPath = path.substring(parts[0].length() + 1); + ((MemorySection) current).set(subPath, value); + } + + @Override + public void remove(@NotNull String path) { + String[] parts = splitPath(path); + ConfigData current = this.dataMap.get(parts[0]); + + if(parts.length == 1) { + this.dataMap.remove(path); + return; + } + + if(current instanceof MemorySection) { + String subPath = path.substring(parts[0].length() + 1); + ((MemorySection) current).remove(subPath); + } + } + + @Override + public void clear() { + this.dataMap.clear(); + } + + @Override + public @Nullable Object getObject(@NotNull String path) { + String[] parts = splitPath(path); + ConfigData current = this.dataMap.get(parts[0]); + + if(parts.length == 1) { + if(current instanceof MemoryData) { + return ((MemoryData) current).getData(); + } + + return null; + } + + if(current instanceof MemorySection) { + String subPath = path.substring(parts[0].length() + 1); + return ((MemorySection) current).getObject(subPath); + } + + return null; + } + + @Override + public Collection keys(boolean deep) { + Set keys = new LinkedHashSet<>(); + + for (Map.Entry entry : this.dataMap.entrySet()) { + String key = entry.getKey(); + keys.add(key); + + if (deep && entry.getValue() instanceof MemorySection) { + ConfigSection section = (ConfigSection) entry.getValue(); + for (String subKey : section.keys(true)) { + keys.add(key + root.settings().pathSeparator() + subKey); + } + } + } + + return keys; + } + + @Override + public Map values(boolean deep) { + Map values = new LinkedHashMap<>(); + + for (Map.Entry entry : this.dataMap.entrySet()) { + String key = entry.getKey(); + ConfigData data = entry.getValue(); + + if(data instanceof MemoryData) { + values.put(key, ((MemoryData) data).getData()); + continue; + } + + if (deep && data instanceof MemorySection) { + ConfigSection section = (ConfigSection) entry.getValue(); + for (Map.Entry subEntry : section.values(true) + .entrySet()) { + values.put(key + root.settings().pathSeparator() + subEntry.getKey(), subEntry.getValue()); + } + } + } + + return values; + } + + @Override + public void opposite(@NotNull String path) { + set(path, !getBoolean(path)); + } + + @Override + public void multiply(@NotNull String path, double value) { + set(path, getDouble(path) * value); + } + + @Override + public void divide(@NotNull String path, double value) { + set(path, getDouble(path) / value); + } + + @Override + public void add(@NotNull String path, double value) { + set(path, getDouble(path) + value); + } + + @Override + public void subtract(@NotNull String path, double value) { + set(path, getDouble(path) - value); + } + + @Override + public Object getObject(@NotNull String path, @Nullable Object defaultValue) { + Object value = getObject(path); + return value != null ? value : defaultValue; + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz) { + Object value = this.getObject(path); + if (value == null) { + return null; + } + + if (Number.class.isAssignableFrom(clazz)) { + return NumberConversion.convert(value, clazz); + } + return clazz.isInstance(value) ? clazz.cast(value) : null; + } + + @Override + public T get(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + T value = get(path, clazz); + return value != null ? value : defaultValue; + } + + @Override + public boolean is(@NotNull String path, @NotNull Class clazz) { + Object object = getObject(path, clazz); + return clazz.isInstance(object); + } + + @Override + public boolean isString(@NotNull String path) { + return getObject(path) instanceof String; + } + + @Override + public String getString(@NotNull String path, @Nullable String defaultValue) { + Object value = this.getObject(path, defaultValue); + return value != null ? value.toString() : defaultValue; + } + + @Override + public boolean isInt(@NotNull String path) { + return getObject(path) instanceof Integer; + } + + @Override + public int getInt(@NotNull String path, int defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toInt(value) : defaultValue; + } + + @Override + public boolean isDouble(@NotNull String path) { + return getObject(path) instanceof Double; + } + + @Override + public double getDouble(@NotNull String path, double defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toDouble(value) : defaultValue; + } + + @Override + public boolean isFloat(@NotNull String path) { + return getObject(path) instanceof Float; + } + + @Override + public float getFloat(@NotNull String path, float defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toFloat(value) : defaultValue; + } + + @Override + public boolean isLong(@NotNull String path) { + return getObject(path) instanceof Long; + } + + @Override + public long getLong(@NotNull String path, long defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Number ? NumberConversion.toLong(value) : defaultValue; + } + + @Override + public boolean isBoolean(@NotNull String path) { + Object value = getObject(path, Boolean.class); + return value instanceof Boolean; + } + + @Override + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + Object value = this.getObject(path, defaultValue); + return value instanceof Boolean ? (boolean) value : defaultValue; + } + + @Override + public Class getClass(@NotNull String path, @Nullable Class defaultValue) { + try { + return Class.forName(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { + try { + return Enum.valueOf(clazz, getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { + try { + return UUID.fromString(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { + try { + return Version.parse(getString(path)); + } catch (Exception ex) { + return defaultValue; + } + } + + @Override + public boolean isList(@NotNull String path) { + Object value = getObject(path); + return value instanceof List; + } + + @Override + public List getList(@NotNull String path, @Nullable List defaultValue) { + Object value = getObject(path, defaultValue); + return (List) (value instanceof List ? value : defaultValue); + } + + @Override + public List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { + List list = new ArrayList<>(); + List value = getList(path, new ArrayList<>()); + + if (value == null || value.isEmpty()) { + return list; + } + + for (Object object : value) { + if (clazz.isInstance(object)) { + list.add(clazz.cast(object)); + } + } + + return list; + } + + @Override + public List> getMapList(@NotNull String path) { + List list = getList(path, new ArrayList<>()); + List> mapList = new ArrayList<>(); + + if (list == null || list.isEmpty()) { + return mapList; + } + + for (Object object : list) { + if (object instanceof Map) { + @SuppressWarnings("unchecked") + Map map = (Map) object; + mapList.add(map); + } + } + + return mapList; + } + + @Override + public List getSectionList(@NotNull String path) { + List> list = this.getMapList(path); + final List sections = new ArrayList<>(); + + for (Map map : list) { + ConfigSection section = new MemorySection(this, path); + + for (Map.Entry entry : map.entrySet()) { + setSectionValue(section, entry.getKey(), entry.getValue()); + } + sections.add(section); + } + + return sections; + } + + private void setSectionValue(@NotNull ConfigSection section, @NotNull String key, @Nullable Object value) { + if (value instanceof Map) { + ConfigSection child = section.createSection(key); + + for (Map.Entry entry : ((Map) value).entrySet()) { + if (entry.getKey() instanceof String) { + setSectionValue(child, (String) entry.getKey(), entry.getValue()); + } + } + return; + } + + section.set(key, value); + } + + @Override + public @NotNull List getStringList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, Object::toString); + } + + @Override + public @NotNull List getBooleanList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toBoolean); + } + + @Override + public @NotNull List getIntegerList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toInt); + } + + @Override + public @NotNull List getDoubleList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toDouble); + } + + @Override + public @NotNull List getLongList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toLong); + } + + @Override + public @NotNull List getFloatList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toFloat); + } + + @Override + public @NotNull List getByteList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toByte); + } + + @Override + public @NotNull List getCharacterList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, object -> { + String string = object.toString(); + return string.isEmpty() ? null : string.charAt(0); + }); + } + + @Override + public @NotNull List getShortList(@NotNull String path) { + List list = this.getList(path, new ArrayList<>()); + return map(list, NumberConversion::toShort); + } + + @NotNull + private List map(@Nullable List list, @NotNull Function mapper) { + if (list == null) return Collections.emptyList(); + + List mapped = new ArrayList<>(); + for (Object object : list) { + + T value = null; + + try { + value = mapper.apply(object); + } catch (Throwable ignored) { + } + + if (value != null) { + mapped.add(value); + } + } + + return mapped; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java index 38c7624..ce02187 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Readable.java @@ -68,33 +68,6 @@ public interface Readable { */ boolean is(@NotNull String path, @NotNull Class clazz); - /** - * Retrieves a serializable object from the specified path. If the retrieved value is null or - * not serializable to the expected type, the provided default value will be returned. - * - * @param The type of the serializable object to retrieve. - * @param path The path to the value in the configuration. Must not be null. - * @param clazz The class type to which the object should be cast. Must not be null. - * @param defaultValue The default value to return if the retrieved object is null or cannot be serialized to the expected type. Can be null. - * - * @return The serializable object at the specified path, or the default value if the object - * is null or cannot be serialized to the expected type. - */ - T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue); - - /** - * Retrieves a serializable object from the specified path in the configuration. - * - * @param path the path to the object in the configuration (not null) - * @param clazz the class type to which the object should be cast (not null) - * @param the type of the object to be retrieved - * - * @return the deserialized object of the specified type, or {@code null} if the object - * is not found, cannot be cast to the specified type, or deserialization fails - */ - @Nullable - T getSerializable(@NotNull String path, @NotNull Class clazz); - /** * Checks if the value at the specified path is a {@link String}. * @@ -298,7 +271,7 @@ default Version getVersion(@NotNull String path) { } /** - * Checks if the value at the specified path is {@link Set} + * Checks if the value at the specified path exists. * * @param path the path to check * @@ -325,7 +298,9 @@ default Version getVersion(@NotNull String path) { * @return the value at the specified path as a List, or {@code null} if the path does not exist, is not set, or cannot be converted to a List */ @Nullable - List getList(@NotNull String path); + default List getList(@NotNull String path) { + return getList(path, null); + } /** * Gets the value at the specified path as a List, diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java index d87d893..cdd11fd 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java @@ -1,14 +1,11 @@ package dev.spoocy.utils.config; -import dev.spoocy.utils.config.constructor.Constructor; import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.ConfigLoader; import dev.spoocy.utils.config.loader.JsonConfigLoader; import dev.spoocy.utils.config.loader.YamlConfigLoader; -import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.update.ConfigUpdater; import org.jetbrains.annotations.NotNull; - -import java.io.IOException; +import org.jetbrains.annotations.Nullable; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -21,18 +18,18 @@ public interface ResourceResolver { JsonConfigLoader.INSTANCE ); - SafeResourceResolver SAFE = new SafeResourceResolver(Resources.class.getClassLoader(), - YamlConfigLoader.INSTANCE, - JsonConfigLoader.INSTANCE - ); - static BaseResourceResolver defaultResolver() { return DEFAULT; } - static SafeResourceResolver safeResolver() { - return SAFE; - } + /** + * Retrieves the {@link ClassLoader} associated with the current implementation of the resource resolver. + * + * @return the {@link ClassLoader} used for resource resolution, or null if no specific + * {@link ClassLoader} is associated. + */ + @Nullable + ClassLoader getClassLoader(); /** * Resolves a location string to a concrete {@link Resource}. @@ -44,31 +41,40 @@ static SafeResourceResolver safeResolver() { @NotNull Resource resolve(@NotNull String path); - @NotNull - Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException; - - @NotNull - Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor, @NotNull ConfigUpdater updater) - throws IOException; - - @NotNull - Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException; - - @NotNull - Document loadDocument(@NotNull Resource resource, @NotNull Constructor constructor, @NotNull ConfigUpdater updater) - throws IOException; - - @NotNull - T load(@NotNull Class type, @NotNull Readable readable); + /** + * Resolves a suitable {@link ConfigLoader} for the given {@link Resource}. The resulting loader + * is capable of parsing and handling the configuration data associated with the resource. + * + * @param resource the resource for which a configuration loader needs to be resolved; + * must not be null + * + * @return a {@link ConfigLoader} instance capable of processing the specified resource, + * or null if no suitable loader is found + */ + @Nullable + ConfigLoader resolveLoader(@NotNull Resource resource); + /** + * Resolves and retrieves a suitable {@link ConfigLoader} for the specified {@link Resource}. + * The resulting loader is guaranteed to be non-null and capable of parsing and handling + * configuration data associated with the given resource. + * + * @param resource the resource for which a configuration loader is required; + * must not be null + * @return a {@link ConfigLoader} instance capable of processing the specified resource; + * never null + * @throws IllegalArgumentException if no suitable loader is found for the resource + */ @NotNull - T load(@NotNull Class type, @NotNull Constructor constructor) throws IOException; + ConfigLoader requireLoader(@NotNull Resource resource); + /** + * Creates an empty configuration instance associated with the provided resource. + * + * @param resource the resource with which the empty configuration will be associated; + * must not be null + * @return a new instance of {@link Config} representing an empty configuration + */ @NotNull - T load(@NotNull Class type, @NotNull Constructor constructor, @NotNull Resource resource) throws IOException; - - void save(@NotNull Object instance, @NotNull Representer representer) throws IOException; - - void save(@NotNull Object instance, @NotNull Representer representer, @NotNull Resource resource) throws IOException; - + Config createEmpty(@NotNull Resource resource); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java b/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java index 4b2e8a2..1ac35c2 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java @@ -11,6 +11,7 @@ import dev.spoocy.utils.config.loader.JsonConfigLoader; import dev.spoocy.utils.config.loader.YamlConfigLoader; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; @@ -88,13 +89,13 @@ public static FileSystemResource fromFile(@NotNull FileSystem fileSystem, @NotNu } @NotNull - public static ClassPathResource fromJar(@NotNull String path, Class clazz) { + public static ClassPathResource fromJar(@NotNull String path, @Nullable Class clazz) { Args.notNull(path, "path"); return new ClassPathResource(path, clazz); } @NotNull - public static ClassPathResource fromJar(@NotNull String path, ClassLoader loader) { + public static ClassPathResource fromJar(@NotNull String path, @Nullable ClassLoader loader) { Args.notNull(path, "path"); return new ClassPathResource(path, loader); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java b/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java deleted file mode 100644 index 69e4eb9..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/SafeResourceResolver.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.spoocy.utils.config; - -import dev.spoocy.utils.common.misc.Args; -import dev.spoocy.utils.config.constructor.Constructor; -import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.loader.ConfigLoader; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class SafeResourceResolver extends BaseResourceResolver { - - public SafeResourceResolver( - @Nullable ClassLoader classLoader, - @NotNull ConfigLoader... loader - ) { - super(classLoader, loader); - } - - @Override - public @NotNull Config loadConfig(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { - Args.notNull(resource, "resource"); - Args.notNull(constructor, "constructor"); - - ConfigLoader loader = resolveLoader(resource); - if (loader == null) { - throw new IOException("No config loader for resource " + resource); - } - - if(!resource.exists()) { - return loader.createEmpty(); - } - - return loader.load(resource, constructor); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Tag.java b/config-utils/src/main/java/dev/spoocy/utils/config/Tag.java new file mode 100644 index 0000000..c5061b1 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Tag.java @@ -0,0 +1,178 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.misc.Args; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public final class Tag { + + public static final Set STANDARD_TAGS = new HashSet<>(8); + private static final Map>> COMPATIBILITY_MAP = new HashMap<>(6); + + public static final String PREFIX = "tag:type:"; + + public static final Tag NULL = forStandard("null", + Void.class, void.class + ); + public static final Tag STR = forStandard("str", + String.class + ); + public static final Tag BOOL = forStandard("bool", + boolean.class, Boolean.class + ); + public static final Tag INT = forStandard("int", + int.class, Integer.class, + long.class, Long.class, + BigInteger.class + ); + public static final Tag FLOAT = forStandard("float", + double.class, Double.class, + float.class, Float.class, + BigDecimal.class + ); + public static final Tag SET = forStandard("set"); + public static final Tag SEQ = forStandard("seq"); + public static final Tag MAP = forStandard("map"); + + @NotNull + private static Tag forStandard(@NotNull String tagName, @NotNull Class... compatibleTypes) { + + Tag tag = new Tag(PREFIX + tagName); + STANDARD_TAGS.add(tag); + + if (compatibleTypes.length != 0) { + COMPATIBILITY_MAP.put(tag, new HashSet<>(Arrays.asList(compatibleTypes))); + } + + return tag; + } + + private final String value; + private final boolean custom; + + public Tag(@NotNull String tag) { + this.value = Args.notNullOrEmpty(tag, "tag"); + + if (tag.trim().length() != tag.length()) { + throw new IllegalArgumentException("Tag must not contain leading or trailing spaces."); + } + + this.custom = !this.value.startsWith(PREFIX); + } + + public Tag(@NotNull Class clazz) { + Args.notNull(clazz, "class"); + this.value = PREFIX + clazz.getName(); + this.custom = false; + } + + public boolean isStandard() { + return STANDARD_TAGS.contains(this); + } + + public String getValue() { + return this.value; + } + + public boolean isCustom() { + return this.custom; + } + + public boolean startsWith(@NotNull String prefix) { + return this.value.startsWith(prefix); + } + + public String getClassName() { + if (this.custom) { + throw new IllegalStateException("Cannot get class name from a custom tag"); + } + + String name = value.substring(Tag.PREFIX.length()); + return URLDecoder.decode(name, StandardCharsets.UTF_8); + } + + public boolean isCompatible(@NotNull Class clazz) { + Set> set = COMPATIBILITY_MAP.get(this); + + if (set != null) { + return set.contains(clazz); + } + + if (!this.custom) { + String className = getClassName(); + return className.equals(clazz.getName()); + } + + return false; + } + + @Override + public String toString() { + return this.value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj instanceof Tag) { + Tag other = (Tag) obj; + return this.value.equals(other.value); + } + + return false; + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + public static Tag getDefaultTag(@NotNull Class clazz) { + if (NULL.isCompatible(clazz)) { + return NULL; + } + + if (INT.isCompatible(clazz)) { + return INT; + } + + if (FLOAT.isCompatible(clazz)) { + return FLOAT; + } + + if (BOOL.isCompatible(clazz)) { + return BOOL; + } + + if (STR.isCompatible(clazz)) { + return STR; + } + + if(Set.class.isAssignableFrom(clazz)) { + return Tag.SET; + } + + if(Iterable.class.isAssignableFrom(clazz) || clazz.isArray()) { + return Tag.SEQ; + } + + if(Map.class.isAssignableFrom(clazz)) { + return Tag.MAP; + } + + return new Tag(clazz); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/TagProcessor.java b/config-utils/src/main/java/dev/spoocy/utils/config/TagProcessor.java new file mode 100644 index 0000000..a1fc660 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/TagProcessor.java @@ -0,0 +1,14 @@ +package dev.spoocy.utils.config; + +import org.jetbrains.annotations.Nullable; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface TagProcessor { + + @Nullable + Tag process(@Nullable Object data); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java b/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java index e5dd3c3..d0b6899 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Writeable.java @@ -7,7 +7,7 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public interface Writeable extends Commentable { +public interface Writeable extends Readable, Commentable { void set(@NotNull String path, @Nullable Object value); diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundField.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundField.java new file mode 100644 index 0000000..be72900 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundField.java @@ -0,0 +1,172 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.Writeable; +import dev.spoocy.utils.reflection.Reflection; +import dev.spoocy.utils.reflection.accessor.Accessor; +import dev.spoocy.utils.reflection.accessor.FieldAccessor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class BoundField { + + private final FieldAccessor accessor; + + private final String fieldName; + private final String propertyKey; + private final boolean saveDefault; + private final String[] comments; + private final String[] inlineComments; + private final Class type; + private final Class collectionElementType; + private final PropertyLoader loader; + + @NotNull + public static BoundField of(@NotNull ConfigBean bean, @NotNull Field field) { + FieldAccessor accessor = Accessor.getField(field); + + String fieldName = field.getName(); + + ConfigProperty annotation = field.getAnnotation(ConfigProperty.class); + String propertyKey = annotation != null ? annotation.value() : toPropertyName(fieldName); + String[] comments = annotation != null ? annotation.comments() : new String[0]; + String[] inlineComments = annotation != null ? annotation.inlineComments() : new String[0]; + boolean saveDefault = annotation != null ? annotation.saveDefault() : bean.saveDefaults(); + + return new BoundField( + accessor, + fieldName, + propertyKey, + saveDefault, + comments, + inlineComments, + field.getType(), + Reflection.resolveCollectionElementType(field) + ); + } + + private static String toPropertyName(@NotNull String fieldName) { + StringBuilder builder = new StringBuilder(fieldName.length()); + for (int i = 0; i < fieldName.length(); i++) { + char c = fieldName.charAt(i); + + if (Character.isUpperCase(c)) { + builder.append('-'); + builder.append(Character.toLowerCase(c)); + } else { + builder.append(c); + } + + } + + return builder.toString(); + } + + private BoundField( + @NotNull FieldAccessor accessor, + @NotNull String fieldName, + @NotNull String propertyKey, + boolean saveDefault, + @NotNull String[] comments, + @NotNull String[] inlineComments, + @NotNull Class type, + @Nullable Class collectionElementType + ) { + this.accessor = accessor; + this.fieldName = fieldName; + this.propertyKey = propertyKey; + this.saveDefault = saveDefault; + this.comments = comments; + this.inlineComments = inlineComments; + this.type = type; + this.collectionElementType = collectionElementType; + this.loader = new DefaultPropertyLoader(); + } + + @NotNull + public String name() { + return this.fieldName; + } + + @NotNull + public String propertyKey() { + return this.propertyKey; + } + + public boolean shouldSaveDefault() { + return this.saveDefault; + } + + @Nullable + public String[] comments() { + return this.comments; + } + + @Nullable + public String[] inlineComments() { + return this.inlineComments; + } + + @NotNull + public Class type() { + return this.type; + } + + @Nullable + public Class collectionElementType() { + return this.collectionElementType; + } + + @Nullable + public Object get(@NotNull Object instance) { + return this.accessor.get(instance); + } + + public void load(@NotNull Object instance, @NotNull ConfigSection section) { + Object value = this.loader.load(section, this); + if (value == null) { + // wrong data type or no data set + return; + } + + set(instance, value); + } + + public void save(@NotNull Object instance, @NotNull Writeable writable) { + Object value = this.accessor.get(instance); + writable.set(this.propertyKey, value); + writable.setInlineComments(this.propertyKey, this.inlineComments); + writable.setComments(this.propertyKey, this.comments); + } + + public boolean saveIfMissing(@NotNull Object instance, @NotNull Writeable writable) { + if (writable.isSet(this.propertyKey)) { + return false; + } + + save(instance, writable); + return true; + } + + private void set(@NotNull Object instance, @Nullable Object value) { + + if (value == null && this.type.isPrimitive()) { + throw new IllegalArgumentException("Cannot assign null to primitive field: '" + this.fieldName + "' (" + this.type.getName() + ") << null"); + } + + try { + this.accessor.set(instance, value); + } catch (Exception ex) { + String valueType = value == null ? "null" : value.getClass().getName(); + throw new IllegalArgumentException("Failed to set field: '" + this.fieldName + "' (" + this.type.getName() + ") << " + valueType, ex); + } + + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundHook.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundHook.java new file mode 100644 index 0000000..acf15bc --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/BoundHook.java @@ -0,0 +1,87 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.Readable; +import dev.spoocy.utils.reflection.accessor.Accessor; +import dev.spoocy.utils.reflection.accessor.MethodAccessor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class BoundHook { + + private final MethodAccessor accessor; + private final Class parameterType; + private final boolean returnsPostLoadResult; + + @Nullable + public static BoundHook of( + @NotNull Method method, + @NotNull Class annotation + ) { + if (!method.isAnnotationPresent(annotation)) { + return null; + } + + if (Modifier.isStatic(method.getModifiers())) { + throw new IllegalArgumentException("Hook method must not be static: " + method); + } + + Class[] parameters = method.getParameterTypes(); + if (parameters.length > 1) { + throw new IllegalArgumentException("Hook method must have zero or one parameter: " + method); + } + + Class returnType = method.getReturnType(); + boolean returnsPostLoadResult = false; + if (annotation == PostLoad.class) { + if (returnType == PostLoadResult.class) { + returnsPostLoadResult = true; + } else if (returnType != void.class) { + throw new IllegalArgumentException("@PostLoad hook method must return void or PostLoadResult: " + method); + } + } else if (returnType != void.class) { + throw new IllegalArgumentException("Hook method must return void: " + method); + } + + Class parameterType = parameters.length == 0 ? null : parameters[0]; + return new BoundHook(Accessor.getMethod(method), parameterType, returnsPostLoadResult); + } + + private BoundHook( + @NotNull MethodAccessor accessor, + @Nullable Class parameterType, + boolean returnsPostLoadResult + ) { + this.accessor = accessor; + this.parameterType = parameterType; + this.returnsPostLoadResult = returnsPostLoadResult; + } + + @NotNull + public PostLoadResult invoke(@NotNull Object instance, @NotNull Readable readable) { + Object result; + + if (this.parameterType == null) { + result = this.accessor.invoke(instance); + } else { + if (!this.parameterType.isInstance(readable)) { + throw new IllegalArgumentException("Hook parameter type is not compatible with readable instance: " + this.accessor.getMethod()); + } + result = this.accessor.invoke(instance, readable); + } + + if (this.returnsPostLoadResult && result instanceof PostLoadResult) { + return (PostLoadResult) result; + } + + return PostLoadResult.NONE; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBean.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBean.java new file mode 100644 index 0000000..82c0fcf --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBean.java @@ -0,0 +1,258 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.Readable; +import dev.spoocy.utils.config.ResourceResolver; +import dev.spoocy.utils.config.Writeable; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.reflection.ClassWalker; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigBean { + + @NotNull + private final Class clazz; + + @NotNull + private final String resourcePath; + + @Nullable + private final String section; + + + private final boolean saveDefaults; + + + private final boolean allowMissingResource; + + @NotNull + private final String[] headerComments; + + @NotNull + private final String[] footerComments; + + @NotNull + private final List fields; + + @NotNull + private final List preHooks; + + @NotNull + private final List postHooks; + + protected ConfigBean( + @NotNull Class clazz, + @NotNull String resourcePath, + @Nullable String section, + boolean saveDefaults, + boolean allowMissingResource, + @NotNull String[] headerComments, + @NotNull String[] footerComments + ) { + this.clazz = clazz; + this.resourcePath = resourcePath; + this.section = section; + this.saveDefaults = saveDefaults; + this.allowMissingResource = allowMissingResource; + this.headerComments = headerComments; + this.footerComments = footerComments; + this.fields = resolveFields(clazz); + this.preHooks = resolveHooks(clazz, PreLoad.class); + this.postHooks = resolveHooks(clazz, PostLoad.class); + } + + @NotNull + public Class type() { + return this.clazz; + } + + @NotNull + public String resourcePath() { + return this.resourcePath; + } + + @Nullable + public String section() { + return this.section; + } + + public boolean saveDefaults() { + return this.saveDefaults; + } + + public boolean allowMissingResource() { + return this.allowMissingResource; + } + + @NotNull + public String[] headerComments() { + return this.headerComments; + } + + @NotNull + public String[] footerComments() { + return this.footerComments; + } + + @NotNull + public List fields() { + return this.fields; + } + + @NotNull + public List preHooks() { + return this.preHooks; + } + + @NotNull + public List postHooks() { + return this.postHooks; + } + + public Resource resource(@NotNull ResourceResolver resolver) { + if(this.resourcePath.isEmpty()) { + throw new IllegalStateException("ConfigBean does not have a resource path: " + this.clazz.getName()); + } + + return resolver.resolve(this.resourcePath()); + } + + public PostLoadResult read(@NotNull Object instance, @NotNull ConfigSection section) { + invokePreHooks(instance, section); + + for (BoundField field : this.fields) { + field.load(instance, section); + } + + return invokePostHooks(instance, section); + } + + public void write(@NotNull Object instance, @NotNull Writeable writable) { + writable.setHeaderComments(this.headerComments); + writable.setFooterComments(this.footerComments); + + for (BoundField field : this.fields) { + field.save(instance, writable); + } + } + + public boolean writeDefaults(@NotNull Object instance, @NotNull Writeable writable) { + boolean changed = false; + + for (BoundField field : this.fields) { + + if (field.shouldSaveDefault()) { + boolean written = field.saveIfMissing(instance, writable); + + if (!changed && written) { + changed = true; + } + } + + } + + if(changed) { + writable.setHeaderComments(this.headerComments); + writable.setFooterComments(this.footerComments); + } + + return changed; + } + + public void invokePreHooks(@NotNull Object instance, @NotNull Readable readable) { + for (BoundHook hook : this.preHooks) { + hook.invoke(instance, readable); + } + } + + @NotNull + public PostLoadResult invokePostHooks(@NotNull Object instance, @NotNull Readable readable) { + PostLoadResult result = PostLoadResult.NONE; + for (BoundHook hook : this.postHooks) { + PostLoadResult hookResult = hook.invoke(instance, readable); + if (hookResult.ordinal() > result.ordinal()) { + result = hookResult; + } + } + return result; + } + + @NotNull + public T newInstance() { + + try { + Constructor constructor = this.clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Cannot instantiate " + this.clazz.getName(), ex); + } + + } + + @NotNull + private List resolveFields(@NotNull Class type) { + List fields = new ArrayList<>(); + + for (Class current : ClassWalker.walk(type)) { + if (current == Object.class || current.isInterface()) { + continue; + } + + for (Field field : current.getDeclaredFields()) { + + if (field.isSynthetic()) { + continue; + } + + int modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers) || Modifier.isFinal(modifiers)) { + continue; + } + + BoundField boundField = BoundField.of(this, field); + fields.add(boundField); + } + } + + return fields; + } + + @NotNull + private static List resolveHooks( + @NotNull Class type, + @NotNull Class annotation + ) { + List hooks = new ArrayList<>(); + + for (Class current : ClassWalker.walk(type)) { + if (current == Object.class || current.isInterface()) { + continue; + } + + for (Method method : current.getDeclaredMethods()) { + BoundHook hook = BoundHook.of(method, annotation); + if (hook != null) { + hooks.add(hook); + } + } + } + + return hooks; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBeanLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBeanLoader.java new file mode 100644 index 0000000..13d3497 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigBeanLoader.java @@ -0,0 +1,300 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.*; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.ConfigLoader; +import dev.spoocy.utils.config.representer.Representer; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ConfigBeanLoader { + + private static final ConcurrentMap, ConfigBean> TYPES = new ConcurrentHashMap<>(); + + private final ResourceResolver resourceResolver; + private final Representer representer; + private final Constructor constructor; + + public ConfigBeanLoader( + @NotNull ResourceResolver resourceResolver, + @NotNull Representer representer, + @NotNull Constructor constructor + ) { + this.resourceResolver = Args.notNull(resourceResolver, "resourceResolver"); + this.representer = Args.notNull(representer, "representer"); + this.constructor = Args.notNull(constructor, "constructor"); + } + + /** + * Binds a specified class type to a configuration bean, creating it if necessary. + * + * @param the type of the class being bound + * @param clazz the class type to bind; must not be null + * + * @return a {@code ConfigBean} instance representing the bound configuration for the provided class type + * + * @throws IllegalArgumentException if {@code clazz} is null + */ + @Contract("_ -> new") + @NotNull + @SuppressWarnings("unchecked") + public ConfigBean bind(@NotNull Class clazz) { + Args.notNull(clazz, "clazz"); + return (ConfigBean) TYPES.computeIfAbsent(clazz, this::createBean); + } + + /** + * Loads an instance of the specified class type from the given configuration using the provided load strategy. + * + * @param the type of the object being loaded + * @param clazz the class type to load; must not be null + * @param config the configuration source from which the data will be loaded; must not be null + * @param strategy the load strategy defining how the configuration data should be processed; must not be null + * + * @return an instance of the specified class type populated with data from the given configuration + * + * @throws IllegalArgumentException if {@code clazz} or {@code config} is null + */ + @Contract("_, _, _ -> new") + @NotNull + public T load(@NotNull Class clazz, @NotNull Config config, @NotNull LoadStrategy strategy) { + Args.notNull(config, "config"); + Args.notNull(clazz, "clazz"); + + ConfigBean bean = bind(clazz); + T instance = bean.newInstance(); + return load(bean, config, instance, strategy); + } + + /** + * Loads an instance of the specified class type using the given load strategy. + * + * @param the type of the object being loaded + * @param clazz the class type to load; must not be null + * @param strategy the load strategy defining how the configuration data should be processed; must not be null + * + * @return an instance of the specified class type loaded with data based on the provided strategy + * + * @throws IllegalArgumentException if {@code clazz} or {@code strategy} is null + */ + @Contract("_, _ -> new") + @NotNull + public T load(@NotNull Class clazz, @NotNull LoadStrategy strategy) { + Args.notNull(clazz, "clazz"); + Args.notNull(strategy, "strategy"); + + ConfigBean bean = bind(clazz); + Config config = resolveDocument(bean); + T instance = bean.newInstance(); + + return load(bean, config, instance, strategy); + } + + @Contract("_, _, _, _ -> param3") + @NotNull + private T load( + @NotNull ConfigBean bean, + @NotNull Config config, + @NotNull T instance, + @NotNull LoadStrategy strategy + ) { + ConfigSection source = resolveSection(config, bean.section()); + PostLoadResult res = bean.read(instance, source); + + switch (strategy) { + case JUST_LOAD: + break; + + case SAVE_DEFAULTS: + bean.writeDefaults(instance, source); + break; + + case SAVE_DEFAULTS_AND_RESOURCE: + + if (!(config instanceof Document)) { + throw new IllegalArgumentException("Config must be a Document when using LoadStrategy.SAVE_DEFAULTS_AND_RESOURCE."); + } + + boolean changed = bean.writeDefaults(instance, source); + + if(res == PostLoadResult.SAVE || changed) { + + // some defaults were written so save the config + try { + ((Document) config).save(this.representer); + } catch (IOException ex) { + throw new IllegalStateException("Failed to save config after loading defaults for " + bean.type() + .getName(), ex); + } + + } + + break; + } + + return instance; + } + + /** + * Converts the provided instance into a {@link Config} object. + * + * @param the type of the instance being processed + * @param instance the instance to be written to a configuration; must not be null + * + * @return a {@code Config} object representing the serialized configuration of the provided instance + */ + @Contract("_ -> new") + @NotNull + public Config writeToConfig(@NotNull T instance) { + Args.notNull(instance, "instance"); + + Class type = (Class) instance.getClass(); + ConfigBean bean = bind(type); + + Document config = resolveDocument(bean); + write(bean, config, instance); + + return config.withoutRelation(); + } + + /** + * Writes the provided instance to the specified {@link Config}. + * + * @param the type of the instance being written + * @param instance the instance to be written to the configuration; must not be null + * @param config the configuration to which the instance data will be written; must not be null + * + * @return the updated {@code Config} object containing the written instance data + * + * @throws NullPointerException if {@code instance} or {@code config} is null + */ + @Contract("_, _ -> param2") + @NotNull + public Config writeToConfig(@NotNull T instance, @NotNull Config config) { + Args.notNull(instance, "instance"); + Args.notNull(config, "config"); + + Class type = (Class) instance.getClass(); + ConfigBean bean = bind(type); + write(bean, config, instance); + + return config; + } + + private void write(@NotNull ConfigBean bean, @NotNull Config config, @NotNull T instance) { + ConfigSection section = resolveSection(config, bean.section()); + bean.write(instance, section); + } + + /** + * Saves the provided configuration instance to the underlying configuration source. + * + * @param the type of the instance being saved + * @param instance the instance to save; must not be null + * + * @throws IOException if an I/O error occurs while saving the instance + * @throws NullPointerException if {@code instance} is null + */ + public void save(@NotNull T instance) throws IOException { + Args.notNull(instance, "instance"); + + Class type = (Class) instance.getClass(); + ConfigBean bean = bind(type); + Document config = resolveDocument(bean); + writeAndSave(bean, config, instance); + } + + /** + * Saves the provided configuration instance to the specified {@link Document}. + * + * @param the type of the instance being saved + * @param instance the instance to save; must not be null + * @param config the configuration document to which the instance data will be + * written; must not be null + * + * @throws IOException if an I/O error occurs while saving the instance + * @throws NullPointerException if {@code instance} or {@code config} is null + */ + public void save(@NotNull T instance, @NotNull Document config) throws IOException { + Args.notNull(instance, "instance"); + Args.notNull(config, "config"); + + Class type = (Class) instance.getClass(); + ConfigBean bean = bind(type); + writeAndSave(bean, config, instance); + } + + private void writeAndSave(@NotNull ConfigBean bean, @NotNull Document config, @NotNull T instance) + throws IOException { + write(bean, config, instance); + config.save(this.representer); + } + + @NotNull + private ConfigBean createBean(@NotNull Class clazz) { + ConfigSource source = clazz.getAnnotation(ConfigSource.class); + if (source == null) { + throw new IllegalArgumentException("Class " + clazz.getName() + " is not annotated with @ConfigSource"); + } + + return new ConfigBean<>( + clazz, + source.value(), + source.section() + .isEmpty() ? null : source.section(), + source.saveDefaults(), + source.allowMissingResource(), + source.headerComments(), + source.footerComments() + ); + } + + @NotNull + private ConfigSection resolveSection(@NotNull Config config, @Nullable String section) { + if (section == null || section.isEmpty()) { + return config; + } + + ConfigSection sec = config.getSectionIfExists(section); + return sec != null ? sec : config.createSection(section); + } + + private Document resolveDocument(@NotNull ConfigBean bean) { + Resource resource = bean.resource(this.resourceResolver); + ConfigLoader loader = this.resourceResolver.requireLoader(resource); + + Config config; + + if (resource.exists()) { + + try { + config = loader.load(resource, this.constructor); + } catch (IOException e) { + throw new IllegalStateException("Failed to load config: " + bean.resourcePath(), e); + } + + } else { + + // config doesn't exist so create empty if allowed + if (bean.allowMissingResource()) { + config = loader.createEmpty(); + } else { + throw new IllegalStateException("Missing config resource: " + bean.resourcePath()); + } + + } + + return config.withRelation(resource); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigIgnore.java similarity index 90% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigIgnore.java index 23079ce..77f9aee 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigIgnore.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigIgnore.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigProperty.java similarity index 95% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigProperty.java index 2525c09..75b66cf 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigProperty.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigProperty.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; import org.jetbrains.annotations.NotNull; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigSource.java similarity index 78% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigSource.java index 805538c..1b26336 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigSource.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigSource.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; import org.jetbrains.annotations.NotNull; @@ -29,7 +29,8 @@ String section() default ""; /** - * Persist missing values from field defaults back to the document. + * Persist missing values from field defaults back to the document when + * using {@link LoadStrategy#SAVE_DEFAULTS} or {@link LoadStrategy#SAVE_DEFAULTS_AND_RESOURCE}. */ boolean saveDefaults() default false; @@ -50,17 +51,5 @@ @NotNull String[] footerComments() default {}; - /** - * Block comments applied to the configured {@link #section() Section}. - */ - @NotNull - String[] comments() default {}; - - /** - * Inline comments applied to the configured {@link #section() Section}. - */ - @NotNull - String[] inlineComments() default {}; - } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/DefaultPropertyLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/DefaultPropertyLoader.java new file mode 100644 index 0000000..c52d165 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/DefaultPropertyLoader.java @@ -0,0 +1,439 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.common.version.Version; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.Readable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class DefaultPropertyLoader implements PropertyLoader { + + private static final Object UNRESOLVED = new Object(); + + @Override + public Object load(@NotNull ConfigSection config, @NotNull BoundField field) { + String path = field.propertyKey(); + + Object raw; + + if (config.isSection(path)) { + raw = config.getSection(path).values(true); + } else { + + if (!config.isSet(path)) { + return null; + } + + raw = config.getObject(path); + } + + if (raw == null) { + return null; + } + + Class fieldType = field.type(); + + if (fieldType == String.class) { + return config.getString(path, raw.toString()); + } + + if (fieldType == int.class || fieldType == Integer.class) { + return config.getInt(path, raw instanceof Number ? ((Number) raw).intValue() : 0); + } + + if (fieldType == long.class || fieldType == Long.class) { + return config.getLong(path, raw instanceof Number ? ((Number) raw).longValue() : 0L); + } + + if (fieldType == double.class || fieldType == Double.class) { + return config.getDouble(path, raw instanceof Number ? ((Number) raw).doubleValue() : 0.0D); + } + + if (fieldType == float.class || fieldType == Float.class) { + return config.getFloat(path, raw instanceof Number ? ((Number) raw).floatValue() : 0.0F); + } + + if (fieldType == boolean.class || fieldType == Boolean.class) { + return config.getBoolean(path, raw instanceof Boolean && (Boolean) raw); + } + + if (fieldType == UUID.class) { + return config.getUUID(path, raw instanceof UUID ? (UUID) raw : null); + } + + if (fieldType == Version.class) { + return config.getVersion(path, raw instanceof Version ? (Version) raw : null); + } + + if (fieldType.isEnum()) { + return resolveEnum(config, path, fieldType, raw); + } + + if (Collection.class.isAssignableFrom(fieldType)) { + Object collection = loadCollection(field, raw); + if (collection != null) { + return collection; + } + } + + if (fieldType.isArray() && raw instanceof List) { + return loadArray(fieldType.getComponentType(), (List) raw); + } + + if (fieldType.isInstance(raw)) { + return raw; + } + + Object converted = convertComplexValue(raw, fieldType); + if (converted != UNRESOLVED) { + return converted; + } + + Object fallback = config.get(path, fieldType); + if (fallback != null) { + return fallback; + } + + return raw; + } + + @Nullable + private Object loadCollection(@NotNull BoundField field, @NotNull Object raw) { + Class genericType = field.collectionElementType(); + if (genericType == null) { + return raw instanceof Collection ? raw : null; + } + + if (List.class.isAssignableFrom(field.type())) { + return convertCollectionValue(raw, genericType, false, field); + } + + if (Set.class.isAssignableFrom(field.type())) { + return convertCollectionValue(raw, genericType, true, field); + } + + return null; + } + + @NotNull + private Collection convertCollectionValue( + @NotNull Object raw, + @NotNull Class elementType, + boolean set, + @NotNull BoundField field + ) { + if (!(raw instanceof Collection)) { + throw new IllegalArgumentException( + "Unsupported collection field data for '" + field.name() + "': " + raw.getClass() + .getName() + ); + } + + Collection source = (Collection) raw; + Collection values = set ? new java.util.LinkedHashSet<>(source.size()) : new ArrayList<>(source.size()); + + for (Object entry : source) { + Object converted = convertComplexValue(entry, elementType); + if (converted == UNRESOLVED) { + String entryType = entry == null ? "null" : entry.getClass() + .getName(); + throw new IllegalArgumentException( + "Cannot convert collection element of field '" + field.name() + "' from " + entryType + + " to " + elementType.getName() + ); + } + values.add(converted); + } + + return values; + } + + @Nullable + private Object loadArray(@NotNull Class componentType, @NotNull List values) { + Object array = java.lang.reflect.Array.newInstance(componentType, values.size()); + for (int i = 0; i < values.size(); i++) { + Object converted = convertComplexValue(values.get(i), componentType); + if (converted == UNRESOLVED) { + throw new IllegalArgumentException("Cannot convert array element to " + componentType.getName()); + } + java.lang.reflect.Array.set(array, i, converted); + } + return array; + } + + @Nullable + @SuppressWarnings("unchecked") + private static > E resolveEnum( + @NotNull Readable readable, + @NotNull String path, + @NotNull Class enumType, + @Nullable Object defaultValue + ) { + Class castedType = (Class) enumType; + E fallback = castedType.isInstance(defaultValue) ? castedType.cast(defaultValue) : null; + return readable.getEnum(path, castedType, fallback); + } + + private Object convertComplexValue(@NotNull Object raw, @NotNull Class targetType) { + if (targetType.isInstance(raw)) { + return raw; + } + + Class boxedType = box(targetType); + + if (boxedType == String.class) { + return raw.toString(); + } + + if (Number.class.isAssignableFrom(boxedType) && raw instanceof Number) { + Number number = (Number) raw; + if (boxedType == Integer.class) return number.intValue(); + if (boxedType == Long.class) return number.longValue(); + if (boxedType == Double.class) return number.doubleValue(); + if (boxedType == Float.class) return number.floatValue(); + if (boxedType == Short.class) return number.shortValue(); + if (boxedType == Byte.class) return number.byteValue(); + } + + if (boxedType == Boolean.class) { + if (raw instanceof Boolean) { + return raw; + } + if (raw instanceof String) { + return Boolean.parseBoolean((String) raw); + } + } + + if (boxedType == Character.class) { + if (raw instanceof Character) { + return raw; + } + if (raw instanceof String) { + String text = (String) raw; + if (!text.isEmpty()) { + return text.charAt(0); + } + } + } + + if (boxedType == UUID.class && raw instanceof String) { + return UUID.fromString((String) raw); + } + + if (boxedType == Version.class && raw instanceof String) { + return Version.parse((String) raw); + } + + if (boxedType.isEnum() && raw instanceof String) { + @SuppressWarnings({"unchecked", "rawtypes"}) + Object constant = Enum.valueOf((Class) boxedType.asSubclass(Enum.class), (String) raw); + return constant; + } + + if (raw instanceof ConfigSection) { + return convertComplexValue(((ConfigSection) raw).values(true), targetType); + } + + if (raw instanceof Map) { + Object mapped = instantiateFromMap(targetType, (Map) raw); + if (mapped != UNRESOLVED) { + return mapped; + } + } + + if (raw instanceof Collection && targetType.isArray()) { + return loadArray(targetType.getComponentType(), List.copyOf((Collection) raw)); + } + + Object singleArg = instantiateFromSingleArgument(targetType, raw); + if (singleArg != UNRESOLVED) { + return singleArg; + } + + return UNRESOLVED; + } + + private Object instantiateFromSingleArgument(@NotNull Class targetType, @NotNull Object raw) { + for (Constructor constructor : targetType.getDeclaredConstructors()) { + Class[] parameters = constructor.getParameterTypes(); + if (parameters.length != 1) { + continue; + } + + Object value = convertForParameter(raw, parameters[0]); + if (value == UNRESOLVED) { + continue; + } + + try { + constructor.setAccessible(true); + return constructor.newInstance(value); + } catch (ReflectiveOperationException ex) { + throw new IllegalArgumentException("Failed to construct " + targetType.getName(), ex); + } + } + + return UNRESOLVED; + } + + private Object instantiateFromMap(@NotNull Class targetType, @NotNull Map raw) { + if (targetType.isInterface() || Modifier.isAbstract(targetType.getModifiers())) { + return UNRESOLVED; + } + + // If the map is empty, try the default constructor first + if (raw.isEmpty()) { + try { + Constructor constructor = targetType.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (ReflectiveOperationException ignored) { + // Fall through to other constructor strategies. + } + } + + // Try constructors that accept a single parameter or match the map size + for (Constructor constructor : targetType.getDeclaredConstructors()) { + Class[] parameters = constructor.getParameterTypes(); + + if (parameters.length == 1) { + Object value = convertForParameter(raw, parameters[0]); + if (value == UNRESOLVED) { + continue; + } + + try { + constructor.setAccessible(true); + return constructor.newInstance(value); + } catch (ReflectiveOperationException ex) { + throw new IllegalArgumentException("Failed to construct " + targetType.getName(), ex); + } + } + + if (parameters.length != raw.size()) { + continue; + } + + Object[] arguments = new Object[parameters.length]; + int index = 0; + boolean compatible = true; + for (Object value : raw.values()) { + Object converted = convertForParameter(value, parameters[index]); + if (converted == UNRESOLVED) { + compatible = false; + break; + } + arguments[index] = converted; + index++; + } + + if (!compatible) { + continue; + } + + try { + constructor.setAccessible(true); + return constructor.newInstance(arguments); + } catch (ReflectiveOperationException ex) { + throw new IllegalArgumentException("Failed to construct " + targetType.getName(), ex); + } + } + + // As a fallback, if a no-arg constructor exists, instantiate and populate fields from the map + try { + Constructor noArg = targetType.getDeclaredConstructor(); + noArg.setAccessible(true); + Object instance = noArg.newInstance(); + + Class current = targetType; + while (current != null && current != Object.class) { + for (java.lang.reflect.Field field : current.getDeclaredFields()) { + if (field.isSynthetic()) continue; + int mods = field.getModifiers(); + if (Modifier.isStatic(mods) || Modifier.isTransient(mods) || Modifier.isFinal(mods)) continue; + + ConfigProperty ann = field.getAnnotation(ConfigProperty.class); + String key; + if (ann != null) { + key = ann.value(); + } else { + // convert camelCase field name to property name (dash + lowercase for uppercase letters) + String name = field.getName(); + StringBuilder builder = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c)) { + builder.append('-'); + builder.append(Character.toLowerCase(c)); + } else { + builder.append(c); + } + } + key = builder.toString(); + } + + if (!raw.containsKey(key)) continue; + + Object rawValue = raw.get(key); + Object converted = convertComplexValue(rawValue, field.getType()); + if (converted == UNRESOLVED) { + throw new IllegalArgumentException("Cannot convert map value for field '" + field.getName() + "' to " + field.getType().getName()); + } + + field.setAccessible(true); + field.set(instance, converted); + } + current = current.getSuperclass(); + } + + return instance; + } catch (ReflectiveOperationException ignored) { + // No suitable no-arg constructor or failed to populate, fall through + } + + return UNRESOLVED; + } + + private Object convertForParameter(@NotNull Object raw, @NotNull Class parameterType) { + Object converted = convertComplexValue(raw, parameterType); + if (converted != UNRESOLVED) { + return converted; + } + + Class boxed = box(parameterType); + if (boxed.isInstance(raw)) { + return raw; + } + + return UNRESOLVED; + } + + @NotNull + private static Class box(@NotNull Class type) { + if (!type.isPrimitive()) { + return type; + } + + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == double.class) return Double.class; + if (type == float.class) return Float.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + if (type == byte.class) return Byte.class; + if (type == short.class) return Short.class; + return type; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/LoadStrategy.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/LoadStrategy.java new file mode 100644 index 0000000..9c0fa30 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/LoadStrategy.java @@ -0,0 +1,15 @@ +package dev.spoocy.utils.config.bean; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public enum LoadStrategy { + + JUST_LOAD, + + SAVE_DEFAULTS, + + SAVE_DEFAULTS_AND_RESOURCE; + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoad.java similarity index 96% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoad.java index 042a953..de68a2e 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoad.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoad.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoadResult.java similarity index 92% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoadResult.java index 25e7ba9..b4ddc9b 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PostLoadResult.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoadResult.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; /** * Return value for {@link PostLoad} hook methods. diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PreLoad.java similarity index 95% rename from config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java rename to config-utils/src/main/java/dev/spoocy/utils/config/bean/PreLoad.java index c35ec19..cea4d77 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/PreLoad.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PreLoad.java @@ -1,4 +1,4 @@ -package dev.spoocy.utils.config.loader; +package dev.spoocy.utils.config.bean; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/bean/PropertyLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PropertyLoader.java new file mode 100644 index 0000000..b9f0104 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PropertyLoader.java @@ -0,0 +1,14 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.ConfigSection; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface PropertyLoader { + + Object load(@NotNull ConfigSection config, @NotNull BoundField field); + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java deleted file mode 100644 index ff3e660..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractDataHolder.java +++ /dev/null @@ -1,356 +0,0 @@ -package dev.spoocy.utils.config.components; - -import dev.spoocy.utils.common.misc.NumberConversion; -import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.config.ConfigSection; -import dev.spoocy.utils.config.serializer.Serializer; -import dev.spoocy.utils.config.serializer.Serializers; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Function; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public abstract class AbstractDataHolder implements ConfigSection { - - private Serializers serializers() { - return this.getRoot().settings().serializers(); - } - - @Override - public void opposite(@NotNull String path) { - set(path, !getBoolean(path)); - } - - @Override - public void multiply(@NotNull String path, double value) { - set(path, getDouble(path) * value); - } - - @Override - public void divide(@NotNull String path, double value) { - set(path, getDouble(path) / value); - } - - @Override - public void add(@NotNull String path, double value) { - set(path, getDouble(path) + value); - } - - @Override - public void subtract(@NotNull String path, double value) { - set(path, getDouble(path) - value); - } - - @Override - public Object getObject(@NotNull String path, @Nullable Object defaultValue) { - Object value = getObject(path); - return value != null ? value : defaultValue; - } - - @Override - public T get(@NotNull String path, @NotNull Class clazz) { - Object value = this.getObject(path); - if (value == null) { - return null; - } - - if (Number.class.isAssignableFrom(clazz)) { - return NumberConversion.convert(value, clazz); - } - return clazz.isInstance(value) ? clazz.cast(value) : null; - } - - @Override - public T get(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - T value = get(path, clazz); - return value != null ? value : defaultValue; - } - - @Override - public boolean is(@NotNull String path, @NotNull Class clazz) { - Object object = getObject(path, clazz); - return clazz.isInstance(object); - } - - @Override - public T getSerializable(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - T value = getSerializable(path, clazz); - return value != null ? value : defaultValue; - } - - @Override - @Nullable - public T getSerializable(@NotNull String path, @NotNull Class clazz) { - Object value = getObject(path); - if (value == null) { - return null; - } - - if (clazz.isInstance(value)) { - return clazz.cast(value); - } - - Serializers serializers = this.serializers(); - - if (value instanceof ConfigSection) { - Serializer serializer = serializers.resolve(clazz); - if (serializer != null) { - return serializer.deserializeSafely(((ConfigSection) value).values(false)); - } - } - - if (value instanceof Map) { - Serializer serializer = serializers.resolve(clazz); - if (serializer != null) { - return serializer.deserializeSafely((Map) value); - } - } - - return null; - } - - @Override - public boolean isString(@NotNull String path) { - return getObject(path) instanceof String; - } - - @Override - public String getString(@NotNull String path, @Nullable String defaultValue) { - Object value = this.getObject(path, defaultValue); - return value != null ? value.toString() : defaultValue; - } - - @Override - public boolean isInt(@NotNull String path) { - return getObject(path) instanceof Integer; - } - - @Override - public int getInt(@NotNull String path, int defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toInt(value) : defaultValue; - } - - @Override - public boolean isDouble(@NotNull String path) { - return getObject(path) instanceof Double; - } - - @Override - public double getDouble(@NotNull String path, double defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toDouble(value) : defaultValue; - } - - @Override - public boolean isFloat(@NotNull String path) { - return getObject(path) instanceof Float; - } - - @Override - public float getFloat(@NotNull String path, float defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toFloat(value) : defaultValue; - } - - @Override - public boolean isLong(@NotNull String path) { - return getObject(path) instanceof Long; - } - - @Override - public long getLong(@NotNull String path, long defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Number ? NumberConversion.toLong(value) : defaultValue; - } - - @Override - public boolean isBoolean(@NotNull String path) { - Object value = getObject(path, Boolean.class); - return value instanceof Boolean; - } - - @Override - public boolean getBoolean(@NotNull String path, boolean defaultValue) { - Object value = this.getObject(path, defaultValue); - return value instanceof Boolean ? (boolean) value : defaultValue; - } - - @Override - public Class getClass(@NotNull String path, @Nullable Class defaultValue) { - try { - return Class.forName(getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public > T getEnum(@NotNull String path, @NotNull Class clazz, @Nullable T defaultValue) { - try { - return Enum.valueOf(clazz, getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public UUID getUUID(@NotNull String path, @Nullable UUID defaultValue) { - try { - return UUID.fromString(getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { - try { - return Version.parse(getString(path)); - } catch (Exception ex) { - return defaultValue; - } - } - - @Override - public boolean isList(@NotNull String path) { - Object value = getObject(path); - return value instanceof List; - } - - @Override - @Nullable - public List getList(@NotNull String path) { - return getList(path, null); - } - - @Override - public List getList(@NotNull String path, @Nullable List defaultValue) { - Object value = getObject(path, defaultValue); - return (List) (value instanceof List ? value : defaultValue); - } - - @Override - public List> getMapList(@NotNull String path) { - List list = getList(path, new ArrayList<>()); - List> mapList = new ArrayList<>(); - - if (list == null || list.isEmpty()) { - return mapList; - } - - for (Object object : list) { - if (object instanceof Map) { - mapList.add((Map) object); - } - } - - return mapList; - } - - @Override - public List getList(@NotNull String path, @NotNull Class clazz, @Nullable List defaultValue) { - List list = new ArrayList<>(); - List value = getList(path, new ArrayList<>()); - - if (value == null || value.isEmpty()) { - return list; - } - - Serializer serializer = this.serializers().resolve(clazz); - if (serializer != null) { - return map(list, object -> serializer.deserializeSafely((Map) object)); - } - - for (Object object : value) { - if (clazz.isInstance(object)) { - list.add(clazz.cast(object)); - } - } - - return list; - } - - @Override - public @NotNull List getStringList(@NotNull String path) { - List list = this.getList(path); - return map(list, Object::toString); - } - - @Override - public @NotNull List getBooleanList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toBoolean); - } - - @Override - public @NotNull List getIntegerList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toInt); - } - - @Override - public @NotNull List getDoubleList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toDouble); - } - - @Override - public @NotNull List getLongList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toLong); - } - - @Override - public @NotNull List getFloatList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toFloat); - } - - @Override - public @NotNull List getByteList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toByte); - } - - @Override - public @NotNull List getCharacterList(@NotNull String path) { - List list = this.getList(path); - return map(list, object -> { - String string = object.toString(); - return string.isEmpty() ? null : string.charAt(0); - }); - } - - @Override - public @NotNull List getShortList(@NotNull String path) { - List list = this.getList(path); - return map(list, NumberConversion::toShort); - } - - private List map(@Nullable List list, @NotNull Function mapper) { - if (list == null) return Collections.emptyList(); - - List mapped = new ArrayList<>(); - for (Object object : list) { - - T value = null; - - try { - value = mapper.apply(object); - } catch (Throwable ignored) { - } - - if (value != null) { - mapped.add(value); - } - } - - return mapped; - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java deleted file mode 100644 index fedecf8..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/ConfigNode.java +++ /dev/null @@ -1,123 +0,0 @@ -package dev.spoocy.utils.config.components; - -import dev.spoocy.utils.config.serializer.Tag; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Collections; -import java.util.List; -import java.math.BigDecimal; -import java.math.BigInteger; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class ConfigNode { - - @NotNull - private Tag tag; - - @Nullable - private Class type; - - @Nullable - private Object data; - - @NotNull - private List comments; - - @NotNull - private List inlineComments; - - public ConfigNode(@Nullable Object data) { - this(data, null, null); - } - - public ConfigNode( - @Nullable Object data, - @Nullable List comments, - @Nullable List inlineComments - ) { - this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); - this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); - - setData(data); - } - - @NotNull - public Tag getTag() { - return this.tag; - } - - @Nullable - public Object getData() { - return this.data; - } - - @Nullable - public Class getType() { - return this.type; - } - - public void setData(@Nullable Object data) { - this.data = data; - this.type = data == null ? null : data.getClass(); - this.tag = resolveTag(data); - } - - @NotNull - private static Tag resolveTag(@Nullable Object data) { - if (data == null) { - return Tag.NULL; - } - - if (data instanceof MemorySection) { - return Tag.SECTION; - } - - if (data instanceof java.util.Map) { - return Tag.MAP; - } - - if (data instanceof java.util.Collection || data.getClass().isArray()) { - return Tag.SEQ; - } - - if (data instanceof Boolean) { - return Tag.BOOL; - } - - if (data instanceof Byte || data instanceof Short || data instanceof Integer || data instanceof Long || data instanceof BigInteger) { - return Tag.INT; - } - - if (data instanceof Float || data instanceof Double || data instanceof BigDecimal) { - return Tag.FLOAT; - } - - if (data instanceof CharSequence || data instanceof Character) { - return Tag.STR; - } - - return new Tag(data.getClass()); - } - - @NotNull - public List getComments() { - return this.comments; - } - - public void setComments(@Nullable List comments) { - this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); - } - - @NotNull - public List getInlineComments() { - return this.inlineComments; - } - - public void setInlineComments(@Nullable List inlineComments) { - this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java deleted file mode 100644 index 4b084f7..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/MemorySection.java +++ /dev/null @@ -1,409 +0,0 @@ -package dev.spoocy.utils.config.components; - -import dev.spoocy.utils.common.misc.Args; -import dev.spoocy.utils.common.tuple.Pair; -import dev.spoocy.utils.config.*; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class MemorySection extends AbstractDataHolder implements ConfigSection { - - protected final Map map = new LinkedHashMap<>(); - private final Config root; - private final ConfigSection parent; - private final String path; - private final String fullPath; - - public MemorySection() { - if (!(this instanceof Config)) { - throw new IllegalStateException("MemorySection must be a Config if no parent is provided"); - } - this.root = (Config) this; - this.parent = null; - this.path = ""; - this.fullPath = ""; - } - - public MemorySection(@NotNull ConfigSection parent, @NotNull String path) { - this.parent = Args.notNull(parent, "parent"); - this.path = Args.notNull(path, "path"); - this.root = parent.getRoot(); - this.fullPath = createPath(parent, path); - } - - @Override - public @NotNull String getName() { - return this.path; - } - - @Override - public @NotNull String getWorkingPath() { - return this.fullPath; - } - - @Override - public @NotNull Config getRoot() { - return this.root; - } - - @Override - public @Nullable ConfigSection getParent() { - return this.parent; - } - - public List> entries() { - List> entries = new ArrayList<>(); - - for (Map.Entry entry : this.map.entrySet()) { - entries.add(new Pair<>(entry.getKey(), entry.getValue())); - } - - return entries; - } - - @Nullable - private ConfigNode getConfigValue(@NotNull String path) { - PathContext context = this.resolvePath(path); - - if (context.hasNextPath()) { - ConfigSection section = getSection(context.key); - - if (section instanceof MemorySection) { - return ((MemorySection) section).getConfigValue(context.nextPath); - } - - return null; - } - - return this.map.get(context.key); - } - - @Override - public List getHeaderComments() { - return this.root.getHeaderComments(); - } - - @Override - public List getFooterComments() { - return this.root.getFooterComments(); - } - - @Override - public void setHeaderComments(@Nullable List comments) { - this.root.setHeaderComments(comments); - } - - @Override - public void setFooterComments(@Nullable List comments) { - this.root.setFooterComments(comments); - } - - @Override - public List getComments(@NotNull String path) { - ConfigNode value = getConfigValue(path); - return value == null ? Collections.emptyList() : value.getComments(); - } - - @Override - public List getInlineComments(@NotNull String path) { - ConfigNode value = getConfigValue(path); - return value == null ? Collections.emptyList() : value.getInlineComments(); - } - - @Override - public void setComments(@NotNull String path, @Nullable List comments) { - ConfigNode value = getConfigValue(path); - - if (value == null) { - return; - } - - value.setComments(comments); - } - - @Override - public void setInlineComments(@NotNull String path, @Nullable List comments) { - ConfigNode value = getConfigValue(path); - - if (value == null) { - return; - } - - value.setInlineComments(comments); - } - - - @Override - public void set(@NotNull String path, @Nullable Object value) { - PathContext context = this.resolvePath(path); - - if (context.hasNextPath()) { - ConfigSection section = getSection(context.key); - - if (section == null) { - - if (value == null) { - // Value should be removed, but the section doesn't exist, so it is effectively removed already - return; - } - - section = createSection(context.key); - } - - section.set(context.nextPath, value); - return; - } - - if (value == null) { - this.map.remove(context.key); - return; - } - - ConfigNode configValue = this.map.get(context.key); - - if (configValue == null) { - this.map.put(context.key, new ConfigNode(value)); - return; - } - - configValue.setData(value); - } - - @Override - public void remove(@NotNull String path) { - PathContext context = this.resolvePath(path); - - if (context.hasNextPath()) { - ConfigSection section = getSection(context.key); - - if (section == null) { - return; - } - - section.remove(context.nextPath); - return; - } - - this.map.remove(context.key); - } - - @Override - public void clear() { - this.map.clear(); - } - - @Override - public boolean isSet(@NotNull String path) { - return getConfigValue(path) != null; - } - - @Override - public List getSectionList(@NotNull String path) { - List> list = this.getMapList(path); - final List sections = new ArrayList<>(); - - for (Map map : list) { - ConfigSection section = new MemorySection(this, path); - - for (Map.Entry entry : map.entrySet()) { - setSectionValue(section, entry.getKey(), entry.getValue()); - } - sections.add(section); - } - - return sections; - } - - private void setSectionValue(@NotNull ConfigSection section, @NotNull String key, @Nullable Object value) { - if (value instanceof Map) { - ConfigSection child = section.createSection(key); - - for (Map.Entry entry : ((Map) value).entrySet()) { - if (entry.getKey() instanceof String) { - setSectionValue(child, (String) entry.getKey(), entry.getValue()); - } - } - return; - } - - section.set(key, value); - } - - @Override - public @Nullable Object getObject(@NotNull String path) { - ConfigNode value = getConfigValue(path); - return value == null ? null : value.getData(); - } - - @Override - public Collection keys(boolean deep) { - Set keys = new LinkedHashSet<>(); - - for (String key : this.map.keySet()) { - keys.add(key); - - if (deep) { - ConfigSection section = getSection(key); - - if (section != null) { - for (String subKey : section.keys(true)) { - keys.add(key + root.settings() - .pathSeparator() + subKey); - } - } - } - } - - return keys; - } - - @Override - public Map values(boolean deep) { - Map values = new LinkedHashMap<>(); - - for (Map.Entry entry : this.map.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue() - .getData(); - - values.put(key, value); - - if (deep) { - ConfigSection section = getSection(key); - - if (section != null) { - for (Map.Entry subEntry : section.values(true) - .entrySet()) { - values.put(key + root.settings() - .pathSeparator() + subEntry.getKey(), subEntry.getValue()); - } - } - } - } - - return values; - } - - @Override - public boolean isSection(@NotNull String path) { - Object value = getObject(path); - return value instanceof ConfigSection; - } - - @Override - public @Nullable ConfigSection getSection(@NotNull String path) { - Object value = getObject(path); - - if (value instanceof ConfigSection) { - return (ConfigSection) value; - } - - return null; - } - - @Override - public @NotNull ConfigSection getOrCreateSection(@NotNull String path) { - ConfigSection section = getSection(path); - - if (section == null) { - return createSection(path); - } - - return section; - } - - @Override - public @NotNull ConfigSection createSection(@NotNull String path) { - if (isSet(path)) { - throw new IllegalArgumentException("Cannot create section at path '" + path + "' because an object already exists."); - } - - ConfigSection section = new MemorySection(this, path); - set(path, section); - return section; - } - - @Override - public @NotNull ConfigSection createSection(@NotNull String path, @NotNull Map map) { - ConfigSection section = createSection(path); - - for (Map.Entry entry : map.entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new IllegalArgumentException("Only String keys are supported in section maps"); - } - - section.set((String) entry.getKey(), entry.getValue()); - } - - return section; - } - - private PathContext resolvePath(@NotNull String path) { - final char sectionSeparator = this.root.settings().pathSeparator(); - int separatorIndex = path.indexOf(sectionSeparator); - - if (separatorIndex == -1) { - return new PathContext(path, null); - } - - return new PathContext( - path.substring(0, separatorIndex), - path.substring(separatorIndex + 1) - ); - } - - private static final class PathContext { - - private final String key; - private final String nextPath; - - private PathContext(@NotNull String key, @Nullable String nextPath) { - this.key = key; - this.nextPath = nextPath; - } - - private boolean hasNextPath() { - return this.nextPath != null; - } - } - - @NotNull - private static String createPath(@NotNull ConfigSection section, @Nullable String key) { - return createPath(section, key, section.getRoot()); - } - - @NotNull - private static String createPath(@NotNull ConfigSection section, @Nullable String key, @Nullable ConfigSection relativeTo) { - Args.notNull(section, "Cannot create path without a section"); - - Config root = section.getRoot(); - - char separator = root.settings().pathSeparator(); - - StringBuilder builder = new StringBuilder(); - - for (ConfigSection parent = section; parent != relativeTo; parent = parent.getParent()) { - if (builder.length() > 0) { - builder.insert(0, separator); - } - builder.insert(0, parent.getName()); - } - - if ((key != null) && (!key.isEmpty())) { - if (builder.length() > 0) { - builder.append(separator); - } - - builder.append(key); - } - - return builder.toString(); - } - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java index 7d50ae4..b8ccd07 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java @@ -1,15 +1,13 @@ package dev.spoocy.utils.config.constructor; -import dev.spoocy.utils.common.tuple.Pair; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.components.MemorySection; -import dev.spoocy.utils.config.serializer.Tag; +import dev.spoocy.utils.config.AbstractConfig; +import dev.spoocy.utils.config.MemorySection; +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -17,59 +15,116 @@ public abstract class BaseConstructor implements Constructor { - private final Map constructs = new HashMap<>(); - public BaseConstructor() { + /** + * A {@link Construct} defines how a specific tag should be constructed. + */ + @NotNull + protected final Map constructors = new HashMap<>(); - } + /** + * Default constructor for null values. + */ + @NotNull + protected Construct nullConstructor = data -> null; + + public BaseConstructor() { - protected void setConstruct(@NotNull Tag tag, @NotNull Construct construct) { - this.constructs.put(tag, construct); } - protected void setConstruct(@NotNull Class clazz, @NotNull Construct construct) { - setConstruct(new Tag(clazz), construct); + protected void constructNull(@NotNull Construct constructor) { + this.nullConstructor = constructor; } - @Override - public void constructMappings(@NotNull MemorySection section, Map mapping) { - flattenMappings(section); + protected void construct(@NotNull Tag tag, @NotNull Construct constructor) { + this.constructors.put(tag, constructor); } @Nullable protected Construct getConstruct(@NotNull Tag tag) { - return this.constructs.get(tag); + return this.constructors.get(tag); } - protected void flattenMappings(@NotNull MemorySection section) { - List> nodes = section.entries(); + @Override + public void constructMappings(@NotNull AbstractConfig config, @NotNull Map map, @NotNull NodeConstructor nodeConstructor) { + applyTree(config, constructTree(map, nodeConstructor)); + } - for (Pair pair : nodes) { - ConfigNode node = pair.second(); + protected void applyTree(@NotNull MemorySection section, @NotNull NodeTree tree) { + // first level will always be treated as base + for (NodeTuple tuple : tree) { - if (node.getTag() == Tag.SECTION && node.getData() instanceof MemorySection) { - MemorySection data = (MemorySection) node.getData(); - flattenMappings(data); - } + String key = constructObject(tuple.getKeyNode()).toString(); + Object value = constructObject(tuple.getValueNode()); - Object value = construct(node); - if (value != null) { - node.setData(value); + if (value instanceof Map) { + // map should be converted to section for easier access + section.createSection(key, (Map) value); + continue; } + + section.set(key, value); } } - protected Object construct(@Nullable ConfigNode node) { - if (node == null) { - return null; + @Override + public @NotNull NodeTree constructTree(@NotNull Map mappings, @NotNull NodeConstructor nodeConstructor) { + List tuples = new ArrayList<>(mappings.size()); + + for (Map.Entry entry : mappings.entrySet()) { + Node keyNode = nodeConstructor.construct(entry.getKey()); + Node valueNode = nodeConstructor.construct(entry.getValue()); + tuples.add(NodeTuple.of(keyNode, valueNode)); } + return new NodeTree(Tag.MAP, tuples, null, null); + } + + protected Object constructObject(@NotNull Node node) { + Tag tag = node.getTag(); - Construct construct = getConstruct(tag); - if (construct != null) { - return construct.construct(node); + if (tag == Tag.NULL) { + return this.nullConstructor.construct(node); + } + + Construct constructor = getConstruct(tag); + if (constructor != null) { + return constructor.construct(node); } - return null; + + if(node instanceof ScalarNode) { + return constructScalar((ScalarNode) node); + } + + throw new IllegalStateException("Unsupported tag: " + tag); + } + + @Nullable + protected Object constructScalar(@NotNull ScalarNode node) { + return node.getData(); + } + + @Nullable + protected String constructScalarString(@NotNull ScalarNode node) { + Object data = constructScalar(node); + if (data == null) { + return "null"; + } + + return data.toString(); + } + + protected List createList(int initSize) { + return new ArrayList<>(initSize); + } + + protected Set createSet(int initSize) { + return new LinkedHashSet<>(initSize); + } + + protected Map createMap(int initSize) { + return new LinkedHashMap<>(initSize); } + } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java index c858cda..a65cab8 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.java @@ -1,7 +1,8 @@ package dev.spoocy.utils.config.constructor; -import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.nodes.Node; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -10,13 +11,7 @@ @FunctionalInterface public interface Construct { - /** - * Constructs and returns an object based on the provided ConfigNode. - * - * @param node the ConfigNode containing the necessary data for the construction process; must not be null - * - * @return the constructed object based on the data and structure of the given ConfigNode - */ - Object construct(@NotNull ConfigNode node); + @Nullable + Object construct(@Nullable Node node); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java index 8c7fa35..34a60b9 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java @@ -1,6 +1,10 @@ package dev.spoocy.utils.config.constructor; -import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.AbstractConfig; +import dev.spoocy.utils.config.TagProcessor; +import dev.spoocy.utils.config.nodes.NodeTree; +import dev.spoocy.utils.config.representer.Represent; +import dev.spoocy.utils.config.types.JsonConfig; import org.jetbrains.annotations.NotNull; import java.util.Map; @@ -11,6 +15,14 @@ public interface Constructor { - void constructMappings(@NotNull MemorySection section, Map mapping); + @NotNull + NodeTree constructTree(@NotNull Map mappings, @NotNull NodeConstructor nodeConstructor); + /** + * Constructs and populates mappings into the given map based on the provided configuration. + * + * @param config the configuration object containing the data required to construct the mappings; must not be null + * @param map the map into which the constructed mappings are populated; must not be null + */ + void constructMappings(@NotNull AbstractConfig config, @NotNull Map map, @NotNull NodeConstructor nodeConstructor); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/DefaultNodeConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/DefaultNodeConstructor.java new file mode 100644 index 0000000..9b6cd62 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/DefaultNodeConstructor.java @@ -0,0 +1,106 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.TagProcessor; +import dev.spoocy.utils.config.nodes.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class DefaultNodeConstructor implements NodeConstructor { + + private final TagProcessor tagProcessor; + + public DefaultNodeConstructor(@NotNull TagProcessor tagProcessor) { + this.tagProcessor = tagProcessor; + } + + @Override + public @NotNull Node construct(@Nullable Object data) { + + if (data instanceof Node) { + throw new IllegalArgumentException("Data already constructed."); + } + + // what is the type? + Tag tag = resolveTag(data); + + // null + if (tag == Tag.NULL || data == null) { + return ScalarNode.nullValue(); + } + + // sequences + if (data instanceof Set) { + return constructSequence(Tag.SET, (Set) data); + } + + if (data instanceof Iterable) { + return constructSequence(Tag.SEQ, (Iterable) data); + } + + if (data.getClass() + .isArray()) { + int length = Array.getLength(data); + List values = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + values.add(Array.get(data, i)); + } + + return constructSequence(Tag.SEQ, values); + } + + // map + if (data instanceof Map) { + return constructMap((Map) data); + } + + // scalar + return new ScalarNode(data, tag, null, null); + } + + protected SequenceNode constructSequence(@NotNull Tag tag, @NotNull Iterable iterable) { + List nodes = new ArrayList<>(); + + for (Object item : iterable) { + nodes.add(construct(item)); + } + + return new SequenceNode(tag, nodes, null, null); + } + + protected NodeTree constructMap(@NotNull Map map) { + List tuples = new ArrayList<>(map.size()); + + for (Map.Entry entry : map.entrySet()) { + Node keyNode = construct(entry.getKey()); + Node valueNode = construct(entry.getValue()); + tuples.add(NodeTuple.of(keyNode, valueNode)); + } + + return new NodeTree(Tag.MAP, tuples, null, null); + } + + @NotNull + protected Tag resolveTag(@Nullable Object object) { + Class type = object == null ? Node.NULL_TYPE : object.getClass(); + + Tag processorTag = this.tagProcessor.process(type); + + if (processorTag != null) { + return processorTag; + } + + return Tag.getDefaultTag(type); + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/NodeConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/NodeConstructor.java new file mode 100644 index 0000000..b1284d6 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/NodeConstructor.java @@ -0,0 +1,17 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.nodes.Node; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface NodeConstructor { + + @NotNull + Node construct(@Nullable Object value); + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SafeConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SafeConstructor.java new file mode 100644 index 0000000..79e2702 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SafeConstructor.java @@ -0,0 +1,172 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.common.misc.NumberConversion; +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.*; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SafeConstructor extends BaseConstructor { + + public SafeConstructor() { + super(); + + construct(Tag.STR, new StringConstructor()); + construct(Tag.BOOL, new BooleanConstructor()); + construct(Tag.INT, new IntConstructor()); + construct(Tag.FLOAT, new FloatConstructor()); + construct(Tag.SET, new SetConstructor()); + construct(Tag.SEQ, new SequenceConstructor()); + construct(Tag.MAP, new MapConstructor()); + } + + protected class StringConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof ScalarNode)) { + throw new IllegalArgumentException("Tried to construct non-scalar data."); + } + return constructScalarString((ScalarNode) node); + } + } + + protected class BooleanConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof ScalarNode)) { + throw new IllegalArgumentException("Tried to construct non-scalar data."); + } + + ScalarNode scalarNode = (ScalarNode) node; + Object data = scalarNode.getData(); + + if(data == null) { + return false; + } + + String value = data.toString().toLowerCase(); + + if (value.equals("true") || value.equals("yes") || value.equals("on")) { + return true; + + } else if (value.equals("false") || value.equals("no") || value.equals("off")) { + return false; + + } else { + throw new IllegalArgumentException("Invalid boolean value: " + scalarNode.getData()); + } + } + } + + protected class IntConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + + if (!(node instanceof ScalarNode)) { + throw new IllegalArgumentException("Tried to construct non-scalar data."); + } + + ScalarNode scalarNode = (ScalarNode) node; + Object data = scalarNode.getData(); + + if (data instanceof Number) { + return ((Number) data).longValue(); + } + + return NumberConversion.toLong(data); + } + + } + + protected static class FloatConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof ScalarNode)) { + throw new IllegalArgumentException("Tried to construct non-scalar data."); + } + + ScalarNode scalarNode = (ScalarNode) node; + Object data = scalarNode.getData(); + + if (data instanceof Number) { + return ((Number) data).doubleValue(); + } + + return NumberConversion.toDouble(data); + } + } + + + protected class SetConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof SequenceNode)) { + throw new IllegalArgumentException("Tried to construct non-sequence data."); + } + + SequenceNode sequenceNode = (SequenceNode) node; + + Set set = createSet(sequenceNode.size()); + + for (Node item : sequenceNode) { + set.add(constructObject(item)); + } + + return set; + } + } + + protected class SequenceConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof SequenceNode)) { + throw new IllegalArgumentException("Tried to construct non-sequence data."); + } + + SequenceNode sequenceNode = (SequenceNode) node; + + List list = createList(sequenceNode.size()); + + for (Node item : sequenceNode) { + list.add(constructObject(item)); + } + + return list; + } + } + + protected class MapConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (!(node instanceof NodeTree)) { + throw new IllegalArgumentException("Tried to construct non-sequence data."); + } + + NodeTree tree = (NodeTree) node; + Map map = createMap(tree.size()); + + for (NodeTuple tuple : tree) { + Object key = constructObject(tuple.getKeyNode()); + Object value = constructObject(tuple.getValueNode()); + map.put(key, value); + } + + return map; + } + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java deleted file mode 100644 index 41367f1..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializerAssignable.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.spoocy.utils.config.constructor; - -import dev.spoocy.utils.config.serializer.Serializers; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public interface SerializerAssignable { - - /** - * Retrieves the registered {@link Serializers} instance. - * - * @return the registered Serializers instance - */ - Serializers getSerializers(); - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java deleted file mode 100644 index 18e6344..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/constructor/SerializingConstructor.java +++ /dev/null @@ -1,183 +0,0 @@ -package dev.spoocy.utils.config.constructor; - -import dev.spoocy.utils.common.misc.Args; -import dev.spoocy.utils.config.ConfigSection; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.components.MemorySection; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import dev.spoocy.utils.config.serializer.Serializers; -import dev.spoocy.utils.config.serializer.Tag; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -public class SerializingConstructor extends BaseConstructor implements SerializerAssignable { - - public static final SerializingConstructor DEFAULT_INSTANCE = new SerializingConstructor(NamedSerializers.DEFAULT_INSTANCE); - - @NotNull - private final Serializers serializers; - - public SerializingConstructor(@NotNull Serializers serializers) { - this.serializers = Args.notNull(serializers, "serializers"); - - setConstruct(Tag.SECTION, this::constructSection); - setConstruct(Tag.MAP, this::constructMap); - setConstruct(Tag.SEQ, this::constructList); - } - - @Override - public @NotNull Serializers getSerializers() { - return serializers; - } - - private boolean shouldDeserialize(@NotNull Map map) { - Object type = map.get(NamedSerializers.SERIALIZED_TYPE_KEY); - return type instanceof String; - } - - @Override - public void constructMappings(@NotNull MemorySection section, @NotNull Map mapping) { - for (Map.Entry entry : mapping.entrySet()) { - if (!(entry.getKey() instanceof String)) { - continue; - } - - String key = (String) entry.getKey(); - Object value = entry.getValue(); - - if (value instanceof Map && !shouldDeserialize((Map) value)) { - ConfigSection child = section.createSection(key); - this.constructMappings((MemorySection) child, castMap((Map) value)); - continue; - } - - section.set(key, normalize(value)); - } - - super.constructMappings(section, mapping); - } - - @Nullable - @Override - protected Object construct(@Nullable ConfigNode node) { - Object value = super.construct(node); - if (value != null) { - return value; - } - - if (node == null) { - return null; - } - - Class type = node.getType(); - if (type != null && node.getData() instanceof Map) { - Serializer serializer = serializers.resolve(type); - if (serializer != null) { - Object deserialized = serializer.deserializeSafely(castStringMap((Map) node.getData())); - if (deserialized != null) { - return deserialized; - } - } - } - - return node.getData(); - } - - @Nullable - private Object constructSection(@NotNull ConfigNode node) { - Object data = node.getData(); - if (data instanceof MemorySection) { - super.flattenMappings((MemorySection) data); - } - return data; - } - - @Nullable - private Object constructMap(@NotNull ConfigNode node) { - if (!(node.getData() instanceof Map)) { - return node.getData(); - } - - Map map = (Map) node.getData(); - if (shouldDeserialize(map)) { - Object deserialized = serializers.deserialize(castStringMap(map)); - if (deserialized != null) { - return deserialized; - } - } - - Map copy = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - copy.put(entry.getKey(), normalize(entry.getValue())); - } - - return copy; - } - - @Nullable - private Object constructList(@NotNull ConfigNode node) { - Object data = node.getData(); - if (!(data instanceof List)) { - return data; - } - - List represented = new ArrayList<>(((List) data).size()); - for (Object element : (List) data) { - represented.add(normalize(element)); - } - - return represented; - } - - @Nullable - private Object normalize(@Nullable Object value) { - if (value instanceof Map) { - Map map = (Map) value; - - if (shouldDeserialize(map)) { - Object deserialized = serializers.deserialize(castStringMap(map)); - if (deserialized != null) { - return deserialized; - } - } - - Map copy = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - copy.put(entry.getKey(), normalize(entry.getValue())); - } - return copy; - } - - if (value instanceof List) { - List represented = new ArrayList<>(((List) value).size()); - for (Object element : (List) value) { - represented.add(normalize(element)); - } - return represented; - } - - return value; - } - - @NotNull - private static Map castMap(@NotNull Map map) { - Map typed = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - typed.put(entry.getKey(), entry.getValue()); - } - return typed; - } - - @NotNull - private static Map castStringMap(@NotNull Map map) { - Map typed = new LinkedHashMap<>(); - for (Map.Entry entry : map.entrySet()) { - typed.put(String.valueOf(entry.getKey()), entry.getValue()); - } - return typed; - } -} - diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java index d5974fe..0f82bc8 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java @@ -173,6 +173,11 @@ public int hashCode() { return this.absolutePath.hashCode(); } + @Override + public String toString() { + return "Class Path resource [" + this.absolutePath + "]"; + } + private static ClassLoader getDefaultClassLoader() { ClassLoader cl = Thread.currentThread().getContextClassLoader(); if (cl == null) { diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java index 9aadad4..790d6de 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java @@ -166,4 +166,9 @@ public OutputStream getOutputStream() throws IOException { public int hashCode() { return this.path.hashCode(); } + + @Override + public String toString() { + return "File System Resource [" + this.path + "]"; + } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java b/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java index 0f25294..a1f392b 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java @@ -183,5 +183,10 @@ protected boolean shouldApplyCaches() { public int hashCode() { return this.url.hashCode(); } + + @Override + public String toString() { + return "Url resource [" + this.url + "]"; + } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java deleted file mode 100644 index 8891798..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoader.java +++ /dev/null @@ -1,704 +0,0 @@ -package dev.spoocy.utils.config.loader; - -import dev.spoocy.utils.common.misc.Args; -import dev.spoocy.utils.common.version.Version; -import dev.spoocy.utils.config.*; -import dev.spoocy.utils.config.Readable; -import dev.spoocy.utils.reflection.ClassWalker; -import dev.spoocy.utils.reflection.Reflection; -import dev.spoocy.utils.reflection.accessor.Accessor; -import dev.spoocy.utils.reflection.accessor.ConstructorAccessor; -import dev.spoocy.utils.reflection.accessor.FieldAccessor; -import dev.spoocy.utils.reflection.accessor.MethodAccessor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -/** - * Binds config values to annotated classes using reflection. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public class AnnotatedConfigLoader { - - private static final ConcurrentMap, BoundType> TYPES = new ConcurrentHashMap<>(); - - public AnnotatedConfigLoader() { - } - - public T load(@NotNull Class type, @NotNull Readable readable) { - return loadResult(type, readable).instance(); - } - - @NotNull - public LoadResult loadResult(@NotNull Class type, @NotNull Readable readable) { - Args.notNull(type, "type"); - return bind(describe(type), readable); - } - - @SuppressWarnings("unchecked") - public int write(@NotNull T instance, @NotNull Writeable writeable) { - Args.notNull(instance, "instance"); - Args.notNull(writeable, "writeable"); - - BoundType boundType = describe((Class) instance.getClass()); - return writeFieldsToConfig(boundType, instance, writeable, resolveBasePath(boundType.source())); - } - - @NotNull - private LoadResult bind(@NotNull BoundType boundType, @NotNull Readable readable) { - Args.notNull(boundType, "boundType"); - Args.notNull(readable, "readable"); - - T instance = createInstance(boundType); - String basePath = resolveBasePath(boundType.source()); - int mutations = 0; - - invokePreHooks(boundType.preHooks(), instance, readable); - - for (BoundField field : boundType.fields()) { - ConfigProperty property = field.property(); - String rawPath = resolvePropertyPath(field); - String fullPath = combinePath(basePath, rawPath); - boolean shouldPersistField = shouldSaveDefault(boundType.source(), property); - - Object current = field.get(instance); - Object loaded = resolveValue(readable, fullPath, field, current); - - if (loaded == null) { - if (shouldPersistField && current != null && !readable.isSet(fullPath) && readable instanceof Writeable) { - ((Writeable) readable).set(fullPath, current); - mutations++; - } - } else { - field.set(instance, loaded); - } - - if (shouldPersistField) { - mutations += applyPropertyComments(readable, fullPath, property); - } - } - - mutations += applySourceComments(boundType.source(), readable, basePath); - - PostLoadResult postLoadResult = invokePostHooks(boundType.postHooks(), instance, readable); - if (postLoadResult == PostLoadResult.SAVE && readable instanceof Writeable) { - mutations += writeFieldsToConfig(boundType, instance, (Writeable) readable, basePath); - } - - return new LoadResult<>(instance, mutations); - } - - /** - * Writes all bound field values of {@code instance} into {@code writeable} and returns - * the number of fields written (each counts as one mutation). - */ - private static int writeFieldsToConfig( - @NotNull BoundType boundType, - @NotNull T instance, - @NotNull Writeable writeable, - @NotNull String basePath - ) { - int count = 0; - for (BoundField field : boundType.fields()) { - if (!shouldWriteField(boundType.source(), field.property())) { - continue; - } - - String fullPath = combinePath(basePath, resolvePropertyPath(field)); - Object value = field.get(instance); - if (value != null) { - writeable.set(fullPath, value); - count++; - } - } - return count; - } - - @NotNull - @SuppressWarnings("unchecked") - private static BoundType describe(@NotNull Class type) { - return (BoundType) TYPES.computeIfAbsent(type, AnnotatedConfigLoader::createBoundType); - } - - @NotNull - private static BoundType createBoundType(@NotNull Class type) { - return new BoundType<>( - type, - Reflection.getAnnotation(type, ConfigSource.class, false), - resolveConstructor(type), - List.copyOf(resolveFields(type)), - List.copyOf(resolveHooks(type, PreLoad.class)), - List.copyOf(resolveHooks(type, PostLoad.class)) - ); - } - - public static final class LoadResult { - - private final T instance; - private final int mutations; - - private LoadResult(@NotNull T instance, int mutations) { - this.instance = instance; - this.mutations = mutations; - } - - @NotNull - public T instance() { - return this.instance; - } - - public int mutations() { - return this.mutations; - } - } - - @NotNull - private static ConstructorAccessor resolveConstructor(@NotNull Class type) { - ConstructorAccessor accessor = Reflection.builder() - .forClass(type) - .privateMembers() - .buildAccess() - .constructor(); - - if (accessor == null) { - throw new IllegalArgumentException("Class must have a no-args constructor: " + type.getName()); - } - - return accessor; - } - - @NotNull - private static List resolveFields(@NotNull Class type) { - List fields = new ArrayList<>(); - - for (Class current : ClassWalker.walk(type)) { - if (current == Object.class || current.isInterface()) { - continue; - } - - for (Field field : current.getDeclaredFields()) { - BoundField boundField = BoundField.of(field); - if (boundField != null) { - fields.add(boundField); - } - } - } - - return fields; - } - - @NotNull - private static List resolveHooks( - @NotNull Class type, - @NotNull Class annotation - ) { - List hooks = new ArrayList<>(); - - for (Class current : ClassWalker.walk(type)) { - if (current == Object.class || current.isInterface()) { - continue; - } - - for (Method method : current.getDeclaredMethods()) { - BoundHook hook = BoundHook.of(method, annotation); - if (hook != null) { - hooks.add(hook); - } - } - } - - return hooks; - } - - private static void invokePreHooks( - @NotNull List hooks, - @NotNull Object instance, - @NotNull Readable readable - ) { - for (BoundHook hook : hooks) { - hook.invoke(instance, readable); - } - } - - @NotNull - private static PostLoadResult invokePostHooks( - @NotNull List hooks, - @NotNull Object instance, - @NotNull Readable readable - ) { - PostLoadResult result = PostLoadResult.NONE; - for (BoundHook hook : hooks) { - PostLoadResult hookResult = hook.invoke(instance, readable); - if (hookResult.ordinal() > result.ordinal()) { - result = hookResult; - } - } - return result; - } - - @NotNull - private static String resolveBasePath(@Nullable ConfigSource source) { - return source == null ? "" : source.section() - .trim(); - } - - private static boolean shouldSaveDefaults(@Nullable ConfigSource source) { - return source != null && source.saveDefaults(); - } - - private static boolean shouldSaveDefault(@Nullable ConfigSource source, @Nullable ConfigProperty property) { - if (property != null) { - return property.saveDefault() && shouldSaveDefaults(source); - } - return shouldSaveDefaults(source); - } - - private static boolean shouldWriteField(@Nullable ConfigSource source, @Nullable ConfigProperty property) { - return shouldSaveDefault(source, property); - } - - @NotNull - private static String resolvePropertyPath(@NotNull BoundField field) { - ConfigProperty property = field.property(); - if (property == null || property.value() - .trim() - .isEmpty()) { - return field.name(); - } - return property.value() - .trim(); - } - - @NotNull - private static String combinePath(@NotNull String basePath, @NotNull String path) { - if (basePath.isEmpty()) { - return path; - } - if (path.isEmpty()) { - return basePath; - } - return basePath + '.' + path; - } - - @NotNull - private static T createInstance(@NotNull BoundType boundType) { - try { - return boundType.type() - .cast(boundType.constructor() - .invoke()); - } catch (Exception e) { - throw new IllegalArgumentException("Class must have a no-args constructor: " + boundType.type() - .getName(), e); - } - } - - private static int applySourceComments( - @Nullable ConfigSource source, - @NotNull Readable readable, - @NotNull String basePath - ) { - if (!(readable instanceof Commentable)) { - return 0; - } - - if (source == null) { - return 0; - } - - Commentable commentable = (Commentable) readable; - int mutations = 0; - - mutations += updateComments(commentable.getHeaderComments(), toComments(source.headerComments()), commentable::setHeaderComments); - mutations += updateComments(commentable.getFooterComments(), toComments(source.footerComments()), commentable::setFooterComments); - - if (!basePath.isEmpty() && ensurePathExists(readable, basePath)) { - mutations += updateComments(commentable.getComments(basePath), toComments(source.comments()), comments -> commentable.setComments(basePath, comments)); - mutations += updateComments(commentable.getInlineComments(basePath), toComments(source.inlineComments()), comments -> commentable.setInlineComments(basePath, comments)); - } - - return mutations; - } - - private static int applyPropertyComments( - @NotNull Readable readable, - @NotNull String fullPath, - @Nullable ConfigProperty property - ) { - if (!(readable instanceof Commentable) || property == null || !ensurePathExists(readable, fullPath)) { - return 0; - } - - Commentable commentable = (Commentable) readable; - int mutations = 0; - - mutations += updateComments(commentable.getComments(fullPath), toComments(property.comments()), comments -> commentable.setComments(fullPath, comments)); - mutations += updateComments(commentable.getInlineComments(fullPath), toComments(property.inlineComments()), comments -> commentable.setInlineComments(fullPath, comments)); - - return mutations; - } - - private static boolean ensurePathExists(@NotNull Readable readable, @NotNull String path) { - if (path.isEmpty() || readable.isSet(path)) { - return true; - } - - if (!(readable instanceof ConfigSection)) { - return false; - } - - ConfigSection root = (ConfigSection) readable; - char separator = root.getRoot() - .settings() - .pathSeparator(); - String[] parts = path.split(java.util.regex.Pattern.quote(String.valueOf(separator))); - ConfigSection current = root; - - for (int index = 0; index < parts.length - 1; index++) { - String part = parts[index]; - if (part.isEmpty()) { - continue; - } - - ConfigSection next = current.getSection(part); - if (next == null) { - if (current.isSet(part)) { - return false; - } - next = current.createSection(part); - } - current = next; - } - - return readable.isSet(path); - } - - @NotNull - private static List toComments(@NotNull String[] comments) { - if (comments.length == 0) { - return Collections.emptyList(); - } - - List lines = new ArrayList<>(comments.length); - Collections.addAll(lines, comments); - return Collections.unmodifiableList(lines); - } - - private static int updateComments( - @NotNull List current, - @NotNull List desired, - @NotNull CommentSetter setter - ) { - if (desired.isEmpty() || Objects.equals(current, desired)) { - return 0; - } - - setter.apply(desired); - return 1; - } - - @FunctionalInterface - private interface CommentSetter { - - void apply(@NotNull List comments); - } - - @Nullable - @SuppressWarnings("unchecked") - private static Object resolveValue( - @NotNull Readable readable, - @NotNull String path, - @NotNull BoundField field, - @Nullable Object defaultValue - ) { - Class fieldType = field.type(); - if (!readable.isSet(path)) { - return null; - } - - if (fieldType == String.class) { - return readable.getString(path, defaultValue instanceof String ? (String) defaultValue : ""); - } - if (fieldType == int.class || fieldType == Integer.class) { - int fallback = defaultValue instanceof Number ? ((Number) defaultValue).intValue() : 0; - return readable.getInt(path, fallback); - } - if (fieldType == long.class || fieldType == Long.class) { - long fallback = defaultValue instanceof Number ? ((Number) defaultValue).longValue() : 0L; - return readable.getLong(path, fallback); - } - if (fieldType == double.class || fieldType == Double.class) { - double fallback = defaultValue instanceof Number ? ((Number) defaultValue).doubleValue() : 0.0D; - return readable.getDouble(path, fallback); - } - if (fieldType == float.class || fieldType == Float.class) { - float fallback = defaultValue instanceof Number ? ((Number) defaultValue).floatValue() : 0.0F; - return readable.getFloat(path, fallback); - } - if (fieldType == boolean.class || fieldType == Boolean.class) { - boolean fallback = defaultValue instanceof Boolean && (boolean) defaultValue; - return readable.getBoolean(path, fallback); - } - if (fieldType == UUID.class) { - return readable.getUUID(path, (UUID) defaultValue); - } - if (fieldType == Version.class) { - return readable.getVersion(path, (Version) defaultValue); - } - if (fieldType.isEnum()) { - return resolveEnum(readable, path, fieldType, defaultValue); - } - if (Collection.class.isAssignableFrom(fieldType)) { - Class genericType = field.collectionElementType(); - - if (genericType == null) { - throw new IllegalArgumentException( - "Unsupported collection field without resolvable generic type: " + field.name() + - " (path='" + path + "', type=" + fieldType.getName() + ')' - ); - } - - if (List.class.isAssignableFrom(fieldType)) { - return readable.getList(path, genericType, List.of()); - } - - if (Set.class.isAssignableFrom(fieldType)) { - Collection collection = readable.getList(path, genericType, List.of()); - return Set.copyOf(collection); - } - } - - Object value = readable.get(path, fieldType); - if (value != null) { - return value; - } - - return readable.getSerializable(path, (Class) fieldType); - } - - @Nullable - @SuppressWarnings("unchecked") - private static > E resolveEnum( - @NotNull Readable readable, - @NotNull String path, - @NotNull Class enumType, - @Nullable Object defaultValue - ) { - Class castedType = (Class) enumType; - E fallback = castedType.isInstance(defaultValue) ? castedType.cast(defaultValue) : null; - return readable.getEnum(path, castedType, fallback); - } - - private static final class BoundType { - - private final Class type; - private final ConfigSource source; - private final ConstructorAccessor constructor; - private final List fields; - private final List preHooks; - private final List postHooks; - - private BoundType( - @NotNull Class type, - @Nullable ConfigSource source, - @NotNull ConstructorAccessor constructor, - @NotNull List fields, - @NotNull List preHooks, - @NotNull List postHooks - ) { - this.type = type; - this.source = source; - this.constructor = constructor; - this.fields = fields; - this.preHooks = preHooks; - this.postHooks = postHooks; - } - - @NotNull - private Class type() { - return this.type; - } - - @Nullable - private ConfigSource source() { - return this.source; - } - - @NotNull - private ConstructorAccessor constructor() { - return this.constructor; - } - - @NotNull - private List fields() { - return this.fields; - } - - @NotNull - private List preHooks() { - return this.preHooks; - } - - @NotNull - private List postHooks() { - return this.postHooks; - } - } - - private static final class BoundHook { - - private final MethodAccessor accessor; - private final Class parameterType; - private final boolean returnsPostLoadResult; - - @Nullable - private static BoundHook of( - @NotNull Method method, - @NotNull Class annotation - ) { - if (!method.isAnnotationPresent(annotation)) { - return null; - } - - if (Modifier.isStatic(method.getModifiers())) { - throw new IllegalArgumentException("Hook method must not be static: " + method); - } - - Class[] parameters = method.getParameterTypes(); - if (parameters.length > 1) { - throw new IllegalArgumentException("Hook method must have zero or one parameter: " + method); - } - - Class returnType = method.getReturnType(); - boolean returnsPostLoadResult = false; - if (annotation == PostLoad.class) { - if (returnType == PostLoadResult.class) { - returnsPostLoadResult = true; - } else if (returnType != void.class) { - throw new IllegalArgumentException("@PostLoad hook method must return void or PostLoadResult: " + method); - } - } else if (returnType != void.class) { - throw new IllegalArgumentException("Hook method must return void: " + method); - } - - Class parameterType = parameters.length == 0 ? null : parameters[0]; - return new BoundHook(Accessor.getMethod(method), parameterType, returnsPostLoadResult); - } - - private BoundHook( - @NotNull MethodAccessor accessor, - @Nullable Class parameterType, - boolean returnsPostLoadResult - ) { - this.accessor = accessor; - this.parameterType = parameterType; - this.returnsPostLoadResult = returnsPostLoadResult; - } - - @NotNull - private PostLoadResult invoke(@NotNull Object instance, @NotNull Readable readable) { - Object result; - - if (this.parameterType == null) { - result = this.accessor.invoke(instance); - } else { - if (!this.parameterType.isInstance(readable)) { - throw new IllegalArgumentException("Hook parameter type is not compatible with readable instance: " + this.accessor.getMethod()); - } - result = this.accessor.invoke(instance, readable); - } - - if (this.returnsPostLoadResult && result instanceof PostLoadResult) { - return (PostLoadResult) result; - } - - return PostLoadResult.NONE; - } - } - - private static final class BoundField { - - private final FieldAccessor accessor; - private final String name; - private final Class type; - private final Class collectionElementType; - private final ConfigProperty property; - - @Nullable - private static BoundField of(@NotNull Field field) { - if (field.isAnnotationPresent(ConfigIgnore.class)) { - return null; - } - - int modifiers = field.getModifiers(); - if (Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers)) { - return null; - } - - FieldAccessor accessor = Accessor.getField(field); - return new BoundField( - accessor, - field.getName(), - field.getType(), - Reflection.resolveCollectionElementType(field), - field.getAnnotation(ConfigProperty.class) - ); - } - - private BoundField( - @NotNull FieldAccessor accessor, - @NotNull String name, - @NotNull Class type, - @Nullable Class collectionElementType, - @Nullable ConfigProperty property - ) { - this.accessor = accessor; - this.name = name; - this.type = type; - this.collectionElementType = collectionElementType; - this.property = property; - } - - @NotNull - private String name() { - return this.name; - } - - @NotNull - private Class type() { - return this.type; - } - - @Nullable - private Class collectionElementType() { - return this.collectionElementType; - } - - @Nullable - private ConfigProperty property() { - return this.property; - } - - @Nullable - private Object get(@NotNull Object instance) { - return this.accessor.get(instance); - } - - private void set(@NotNull Object instance, @NotNull Object value) { - try { - this.accessor.set(instance, value); - } catch (Exception ex) { - throw new IllegalArgumentException("Failed to set field '" + this.name + "' with value type " + value.getClass() - .getName(), ex); - } - } - } - -} - diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java index 7e33a79..0ca3b79 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java @@ -1,7 +1,6 @@ package dev.spoocy.utils.config.loader; import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.constructor.SerializingConstructor; import dev.spoocy.utils.config.io.Resource; import dev.spoocy.utils.config.constructor.Constructor; import dev.spoocy.utils.config.types.ConfigSettings; diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java index 87f8548..eadeefd 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java @@ -1,16 +1,12 @@ package dev.spoocy.utils.config.loader; import dev.spoocy.utils.config.constructor.Constructor; -import dev.spoocy.utils.config.constructor.SerializerAssignable; -import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.constructor.DefaultNodeConstructor; +import dev.spoocy.utils.config.constructor.NodeConstructor; import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.types.ConfigSettings; import dev.spoocy.utils.config.types.JsonConfig; import dev.spoocy.utils.config.types.JsonSettings; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONArray; -import org.json.JSONObject; import java.io.BufferedReader; import java.io.IOException; @@ -27,6 +23,8 @@ public class JsonConfigLoader implements ConfigLoader public static final JsonConfigLoader INSTANCE = new JsonConfigLoader(); + protected final NodeConstructor nodeConstructor = new DefaultNodeConstructor(o -> null); + private JsonConfigLoader() { } @Override @@ -36,6 +34,7 @@ public String[] getSupportedExtensions() { @Override public JsonConfig createEmpty(@NotNull Consumer settingsEditor) { + checkDependency(); return new JsonConfig(settingsEditor); } @@ -49,13 +48,6 @@ public JsonConfig load( JsonConfig config = new JsonConfig(); - - // apply settings - config.settings().constructor(constructor); - if(constructor instanceof SerializerAssignable) { - config.settings().serializers(((SerializerAssignable) constructor).getSerializers()); - } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { String data = reader.lines().collect(Collectors.joining(System.lineSeparator())); @@ -64,9 +56,8 @@ public JsonConfig load( return config; } - JSONObject json = new JSONObject(data); - Map map = new LinkedHashMap<>(toMap(json)); - constructor.constructMappings(config, map); + Map map = new LinkedHashMap<>((JsonProcessor.toJsonMap(data))); + constructor.constructMappings(config, map, nodeConstructor); return config; } @@ -79,34 +70,4 @@ private static void checkDependency() { throw new IllegalStateException("org.json:json could not be found in the classpath."); } } - - private static Map toMap(@NotNull JSONObject json) { - Map map = new LinkedHashMap<>(); - for (String key : json.keySet()) { - Object val = json.get(key); - map.put(key, toObject(val)); - } - return map; - } - - private static Object toObject(@Nullable Object val) { - if (val instanceof JSONObject) { - return toMap((JSONObject) val); - } - - if (val instanceof JSONArray) { - JSONArray arr = (JSONArray) val; - List list = new ArrayList<>(); - for (int i = 0; i < arr.length(); i++) { - Object element = toObject(arr.get(i)); - list.add(element); - } - return list; - } - - if (val == JSONObject.NULL) { - return null; - } - return val; - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonProcessor.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonProcessor.java new file mode 100644 index 0000000..62c825e --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonProcessor.java @@ -0,0 +1,56 @@ +package dev.spoocy.utils.config.loader; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class JsonProcessor { + + public JsonProcessor() { + + } + + public static Map toJsonMap(@NotNull String json) { + return toMap(new JSONObject(json)); + } + + private static Map toMap(@NotNull JSONObject json) { + Map map = new LinkedHashMap<>(); + for (String key : json.keySet()) { + Object val = json.get(key); + map.put(key, toObject(val)); + } + return map; + } + + private static Object toObject(@Nullable Object val) { + if (val instanceof JSONObject) { + return toMap((JSONObject) val); + } + + if (val instanceof JSONArray) { + JSONArray arr = (JSONArray) val; + List list = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + Object element = toObject(arr.get(i)); + list.add(element); + } + return list; + } + + if (val == JSONObject.NULL) { + return null; + } + return val; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java index 80e9df7..b9f6634 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java @@ -1,37 +1,15 @@ package dev.spoocy.utils.config.loader; import dev.spoocy.utils.config.constructor.Constructor; -import dev.spoocy.utils.config.constructor.SerializerAssignable; -import dev.spoocy.utils.config.constructor.SerializingConstructor; import dev.spoocy.utils.config.io.Resource; import dev.spoocy.utils.config.types.YamlConfig; import dev.spoocy.utils.config.types.YamlSettings; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.yaml.snakeyaml.comments.CommentLine; -import org.yaml.snakeyaml.comments.CommentType; -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.reader.UnicodeReader; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InvalidObjectException; -import java.io.Reader; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; import java.util.function.Consumer; -import dev.spoocy.utils.config.ConfigSection; - /** * @author Spoocy99 | GitHub: Spoocy99 */ @@ -39,8 +17,6 @@ public class YamlConfigLoader implements ConfigLoader public static final YamlConfigLoader INSTANCE = new YamlConfigLoader(); - private final YamlProcessor processor = new YamlProcessor(); - private YamlConfigLoader() { } @Override @@ -50,6 +26,7 @@ public String[] getSupportedExtensions() { @Override public YamlConfig createEmpty(@NotNull Consumer settingsEditor) { + checkDependency(); return new YamlConfig(settingsEditor); } @@ -62,15 +39,8 @@ public YamlConfig load( checkDependency(); YamlConfig config = createEmpty(settingsEditor); - - // apply settings - config.settings().constructor(constructor); - if(constructor instanceof SerializerAssignable) { - config.settings().serializers(((SerializerAssignable) constructor).getSerializers()); - } - String contents = resource.getContentAsString(StandardCharsets.UTF_8); - loadFromString(config, contents, constructor); + new YamlProcessor(config.settings()).loadFromString(config, contents, constructor); return config; } @@ -81,149 +51,4 @@ private static void checkDependency() { throw new IllegalStateException("SnakeYAML could not be found in the classpath."); } } - - public void loadFromString(@NotNull YamlConfig config, @NotNull String contents) throws IOException { - loadFromString(config, contents, SerializingConstructor.DEFAULT_INSTANCE); - } - - private void loadFromString( - @NotNull YamlConfig config, - @NotNull String contents, - @NotNull Constructor constructor - ) throws IOException { - this.processor.applyOptions(config.settings()); - - config.clear(); - config.setHeaderComments(List.of()); - config.setFooterComments(List.of()); - - MappingNode node = composeRootNode(this.processor, contents); - if (node != null) { - adjustNodeComments(node); - config.setHeaderComments(loadHeader(getCommentLines(node.getBlockComments()))); - config.setFooterComments(getCommentLines(node.getEndComments())); - } - - Object loaded = this.processor.loadFromString(contents); - if (!(loaded instanceof Map)) { - return; - } - - Map mapping = new LinkedHashMap<>(); - mapping.putAll((Map) loaded); - constructor.constructMappings(config, mapping); - - if (node != null) { - applyNodeComments(node, config); - } - } - - @Nullable - private MappingNode composeRootNode(@NotNull YamlProcessor processor, @NotNull String contents) throws IOException { - if (contents.trim().isEmpty()) { - return null; - } - - Node rawNode; - try (Reader reader = new UnicodeReader(new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)))) { - rawNode = processor.yaml.compose(reader); - } - - if (rawNode == null) { - return null; - } - - if (!(rawNode instanceof MappingNode)) { - throw new InvalidObjectException("Top level is not a Map."); - } - - return (MappingNode) rawNode; - } - - private void adjustNodeComments(@NotNull MappingNode node) { - if ((node.getBlockComments() == null || node.getBlockComments().isEmpty()) && !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())); - } - } - } - } - - private void applyNodeComments(@NotNull MappingNode input, @NotNull ConfigSection section) { - for (NodeTuple tuple : input.getValue()) { - Node keyNode = tuple.getKeyNode(); - Node valueNode = tuple.getValueNode(); - - if (!(keyNode instanceof ScalarNode)) { - continue; - } - - String key = ((ScalarNode) keyNode).getValue(); - - while (valueNode instanceof AnchorNode) { - valueNode = ((AnchorNode) valueNode).getRealNode(); - } - - section.setComments(key, getCommentLines(keyNode.getBlockComments())); - if (valueNode instanceof MappingNode || valueNode instanceof SequenceNode) { - section.setInlineComments(key, getCommentLines(keyNode.getInLineComments())); - } else { - section.setInlineComments(key, getCommentLines(valueNode.getInLineComments())); - } - - if (valueNode instanceof MappingNode) { - ConfigSection child = section.getSection(key); - if (child != null) { - applyNodeComments((MappingNode) valueNode, child); - } - } - } - } - - @NotNull - private List getCommentLines(@Nullable List comments) { - List lines = new ArrayList<>(); - if (comments == null) { - return lines; - } - - for (CommentLine comment : comments) { - if (comment.getCommentType() == CommentType.BLANK_LINE) { - lines.add(null); - continue; - } - - String line = comment.getValue(); - lines.add(line.startsWith(" ") ? line.substring(1) : line); - } - - return lines; - } - - @NotNull - private List loadHeader(@NotNull List header) { - LinkedList list = new LinkedList<>(header); - - if (!list.isEmpty()) { - list.removeLast(); - } - - while (!list.isEmpty() && list.peek() == null) { - list.remove(); - } - - return list; - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java index ca10f33..08451ce 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java @@ -1,29 +1,36 @@ package dev.spoocy.utils.config.loader; +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.constructor.DefaultNodeConstructor; +import dev.spoocy.utils.config.constructor.NodeConstructor; import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.types.YamlConfig; import dev.spoocy.utils.config.types.YamlSettings; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; 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.constructor.BaseConstructor; import org.yaml.snakeyaml.constructor.SafeConstructor; -import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.*; +import org.yaml.snakeyaml.reader.UnicodeReader; import org.yaml.snakeyaml.representer.Representer; -import java.io.IOException; -import java.io.InputStream; -import java.io.StringReader; -import java.io.Writer; -import java.util.Collections; -import java.util.Map; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; /** * @author Spoocy99 | GitHub: Spoocy99 */ public class YamlProcessor { + protected final NodeConstructor nodeConstructor = new DefaultNodeConstructor(o -> null); + protected final DumperOptions dumperOptions; protected final LoaderOptions loaderOptions; protected final BaseConstructor constructor; @@ -38,6 +45,11 @@ public YamlProcessor() { this.yaml = createYaml(this.constructor, this.representer, this.loaderOptions, this.dumperOptions); } + public YamlProcessor(@NotNull YamlSettings settings) { + this(); + applyOptions(settings); + } + protected DumperOptions createDumperOptions() { DumperOptions options = new DumperOptions(); options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); @@ -109,10 +121,151 @@ private static Map castMapping(@Nullable Object data) { return Collections.emptyMap(); } - Map map = new java.util.LinkedHashMap<>(); + Map map = new LinkedHashMap<>(); for (Map.Entry entry : ((Map) data).entrySet()) { map.put(String.valueOf(entry.getKey()), entry.getValue()); } return map; } + + public void loadFromString( + @NotNull YamlConfig config, + @NotNull String contents, + @NotNull Constructor constructor + ) throws IOException { + this.applyOptions(config.settings()); + + config.clear(); + config.setHeaderComments(List.of()); + config.setFooterComments(List.of()); + + MappingNode node = composeRootNode(contents); + if (node != null) { + adjustNodeComments(node); + config.setHeaderComments(loadHeader(getCommentLines(node.getBlockComments()))); + config.setFooterComments(getCommentLines(node.getEndComments())); + } + + Object loaded = this.loadFromString(contents); + if (!(loaded instanceof Map)) { + return; + } + + Map mapping = new LinkedHashMap<>((Map) loaded); + constructor.constructMappings(config, mapping, nodeConstructor); + + if (node != null) { + applyNodeComments(node, config); + } + } + + @Nullable + private MappingNode composeRootNode(@NotNull String contents) throws IOException { + if (contents.trim().isEmpty()) { + return null; + } + + Node rawNode; + try (Reader reader = new UnicodeReader(new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)))) { + rawNode = this.yaml.compose(reader); + } + + if (rawNode == null) { + return null; + } + + if (!(rawNode instanceof MappingNode)) { + throw new InvalidObjectException("Top level is not a Map."); + } + + return (MappingNode) rawNode; + } + + private void adjustNodeComments(@NotNull MappingNode node) { + if ((node.getBlockComments() == null || node.getBlockComments().isEmpty()) && !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())); + } + } + } + } + + private void applyNodeComments(@NotNull MappingNode input, @NotNull ConfigSection section) { + for (NodeTuple tuple : input.getValue()) { + Node keyNode = tuple.getKeyNode(); + Node valueNode = tuple.getValueNode(); + + if (!(keyNode instanceof ScalarNode)) { + continue; + } + + String key = ((ScalarNode) keyNode).getValue(); + + while (valueNode instanceof AnchorNode) { + valueNode = ((AnchorNode) valueNode).getRealNode(); + } + + section.setComments(key, getCommentLines(keyNode.getBlockComments())); + if (valueNode instanceof MappingNode || valueNode instanceof SequenceNode) { + section.setInlineComments(key, getCommentLines(keyNode.getInLineComments())); + } else { + section.setInlineComments(key, getCommentLines(valueNode.getInLineComments())); + } + + if (valueNode instanceof MappingNode) { + ConfigSection child = section.getSectionIfExists(key); + if (child != null) { + applyNodeComments((MappingNode) valueNode, child); + } + } + } + } + + @NotNull + private List getCommentLines(@Nullable List comments) { + List lines = new ArrayList<>(); + if (comments == null) { + return lines; + } + + for (CommentLine comment : comments) { + if (comment.getCommentType() == CommentType.BLANK_LINE) { + lines.add(null); + continue; + } + + String line = comment.getValue(); + lines.add(line.startsWith(" ") ? line.substring(1) : line); + } + + return lines; + } + + @NotNull + private List loadHeader(@NotNull List header) { + LinkedList list = new LinkedList<>(header); + + if (!list.isEmpty()) { + list.removeLast(); + } + + while (!list.isEmpty() && list.peek() == null) { + list.remove(); + } + + return list; + } + } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/CollectionNode.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/CollectionNode.java new file mode 100644 index 0000000..1c136f0 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/CollectionNode.java @@ -0,0 +1,28 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.config.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Iterator; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class CollectionNode extends Node implements Iterable { + + public CollectionNode( + @NotNull Tag tag, + @Nullable List comments, + @Nullable List inlineComments + ) { + super(tag, comments, inlineComments); + } + + public abstract List getValue(); + + @Override + public abstract @NotNull Iterator iterator(); +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ConfigData.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ConfigData.java new file mode 100644 index 0000000..6e7a8b7 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ConfigData.java @@ -0,0 +1,57 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.config.ConfigSection; +import dev.spoocy.utils.config.MemorySection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ConfigData { + + @NotNull + protected List comments; + + @NotNull + protected List inlineComments; + + public ConfigData(@Nullable List comments, @Nullable List inlineComments) { + this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + } + + @NotNull + public List getComments() { + return this.comments; + } + + @NotNull + public List getInlineComments() { + return this.inlineComments; + } + + public void setComments(@Nullable List comments) { + this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + } + + public void setInlineComments(@Nullable List inlineComments) { + this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + } + + public MemoryData asHolder() { + return new MemoryData(this.comments, this.inlineComments); + } + + public MemorySection asSection(@NotNull ConfigSection parent, @NotNull String path) { + MemorySection section = new MemorySection(parent, path); + section.comments = this.comments; + section.inlineComments = this.inlineComments; + return section; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/MemoryData.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/MemoryData.java new file mode 100644 index 0000000..96dd82c --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/MemoryData.java @@ -0,0 +1,53 @@ +package dev.spoocy.utils.config.nodes; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class MemoryData extends ConfigData { + + @NotNull + protected Class type; + + @Nullable + protected Object data; + + public MemoryData(@Nullable List comments, @Nullable List inlineComments) { + super(comments, inlineComments); + this.type = Node.NULL_TYPE; + this.data = null; + } + + @NotNull + public Class getType() { + return this.type; + } + + public boolean hasData() { + return this.data != null; + } + + @Nullable + public Object getData() { + return this.data; + } + + public void setData(@Nullable Object data) { + + if (data == null) { + this.type = Node.NULL_TYPE; + this.data = null; + } else { + this.type = data.getClass(); + this.data = data; + } + + } + +} + diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/Node.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/Node.java new file mode 100644 index 0000000..1d1e3d3 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/Node.java @@ -0,0 +1,72 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class Node { + + public static final Class NULL_TYPE = void.class; + + @NotNull + private Tag tag; + + @NotNull + protected List comments; + + @NotNull + protected List inlineComments; + + public Node(@NotNull Tag tag, @Nullable List comments, @Nullable List inlineComments) { + setTag(tag); + setComments(comments); + setInlineComments(inlineComments); + } + + @NotNull + public Tag getTag() { + return tag; + } + + @NotNull + public List getComments() { + return comments; + } + + @NotNull + public List getInlineComments() { + return inlineComments; + } + + public void setTag(@NotNull Tag tag) { + this.tag = Args.notNull(tag, "tag"); + } + + public void setComments(@Nullable List comments) { + this.comments = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + } + + public void setInlineComments(@Nullable List inlineComments) { + this.inlineComments = inlineComments == null ? Collections.emptyList() : Collections.unmodifiableList(inlineComments); + } + + @NotNull + public abstract NodeType getNodeType(); + + public String displayForm() { + return NodeTree.displayForm(0, this); + } + + public String displayForm(int indent) { + return NodeTree.displayForm(indent, this); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTree.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTree.java new file mode 100644 index 0000000..391a93a --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTree.java @@ -0,0 +1,132 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Iterator; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class NodeTree extends CollectionNode { + + private List values; + + public NodeTree( + @NotNull Tag tag, + @NotNull List values, + @Nullable List comments, + @Nullable List inlineComments + ) { + super(tag, comments, inlineComments); + setValue(values); + } + + public void setValue(@NotNull List values) { + this.values = Args.notNull(values, "values"); + } + + public int size() { + return this.values.size(); + } + + @Override + public List getValue() { + return this.values; + } + + @NotNull + @Override + public Iterator iterator() { + return this.values.iterator(); + } + + @Override + public @NotNull NodeType getNodeType() { + return NodeType.TREE; + } + + @Override + public String toString() { + return ""; + } + + private String formatSequence() { + return this.values.toString(); + } + + public String displayForm() { + return displayForm(0, this); + } + + public String displayForm(int indent) { + return displayForm(indent, this); + } + + public static @NotNull String displayForm(@NotNull Node node) { + return displayForm(0, node); + } + + public static @NotNull String displayForm(int indent, @NotNull Node node) { + Args.notNull(node, "node"); + String spaces = " ".repeat(Math.max(0, indent)); + + if (node instanceof ScalarNode) { + return spaces + node; + } + + Tag tag = node.getTag(); + + if (node instanceof SequenceNode) { + SequenceNode sequenceNode = (SequenceNode) node; + List values = sequenceNode.getValue(); + if (values.isEmpty()) { + return spaces + ""; + } + + StringBuilder builder = new StringBuilder(); + builder.append(spaces).append(""); + return builder.toString(); + } + + if (node instanceof NodeTree) { + NodeTree treeNode = (NodeTree) node; + List values = treeNode.getValue(); + if (values.isEmpty()) { + return spaces + ""; + } + + StringBuilder builder = new StringBuilder(); + builder.append(spaces).append(""); + if (i < values.size() - 1) { + builder.append(","); + } + builder.append("\n"); + } + builder.append(spaces).append("]>"); + return builder.toString(); + } + + return spaces + node; + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTuple.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTuple.java new file mode 100644 index 0000000..a50dcf4 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeTuple.java @@ -0,0 +1,54 @@ +package dev.spoocy.utils.config.nodes; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class NodeTuple { + + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull NodeTuple of(@NotNull Node keyNode, @NotNull Node valueNode) { + return new NodeTuple(keyNode, valueNode); + } + + @NotNull + private final Node keyNode; + + @NotNull + private final Node valueNode; + + private NodeTuple(@NotNull Node keyNode, @NotNull Node valueNode) { + this.keyNode = keyNode; + this.valueNode = valueNode; + } + + @NotNull + public Node getKeyNode() { + return this.keyNode; + } + + @NotNull + public Node getValueNode() { + return this.valueNode; + } + + @Override + public String toString() { + return ""; + } + + public String displayForm() { + return displayForm(0); + } + + public String displayForm(int indent) { + String spaces = " ".repeat(Math.max(0, indent)); + return spaces + ""; + } +} \ No newline at end of file diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeType.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeType.java new file mode 100644 index 0000000..9ae232e --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/NodeType.java @@ -0,0 +1,31 @@ +package dev.spoocy.utils.config.nodes; + +/** + * Represents a scalar node type in a configuration structure. + * + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public enum NodeType { + + /** + * A scalar node represents a single value. + *

+ * {@link ScalarNode} + */ + SCALAR, + + /** + * A sequence node corresponds to an ordered collection of elements. + *

+ * {@link SequenceNode} + */ + SEQUENCE, + + /** + * A tree node corresponds to a collection of key-value pairs. + *

+ * {@link NodeTree} + */ + TREE +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ScalarNode.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ScalarNode.java new file mode 100644 index 0000000..68390b7 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/ScalarNode.java @@ -0,0 +1,57 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.config.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ScalarNode extends Node { + + public static ScalarNode nullValue() { + return new ScalarNode(null, Tag.NULL, null, null); + } + + private Object data; + + public ScalarNode( + @Nullable Object data, + @NotNull Tag tag, + @Nullable List comments, + @Nullable List inlineComments + ) { + super(tag, comments, inlineComments); + this.data = data; + } + + @Override + public @NotNull NodeType getNodeType() { + return NodeType.SCALAR; + } + + @Nullable + public Object getData() { + return data; + } + + @Nullable + public String getValue() { + if (data == null) { + return null; + } + return data.toString(); + } + + public void setData(@Nullable Object data) { + this.data = data; + } + + @Override + public String toString() { + return ""; + } +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/nodes/SequenceNode.java b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/SequenceNode.java new file mode 100644 index 0000000..c5ef22f --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/nodes/SequenceNode.java @@ -0,0 +1,61 @@ +package dev.spoocy.utils.config.nodes; + +import dev.spoocy.utils.common.misc.Args; +import dev.spoocy.utils.config.Tag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Iterator; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SequenceNode extends CollectionNode { + + private List values; + + public SequenceNode( + @NotNull Tag tag, + @NotNull List values, + @Nullable List comments, + @Nullable List inlineComments + ) { + super(tag, comments, inlineComments); + setValue(values); + } + + public void setValue(@NotNull List values) { + this.values = Args.notNull(values, "values"); + } + + public int size() { + return this.values.size(); + } + + @Override + public List getValue() { + return this.values; + } + + @Override + public @NotNull Iterator iterator() { + return this.values.iterator(); + } + + @Override + public @NotNull NodeType getNodeType() { + return NodeType.SEQUENCE; + } + + @Override + public String toString() { + return ""; + } + + private String formatSequence() { + return this.values.toString(); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java index 65bda95..bcc0d99 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java @@ -1,15 +1,18 @@ package dev.spoocy.utils.config.representer; import dev.spoocy.utils.common.tuple.Pair; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.components.MemorySection; -import dev.spoocy.utils.config.serializer.Tag; +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.ConfigData; +import dev.spoocy.utils.config.MemorySection; +import dev.spoocy.utils.config.nodes.NodeTree; +import dev.spoocy.utils.config.nodes.ScalarNode; +import dev.spoocy.utils.config.nodes.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.*; + +import static dev.spoocy.utils.config.nodes.Node.NULL_TYPE; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -17,49 +20,197 @@ public abstract class BaseRepresenter implements Representer { - private final Map represents = new HashMap<>(); + /** + * The representer for null values. + */ + protected Represent nullRepresenter = data -> ScalarNode.nullValue(); + + /** + * Direct representers. + */ + protected final Map, Represent> representers = new HashMap<>(); + + /** + * Assignable representers. + */ + protected final Map, Represent> parentRepresenters = new LinkedHashMap<>(); + + /** + * Representer required for all types. + */ + private boolean strict = false; public BaseRepresenter() { } - protected void setRepresent(@NotNull Tag tag, @NotNull Represent represent) { - this.represents.put(tag, represent); + protected void setStrict(boolean strict) { + this.strict = strict; } - protected void setRepresent(@NotNull Class clazz, @NotNull Represent represent) { - setRepresent(new Tag(clazz), represent); + protected void representNull(@NotNull Represent represent) { + this.nullRepresenter = represent; } - @Override - public @NotNull Map represent(@NotNull MemorySection section) { - Map represented = new LinkedHashMap<>(); + protected void represent(@NotNull Class type, @NotNull Represent represent) { + this.representers.put(type, represent); + } + + protected void representOf(@NotNull Class parent, @NotNull Represent represent) { + this.parentRepresenters.put(parent, represent); + } + + @Nullable + protected Represent getRepresent(@NotNull Class type) { + type = unwrap(type); + + if (type == NULL_TYPE) { + return nullRepresenter; + } + + Represent represent = representers.get(type); + if (represent != null) { + return represent; + } - for (Pair pair : section.entries()) { - represented.put(pair.first(), represent(pair.second())); + for (Map.Entry, Represent> entry : parentRepresenters.entrySet()) { + if (entry.getKey().isAssignableFrom(type)) { + return entry.getValue(); + } } - return represented; + return null; } - @Nullable - protected Represent getRepresent(@NotNull Tag tag) { - return this.represents.get(tag); + protected Class unwrap(@NotNull Class type) { + + if(type.isPrimitive()) { + if(type == int.class) return Integer.class; + if(type == long.class) return Long.class; + if(type == double.class) return Double.class; + if(type == float.class) return Float.class; + if(type == boolean.class) return Boolean.class; + if(type == char.class) return Character.class; + if(type == byte.class) return Byte.class; + if(type == short.class) return Short.class; + } + + return type; } - @Nullable @Override - public Object represent(@Nullable ConfigNode data) { + public @NotNull NodeTree createTree(@NotNull MemorySection section) { + List tuples = new ArrayList<>(); + + for (Pair entry : section.entries()) { + + ConfigData data = entry.second(); + if (data == null) { + continue; + } + + ScalarNode keyNode = representScalar(Tag.STR, entry.first()); + Node valueNode = representData(data); + + tuples.add(NodeTuple.of(keyNode, valueNode)); + } + + return new NodeTree(Tag.MAP, tuples, section.getComments(), section.getInlineComments()); + } + + @NotNull + protected Node representData(@NotNull ConfigData data) { + if (data instanceof MemorySection) { + return createTree((MemorySection) data); + } + + if (data instanceof MemoryData) { + MemoryData memoryData = (MemoryData) data; + Node node = representObject(memoryData.getData()); + + node.setComments(data.getComments()); + node.setInlineComments(data.getInlineComments()); + return node; + } + + throw new IllegalArgumentException("Invalid ConfigData type: " + data.getClass().getName()); + } + + @NotNull + protected Node representObject(@Nullable Object data) { + + if (data instanceof Node) { + throw new IllegalArgumentException("Data already represented."); + } + + if (data instanceof ConfigData) { + data = representData((ConfigData) data); + } + if (data == null) { - return null; + return nullRepresenter.represent(null); } - Represent represent = getRepresent(data.getTag()); + Class type = data.getClass(); + Represent represent = getRepresent(type); + if (represent != null) { return represent.represent(data); } - return null; + if(strict) { + throw new IllegalArgumentException("Type '" + type.getName() + "' cannot be represented."); + } + + return representScalar(new Tag(data.getClass()), data); } -} + protected ScalarNode representScalar(@NotNull Tag tag, @NotNull Object data) { + return representScalar(tag, data, null, null); + } + + protected ScalarNode representScalar( + @NotNull Tag tag, + @NotNull Object data, + @Nullable List comments, + @Nullable List inlineComments + ) { + return new ScalarNode(data, tag, comments, inlineComments); + } + + protected SequenceNode representSequence(@NotNull Tag tag, @NotNull Collection data) { + return representSequence(tag, data, null, null); + } + + protected SequenceNode representSequence( + @NotNull Tag tag, + @NotNull Collection data, + @Nullable List comments, + @Nullable List inlineComments + ) { + final List nodes = new ArrayList<>(data.size()); + for (Object obj : data) { + nodes.add(representObject(obj)); + } + return new SequenceNode(tag, nodes, comments, inlineComments); + } + + protected NodeTree representMapping(@NotNull Map data) { + return representMapping(data, null, null); + } + + protected NodeTree representMapping( + @NotNull Map data, + @Nullable List comments, + @Nullable List inlineComments + ) { + final List tuples = new ArrayList<>(data.size()); + for (Map.Entry entry : data.entrySet()) { + Node keyNode = representObject(entry.getKey()); + Node valueNode = representObject(entry.getValue()); + tuples.add(NodeTuple.of(keyNode, valueNode)); + } + return new NodeTree(Tag.MAP, tuples, comments, inlineComments); + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java index accf9d4..21e5594 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java @@ -1,7 +1,8 @@ package dev.spoocy.utils.config.representer; -import dev.spoocy.utils.config.components.ConfigNode; +import dev.spoocy.utils.config.nodes.Node; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -10,14 +11,8 @@ @FunctionalInterface public interface Represent { - /** - * Represents and returns a serializable object based on the provided ConfigNode. - * - * @param node the ConfigNode containing data to represent; must not be null - * - * @return represented data - */ - Object represent(@NotNull ConfigNode node); + @NotNull + Node represent(@Nullable Object data); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java index b57fcc8..4408e64 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java @@ -1,11 +1,8 @@ package dev.spoocy.utils.config.representer; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.components.MemorySection; +import dev.spoocy.utils.config.MemorySection; +import dev.spoocy.utils.config.nodes.NodeTree; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -13,10 +10,10 @@ public interface Representer { + /** + * + */ @NotNull - Map represent(@NotNull MemorySection section); - - @Nullable - Object represent(@Nullable ConfigNode data); + NodeTree createTree(@NotNull MemorySection section); } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/SafeRepresenter.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/SafeRepresenter.java new file mode 100644 index 0000000..869dac2 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/SafeRepresenter.java @@ -0,0 +1,275 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.representer.Representer; + +import java.util.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class SafeRepresenter extends BaseRepresenter { + + public SafeRepresenter() { + super(); + representNull(new NullRepresenter()); + + represent(String.class, new StringRepresenter()); + represent(Boolean.class, new BooleanRepresenter()); + + Represent primitiveArrayRepresenter = new RepresentPrimitiveArray(); + represent(byte[].class, primitiveArrayRepresenter); + represent(short[].class, primitiveArrayRepresenter); + represent(int[].class, primitiveArrayRepresenter); + represent(long[].class, primitiveArrayRepresenter); + represent(float[].class, primitiveArrayRepresenter); + represent(double[].class, primitiveArrayRepresenter); + represent(char[].class, primitiveArrayRepresenter); + represent(boolean[].class, primitiveArrayRepresenter); + + representOf(Number.class, new NumberRepresenter()); + representOf(Object[].class, new RepresentArray()); + representOf(Set.class, new SetRepresenter()); + representOf(Map.class, new MapRepresenter()); + representOf(List.class, new ListRepresenter()); + representOf(Enum.class, new RepresentEnum()); + } + + protected class NullRepresenter implements Represent { + @Override + public @NotNull Node represent(@Nullable Object data) { + return ScalarNode.nullValue(); + } + } + + protected class StringRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof String)) { + throw new IllegalArgumentException("Tried to represent non-string data."); + } + return representScalar(Tag.STR, data); + } + + } + + protected class BooleanRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof Boolean)) { + throw new IllegalArgumentException("Tried to represent non-boolean data."); + } + return representScalar(Tag.BOOL, data); + } + } + + protected class NumberRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof Number)) { + throw new IllegalArgumentException("Tried to represent non-number data."); + } + + Class type = data.getClass(); + + if (Tag.INT.isCompatible(type)) { + return representScalar(Tag.INT, data); + } + + if (Tag.FLOAT.isCompatible(type)) { + return representScalar(Tag.FLOAT, data); + } + + if (Tag.BOOL.isCompatible(type)) { + return representScalar(Tag.BOOL, data); + } + + throw new IllegalArgumentException("No compatible tag found for type: " + type.getName()); + } + } + + + protected class RepresentArray implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if(!(data.getClass().isArray())) { + throw new IllegalArgumentException("Tried to represent non-array data."); + } + + Object[] array = (Object[]) data; + List list = Arrays.asList(array); + return representSequence(Tag.SEQ, list); + } + } + + protected class RepresentPrimitiveArray implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + Class type = data.getClass().getComponentType(); + + if (byte.class == type) { + return representSequence(Tag.SEQ, asByteList(data)); + } else if (short.class == type) { + return representSequence(Tag.SEQ, asShortList(data)); + } else if (int.class == type) { + return representSequence(Tag.SEQ, asIntList(data)); + } else if (long.class == type) { + return representSequence(Tag.SEQ, asLongList(data)); + } else if (float.class == type) { + return representSequence(Tag.SEQ, asFloatList(data)); + } else if (double.class == type) { + return representSequence(Tag.SEQ, asDoubleList(data)); + } else if (char.class == type) { + return representSequence(Tag.SEQ, asCharList(data)); + } else if (boolean.class == type) { + return representSequence(Tag.SEQ, asBooleanList(data)); + } + + throw new YAMLException("Unexpected primitive '" + type.getCanonicalName() + "'"); + } + + @NotNull + private List asByteList(@NotNull Object in) { + byte[] array = (byte[]) in; + List list = new ArrayList<>(array.length); + for (byte b : array) { + list.add(b); + } + return list; + } + + @NotNull + private List asShortList(@NotNull Object in) { + short[] array = (short[]) in; + List list = new ArrayList<>(array.length); + for (short value : array) { + list.add(value); + } + return list; + } + + @NotNull + private List asIntList(@NotNull Object in) { + int[] array = (int[]) in; + List list = new ArrayList<>(array.length); + for (int j : array) { + list.add(j); + } + return list; + } + + @NotNull + private List asLongList(@NotNull Object in) { + long[] array = (long[]) in; + List list = new ArrayList<>(array.length); + for (long l : array) { + list.add(l); + } + return list; + } + + @NotNull + private List asFloatList(@NotNull Object in) { + float[] array = (float[]) in; + List list = new ArrayList<>(array.length); + for (float v : array) { + list.add(v); + } + return list; + } + + @NotNull + private List asDoubleList(@NotNull Object in) { + double[] array = (double[]) in; + List list = new ArrayList<>(array.length); + for (double v : array) { + list.add(v); + } + return list; + } + + @NotNull + private List asCharList(Object in) { + char[] array = (char[]) in; + List list = new ArrayList<>(array.length); + for (char c : array) { + list.add(c); + } + return list; + } + + @NotNull + private List asBooleanList(Object in) { + boolean[] array = (boolean[]) in; + List list = new ArrayList<>(array.length); + for (boolean b : array) { + list.add(b); + } + return list; + } + } + + protected class SetRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof Set)) { + throw new IllegalArgumentException("Tried to represent non-set data."); + } + + Set set = (Set) data; + return representSequence(Tag.SET, set); + } + } + + protected class ListRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof List)) { + throw new IllegalArgumentException("Tried to represent non-list data."); + } + + List list = (List) data; + return representSequence(Tag.SEQ, list); + } + } + + protected class MapRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof Map)) { + throw new IllegalArgumentException("Tried to represent non-map data."); + } + + Map map = (Map) data; + return representMapping(map); + } + } + + protected class RepresentEnum implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + if (!(data instanceof Enum)) { + throw new IllegalArgumentException("Tried to represent non-enum data."); + } + Tag tag = new Tag(data.getClass()); + return representScalar(tag, ((Enum) data).name()); + } + + } + +} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java b/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java deleted file mode 100644 index cc7a3be..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/representer/SerializingRepresenter.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.spoocy.utils.config.representer; - -import dev.spoocy.utils.common.misc.Args; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.components.MemorySection; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import dev.spoocy.utils.config.serializer.Serializers; -import dev.spoocy.utils.config.serializer.Tag; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -/** - * Default representation strategy that converts config sections and nodes into serializable data. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public class SerializingRepresenter extends BaseRepresenter { - - public static final SerializingRepresenter DEFAULT_INSTANCE = new SerializingRepresenter(NamedSerializers.DEFAULT_INSTANCE); - - private final Serializers serializers; - - public SerializingRepresenter(@NotNull Serializers serializers) { - this.serializers = Args.notNull(serializers, "serializers"); - - setRepresent(Tag.SECTION, this::representSection); - setRepresent(Tag.MAP, this::representMap); - setRepresent(Tag.SEQ, this::representList); - } - - @Override - public @Nullable Object represent(@Nullable ConfigNode data) { - Object represented = super.represent(data); - if (represented != null) { - return represented; - } - - if (data == null) { - return null; - } - - Object nodeData = data.getData(); - Tag tag = data.getTag(); - - // For typed objects (those with a custom class tag), attempt serialization - Class type = data.getType(); - if (type != null && !tag.isStandard()) { - @SuppressWarnings("unchecked") - Map serialized = serializeIfSupported(nodeData, (Class) type); - if (serialized != null) { - return serialized; - } - } - - // Return the data as-is for scalars and other types - return nodeData; - } - - @Nullable - private Object representSection(@NotNull ConfigNode node) { - Object data = node.getData(); - if (data instanceof MemorySection) { - return represent((MemorySection) data); - } - return data; - } - - @Nullable - private Object representMap(@NotNull ConfigNode node) { - Object data = node.getData(); - if (!(data instanceof Map)) { - return data; - } - - Map represented = new LinkedHashMap<>(); - for (Map.Entry entry : ((Map) data).entrySet()) { - Object value = entry.getValue(); - if (value instanceof ConfigNode) { - value = represent((ConfigNode) value); - } - - represented.put(entry.getKey(), value); - } - - return represented; - } - - @Nullable - private Object representList(@NotNull ConfigNode node) { - Object data = node.getData(); - if (!(data instanceof List)) { - return data; - } - - List represented = new ArrayList<>(((List) data).size()); - for (Object element : (List) data) { - if (element instanceof ConfigNode) { - represented.add(represent((ConfigNode) element)); - } else { - represented.add(element); - } - } - - return represented; - } - - - @Nullable - private Map serializeIfSupported(@NotNull T data, @NotNull Class clazz) { - Serializer serializer = this.serializers.resolve(clazz); - return serializer == null ? null : serializer.serializeSafely(data); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java deleted file mode 100644 index 7a1b179..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/BaseSerializers.java +++ /dev/null @@ -1,60 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import dev.spoocy.utils.config.serializer.impl.AtomicsSerializer; -import dev.spoocy.utils.config.serializer.impl.DurationSerializer; -import dev.spoocy.utils.config.serializer.impl.JavaSerializer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.Serializable; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public abstract class BaseSerializers implements Serializers { - - public static final String SERIALIZED_TYPE_KEY = "=="; - private static final Map, Serializer> DEFAULT_SERIALIZERS = new ConcurrentHashMap<>(); - - static { - registerDefault(Duration.class, DurationSerializer.INSTANCE); - registerDefault(AtomicInteger.class, new AtomicsSerializer<>(AtomicInteger.class)); - registerDefault(AtomicLong.class, new AtomicsSerializer<>(AtomicLong.class)); - registerDefault(AtomicBoolean.class, new AtomicsSerializer<>(AtomicBoolean.class)); - } - - public static void registerDefault(@NotNull Class clazz, @NotNull Serializer serializer) { - DEFAULT_SERIALIZERS.put(clazz, serializer); - } - - private final Map, Serializer> serializers = new ConcurrentHashMap<>(); - - protected BaseSerializers() { } - - @Override - public void register(@NotNull Class clazz, @NotNull Serializer serializer) { - this.serializers.put(clazz, serializer); - } - - public void registerJavaSerializer(@NotNull Class clazz) { - this.register(clazz, JavaSerializer.create(clazz)); - } - - @Override - public @Nullable Serializer resolve(@NotNull Class clazz) { - Serializer serializer = this.serializers.get(clazz); - if (serializer != null) { - return (Serializer) serializer; - } - - return (Serializer) DEFAULT_SERIALIZERS.get(clazz); - } - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java deleted file mode 100644 index ead4fb0..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import java.util.Map; - -/** - * Interface for serializable configuration objects. - *

- * When implementing this interface, the class is expected to - * contain a public static method {@code deserialize(Map map)} that returns a - * new instance of the class from a Map. - *

- * {@code
- * public static MyClass deserialize(Map map) {
- *      // Deserialize the map into an instance of MyClass
- *      return new MyClass(...);
- * }
- *
- * }
- *
- * @author Spoocy99 | GitHub: Spoocy99
- */
-public interface ConfigSerializable {
-
-    Map serialize();
-
-}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/DelegateDeserialization.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/DelegateDeserialization.java
deleted file mode 100644
index 37b3ec4..0000000
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/DelegateDeserialization.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.spoocy.utils.config.serializer;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Applies to a {@link ConfigSerializable} that will delegate all
- * deserialization to another {@link ConfigSerializable}.
- *
- * @author Spoocy99 | GitHub: Spoocy99
- */
-@Target(ElementType.TYPE)
-@Retention(RetentionPolicy.RUNTIME)
-public @interface DelegateDeserialization {
-
-    /**
-     * Defines which class should be used as a delegate for this
-     * classes' deserialization.
-     *
-     * @return the delegate class
-     */
-    @NotNull
-    Class value();
-
-}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java
deleted file mode 100644
index ceebcaf..0000000
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/NamedSerializers.java
+++ /dev/null
@@ -1,203 +0,0 @@
-package dev.spoocy.utils.config.serializer;
-
-import dev.spoocy.utils.common.cache.Cache;
-import dev.spoocy.utils.common.cache.Caches;
-import dev.spoocy.utils.reflection.Reflection;
-import dev.spoocy.utils.reflection.accessor.ClassAccess;
-import dev.spoocy.utils.reflection.accessor.MethodAccessor;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * @author Spoocy99 | GitHub: Spoocy99
- */
-
-public class NamedSerializers extends BaseSerializers {
-
-    public static final NamedSerializers DEFAULT_INSTANCE = new NamedSerializers();
-
-    /**
-     * Get the serialization name for a given {@link ConfigSerializable} class.
-     *
-     * @param clazz the class to get the serialization name for
-     *
-     * @return the serialization name
-     */
-    @NotNull
-    public static String getSerializeName(@NotNull Class clazz) {
-        DelegateDeserialization delegate = clazz.getAnnotation(DelegateDeserialization.class);
-        if (delegate != null && delegate.value() != clazz) {
-            return getSerializeName(delegate.value());
-        }
-
-        SerializableAs alias = clazz.getAnnotation(SerializableAs.class);
-        if (alias != null) {
-            return alias.value();
-        }
-
-        return clazz.getName();
-    }
-
-    private final Map> names = new ConcurrentHashMap<>();
-
-    public NamedSerializers() {
-        super();
-    }
-
-    public void register(@NotNull Class clazz) {
-        String name = getSerializeName(clazz);
-        this.registerName(clazz, name);
-    }
-
-    protected void registerName(@NotNull Class clazz, @NotNull String name) {
-        if (this.names.containsKey(name)) {
-            throw new IllegalArgumentException("Class with name '" + name + "' is already registered for serialization: " + this.names.get(name).getName());
-        }
-        this.names.put(name, clazz);
-    }
-
-    /**
-     * Get a registered {@link ConfigSerializable} class by its serialization name.
-     *
-     * @param name the name used during serialization
-     *
-     * @return the registered class, or null if not found
-     */
-    @Nullable
-    public Class getSerializableClassByName(@NotNull String name) {
-        return this.names.get(name);
-    }
-
-    @Nullable
-    public  Serializer resolve(@NotNull Class clazz) {
-        Serializer serializer = super.resolve(clazz);
-
-        if(serializer != null) {
-            // direct serializer found
-
-            return (Serializer) serializer;
-        }
-
-        if(ConfigSerializable.class.isAssignableFrom(clazz)) {
-            // implements ConfigSerializable
-
-            Class csClass = (Class) clazz;
-            return (Serializer) ClassSerializer.create(csClass);
-
-        }
-
-        return null;
-    }
-
-    @Override
-    public @NotNull Map serialize(@NotNull Object object) {
-        Serializer serializer = resolve(object.getClass());
-        if (serializer == null) {
-            throw new IllegalArgumentException("No serializer found for class: " + object.getClass().getName());
-        }
-        return serializer.serialize(object);
-    }
-
-    @Override
-    public @Nullable Object deserialize(@NotNull Map map) {
-        Object typeObj = map.get(SERIALIZED_TYPE_KEY);
-        if (!(typeObj instanceof String)) {
-            return null;
-        }
-
-        String typeName = (String) typeObj;
-        Class clazz = getSerializableClassByName(typeName);
-        if (clazz == null) {
-            return null;
-        }
-
-        Serializer serializer = resolve(clazz);
-        if (serializer == null) {
-            return null;
-        }
-
-        try {
-            return serializer.deserialize(map);
-        } catch (Exception e) {
-            return null;
-        }
-    }
-
-    /**
-     * Serializer implementation for classes implementing {@link ConfigSerializable}.
-     */
-    public static class ClassSerializer implements Serializer {
-
-        private static final Cache, ClassSerializer> CLASS_CACHE = Caches.createLRUCache(200);
-        private static final Cache METHOD_CACHE = Caches.createLRUCache(200);
-
-        public static  ClassSerializer create(@NotNull Class clazz) {
-            return (ClassSerializer) CLASS_CACHE.computeIfAbsent(clazz, c -> new ClassSerializer<>(clazz));
-        }
-
-        private final Class clazz;
-        private final ClassAccess access;
-
-        private ClassSerializer(@NotNull Class clazz) {
-            this.clazz = clazz;
-            this.access = Reflection.builder()
-                    .forClass(clazz)
-                    .publicMembers()
-                    .buildAccess();
-        }
-
-        @Override
-        public @NotNull Map serialize(@NotNull O object) {
-            try {
-                Map map = new HashMap<>(object.serialize());
-                map.put(SERIALIZED_TYPE_KEY, getSerializeName(object.getClass()));
-                return map;
-            } catch (Exception e) {
-                throw new IllegalArgumentException("Failed to serialize object of type: " + object.getClass().getName(), e);
-            }
-        }
-
-        @Override
-        public @NotNull O deserialize(@NotNull Map map) {
-            Object typeObj = map.get(SERIALIZED_TYPE_KEY);
-            if (!(typeObj instanceof String)) {
-                throw new IllegalArgumentException("Serialized type key is missing or not a string!");
-            }
-
-            MethodAccessor deserializeMethod = findMethod("deserialize");
-            if (deserializeMethod == null) {
-                deserializeMethod = findMethod("valueOf");
-            }
-
-            if (deserializeMethod == null) {
-                throw new IllegalStateException("Class " + clazz.getName() + " does not have a static deserialize(Map) method!");
-            }
-
-            Object result = deserializeMethod.invoke(null, map);
-            if (!clazz.isInstance(result)) {
-                throw new IllegalStateException("Deserialized object is not of type " + clazz.getName() + "!");
-            }
-
-            return (O) result;
-        }
-
-        @Nullable
-        private MethodAccessor findMethod(@NotNull String methodName) {
-            return METHOD_CACHE.computeIfAbsent(
-                this.clazz + "#" + methodName,
-                    key -> this.access.method(
-                            Reflection.method()
-                                    .requireStatic()
-                                    .name(methodName)
-                                    .parameterCount(1)
-                                    .parameterType(0, Map.class)
-                                    .build()
-                    ));
-        }
-
-    }
-
-}
diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java
deleted file mode 100644
index cac53fc..0000000
--- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package dev.spoocy.utils.config.serializer;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-
-/**
- * Represents an "alias" that a {@link ConfigSerializable} may be stored as.
- * If this is not present on a {@link ConfigSerializable} class, it
- * will use the fully qualified name of the class.
- * 

- * This value will be stored in the configuration so that the configuration - * deserialization can determine what type it is. - *

- * Using this annotation on any other class than a {@link ConfigSerializable} - * will have no effect. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface SerializableAs { - - /** - * This is the name your class will be stored and retrieved as. - *

- * This name MUST be unique. - * - * @return Name to serialize the class as. - */ - @NotNull - String value(); - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializer.java deleted file mode 100644 index cfe4c24..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializer.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -/** - * Serializer interface for serializing and deserializing objects to and from maps. - * @author Spoocy99 | GitHub: Spoocy99 - */ -public interface Serializer { - - /** - * Serialize an object to a map. - * - * @param object the object to serialize - * - * @return the serialized object - */ - @NotNull - Map serialize(@NotNull O object); - - @Nullable - default Map serializeSafely(@NotNull O object) { - try { - return serialize(object); - } catch (Exception e) { - return null; - } - } - - /** - * Deserialize a map to an object - * - * @param map the map to deserialize - * - * @return the deserialized object - */ - @NotNull - O deserialize(@NotNull Map map); - - @Nullable - default O deserializeSafely(@NotNull Map map) { - try { - return deserialize(map); - } catch (Exception e) { - return null; - } - } - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java deleted file mode 100644 index 0aa2ae3..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Serializers.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -/** - * Defines a contract for managing serializers for various object types, - * facilitating their conversion to and from map representations. Implementations - * may support dynamic registration, resolution, serialization, and deserialization - * processes, allowing efficient mapping of object states to maps. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public interface Serializers { - - /** - * Registers a serializer for a specific class type. The provided serializer - * will be used to serialize and deserialize instances of the specified class. - * - * @param clazz the class type to register the serializer for, must not be null - * @param serializer the serializer implementation for the specified class type, must not be null - * @param the type of the class - */ - void register(@NotNull Class clazz, @NotNull Serializer serializer); - - /** - * Resolves and retrieves the serializer associated with the specified class type. - * If no serializer is registered for the given class, this method returns null. - * - * @param the type of the class for which the serializer is being resolved - * @param clazz the class for which the serializer is being resolved, must not be null - * - * @return the serializer registered for the specified class, or null if no serializer is found - */ - @Nullable - Serializer resolve(@NotNull Class clazz); - /** - * Serializes the given object into a map representation. The resulting map - * contains key-value pairs that represent the state and structure of the object, - * allowing it to be easily stored, transferred, or reconstructed. - * - * @param object the object to serialize, must not be null - * - * @return a map containing the serialized representation of the object - * - * @throws IllegalArgumentException if the object cannot be serialized - * due to unsupported type or invalid state - */ - @NotNull - Map serialize(@NotNull Object object); - - /** - * Deserializes a map into an object. The map is expected to contain serialized data - * and associated type information to recreate the original object. - * - * @param map the map containing serialized data and type information, must not be null - * - * @return the deserialized object reconstructed from the map - * - * @throws IllegalArgumentException if the map does not contain valid type information - * or is incompatible with the expected deserialization process - */ - @Nullable - Object deserialize(@NotNull Map map); - - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java deleted file mode 100644 index 96b363d..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/Tag.java +++ /dev/null @@ -1,170 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import dev.spoocy.utils.common.misc.Args; -import org.jetbrains.annotations.NotNull; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.*; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public final class Tag { - - public static final Set STANDARD_TAGS = new HashSet<>(9); - private static final Map>> COMPATIBILITY_MAP = new HashMap<>(); - - public static final String PREFIX = "tag:dev.spoocy.utils.config:"; - public static final Tag SECTION = forName("section"); - public static final Tag NULL = forName("null"); - public static final Tag INT = forName("int"); - public static final Tag FLOAT = forName("float"); - public static final Tag TIMESTAMP = forName("timestamp"); - public static final Tag BOOL = forName("bool"); - public static final Tag STR = forName("str"); - public static final Tag SEQ = forName("seq"); - public static final Tag MAP = forName("map"); - - private static Tag forName(@NotNull String tagName) { - Tag tag = new Tag(PREFIX + tagName); - STANDARD_TAGS.add(tag); - return tag; - } - - static { - COMPATIBILITY_MAP.put( - FLOAT, - Set.of( - Double.class, - Float.class, - BigDecimal.class - ) - ); - - COMPATIBILITY_MAP.put(INT, Set.of( - Integer.class, - Long.class, - BigInteger.class - )); - - Set> timestampSet = new HashSet<>(); - timestampSet.add(Date.class); - - try { - timestampSet.add(Class.forName("java.sql.Date")); - timestampSet.add(Class.forName("java.sql.Timestamp")); - } catch (ClassNotFoundException ignored) { } - - COMPATIBILITY_MAP.put(TIMESTAMP, timestampSet); - } - - private final String value; - private final boolean secondary; - - public Tag(@NotNull String tag) { - this.value = Args.notNullOrEmpty(tag, "tag"); - - if (tag.trim().length() != tag.length()) { - throw new IllegalArgumentException("Tag must not contain leading or trailing spaces."); - } - - this.secondary = !tag.startsWith(PREFIX); - } - - public Tag(@NotNull Class clazz) { - Args.notNull(clazz, "class"); - this.value = PREFIX + clazz.getName(); - this.secondary = false; - } - - public boolean isStandard() { - return STANDARD_TAGS.contains(this); - } - - public boolean isSecondary() { - return this.secondary; - } - - public String getValue() { - return this.value; - } - - public boolean startsWith(String prefix) { - return this.value.startsWith(prefix); - } - - public String getClassName() { - if (secondary) { - throw new IllegalStateException("Cannot get class name from a secondary tag"); - } - - String name = value.substring(Tag.PREFIX.length()); - return URLDecoder.decode(name, StandardCharsets.UTF_8); - } - - @Override - public String toString() { - return this.value; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - - if (obj instanceof Tag) { - Tag other = (Tag) obj; - return this.value.equals(other.value); - } - - return false; - } - - @Override - public int hashCode() { - return value.hashCode(); - } - - /** - * Java has more than 1 class compatible with a language-independent tag (!!int, !!float, - * !!timestamp etc) - * - * @param clazz - Class to check compatibility - * - * @return true when the Class can be represented by this language-independent tag - */ - public boolean isCompatible(@NotNull Class clazz) { - Set> set = COMPATIBILITY_MAP.get(this); - - if (set != null) { - return set.contains(clazz); - } else { - return false; - } - } - - /** - * Check whether this tag matches the global tag for the Class - * - * @param clazz - Class to check - * - * @return true when this tag can be used as a global tag for the Class during serialisation - */ - public boolean matches(@NotNull Class clazz) { - return value.equals(Tag.PREFIX + clazz.getName()); - } - - /** - * Check if the that is global and not standard to provide it to TagInspector for verification. - * - * @return true when the tag must be verified to avoid remote code invocation - */ - public boolean isCustomGlobal() { - return !secondary && !STANDARD_TAGS.contains(this); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java deleted file mode 100644 index 3b3a1ff..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java +++ /dev/null @@ -1,84 +0,0 @@ -package dev.spoocy.utils.config.serializer.impl; - -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import org.jetbrains.annotations.NotNull; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Serializer implementation for atomic types. - * - * @see java.util.concurrent.atomic.AtomicInteger - * @see java.util.concurrent.atomic.AtomicLong - * @see java.util.concurrent.atomic.AtomicBoolean - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public class AtomicsSerializer implements Serializer { - - public static final String SERIALIZED_ATOMIC_VALUE_KEY = "value"; - private final Class clazz; - - public AtomicsSerializer(@NotNull Class clazz) { - this.clazz = clazz; - } - - @Override - public @NotNull Map serialize(@NotNull A object) { - try { - Map map = new HashMap<>(); - - if (clazz.equals(AtomicInteger.class)) { - map.put(SERIALIZED_ATOMIC_VALUE_KEY, ((AtomicInteger) object).get()); - - } else if (clazz.equals(AtomicLong.class)) { - map.put(SERIALIZED_ATOMIC_VALUE_KEY, ((AtomicLong) object).get()); - - } else if (clazz.equals(AtomicBoolean.class)) { - map.put(SERIALIZED_ATOMIC_VALUE_KEY, ((AtomicBoolean) object).get()); - - } else { - throw new IllegalArgumentException("Unsupported atomic type: " + clazz.getName()); - } - - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, clazz.getName()); - return map; - } catch (Exception e) { - throw new IllegalArgumentException("Failed to serialize object of type: " + object.getClass().getName(), e); - } - } - - @Override - public @NotNull A deserialize(@NotNull Map map) { - Object valueObj = map.get(SERIALIZED_ATOMIC_VALUE_KEY); - - if (valueObj == null) { - throw new IllegalArgumentException("Serialized atomic value is missing!"); - } - - try { - if (clazz.equals(AtomicInteger.class)) { - return (A) new AtomicInteger((Integer) valueObj); - - } else if (clazz.equals(AtomicLong.class)) { - return (A) new AtomicLong((Long) valueObj); - - } else if (clazz.equals(AtomicBoolean.class)) { - return (A) new AtomicBoolean((Boolean) valueObj); - - } else { - throw new IllegalArgumentException("Unsupported atomic type: " + clazz.getName()); - } - - } catch (Exception e) { - throw new IllegalArgumentException("Failed to deserialize atomic of type: " + clazz.getName(), e); - } - } - - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java deleted file mode 100644 index e7afb45..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/DurationSerializer.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.spoocy.utils.config.serializer.impl; - -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.util.Map; - -/** - * Serializer for {@link Duration}. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class DurationSerializer implements Serializer { - - public static final DurationSerializer INSTANCE = new DurationSerializer(); - - public static final String SERIALIZED_DURATION_SECONDS_KEY = "seconds"; - public static final String SERIALIZED_DURATION_NANOS_KEY = "nanos"; - - private DurationSerializer() {} - - @Override - public @NotNull Map serialize(@NotNull Duration object) { - return Map.of( - NamedSerializers.SERIALIZED_TYPE_KEY, Duration.class.getName(), - SERIALIZED_DURATION_SECONDS_KEY, object.getSeconds(), - SERIALIZED_DURATION_NANOS_KEY, object.getNano() - ); - } - - @Override - public @NotNull Duration deserialize(@NotNull Map map) { - return Duration.ofSeconds( - ((Number) map.get(SERIALIZED_DURATION_SECONDS_KEY)).longValue(), - ((Number) map.get(SERIALIZED_DURATION_NANOS_KEY)).longValue() - ); - } -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java deleted file mode 100644 index 3ebe90d..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/JavaSerializer.java +++ /dev/null @@ -1,74 +0,0 @@ -package dev.spoocy.utils.config.serializer.impl; - -import dev.spoocy.utils.common.cache.Cache; -import dev.spoocy.utils.common.cache.Caches; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializer; -import org.jetbrains.annotations.NotNull; - -import java.io.*; -import java.util.Base64; -import java.util.Map; - -/** - * Serializer implementation using Java's built-in serialization mechanism. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class JavaSerializer implements Serializer { - - private static final Cache, JavaSerializer> CLASS_CACHE = Caches.createLRUCache(50); - - public static JavaSerializer create(@NotNull Class clazz) { - return (JavaSerializer) CLASS_CACHE.computeIfAbsent(clazz, c -> new JavaSerializer(c)); - } - - private static final String SERIALIZED_DATA_KEY = "data"; - private final Class clazz; - - private JavaSerializer(@NotNull Class clazz) { - this.clazz = clazz; - } - - @Override - public @NotNull Map serialize(@NotNull O object) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos)) { - - oos.writeObject(object); - oos.flush(); - String data = Base64.getEncoder().encodeToString(baos.toByteArray()); - - return Map.of( - NamedSerializers.SERIALIZED_TYPE_KEY, clazz.getName(), - SERIALIZED_DATA_KEY, data - ); - - } catch (IOException e) { - throw new RuntimeException("Failed to serialize object", e); - } - } - - @Override - public @NotNull O deserialize(@NotNull Map map) { - Object dataObj = map.get(SERIALIZED_DATA_KEY); - - if (!(dataObj instanceof String)) { - throw new IllegalArgumentException("Serialized data missing or not a String under key '" + SERIALIZED_DATA_KEY + "'"); - } - - byte[] bytes = Base64.getDecoder().decode((String) dataObj); - - try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes); - ObjectInputStream ois = new ObjectInputStream(bais)) { - - Object obj = ois.readObject(); - - return clazz.cast(obj); - } catch (IOException | ClassNotFoundException e) { - throw new RuntimeException("Failed to deserialize object", e); - } - } - -} diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java index 5631dad..1591697 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java @@ -2,12 +2,9 @@ import dev.spoocy.utils.config.Config; import dev.spoocy.utils.config.constructor.Constructor; -import dev.spoocy.utils.config.constructor.SerializingConstructor; import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.representer.SerializingRepresenter; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.serializer.Serializers; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collections; import java.util.List; @@ -18,14 +15,10 @@ public class ConfigSettings { + @NotNull protected final Config config; - protected Constructor constructor = SerializingConstructor.DEFAULT_INSTANCE; - protected Representer representer = SerializingRepresenter.DEFAULT_INSTANCE; - protected char pathSeparator = '.'; - protected Serializers resolvingSerializers = NamedSerializers.DEFAULT_INSTANCE; - protected List header = Collections.emptyList(); - protected List footer = Collections.emptyList(); + protected char pathSeparator = '.'; public ConfigSettings(@NotNull Config config) { this.config = config; @@ -36,28 +29,6 @@ public Config configuration() { return this.config; } - @NotNull - public Constructor constructor() { - return this.constructor; - } - - @NotNull - public ConfigSettings constructor(@NotNull Constructor constructor) { - this.constructor = constructor; - return this; - } - - @NotNull - public Representer representer() { - return this.representer; - } - - @NotNull - public ConfigSettings representer(@NotNull Representer representer) { - this.representer = representer; - return this; - } - public char pathSeparator() { return this.pathSeparator; } @@ -67,37 +38,4 @@ public ConfigSettings pathSeparator(char value) { this.pathSeparator = value; return this; } - - @NotNull - public Serializers serializers() { - return this.resolvingSerializers; - } - - @NotNull - public ConfigSettings serializers(@NotNull Serializers serializers) { - this.resolvingSerializers = serializers; - return this; - } - - @NotNull - public List header() { - return this.header; - } - - @NotNull - public ConfigSettings header(@NotNull List header) { - this.header = Collections.unmodifiableList(header); - return this; - } - - @NotNull - public List footer() { - return this.footer; - } - - @NotNull - public ConfigSettings footer(@NotNull List footer) { - this.footer = Collections.unmodifiableList(footer); - return this; - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java index b71f14b..6b6593e 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonConfig.java @@ -1,16 +1,15 @@ package dev.spoocy.utils.config.types; -import dev.spoocy.utils.config.components.AbstractConfig; -import dev.spoocy.utils.config.representer.SerializingRepresenter; +import dev.spoocy.utils.config.AbstractConfig; +import dev.spoocy.utils.config.nodes.ScalarNode; import dev.spoocy.utils.config.representer.Representer; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.json.JSONObject; import java.util.function.Consumer; /** - * Configuration implementation backed by a JSON object. - * * @author Spoocy99 | GitHub: Spoocy99 */ @@ -35,7 +34,8 @@ public JsonConfig(@NotNull Consumer settingsEditor) { @Override public @NotNull String saveToString(@NotNull Representer representer) { - JSONObject json = new JSONObject(serializedValues(this, representer)); + JSONObject json = new JSONObject(this.representAsMap(representer)); return json.toString(2); } + } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java index 2d35ab9..4be9057 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java @@ -1,9 +1,6 @@ package dev.spoocy.utils.config.types; import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.constructor.Constructor; -import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.serializer.Serializers; import org.jetbrains.annotations.NotNull; /** @@ -16,27 +13,9 @@ public JsonSettings(@NotNull Config config) { super(config); } - @Override - public @NotNull JsonSettings constructor(@NotNull Constructor constructor) { - super.constructor(constructor); - return this; - } - - @Override - public @NotNull JsonSettings representer(@NotNull Representer representer) { - super.representer(representer); - return this; - } - @Override public @NotNull JsonSettings pathSeparator(char value) { super.pathSeparator(value); return this; } - - @Override - public @NotNull ConfigSettings serializers(@NotNull Serializers serializers) { - super.serializers(serializers); - return this; - } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java index fe9bdc0..f46baa8 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java @@ -1,9 +1,11 @@ package dev.spoocy.utils.config.types; -import dev.spoocy.utils.config.components.AbstractConfig; +import dev.spoocy.utils.config.AbstractConfig; import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.nodes.ScalarNode; import dev.spoocy.utils.config.representer.Representer; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -34,4 +36,9 @@ public MemoryConfig() { public void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException { throw new UnsupportedOperationException("Saving to string is not supported for MemoryConfig"); } + + @Override + protected @Nullable Object unpackScalar(@NotNull ScalarNode node) { + return node.getData(); + } } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java index 3439660..d23b6d6 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java @@ -1,15 +1,12 @@ package dev.spoocy.utils.config.types; - -import dev.spoocy.utils.config.components.AbstractConfig; -import dev.spoocy.utils.config.components.MemorySection; -import dev.spoocy.utils.common.tuple.Pair; -import dev.spoocy.utils.config.components.ConfigNode; -import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.AbstractConfig; import dev.spoocy.utils.config.loader.YamlProcessor; -import dev.spoocy.utils.config.representer.SerializingRepresenter; +import dev.spoocy.utils.config.nodes.NodeTree; import dev.spoocy.utils.config.representer.Representer; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.comments.CommentLine; import org.yaml.snakeyaml.comments.CommentType; @@ -47,10 +44,11 @@ public YamlConfig(@NotNull Consumer settingsEditor) { YamlProcessor processor = this.settings.processor(); processor.applyOptions(this.settings); - MappingNode node = toNodeTree(this, processor, representer); + NodeTree tree = representer.createTree(this); + MappingNode node = toYamlTree(tree, processor); - node.setBlockComments(getCommentLines(saveHeader(this.header), CommentType.BLOCK)); - node.setEndComments(getCommentLines(this.footer, CommentType.BLOCK)); + node.setBlockComments(getCommentLines(saveHeader(this.header), CommentType.BLOCK, false)); + node.setEndComments(getCommentLines(this.footer, CommentType.BLOCK, false)); StringWriter writer = new StringWriter(); if (node.getBlockComments().isEmpty() && node.getEndComments().isEmpty() && node.getValue().isEmpty()) { @@ -66,44 +64,48 @@ public YamlConfig(@NotNull Consumer settingsEditor) { return writer.toString(); } - private MappingNode toNodeTree(@NotNull MemorySection section, @NotNull YamlProcessor processor, @NotNull Representer representer) { + private Node toYamlNode(@NotNull dev.spoocy.utils.config.nodes.Node node, @NotNull YamlProcessor processor) { + if (node instanceof NodeTree) { + return toYamlTree((NodeTree) node, processor); + } + + Object value = this.unpack(node); + return processor.represent(value); + } + + @Contract("_, _ -> new") + private @NotNull MappingNode toYamlTree(@NotNull NodeTree tree, @NotNull YamlProcessor processor) { List nodeTuples = new ArrayList<>(); - for (Pair entry : section.entries()) { - String key = entry.first(); - ConfigNode node = entry.second(); + for (dev.spoocy.utils.config.nodes.NodeTuple entry : tree) { + dev.spoocy.utils.config.nodes.Node keyNode = entry.getKeyNode(); + dev.spoocy.utils.config.nodes.Node valueNode = entry.getValueNode(); - Node keyNode = processor.represent(key); - Node valueNode; + Node yamlKey = toYamlNode(keyNode, processor); + Node yamlValue = toYamlNode(valueNode, processor); - Object nodeData = node.getData(); - dev.spoocy.utils.config.serializer.Tag tag = node.getTag(); - - if (tag == dev.spoocy.utils.config.serializer.Tag.SECTION && nodeData instanceof MemorySection) { - // Recursively handle subsections - valueNode = toNodeTree((MemorySection) nodeData, processor, representer); - } else { - // Use representer for non-section values - Object represented = representer.represent(node); - valueNode = processor.represent(represented); - } + yamlKey.setBlockComments(getCommentLines(valueNode.getComments(), CommentType.BLOCK, true)); - keyNode.setBlockComments(getCommentLines(section.getComments(key), CommentType.BLOCK)); - if (valueNode instanceof MappingNode || valueNode instanceof SequenceNode) { - keyNode.setInLineComments(getCommentLines(section.getInlineComments(key), CommentType.IN_LINE)); + if (yamlValue instanceof MappingNode || yamlValue instanceof SequenceNode) { + yamlKey.setInLineComments(getCommentLines(valueNode.getInlineComments(), CommentType.IN_LINE, false)); } else { - valueNode.setInLineComments(getCommentLines(section.getInlineComments(key), CommentType.IN_LINE)); + yamlValue.setInLineComments(getCommentLines(valueNode.getInlineComments(), CommentType.IN_LINE, false)); } - nodeTuples.add(new NodeTuple(keyNode, valueNode)); + nodeTuples.add(new NodeTuple(yamlKey, yamlValue)); } return new MappingNode(Tag.MAP, nodeTuples, DumperOptions.FlowStyle.BLOCK); } - private List getCommentLines(@NotNull List comments, @NotNull CommentType commentType) { + @NotNull + private List getCommentLines(@NotNull List comments, @NotNull CommentType commentType, boolean blankLineBefore) { List lines = new ArrayList<>(); + if(blankLineBefore && !comments.isEmpty()) { + lines.add(new CommentLine(null, null, "", CommentType.BLANK_LINE)); + } + for (String comment : comments) { if (comment == null) { lines.add(new CommentLine(null, null, "", CommentType.BLANK_LINE)); @@ -114,9 +116,11 @@ private List getCommentLines(@NotNull List comments, @NotNu line = line.isEmpty() ? line : " " + line; lines.add(new CommentLine(null, null, line, commentType)); } + return lines; } + @NotNull private List saveHeader(@NotNull List header) { LinkedList list = new LinkedList<>(header); diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java index c8496c8..07d1621 100644 --- a/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java @@ -1,10 +1,7 @@ package dev.spoocy.utils.config.types; import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.constructor.Constructor; import dev.spoocy.utils.config.loader.YamlProcessor; -import dev.spoocy.utils.config.representer.Representer; -import dev.spoocy.utils.config.serializer.Serializers; import org.jetbrains.annotations.NotNull; /** @@ -24,30 +21,12 @@ public YamlSettings(@NotNull Config config) { this.processor = new YamlProcessor(); } - @Override - public @NotNull YamlSettings constructor(@NotNull Constructor constructor) { - super.constructor(constructor); - return this; - } - - @Override - public @NotNull YamlSettings representer(@NotNull Representer representer) { - super.representer(representer); - return this; - } - @Override public @NotNull YamlSettings pathSeparator(char value) { super.pathSeparator(value); return this; } - @Override - public @NotNull YamlSettings serializers(@NotNull Serializers serializers) { - super.serializers(serializers); - return this; - } - public YamlProcessor processor() { if(this.processor == null) { this.processor = new YamlProcessor(); diff --git a/config-utils/src/main/java/module-info.java b/config-utils/src/main/java/module-info.java index 1820c0b..ffbf921 100644 --- a/config-utils/src/main/java/module-info.java +++ b/config-utils/src/main/java/module-info.java @@ -11,9 +11,7 @@ exports dev.spoocy.utils.config; exports dev.spoocy.utils.config.types; - exports dev.spoocy.utils.config.serializer; - exports dev.spoocy.utils.config.serializer.impl; - exports dev.spoocy.utils.config.components; + exports dev.spoocy.utils.config.bean; exports dev.spoocy.utils.config.io; exports dev.spoocy.utils.config.loader; exports dev.spoocy.utils.config.update; @@ -22,4 +20,5 @@ exports dev.spoocy.utils.config.update.match; exports dev.spoocy.utils.config.representer; exports dev.spoocy.utils.config.constructor; + exports dev.spoocy.utils.config.nodes; } \ No newline at end of file diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java deleted file mode 100644 index e626c73..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/AbstractAnnotatedConfigTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package dev.spoocy.utils.config; - -import dev.spoocy.utils.config.loader.*; -import dev.spoocy.utils.config.io.ResourceTest; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.types.SerializableExample; -import dev.spoocy.utils.config.types.YamlConfig; -import org.junit.jupiter.api.BeforeAll; - -import java.util.List; -import java.util.Set; - -/** - * Shared fixtures for annotated config binding/resolver tests. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public abstract class AbstractAnnotatedConfigTest extends ResourceTest { - - @BeforeAll - static void setupSerializers() { - String name = NamedSerializers.getSerializeName(SerializableExample.class); - if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { - NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); - } - } - - public static final class TestResourceResolver extends BaseResourceResolver { - - public TestResourceResolver(ClassLoader classLoader) { - super(classLoader); - registerLoader(JsonConfigLoader.INSTANCE); - registerLoader(YamlConfigLoader.INSTANCE); - } - } - - public enum Mode { - EASY, - HARD - } - - @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/example.yml") - public static class BasicSettings { - - @ConfigProperty("database.host") - public String host = "localhost"; - - @ConfigProperty("database.port") - public int port = 3306; - - @ConfigProperty("feature.enabled") - public boolean enabled; - - @ConfigProperty("feature.mode") - public Mode mode = Mode.EASY; - - public String name = "fallback"; - - @ConfigIgnore - public String ignored = "keep-me"; - } - - @ConfigSource(section = "database") - public static class DatabaseSettings { - - public String host; - public int port; - } - - @ConfigSource(saveDefaults = true) - public static class DefaultsSettings { - - public String present; - - @ConfigProperty("missing.number") - public int missingNumber = 42; - } - - @ConfigSource(saveDefaults = true, allowMissingResource = true) - public static class AllowMissingDefaultsSettings { - - @ConfigProperty("database.host") - public String host = "127.0.0.1"; - - @ConfigProperty(value = "database.port", saveDefault = false, comments = {"Port should stay unmanaged"}) - public int port = 3306; - - @ConfigProperty("feature.enabled") - public boolean enabled = true; - } - - @ConfigSource(saveDefaults = true) - public static class SelectiveDefaultsSettings { - - @ConfigProperty("database.host") - public String host = "localhost"; - - @ConfigProperty(value = "database.port", saveDefault = false) - public int port = 3306; - - @ConfigProperty("database.user") - public String user = "root"; - } - - @ConfigSource( - section = "database", - saveDefaults = true, - headerComments = {"Generated config", "Managed by annotations"}, - footerComments = {"End of config"}, - comments = {"Database connection settings"} - ) - public static class CommentedSettings { - - @ConfigProperty(value = "host", comments = {"Database host"}, inlineComments = {"required"}) - public String host = "localhost"; - - @ConfigProperty(value = "port", comments = {"Database port"}) - public int port = 3306; - } - - public static class BasePrivateSettings { - - @ConfigProperty("database.host") - public String host = "localhost"; - } - - @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/example.yml") - public static class PrivateInheritedSettings extends BasePrivateSettings { - - @ConfigProperty("database.port") - public int port = 3306; - - private PrivateInheritedSettings() { - } - } - - @ConfigSource("classpath:dev/spoocy/utils/config/loader/annotation/serializable.yml") - public static class SerializableFromResource { - - @ConfigProperty("serializable") - public SerializableExample serializable; - } - - public static class SerializableFromReadable { - - @ConfigProperty("serializable") - public SerializableExample serializable; - } - - public static class HookedSettings { - - @ConfigProperty("message") - public String message = "default"; - - public boolean preCalled; - public boolean postCalled; - - @PreLoad - private void beforeLoad(YamlConfig config) { - this.preCalled = true; - if (!config.isSet("message")) { - this.message = "from-pre"; - } - } - - @PostLoad - private void afterLoad() { - this.postCalled = true; - this.message = this.message + "-post"; - } - } - - @ConfigSource(saveDefaults = true) - public static class PostLoadMutationSettings { - - @ConfigProperty("message") - public String message = "default"; - - public boolean postCalled; - - @PostLoad - private PostLoadResult afterLoad() { - this.postCalled = true; - this.message = this.message + "-modified"; - return PostLoadResult.SAVE; - } - } - - @ConfigSource(saveDefaults = true) - public static class SelectivePostLoadMutationSettings { - - @ConfigProperty("message") - public String message = "default"; - - @ConfigProperty(value = "runtime.only", saveDefault = false) - public String runtimeOnly = "runtime"; - - @PostLoad - private PostLoadResult afterLoad() { - this.message = this.message + "-saved"; - this.runtimeOnly = this.runtimeOnly + "-changed"; - return PostLoadResult.SAVE; - } - } - - public static class CollectionSettings { - - @ConfigProperty("flags") - public List flags = List.of(); - - @ConfigProperty("ids") - public Set ids = Set.of(); - } -} - diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java index fba3960..d02d076 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java @@ -1,225 +1,134 @@ package dev.spoocy.utils.config; -import dev.spoocy.utils.config.constructor.SerializingConstructor; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.io.ClassPathResource; +import dev.spoocy.utils.config.io.FileSystemResource; +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; import dev.spoocy.utils.config.types.YamlConfig; -import dev.spoocy.utils.reflection.Reflection; +import dev.spoocy.utils.config.types.YamlSettings; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; +import java.util.function.Consumer; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author Spoocy99 | GitHub: Spoocy99 */ -public class BaseResourceResolverTest extends AbstractAnnotatedConfigTest { +public class BaseResourceResolverTest extends ResourceTest { - private abstract static class ResolverCase { - protected final TestResourceResolver resolver = new TestResourceResolver(BaseResourceResolverTest.class.getClassLoader()); + private static final ClassLoader CLASS_LOADER = BaseResourceResolverTest.class.getClassLoader(); - protected YamlConfig loadYaml(Path path) throws IOException { - return (YamlConfig) this.resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); + private static final dev.spoocy.utils.config.loader.ConfigLoader PROPERTIES_LOADER = new dev.spoocy.utils.config.loader.ConfigLoader<>() { + + @Override + public String[] getSupportedExtensions() { + return new String[] {"properties"}; + } + + @Override + public YamlConfig createEmpty(@NotNull Consumer settingsEditor) { + return new YamlConfig(settingsEditor); } + + @Override + public YamlConfig load( + @NotNull dev.spoocy.utils.config.io.Resource resource, + @NotNull Constructor constructor, + @NotNull Consumer settingsEditor + ) { + throw new UnsupportedOperationException("Not used in this test"); + } + }; + + private static BaseResourceResolver createResolver() { + return new BaseResourceResolver(CLASS_LOADER, + JsonConfigLoader.INSTANCE, + YamlConfigLoader.INSTANCE + ); } @Nested - class SourceResolution extends ResolverCase { + class Config { @Test - void loadFromClassPathSourceAnnotation() throws IOException { - BasicSettings settings = this.resolver.load(BasicSettings.class, SerializingConstructor.DEFAULT_INSTANCE); - - assertEquals("db.local", settings.host); - assertEquals(3307, settings.port); - assertTrue(settings.enabled); - assertEquals(Mode.HARD, settings.mode); - assertEquals("ExamplePlugin", settings.name); - assertEquals("keep-me", settings.ignored); + void createEmptyConfig() { + var config = createResolver().createEmpty(Resources.fromPath(resourcesPath("types/example.yaml"))); + + assertNotNull(config); + assertTrue(config.values(true).isEmpty()); + assertInstanceOf(YamlConfig.class, config); } + } + + @Nested + class Resource { + @Test - void loadFromExplicitResourceAndSection() throws IOException { - String file = resourcesPath("loader/annotation/example.yml"); - DatabaseSettings settings = this.resolver.load( - DatabaseSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromFile(file) - ); - - assertEquals("db.local", settings.host); - assertEquals(3307, settings.port); + void resolveClassPathResource() { + var resource = createResolver().resolve("classpath:dev/spoocy/utils/config/types/example.yml"); + + assertInstanceOf(ClassPathResource.class, resource); } @Test - void loadsInheritedFieldsWithPrivateConstructor() throws IOException { - PrivateInheritedSettings settings = this.resolver.load(PrivateInheritedSettings.class, SerializingConstructor.DEFAULT_INSTANCE); + void resolveFileSystemResource() { + var resource = createResolver().resolve("file:" + resourcesPath("types/example.yml")); - assertEquals("db.local", Reflection.getField(PrivateInheritedSettings.class, "host", String.class).get(settings)); - assertEquals(3307, settings.port); + assertInstanceOf(FileSystemResource.class, resource); } @Test - void loadsSerializableFromClasspathYaml() throws IOException { - SerializableFromResource settings = this.resolver.load(SerializableFromResource.class, SerializingConstructor.DEFAULT_INSTANCE); + void resolvePathResource() { + var resource = createResolver().resolve(resourcesPath("types/example.yml")); - assertNotNull(settings.serializable); - assertEquals("example", settings.serializable.getName()); - assertEquals(42, settings.serializable.getValue()); + assertInstanceOf(PathResource.class, resource); } + } @Nested - class MutationPersistence extends ResolverCase { + class ConfigLoader { @Test - void writesMissingDefaultsWhenEnabled(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("annotated.yml"); - Files.writeString(target, "present: hello\n", StandardCharsets.UTF_8); - - DefaultsSettings settings = this.resolver.load( - DefaultsSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - assertEquals("hello", settings.present); - assertEquals(42, settings.missingNumber); - - YamlConfig written = loadYaml(target); - assertEquals(42, written.getInt("missing.number")); - } + void resolveJsonLoader() { + var loader = createResolver().resolveLoader(Resources.fromPath(resourcesPath("types/example.json"))); - @Test - void allowsMissingSourceResourceWhenEnabled(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("missing-allowed.yml"); - - AllowMissingDefaultsSettings settings = this.resolver.load( - AllowMissingDefaultsSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - - assertEquals("127.0.0.1", settings.host); - assertEquals(3306, settings.port); - assertTrue(settings.enabled); - assertTrue(Files.exists(target)); - - YamlConfig written = loadYaml(target); - assertEquals("127.0.0.1", written.getString("database.host", "")); - assertTrue(written.getBoolean("feature.enabled")); - assertFalse(written.isSet("database.port")); + assertSame(JsonConfigLoader.INSTANCE, loader); } @Test - void persistsOnlySaveDefaultFieldsWhenConfigIsPartial(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("partial.yml"); - Files.writeString(target, "database:\n host: db.local\n", StandardCharsets.UTF_8); - - SelectiveDefaultsSettings settings = this.resolver.load( - SelectiveDefaultsSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - - assertEquals("db.local", settings.host); - assertEquals(3306, settings.port); - assertEquals("root", settings.user); - - YamlConfig written = loadYaml(target); - assertEquals("db.local", written.getString("database.host", "")); - assertEquals("root", written.getString("database.user", "")); - assertFalse(written.isSet("database.port")); - } + void resolveYamlLoader() { + var loader = createResolver().resolveLoader(Resources.fromPath(resourcesPath("types/example.yml"))); - @Test - void doesNotPersistCommentsForUnmanagedFields(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("comments-unmanaged.yml"); - Files.writeString(target, "database:\n host: db.local\n port: 5500\n", StandardCharsets.UTF_8); - - SelectiveDefaultsSettings settings = this.resolver.load( - SelectiveDefaultsSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - - assertEquals(5500, settings.port); - YamlConfig written = loadYaml(target); - assertIterableEquals(List.of(), written.getComments("database.port")); - assertFalse(Files.readString(target, StandardCharsets.UTF_8).contains("Port should stay unmanaged")); + assertSame(YamlConfigLoader.INSTANCE, loader); } @Test - void writesConfiguredComments(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("comments.yml"); - Files.writeString(target, "database:\n host: db.local\n", StandardCharsets.UTF_8); - - CommentedSettings settings = this.resolver.load( - CommentedSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - assertEquals("db.local", settings.host); - assertEquals(3306, settings.port); - - YamlConfig written = loadYaml(target); - String yaml = Files.readString(target, StandardCharsets.UTF_8); - assertIterableEquals(List.of("Generated config", "Managed by annotations"), written.getHeaderComments()); - assertIterableEquals(List.of("End of config"), written.getFooterComments()); - assertIterableEquals(List.of("Database connection settings"), written.getComments("database")); - assertIterableEquals(List.of("Database host"), written.getComments("database.host")); - assertIterableEquals(List.of("required"), written.getInlineComments("database.host")); - assertIterableEquals(List.of("Database port"), written.getComments("database.port")); - assertEquals(3306, written.getInt("database.port")); - assertTrue(yaml.contains("# Generated config")); - assertTrue(yaml.contains("# Database connection settings")); - assertTrue(yaml.contains("host: db.local # required")); - } + void throwsWhenRequireLoader() { + var resolver = createResolver(); + var resource = Resources.fromPath(resourcesPath("io/example.properties")); - @Test - void postLoadResultSaveWritesInstanceFieldsBack(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("post-load-save.yml"); - Files.writeString(target, "message: hello\n", StandardCharsets.UTF_8); - - PostLoadMutationSettings settings = this.resolver.load( - PostLoadMutationSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - assertTrue(settings.postCalled); - assertEquals("hello-modified", settings.message); - - YamlConfig written = loadYaml(target); - assertEquals("hello-modified", written.getString("message", "")); - assertTrue(Files.readString(target, StandardCharsets.UTF_8).contains("hello-modified")); + assertThrows(IllegalArgumentException.class, () -> resolver.requireLoader(resource)); } @Test - void postLoadSaveSkipsFieldsWithSaveDefaultDisabled(@TempDir Path tempDir) throws IOException { - Path target = tempDir.resolve("post-load-selective.yml"); - Files.writeString(target, "message: hello\n", StandardCharsets.UTF_8); - - SelectivePostLoadMutationSettings settings = this.resolver.load( - SelectivePostLoadMutationSettings.class, - SerializingConstructor.DEFAULT_INSTANCE, - Resources.fromPath(target) - ); - - assertEquals("hello-saved", settings.message); - assertEquals("runtime-changed", settings.runtimeOnly); - - YamlConfig written = loadYaml(target); - assertEquals("hello-saved", written.getString("message", "")); - assertFalse(written.isSet("runtime.only")); + void registerCustomLoader() { + var resolver = createResolver(); + resolver.registerLoader(PROPERTIES_LOADER); + + var resource = Resources.fromPath(resourcesPath("io/example.PROPERTIES")); + assertSame(PROPERTIES_LOADER, resolver.requireLoader(resource)); } + } } - diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/ResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/ResourceTest.java new file mode 100644 index 0000000..876435a --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/ResourceTest.java @@ -0,0 +1,37 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ResourceTest { + + protected static final String TEST_RESOURCES_DIR = "src/test/resources/dev/spoocy/utils/config"; + protected static final TestResourceResolver RESOLVER = new TestResourceResolver(ResourceTest.class.getClassLoader()); + + @NotNull + protected static String resourcesPath(@NotNull String string) { + return (TEST_RESOURCES_DIR + '/' + string).replace('/', File.separatorChar); + } + + @NotNull + protected static BaseResourceResolver resolver() { + return RESOLVER; + } + + public static final class TestResourceResolver extends BaseResourceResolver { + + public TestResourceResolver(ClassLoader classLoader) { + super(classLoader); + registerLoader(JsonConfigLoader.INSTANCE); + registerLoader(YamlConfigLoader.INSTANCE); + } + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java deleted file mode 100644 index 950d30b..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/SafeResourceResolverTest.java +++ /dev/null @@ -1,92 +0,0 @@ - -package dev.spoocy.utils.config; - -import dev.spoocy.utils.config.constructor.SerializingConstructor; -import dev.spoocy.utils.config.loader.JsonConfigLoader; -import dev.spoocy.utils.config.loader.YamlConfigLoader; -import dev.spoocy.utils.config.types.JsonConfig; -import dev.spoocy.utils.config.types.YamlConfig; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ -public class SafeResourceResolverTest { - - private final SafeResourceResolver resolver = ResourceResolver.safeResolver(); - - @Nested - class LoadConfig { - - @Test - void returnsEmptyConfigWhenResourceDoesNotExist(@TempDir Path tempDir) throws IOException { - Path path = tempDir.resolve("missing.yml"); - - Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); - - YamlConfig yaml = assertInstanceOf(YamlConfig.class, loaded); - assertEquals(0, yaml.values(true).size()); - } - - @Test - void loadsExistingYamlResource(@TempDir Path tempDir) throws IOException { - Path path = tempDir.resolve("config.YML"); - Files.writeString(path, "key: value\n", StandardCharsets.UTF_8); - - Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); - - YamlConfig yaml = assertInstanceOf(YamlConfig.class, loaded); - assertEquals("value", yaml.getString("key")); - } - - @Test - void loadsExistingJsonResource(@TempDir Path tempDir) throws IOException { - Path path = tempDir.resolve("config.json"); - Files.writeString(path, "{\"key\":\"value\"}", StandardCharsets.UTF_8); - - Config loaded = resolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE); - - JsonConfig json = assertInstanceOf(JsonConfig.class, loaded); - assertEquals("value", json.getString("key")); - } - - @Test - void throwsWhenNoLoaderMatchesResource(@TempDir Path tempDir) { - SafeResourceResolver noLoaderResolver = new SafeResourceResolver(SafeResourceResolverTest.class.getClassLoader()); - Path path = tempDir.resolve("config.yml"); - - IOException exception = assertThrows( - IOException.class, - () -> noLoaderResolver.loadConfig(Resources.fromPath(path), SerializingConstructor.DEFAULT_INSTANCE) - ); - - assertTrue(exception.getMessage().contains("No config loader")); - } - - @Test - void validatesArguments(@TempDir Path tempDir) { - Path path = tempDir.resolve("config.yml"); - - assertThrows( - NullPointerException.class, - () -> resolver.loadConfig(null, SerializingConstructor.DEFAULT_INSTANCE) - ); - assertThrows( - NullPointerException.class, - () -> resolver.loadConfig(Resources.fromPath(path), null) - ); - } - } -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanLoadTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanLoadTest.java new file mode 100644 index 0000000..3101de5 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanLoadTest.java @@ -0,0 +1,144 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.types.MemoryConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +class ConfigBeanLoadTest extends ConfigBeanTest { + + @Nested + class Errors { + + @Test + void rejectsClassesWithoutConfigSource() { + MemoryConfig config = new MemoryConfig(); + assertThrows(IllegalArgumentException.class, () -> LOADER.load(UnannotatedBean.class, config, LoadStrategy.JUST_LOAD)); + } + + @Test + void ignoreFinalFields() { + MemoryConfig config = new MemoryConfig(); + config.set("value", "test123"); + + FinalValueBean bean = LOADER.load(FinalValueBean.class, config, LoadStrategy.JUST_LOAD); + assertEquals("test", bean.value); + } + + } + + public static class UnannotatedBean { + public String value; + } + + @ConfigSource() + public static class FinalValueBean { + + @ConfigProperty("value") + public final String value = "test"; + + } + + @Nested + class Primitives { + + @Test + void loadsPrimitives() { + MemoryConfig config = new MemoryConfig(); + config.set("str", "test123"); + config.set("num", 12); + + PrimitivesBean bean = LOADER.load(PrimitivesBean.class, config, LoadStrategy.JUST_LOAD); + + // overwritten by config + assertEquals("test123", bean.str); + assertEquals(12, bean.num); + + // default value + assertFalse(bean.bool); + } + + } + + @ConfigSource() + public static class PrimitivesBean { + + @ConfigProperty("str") + public String str = "test"; + + @ConfigProperty("num") + public int num = 1; + + @ConfigProperty("bool") + public boolean bool = false; + } + + @Nested + class Collections { + + @Test + void loadsCollections() { + MemoryConfig config = new MemoryConfig(); + config.set("list", List.of("a", "b", "c")); + config.set("map", Map.of("key1", "value1", "key2", 42)); + + CollectionsBean bean = LOADER.load(CollectionsBean.class, config, LoadStrategy.JUST_LOAD); + assertEquals(List.of("a", "b", "c"), bean.list); + assertEquals(Map.of("key1", "value1", "key2", 42), bean.map); + } + } + + @ConfigSource() + public static class CollectionsBean { + + @ConfigProperty("list") + public List list = new ArrayList<>(); + + @ConfigProperty("map") + public Map map = new LinkedHashMap<>(); + } + + @Nested + class NestedBeans { + + @Test + void loadsNestedBeans() { + MemoryConfig config = new MemoryConfig(); + config.set("nested.str", "nestedValue"); + config.set("nested.num", 99); + + NestedBean bean = LOADER.load(NestedBean.class, config, LoadStrategy.JUST_LOAD); + + assertNotNull(bean.nested); + assertEquals("nestedValue", bean.nested.str); + assertEquals(99, bean.nested.num); + } + } + + @ConfigSource() + public static class NestedBean { + + @ConfigProperty("nested") + public NestedValue nested; + } + + public static class NestedValue { + + @ConfigProperty("str") + public String str; + + @ConfigProperty("num") + public int num; + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanSaveTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanSaveTest.java new file mode 100644 index 0000000..b89ad13 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanSaveTest.java @@ -0,0 +1,109 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.Document; +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.types.YamlConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigBeanSaveTest extends ConfigBeanTest { + + @Test + void writeToConfig() { + Bean bean = new Bean(); + Config config = LOADER.writeToConfig(bean); + + assertEquals("test", config.getString("str", null)); + assertEquals(12, config.getInt("num", 0)); + + List comments = config.getInlineComments("str"); + assertNotNull(comments); + assertEquals(1, comments.size()); + assertTrue(comments.contains("An example string")); + + List inlineComments = config.getComments("num"); + assertNotNull(inlineComments); + assertEquals(1, inlineComments.size()); + assertTrue(inlineComments.contains("An integer number")); + } + + @Test + void writeToProvidedConfig() { + Bean bean = new Bean(); + YamlConfig yaml = YamlConfigLoader.INSTANCE.createEmpty(s -> {}); + Config config = LOADER.writeToConfig(bean, yaml); + + assertEquals("test", config.getString("str", null)); + assertEquals(12, config.getInt("num", 0)); + + List comments = config.getInlineComments("str"); + assertNotNull(comments); + assertTrue(comments.contains("An example string")); + + List inlineComments = config.getComments("num"); + assertNotNull(inlineComments); + assertTrue(inlineComments.contains("An integer number")); + } + + @Test + void save(@TempDir Path path) throws Exception { + Bean bean = new Bean(); + Resource resource = Resources.fromPath(path.resolve("test-bean-save.yml")); + Document doc = YamlConfigLoader.INSTANCE.createEmpty(s -> {}).withRelation(resource); + LOADER.save(bean, doc); + + assertTrue(resource.exists()); + String contents = resource.getContentAsString(StandardCharsets.UTF_8); + assertTrue(contents.contains("num: 12")); + assertTrue(contents.contains("str: test") || contents.contains("str: \"test\"")); + assertTrue(contents.contains("# An example string")); + assertTrue(contents.contains("# An integer number")); + } + + @Test + void saveToProvidedDocument() throws Exception { + Bean bean = new Bean(); + YamlConfig yaml = YamlConfigLoader.INSTANCE.createEmpty(s -> {}); + Resource resource = Resources.fromPath(Path.of("test-bean-save1.yml")); + + Document document = yaml.withRelation(resource); + LOADER.save(bean, document); + + assertTrue(resource.exists()); + String contents = resource.getContentAsString(StandardCharsets.UTF_8); + assertTrue(contents.contains("num: 12")); + assertTrue(contents.contains("str: test") || contents.contains("str: \"test\"")); + assertTrue(contents.startsWith("# Example config for testing saving a bean to a config file")); + assertTrue(contents.contains("# An example string")); + assertTrue(contents.contains("# An integer number")); + } + + @ConfigSource( + value = "test-bean-save.yml", + allowMissingResource = true, + headerComments = "Example config for testing saving a bean to a config file" + ) + static class Bean { + + @ConfigProperty(value = "str", inlineComments = "An example string") + public String str = "test"; + + @ConfigProperty(value = "num", comments = "An integer number") + public int num = 12; + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanStrategiesTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanStrategiesTest.java new file mode 100644 index 0000000..d79269f --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanStrategiesTest.java @@ -0,0 +1,153 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.Document; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.types.JsonConfig; +import dev.spoocy.utils.config.types.MemoryConfig; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigBeanStrategiesTest extends ConfigBeanTest { + + @Nested + class JustLoad { + + @Test + void missingValue() { + MemoryConfig config = new MemoryConfig(); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, config, LoadStrategy.JUST_LOAD); + + // value in bean is overwritten + assertEquals("default", bean.value); + + // value in config is not overwritten + assertFalse(config.isSet("value")); + } + + @Test + void nonMissingValue() { + MemoryConfig config = new MemoryConfig(); + config.set("value", "test123"); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, config, LoadStrategy.JUST_LOAD); + + // value in bean is overwritten + assertEquals("test123", bean.value); + + // value in config is not overwritten + assertEquals("test123", config.getString("value")); + } + + } + + @Nested + class SaveDefaults { + + @Test + void missingValue() { + MemoryConfig config = new MemoryConfig(); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, config, LoadStrategy.SAVE_DEFAULTS); + + // value in bean is overwritten + assertEquals("default", bean.value); + + // value in config is overwritten + assertEquals("default", config.getString("value")); + + // comments + List comments = config.getComments("value"); + assertNotNull(comments); + assertEquals(2, comments.size()); + assertEquals("Test comment 1", comments.get(0)); + assertEquals("Test comment 2", comments.get(1)); + + List inlineComments = config.getInlineComments("value"); + assertNotNull(inlineComments); + assertEquals(1, inlineComments.size()); + assertEquals("Test comment", inlineComments.get(0)); + } + + @Test + void nonMissingValue() { + MemoryConfig config = new MemoryConfig(); + config.set("value", "test123"); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, config, LoadStrategy.SAVE_DEFAULTS); + + // value in bean is not overwritten + assertEquals("test123", bean.value); + + // value in config is not overwritten + assertEquals("test123", config.getString("value")); + } + + } + + @Nested + class SaveDefaultsAndResource { + + @Test + void missingValue() throws IOException { + Resource resource = RESOURCE_RESOLVER.resolve("strategies1.json"); + Document doc = new JsonConfig().withRelation(resource); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, doc, LoadStrategy.SAVE_DEFAULTS_AND_RESOURCE); + + // value in bean is overwritten + assertEquals("default", bean.value); + + // value in resource is overwritten + assertEquals("default", doc.getString("value")); + + Config loaded = YamlConfigLoader.INSTANCE.load(resource, CONSTRUCTOR); + // value was saved to resource + assertEquals("default", loaded.getString("value")); + } + + @Test + void nonMissingValue() throws IOException { + Resource resource = RESOURCE_RESOLVER.resolve("strategies2.json"); + Document doc = new JsonConfig().withRelation(resource); + doc.set("value", "test123"); + + StrategiesBean bean = LOADER.load(StrategiesBean.class, doc, LoadStrategy.SAVE_DEFAULTS_AND_RESOURCE); + + // value in bean is not overwritten + assertEquals("test123", bean.value); + + // value in resource is not overwritten + assertEquals("test123", doc.getString("value")); + + // no changes so no file should be created + assertFalse(resource.exists()); + + } + + } + + @ConfigSource("strategies1.json") + public static class StrategiesBean { + + @ConfigProperty( + value = "value", + inlineComments = "Test comment", + comments = {"Test comment 1", "Test comment 2"} + ) + public String value = "default"; + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanTest.java new file mode 100644 index 0000000..27bde0e --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/bean/ConfigBeanTest.java @@ -0,0 +1,48 @@ +package dev.spoocy.utils.config.bean; + +import dev.spoocy.utils.config.BaseResourceResolver; +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.constructor.SafeConstructor; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import dev.spoocy.utils.config.representer.SafeRepresenter; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ConfigBeanTest { + + @TempDir + private static Path tempDir; + + public static final TestResourceResolver RESOURCE_RESOLVER = new TestResourceResolver(); + public static final SafeRepresenter REPRESENTER = new SafeRepresenter(); + public static final SafeConstructor CONSTRUCTOR = new SafeConstructor(); + + public static final ConfigBeanLoader LOADER = new ConfigBeanLoader( + RESOURCE_RESOLVER, + REPRESENTER, + CONSTRUCTOR + ); + + + public static class TestResourceResolver extends BaseResourceResolver { + + public TestResourceResolver() { + super(ConfigBeanTest.class.getClassLoader(), YamlConfigLoader.INSTANCE, JsonConfigLoader.INSTANCE); + } + + @Override + public @NotNull Resource resolve(@NotNull String location) { + return Resources.fromPath(tempDir.resolve("config-utils-test/" + location)); + } + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/constructor/ConstructorTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/constructor/ConstructorTest.java new file mode 100644 index 0000000..3d04cd0 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/constructor/ConstructorTest.java @@ -0,0 +1,429 @@ +package dev.spoocy.utils.config.constructor; + +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.Node; +import dev.spoocy.utils.config.nodes.NodeTree; +import dev.spoocy.utils.config.nodes.NodeTuple; +import dev.spoocy.utils.config.nodes.NodeType; +import dev.spoocy.utils.config.nodes.ScalarNode; +import dev.spoocy.utils.config.nodes.SequenceNode; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class ConstructorTest { + + + + private final NodeConstructor nodes = new DefaultNodeConstructor(o -> { + if(o instanceof CustomObject) { + return ConstructorTest.CustomConstructor.CUSTOM_TAG; + } + return null; + }); + + private final CustomConstructor constructor = new CustomConstructor(); + + @Nested + class Constructors { + + @Test + public void testSafeConstructorRegistersDefaults() { + SafeConstructor safeConstructor = new SafeConstructor(); + + assertNotNull(safeConstructor.getConstruct(Tag.STR)); + assertNotNull(safeConstructor.getConstruct(Tag.BOOL)); + assertNotNull(safeConstructor.getConstruct(Tag.INT)); + assertNotNull(safeConstructor.getConstruct(Tag.FLOAT)); + assertNotNull(safeConstructor.getConstruct(Tag.SET)); + assertNotNull(safeConstructor.getConstruct(Tag.SEQ)); + assertNotNull(safeConstructor.getConstruct(Tag.MAP)); + } + + @Test + public void testCustomConstructorKeepsDefaultsAndRegistersCustomType() { + CustomConstructor customConstructor = new CustomConstructor(); + + assertNotNull(customConstructor.getConstruct(Tag.STR)); + assertNotNull(customConstructor.getConstruct(Tag.BOOL)); + assertNotNull(customConstructor.getConstruct(Tag.INT)); + assertNotNull(customConstructor.getConstruct(Tag.FLOAT)); + assertNotNull(customConstructor.getConstruct(Tag.SET)); + assertNotNull(customConstructor.getConstruct(Tag.SEQ)); + assertNotNull(customConstructor.getConstruct(Tag.MAP)); + assertNotNull(customConstructor.getConstruct(new Tag(CustomObject.class))); + } + } + + @Nested + class Primitives { + + @Test + public void testString() { + String value = "Hello, World!"; + + // Node + Node node = nodes.construct(value); + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals(value, scalarNode.getData()); + assertEquals(Tag.STR, scalarNode.getTag()); + + // reconstruction + Object constructed = constructor.constructObject(node); + assertEquals(value, constructed); + } + + @Test + public void testNull() { + // Node + Node node = nodes.construct(null); + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertNull(scalarNode.getData()); + assertEquals(Tag.NULL, scalarNode.getTag()); + + // reconstruction + Object constructed = constructor.constructObject(node); + assertNull(constructed); + } + } + + @Nested + class Collections { + + @Test + public void testListSerialization() { + // Node + List value = List.of("one", 2L, true); + Node node = nodes.construct(value); + + assertNotNull(node); + assertEquals(NodeType.SEQUENCE, node.getNodeType()); + + SequenceNode sequenceNode = assertInstanceOf(SequenceNode.class, node); + assertEquals(Tag.SEQ, sequenceNode.getTag()); + assertEquals(3, sequenceNode.getValue().size()); + + ScalarNode first = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(0)); + ScalarNode second = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(1)); + ScalarNode third = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(2)); + + assertEquals("one", first.getData()); + assertEquals(Tag.STR, first.getTag()); + assertEquals(2L, second.getData()); + assertEquals(Tag.INT, second.getTag()); + assertEquals(true, third.getData()); + assertEquals(Tag.BOOL, third.getTag()); + + + // reconstruction + Object constructed = constructor.constructObject(node); + List constructedList = assertInstanceOf(List.class, constructed); + assertEquals(value.size(), constructedList.size()); + for (int i = 0; i < value.size(); i++) { + assertEquals(value.get(i), constructedList.get(i)); + } + } + + @Test + public void testEmptyListSerialization() { + // Node + Node node = nodes.construct(List.of()); + + SequenceNode sequenceNode = assertInstanceOf(SequenceNode.class, node); + assertEquals(Tag.SEQ, sequenceNode.getTag()); + assertTrue(sequenceNode.getValue().isEmpty()); + + // reconstruction + Object constructed = constructor.constructObject(node); + List constructedList = assertInstanceOf(List.class, constructed); + assertTrue(constructedList.isEmpty()); + } + } + + @Nested + class Maps { + + @Test + public void testMapSerialization() { + Map value = new LinkedHashMap<>(); + value.put("name", "test"); + value.put("age", 21.1D); + + // Node + Node node = nodes.construct(value); + + assertNotNull(node); + assertEquals(NodeType.TREE, node.getNodeType()); + + NodeTree treeNode = assertInstanceOf(NodeTree.class, node); + assertEquals(Tag.MAP, treeNode.getTag()); + assertEquals(2, treeNode.getValue().size()); + + NodeTuple firstTuple = treeNode.getValue().get(0); + ScalarNode firstKey = assertInstanceOf(ScalarNode.class, firstTuple.getKeyNode()); + ScalarNode firstValue = assertInstanceOf(ScalarNode.class, firstTuple.getValueNode()); + assertEquals("name", firstKey.getData()); + assertEquals("test", firstValue.getData()); + + NodeTuple secondTuple = treeNode.getValue().get(1); + ScalarNode secondKey = assertInstanceOf(ScalarNode.class, secondTuple.getKeyNode()); + ScalarNode secondValue = assertInstanceOf(ScalarNode.class, secondTuple.getValueNode()); + assertEquals("age", secondKey.getData()); + assertEquals(21.1D, secondValue.getData()); + + // reconstruction + Object constructed = constructor.constructObject(node); + Map constructedMap = assertInstanceOf(Map.class, constructed); + + for (Map.Entry entry : value.entrySet()) { + String key = entry.getKey(); + assertTrue(constructedMap.containsKey(key)); + + Object val = constructedMap.get(entry.getKey()); + assertEquals(entry.getValue(), val); + } + } + + @Test + public void testEmptyMapSerialization() { + // Node + Node node = nodes.construct(Map.of()); + + NodeTree treeNode = assertInstanceOf(NodeTree.class, node); + assertEquals(Tag.MAP, treeNode.getTag()); + assertTrue(treeNode.getValue().isEmpty()); + + // reconstruction + Object constructed = constructor.constructObject(node); + assertEquals(Map.of(), constructed); + } + } + + @Nested + class Custom { + + @Test + public void testSingleSerialization() { + ScalarNode node = new ScalarNode("data-5", new Tag(CustomObject.class), null, null); + + // reconstruction + Object constructed = constructor.constructObject(node); + CustomObject obj = assertInstanceOf(CustomObject.class, constructed); + assertEquals(obj, constructed); + } + + @Test + public void testListSerialization() { + Node node = new SequenceNode(Tag.SEQ, + List.of( + new ScalarNode("data-1", new Tag(CustomObject.class), null, null), + new ScalarNode("data-2", new Tag(CustomObject.class), null, null) + ), + List.of(), + List.of() + + ); + + // reconstruction + Object constructed = constructor.constructObject(node); + List constructedList = assertInstanceOf(List.class, constructed); + + CustomObject obj1 = assertInstanceOf(CustomObject.class, constructedList.get(0)); + assertEquals("data", obj1.getData()); + assertEquals(1, obj1.getNumber()); + + CustomObject obj2 = assertInstanceOf(CustomObject.class, constructedList.get(1)); + assertEquals("data", obj2.getData()); + assertEquals(2, obj2.getNumber()); + } + + @Test + public void testMapSerialization() { + Node node = new NodeTree(Tag.MAP, + List.of( + NodeTuple.of( + new ScalarNode("data1", Tag.STR, null, null), + new ScalarNode("data-1", new Tag(CustomObject.class), null, null) + ), + NodeTuple.of( + new ScalarNode("data2", Tag.STR, null, null), + new ScalarNode("data-2", new Tag(CustomObject.class), null, null) + ) + ), + List.of(), + List.of() + + ); + + // reconstruction + Object constructed = constructor.constructObject(node); + Map constructedMap = assertInstanceOf(Map.class, constructed); + + CustomObject obj1 = assertInstanceOf(CustomObject.class, constructedMap.get("data1")); + assertEquals("data", obj1.getData()); + assertEquals(1, obj1.getNumber()); + + CustomObject obj2 = assertInstanceOf(CustomObject.class, constructedMap.get("data2")); + assertEquals("data", obj2.getData()); + assertEquals(2, obj2.getNumber()); + } + + @Test + public void testMapOverwriteSerialization() { + Node node = new NodeTree(Tag.MAP, + List.of( + NodeTuple.of( + new ScalarNode("==", Tag.STR, null, null), + new ScalarNode("CustomObject", Tag.STR, null, null) + ), + NodeTuple.of( + new ScalarNode("data", Tag.STR, null, null), + new ScalarNode("test", Tag.STR, null, null) + ), + NodeTuple.of( + new ScalarNode("number", Tag.STR, null, null), + new ScalarNode("1", Tag.STR, null, null) + ) + ), + List.of(), + List.of() + + ); + + // reconstruction + Object constructed = constructor.constructObject(node); + CustomObject constructedObj = assertInstanceOf(CustomObject.class, constructed); + assertEquals("test", constructedObj.getData()); + assertEquals(1, constructedObj.getNumber()); + } + } + + @Nested + class EdgeCases { + + @Test + public void testFallbackToTypeTagForUnknownObject() { + + UnknownObject value = new UnknownObject("fallback"); + Node node = nodes.construct(value); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertSame(value, scalarNode.getData()); + assertEquals(new Tag(UnknownObject.class), scalarNode.getTag()); + } + + @Test + public void testConstructionRejectsNodeInput() { + ScalarNode alreadyConstructed = new ScalarNode("test", Tag.STR, null, null); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> nodes.construct(alreadyConstructed) + ); + + assertEquals("Data already constructed.", exception.getMessage()); + } + } + + static class UnknownObject { + + private final String value; + + public UnknownObject(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + static class CustomObject { + + private final String data; + private final int number; + + public CustomObject(String data, int number) { + this.data = data; + this.number = number; + } + + public String getData() { + return data; + } + + public int getNumber() { + return number; + } + + @Override + public boolean equals(Object obj) { + CustomObject bean = (CustomObject) obj; + return data.equals(bean.getData()) && number == bean.getNumber(); + } + } + + static class CustomConstructor extends SafeConstructor { + + public static final Tag CUSTOM_TAG = new Tag(CustomObject.class); + + public CustomConstructor() { + super(); + this.construct(Tag.MAP, new CustomMapConstructor()); + this.construct(CUSTOM_TAG, new CustomObjConstructor()); + } + + public Object constructObject(@NotNull Node node) { + return super.constructObject(node); + } + + private class CustomMapConstructor extends MapConstructor { + @Override + public @Nullable Object construct(@Nullable Node node) { + Map map = (Map) super.construct(node); + + if(map.containsKey("==") && map.get("==").equals("CustomObject")) { + return new CustomObject( + map.get("data").toString(), + Integer.parseInt(map.get("number").toString()) + ); + } + + return map; + } + } + + private class CustomObjConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + String data = ((ScalarNode) node).getData().toString(); + return new CustomObject( + data.substring(0, data.indexOf("-")), + Integer.parseInt(data.substring(data.indexOf("-") + 1)) + ); + } + } + } + + + + + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java index 121c77e..6f53d34 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java @@ -1,6 +1,7 @@ package dev.spoocy.utils.config.io; import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.ResourceTest; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java index 6e9fc27..5e5daf0 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java @@ -1,6 +1,7 @@ package dev.spoocy.utils.config.io; import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.ResourceTest; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java index 6dde913..5c2d744 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java @@ -1,6 +1,7 @@ package dev.spoocy.utils.config.io; import dev.spoocy.utils.common.misc.FileUtils; +import dev.spoocy.utils.config.ResourceTest; import dev.spoocy.utils.config.Resources; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java deleted file mode 100644 index abd96e0..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/io/ResourceTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.spoocy.utils.config.io; - -import dev.spoocy.utils.config.BaseResourceResolver; -import dev.spoocy.utils.config.Config; -import dev.spoocy.utils.config.ResourceResolver; -import dev.spoocy.utils.config.constructor.SerializingConstructor; -import dev.spoocy.utils.config.loader.JsonConfigLoader; -import dev.spoocy.utils.config.loader.YamlConfigLoader; -import org.jetbrains.annotations.NotNull; - -import java.io.File; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public abstract class ResourceTest { - - public static final String TEST_RESOURCES_DIR = "src/test/resources/dev/spoocy/utils/config"; - private static final ResourceResolver RESOLVER = createResolver(); - - private static ResourceResolver createResolver() { - BaseResourceResolver resolver = new BaseResourceResolver(ResourceTest.class.getClassLoader()); - resolver.registerLoader(JsonConfigLoader.INSTANCE); - resolver.registerLoader(YamlConfigLoader.INSTANCE); - return resolver; - } - - public static String resourcesPath(@NotNull String string) { - return (TEST_RESOURCES_DIR + '/' + string).replace('/', File.separatorChar); - } - - public static Config loadDefault(@NotNull Resource resource) { - try { - return RESOLVER.loadConfig(resource, SerializingConstructor.DEFAULT_INSTANCE); - } catch (Exception e) { - throw new RuntimeException("Failed to load resource: " + resource, e); - } - } - -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java deleted file mode 100644 index 593a468..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/loader/AnnotatedConfigLoaderTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package dev.spoocy.utils.config.loader; - -import dev.spoocy.utils.config.AbstractAnnotatedConfigTest; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import dev.spoocy.utils.config.types.SerializableExample; -import dev.spoocy.utils.config.types.YamlConfig; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ -public class AnnotatedConfigLoaderTest extends AbstractAnnotatedConfigTest { - - private abstract static class LoaderCase { - protected final AnnotatedConfigLoader loader = new AnnotatedConfigLoader(); - } - - @Nested - class SerializableBinding extends LoaderCase { - - @Test - void loadsSerializableFromReadableMapUsingGetSerializable() { - YamlConfig config = new YamlConfig(); - - Map serialized = new LinkedHashMap<>(); - serialized.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); - serialized.put("name", "example"); - serialized.put("value", 42); - config.set("serializable", serialized); - - SerializableFromReadable settings = this.loader.load(SerializableFromReadable.class, config); - - assertNotNull(settings.serializable); - assertEquals("example", settings.serializable.getName()); - assertEquals(42, settings.serializable.getValue()); - } - } - - @Nested - class HookBinding extends LoaderCase { - - @Test - void invokesPreAndPostLoadHooks() { - YamlConfig config = new YamlConfig(); - - HookedSettings withoutConfigValue = this.loader.load(HookedSettings.class, config); - assertTrue(withoutConfigValue.preCalled); - assertTrue(withoutConfigValue.postCalled); - assertEquals("from-pre-post", withoutConfigValue.message); - - config.set("message", "from-config"); - HookedSettings withConfigValue = this.loader.load(HookedSettings.class, config); - assertTrue(withConfigValue.preCalled); - assertTrue(withConfigValue.postCalled); - assertEquals("from-config-post", withConfigValue.message); - } - } - - @Nested - class CollectionBinding extends LoaderCase { - - @Test - void loadsTypedCollectionFieldsFromReadable() { - YamlConfig config = new YamlConfig(); - config.set("flags", List.of("one", "two")); - config.set("ids", List.of(7, 11)); - - CollectionSettings settings = this.loader.load(CollectionSettings.class, config); - - assertIterableEquals(List.of("one", "two"), settings.flags); - assertEquals(Set.of(7, 11), settings.ids); - } - } - - @Nested - class WriteBinding extends LoaderCase { - - @Test - void writeOnlyPersistsFieldsMarkedForDefaultSaving() { - SelectiveDefaultsSettings settings = new SelectiveDefaultsSettings(); - settings.host = "db.remote"; - settings.port = 1234; - settings.user = "admin"; - - YamlConfig target = new YamlConfig(); - int mutations = this.loader.write(settings, target); - - assertEquals(2, mutations); - assertEquals("db.remote", target.getString("database.host", "")); - assertEquals("admin", target.getString("database.user", "")); - assertFalse(target.isSet("database.port")); - } - } - -} - diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/representer/RepresenterTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/representer/RepresenterTest.java new file mode 100644 index 0000000..81630f3 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/representer/RepresenterTest.java @@ -0,0 +1,390 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.Tag; +import dev.spoocy.utils.config.nodes.Node; +import dev.spoocy.utils.config.nodes.NodeTree; +import dev.spoocy.utils.config.nodes.NodeTuple; +import dev.spoocy.utils.config.nodes.NodeType; +import dev.spoocy.utils.config.nodes.ScalarNode; +import dev.spoocy.utils.config.nodes.SequenceNode; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class RepresenterTest { + + private final CustomRepresenter representer = new CustomRepresenter(); + + @Nested + class Constructors { + + @Test + public void testSafeRepresenterConstructorRegistersDefaults() { + SafeRepresenter safeRepresenter = new SafeRepresenter(); + + assertNotNull(safeRepresenter.getRepresent(Node.NULL_TYPE)); + assertNotNull(safeRepresenter.getRepresent(String.class)); + assertNotNull(safeRepresenter.getRepresent(Boolean.class)); + assertNotNull(safeRepresenter.getRepresent(Map.class)); + assertNotNull(safeRepresenter.getRepresent(List.class)); + assertNotNull(safeRepresenter.getRepresent(LinkedHashMap.class)); + assertNotNull(safeRepresenter.getRepresent(ArrayList.class)); + } + + @Test + public void testCustomRepresenterConstructorKeepsDefaultsAndRegistersCustomType() { + CustomRepresenter customRepresenter = new CustomRepresenter(); + + assertNotNull(customRepresenter.getRepresent(Node.NULL_TYPE)); + assertNotNull(customRepresenter.getRepresent(Map.class)); + assertNotNull(customRepresenter.getRepresent(List.class)); + assertNotNull(customRepresenter.getRepresent(DirectObject.class)); + } + + } + + @Nested + class Primitives { + + @Test + public void testNull() { + Node node = representer.representObject(null); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertNull(scalarNode.getData()); + assertEquals(Tag.NULL, scalarNode.getTag()); + } + + @Test + public void testString() { + String value = "Hello, World!"; + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals(value, scalarNode.getData()); + assertEquals(Tag.STR, scalarNode.getTag()); + } + + @Test + public void testBoolean() { + Boolean value = true; + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals(value, scalarNode.getData()); + assertEquals(Tag.BOOL, scalarNode.getTag()); + } + + @Test + public void testInteger() { + Integer value = 42; + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals(value, scalarNode.getData()); + assertEquals(Tag.INT, scalarNode.getTag()); + } + + @Test + public void testFloatingPoint() { + Double value = 3.14d; + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals(value, scalarNode.getData()); + assertEquals(Tag.FLOAT, scalarNode.getTag()); + } + + } + + @Nested + class Collections { + + @Test + public void testSetSerialization() { + Set value = new LinkedHashSet<>(); + value.add("one"); + value.add(2); + value.add(true); + + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SEQUENCE, node.getNodeType()); + assertInstanceOf(SequenceNode.class, node); + + SequenceNode sequenceNode = (SequenceNode) node; + assertEquals(Tag.SET, sequenceNode.getTag()); + assertEquals(3, sequenceNode.getValue().size()); + + ScalarNode first = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(0)); + ScalarNode second = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(1)); + ScalarNode third = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(2)); + + assertEquals("one", first.getData()); + assertEquals(Tag.STR, first.getTag()); + assertEquals(2, second.getData()); + assertEquals(Tag.INT, second.getTag()); + assertEquals(true, third.getData()); + assertEquals(Tag.BOOL, third.getTag()); + } + + @Test + public void testListSerialization() { + List value = List.of("one", 2, true); + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SEQUENCE, node.getNodeType()); + assertInstanceOf(SequenceNode.class, node); + + SequenceNode sequenceNode = (SequenceNode) node; + assertEquals(Tag.SEQ, sequenceNode.getTag()); + assertEquals(3, sequenceNode.getValue().size()); + + ScalarNode first = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(0)); + ScalarNode second = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(1)); + ScalarNode third = assertInstanceOf(ScalarNode.class, sequenceNode.getValue().get(2)); + + assertEquals("one", first.getData()); + assertEquals(Tag.STR, first.getTag()); + assertEquals(2, second.getData()); + assertEquals(Tag.INT, second.getTag()); + assertEquals(true, third.getData()); + assertEquals(Tag.BOOL, third.getTag()); + } + + @Test + public void testEmptyListSerialization() { + Node node = representer.representObject(List.of()); + + SequenceNode sequenceNode = assertInstanceOf(SequenceNode.class, node); + assertEquals(Tag.SEQ, sequenceNode.getTag()); + assertTrue(sequenceNode.getValue().isEmpty()); + } + + } + + @Nested + class Maps { + + @Test + public void testMapSerialization() { + Map value = new LinkedHashMap<>(); + value.put("name", "test"); + value.put("age", 21); + + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.TREE, node.getNodeType()); + + NodeTree treeNode = assertInstanceOf(NodeTree.class, node); + assertEquals(Tag.MAP, treeNode.getTag()); + assertEquals(2, treeNode.getValue().size()); + + NodeTuple firstTuple = treeNode.getValue().get(0); + ScalarNode firstKey = assertInstanceOf(ScalarNode.class, firstTuple.getKeyNode()); + ScalarNode firstValue = assertInstanceOf(ScalarNode.class, firstTuple.getValueNode()); + assertEquals("name", firstKey.getData()); + assertEquals("test", firstValue.getData()); + + NodeTuple secondTuple = treeNode.getValue().get(1); + ScalarNode secondKey = assertInstanceOf(ScalarNode.class, secondTuple.getKeyNode()); + ScalarNode secondValue = assertInstanceOf(ScalarNode.class, secondTuple.getValueNode()); + assertEquals("age", secondKey.getData()); + assertEquals(21, secondValue.getData()); + } + + @Test + public void testEmptyMapSerialization() { + Node node = representer.representObject(Map.of()); + + NodeTree treeNode = assertInstanceOf(NodeTree.class, node); + assertEquals(Tag.MAP, treeNode.getTag()); + assertTrue(treeNode.getValue().isEmpty()); + } + + } + + @Nested + class Custom { + + @Test + public void testDirectSerialization() { + DirectObject value = new DirectObject("data", 5); + + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals("data-5", scalarNode.getData()); + assertEquals(new Tag(DirectObject.class), scalarNode.getTag()); + } + + @Test + public void testParentSerialization() { + Represent found = representer.getRepresent(ParentObject.class); + assertEquals(CustomRepresenter.RepresentByInterface.class, found.getClass()); + + + ParentObject value = new ParentObject("parent-data"); + + Node node = representer.representObject(value); + + assertNotNull(node); + assertEquals(NodeType.SCALAR, node.getNodeType()); + + ScalarNode scalarNode = assertInstanceOf(ScalarNode.class, node); + assertEquals("parent-data", scalarNode.getData()); + assertEquals(new Tag(IRepresentable.class), scalarNode.getTag()); + } + + } + + @Nested + class EdgeCases { + + @Test + public void testRepresentationRejectsNodeInput() { + ScalarNode alreadyRepresented = new ScalarNode("test", Tag.STR, null, null); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> representer.representObject(alreadyRepresented) + ); + + assertEquals("Data already represented.", exception.getMessage()); + } + + @Test + public void testRepresentationRejectsUnknownType() { + UnknownObject value = new UnknownObject("fallback"); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> representer.representObject(value) + ); + + assertEquals("Type '" + UnknownObject.class.getName() + "' cannot be represented.", exception.getMessage()); + } + } + + interface IRepresentable { + String getData(); + } + + static class UnknownObject { + + private final String value; + + public UnknownObject(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + static class DirectObject { + + private final String data; + private final int number; + + public DirectObject(String data, int number) { + this.data = data; + this.number = number; + } + + public String getData() { + return data; + } + + public int getNumber() { + return number; + } + + @Override + public boolean equals(Object obj) { + DirectObject bean = (DirectObject) obj; + return data.equals(bean.getData()) && number == bean.getNumber(); + } + } + + static class ParentObject implements IRepresentable { + + private final String data; + + public ParentObject(String data) { + this.data = data; + } + + @Override + public String getData() { + return data; + } + } + + static class CustomRepresenter extends SafeRepresenter { + + public CustomRepresenter() { + super(); + setStrict(true); + represent(DirectObject.class, new CustomRepresenter.RepresentCustom()); + representOf(IRepresentable.class, new CustomRepresenter.RepresentByInterface()); + } + + private class RepresentCustom implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + DirectObject obj = (DirectObject) data; + String value = obj.getData() + "-" + obj.getNumber(); + return representScalar(new Tag(DirectObject.class), value); + } + } + + private class RepresentByInterface implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + IRepresentable obj = (IRepresentable) data; + String value = obj.getData(); + return representScalar(new Tag(IRepresentable.class), value); + } + } + + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java deleted file mode 100644 index a641a54..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/AtomicsSerializerTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import dev.spoocy.utils.config.serializer.impl.AtomicsSerializer; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; - -import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class AtomicsSerializerTest { - - @Test - void serializeAtomicInteger() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicInteger.class); - AtomicInteger value = new AtomicInteger(42); - - Map map = serializer.serialize(value); - - assertEquals(42, ((Number) map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)).intValue()); - assertEquals(AtomicInteger.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); - } - - @Test - void deserializeAtomicInteger() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicInteger.class); - Map map = new HashMap<>(); - map.put("value", Integer.valueOf(7)); - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); - - AtomicInteger result = serializer.deserialize(map); - - assertEquals(7, result.get()); - } - - @Test - void serializeAtomicLong() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicLong.class); - AtomicLong value = new AtomicLong(123456789L); - - Map map = serializer.serialize(value); - - assertEquals(123456789L, ((Number) map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)).longValue()); - assertEquals(AtomicLong.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); - } - - @Test - void deserializeAtomicLong() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicLong.class); - Map map = new HashMap<>(); - map.put(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY, Long.valueOf(99L)); - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicLong.class.getName()); - - AtomicLong result = serializer.deserialize(map); - - assertEquals(99L, result.get()); - } - - @Test - void serializeAtomicBoolean() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicBoolean.class); - AtomicBoolean value = new AtomicBoolean(true); - - Map map = serializer.serialize(value); - - assertEquals(Boolean.TRUE, map.get(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY)); - assertEquals(AtomicBoolean.class.getName(), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); - } - - @Test - void deserializeAtomicBoolean() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicBoolean.class); - Map map = new HashMap<>(); - map.put(AtomicsSerializer.SERIALIZED_ATOMIC_VALUE_KEY, Boolean.FALSE); - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicBoolean.class.getName()); - - AtomicBoolean result = serializer.deserialize(map); - - assertFalse(result.get()); - } - - @Test - void serializeUnsupportedTypeThrows() { - AtomicsSerializer serializer = new AtomicsSerializer<>(String.class); - assertThrows(IllegalArgumentException.class, () -> serializer.serialize("not-an-atomic")); - } - - @Test - void deserializeMissingValueThrows() { - AtomicsSerializer serializer = new AtomicsSerializer<>(AtomicInteger.class); - Map map = new HashMap<>(); - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, AtomicInteger.class.getName()); - - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); - } - - @Test - void deserializeUnsupportedTypeThrows() { - AtomicsSerializer serializer = new AtomicsSerializer<>(String.class); - Map map = new HashMap<>(); - map.put("value", "x"); - map.put(NamedSerializers.SERIALIZED_TYPE_KEY, String.class.getName()); - - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); - } - -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java deleted file mode 100644 index f90df7d..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/JavaSerializerTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import dev.spoocy.utils.config.serializer.impl.JavaSerializer; -import org.junit.jupiter.api.Test; - -import java.io.Serializable; -import java.time.Duration; -import java.util.Base64; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -class JavaSerializerTest { - - @Test - void serializeDeserializeDuration_roundTrip() { - JavaSerializer serializer = JavaSerializer.create(Duration.class); - Duration original = Duration.ofHours(5).plusMinutes(30).plusSeconds(15); - Map data = serializer.serialize(original); - - Duration restored = serializer.deserialize(data); - assertEquals(original, restored); - } - - @Test - void serializeDeserializeCustomSerializable_roundTrip() { - JavaSerializer serializer = JavaSerializer.create(Person.class); - Person original = new Person("Alice", 28); - Map data = serializer.serialize(original); - - Person restored = serializer.deserialize(data); - assertEquals(original, restored); - } - - @Test - void deserialize_missingDataKey_throwsIllegalArgumentException() { - JavaSerializer serializer = JavaSerializer.create(Duration.class); - Map bad = Map.of("class", "java.time.Duration"); - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(bad)); - } - - @Test - void deserialize_invalidBase64_throwsIllegalArgumentException() { - JavaSerializer serializer = JavaSerializer.create(Duration.class); - Map bad = Map.of("class", "java.time.Duration", "data", "not-base64-!!!"); - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(bad)); - } - - @Test - void deserialize_corruptedBytes_throwsRuntimeException() { - JavaSerializer serializer = JavaSerializer.create(Duration.class); - // valid base64 but not a serialized object - String corrupted = Base64.getEncoder().encodeToString(new byte[]{1, 2, 3, 4, 5}); - Map bad = Map.of("class", "java.time.Duration", "data", corrupted); - assertThrows(RuntimeException.class, () -> serializer.deserialize(bad)); - } - - // Serializable class for testing - static final class Person implements Serializable { - private static final long serialVersionUID = 1L; - final String name; - final int age; - - Person(String name, int age) { - this.name = name; - this.age = age; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Person)) return false; - Person p = (Person) o; - return age == p.age && (name == null ? p.name == null : name.equals(p.name)); - } - - @Override - public int hashCode() { - int result = name != null ? name.hashCode() : 0; - result = 31 * result + age; - return result; - } - } -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java deleted file mode 100644 index 70ed2d2..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/serializer/SerializerTest.java +++ /dev/null @@ -1,174 +0,0 @@ -package dev.spoocy.utils.config.serializer; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class SerializerTest { - - private static class LocalSerializers extends BaseSerializers { - @Override - public @NotNull Map serialize(@NotNull Object object) { - throw new UnsupportedOperationException("Not needed in this test"); - } - - @Override - public Object deserialize(@NotNull Map map) { - throw new UnsupportedOperationException("Not needed in this test"); - } - } - - // Simple ConfigSerializable implementation used in tests - public static class Person implements ConfigSerializable { - private final String name; - private final int age; - - public Person(String name, int age) { - this.name = name; - this.age = age; - } - - @Override - public Map serialize() { - Map map = new HashMap<>(); - map.put("name", name); - map.put("age", age); - return map; - } - - public static Person deserialize(Map map) { - Object nameObj = map.get("name"); - Object ageObj = map.get("age"); - String name = nameObj instanceof String ? (String) nameObj : null; - int age = ageObj instanceof Number ? ((Number) ageObj).intValue() : 0; - return new Person(name, age); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Person)) return false; - Person person = (Person) o; - return age == person.age && (name == null ? person.name == null : name.equals(person.name)); - } - - @Override - public int hashCode() { - int result = name != null ? name.hashCode() : 0; - result = 31 * result + age; - return result; - } - } - - private static final NamedSerializers serializers = NamedSerializers.DEFAULT_INSTANCE; - - @BeforeAll - static void registerSerializableClass() { - // Ensure Person class is registered before tests - serializers.register(Person.class); - } - - @Test - void registerAndDeserializeUsingConfigSerializer() { - // obtain serializer via resolve and serialize an instance - Serializer serializer = serializers.resolve(Person.class); - assertNotNull(serializer); - - Person original = new Person("Alice", 30); - Map serialized = serializer.serialize(original); - - // ensure the serializer included the serialized type key - assertEquals(NamedSerializers.getSerializeName(Person.class), serialized.get(NamedSerializers.SERIALIZED_TYPE_KEY)); - - // use ConfigSerializer.deserialize to reconstruct the object - ConfigSerializable cs = serializer.deserialize(serialized); - assertNotNull(cs); - assertTrue(cs instanceof Person); - - Person deserialized = (Person) cs; - assertEquals(original, deserialized); - } - - @Test - void duplicateRegisterThrows() { - assertThrows(IllegalArgumentException.class, () -> serializers.register(Person.class)); - } - - @Test - void deserializeMissingOrUnknownTypeReturnsNull() { - // missing serialized type key - Map missing = new HashMap<>(); - missing.put("foo", "bar"); - assertNull(serializers.deserialize(missing)); - - // unknown type name - Map unknown = new HashMap<>(); - unknown.put(NamedSerializers.SERIALIZED_TYPE_KEY, "non.existent.ClassName"); - assertNull(serializers.deserialize(unknown)); - - // serialized type key present but not a string - Map wrongType = new HashMap<>(); - wrongType.put(NamedSerializers.SERIALIZED_TYPE_KEY, 123); - assertNull(serializers.deserialize(wrongType)); - } - - @Test - void resolveReturnsClassSerializerForConfigSerializable() { - // ensure resolver returns a serializer for classes implementing ConfigSerializable - Serializer serializer = serializers.resolve(Person.class); - assertNotNull(serializer); - - Person p = new Person("Bob", 25); - Map map = serializer.serialize(p); - - // serialized map must contain the type key and the values - assertEquals(NamedSerializers.getSerializeName(Person.class), map.get(NamedSerializers.SERIALIZED_TYPE_KEY)); - assertEquals("Bob", map.get("name")); - assertEquals(25, ((Number) map.get("age")).intValue()); - } - - @Test - void baseSerializersProvideStaticDefaultsForAllSubclasses() { - LocalSerializers first = new LocalSerializers(); - LocalSerializers second = new LocalSerializers(); - - assertNotNull(first.resolve(Duration.class)); - assertNotNull(second.resolve(Duration.class)); - assertNotNull(first.resolve(AtomicInteger.class)); - assertNotNull(second.resolve(AtomicInteger.class)); - } - - @Test - void localRegistrationOverridesBaseDefault() { - LocalSerializers serializers = new LocalSerializers(); - Serializer custom = new Serializer<>() { - @Override - public @NotNull Map serialize(@NotNull Duration object) { - return Map.of("custom", object.toString()); - } - - @Override - public @NotNull Duration deserialize(@NotNull Map map) { - return Duration.parse((String) map.get("custom")); - } - }; - - serializers.register(Duration.class, custom); - assertSame(custom, serializers.resolve(Duration.class)); - } -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigSettingsTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigSettingsTest.java new file mode 100644 index 0000000..c1a7b41 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigSettingsTest.java @@ -0,0 +1,31 @@ +package dev.spoocy.utils.config.types; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigSettingsTest { + + @Test + void configurationReturnsOriginalConfig() { + MemoryConfig config = new MemoryConfig(); + ConfigSettings settings = new ConfigSettings(config); + + assertSame(config, settings.configuration()); + } + + @Test + void pathSeparatorIsFluent() { + ConfigSettings settings = new ConfigSettings(new MemoryConfig()); + + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigTest.java new file mode 100644 index 0000000..e9a836e --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/ConfigTest.java @@ -0,0 +1,1486 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.*; +import dev.spoocy.utils.config.constructor.Construct; +import dev.spoocy.utils.config.constructor.Constructor; +import dev.spoocy.utils.config.constructor.SafeConstructor; +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.io.WriteableResource; +import dev.spoocy.utils.config.loader.ConfigLoader; +import dev.spoocy.utils.config.nodes.Node; +import dev.spoocy.utils.config.nodes.NodeTree; +import dev.spoocy.utils.config.nodes.NodeTuple; +import dev.spoocy.utils.config.nodes.ScalarNode; +import dev.spoocy.utils.config.representer.Represent; +import dev.spoocy.utils.config.representer.Representer; +import dev.spoocy.utils.config.representer.SafeRepresenter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class ConfigTest extends ResourceTest { + + private static final String EMPTY_FILE_NAME = "__empty-config.txt"; + private static final String NON_EXISTING_FILE = resourcesPath("__invalid__/non-existing-config.txt"); + + protected static final Representer REPRESENTER = new ConfigRepresenter(); + protected static final Constructor CONSTRUCTOR = new ConfigConstructor(); + + /** + * {@link ConfigLoader} implementation for this type of config. + */ + protected abstract ConfigLoader loader(); + + /** + * {@link Resource} with example data. + */ + protected abstract Resource exampleResource(); + + /** + * {@link Resource} with example map data to serialize. + */ + protected abstract Resource exampleMapResource(); + + /** + * Number of entries in the example config file. + */ + protected abstract int exampleConfigEntries(); + + /** + * Whether the config loader supports {@link dev.spoocy.utils.config.Tag}. + */ + protected abstract boolean supportsTags(); + + protected C load(@NotNull Resource resource) throws IOException { + return load(resource, CONSTRUCTOR); + } + + protected C load(@NotNull Resource resource, @NotNull Constructor constructor) throws IOException { + return loader().load(resource, constructor); + } + + protected C emptyConfig() { + return loader().createEmpty(); + } + + @NotNull + protected static Map serializableMap() { + Map serialMap = new LinkedHashMap<>(); + serialMap.put("name", "example"); + serialMap.put("value", 42); + return serialMap; + } + + @NotNull + protected static Resource emptyFile(@NotNull Path dir) throws IOException { + Path file = dir.resolve(EMPTY_FILE_NAME); + + if (!Files.exists(file)) { + Files.createFile(file); + } + + return Resources.fromPath(file); + } + + @NotNull + protected static Resource invalidResource() { + return Resources.fromPath(Path.of(NON_EXISTING_FILE)); + } + + @Nested + class Creation { + + @Test + void createEmpty() { + Config config = loader().createEmpty(); + assertEquals(0, config.values(true).size()); + } + + @Test + void createEmptyWithSettings() { + Config config = loader().createEmpty(s -> s.pathSeparator('/')); + assertEquals(0, config.values(true).size()); + assertEquals('/', config.settings().pathSeparator()); + } + + @Test + void createWithValues() { + Config config = loader().createEmpty(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("key3", serializableMap()); + + assertEquals(3, config.values(true).size()); + assertEquals("value1", config.getString("key1")); + assertEquals(123, config.getInt("key2")); + + Object rawSerializable = config.getObject("key3"); + assertInstanceOf(Map.class, rawSerializable); + assertEquals("example", ((Map) rawSerializable).get("name")); + } + } + + @Nested + class Mutation { + + @Test + void setValue() { + Config config = emptyConfig(); + config.set("key", "value"); + assertEquals("value", config.getString("key")); + } + + @Test + void overwriteValue() { + Config config = emptyConfig(); + config.set("key", "original"); + config.set("key", "updated"); + assertEquals("updated", config.getString("key")); + } + + @Test + void setNullValue() { + Config config = emptyConfig(); + config.set("key", null); + assertNull(config.getObject("key")); + } + + @Test + void removeValue() { + Config config = emptyConfig(); + config.set("key1", "value1"); + config.set("key2", "value2"); + config.remove("key1"); + + assertNull(config.getObject("key1")); + assertEquals("value2", config.getString("key2")); + } + + @Test + void removeNonExistentPath() { + Config config = emptyConfig(); + // Should not throw + config.remove("non.existent.path"); + } + + @Test + void clearConfig() { + Config config = emptyConfig(); + config.set("key1", "value1"); + config.set("key2", "value2"); + config.set("nested.key", "value3"); + + config.clear(); + assertEquals(0, config.values(true).size()); + } + + @Test + void toggleBoolean() { + Config config = emptyConfig(); + config.set("enabled", true); + config.opposite("enabled"); + assertFalse(config.getBoolean("enabled")); + + config.opposite("enabled"); + assertTrue(config.getBoolean("enabled")); + } + + @Test + void toggleBooleanOnNonBoolean() { + Config config = emptyConfig(); + config.set("value", "not a boolean"); + // Should handle gracefully + config.opposite("value"); + } + + @Test + void arithmeticAdd() { + Config config = emptyConfig(); + config.set("counter", 10); + config.add("counter", 5); + assertEquals(15, config.getDouble("counter")); + } + + @Test + void arithmeticSubtract() { + Config config = emptyConfig(); + config.set("counter", 20); + config.subtract("counter", 3); + assertEquals(17, config.getDouble("counter")); + } + + @Test + void arithmeticMultiply() { + Config config = emptyConfig(); + config.set("value", 5); + config.multiply("value", 2); + assertEquals(10, config.getDouble("value")); + } + + @Test + void arithmeticDivide() { + Config config = emptyConfig(); + config.set("value", 20); + config.divide("value", 4); + assertEquals(5, config.getDouble("value")); + } + + @Test + void arithmeticWithDouble() { + Config config = emptyConfig(); + config.set("price", 9.99); + config.multiply("price", 1.5); + assertEquals(14.985, config.getDouble("price")); + } + } + + @Nested + class TypeConversions { + + @Test + void getStringDefaultValue() { + Config config = loader().createEmpty(); + assertEquals("default", config.getString("missing", "default")); + } + + @Test + void getStringWithoutDefault() { + Config config = loader().createEmpty(); + assertEquals("", config.getString("missing")); + } + + @Test + void getIntDefaultValue() { + Config config = loader().createEmpty(); + assertEquals(42, config.getInt("missing", 42)); + } + + @Test + void getIntWithoutDefault() { + Config config = loader().createEmpty(); + assertEquals(0, config.getInt("missing")); + } + + @Test + void getDoubleDefaultValue() { + Config config = loader().createEmpty(); + assertEquals(3.14, config.getDouble("missing", 3.14)); + } + + @Test + void getFloatDefaultValue() { + Config config = loader().createEmpty(); + assertEquals(2.5f, config.getFloat("missing", 2.5f)); + } + + @Test + void getLongDefaultValue() { + Config config = loader().createEmpty(); + assertEquals(999999999L, config.getLong("missing", 999999999L)); + } + + @Test + void getBooleanDefaultValue() { + Config config = loader().createEmpty(); + assertTrue(config.getBoolean("missing", true)); + assertFalse(config.getBoolean("missing", false)); + } + + @Test + void getBooleanWithoutDefault() { + Config config = loader().createEmpty(); + assertFalse(config.getBoolean("missing")); + } + + @Test + void getObjectDefaultValue() { + Config config = loader().createEmpty(); + Object defaultObj = new Object(); + assertSame(defaultObj, config.getObject("missing", defaultObj)); + } + + @Test + void getGenericTypeWithClass() { + Config config = loader().createEmpty(); + config.set("text", "hello"); + String result = config.get("text", String.class); + assertEquals("hello", result); + } + + @Test + void getGenericTypeWithWrongClass() { + Config config = loader().createEmpty(); + config.set("text", "hello"); + // Attempting to get as Integer may use NumberConversion + // which may convert "hello" to 0 or return null depending on implementation + Integer result = config.get("text", Integer.class); + // Result depends on NumberConversion behavior + assertTrue(result == null || result == 0); + } + + @Test + void getGenericTypeWithDefault() { + Config config = loader().createEmpty(); + String result = config.get("missing", String.class, "default"); + assertEquals("default", result); + } + + @Test + void isTypeCheck() { + Config config = loader().createEmpty(); + config.set("text", "hello"); + config.set("number", 42); + + assertTrue(config.is("text", String.class)); + assertFalse(config.is("text", Integer.class)); + assertTrue(config.is("number", Integer.class)); + } + + @Test + void isStringCheck() { + Config config = loader().createEmpty(); + config.set("text", "hello"); + config.set("number", 42); + + assertTrue(config.isString("text")); + assertFalse(config.isString("number")); + } + + @Test + void isIntCheck() { + Config config = loader().createEmpty(); + config.set("number", 42); + config.set("text", "hello"); + + assertTrue(config.isInt("number")); + assertFalse(config.isInt("text")); + } + + @Test + void isDoubleCheck() { + Config config = loader().createEmpty(); + config.set("decimal", 3.14); + config.set("text", "hello"); + + assertTrue(config.isDouble("decimal")); + assertFalse(config.isDouble("text")); + } + + @Test + void isFloatCheck() { + Config config = loader().createEmpty(); + config.set("decimal", 2.5f); + config.set("text", "hello"); + + assertTrue(config.isFloat("decimal")); + assertFalse(config.isFloat("text")); + } + + @Test + void isLongCheck() { + Config config = loader().createEmpty(); + config.set("bignum", 999999999L); + config.set("text", "hello"); + + assertTrue(config.isLong("bignum")); + assertFalse(config.isLong("text")); + } + + @Test + void isBooleanCheck() { + Config config = loader().createEmpty(); + config.set("flag", true); + config.set("text", "hello"); + + assertTrue(config.isBoolean("flag")); + assertFalse(config.isBoolean("text")); + } + + @Test + void isSetCheck() { + Config config = loader().createEmpty(); + config.set("exists", "value"); + + assertTrue(config.isSet("exists")); + assertFalse(config.isSet("missing")); + } + + @Test + void isListCheck() { + Config config = loader().createEmpty(); + config.set("list", List.of(1, 2, 3)); + config.set("text", "hello"); + + assertTrue(config.isList("list")); + assertFalse(config.isList("text")); + } + + @Test + void stringConversionFromNumber() { + Config config = loader().createEmpty(); + config.set("number", 42); + assertEquals("42", config.getString("number")); + } + + @Test + void numberConversionFromString() { + Config config = loader().createEmpty(); + config.set("text", "123"); + // Should attempt conversion + config.getInt("text", 0); + // Result depends on NumberConversion implementation + assertNotNull(config.getObject("text")); + } + } + + @Nested + class KeysAndValues { + + @Test + void keysShallow() { + Config config = loader().createEmpty(); + config.set("key1", "value1"); + config.set("key2", "value2"); + config.createSection("section.nested"); + + Collection keys = config.keys(false); + assertTrue(keys.contains("key1")); + assertTrue(keys.contains("key2")); + assertTrue(keys.contains("section")); + } + + @Test + void keysDeep() { + Config config = loader().createEmpty(); + config.set("key1", "value1"); + config.set("nested.key2", "value2"); + config.set("nested.key3.deep", "value3"); + + Collection keys = config.keys(true); + assertTrue(keys.contains("key1")); + assertTrue(keys.contains("nested.key2")); + assertTrue(keys.contains("nested.key3.deep")); + } + + @Test + void valuesShallow() { + Config config = loader().createEmpty(); + config.set("key1", "value1"); + config.set("key2", 42); + config.createSection("section"); + + Map values = config.values(false); + assertEquals("value1", values.get("key1")); + assertEquals(42, values.get("key2")); + assertFalse(values.containsKey("section.nested")); + } + + @Test + void valuesDeep() { + Config config = loader().createEmpty(); + config.set("key1", "value1"); + config.set("nested.key2", "value2"); + config.set("nested.key3.deep", "value3"); + + Map values = config.values(true); + assertEquals("value1", values.get("key1")); + assertEquals("value2", values.get("nested.key2")); + assertEquals("value3", values.get("nested.key3.deep")); + } + + @Test + void keysEmpty() { + Config config = loader().createEmpty(); + assertEquals(0, config.keys(true).size()); + assertEquals(0, config.keys(false).size()); + } + + @Test + void valuesEmpty() { + Config config = loader().createEmpty(); + assertEquals(0, config.values(true).size()); + assertEquals(0, config.values(false).size()); + } + } + + @Nested + class Sections { + + @Test + void createSection() { + Config config = loader().createEmpty(); + ConfigSection section = config.createSection("settings"); + + assertTrue(config.isSection("settings")); + assertEquals("settings", section.getName()); + assertSame(config, section.getRoot()); + assertSame(config, section.getParent()); + } + + @Test + void createNestedSection() { + Config config = loader().createEmpty(); + ConfigSection deep = config.createSection("a.b.c"); + + assertTrue(config.isSection("a")); + assertTrue(config.isSection("a.b")); + assertTrue(config.isSection("a.b.c")); + assertNotNull(deep); + } + + @Test + void createSectionWithData() { + Config config = loader().createEmpty(); + Map data = serializableMap(); + ConfigSection section = config.createSection("data", data); + + assertEquals("example", section.getString("name")); + assertEquals(42, section.getInt("value")); + } + +// @Test +// void createSectionWithNonStringKeys() { +// Config config = loader().createEmpty(); +// Map data = new LinkedHashMap<>(); +// data.put(1, "value"); +// data.put("key", "value2"); +// +// assertThrows(IllegalArgumentException.class, () -> config.createSection("section", data)); +// } + + @Test + void getSection() { + Config config = loader().createEmpty(); + config.createSection("settings"); + ConfigSection section = config.getSection("settings"); + + assertNotNull(section); + assertTrue(config.isSection("settings")); + } + + @Test + void getSectionNonExistent() { + Config config = loader().createEmpty(); + assertThrows(IllegalArgumentException.class, () -> config.getSection("missing")); + } + + @Test + void getSectionIfExists() { + Config config = loader().createEmpty(); + config.createSection("settings"); + + assertNotNull(config.getSectionIfExists("settings")); + assertNull(config.getSectionIfExists("missing")); + } + + @Test + void getSectionOrEmpty() { + Config config = loader().createEmpty(); + config.set("sec.key", "value"); + + ConfigSection section = config.getSectionOrEmpty("sec"); + assertNotNull(section); + assertEquals("value", section.getString("key")); + + ConfigSection nonExistent = config.getSectionOrEmpty("nonexistent"); + assertNotNull(nonExistent); + assertEquals(0, nonExistent.values(true).size()); + + assertFalse(nonExistent.isSection("nonexistent")); + } + + @Test + void getOrCreateSection() { + Config config = loader().createEmpty(); + assertFalse(config.isSection("sec")); + + ConfigSection section = config.getOrCreateSection("sec"); + assertNotNull(section); + assertTrue(config.isSection("sec")); + + ConfigSection existing = config.getOrCreateSection("sec"); + assertSame(section, existing); + } + + @Test + void getSectionFromValue() { + Config config = loader().createEmpty(); + config.set("key", "value"); + + assertThrows(IllegalArgumentException.class, () -> config.getSection("key")); + } + + @Test + void isSectionCheck() { + Config config = loader().createEmpty(); + config.createSection("section"); + config.set("value", "test"); + + assertTrue(config.isSection("section")); + assertFalse(config.isSection("value")); + assertFalse(config.isSection("missing")); + } + + @Test + void sectionValues() { + Config config = loader().createEmpty(); + config.createSection("data"); + config.set("data.key1", "value1"); + config.set("data.key2", 42); + + ConfigSection section = config.getSection("data"); + Map values = section.values(false); + + assertEquals("value1", values.get("key1")); + assertEquals(42, values.get("key2")); + } + + @Test + void sectionKeys() { + Config config = loader().createEmpty(); + config.set("data.key1", "value1"); + config.set("data.key2", 42); + + ConfigSection section = config.getSection("data"); + Collection keys = section.keys(false); + + assertTrue(keys.contains("key1")); + assertTrue(keys.contains("key2")); + } + + @Test + void nestedSectionNavigation() { + Config config = loader().createEmpty(); + config.createSection("root.level1.level2"); + config.set("root.level1.level2.value", "deep"); + + ConfigSection root = config.getSection("root"); + ConfigSection level1 = root.getSection("level1"); + ConfigSection level2 = level1.getSection("level2"); + + assertEquals("deep", level2.getString("value")); + } + + @Test + void sectionParent() { + Config config = loader().createEmpty(); + config.createSection("a.b"); + ConfigSection subSection = config.getSection("a.b"); + ConfigSection parentSection = subSection.getParent(); + + assertNotNull(parentSection); + assertEquals("a", parentSection.getName()); + assertSame(config, parentSection.getParent()); + } + + @Test + void sectionDoesntCountAsIsSet() { + Config config = loader().createEmpty(); + assertFalse(config.isSet("section")); + assertFalse(config.isSection("section")); + + config.set("section.key", "value"); + assertFalse(config.isSet("section")); + assertTrue(config.isSection("section")); + + config.set("section", "value"); + assertTrue(config.isSet("section")); + assertFalse(config.isSection("section")); + + config.remove("section"); + assertFalse(config.isSet("section")); + assertFalse(config.isSection("section")); + + config.createSection("section"); + assertFalse(config.isSet("section")); + assertTrue(config.isSection("section")); + + } + + } + + @Nested + class Lists { + + @Test + void getListDefaultValue() { + Config config = loader().createEmpty(); + List defaultList = List.of("default"); + List result = config.getList("missing", defaultList); + + assertEquals(defaultList, result); + } + + @Test + void getListWithoutDefault() { + Config config = loader().createEmpty(); + List result = config.getList("missing"); + + assertNull(result); + } + + @Test + void getListWithType() { + Config config = loader().createEmpty(); + config.set("numbers", List.of(1, 2, 3)); + List result = config.getList("numbers", Integer.class, null); + + assertEquals(3, result.size()); + assertEquals(1, result.get(0)); + } + + @Test + void getStringList() { + Config config = loader().createEmpty(); + config.set("items", List.of("apple", "banana", "cherry")); + List result = config.getStringList("items"); + + assertEquals(3, result.size()); + assertTrue(result.contains("apple")); + } + + @Test + void getStringListEmpty() { + Config config = loader().createEmpty(); + List result = config.getStringList("missing"); + + assertEquals(0, result.size()); + } + + @Test + void getBooleanList() { + Config config = loader().createEmpty(); + config.set("flags", List.of(true, false, true)); + List result = config.getBooleanList("flags"); + + assertEquals(3, result.size()); + assertTrue(result.get(0)); + assertFalse(result.get(1)); + } + + @Test + void getIntegerList() { + Config config = loader().createEmpty(); + config.set("numbers", List.of(1, 2, 3, 4, 5)); + List result = config.getIntegerList("numbers"); + + assertEquals(5, result.size()); + assertEquals(1, result.get(0)); + assertEquals(5, result.get(4)); + } + + @Test + void getDoubleList() { + Config config = loader().createEmpty(); + config.set("decimals", List.of(1.1, 2.2, 3.3)); + List result = config.getDoubleList("decimals"); + + assertEquals(3, result.size()); + assertEquals(1.1, result.get(0)); + } + + @Test + void getFloatList() { + Config config = loader().createEmpty(); + config.set("floats", List.of(1.5f, 2.5f, 3.5f)); + List result = config.getFloatList("floats"); + + assertEquals(3, result.size()); + } + + @Test + void getLongList() { + Config config = loader().createEmpty(); + config.set("longs", List.of(100L, 200L, 300L)); + List result = config.getLongList("longs"); + + assertEquals(3, result.size()); + assertEquals(100L, result.get(0)); + } + + @Test + void getByteList() { + Config config = loader().createEmpty(); + config.set("bytes", List.of((byte) 1, (byte) 2, (byte) 3)); + List result = config.getByteList("bytes"); + + assertEquals(3, result.size()); + } + + @Test + void getCharacterList() { + Config config = loader().createEmpty(); + config.set("chars", List.of("a", "b", "c")); + List result = config.getCharacterList("chars"); + + assertEquals(3, result.size()); + assertEquals('a', result.get(0).charValue()); + } + + @Test + void getShortList() { + Config config = loader().createEmpty(); + config.set("shorts", List.of((short) 1, (short) 2, (short) 3)); + List result = config.getShortList("shorts"); + + assertEquals(3, result.size()); + } + + @Test + void getMapList() { + Config config = loader().createEmpty(); + List> mapData = List.of( + Map.of("name", "Alice", "age", 30), + Map.of("name", "Bob", "age", 25) + ); + config.set("people", mapData); + + List> result = config.getMapList("people"); + assertEquals(2, result.size()); + assertEquals("Alice", result.get(0).get("name")); + assertEquals(25, result.get(1).get("age")); + } + + @Test + void getMapListEmpty() { + Config config = loader().createEmpty(); + List> result = config.getMapList("missing"); + + assertEquals(0, result.size()); + } + + @Test + void getMapListWithoutMaps() { + Config config = loader().createEmpty(); + config.set("mixed", List.of("string", 42, true)); + List> result = config.getMapList("mixed"); + + assertEquals(0, result.size()); + } + + @Test + void getSectionList() { + Config config = loader().createEmpty(); + List> mapData = List.of( + Map.of("name", "Alice"), + Map.of("name", "Bob") + ); + config.set("people", mapData); + + List result = config.getSectionList("people"); + assertEquals(2, result.size()); + assertEquals("Alice", result.get(0).getString("name")); + assertEquals("Bob", result.get(1).getString("name")); + } + + @Test + void getSectionListEmpty() { + Config config = loader().createEmpty(); + List result = config.getSectionList("missing"); + + assertEquals(0, result.size()); + } + } + + @Nested + class SpecialTypes { + + @Test + void getUUID() { + Config config = loader().createEmpty(); + String uuidString = "550e8400-e29b-41d4-a716-446655440000"; + config.set("id", uuidString); + + UUID result = config.getUUID("id"); + assertEquals(UUID.fromString(uuidString), result); + } + + @Test + void getUUIDInvalid() { + Config config = loader().createEmpty(); + config.set("id", "not-a-uuid"); + + UUID result = config.getUUID("id", null); + assertNull(result); + } + + @Test + void getUUIDWithDefault() { + Config config = loader().createEmpty(); + UUID defaultUUID = UUID.randomUUID(); + UUID result = config.getUUID("missing", defaultUUID); + + assertEquals(defaultUUID, result); + } + + @Test + void getClassType() { + Config config = loader().createEmpty(); + config.set("type", String.class.getCanonicalName()); + + Class result = config.getClass("type"); + assertEquals(String.class, result); + } + + @Test + void getClassTypeInvalid() { + Config config = loader().createEmpty(); + config.set("type", "not.a.valid.ClassName"); + + Class result = config.getClass("type", null); + assertNull(result); + } + + @Test + void getClassTypeWithDefault() { + Config config = loader().createEmpty(); + Class result = config.getClass("missing", Object.class); + + assertEquals(Object.class, result); + } + + @Test + void getEnum() { + Config config = loader().createEmpty(); + config.set("level", "HIGH"); + + TestLevel result = config.getEnum("level", TestLevel.class); + assertEquals(TestLevel.HIGH, result); + } + + @Test + void getEnumInvalid() { + Config config = loader().createEmpty(); + config.set("level", "INVALID"); + + TestLevel result = config.getEnum("level", TestLevel.class, TestLevel.LOW); + assertEquals(TestLevel.LOW, result); + } + + @Test + void getEnumWithDefault() { + Config config = loader().createEmpty(); + TestLevel result = config.getEnum("level", TestLevel.class, TestLevel.MEDIUM); + + assertEquals(TestLevel.MEDIUM, result); + } + } + + @Nested + class PathSeparator { + + @Test + void customPathSeparator() { + Config config = loader().createEmpty(s -> s.pathSeparator('/')); + config.set("a/b/c", "value"); + + assertEquals("value", config.getString("a/b/c")); + assertTrue(config.isSection("a")); + assertTrue(config.isSection("a/b")); + } + + @Test + void getWithCustomSeparator() { + Config config = loader().createEmpty(s -> s.pathSeparator('_')); + config.set("level_one_two", "value"); + + assertEquals("value", config.getString("level_one_two")); + } + + @Test + void keysWithCustomSeparator() { + Config config = loader().createEmpty(s -> s.pathSeparator('/')); + config.set("a/b/c", "value"); + + Collection keys = config.keys(true); + assertTrue(keys.stream().anyMatch(k -> k.contains("/"))); + } + } + + @Nested + class EdgeCases { + + @Test + void emptyKeyPath() { + Config config = loader().createEmpty(); + // Empty string key should be valid + config.set("", "value"); + assertEquals("value", config.getObject("")); + } + + @Test + void nullObject() { + Config config = loader().createEmpty(); + config.set("nullValue", null); + + assertNull(config.getObject("nullValue")); + assertTrue(config.isSet("nullValue")); + } + + @Test + void veryDeepPath() { + Config config = loader().createEmpty(); + String deepPath = "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t"; + config.set(deepPath, "deep"); + + assertEquals("deep", config.getString(deepPath)); + assertTrue(config.isSet(deepPath)); + } + + @Test + void largeList() { + Config config = loader().createEmpty(); + List largeList = new java.util.ArrayList<>(); + for (int i = 0; i < 1000; i++) { + largeList.add(i); + } + config.set("large", largeList); + + List result = config.getIntegerList("large"); + assertEquals(1000, result.size()); + assertEquals(999, (int) result.get(999)); + } + + @Test + void specialCharactersInValues() { + Config config = loader().createEmpty(); + String specialValue = "!@#$%^&*()[]{}|\\:;<>?,./"; + config.set("special", specialValue); + + assertEquals(specialValue, config.getString("special")); + } + + @Test + void getListWithTypeMismatch() { + Config config = loader().createEmpty(); + config.set("mixed", List.of("one", 2, "three", 4)); + List result = config.getList("mixed", Integer.class, new ArrayList<>()); + + // Should only get the integers + assertEquals(2, result.size()); + assertTrue(result.contains(2)); + assertTrue(result.contains(4)); + } + + @Test + void multipleNestedSections() { + Config config = loader().createEmpty(); + config.set("section1.key1", "value1"); + config.set("section2.key2", "value2"); + config.set("section1.sub.key3", "value3"); + + assertEquals(3, config.values(true).size()); + assertTrue(config.isSection("section1")); + assertTrue(config.isSection("section2")); + assertTrue(config.isSection("section1.sub")); + } + + @Test + void overwriteSectionWithValue() { + Config config = loader().createEmpty(); + config.createSection("section"); + config.set("section", "value"); + + assertEquals("value", config.getString("section")); + } + + @Test + void overwriteValueWithSection() { + Config config = loader().createEmpty(); + config.set("key", "value"); + // Setting a nested value under a key creates a section + config.set("key.nested", "nested"); + + // The section should now exist + assertTrue(config.isSection("key")); + } + + @Test + void getObjectFromSection() { + Config config = loader().createEmpty(); + config.createSection("data"); + Object result = config.getObject("data"); + + assertNull(result); + } + + @Test + void getDivisionByZeroHandling() { + Config config = loader().createEmpty(); + config.set("value", 10); + // This will throw - depends on implementation + try { + config.divide("value", 0); + } catch (ArithmeticException | NumberFormatException ignored) { + // Expected + } + } + + } + + @Nested + class Load { + + @Test + void loadNonExisting() { + Resource nonExisting = invalidResource(); + assertThrows(IOException.class, () -> load(nonExisting)); + } + + @Test + void loadEmpty(@TempDir Path temp) throws IOException { + Config config = load(emptyFile(temp)); + assertEquals(0, config.values(true).size()); + } + + @Test + void loadWithValues() throws IOException { + Resource file = exampleResource(); + assertTrue(file.exists()); + + Config config = load(file); + + // The example file has at least ... top-level keys + Map deepValues = config.values(true); + assertEquals(exampleConfigEntries(), deepValues.size()); + + // foo: bar + assertTrue(config.isSet("foo")); + assertEquals("bar", config.getString("foo")); + + // key2: 123 + assertTrue(config.isSet("key2")); + assertEquals(123, config.getInt("key2")); + + // serializable: + // ==: "dev.spoocy.utils.config.SerializableExample" + // name: "example" + // value: 42 + assertFalse(config.isSet("serializable")); // section does not count as set + assertTrue(config.isSection("serializable")); + ConfigSection serializableSection = config.getSection("serializable"); + assertEquals("example", serializableSection.getString("name")); + assertEquals(42, serializableSection.getInt("value")); + + // list: + // - item1 + // - item2 + // - item3 + assertTrue(config.isSet("list")); + assertTrue(config.isList("list")); + List list = config.getStringList("list"); + assertEquals(3, list.size()); + assertTrue(list.contains("item1")); + assertTrue(list.contains("item2")); + assertTrue(list.contains("item3")); + + // objects: + // - name: "object1" + // value: 1 + // - name: "object2" + // value: 2 + assertTrue(config.isSet("objects")); + assertTrue(config.isList("objects")); + List objects = config.getSectionList("objects"); + assertEquals(2, objects.size()); + + ConfigSection object1 = objects.get(0); + assertTrue(object1.isSet("name")); + assertEquals("object1", object1.getString("name")); + assertTrue(object1.isSet("value")); + assertEquals(1, object1.getInt("value")); + + ConfigSection object2 = objects.get(1); + assertTrue(object2.isSet("name")); + assertEquals("object2", object2.getString("name")); + assertTrue(object2.isSet("value")); + assertEquals(2, object2.getInt("value")); + + } + + } + + @Nested + class Save { + + @Test + void saveToString() { + Config config = emptyConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + config.set("nested.key", "nested_value"); + + String saved = config.saveToString(REPRESENTER); + assertNotNull(saved); + assertFalse(saved.isEmpty()); + } + + @Test + void saveToResource(@TempDir Path temp) throws IOException { + Config config = emptyConfig(); + config.set("key1", "value1"); + config.set("key2", 123); + + Path file = temp.resolve("test-save.txt"); + Resource writeableResource = Resources.fromPath(file); + + config.save((WriteableResource) writeableResource, REPRESENTER); + + assertTrue(Files.exists(file)); + String content = Files.readString(file); + assertFalse(content.isEmpty()); + } + + @Test + void saveAndLoadRoundTrip(@TempDir Path temp) throws IOException { + Config original = emptyConfig(); + original.set("string", "hello"); + original.set("number", 42); + original.set("decimal", 3.14); + original.set("boolean", true); + original.set("list", List.of(1, 2, 3)); + original.set("nested.value", "deep"); + + Path file = temp.resolve("roundtrip.txt"); + PathResource resource = Resources.fromPath(file); + + original.save(resource, REPRESENTER); + + Config loaded = load(resource, CONSTRUCTOR); + + assertEquals("hello", loaded.getString("string")); + assertEquals(42, loaded.getInt("number")); + assertTrue(loaded.getBoolean("boolean")); + assertEquals("deep", loaded.getString("nested.value")); + } + + @Test + void saveSectionValues() { + Config config = emptyConfig(); + config.createSection("section"); + config.set("section.key1", "value1"); + config.set("section.key2", "value2"); + + ConfigSection section = config.getSection("section"); + Map values = section.values(false); + + assertEquals(2, values.size()); + assertEquals("value1", values.get("key1")); + assertEquals("value2", values.get("key2")); + } + + } + + @Nested + class CustomObjects { + + @Test + void storeAndLoadCustomObject(@TempDir Path temp) throws IOException { + if (!supportsTags()) { + return; + } + + Config config = emptyConfig(); + CustomObject obj = new CustomObject("John", 30); + config.set("person", obj); + + Path file = temp.resolve("custom-object.txt"); + Resource resource = Resources.fromPath(file); + + config.save((WriteableResource) resource, REPRESENTER); + + Config loaded = load(resource, CONSTRUCTOR); + CustomObject loadedObj = loaded.get("person", CustomObject.class); + + assertNotNull(loadedObj); + assertEquals("John", loadedObj.getName()); + assertEquals(30, loadedObj.getAge()); + } + + } + + @Nested + class CustomMapSerialization { + + @Test + void loadWithSerializedMapValues() throws IOException { + Config config = load(exampleMapResource(), new CustomObjectMapConstructor()); + + for (Map.Entry e : config.values(true).entrySet()) { + System.out.println(e.getKey() + " >> " + e.getValue()); + } + + assertTrue(config.isSection("serialize:objects")); + ConfigSection main = config.getSection("serialize:objects"); + + assertTrue(main.isSection("map-objects")); + ConfigSection objects = main.getSection("map-objects"); + + assertTrue(objects.isSet("o-1")); + Object o1 = objects.getObject("o-1"); + CustomObject co1 = assertInstanceOf(CustomObject.class, o1); + assertEquals("name1", co1.getName()); + assertEquals(1, co1.getAge()); + + assertTrue(objects.isSet("o-2")); + Object o2 = objects.getObject("o-2"); + CustomObject co2 = assertInstanceOf(CustomObject.class, o2); + assertEquals("name2", co2.getName()); + assertEquals(2, co2.getAge()); + } + + @Test + void loadWithSerializedSequenceValues() throws IOException { + Config config = load(exampleMapResource(), new CustomObjectMapConstructor()); + + assertTrue(config.isSection("serialize:objects")); + ConfigSection main = config.getSection("serialize:objects"); + + assertTrue(main.isSet("sequence-objects")); + List list = main.getList("sequence-objects", CustomObject.class, List.of()); + + assertEquals(2, list.size()); + + CustomObject co1 = list.get(0); + assertEquals("name1", co1.getName()); + assertEquals(1, co1.getAge()); + + CustomObject co2 = list.get(1); + assertEquals("name2", co2.getName()); + assertEquals(2, co2.getAge()); + } + + } + + enum TestLevel { + LOW, + MEDIUM, + HIGH + } + + static class ConfigRepresenter extends SafeRepresenter { + + public static final Tag CUSTOM_OBJECT_TAG = new Tag(CustomObject.class); + + public ConfigRepresenter() { + this.represent(CustomObject.class, new CustomObjectRepresenter()); + } + + class CustomObjectRepresenter implements Represent { + + @Override + public @NotNull Node represent(@Nullable Object data) { + CustomObject obj = (CustomObject) data; + String value = obj.getName() + "-" + obj.getAge(); + return representScalar(CUSTOM_OBJECT_TAG, value); + } + } + + } + + static class ConfigConstructor extends SafeConstructor { + + public ConfigConstructor() { + this.construct(ConfigRepresenter.CUSTOM_OBJECT_TAG, new CustomObjectConstructor()); + } + + static class CustomObjectConstructor implements Construct { + + @Override + public @Nullable Object construct(@Nullable Node node) { + if (node instanceof ScalarNode) { + String value = ((ScalarNode) node).getData().toString(); + String[] parts = value.split("-"); + if (parts.length == 2) { + String name = parts[0]; + int age = Integer.parseInt(parts[1]); + return new CustomObject(name, age); + } + } + + throw new IllegalArgumentException("Invalid data for CustomObject: " + node); + } + } + + } + + static class CustomObjectMapConstructor extends SafeConstructor { + + public CustomObjectMapConstructor() { + this.construct(Tag.MAP, new OverwriteMapConstructor()); + } + + class OverwriteMapConstructor extends MapConstructor { + + @Override + public @Nullable Object construct(@Nullable Node node) { + Map map = (Map) super.construct(node); + + if (map.containsKey("==") && map.get("==").equals("CustomObject")) { + return new CustomObject( + map.get("name").toString(), + Integer.parseInt(map.get("age").toString()) + ); + } + + return map; + } + + } + + } + + + static class CustomObject { + + private final String name; + private final int age; + + public CustomObject(@NotNull String name, int age) { + this.name = name; + this.age = age; + } + + public String getName() { + return name; + } + + public int getAge() { + return age; + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof CustomObject)) return false; + CustomObject that = (CustomObject) object; + return age == that.age && Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, age); + } + } + +} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java index a582b98..98dabb3 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java @@ -1,269 +1,94 @@ package dev.spoocy.utils.config.types; import dev.spoocy.utils.config.Resources; -import dev.spoocy.utils.config.io.PathResource; import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.io.ResourceTest; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Nested; +import dev.spoocy.utils.config.loader.ConfigLoader; +import dev.spoocy.utils.config.loader.JsonConfigLoader; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; -import dev.spoocy.utils.config.ConfigSection; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author Spoocy99 | GitHub: Spoocy99 */ -public class JsonConfigTest extends ResourceTest { +public class JsonConfigTest extends ConfigTest { private static final String EXAMPLE_FILE = resourcesPath("types/example.json"); + private static final String EXAMPLE_MAP_FILE = resourcesPath("types/map-example.json"); - @BeforeAll - static void setupSerializers() { - String name = NamedSerializers.getSerializeName(SerializableExample.class); - if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { - NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); + private static Resource resolveExisting(@NotNull String file) { + Resource classpath = Resources.fromJar(file); + if (classpath.exists()) { + return classpath; } - } - - @Nested - class Creation { - @Test - void createEmpty() { - JsonConfig config = new JsonConfig(); - assertEquals(0, config.values(true).size()); - } - - @Test - void createWithValues() { - JsonConfig config = new JsonConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - assertEquals(3, config.values(true).size()); - assertEquals("value1", config.getString("key1")); - assertEquals(123, config.getInt("key2")); - assertEquals(SerializableExample.EXAMPLE, config.get("key3", SerializableExample.class)); + Path direct = Path.of(file).toAbsolutePath(); + if (Files.exists(direct)) { + return Resources.fromPath(direct); } + return Resources.fromPath(Path.of("config-utils", file).toAbsolutePath()); } - @Nested - class Load { - - @Test - void loadEmpty(@TempDir Path temporaryFolder) throws IOException { - Path filePath = temporaryFolder.resolve("empty.json"); - Files.createFile(filePath); - - Resource resource = new PathResource(filePath); - JsonConfig config = (JsonConfig) loadDefault(resource); - - assertEquals(0, config.values(true).size()); - } - - @Test - void loadWithValues() throws IOException { - Resource file = Resources.fromFile(EXAMPLE_FILE); - JsonConfig config = (JsonConfig) loadDefault(file); - - assertEquals(3, config.values(true).size()); - assertEquals("bar", config.getString("foo")); - assertEquals(123, config.getInt("key2")); - - SerializableExample expected = config.get("serializable", SerializableExample.class); - assertEquals("example", expected.getName()); - assertEquals(42, expected.getValue()); - } - - @Test - void loadObjectNodeAsSection(@TempDir Path temporaryFolder) throws IOException { - Path filePath = temporaryFolder.resolve("object-node.json"); - Files.writeString(filePath, "{\n \"database\": {\n \"host\": \"localhost\"\n }\n}"); - - JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); - - assertTrue(config.isSection("database")); - assertInstanceOf(ConfigSection.class, config.getObject("database")); - assertEquals("localhost", config.getString("database.host")); - } - - @Test - void loadArrayObjectNodesAsLinkedHashMap(@TempDir Path temporaryFolder) throws IOException { - Path filePath = temporaryFolder.resolve("array-node.json"); - Files.writeString(filePath, "{\n \"servers\": [\n { \"name\": \"alpha\" },\n { \"name\": \"beta\" }\n ]\n}"); - - JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); - - List rawList = config.getList("servers"); - assertNotNull(rawList); - assertEquals(2, rawList.size()); - assertInstanceOf(LinkedHashMap.class, rawList.get(0)); - - // Section conversion happens in getSectionList, not during JSON array parsing. - List sectionList = config.getSectionList("servers"); - assertEquals(2, sectionList.size()); - assertEquals("alpha", sectionList.get(0).getString("name")); - assertEquals("beta", sectionList.get(1).getString("name")); - } - - @Test - void loadNestedObjectInsideArrayAsSectionInSectionList(@TempDir Path temporaryFolder) throws IOException { - Path filePath = temporaryFolder.resolve("formats.json"); - Files.writeString(filePath, - "{\n" + - " \"formats\": [\n" + - " {\n" + - " \"name\": \"digital\",\n" + - " \"units\": {\n" + - " \"seconds\": {\n" + - " \"text\": \"\",\n" + - " \"display-when-zero\": true\n" + - " }\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"); - - JsonConfig config = (JsonConfig) loadDefault(new PathResource(filePath)); - - List formats = config.getSectionList("formats"); - assertEquals(1, formats.size()); - assertTrue(formats.get(0).isSection("units")); - assertEquals("", formats.get(0).getString("units.seconds.text")); - assertTrue(formats.get(0).getBoolean("units.seconds.display-when-zero")); - } - + @Override + protected ConfigLoader loader() { + return JsonConfigLoader.INSTANCE; } - @Nested - class Save { - - @Test - void saveToString() { - JsonConfig config = new JsonConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - String json = config.saveToString(); - assertNotNull(json); - assertTrue(json.contains("\"key1\": \"value1\"")); - assertTrue(json.contains("\"key2\": 123")); - assertTrue(json.contains("\"==\":")); - assertTrue(json.contains("\"name\": \"example\"")); - assertTrue(json.contains("\"value\": 42")); - } - - @Test - void write(@TempDir Path temporaryFolder) throws IOException { - JsonConfig config = new JsonConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - Path filePath = temporaryFolder.resolve("config.json"); - PathResource resource = new PathResource(filePath); - config.save(resource); - - JsonConfig loadedConfig = (JsonConfig) loadDefault(resource); - assertEquals(3, loadedConfig.values(true).size()); - assertEquals("value1", loadedConfig.getString("key1")); - assertEquals(123, loadedConfig.getInt("key2")); - - SerializableExample expected = loadedConfig.get("key3", SerializableExample.class); - assertEquals("example", expected.getName()); - assertEquals(42, expected.getValue()); - - } - + @Override + protected Resource exampleResource() { + return resolveExisting(EXAMPLE_FILE); } - @Nested - class Advanced { - - @Test - void nestedSectionsAndKeys() { - JsonConfig config = new JsonConfig(); - config.set("parent.child.key", "value"); - - assertTrue(config.isSet("parent.child.key")); - assertEquals("value", config.getString("parent.child.key")); - - ConfigSection parent = config.getSection("parent"); - assertNotNull(parent); - assertEquals("value", parent.getString("child.key")); - } - - @Test - void listsAndSectionArrays() { - JsonConfig config = new JsonConfig(); - - List list = Arrays.asList("a", "b", "c"); - config.set("strings", list); - - assertTrue(config.isList("strings")); - assertEquals(list, config.getStringList("strings")); - - Map m1 = new LinkedHashMap<>(); - m1.put("k", "v"); - Map m2 = new LinkedHashMap<>(); - m2.put("k", "w"); - - config.set("sections", Arrays.asList(m1, m2)); - List sections = config.getSectionList("sections"); - assertEquals(2, sections.size()); - assertEquals("v", sections.get(0).getString("k")); - - List> mapList = config.getMapList("sections"); - assertEquals("v", mapList.get(0).get("k")); - } + @Override + protected Resource exampleMapResource() { + return resolveExisting(EXAMPLE_MAP_FILE); + } - @Test - void serializableFromMap() { - JsonConfig config = new JsonConfig(); + @Override + protected int exampleConfigEntries() { + return 7; + } - Map serialMap = new LinkedHashMap<>(); - serialMap.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); - serialMap.put("name", "example"); - serialMap.put("value", 42); + @Override + protected boolean supportsTags() { + return false; + } - config.set("serialMap", serialMap); + @Test + void settings() { + JsonConfig config = loader().createEmpty(); + JsonSettings settings = (JsonSettings) config.settings(); - SerializableExample se = config.getSerializable("serialMap", SerializableExample.class); - assertNotNull(se); - assertEquals("example", se.getName()); - assertEquals(42, se.getValue()); - } + assertEquals('.', settings.pathSeparator()); + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } - @Test - void removeAndKeysValuesSeparator() { - JsonConfig config = new JsonConfig(); - config.set("toRemove", "x"); - assertTrue(config.isSet("toRemove")); - config.remove("toRemove"); - assertFalse(config.isSet("toRemove")); + @Test + void loadAndSaveFlatValues() throws IOException { + Path file = Files.createTempFile("json-flat", ".json"); + Files.writeString(file, "{\"foo\":\"bar\",\"key2\":123}", StandardCharsets.UTF_8); - // nested key and path separator behavior - config.set("a.b.c", "d"); - Collection keysDefault = config.keys(true); - assertTrue(keysDefault.contains("a.b.c")); + JsonConfig config = load(Resources.fromPath(file)); - config.settings().pathSeparator('/'); - Collection keysAlt = config.keys(true); - assertTrue(keysAlt.contains("a/b/c")); - } + assertEquals("bar", config.getString("foo")); + assertEquals(123, config.getInt("key2")); + String saved = config.saveToString(REPRESENTER); + assertTrue(saved.contains("foo")); + assertTrue(saved.contains("bar")); + assertTrue(saved.contains("key2")); + assertTrue(saved.contains("123")); } } diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/MemoryConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/MemoryConfigTest.java new file mode 100644 index 0000000..1354a47 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/MemoryConfigTest.java @@ -0,0 +1,40 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.io.PathResource; +import dev.spoocy.utils.config.representer.SafeRepresenter; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class MemoryConfigTest { + + @Test + void settingsAreAvailableAndMutable() { + MemoryConfig config = new MemoryConfig(); + ConfigSettings settings = config.settings(); + + assertSame(config, settings.configuration()); + assertEquals('.', settings.pathSeparator()); + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } + + @Test + void savingIsNotSupported() { + MemoryConfig config = new MemoryConfig(); + SafeRepresenter representer = new SafeRepresenter(); + + assertThrows(UnsupportedOperationException.class, () -> config.saveToString(representer)); + assertThrows(UnsupportedOperationException.class, () -> config.save(new PathResource(Path.of("memory-config-test.yml")), representer)); + } + +} + diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java deleted file mode 100644 index 3a69305..0000000 --- a/config-utils/src/test/java/dev/spoocy/utils/config/types/SerializableExample.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.spoocy.utils.config.types; - -import dev.spoocy.utils.config.serializer.ConfigSerializable; -import org.jetbrains.annotations.NotNull; - -import java.util.Map; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class SerializableExample implements ConfigSerializable { - - public static final SerializableExample EXAMPLE = new SerializableExample("example", 42); - - private final String name; - private final int value; - - public SerializableExample(@NotNull String name, int value) { - this.name = name; - this.value = value; - } - - @NotNull - public String getName() { - return this.name; - } - - public int getValue() { - return this.value; - } - - @Override - public Map serialize() { - return Map.of( - "name", this.name, - "value", this.value - ); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - SerializableExample that = (SerializableExample) obj; - return value == that.value && name.equals(that.name); - } - - public static SerializableExample deserialize(@NotNull Map map) { - String name = (String) map.get("name"); - int value = (int) map.get("value"); - return new SerializableExample(name, value); - } - -} diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java index db97858..4542b09 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java @@ -1,20 +1,16 @@ package dev.spoocy.utils.config.types; import dev.spoocy.utils.config.Resources; -import dev.spoocy.utils.config.io.PathResource; import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.loader.ConfigLoader; import dev.spoocy.utils.config.loader.YamlConfigLoader; -import dev.spoocy.utils.config.serializer.NamedSerializers; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Nested; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.*; -import dev.spoocy.utils.config.ConfigSection; import static org.junit.jupiter.api.Assertions.*; @@ -22,187 +18,112 @@ * @author Spoocy99 | GitHub: Spoocy99 */ -public class YamlConfigTest extends ResourceTest { +public class YamlConfigTest extends ConfigTest { private static final String EXAMPLE_FILE = resourcesPath("types/example.yml"); + private static final String EXAMPLE_MAP_FILE = resourcesPath("types/map-example.yml"); - @BeforeAll - static void setupSerializers() { - String name = NamedSerializers.getSerializeName(SerializableExample.class); - if (NamedSerializers.DEFAULT_INSTANCE.getSerializableClassByName(name) == null) { - NamedSerializers.DEFAULT_INSTANCE.register(SerializableExample.class); + private static Resource resolveExisting(@NotNull String file) { + Resource classpath = Resources.fromJar(file); + if (classpath.exists()) { + return classpath; } - } - - @Nested - class Creation { - @Test - void createEmpty() { - YamlConfig config = new YamlConfig(); - assertEquals(0, config.values(true).size()); - } - - @Test - void createWithValues() { - YamlConfig config = new YamlConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - assertEquals(3, config.values(true).size()); - assertEquals("value1", config.getString("key1")); - assertEquals(123, config.getInt("key2")); - assertEquals(SerializableExample.EXAMPLE, config.get("key3", SerializableExample.class)); + Path direct = Path.of(file).toAbsolutePath(); + if (Files.exists(direct)) { + return Resources.fromPath(direct); } + return Resources.fromPath(Path.of("config-utils", file).toAbsolutePath()); } - @Nested - class Load { - - @Test - void loadEmpty() throws IOException { - YamlConfig config = new YamlConfig(); - YamlConfigLoader.INSTANCE.loadFromString(config, ""); - - assertEquals(0, config.values(true).size()); - } - - @Test - void loadWithValues() throws IOException { - Resource file = Resources.fromFile(EXAMPLE_FILE); - YamlConfig config = (YamlConfig) loadDefault(file); - - assertEquals(3, config.values(true).size()); - assertEquals("bar", config.getString("foo")); - assertEquals(123, config.getInt("key2")); - - SerializableExample expected = config.get("serializable", SerializableExample.class); - assertEquals("example", expected.getName()); - assertEquals(42, expected.getValue()); - } - + @Override + protected ConfigLoader loader() { + return YamlConfigLoader.INSTANCE; } - @Nested - class Save { - - @Test - void saveToString() { - YamlConfig config = new YamlConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - String yaml = config.saveToString(); - assertNotNull(yaml); - assertTrue(yaml.contains("key1: value1")); - assertTrue(yaml.contains("key2: 123")); - assertTrue(yaml.contains("key3:")); - assertTrue(yaml.contains("name: example")); - assertTrue(yaml.contains("value: 42")); - } - - @Test - void write(@TempDir Path temporaryFolder) throws IOException { - YamlConfig config = new YamlConfig(); - config.set("key1", "value1"); - config.set("key2", 123); - config.set("key3", SerializableExample.EXAMPLE); - - Path filePath = temporaryFolder.resolve("config.yml"); - PathResource resource = new PathResource(filePath); - config.save(resource); - - YamlConfig loadedConfig = (YamlConfig) loadDefault(resource); - assertEquals(3, loadedConfig.values(true).size()); - assertEquals("value1", loadedConfig.getString("key1")); - assertEquals(123, loadedConfig.getInt("key2")); - - SerializableExample expected = loadedConfig.get("key3", SerializableExample.class); - assertEquals("example", expected.getName()); - assertEquals(42, expected.getValue()); - - } - + @Override + protected Resource exampleResource() { + return resolveExisting(EXAMPLE_FILE); } - @Nested - class Advanced { - - @Test - void nestedSectionsAndKeys() { - YamlConfig config = new YamlConfig(); - config.set("parent.child.key", "value"); - - assertTrue(config.isSet("parent.child.key")); - assertEquals("value", config.getString("parent.child.key")); - - ConfigSection parent = config.getSection("parent"); - assertNotNull(parent); - assertEquals("value", parent.getString("child.key")); - } + @Override + protected Resource exampleMapResource() { + return resolveExisting(EXAMPLE_MAP_FILE); + } - @Test - void listsAndSectionArrays() { - YamlConfig config = new YamlConfig(); + @Override + protected int exampleConfigEntries() { + return 7; + } - List list = Arrays.asList("a", "b", "c"); - config.set("strings", list); + @Override + protected boolean supportsTags() { + return false; + } - assertTrue(config.isList("strings")); - assertEquals(list, config.getStringList("strings")); + @Test + void settings() { + YamlSettings settings = loader().createEmpty().settings(); - Map m1 = new LinkedHashMap<>(); - m1.put("k", "v"); - Map m2 = new LinkedHashMap<>(); - m2.put("k", "w"); + assertEquals('.', settings.pathSeparator()); + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } - config.set("sections", Arrays.asList(m1, m2)); - List sections = config.getSectionList("sections"); - assertEquals(2, sections.size()); - assertEquals("v", sections.get(0).getString("k")); + @Test + void loadAndSaveFlatValues() throws IOException { + Path file = Files.createTempFile("yaml-flat", ".yml"); + Files.writeString(file, "foo: bar\nkey2: 123\n", StandardCharsets.UTF_8); - List> mapList = config.getMapList("sections"); - assertEquals("v", mapList.get(0).get("k")); - } + YamlConfig config = load(Resources.fromPath(file)); - @Test - void serializableFromMap() { - YamlConfig config = new YamlConfig(); + assertEquals("bar", config.getString("foo")); + assertEquals(123, config.getInt("key2")); - Map serialMap = new LinkedHashMap<>(); - serialMap.put(NamedSerializers.SERIALIZED_TYPE_KEY, NamedSerializers.getSerializeName(SerializableExample.class)); - serialMap.put("name", "example"); - serialMap.put("value", 42); + String saved = config.saveToString(REPRESENTER); + assertTrue(saved.contains("foo")); + assertTrue(saved.contains("bar")); + assertTrue(saved.contains("key2")); + assertTrue(saved.contains("123")); + } - config.set("serialMap", serialMap); + @Test + void savePlacesBlockCommentsBeforeTheKey() { + YamlConfig config = new YamlConfig(); + config.set("num", 12); + config.setComments("num", "An integer number"); - SerializableExample se = config.getSerializable("serialMap", SerializableExample.class); - assertNotNull(se); - assertEquals("example", se.getName()); - assertEquals(42, se.getValue()); - } + String saved = config.saveToString(REPRESENTER).replace("\r", ""); - @Test - void removeAndKeysValuesSeparator() { - YamlConfig config = new YamlConfig(); - config.set("toRemove", "x"); - assertTrue(config.isSet("toRemove")); - config.remove("toRemove"); - assertFalse(config.isSet("toRemove")); - - // nested key and path separator behavior - config.set("a.b.c", "d"); - Collection keysDefault = config.keys(true); - assertTrue(keysDefault.contains("a.b.c")); - - config.settings().pathSeparator('/'); - Collection keysAlt = config.keys(true); - assertTrue(keysAlt.contains("a/b/c")); - } + assertTrue(saved.contains("# An integer number\nnum: 12")); + assertFalse(saved.contains("num:\n # An integer number")); + } + @Test + void commentLayout() { + YamlConfig config = new YamlConfig(); + + config.setHeaderComments("Header comment"); + + config.set("test", "abc"); + config.set("num", 12); + config.setComments("num", "An integer number"); + config.setInlineComments("num", "This is an inline comment"); + + config.setComments("num.sub", "A sub-number"); + + String saved = config.saveToString(REPRESENTER).replace("\r", ""); + System.out.println(saved); + assertEquals( + "# Header comment" + "\n" + + "\n" + + "test: abc" + "\n" + + "\n" + + "# An integer number" + "\n" + + "num: 12 # This is an inline comment" + "\n", + saved + ); } } diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java index a0a44f2..a162dcf 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/ConfigUpdaterTestBase.java @@ -3,7 +3,7 @@ import dev.spoocy.utils.common.version.Version; import dev.spoocy.utils.config.*; import dev.spoocy.utils.config.io.Resource; -import dev.spoocy.utils.config.io.ResourceTest; +import dev.spoocy.utils.config.ResourceTest; import dev.spoocy.utils.config.types.MemoryConfig; import org.jetbrains.annotations.NotNull; diff --git a/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java index 898136a..f2c4bab 100644 --- a/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java +++ b/config-utils/src/test/java/dev/spoocy/utils/config/update/MissingFieldsMigrationTest.java @@ -5,6 +5,7 @@ import dev.spoocy.utils.config.ConfigProvider; import dev.spoocy.utils.config.ConfigSection; import dev.spoocy.utils.config.update.migrations.MissingFieldsMigration; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -49,11 +50,11 @@ void skipsIncompatibleTreeMerges() { defaults.set("database.host", "db.local"); ConfigSection target = memoryConfig(); - target.set("database", "flat-value"); + target.set("database.val", "flat-value"); assertTrue(migration(() -> defaults).apply(target)); - assertEquals("flat-value", target.getString("database")); - assertFalse(target.isSet("database.host")); + assertEquals("flat-value", target.getString("database.val")); + assertEquals("db.local", target.getString("database.host")); } @Test @@ -135,18 +136,17 @@ void rejectsNullTargetConfig() { } } - @SuppressWarnings("unchecked") private static T nullValue() { return (T) null; } private abstract static class MissingFieldsContext { - protected MissingFieldsMigration migration(Config defaults) { + protected MissingFieldsMigration migration(@NotNull Config defaults) { return new MissingFieldsMigration(defaults, VersionMatcher.ANY, DEFAULTS_VERSION_RESOLVER); } - protected MissingFieldsMigration migration(ConfigProvider provider) { + protected MissingFieldsMigration migration(@NotNull ConfigProvider provider) { return new MissingFieldsMigration(provider, VersionMatcher.ANY, DEFAULTS_VERSION_RESOLVER); } } diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml index 35e0fdb..8818a5b 100644 --- a/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/loader/annotation/serializable.yml @@ -1,5 +1,5 @@ serializable: - ==: dev.spoocy.utils.config.types.SerializableExample + ==: dev.spoocy.utils.config.SerializableExample name: example value: 42 diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json index 706964e..3fe609f 100644 --- a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json @@ -5,6 +5,21 @@ "==": "dev.spoocy.utils.config.types.SerializableExample", "name": "example", "value": 42 - } + }, + "list": [ + "item1", + "item2", + "item3" + ], + "objects": [ + { + "name": "object1", + "value": 1 + }, + { + "name": "object2", + "value": 2 + } + ] } diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml index 53e31a6..14b008f 100644 --- a/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml @@ -1,6 +1,18 @@ foo: bar key2: 123 + serializable: - ==: "dev.spoocy.utils.config.types.SerializableExample" + ==: "dev.spoocy.utils.config.SerializableExample" name: "example" - value: 42 \ No newline at end of file + value: 42 + +list: + - item1 + - item2 + - item3 + +objects: + - name: "object1" + value: 1 + - name: "object2" + value: 2 \ No newline at end of file diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.json b/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.json new file mode 100644 index 0000000..e7c110e --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.json @@ -0,0 +1,30 @@ +{ + "serialize:objects": { + + "sequence-objects": [ + { + "==": "CustomObject", + "name": "name1", + "age": 1 + }, + { + "==": "CustomObject", + "name": "name2", + "age": 2 + } + ], + + "map-objects": { + "o-1": { + "==": "CustomObject", + "name": "name1", + "age": 1 + }, + "o-2": { + "==": "CustomObject", + "name": "name2", + "age": 2 + } + } + } +} \ No newline at end of file diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.yml new file mode 100644 index 0000000..806b53b --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/map-example.yml @@ -0,0 +1,17 @@ +'serialize:objects': + sequence-objects: + - ==: CustomObject + name: "name1" + age: 1 + - ==: CustomObject + name: "name2" + age: 2 + map-objects: + o-1: + ==: CustomObject + name: "name1" + age: 1 + o-2: + ==: CustomObject + name: "name2" + age: 2 \ No newline at end of file diff --git a/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml index 14983e4..255aa39 100644 --- a/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml @@ -4,4 +4,8 @@ database: port: 3306 feature: enabled: true + map: !!map + key: value + name: default + version: 1.0.0 diff --git a/pom.xml b/pom.xml index 5dc40e9..ce818fb 100644 --- a/pom.xml +++ b/pom.xml @@ -190,7 +190,7 @@ org.jetbrains annotations - 26.0.2-1 + 26.1.0