diff --git a/bukkit-utils/pom.xml b/bukkit-utils/pom.xml index 2c244ea..579dd63 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 @@ -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/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 9c3dd24..0000000 --- a/bukkit-utils/src/main/java/dev/spoocy/utils/bukkit/serializers/InventorySerializer.java +++ /dev/null @@ -1,76 +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.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.writeInt(object.getSize()); - - for (int i = 0; i < object.getSize(); i++) { - dataOutput.writeObject(object.getItem(i)); - } - - 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 Inventory deserialize(@NotNull Map map) { - if(!map.containsKey("base64")) { - throw new IllegalArgumentException("Map does not contain key 'base64'."); - } - - try { - String base64 = (String) map.get("base64"); - byte[] data = Base64Coder.decode(base64); - ByteArrayInputStream inputStream = new ByteArrayInputStream(data); - BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream); - Inventory inventory = Bukkit.getServer().createInventory(null, dataInput.readInt()); - - for (int i = 0; i < inventory.getSize(); i++) { - inventory.setItem(i, (ItemStack) dataInput.readObject()); - } - dataInput.close(); - return inventory; - } catch (Throwable 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/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/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 cfd0774..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 @@ -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,186 @@ 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))); + } + + /** + * 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 ""; + } + 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..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,6 +1,7 @@ package dev.spoocy.utils.common.scheduler; -import dev.spoocy.utils.common.scheduler.task.ScheduledTask; +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; @@ -11,10 +12,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(@NotNull ExecutorService executorService, @NotNull ScheduledExecutorService scheduledExecutorService) { + this.executorService = Args.notNull(executorService, "executorService"); + this.scheduledExecutorService = Args.notNull(scheduledExecutorService, "scheduledExecutorService"); + } @Override public void executeSync(@NotNull Runnable runnable) { @@ -23,7 +34,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 +93,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 +105,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 +134,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/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/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..b9592cf 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,15 @@ org.json json + true + compile + + + + org.yaml + snakeyaml + true + compile diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java new file mode 100644 index 0000000..25fc1b0 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/AbstractConfig.java @@ -0,0 +1,124 @@ +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.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.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class AbstractConfig extends MemorySection implements Config { + + protected List header = Collections.emptyList(); + protected List footer = Collections.emptyList(); + + public AbstractConfig() { + super(); + } + + @Override + 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()); +// } + + // 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()); + + // 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 @NotNull Document withRelation(@NotNull Resource resource) { + return new FileDocument(this, resource); + } + + @Override + public @NotNull List getFooterComments() { + return this.footer; + } + + @Override + public @NotNull List getHeaderComments() { + return this.header; + } + + @Override + public void setHeaderComments(@Nullable List comments) { + this.header = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + } + + @Override + public void setFooterComments(@Nullable List comments) { + this.footer = comments == null ? Collections.emptyList() : Collections.unmodifiableList(comments); + } + + @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); + } + + 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 new file mode 100644 index 0000000..b1840df --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/BaseResourceResolver.java @@ -0,0 +1,101 @@ +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.Resource; +import dev.spoocy.utils.config.loader.ConfigLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class BaseResourceResolver implements ResourceResolver { + + 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); + } + } + + @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)) { + 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 null; + } + + @Nullable + 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); + } + + @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; + } +} 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..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 @@ -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,20 @@ 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."); - } + @NotNull + 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, ""); - } + @NotNull + List getFooterComments(); /** * Gets the comments of the specified path. @@ -64,9 +34,8 @@ 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); - } + @NotNull + List getComments(@NotNull final String path); /** * Gets the inline comments of the specified path. @@ -75,18 +44,22 @@ 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); - } + @NotNull + 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 +67,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 +75,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 +102,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..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 @@ -1,293 +1,56 @@ 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 configuration data into a string format using the provided representer. * - * @param configClass the class of the Config to create an instance of + * @param representer the representer used to serialize the configuration data * - * @return an empty config + * @return the serialized string representation of the configuration data */ - 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 + 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 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 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 data will be saved + * @param representer the representer used for serializing the configuration data * - * @return an instance of the given file type - * - * @see #readPath(Class, Path) - */ - static Config readPath(@NotNull Class configClass, @NotNull String path) { - return readPath(configClass, Paths.get(path)); - } - - /** - * 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 - * - * @return an instance of the given file type - * - * @see #readFile(Class, File) - */ - static Config readPath(@NotNull Class configClass, @NotNull Path path) { - return readFile(configClass, path.toFile()); - } - - /** - * Reads the file and creates an instance of the given file type. - * - * @param configClass the class of the Config to create an instance of - * @param file the file to read - * - * @return an instance of the given file type - * - * @throws UnsupportedOperationException if the config cannot read files - */ - 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); - } - } - - /** - * 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 - * - * @return an instance of the given file type - * - * @throws UnsupportedOperationException if the config cannot read the InputStreams - */ - 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); - } - } - - /** - * 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 + * @throws IOException if an I/O error occurs during the save operation */ - default void save(@NotNull String path) throws IOException { - File file = Paths.get(path).toFile(); - save(file); - } + void save(@NotNull WriteableResource file, @NotNull Representer representer) throws IOException; /** - * Saves the config to the given path. + * Creates a new {@link Document} instance with the specified relation. * - * @param path the path to save the document to + * @param resource the resource to the related config file * - * @return true if the document was saved successfully + * @return a new config instance with the specified relation */ - 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; - } - } + @NotNull + Document withRelation(@NotNull Resource resource); - /** - * 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..419bf12 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ConfigSection.java @@ -0,0 +1,124 @@ +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 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. + * 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); + + /** + * 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. + * + * @throws IllegalArgumentException if there is a value at the specified path that is not a section. + */ + @NotNull + ConfigSection getSection(@NotNull String 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 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); + + /** + * 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. + */ + @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. + */ + @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..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 @@ -1,19 +1,10 @@ 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 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 +12,33 @@ 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(); + Resource getRelation(); /** - * Gets the path of the file this document is associated with. + * 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. * - * @return the path of the file + * @return a new {@link Config} instance without an associated relation */ @NotNull - Path getPath(); - - /** - * Rereads the file and loads the configuration. - */ - void reload(); - - /** - * Saves the file to the given location. - * - * @throws IOException if an error occurs while saving the file - */ - void save() throws IOException; + Config withoutRelation(); /** - * Saves the file to the watched location. + * 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. * - * @return {@code true} if the save operation was successful, otherwise {@code false} - */ - boolean saveSafely(); - - /** - * Saves the file to the given location asynchronously. + * @param representer the representer used to serialize the document data * - * @return a task for handling the result of the save operation + * @throws IOException if an I/O error occurs during 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(); - } + void save(@NotNull Representer representer) throws IOException; } diff --git a/config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java b/config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java new file mode 100644 index 0000000..141c43b --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/FileDocument.java @@ -0,0 +1,395 @@ +package dev.spoocy.utils.config; + +import dev.spoocy.utils.common.version.Version; +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 @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, 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 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 @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); + } + + @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 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 @NotNull List getHeaderComments() { + return this.config.getHeaderComments(); + } + + @Override + public @NotNull List getFooterComments() { + return this.config.getFooterComments(); + } + + @Override + public @NotNull List getComments(@NotNull String path) { + return this.config.getComments(path); + } + + @Override + public @NotNull 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/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/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 5e5a500..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 @@ -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,408 @@ * @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); + + /** + * 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 exists. + * + * @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 + default List getList(@NotNull String path) { + return getList(path, null); + } + + /** + * 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..cdd11fd --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/ResourceResolver.java @@ -0,0 +1,80 @@ +package dev.spoocy.utils.config; + +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 org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface ResourceResolver { + + BaseResourceResolver DEFAULT = new BaseResourceResolver(Resources.class.getClassLoader(), + YamlConfigLoader.INSTANCE, + JsonConfigLoader.INSTANCE + ); + + static BaseResourceResolver defaultResolver() { + return DEFAULT; + } + + /** + * 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}. + * + * @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); + + /** + * 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 + 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 + 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 new file mode 100644 index 0000000..1ac35c2 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/Resources.java @@ -0,0 +1,131 @@ +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 org.jetbrains.annotations.Nullable; + +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, @Nullable Class clazz) { + Args.notNull(path, "path"); + return new ClassPathResource(path, clazz); + } + + @NotNull + public static ClassPathResource fromJar(@NotNull String path, @Nullable 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/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/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 4c8c95e..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 @@ -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; @@ -10,10 +9,6 @@ public interface Writeable extends Readable, Commentable { - void setReadOnly(); - - boolean isReadonly(); - void set(@NotNull String path, @Nullable Object value); void remove(@NotNull String path); @@ -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/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/bean/ConfigIgnore.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigIgnore.java new file mode 100644 index 0000000..77f9aee --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigIgnore.java @@ -0,0 +1,17 @@ +package dev.spoocy.utils.config.bean; + +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/bean/ConfigProperty.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigProperty.java new file mode 100644 index 0000000..75b66cf --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigProperty.java @@ -0,0 +1,43 @@ +package dev.spoocy.utils.config.bean; + +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/bean/ConfigSource.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigSource.java new file mode 100644 index 0000000..1b26336 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/ConfigSource.java @@ -0,0 +1,55 @@ +package dev.spoocy.utils.config.bean; + +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 when + * using {@link LoadStrategy#SAVE_DEFAULTS} or {@link LoadStrategy#SAVE_DEFAULTS_AND_RESOURCE}. + */ + 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 {}; + +} + 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/bean/PostLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoad.java new file mode 100644 index 0000000..de68a2e --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoad.java @@ -0,0 +1,39 @@ +package dev.spoocy.utils.config.bean; + +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/bean/PostLoadResult.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoadResult.java new file mode 100644 index 0000000..b4ddc9b --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PostLoadResult.java @@ -0,0 +1,23 @@ +package dev.spoocy.utils.config.bean; + +/** + * 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/bean/PreLoad.java b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PreLoad.java new file mode 100644 index 0000000..cea4d77 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/bean/PreLoad.java @@ -0,0 +1,29 @@ +package dev.spoocy.utils.config.bean; + +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/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/AbstractConfig.java b/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java deleted file mode 100644 index 2c255fc..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/components/AbstractConfig.java +++ /dev/null @@ -1,459 +0,0 @@ -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.config.Config; -import dev.spoocy.utils.config.serializer.ConfigSerializer; -import dev.spoocy.utils.config.serializer.Serializer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.time.OffsetDateTime; -import java.util.*; -import java.util.function.Function; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public abstract class AbstractConfig implements Config { - - private boolean readonly = false; - private final Config parent; - - 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); - } - - @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); - } - - @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); - } - - @Override - public OffsetDateTime getOffsetDateTime(@NotNull String path, @Nullable OffsetDateTime defaultValue) { - try { - return OffsetDateTime.parse(getString(path)); - } catch (Exception e) { - return defaultValue; - } - } - - @Override - public Version getVersion(@NotNull String path) { - return getVersion(path, null); - } - - @Override - public Version getVersion(@NotNull String path, @Nullable Version defaultValue) { - try { - return Version.parse(getString(path)); - } catch (Exception e) { - 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 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)); - } - } - - return list; - } - - @Override - public Map valuesAsString() { - Map values = new HashMap<>(); - this.values().forEach((key, value) -> values.put(key, String.valueOf(value))); - return values; - } - - 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/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/constructor/BaseConstructor.java b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java new file mode 100644 index 0000000..b8ccd07 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/BaseConstructor.java @@ -0,0 +1,130 @@ +package dev.spoocy.utils.config.constructor; + +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.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class BaseConstructor implements Constructor { + + + /** + * 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 constructNull(@NotNull Construct constructor) { + this.nullConstructor = constructor; + } + + protected void construct(@NotNull Tag tag, @NotNull Construct constructor) { + this.constructors.put(tag, constructor); + } + + @Nullable + protected Construct getConstruct(@NotNull Tag tag) { + return this.constructors.get(tag); + } + + @Override + public void constructMappings(@NotNull AbstractConfig config, @NotNull Map map, @NotNull NodeConstructor nodeConstructor) { + applyTree(config, constructTree(map, nodeConstructor)); + } + + protected void applyTree(@NotNull MemorySection section, @NotNull NodeTree tree) { + // first level will always be treated as base + for (NodeTuple tuple : tree) { + + String key = constructObject(tuple.getKeyNode()).toString(); + Object value = constructObject(tuple.getValueNode()); + + if (value instanceof Map) { + // map should be converted to section for easier access + section.createSection(key, (Map) value); + continue; + } + + section.set(key, value); + } + } + + @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(); + + if (tag == Tag.NULL) { + return this.nullConstructor.construct(node); + } + + Construct constructor = getConstruct(tag); + if (constructor != null) { + return constructor.construct(node); + } + + 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 new file mode 100644 index 0000000..a65cab8 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Construct.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 + */ + +@FunctionalInterface +public interface Construct { + + @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 new file mode 100644 index 0000000..34a60b9 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/constructor/Constructor.java @@ -0,0 +1,28 @@ +package dev.spoocy.utils.config.constructor; + +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; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface Constructor { + + @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/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..0f82bc8 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/ClassPathResource.java @@ -0,0 +1,188 @@ +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(); + } + + @Override + public String toString() { + return "Class Path resource [" + this.absolutePath + "]"; + } + + 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..790d6de --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/FileSystemResource.java @@ -0,0 +1,174 @@ +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(); + } + + @Override + public String toString() { + return "File System Resource [" + this.path + "]"; + } +} 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..a1f392b --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/io/UrlResource.java @@ -0,0 +1,192 @@ +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(); + } + + @Override + public String toString() { + return "Url resource [" + this.url + "]"; + } +} + 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/ConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java new file mode 100644 index 0000000..0ca3b79 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/ConfigLoader.java @@ -0,0 +1,72 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.Config; +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/JsonConfigLoader.java b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java new file mode 100644 index 0000000..eadeefd --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/JsonConfigLoader.java @@ -0,0 +1,73 @@ +package dev.spoocy.utils.config.loader; + +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.JsonConfig; +import dev.spoocy.utils.config.types.JsonSettings; +import org.jetbrains.annotations.NotNull; + +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(); + + protected final NodeConstructor nodeConstructor = new DefaultNodeConstructor(o -> null); + + private JsonConfigLoader() { } + + @Override + public String[] getSupportedExtensions() { + return new String[]{"json"}; + } + + @Override + public JsonConfig createEmpty(@NotNull Consumer settingsEditor) { + checkDependency(); + return new JsonConfig(settingsEditor); + } + + @Override + public JsonConfig load( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull Consumer settingsEditor + ) throws IOException { + checkDependency(); + + JsonConfig config = new JsonConfig(); + + 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; + } + + Map map = new LinkedHashMap<>((JsonProcessor.toJsonMap(data))); + constructor.constructMappings(config, map, nodeConstructor); + + 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."); + } + } +} 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 new file mode 100644 index 0000000..b9f6634 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlConfigLoader.java @@ -0,0 +1,54 @@ +package dev.spoocy.utils.config.loader; + +import dev.spoocy.utils.config.constructor.Constructor; +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 java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ +public class YamlConfigLoader implements ConfigLoader { + + public static final YamlConfigLoader INSTANCE = new YamlConfigLoader(); + + private YamlConfigLoader() { } + + @Override + public String[] getSupportedExtensions() { + return new String[]{"yml", "yaml"}; + } + + @Override + public YamlConfig createEmpty(@NotNull Consumer settingsEditor) { + checkDependency(); + return new YamlConfig(settingsEditor); + } + + @Override + public YamlConfig load( + @NotNull Resource resource, + @NotNull Constructor constructor, + @NotNull Consumer settingsEditor + ) throws IOException{ + checkDependency(); + + YamlConfig config = createEmpty(settingsEditor); + String contents = resource.getContentAsString(StandardCharsets.UTF_8); + new YamlProcessor(config.settings()).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."); + } + } +} 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..08451ce --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/loader/YamlProcessor.java @@ -0,0 +1,271 @@ +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.*; +import org.yaml.snakeyaml.reader.UnicodeReader; +import org.yaml.snakeyaml.representer.Representer; + +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; + 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); + } + + public YamlProcessor(@NotNull YamlSettings settings) { + this(); + applyOptions(settings); + } + + 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 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/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/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 new file mode 100644 index 0000000..bcc0d99 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/BaseRepresenter.java @@ -0,0 +1,216 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.common.tuple.Pair; +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.*; + +import static dev.spoocy.utils.config.nodes.Node.NULL_TYPE; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public abstract class BaseRepresenter implements Representer { + + /** + * 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 setStrict(boolean strict) { + this.strict = strict; + } + + protected void representNull(@NotNull Represent represent) { + this.nullRepresenter = represent; + } + + 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 (Map.Entry, Represent> entry : parentRepresenters.entrySet()) { + if (entry.getKey().isAssignableFrom(type)) { + return entry.getValue(); + } + } + + return null; + } + + 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; + } + + @Override + 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 nullRepresenter.represent(null); + } + + Class type = data.getClass(); + Represent represent = getRepresent(type); + + if (represent != null) { + return represent.represent(data); + } + + 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 new file mode 100644 index 0000000..21e5594 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Represent.java @@ -0,0 +1,18 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.nodes.Node; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +@FunctionalInterface +public interface Represent { + + @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 new file mode 100644 index 0000000..4408e64 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/representer/Representer.java @@ -0,0 +1,19 @@ +package dev.spoocy.utils.config.representer; + +import dev.spoocy.utils.config.MemorySection; +import dev.spoocy.utils.config.nodes.NodeTree; +import org.jetbrains.annotations.NotNull; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public interface Representer { + + /** + * + */ + @NotNull + 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/serializer/ConfigSerializable.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java deleted file mode 100644 index 5069ace..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializable.java +++ /dev/null @@ -1,18 +0,0 @@ -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 - * new instance of the class from a Map. - * - * @author Spoocy99 | GitHub: Spoocy99 - */ -public interface ConfigSerializable { - - Map serialize(); - -} 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/ConfigSerializer.java deleted file mode 100644 index 6e8540d..0000000 --- a/config-utils/src/main/java/dev/spoocy/utils/config/serializer/ConfigSerializer.java +++ /dev/null @@ -1,240 +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.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; - -/** - * @author Spoocy99 | GitHub: Spoocy99 - */ - -public class ConfigSerializer { - - 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)); - } - - /** - * Singleton instance of ClassSerializer for handling a specific class type. - * - * @param serializer the ClassSerializer instance - */ - public static void registerSerializer(@NotNull Class clazz, @NotNull Serializer serializer) { - DIRECT_SERIALIZERS.put(clazz, serializer); - } - - /** - * 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)); - } - - /** - * {@link ConfigSerializable} Class to be registered for serialization/deserialization. - * - * @param clazz the class to register - */ - public static 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()); - } - 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 static Class getSerializableClassByName(@NotNull String name) { - return 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); - - // direct serializer - if(serializer != null) { - return (Serializer) serializer; - - // implements ConfigSerializable - } else if(ConfigSerializable.class.isAssignableFrom(clazz)) { - Class csClass = (Class) clazz; - 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) { - 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 (ConfigSerializable) 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}. - */ - 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)); - } - - @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; - - 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 = 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; - } - } - -} 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/SerializableAs.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/SerializableAs.java deleted file mode 100644 index 8a2b6c6..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 - public 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/impl/AtomicsSerializer.java b/config-utils/src/main/java/dev/spoocy/utils/config/serializer/impl/AtomicsSerializer.java deleted file mode 100644 index f969be1..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.ConfigSerializer; -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(ConfigSerializer.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 0f32707..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.ConfigSerializer; -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( - ConfigSerializer.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/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 deleted file mode 100644 index e0d3423..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.ConfigSerializer; -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( - ConfigSerializer.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 new file mode 100644 index 0000000..1591697 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/ConfigSettings.java @@ -0,0 +1,41 @@ +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 org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +public class ConfigSettings { + + @NotNull + protected final Config config; + + protected char pathSeparator = '.'; + + public ConfigSettings(@NotNull Config config) { + this.config = config; + } + + @NotNull + public Config configuration() { + return this.config; + } + + public char pathSeparator() { + return this.pathSeparator; + } + + @NotNull + public ConfigSettings pathSeparator(char value) { + this.pathSeparator = value; + 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..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,18 +1,13 @@ 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.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.JSONArray; import org.json.JSONObject; -import java.io.*; -import java.util.*; -import java.util.stream.Collectors; +import java.util.function.Consumer; /** * @author Spoocy99 | GitHub: Spoocy99 @@ -20,292 +15,27 @@ 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(); - } - } - - @Override - public int getInt(@NotNull String path, int defaultValue) { - try { - return json.getInt(path); - } catch (Exception e) { - return defaultValue; - } - } - - @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(); - } + this.settings = new JsonSettings(this); + settingsEditor.accept(this.settings); } @Override - public boolean getBoolean(@NotNull String path, boolean defaultValue) { - try { - return this.json.getBoolean(path); - } catch (Exception e) { - return defaultValue; - } + public @NotNull ConfigSettings settings() { + return this.settings; } @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(); - } + public @NotNull String saveToString(@NotNull Representer representer) { + JSONObject json = new JSONObject(this.representAsMap(representer)); + return json.toString(2); } - @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(); - } } 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..4be9057 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/JsonSettings.java @@ -0,0 +1,21 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Config; +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 pathSeparator(char value) { + super.pathSeparator(value); + 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..f46baa8 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/MemoryConfig.java @@ -0,0 +1,44 @@ +package dev.spoocy.utils.config.types; + +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; + +/** + * @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"); + } + + @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 new file mode 100644 index 0000000..d23b6d6 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlConfig.java @@ -0,0 +1,134 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.AbstractConfig; +import dev.spoocy.utils.config.loader.YamlProcessor; +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; +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); + + NodeTree tree = representer.createTree(this); + MappingNode node = toYamlTree(tree, processor); + + 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()) { + writer.write(""); + } else { + + if (node.getValue().isEmpty()) { + node.setFlowStyle(DumperOptions.FlowStyle.FLOW); + } + + processor.serialize(node, writer); + } + return writer.toString(); + } + + 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 (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 yamlKey = toYamlNode(keyNode, processor); + Node yamlValue = toYamlNode(valueNode, processor); + + yamlKey.setBlockComments(getCommentLines(valueNode.getComments(), CommentType.BLOCK, true)); + + if (yamlValue instanceof MappingNode || yamlValue instanceof SequenceNode) { + yamlKey.setInLineComments(getCommentLines(valueNode.getInlineComments(), CommentType.IN_LINE, false)); + } else { + yamlValue.setInLineComments(getCommentLines(valueNode.getInlineComments(), CommentType.IN_LINE, false)); + } + + nodeTuples.add(new NodeTuple(yamlKey, yamlValue)); + } + + return new MappingNode(Tag.MAP, nodeTuples, DumperOptions.FlowStyle.BLOCK); + } + + @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)); + continue; + } + + String line = comment; + 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); + + 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..07d1621 --- /dev/null +++ b/config-utils/src/main/java/dev/spoocy/utils/config/types/YamlSettings.java @@ -0,0 +1,83 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Config; +import dev.spoocy.utils.config.loader.YamlProcessor; +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 pathSeparator(char value) { + super.pathSeparator(value); + 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..ffbf921 100644 --- a/config-utils/src/main/java/module-info.java +++ b/config-utils/src/main/java/module-info.java @@ -3,15 +3,22 @@ */ 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.bean; + 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; + exports dev.spoocy.utils.config.nodes; } \ No newline at end of file diff --git a/config-utils/src/test/java/AtomicsSerializerTest.java b/config-utils/src/test/java/AtomicsSerializerTest.java deleted file mode 100644 index 15ceab2..0000000 --- a/config-utils/src/test/java/AtomicsSerializerTest.java +++ /dev/null @@ -1,114 +0,0 @@ -import dev.spoocy.utils.config.serializer.ConfigSerializer; -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(ConfigSerializer.SERIALIZED_TYPE_KEY)); - } - - @Test - 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()); - - 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(ConfigSerializer.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(ConfigSerializer.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(ConfigSerializer.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(ConfigSerializer.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(ConfigSerializer.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(ConfigSerializer.SERIALIZED_TYPE_KEY, String.class.getName()); - - assertThrows(IllegalArgumentException.class, () -> serializer.deserialize(map)); - } - -} 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/JavaSerializerTest.java b/config-utils/src/test/java/JavaSerializerTest.java deleted file mode 100644 index af62ee6..0000000 --- a/config-utils/src/test/java/JavaSerializerTest.java +++ /dev/null @@ -1,87 +0,0 @@ -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/SerializerTest.java b/config-utils/src/test/java/SerializerTest.java deleted file mode 100644 index 45566be..0000000 --- a/config-utils/src/test/java/SerializerTest.java +++ /dev/null @@ -1,128 +0,0 @@ -import dev.spoocy.utils.config.serializer.ConfigSerializable; -import dev.spoocy.utils.config.serializer.ConfigSerializer; -import dev.spoocy.utils.config.serializer.Serializer; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import java.util.HashMap; -import java.util.Map; - -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 { - - // 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; - } - } - - @BeforeAll - static void registerSerializableClass() { - // Ensure Person class is registered before tests - ConfigSerializer.register(Person.class); - } - - @Test - void registerAndDeserializeUsingConfigSerializer() { - // obtain serializer via resolve and serialize an instance - Serializer serializer = ConfigSerializer.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)); - - // use ConfigSerializer.deserialize to reconstruct the object - ConfigSerializable cs = ConfigSerializer.deserialize(serialized); - assertNotNull(cs); - assertTrue(cs instanceof Person); - - Person deserialized = (Person) cs; - assertEquals(original, deserialized); - } - - @Test - void duplicateRegisterThrows() { - assertThrows(IllegalArgumentException.class, () -> ConfigSerializer.register(Person.class)); - } - - @Test - void deserializeMissingOrUnknownTypeReturnsNull() { - // missing serialized type key - Map missing = new HashMap<>(); - missing.put("foo", "bar"); - assertNull(ConfigSerializer.deserialize(missing)); - - // unknown type name - Map unknown = new HashMap<>(); - unknown.put(ConfigSerializer.SERIALIZED_TYPE_KEY, "non.existent.ClassName"); - assertNull(ConfigSerializer.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)); - } - - @Test - void resolveReturnsClassSerializerForConfigSerializable() { - // ensure resolver returns a serializer for classes implementing ConfigSerializable - Serializer serializer = ConfigSerializer.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("Bob", map.get("name")); - assertEquals(25, ((Number) map.get("age")).intValue()); - } -} 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..d02d076 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/BaseResourceResolverTest.java @@ -0,0 +1,134 @@ +package dev.spoocy.utils.config; + +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.config.types.YamlSettings; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.function.Consumer; + +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 ResourceTest { + + private static final ClassLoader CLASS_LOADER = BaseResourceResolverTest.class.getClassLoader(); + + 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 Config { + + @Test + 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 resolveClassPathResource() { + var resource = createResolver().resolve("classpath:dev/spoocy/utils/config/types/example.yml"); + + assertInstanceOf(ClassPathResource.class, resource); + } + + @Test + void resolveFileSystemResource() { + var resource = createResolver().resolve("file:" + resourcesPath("types/example.yml")); + + assertInstanceOf(FileSystemResource.class, resource); + } + + @Test + void resolvePathResource() { + var resource = createResolver().resolve(resourcesPath("types/example.yml")); + + assertInstanceOf(PathResource.class, resource); + } + + } + + @Nested + class ConfigLoader { + + @Test + void resolveJsonLoader() { + var loader = createResolver().resolveLoader(Resources.fromPath(resourcesPath("types/example.json"))); + + assertSame(JsonConfigLoader.INSTANCE, loader); + } + + @Test + void resolveYamlLoader() { + var loader = createResolver().resolveLoader(Resources.fromPath(resourcesPath("types/example.yml"))); + + assertSame(YamlConfigLoader.INSTANCE, loader); + } + + @Test + void throwsWhenRequireLoader() { + var resolver = createResolver(); + var resource = Resources.fromPath(resourcesPath("io/example.properties")); + + assertThrows(IllegalArgumentException.class, () -> resolver.requireLoader(resource)); + } + + @Test + 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/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 new file mode 100644 index 0000000..6f53d34 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/ClassPathResourceTest.java @@ -0,0 +1,112 @@ +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 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..5e5daf0 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/FileSystemResourceTest.java @@ -0,0 +1,166 @@ +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; + +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..5c2d744 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/io/PathResourceTest.java @@ -0,0 +1,208 @@ +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; +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/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/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 new file mode 100644 index 0000000..98dabb3 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/JsonConfigTest.java @@ -0,0 +1,94 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.io.Resource; +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 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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +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"); + + private static Resource resolveExisting(@NotNull String file) { + Resource classpath = Resources.fromJar(file); + if (classpath.exists()) { + return classpath; + } + + Path direct = Path.of(file).toAbsolutePath(); + if (Files.exists(direct)) { + return Resources.fromPath(direct); + } + + return Resources.fromPath(Path.of("config-utils", file).toAbsolutePath()); + } + + @Override + protected ConfigLoader loader() { + return JsonConfigLoader.INSTANCE; + } + + @Override + protected Resource exampleResource() { + return resolveExisting(EXAMPLE_FILE); + } + + @Override + protected Resource exampleMapResource() { + return resolveExisting(EXAMPLE_MAP_FILE); + } + + @Override + protected int exampleConfigEntries() { + return 7; + } + + @Override + protected boolean supportsTags() { + return false; + } + + @Test + void settings() { + JsonConfig config = loader().createEmpty(); + JsonSettings settings = (JsonSettings) config.settings(); + + assertEquals('.', settings.pathSeparator()); + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } + + @Test + void loadAndSaveFlatValues() throws IOException { + Path file = Files.createTempFile("json-flat", ".json"); + Files.writeString(file, "{\"foo\":\"bar\",\"key2\":123}", StandardCharsets.UTF_8); + + JsonConfig config = load(Resources.fromPath(file)); + + 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/YamlConfigTest.java b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java new file mode 100644 index 0000000..4542b09 --- /dev/null +++ b/config-utils/src/test/java/dev/spoocy/utils/config/types/YamlConfigTest.java @@ -0,0 +1,129 @@ +package dev.spoocy.utils.config.types; + +import dev.spoocy.utils.config.Resources; +import dev.spoocy.utils.config.io.Resource; +import dev.spoocy.utils.config.loader.ConfigLoader; +import dev.spoocy.utils.config.loader.YamlConfigLoader; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +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.*; + +/** + * @author Spoocy99 | GitHub: Spoocy99 + */ + +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"); + + private static Resource resolveExisting(@NotNull String file) { + Resource classpath = Resources.fromJar(file); + if (classpath.exists()) { + return classpath; + } + + Path direct = Path.of(file).toAbsolutePath(); + if (Files.exists(direct)) { + return Resources.fromPath(direct); + } + + return Resources.fromPath(Path.of("config-utils", file).toAbsolutePath()); + } + + @Override + protected ConfigLoader loader() { + return YamlConfigLoader.INSTANCE; + } + + @Override + protected Resource exampleResource() { + return resolveExisting(EXAMPLE_FILE); + } + + @Override + protected Resource exampleMapResource() { + return resolveExisting(EXAMPLE_MAP_FILE); + } + + @Override + protected int exampleConfigEntries() { + return 7; + } + + @Override + protected boolean supportsTags() { + return false; + } + + @Test + void settings() { + YamlSettings settings = loader().createEmpty().settings(); + + assertEquals('.', settings.pathSeparator()); + assertSame(settings, settings.pathSeparator('/')); + assertEquals('/', settings.pathSeparator()); + } + + @Test + void loadAndSaveFlatValues() throws IOException { + Path file = Files.createTempFile("yaml-flat", ".yml"); + Files.writeString(file, "foo: bar\nkey2: 123\n", StandardCharsets.UTF_8); + + YamlConfig config = load(Resources.fromPath(file)); + + 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")); + } + + @Test + void savePlacesBlockCommentsBeforeTheKey() { + YamlConfig config = new YamlConfig(); + config.set("num", 12); + config.setComments("num", "An integer number"); + + String saved = config.saveToString(REPRESENTER).replace("\r", ""); + + 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/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..a162dcf --- /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.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..f2c4bab --- /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.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 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.val", "flat-value"); + + assertTrue(migration(() -> defaults).apply(target)); + assertEquals("flat-value", target.getString("database.val")); + assertEquals("db.local", target.getString("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())); + } + } + + private static T nullValue() { + return (T) null; + } + + private abstract static class MissingFieldsContext { + + protected MissingFieldsMigration migration(@NotNull Config defaults) { + return new MissingFieldsMigration(defaults, VersionMatcher.ANY, DEFAULTS_VERSION_RESOLVER); + } + + protected MissingFieldsMigration migration(@NotNull 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..8818a5b --- /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.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..3fe609f --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.json @@ -0,0 +1,25 @@ +{ + "foo": "bar", + "key2": 123, + "serializable": { + "==": "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 new file mode 100644 index 0000000..14b008f --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/types/example.yml @@ -0,0 +1,18 @@ +foo: bar +key2: 123 + +serializable: + ==: "dev.spoocy.utils.config.SerializableExample" + name: "example" + 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-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..255aa39 --- /dev/null +++ b/config-utils/src/test/resources/dev/spoocy/utils/config/update/defaults.yml @@ -0,0 +1,11 @@ +config-version: 2.0.0 +database: + host: defaults.local + port: 3306 +feature: + enabled: true + map: !!map + key: value + name: default + version: 1.0.0 + diff --git a/pom.xml b/pom.xml index fabb7fa..ce818fb 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 @@ -191,7 +190,7 @@ org.jetbrains annotations - 26.0.2-1 + 26.1.0 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;