diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationBinder.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationBinder.java new file mode 100644 index 00000000..682ebedc --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationBinder.java @@ -0,0 +1,309 @@ +package com.bencodez.simpleapi.file.annotation; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import java.util.Objects; +import java.util.function.Function; + +import com.bencodez.simpleapi.file.config.ConfigView; + +import com.bencodez.simpleapi.time.ParsedDuration; + +/** + * Platform-neutral implementation of the existing configuration annotations. + * + *

This is a compatibility extraction, not a change to the annotation rules. + * Field fallback values, empty-list handling, declared-field-only traversal, + * annotation order and per-field exception isolation are intentionally retained. + * In particular, the legacy zero-default long reflection behavior is unchanged.

+ */ +public class AnnotationBinder { + + private final Function sectionValue; + + /** Creates a binder that assigns ConfigView values to section fields. */ + public AnnotationBinder() { + this(view -> view); + } + + /** + * Creates a binder with a platform-specific section projection. Only the + * compatibility adapter should unwrap a view to a native section. The + * projection is invoked for present sections, never for an absent section. + * + * @param sectionValue projection used for ConfigDataConfigurationSection + */ + public AnnotationBinder(Function sectionValue) { + this.sectionValue = Objects.requireNonNull(sectionValue, "sectionValue"); + } + + @SuppressWarnings("unchecked") + public void load(ConfigView config, Object classToLoad) { + Class clazz = classToLoad.getClass(); + + for (Field field : clazz.getDeclaredFields()) { + try { + field.setAccessible(true); + + ConfigDataString stringAnnotation = field.getAnnotation(ConfigDataString.class); + if (stringAnnotation != null) { + + String defaultValue = stringAnnotation.defaultValue(); + if (defaultValue.isEmpty()) { + try { + String v = (String) field.get(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + } + String value = ""; + if (!stringAnnotation.secondPath().isEmpty()) { + value = config.getString(stringAnnotation.path(), + config.getString(stringAnnotation.secondPath(), defaultValue)); + } else { + value = config.getString(stringAnnotation.path(), defaultValue); + } + + field.set(classToLoad, value); + + } + + ConfigDataBoolean booleanAnnotation = field.getAnnotation(ConfigDataBoolean.class); + if (booleanAnnotation != null) { + boolean defaultValue = booleanAnnotation.defaultValue(); + if (!defaultValue) { + try { + boolean v = field.getBoolean(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + + } + + boolean value = defaultValue; + if (config.contains(booleanAnnotation.path())) { + value = config.getBoolean(booleanAnnotation.path(), defaultValue); + } else if (!booleanAnnotation.secondPath().isEmpty() + && config.contains(booleanAnnotation.secondPath())) { + value = config.getBoolean(booleanAnnotation.secondPath(), defaultValue); + + if (booleanAnnotation.secondPathInvert()) { + value = !value; + } + } else { + value = config.getBoolean(booleanAnnotation.path(), defaultValue); + } + + field.set(classToLoad, value); + } + + ConfigDataInt intAnnotation = field.getAnnotation(ConfigDataInt.class); + if (intAnnotation != null) { + int defaultValue = intAnnotation.defaultValue(); + if (defaultValue == 0) { + try { + int v = field.getInt(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + } + int value = 0; + if (!intAnnotation.secondPath().isEmpty()) { + value = config.getInt(intAnnotation.path(), + config.getInt(intAnnotation.secondPath(), defaultValue)); + } else { + value = config.getInt(intAnnotation.path(), defaultValue); + } + + field.set(classToLoad, value); + } + + ConfigDataLong longAnnotation = field.getAnnotation(ConfigDataLong.class); + if (longAnnotation != null) { + long defaultValue = longAnnotation.defaultValue(); + if (defaultValue == 0) { + try { + int v = field.getInt(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + } + long value = 0; + if (!longAnnotation.secondPath().isEmpty()) { + value = config.getLong(longAnnotation.path(), + config.getLong(longAnnotation.secondPath(), defaultValue)); + } else { + value = config.getLong(longAnnotation.path(), defaultValue); + } + + field.set(classToLoad, value); + } + + ConfigDataDouble doubleAnnotation = field.getAnnotation(ConfigDataDouble.class); + if (doubleAnnotation != null) { + double defaultValue = doubleAnnotation.defaultValue(); + if (defaultValue == 0) { + try { + double v = field.getDouble(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + } + double value = 0; + if (!doubleAnnotation.secondPath().isEmpty()) { + value = config.getDouble(doubleAnnotation.path(), + config.getDouble(doubleAnnotation.secondPath(), defaultValue)); + } else { + value = config.getDouble(doubleAnnotation.path(), defaultValue); + } + + field.set(classToLoad, value); + } + + ConfigDataListString listAnnotation = field.getAnnotation(ConfigDataListString.class); + if (listAnnotation != null) { + ArrayList defaultValue = new ArrayList<>(); + try { + ArrayList v = (ArrayList) field.get(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + + List list = config.getStringList(listAnnotation.path()); + + if (list.isEmpty()) { + list = config.getStringList(listAnnotation.secondPath()); + } + + ArrayList list1 = new ArrayList<>(list); + // use default value + if (list.isEmpty()) { + list1 = defaultValue; + } + + field.set(classToLoad, list1); + + /* + * ArrayList value = null; if (!listAnnotation.secondPath().isEmpty()) { + * value = (ArrayList) config.getList(listAnnotation.path(), + * config.getList(listAnnotation.secondPath(), defaultValue)); } else { value = + * (ArrayList) config.getList(listAnnotation.path(), defaultValue); } + * + * field.set(classToLoad, value); + */ + } + + ConfigDataListInt intListAnnotation = field.getAnnotation(ConfigDataListInt.class); + if (intListAnnotation != null) { + ArrayList defaultValue = new ArrayList<>(); + try { + ArrayList v = (ArrayList) field.get(classToLoad); + defaultValue = v; + } catch (Exception e) { + + } + + List list = config.getIntegerList(intListAnnotation.path()); + + if (list.isEmpty()) { + list = config.getIntegerList(intListAnnotation.secondPath()); + } + + ArrayList list1 = new ArrayList<>(list); + // use default value + if (list.isEmpty()) { + list1 = defaultValue; + } + + field.set(classToLoad, list1); + + /* + * ArrayList value = null; if (!listAnnotation.secondPath().isEmpty()) { + * value = (ArrayList) config.getList(listAnnotation.path(), + * config.getList(listAnnotation.secondPath(), defaultValue)); } else { value = + * (ArrayList) config.getList(listAnnotation.path(), defaultValue); } + * + * field.set(classToLoad, value); + */ + } + + ConfigDataKeys setAnnotation = field.getAnnotation(ConfigDataKeys.class); + if (setAnnotation != null) { + Set value = new HashSet<>(); + if (config.isConfigurationSection(setAnnotation.path())) { + value = config.getConfigurationSection(setAnnotation.path()).getKeys(false); + } else if (config.isConfigurationSection(setAnnotation.secondPath()) + && setAnnotation.secondPath().length() > 0) { + value = config.getConfigurationSection(setAnnotation.secondPath()).getKeys(false); + } + if (value != null) { + field.set(classToLoad, value); + } + } + + ConfigDataConfigurationSection confAnnotation = field + .getAnnotation(ConfigDataConfigurationSection.class); + if (confAnnotation != null) { + ConfigView value = null; + if (config.isConfigurationSection(confAnnotation.path())) { + value = config.getConfigurationSection(confAnnotation.path()); + } else if (config.isConfigurationSection(confAnnotation.secondPath()) + && !confAnnotation.secondPath().isEmpty()) { + value = config.getConfigurationSection(confAnnotation.secondPath()); + } + + field.set(classToLoad, value == null ? null : sectionValue.apply(value)); + } + + ConfigDataParsedDuration durationAnnotation = field.getAnnotation(ConfigDataParsedDuration.class); + if (durationAnnotation != null) { + + String defaultValue = durationAnnotation.defaultValue(); + + if (defaultValue.isEmpty()) { + try { + Object v = field.get(classToLoad); + if (v != null) { + defaultValue = v.toString(); + } + } catch (Exception e) { + + } + } + + String value = ""; + + if (!durationAnnotation.secondPath().isEmpty()) { + value = config.getString(durationAnnotation.path(), + config.getString(durationAnnotation.secondPath(), defaultValue)); + } else { + value = config.getString(durationAnnotation.path(), defaultValue); + } + + try { + // Assumes ParsedDuration has a constructor or static parse method + Object parsedDuration = ParsedDuration.parse(value, durationAnnotation.defaultTimeUnit()); + field.set(classToLoad, parsedDuration); + } catch (Exception e) { + e.printStackTrace(); + } + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + } + +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationHandler.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationHandler.java index 66591d8f..551b7dd5 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationHandler.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/annotation/AnnotationHandler.java @@ -1,283 +1,27 @@ -package com.bencodez.simpleapi.file.annotation; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import org.bukkit.configuration.ConfigurationSection; - -import com.bencodez.simpleapi.time.ParsedDuration; - -public class AnnotationHandler { - - public AnnotationHandler() { - } - - @SuppressWarnings("unchecked") - public void load(ConfigurationSection config, Object classToLoad) { - Class clazz = classToLoad.getClass(); - - for (Field field : clazz.getDeclaredFields()) { - try { - field.setAccessible(true); - - ConfigDataString stringAnnotation = field.getAnnotation(ConfigDataString.class); - if (stringAnnotation != null) { - - String defaultValue = stringAnnotation.defaultValue(); - if (defaultValue.isEmpty()) { - try { - String v = (String) field.get(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - } - String value = ""; - if (!stringAnnotation.secondPath().isEmpty()) { - value = config.getString(stringAnnotation.path(), - config.getString(stringAnnotation.secondPath(), defaultValue)); - } else { - value = config.getString(stringAnnotation.path(), defaultValue); - } - - field.set(classToLoad, value); - - } - - ConfigDataBoolean booleanAnnotation = field.getAnnotation(ConfigDataBoolean.class); - if (booleanAnnotation != null) { - boolean defaultValue = booleanAnnotation.defaultValue(); - if (!defaultValue) { - try { - boolean v = field.getBoolean(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - - } - - boolean value = defaultValue; - if (config.contains(booleanAnnotation.path())) { - value = config.getBoolean(booleanAnnotation.path(), defaultValue); - } else if (!booleanAnnotation.secondPath().isEmpty() - && config.contains(booleanAnnotation.secondPath())) { - value = config.getBoolean(booleanAnnotation.secondPath(), defaultValue); - - if (booleanAnnotation.secondPathInvert()) { - value = !value; - } - } else { - value = config.getBoolean(booleanAnnotation.path(), defaultValue); - } - - field.set(classToLoad, value); - } - - ConfigDataInt intAnnotation = field.getAnnotation(ConfigDataInt.class); - if (intAnnotation != null) { - int defaultValue = intAnnotation.defaultValue(); - if (defaultValue == 0) { - try { - int v = field.getInt(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - } - int value = 0; - if (!intAnnotation.secondPath().isEmpty()) { - value = config.getInt(intAnnotation.path(), - config.getInt(intAnnotation.secondPath(), defaultValue)); - } else { - value = config.getInt(intAnnotation.path(), defaultValue); - } - - field.set(classToLoad, value); - } - - ConfigDataLong longAnnotation = field.getAnnotation(ConfigDataLong.class); - if (longAnnotation != null) { - long defaultValue = longAnnotation.defaultValue(); - if (defaultValue == 0) { - try { - int v = field.getInt(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - } - long value = 0; - if (!longAnnotation.secondPath().isEmpty()) { - value = config.getLong(longAnnotation.path(), - config.getLong(longAnnotation.secondPath(), defaultValue)); - } else { - value = config.getLong(longAnnotation.path(), defaultValue); - } - - field.set(classToLoad, value); - } - - ConfigDataDouble doubleAnnotation = field.getAnnotation(ConfigDataDouble.class); - if (doubleAnnotation != null) { - double defaultValue = doubleAnnotation.defaultValue(); - if (defaultValue == 0) { - try { - double v = field.getDouble(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - } - double value = 0; - if (!doubleAnnotation.secondPath().isEmpty()) { - value = config.getDouble(doubleAnnotation.path(), - config.getDouble(doubleAnnotation.secondPath(), defaultValue)); - } else { - value = config.getDouble(doubleAnnotation.path(), defaultValue); - } - - field.set(classToLoad, value); - } - - ConfigDataListString listAnnotation = field.getAnnotation(ConfigDataListString.class); - if (listAnnotation != null) { - ArrayList defaultValue = new ArrayList<>(); - try { - ArrayList v = (ArrayList) field.get(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - - List list = config.getStringList(listAnnotation.path()); - - if (list.isEmpty()) { - list = config.getStringList(listAnnotation.secondPath()); - } - - ArrayList list1 = new ArrayList<>(list); - // use default value - if (list.isEmpty()) { - list1 = defaultValue; - } - - field.set(classToLoad, list1); - - /* - * ArrayList value = null; if (!listAnnotation.secondPath().isEmpty()) { - * value = (ArrayList) config.getList(listAnnotation.path(), - * config.getList(listAnnotation.secondPath(), defaultValue)); } else { value = - * (ArrayList) config.getList(listAnnotation.path(), defaultValue); } - * - * field.set(classToLoad, value); - */ - } - - ConfigDataListInt intListAnnotation = field.getAnnotation(ConfigDataListInt.class); - if (intListAnnotation != null) { - ArrayList defaultValue = new ArrayList<>(); - try { - ArrayList v = (ArrayList) field.get(classToLoad); - defaultValue = v; - } catch (Exception e) { - - } - - List list = config.getIntegerList(intListAnnotation.path()); - - if (list.isEmpty()) { - list = config.getIntegerList(intListAnnotation.secondPath()); - } - - ArrayList list1 = new ArrayList<>(list); - // use default value - if (list.isEmpty()) { - list1 = defaultValue; - } - - field.set(classToLoad, list1); - - /* - * ArrayList value = null; if (!listAnnotation.secondPath().isEmpty()) { - * value = (ArrayList) config.getList(listAnnotation.path(), - * config.getList(listAnnotation.secondPath(), defaultValue)); } else { value = - * (ArrayList) config.getList(listAnnotation.path(), defaultValue); } - * - * field.set(classToLoad, value); - */ - } - - ConfigDataKeys setAnnotation = field.getAnnotation(ConfigDataKeys.class); - if (setAnnotation != null) { - Set value = new HashSet<>(); - if (config.isConfigurationSection(setAnnotation.path())) { - value = config.getConfigurationSection(setAnnotation.path()).getKeys(false); - } else if (config.isConfigurationSection(setAnnotation.secondPath()) - && setAnnotation.secondPath().length() > 0) { - value = config.getConfigurationSection(setAnnotation.secondPath()).getKeys(false); - } - if (value != null) { - field.set(classToLoad, value); - } - } - - ConfigDataConfigurationSection confAnnotation = field - .getAnnotation(ConfigDataConfigurationSection.class); - if (confAnnotation != null) { - ConfigurationSection value = null; - if (config.isConfigurationSection(confAnnotation.path())) { - value = config.getConfigurationSection(confAnnotation.path()); - } else if (config.isConfigurationSection(confAnnotation.secondPath()) - && !confAnnotation.secondPath().isEmpty()) { - value = config.getConfigurationSection(confAnnotation.secondPath()); - } - - field.set(classToLoad, value); - } - - ConfigDataParsedDuration durationAnnotation = field.getAnnotation(ConfigDataParsedDuration.class); - if (durationAnnotation != null) { - - String defaultValue = durationAnnotation.defaultValue(); - - if (defaultValue.isEmpty()) { - try { - Object v = field.get(classToLoad); - if (v != null) { - defaultValue = v.toString(); - } - } catch (Exception e) { - - } - } - - String value = ""; - - if (!durationAnnotation.secondPath().isEmpty()) { - value = config.getString(durationAnnotation.path(), - config.getString(durationAnnotation.secondPath(), defaultValue)); - } else { - value = config.getString(durationAnnotation.path(), defaultValue); - } - - try { - // Assumes ParsedDuration has a constructor or static parse method - Object parsedDuration = ParsedDuration.parse(value, durationAnnotation.defaultTimeUnit()); - field.set(classToLoad, parsedDuration); - } catch (Exception e) { - e.printStackTrace(); - } - } - - } catch (Exception e) { - e.printStackTrace(); - } - } - - } - -} +package com.bencodez.simpleapi.file.annotation; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.simpleapi.file.config.bukkit.BukkitConfigView; + +/** Bukkit-compatible entry point for the shared annotation binder. */ +public class AnnotationHandler { + + private final AnnotationBinder binder = new AnnotationBinder( + view -> ((BukkitConfigView) view).getSection()); + + public AnnotationHandler() { + } + + /** + * Loads the existing annotations without changing their Bukkit behavior. + * This signature remains unchanged; a ConfigView overload is deliberately + * not added, so existing calls such as load(null, target) stay unambiguous. + * + * @param config Bukkit configuration (legacy null handling is preserved) + * @param classToLoad object whose declared fields should be populated + */ + public void load(ConfigurationSection config, Object classToLoad) { + binder.load(config == null ? null : new BukkitConfigView(config), classToLoad); + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigView.java new file mode 100644 index 00000000..9e512a75 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigView.java @@ -0,0 +1,42 @@ +package com.bencodez.simpleapi.file.config; + +import java.util.List; +import java.util.Set; + +/** + * Read-only access used by the annotation binder, independent of a server API. + * + *

Implementations own path traversal, configured defaults and scalar/list + * conversion semantics. A platform adapter must delegate these operations rather + * than round-tripping a live configuration through a different parser. This + * interface does not imply a snapshot, thread safety, or permission to mutate the + * underlying configuration.

+ * + *

List getters return an empty list when no compatible list exists. Section + * lookup returns {@code null} when absent. Paths and the {@code deep} key flag + * follow the backing configuration's rules.

+ */ +public interface ConfigView { + + boolean contains(String path); + + String getString(String path, String defaultValue); + + boolean getBoolean(String path, boolean defaultValue); + + int getInt(String path, int defaultValue); + + long getLong(String path, long defaultValue); + + double getDouble(String path, double defaultValue); + + List getStringList(String path); + + List getIntegerList(String path); + + boolean isConfigurationSection(String path); + + ConfigView getConfigurationSection(String path); + + Set getKeys(boolean deep); +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/bukkit/BukkitConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/bukkit/BukkitConfigView.java new file mode 100644 index 00000000..0990bd23 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/bukkit/BukkitConfigView.java @@ -0,0 +1,89 @@ +package com.bencodez.simpleapi.file.config.bukkit; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.simpleapi.file.config.ConfigView; + +/** + * Live, read-only facade over a Bukkit configuration section. Reads deliberately + * preserve the backing section's defaults, coercions, key order and path options. + */ +public final class BukkitConfigView implements ConfigView { + + private final ConfigurationSection section; + + public BukkitConfigView(ConfigurationSection section) { + this.section = Objects.requireNonNull(section, "section"); + } + + /** + * Returns the original section for legacy Bukkit-typed annotated fields. + * No copy is made: existing callers retain section identity and mutability. + * This method belongs to the Bukkit adapter, not the platform-neutral API. + * + * @return the backing Bukkit section + */ + public ConfigurationSection getSection() { + return section; + } + + @Override + public boolean contains(String path) { + return section.contains(path); + } + + @Override + public String getString(String path, String defaultValue) { + return section.getString(path, defaultValue); + } + + @Override + public boolean getBoolean(String path, boolean defaultValue) { + return section.getBoolean(path, defaultValue); + } + + @Override + public int getInt(String path, int defaultValue) { + return section.getInt(path, defaultValue); + } + + @Override + public long getLong(String path, long defaultValue) { + return section.getLong(path, defaultValue); + } + + @Override + public double getDouble(String path, double defaultValue) { + return section.getDouble(path, defaultValue); + } + + @Override + public List getStringList(String path) { + return section.getStringList(path); + } + + @Override + public List getIntegerList(String path) { + return section.getIntegerList(path); + } + + @Override + public boolean isConfigurationSection(String path) { + return section.isConfigurationSection(path); + } + + @Override + public BukkitConfigView getConfigurationSection(String path) { + ConfigurationSection child = section.getConfigurationSection(path); + return child == null ? null : new BukkitConfigView(child); + } + + @Override + public Set getKeys(boolean deep) { + return section.getKeys(deep); + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationBinderHeadlessTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationBinderHeadlessTest.java new file mode 100644 index 00000000..a3c6d377 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationBinderHeadlessTest.java @@ -0,0 +1,30 @@ +package com.bencodez.simpleapi.tests.file.config; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URL; +import java.net.URLClassLoader; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.file.annotation.AnnotationBinder; + +class AnnotationBinderHeadlessTest { + + @Test + void bindsUsingOnlyProjectClassesAndTheJdk() throws Exception { + URL mainClasses = AnnotationBinder.class.getProtectionDomain().getCodeSource().getLocation(); + URL testClasses = getClass().getProtectionDomain().getCodeSource().getLocation(); + // The platform parent supplies only JDK classes, not Maven's dependency classpath. + try (URLClassLoader isolated = new URLClassLoader(new URL[] { mainClasses, testClasses }, + ClassLoader.getPlatformClassLoader())) { + assertThrows(ClassNotFoundException.class, + () -> isolated.loadClass("org.bukkit.configuration.ConfigurationSection")); + assertThrows(ClassNotFoundException.class, + () -> isolated.loadClass("org.junit.jupiter.api.Test")); + Class fixture = isolated.loadClass(HeadlessBindingFixture.class.getName()); + assertSame(isolated, fixture.getClassLoader()); + fixture.getMethod("run").invoke(null); + } + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationHandlerCompatibilityTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationHandlerCompatibilityTest.java new file mode 100644 index 00000000..8d805aac --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/AnnotationHandlerCompatibilityTest.java @@ -0,0 +1,315 @@ +package com.bencodez.simpleapi.tests.file.config; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.file.annotation.*; +import com.bencodez.simpleapi.file.config.bukkit.BukkitConfigView; +import com.bencodez.simpleapi.time.ParsedDuration; + +class AnnotationHandlerCompatibilityTest { + + private final AnnotationHandler handler = new AnnotationHandler(); + + static class Scalars { + @ConfigDataString(path = "string", secondPath = "old.string") + private String string = "initial"; + @ConfigDataBoolean(path = "bool", secondPath = "old.bool", secondPathInvert = true) + private boolean bool = true; + @ConfigDataInt(path = "integer", secondPath = "old.integer") + private int integer = 7; + @ConfigDataLong(path = "long", secondPath = "old.long") + private long number = 99L; + @ConfigDataDouble(path = "double", secondPath = "old.double") + private double decimal = 1.5; + } + + @Test + void preservesPublicConstructorAndUnambiguousLegacySignature() throws Exception { + assertNotNull(AnnotationHandler.class.getConstructor().newInstance()); + Method load = AnnotationHandler.class.getMethod("load", ConfigurationSection.class, Object.class); + assertEquals(void.class, load.getReturnType()); + assertEquals(1L, Arrays.stream(AnnotationHandler.class.getMethods()) + .filter(method -> method.getName().equals("load")).count()); + // Compile-time regression guard: a ConfigView overload would make this ambiguous. + assertDoesNotThrow(() -> handler.load(null, new Object())); + } + + @Test + void preservesInitializedDefaultsIncludingHistoricalLongBehavior() { + Scalars target = new Scalars(); + handler.load(new MemoryConfiguration(), target); + assertEquals("initial", target.string); + assertTrue(target.bool); + assertEquals(7, target.integer); + // Existing Field.getInt(longField) fails and leaves the annotation default at zero. + // Fixing this would be a separate behavior change, not part of the extraction. + assertEquals(0L, target.number); + assertEquals(1.5, target.decimal); + } + + @Test + void honorsExplicitZeroFalseAndEmptyPrimaryValues() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("string", ""); + config.set("bool", false); + config.set("integer", 0); + config.set("long", 0L); + config.set("double", 0.0); + config.set("old.string", "fallback"); + config.set("old.bool", false); + config.set("old.integer", 100); + Scalars target = new Scalars(); + handler.load(config, target); + assertEquals("", target.string); + assertFalse(target.bool); + assertEquals(0, target.integer); + assertEquals(0L, target.number); + assertEquals(0.0, target.decimal); + } + + @Test + void usesAlternatePathsAndInvertsOnlyTheAlternateBoolean() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("old.string", "alternate"); + config.set("old.bool", true); + config.set("old.integer", 12); + config.set("old.long", 4_000_000_000L); + config.set("old.double", 2.25); + Scalars target = new Scalars(); + handler.load(config, target); + assertEquals("alternate", target.string); + assertFalse(target.bool); + assertEquals(12, target.integer); + assertEquals(4_000_000_000L, target.number); + assertEquals(2.25, target.decimal); + config.set("bool", true); + handler.load(config, target); + assertTrue(target.bool); + } + + @Test + void retainsBukkitTypeCoercionAndPresentButInvalidBooleanPrecedence() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("string", 42); + config.set("bool", "not-a-boolean"); + config.set("old.bool", true); + config.set("integer", "invalid"); + config.set("old.integer", 18); + config.set("long", 123); + config.set("double", 4); + Scalars target = new Scalars(); + handler.load(config, target); + assertEquals(config.getString("string", "initial"), target.string); + assertTrue(target.bool); // contains(primary) wins; no alternate inversion. + assertEquals(18, target.integer); + assertEquals(123L, target.number); + assertEquals(4.0, target.decimal); + } + + static class ExplicitDefaults { + @ConfigDataString(path = "missing", defaultValue = "annotation") + String text = "field"; + @ConfigDataInt(path = "missing", defaultValue = 12) + int integer = 7; + @ConfigDataLong(path = "missing", defaultValue = 123L) + long number = 99L; + @ConfigDataDouble(path = "missing", defaultValue = 2.5) + double decimal = 1.5; + @ConfigDataBoolean(path = "missing", defaultValue = true) + boolean bool; + } + + @Test + void preservesExplicitAnnotationDefaults() { + ExplicitDefaults target = new ExplicitDefaults(); + handler.load(new MemoryConfiguration(), target); + assertEquals("annotation", target.text); + assertEquals(12, target.integer); + assertEquals(123L, target.number); + assertEquals(2.5, target.decimal); + assertTrue(target.bool); + } + + static class Lists { + @ConfigDataListString(path = "strings", secondPath = "old.strings") + ArrayList strings = new ArrayList<>(List.of("initial")); + @ConfigDataListInt(path = "integers", secondPath = "old.integers") + ArrayList integers = new ArrayList<>(List.of(7)); + } + + @Test + void preservesEmptyListFallbackAndInitializedListIdentity() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("strings", List.of()); + config.set("integers", List.of()); + Lists target = new Lists(); + ArrayList strings = target.strings; + ArrayList integers = target.integers; + handler.load(config, target); + assertSame(strings, target.strings); + assertSame(integers, target.integers); + config.set("old.strings", List.of("alternate")); + config.set("old.integers", List.of(11)); + handler.load(config, target); + assertEquals(List.of("alternate"), target.strings); + assertEquals(List.of(11), target.integers); + assertNotSame(strings, target.strings); + assertNotSame(integers, target.integers); + } + + @Test + void delegatesMixedListConversionToBukkit() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("strings", Arrays.asList("one", 2, true, null, List.of("ignored"))); + config.set("integers", Arrays.asList(1, "2", 3.5, 'A', false, null, "bad")); + Lists target = new Lists(); + handler.load(config, target); + assertEquals(config.getStringList("strings"), target.strings); + assertEquals(config.getIntegerList("integers"), target.integers); + } + + static class Sections { + @ConfigDataConfigurationSection(path = "rewards", secondPath = "old.rewards") + ConfigurationSection section; + @ConfigDataConfigurationSection(path = "rewards", secondPath = "old.rewards") + Object object; + @ConfigDataKeys(path = "rewards", secondPath = "old.rewards") + Set keys = Set.of("initial"); + } + + @Test + void keepsNativeSectionIdentityForTypedAndObjectFields() { + MemoryConfiguration config = new MemoryConfiguration(); + ConfigurationSection section = config.createSection("rewards"); + section.set("first.command", "say first"); + section.set("second", "say second"); + Sections target = new Sections(); + handler.load(config, target); + assertSame(section, target.section); + assertSame(section, target.object); + assertEquals(new ArrayList<>(section.getKeys(false)), new ArrayList<>(target.keys)); + target.section.set("edited", true); + assertTrue(config.getBoolean("rewards.edited")); + } + + @Test + void supportsAlternateSectionsAndClearsAbsentSectionsAndKeys() { + MemoryConfiguration config = new MemoryConfiguration(); + ConfigurationSection section = config.createSection("old.rewards"); + section.set("command", "say alternate"); + Sections target = new Sections(); + handler.load(config, target); + assertSame(section, target.section); + assertSame(section, target.object); + assertEquals(Set.of("command"), target.keys); + config.set("old.rewards", null); + handler.load(config, target); + assertNull(target.section); + assertNull(target.object); + assertTrue(target.keys.isEmpty()); + } + + static class RootSection { + @ConfigDataConfigurationSection(path = "") + Object root; + } + + @Test + void retainsRootSectionIdentity() { + MemoryConfiguration config = new MemoryConfiguration(); + RootSection target = new RootSection(); + handler.load(config, target); + assertSame(config, target.root); + } + + @Test + void retainsConfiguredDefaultsAndPathSeparatorThroughTheLiveAdapter() { + MemoryConfiguration defaults = new MemoryConfiguration(); + defaults.set("bool", false); + MemoryConfiguration config = new MemoryConfiguration(defaults); + config.options().pathSeparator('/'); + config.set("nested/value", 25); + BukkitConfigView view = new BukkitConfigView(config); + assertEquals(config.contains("bool"), view.contains("bool")); + assertEquals(config.getBoolean("bool", true), view.getBoolean("bool", true)); + assertEquals(25, view.getInt("nested/value", 0)); + assertEquals(config.getKeys(true), view.getKeys(true)); + config.set("nested/value", 26); + assertEquals(26, view.getInt("nested/value", 0)); + assertSame(config.getConfigurationSection("nested"), view.getConfigurationSection("nested").getSection()); + assertNull(view.getConfigurationSection("absent")); + } + + static class Parent { + @ConfigDataInt(path = "value") + int inherited = 3; + } + + static class Child extends Parent { + @ConfigDataInt(path = "value") + private int declared = 4; + } + + @Test + void retainsDeclaredFieldsOnlyTraversal() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("value", 20); + Child target = new Child(); + handler.load(config, target); + assertEquals(3, target.inherited); + assertEquals(20, target.declared); + } + + static class InvalidField { + @ConfigDataString(path = "text") + @ConfigDataInt(path = "number") + int invalid = 5; + @ConfigDataInt(path = "number") + int valid; + } + + @Test + void isolatesFieldErrorsWithoutChangingAnnotationOrder() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("text", "not an integer"); + config.set("number", 10); + InvalidField target = new InvalidField(); + assertDoesNotThrow(() -> handler.load(config, target)); + assertEquals(5, target.invalid); // String runs first; the field's remaining annotations are skipped. + assertEquals(10, target.valid); + } + + static class Durations { + @ConfigDataParsedDuration(path = "delay", secondPath = "old.delay", defaultTimeUnit = TimeUnit.SECONDS) + ParsedDuration duration; + @ConfigDataParsedDuration(path = "missing", defaultValue = "2s", defaultTimeUnit = TimeUnit.SECONDS) + ParsedDuration fallback; + } + + @Test + void keepsDurationParsingAndAlternatePaths() { + MemoryConfiguration config = new MemoryConfiguration(); + config.set("old.delay", "3"); + Durations target = new Durations(); + handler.load(config, target); + assertEquals(3_000L, target.duration.getMillis()); + assertEquals(2_000L, target.fallback.getMillis()); + } + + @Test + void retainsNullTargetFailure() { + assertThrows(NullPointerException.class, () -> handler.load(new MemoryConfiguration(), null)); + assertThrows(NullPointerException.class, () -> handler.load(null, null)); + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/HeadlessBindingFixture.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/HeadlessBindingFixture.java new file mode 100644 index 00000000..38f5a963 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/file/config/HeadlessBindingFixture.java @@ -0,0 +1,118 @@ +package com.bencodez.simpleapi.tests.file.config; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.bencodez.simpleapi.file.annotation.AnnotationBinder; +import com.bencodez.simpleapi.file.annotation.ConfigDataConfigurationSection; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataKeys; +import com.bencodez.simpleapi.file.annotation.ConfigDataParsedDuration; +import com.bencodez.simpleapi.file.annotation.ConfigDataString; +import com.bencodez.simpleapi.file.config.ConfigView; +import com.bencodez.simpleapi.time.ParsedDuration; + +/** Executed in a separate class loader that has no Bukkit or test libraries. */ +public final class HeadlessBindingFixture { + + private HeadlessBindingFixture() { + } + + public static final class Values { + @ConfigDataString(path = "message", secondPath = "legacy-message") + private String message; + @ConfigDataInt(path = "votes") + private int votes; + @ConfigDataConfigurationSection(path = "rewards") + private ConfigView rewards; + @ConfigDataKeys(path = "rewards") + private Set keys; + @ConfigDataParsedDuration(path = "delay", defaultTimeUnit = TimeUnit.SECONDS) + private ParsedDuration delay; + } + + public static void run() { + ConfigView rewards = new TestView(Map.of("command", "say voted")); + ConfigView config = new TestView(Map.of("legacy-message", "thanks", "votes", 12, + "rewards", rewards, "delay", "3")); + Values values = new Values(); + new AnnotationBinder().load(config, values); + if (!"thanks".equals(values.message) || values.votes != 12 || values.rewards != rewards + || !Set.of("command").equals(values.keys) || values.delay == null + || values.delay.getMillis() != 3_000L) { + throw new AssertionError("Headless annotation binding failed"); + } + } + + // A deliberately small in-memory test implementation, not a new YAML parser. + private static final class TestView implements ConfigView { + private final Map values; + + private TestView(Map values) { + this.values = values; + } + + @Override + public boolean contains(String path) { + return values.containsKey(path); + } + + @Override + public String getString(String path, String fallback) { + Object value = values.get(path); + return value == null ? fallback : value.toString(); + } + + @Override + public boolean getBoolean(String path, boolean fallback) { + Object value = values.get(path); + return value instanceof Boolean ? (Boolean) value : fallback; + } + + @Override + public int getInt(String path, int fallback) { + Object value = values.get(path); + return value instanceof Number ? ((Number) value).intValue() : fallback; + } + + @Override + public long getLong(String path, long fallback) { + Object value = values.get(path); + return value instanceof Number ? ((Number) value).longValue() : fallback; + } + + @Override + public double getDouble(String path, double fallback) { + Object value = values.get(path); + return value instanceof Number ? ((Number) value).doubleValue() : fallback; + } + + @Override + public List getStringList(String path) { + return List.of(); + } + + @Override + public List getIntegerList(String path) { + return List.of(); + } + + @Override + public boolean isConfigurationSection(String path) { + return values.get(path) instanceof ConfigView; + } + + @Override + public ConfigView getConfigurationSection(String path) { + Object value = values.get(path); + return value instanceof ConfigView ? (ConfigView) value : null; + } + + @Override + public Set getKeys(boolean deep) { + return values.keySet(); + } + } +} diff --git a/docs/platform-neutral-configuration.md b/docs/platform-neutral-configuration.md new file mode 100644 index 00000000..f6800d26 --- /dev/null +++ b/docs/platform-neutral-configuration.md @@ -0,0 +1,81 @@ +# Platform-neutral configuration foundation + +This is the first compatibility-preserving step toward reusing SimpleAPI's +configuration annotations outside Bukkit. It is not a Forge/Fabric implementation +or a separately published `simpleapi-core` artifact yet. The existing Maven +coordinates, shaded JAR, build command, configuration files and dependencies are +unchanged. Do not put the entire current SimpleAPI JAR on a mod server and assume +that its other classes are platform-neutral. + +## Existing Bukkit callers + +Keep using the existing API without changes: + +```java +new AnnotationHandler().load(configurationSection, target); +``` + +`AnnotationHandler` delegates to `AnnotationBinder` through `BukkitConfigView`. +The adapter forwards reads to the original section rather than serializing, +copying or reparsing it. `@ConfigDataConfigurationSection` continues to assign the +original Bukkit section, including when the annotated field is typed as `Object`. +Section identity, edits through that section, Bukkit defaults and path options +are preserved. There is no new `load` overload on `AnnotationHandler`, so existing +`load(null, target)` call sites are still unambiguous. + +## New shared callers + +A platform supplies the small `ConfigView` read contract and uses: + +```java +new AnnotationBinder().load(view, target); +``` + +The no-argument binder assigns `ConfigView` instances to section-annotated fields; +shared models should declare those fields as `ConfigView`. The optional section +projection constructor exists for compatibility adapters, not for leaking native +server objects into new shared models. It is called only for present sections. + +The binder uses the existing annotation types and `ParsedDuration`. Its execution +path has no Bukkit, proxy, mod-loader or Minecraft dependency. The headless test +loads it with only project classes and the JDK and verifies that Bukkit cannot be +loaded. An in-memory test view is included; production Configurate/YAML adapters +and physical module extraction are separate follow-ups. + +## Behavior deliberately preserved + +This change moves the existing binding body rather than redesigning its rules: + +- Only declared fields are processed, in the existing annotation-check order. +- Existing field/annotation defaults, alternate paths and boolean inversion apply. +- Empty lists fall back to alternate paths and then the initialized list object. +- Missing sections become null; missing key sets become empty. +- Per-field exceptions are printed and isolated as before. +- The historical zero-default `ConfigDataLong` reflection behavior is retained: + a long field initializer is not recovered through `Field.getInt`. Changing it + would alter existing configurations and is intentionally outside this PR. + +`ConfigView` is read-only access, not a promise that its backing data is immutable +or safe to read from arbitrary threads. Each platform must preserve its existing +configuration ownership and thread rules. + +## Verification + +Run the existing full build: + +```sh +mvn -B -f SimpleAPI/pom.xml package +``` + +Focused regression tests: + +```sh +mvn -B -f SimpleAPI/pom.xml \ + -Dtest=AnnotationHandlerCompatibilityTest,AnnotationBinderHeadlessTest test +``` + +The compatibility tests exercise the real Bukkit configuration API. The headless +fixture deliberately does not depend on JUnit or Bukkit within its isolated +class loader. Before merging, also build AdvancedCore and VotingPlugin against +the locally installed candidate SimpleAPI artifact; no consumer changes should +be required.