diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/bukkit/config/BukkitStructuredConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/bukkit/config/BukkitStructuredConfigView.java new file mode 100644 index 0000000..94a73c1 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/bukkit/config/BukkitStructuredConfigView.java @@ -0,0 +1,237 @@ +package com.bencodez.simpleapi.bukkit.config; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import org.bukkit.configuration.ConfigurationSection; + +import com.bencodez.simpleapi.core.config.PlainConfigValues; +import com.bencodez.simpleapi.core.config.StructuredConfigView; + +/** + * Live structured Bukkit reads. Ordinary getters still delegate to Bukkit. + * Literal reads never change the root path separator or mutate the section. + */ +public final class BukkitStructuredConfigView extends BukkitConfigView implements StructuredConfigView { + private static final class RawMapView implements StructuredConfigView { + private static final int MAX_DEPTH = 64; + private static final int MAX_KEYS = 100_000; + private final Map source; + private final char separator; + + private RawMapView(Map values, char separator) { + this.source = values; + index(values); + this.separator = separator; + } + + private static Map index(Map source) { + Map indexed = new java.util.LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (indexed.size() >= MAX_KEYS) { + throw new IllegalArgumentException("Configuration map contains too many keys"); + } + Object key = entry.getKey(); + boolean scalarKey = key instanceof String || key instanceof Boolean || key instanceof Character + || PlainConfigValues.kind(key) == Kind.NUMBER; + if (!scalarKey) throw new IllegalArgumentException("Configuration map keys must be plain scalars"); + String name = String.valueOf(key); + if (indexed.containsKey(name)) { + throw new IllegalArgumentException("Configuration map keys must be unambiguous strings"); + } + indexed.put(name, entry.getValue()); + } + return indexed; + } + + private String[] split(String path) { + Objects.requireNonNull(path, "path"); + return path.isEmpty() ? new String[0] : path.split(Pattern.quote(String.valueOf(separator))); + } + + private Object literal(String... keys) { + Objects.requireNonNull(keys, "keys"); + Object current = source; + for (String key : keys) { + Objects.requireNonNull(key, "key"); + if (current instanceof Map raw) { + Map map = index(raw); + if (!map.containsKey(key)) return null; + current = map.get(key); + } else if (current instanceof ConfigurationSection section) { + current = sectionLiteral(section, key); + } else { + return null; + } + } + return current; + } + + private static boolean scalar(Object value) { + return value instanceof String || value instanceof Number || value instanceof Boolean + || value instanceof Character; + } + + @Override public Kind kind(String path) { return kindAt(split(path)); } + @Override public Kind kindAt(String... keys) { + if (keys.length == 0) return Kind.MAP; + Object value = literal(keys); + if (value instanceof ConfigurationSection) return Kind.SECTION; + return value == null ? Kind.MISSING : PlainConfigValues.kind(value); + } + @Override public Object value(String path) { return valueAt(split(path)); } + @Override public Object valueAt(String... keys) { + Object value = literal(keys); + return value == null ? null : detached(value); + } + @Override public StructuredConfigView at(String... keys) { + Object value = literal(keys); + return value instanceof ConfigurationSection section ? new BukkitStructuredConfigView(section) : null; + } + @Override public StructuredConfigView structuredAt(String... keys) { + Object value = literal(keys); + if (value instanceof ConfigurationSection section) return new BukkitStructuredConfigView(section); + return value instanceof Map map ? new RawMapView(map, separator) : null; + } + @Override public StructuredConfigView getConfigurationSection(String path) { return at(split(path)); } + @Override public boolean isConfigurationSection(String path) { + return literal(split(path)) instanceof ConfigurationSection; + } + @Override public boolean contains(String path) { + String[] keys = split(path); + return keys.length == 0 || literal(keys) != null; + } + @Override public String getString(String path, String fallback) { + Object value = literal(split(path)); + return scalar(value) ? value.toString() : fallback; + } + @Override public boolean getBoolean(String path, boolean fallback) { + Object value = literal(split(path)); + return value instanceof Boolean bool ? bool : fallback; + } + @Override public int getInt(String path, int fallback) { + Object value = literal(split(path)); + return value instanceof Number number ? number.intValue() : fallback; + } + @Override public long getLong(String path, long fallback) { + Object value = literal(split(path)); + return value instanceof Number number ? number.longValue() : fallback; + } + @Override public double getDouble(String path, double fallback) { + Object value = literal(split(path)); + return value instanceof Number number ? number.doubleValue() : fallback; + } + @Override public List getStringList(String path) { + Object value = literal(split(path)); + if (!(value instanceof List list)) return List.of(); + List result = new ArrayList<>(); + for (Object item : list) if (scalar(item)) result.add(item.toString()); + return result; + } + @Override public List getIntegerList(String path) { + Object value = literal(split(path)); + if (!(value instanceof List list)) return List.of(); + List result = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Number number) result.add(number.intValue()); + else if (item instanceof Character character) result.add((int) character); + else if (item instanceof String string) { + try { result.add(Integer.parseInt(string)); } catch (NumberFormatException ignored) { } + } + } + return result; + } + @Override public Set getKeys(boolean deep) { + Set result = new LinkedHashSet<>(); + collectKeys(source, "", deep, result, 0, new int[1], new IdentityHashMap<>()); + return result; + } + private void collectKeys(Object container, String prefix, boolean deep, Set result, + int depth, int[] count, IdentityHashMap ancestors) { + if (depth > MAX_DEPTH || ancestors.put(container, Boolean.TRUE) != null) { + throw new IllegalArgumentException("Configuration map nesting is cyclic or too deep"); + } + try { + Map entries = container instanceof ConfigurationSection section + ? section.getValues(false) : index((Map) container); + for (Map.Entry entry : entries.entrySet()) { + if (++count[0] > MAX_KEYS) { + throw new IllegalArgumentException("Configuration map contains too many keys"); + } + String key = entry.getKey(); + String path = prefix + key; + result.add(path); + Object child = entry.getValue(); + if (deep && (child instanceof Map || child instanceof ConfigurationSection)) { + collectKeys(child, path + separator, true, result, depth + 1, count, ancestors); + } + } + } finally { + ancestors.remove(container); + } + } + } + + public BukkitStructuredConfigView(ConfigurationSection section) { super(section); } + + @Override protected BukkitStructuredConfigView sectionView(ConfigurationSection child) { + return new BukkitStructuredConfigView(child); + } + @Override public BukkitStructuredConfigView getConfigurationSection(String path) { + return (BukkitStructuredConfigView) super.getConfigurationSection(path); + } + @Override public BukkitStructuredConfigView at(String... keys) { + Object value = literal(keys); + return value instanceof ConfigurationSection section ? sectionView(section) : null; + } + @Override public StructuredConfigView structuredAt(String... keys) { + Object value = literal(keys); + if (value instanceof ConfigurationSection section) return sectionView(section); + if (value instanceof Map map) { + ConfigurationSection section = getSection(); + org.bukkit.configuration.Configuration root = section.getRoot(); + return new RawMapView(map, root == null ? '.' : root.options().pathSeparator()); + } + return null; + } + @Override public Kind kind(String path) { return classify(getSection().get(path)); } + @Override public Kind kindAt(String... keys) { return classify(literal(keys)); } + private static Kind classify(Object value) { + return value instanceof ConfigurationSection ? Kind.SECTION : PlainConfigValues.kind(value); + } + @Override public Object value(String path) { return detached(getSection().get(path)); } + @Override public Object valueAt(String... keys) { return detached(literal(keys)); } + private static Object detached(Object value) { + return PlainConfigValues.copy(value, current -> current instanceof ConfigurationSection section + ? section.getValues(false) : current); + } + + private Object literal(String... keys) { + Objects.requireNonNull(keys, "keys"); + Object current = getSection(); + for (String key : keys) { + Objects.requireNonNull(key, "key"); + if (current instanceof ConfigurationSection section) { + current = sectionLiteral(section, key); + } else if (current instanceof Map map) { + current = RawMapView.index(map).get(key); + } else { + return null; + } + } + return current; + } + + private static Object sectionLiteral(ConfigurationSection section, String key) { + Map values = section.getValues(false); + if (values.containsKey(key)) return values.get(key); + ConfigurationSection defaults = section.getDefaultSection(); + return defaults == null ? null : defaults.getValues(false).get(key); + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/CaseInsensitiveConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/CaseInsensitiveConfigView.java new file mode 100644 index 0000000..da2ec89 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/CaseInsensitiveConfigView.java @@ -0,0 +1,118 @@ +package com.bencodez.simpleapi.core.config; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Read-only case-insensitive reward view. Like the legacy CaseInsensitiveSection, + * the first key in the backing section's enumeration wins a case collision; an + * exact spelling does not silently take precedence. Values/casing are not changed. + * Use the constructor separator matching the backing view (normally '.'). + */ +public final class CaseInsensitiveConfigView implements StructuredConfigView { + private final StructuredConfigView delegate; + private final char separator; + + public CaseInsensitiveConfigView(StructuredConfigView delegate) { this(delegate, '.'); } + public CaseInsensitiveConfigView(StructuredConfigView delegate, char separator) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.separator = separator; + } + + private record Resolved(StructuredConfigView parent, String key) { } + + private String[] split(String path) { + Objects.requireNonNull(path, "path"); + // Retain the legacy path wrapper's trailing-separator behavior. + return path.isEmpty() ? new String[0] : path.split(Pattern.quote(String.valueOf(separator))); + } + + private Resolved resolve(String... keys) { + Objects.requireNonNull(keys, "keys"); + StructuredConfigView current = delegate; + if (keys.length == 0) return new Resolved(current, null); + for (int index = 0; index < keys.length; index++) { + String requested = Objects.requireNonNull(keys[index], "key"); + String actual = null; + for (String candidate : current.getKeys(false)) { + if (candidate.equalsIgnoreCase(requested)) { actual = candidate; break; } + } + if (actual == null) return null; + if (index == keys.length - 1) return new Resolved(current, actual); + StructuredConfigView child = childAt(current, actual); + current = child; + if (current == null) return null; + } + throw new AssertionError("Unreachable key traversal"); + } + + private static String path(Resolved value) { return value.key() == null ? "" : value.key(); } + + private StructuredConfigView childAt(StructuredConfigView current, String key) { + return current.structuredAt(key); + } + + @Override public Kind kind(String path) { return kindAt(split(path)); } + @Override public Kind kindAt(String... keys) { + Resolved value = resolve(keys); + if (value == null) return Kind.MISSING; + return value.key() == null ? value.parent().kindAt() : value.parent().kindAt(value.key()); + } + @Override public Object value(String path) { return valueAt(split(path)); } + @Override public Object valueAt(String... keys) { + Resolved value = resolve(keys); + if (value == null) return null; + return value.key() == null ? value.parent().valueAt() : value.parent().valueAt(value.key()); + } + @Override public CaseInsensitiveConfigView at(String... keys) { + Resolved value = resolve(keys); + if (value == null) return null; + StructuredConfigView child = value.key() == null ? value.parent() : value.parent().at(value.key()); + return child == null ? null : new CaseInsensitiveConfigView(child, separator); + } + @Override public CaseInsensitiveConfigView structuredAt(String... keys) { + Resolved value = resolve(keys); + if (value == null) return null; + StructuredConfigView child = value.key() == null ? value.parent() : childAt(value.parent(), value.key()); + return child == null ? null : new CaseInsensitiveConfigView(child, separator); + } + @Override public CaseInsensitiveConfigView getConfigurationSection(String path) { + return isConfigurationSection(path) ? at(split(path)) : null; + } + @Override public boolean isConfigurationSection(String path) { return kind(path) == Kind.SECTION; } + @Override public boolean contains(String path) { + Resolved value = resolve(split(path)); + return value != null && value.parent().contains(path(value)); + } + @Override public String getString(String path, String fallback) { + Resolved value = resolve(split(path)); + return value == null ? fallback : value.parent().getString(path(value), fallback); + } + @Override public boolean getBoolean(String path, boolean fallback) { + Resolved value = resolve(split(path)); + return value == null ? fallback : value.parent().getBoolean(path(value), fallback); + } + @Override public int getInt(String path, int fallback) { + Resolved value = resolve(split(path)); + return value == null ? fallback : value.parent().getInt(path(value), fallback); + } + @Override public long getLong(String path, long fallback) { + Resolved value = resolve(split(path)); + return value == null ? fallback : value.parent().getLong(path(value), fallback); + } + @Override public double getDouble(String path, double fallback) { + Resolved value = resolve(split(path)); + return value == null ? fallback : value.parent().getDouble(path(value), fallback); + } + @Override public List getStringList(String path) { + Resolved value = resolve(split(path)); + return value == null ? List.of() : value.parent().getStringList(path(value)); + } + @Override public List getIntegerList(String path) { + Resolved value = resolve(split(path)); + return value == null ? List.of() : value.parent().getIntegerList(path(value)); + } + @Override public Set getKeys(boolean deep) { return delegate.getKeys(deep); } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/ConfigurateStructuredConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/ConfigurateStructuredConfigView.java new file mode 100644 index 0000000..ddba42f --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/ConfigurateStructuredConfigView.java @@ -0,0 +1,47 @@ +package com.bencodez.simpleapi.core.config; + +import java.util.Map; + +import org.spongepowered.configurate.ConfigurationNode; + +/** Structured reads layered on the existing Configurate getter implementation. */ +public final class ConfigurateStructuredConfigView extends ConfigurateConfigView implements StructuredConfigView { + public ConfigurateStructuredConfigView(ConfigurationNode node) { super(node); } + public ConfigurateStructuredConfigView(ConfigurationNode node, char separator) { super(node, separator); } + public ConfigurateStructuredConfigView(ConfigurateConfigView source) { super(source); } + + @Override + protected ConfigurateStructuredConfigView sectionView(ConfigurationNode child) { + return new ConfigurateStructuredConfigView(super.sectionView(child)); + } + + @Override + public ConfigurateStructuredConfigView at(String... keys) { + return (ConfigurateStructuredConfigView) super.at(keys); + } + + @Override + public ConfigurateStructuredConfigView getConfigurationSection(String path) { + return (ConfigurateStructuredConfigView) super.getConfigurationSection(path); + } + + @Override public Kind kind(String path) { return kindAt(segments(path)); } + @Override public Kind kindAt(String... keys) { + ConfigurationNode child = resolve(keys); + if (child.isMap() || child == node && child.isNull()) return Kind.SECTION; + if (child.isList()) return Kind.LIST; + return PlainConfigValues.kind(child.rawScalar()); + } + + @Override public Object value(String path) { return valueAt(segments(path)); } + @Override public Object valueAt(String... keys) { + ConfigurationNode child = resolve(keys); + if (child == node && child.isNull()) return Map.of(); + return PlainConfigValues.copy(child, value -> { + if (!(value instanceof ConfigurationNode current)) return value; + if (current.isMap()) return current.childrenMap(); + if (current.isList()) return current.childrenList(); + return current.rawScalar(); + }); + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/PlainConfigValues.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/PlainConfigValues.java new file mode 100644 index 0000000..aca402c --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/PlainConfigValues.java @@ -0,0 +1,96 @@ +package com.bencodez.simpleapi.core.config; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +/** Bounded copying of configuration data; no platform object serialization. */ +public final class PlainConfigValues { + private static final int MAX_DEPTH = 64; + private static final int MAX_NODES = 100_000; + + private PlainConfigValues() { } + + public static StructuredConfigView.Kind kind(Object value) { + if (value == null) return StructuredConfigView.Kind.MISSING; + if (value instanceof String || value instanceof Character) return StructuredConfigView.Kind.STRING; + if (value instanceof Boolean) return StructuredConfigView.Kind.BOOLEAN; + if (value instanceof Number && supportedNumber(value)) return StructuredConfigView.Kind.NUMBER; + if (value instanceof List) return StructuredConfigView.Kind.LIST; + if (value instanceof Map) return StructuredConfigView.Kind.MAP; + return StructuredConfigView.Kind.OTHER; + } + + public static Object copy(Object value) { + return copy(value, Function.identity()); + } + + /** + * The adapter unwraps one section/node at a time into a shallow map/list or + * scalar. Its callback must not recursively serialize the entire input. + * Bounds and cycle detection apply before each unwrap and recursive read. + */ + public static Object copy(Object value, Function unwrap) { + return copy(value, Objects.requireNonNull(unwrap, "unwrap"), 0, new int[1], + new IdentityHashMap<>(), "$"); + } + + private static Object copy(Object source, Function unwrap, int depth, + int[] count, IdentityHashMap ancestors, String path) { + if (depth > MAX_DEPTH || ++count[0] > MAX_NODES) { + throw new IllegalArgumentException("Configuration structure exceeds limits at " + path); + } + if (source == null) return null; + if (ancestors.put(source, Boolean.TRUE) != null) { + throw new IllegalArgumentException("Cyclic configuration structure at " + path); + } + try { + Object value = unwrap.apply(source); + if (value == null || scalar(value)) return value; + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object rawKey = entry.getKey(); + if (rawKey == null || !scalar(rawKey)) { + throw new IllegalArgumentException("Unsupported configuration key at " + path); + } + String key = rawKey.toString(); + if (result.containsKey(key)) { + throw new IllegalArgumentException("Ambiguous configuration key at " + path + "." + key); + } + result.put(key, copy(entry.getValue(), unwrap, depth + 1, count, ancestors, path + "." + key)); + } + return Collections.unmodifiableMap(result); + } + if (value instanceof List list) { + List result = new ArrayList<>(); + for (Object item : list) { + result.add(copy(item, unwrap, depth + 1, count, ancestors, path + "[" + result.size() + "]")); + } + return Collections.unmodifiableList(result); + } + throw new IllegalArgumentException("Unsupported configuration value at " + path + ": " + + value.getClass().getName()); + } finally { + ancestors.remove(source); + } + } + + private static boolean scalar(Object value) { + return value instanceof String || value instanceof Boolean || value instanceof Character + || supportedNumber(value); + } + + private static boolean supportedNumber(Object value) { + return value instanceof Byte || value instanceof Short || value instanceof Integer + || value instanceof Long || value instanceof Float || value instanceof Double + || value instanceof BigInteger || value instanceof BigDecimal; + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/StructuredConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/StructuredConfigView.java new file mode 100644 index 0000000..a9b930f --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/core/config/StructuredConfigView.java @@ -0,0 +1,52 @@ +package com.bencodez.simpleapi.core.config; + +import java.util.Map; + +import com.bencodez.simpleapi.file.config.ConfigView; + +/** + * Additive reward-definition reads. Existing ConfigView implementations do not + * need to implement this interface. Views retain their backing API's getter and + * default rules; a view is not a promise of immutability or thread safety. + */ +public interface StructuredConfigView extends ConfigView { + /** MAP is a raw map value, not a native configuration SECTION. */ + enum Kind { MISSING, STRING, BOOLEAN, NUMBER, LIST, MAP, SECTION, OTHER } + + Kind kind(String path); + Kind kindAt(String... keys); + + /** + * Returns a detached, unmodifiable plain-data tree, or null when absent. + * Native objects are rejected, never serialized or converted to strings. + */ + Object value(String path); + Object valueAt(String... keys); + + /** Section lookup using literal keys, including keys containing dots. */ + StructuredConfigView at(String... keys); + + /** + * Literal-key structured traversal. Unlike {@link #at(String...)}, adapters + * may return a view for a plain mapping as well as a native section, without + * exporting the whole intermediate value. + */ + default StructuredConfigView structuredAt(String... keys) { return at(keys); } + + @Override + StructuredConfigView getConfigurationSection(String path); + + /** + * Exports the section's enumerated values. Defaults not exposed by the + * backing API's key enumeration are not implicitly materialized. Individual + * getter/value reads still follow that API's configured default behavior. + */ + @SuppressWarnings("unchecked") + default Map snapshotValues() { + Object result = valueAt(); + if (!(result instanceof Map)) { + throw new IllegalStateException("A section snapshot must be a mapping"); + } + return (Map) result; + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/PlainConfigValuesTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/PlainConfigValuesTest.java new file mode 100644 index 0000000..1287070 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/PlainConfigValuesTest.java @@ -0,0 +1,67 @@ +package com.bencodez.simpleapi.tests.shared; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.core.config.PlainConfigValues; +import com.bencodez.simpleapi.core.config.StructuredConfigView; + +class PlainConfigValuesTest { + @Test void copiesNestedPlainValuesAndNullListEntries() { + var values = new ArrayList<>(Arrays.asList("text", 1, 2L, false, null, Map.of("value", 3.5))); + Object copied = PlainConfigValues.copy(Map.of("values", values)); + values.clear(); + assertEquals(Map.of("values", Arrays.asList("text", 1, 2L, false, null, Map.of("value", 3.5))), copied); + } + + @Test void rejectsCyclesButPermitsSharedSubtrees() { + var cyclic = new LinkedHashMap(); cyclic.put("self", cyclic); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(cyclic)); + var shared = Map.of("command", "say voted"); + assertEquals(List.of(shared, shared), PlainConfigValues.copy(List.of(shared, shared))); + } + + @Test void limitsDepthAndNodeCount() { + Object deep = "leaf"; + for (int i = 0; i < 70; i++) deep = Map.of("child", deep); + Object finalDeep = deep; + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(finalDeep)); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(Collections.nCopies(100_001, 1))); + } + + @Test void rejectsAmbiguousKeysAndMutableNumberObjects() { + var keys = new LinkedHashMap(); keys.put(1, "one"); keys.put("1", "other"); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(keys)); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(new AtomicInteger(1))); + assertEquals(StructuredConfigView.Kind.OTHER, PlainConfigValues.kind(new AtomicInteger(1))); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(Map.of(new Object(), 1))); + } + + @Test void classifiesCopyableCharactersAsStringScalars() { + assertEquals(StructuredConfigView.Kind.STRING, PlainConfigValues.kind('x')); + assertEquals('x', PlainConfigValues.copy('x')); + } + + @Test void unwrapsAdapterNodesUnderTheSameCycleAndDepthChecks() { + var root = new Node(); root.value = Map.of("nested", new Node(List.of(1, 2))); + assertEquals(Map.of("nested", List.of(1, 2)), PlainConfigValues.copy(root, PlainConfigValuesTest::unwrap)); + root.value = Map.of("cycle", root); + assertThrows(IllegalArgumentException.class, () -> PlainConfigValues.copy(root, PlainConfigValuesTest::unwrap)); + } + + private static Object unwrap(Object value) { return value instanceof Node node ? node.value : value; } + private static final class Node { + private Object value; + private Node() { } + private Node(Object value) { this.value = value; } + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/StructuredConfigViewTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/StructuredConfigViewTest.java new file mode 100644 index 0000000..4f2ab5e --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/tests/shared/StructuredConfigViewTest.java @@ -0,0 +1,282 @@ +package com.bencodez.simpleapi.tests.shared; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.BasicConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.bukkit.config.BukkitStructuredConfigView; +import com.bencodez.simpleapi.core.config.AnnotationBinder; +import com.bencodez.simpleapi.core.config.CaseInsensitiveConfigView; +import com.bencodez.simpleapi.core.config.ConfigurateStructuredConfigView; +import com.bencodez.simpleapi.core.config.StructuredConfigView; +import com.bencodez.simpleapi.core.config.StructuredConfigView.Kind; +import com.bencodez.simpleapi.file.CaseInsensitiveSection; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataListString; + +class StructuredConfigViewTest { + @Test void distinguishesRewardReferencesListsAndInlineSectionsWithoutCoercion() throws Exception { + var yaml = YamlConfigurationLoader.builder().buildAndLoadString(""" + single: reward-one + multiple: [reward-one, reward-two] + empty: [] + inline: + Commands: [say voted] + flag: false + amount: 0 + quoted: '12' + """); + var bukkit = new MemoryConfiguration(); + bukkit.set("single", "reward-one"); + bukkit.set("multiple", List.of("reward-one", "reward-two")); + bukkit.set("empty", List.of()); + bukkit.createSection("inline").set("Commands", List.of("say voted")); + bukkit.set("flag", false); bukkit.set("amount", 0); bukkit.set("quoted", "12"); + for (StructuredConfigView view : List.of(new ConfigurateStructuredConfigView(yaml), new BukkitStructuredConfigView(bukkit))) { + assertEquals(Kind.MISSING, view.kind("missing")); + assertEquals(Kind.STRING, view.kind("single")); + assertEquals(Kind.LIST, view.kind("multiple")); + assertEquals(Kind.LIST, view.kind("empty")); + assertEquals(Kind.SECTION, view.kind("inline")); + assertEquals(Kind.BOOLEAN, view.kind("flag")); + assertEquals(Kind.NUMBER, view.kind("amount")); + assertEquals(Kind.STRING, view.kind("quoted")); + assertEquals(99, view.getInt("quoted", 99)); + assertEquals(List.of(), view.value("empty")); + assertNull(view.value("missing")); + assertNull(view.at("single")); + assertEquals(Map.of("Commands", List.of("say voted")), view.value("inline")); + assertEquals(Kind.SECTION, view.kindAt()); + assertEquals(view.snapshotValues(), view.valueAt()); + } + } + + @Test void caseInsensitiveReadsMatchTheExistingBukkitWrapperIncludingCollisions() { + var nativeConfig = new MemoryConfiguration(); + nativeConfig.set("Commands.Console", List.of("say first")); + nativeConfig.set("COMMANDS.Console", List.of("say second")); + nativeConfig.set("Requirements.Enabled", false); + nativeConfig.set("Requirements.Amount", 3); + var legacy = new CaseInsensitiveSection(nativeConfig); + var view = new CaseInsensitiveConfigView(new BukkitStructuredConfigView(nativeConfig)); + for (String path : List.of("commands.console", "COMMANDS.Console", "Commands.Console.", "missing")) { + assertEquals(legacy.getStringList(path), view.getStringList(path)); + assertEquals(legacy.contains(path), view.contains(path)); + } + assertEquals(List.of("say first"), view.getStringList("COMMANDS.Console")); + assertEquals(legacy.getBoolean("requirements.enabled", true), view.getBoolean("requirements.enabled", true)); + assertEquals(legacy.getInt("requirements.amount", 9), view.getInt("requirements.amount", 9)); + assertEquals(legacy.getLong("requirements.amount", 9L), view.getLong("requirements.amount", 9L)); + assertEquals(legacy.getDouble("requirements.amount", 9.0), view.getDouble("requirements.amount", 9.0)); + assertEquals(legacy.getKeys(true), view.getKeys(true)); + assertEquals(Kind.LIST, view.kind("commands.console")); + assertEquals(List.of("say first"), view.value("commands.console")); + assertEquals(Set.of("Console"), view.at("COMMANDS").getKeys(false)); + } + + @Test void caseInsensitiveReadsTraverseRawBukkitMaps() { + var nativeConfig = new MemoryConfiguration(); + Map rawReward = new LinkedHashMap<>(); + rawReward.put("Commands", List.of("say voted")); + rawReward.put("Amount", 3); + rawReward.put("NativeSibling", new Object()); + var nestedSection = new MemoryConfiguration(); + nestedSection.set("Value", "native"); + rawReward.put("NativeSection", nestedSection); + nativeConfig.set("Rewards", rawReward); + var view = new CaseInsensitiveConfigView(new BukkitStructuredConfigView(nativeConfig)); + + assertEquals(Kind.MAP, view.kind("rewards")); + assertEquals(Kind.LIST, view.kind("REWARDS.commands")); + assertEquals(List.of("say voted"), view.getStringList("rewards.COMMANDS")); + assertEquals(3, view.getInt("Rewards.amount", -1)); + assertEquals(Set.of("Commands", "Amount", "NativeSibling", "NativeSection"), + view.structuredAt("REWARDS").getKeys(false)); + assertEquals(List.of("say voted"), view.structuredAt("rewards").getStringList("commands")); + StructuredConfigView rawView = new BukkitStructuredConfigView(nativeConfig).structuredAt("Rewards"); + assertEquals("native", rawView.getString("NativeSection.Value", "missing")); + assertEquals(Map.of("Value", "native"), rawView.valueAt("NativeSection")); + assertTrue(rawView.getKeys(true).contains("NativeSection.Value")); + assertNull(view.at("rewards")); + assertNull(view.getConfigurationSection("rewards")); + assertEquals(Kind.SECTION, view.kind("rewards.nativesection")); + assertEquals("native", view.getString("rewards.nativesection.value", "missing")); + assertNotNull(view.getConfigurationSection("rewards.nativesection")); + } + + @Test void caseInsensitiveRawMapTraversalUsesScalarKeyStringForms() { + var nativeConfig = new MemoryConfiguration(); + nativeConfig.set("Rewards", Map.of(10, Map.of("Commands", List.of("say ten")))); + var view = new CaseInsensitiveConfigView(new BukkitStructuredConfigView(nativeConfig)); + var direct = new BukkitStructuredConfigView(nativeConfig); + + assertEquals(Kind.MAP, view.kind("rewards.10")); + assertEquals(List.of("say ten"), view.getStringList("REWARDS.10.commands")); + assertEquals(Kind.MAP, direct.kindAt("Rewards", "10")); + assertEquals(Map.of("Commands", List.of("say ten")), direct.valueAt("Rewards", "10")); + + Map ambiguous = new LinkedHashMap<>(); + ambiguous.put(10, Map.of("Commands", List.of("first"))); + ambiguous.put("10", Map.of("Commands", List.of("second"))); + nativeConfig.set("Ambiguous", ambiguous); + assertThrows(IllegalArgumentException.class, () -> view.kind("ambiguous.10")); + assertThrows(IllegalArgumentException.class, () -> direct.kindAt("Ambiguous", "10")); + } + + @Test void rawMapChildrenRemainUsableWhenTheBukkitSectionHasNoRoot() { + var detached = mock(org.bukkit.configuration.ConfigurationSection.class); + Map child = Map.of("Value", 7); + when(detached.getValues(false)).thenReturn(Map.of("Child", child)); + when(detached.getDefaultSection()).thenReturn(null); + when(detached.getRoot()).thenReturn(null); + + StructuredConfigView view = new BukkitStructuredConfigView(detached); + StructuredConfigView rawChild = view.structuredAt("Child"); + + assertNotNull(rawChild); + assertEquals(7, rawChild.getInt("Value", -1)); + assertEquals(Map.of("Value", 7), rawChild.valueAt()); + } + + @Test void deepRawMapKeyEnumerationRejectsCyclesAndExcessiveDepth() { + var nativeConfig = new MemoryConfiguration(); + Map cyclic = new LinkedHashMap<>(); + cyclic.put("self", cyclic); + nativeConfig.set("Cyclic", cyclic); + var view = new BukkitStructuredConfigView(nativeConfig); + assertThrows(IllegalArgumentException.class, () -> view.structuredAt("Cyclic").getKeys(true)); + + Map root = new LinkedHashMap<>(); + Map current = root; + for (int depth = 0; depth < 66; depth++) { + Map child = new LinkedHashMap<>(); + current.put("child", child); + current = child; + } + nativeConfig.set("Deep", root); + assertThrows(IllegalArgumentException.class, () -> view.structuredAt("Deep").getKeys(true)); + } + + @Test void rawMapViewsRemainLiveAfterCreation() { + var nativeConfig = new MemoryConfiguration(); + Map rewards = new LinkedHashMap<>(); + rewards.put("Commands", List.of("say first")); + nativeConfig.set("Rewards", rewards); + StructuredConfigView view = new BukkitStructuredConfigView(nativeConfig).structuredAt("Rewards"); + + rewards.remove("Commands"); + rewards.put("Points", 4); + + assertFalse(view.contains("Commands")); + assertNull(view.valueAt("Commands")); + assertEquals(4, view.getInt("Points", -1)); + assertEquals(Set.of("Points"), view.getKeys(false)); + } + + @Test void rawMapViewConstructionRejectsOversizedMapsBeforeCopyingThem() { + var nativeConfig = new MemoryConfiguration(); + Map oversized = new LinkedHashMap<>(); + for (int index = 0; index <= 100_000; index++) oversized.put("key" + index, index); + nativeConfig.set("Oversized", oversized); + + var view = new BukkitStructuredConfigView(nativeConfig); + assertThrows(IllegalArgumentException.class, () -> view.structuredAt("Oversized")); + } + + @Test void literalKeysWorkWithoutChangingPathSeparators() { + var node = BasicConfigurationNode.root(); + node.node("Sites", "example.site", "Commands").raw(List.of("say voted")); + var nativeConfig = new MemoryConfiguration(); + nativeConfig.options().pathSeparator('/'); + nativeConfig.createSection("Sites/example.site").set("Commands", List.of("say voted")); + for (StructuredConfigView view : List.of(new ConfigurateStructuredConfigView(node, '/'), new BukkitStructuredConfigView(nativeConfig))) { + assertEquals(List.of("say voted"), view.valueAt("Sites", "example.site", "Commands")); + assertEquals(Kind.LIST, view.kindAt("Sites", "example.site", "Commands")); + assertEquals(List.of("say voted"), view.at("Sites", "example.site").getStringList("Commands")); + var insensitive = new CaseInsensitiveConfigView(view, '/'); + assertEquals(List.of("say voted"), insensitive.valueAt("sites", "EXAMPLE.SITE", "commands")); + assertEquals(List.of("say voted"), insensitive.getStringList("sites/EXAMPLE.SITE/commands")); + } + assertEquals('/', nativeConfig.options().pathSeparator()); + } + + @SuppressWarnings("unchecked") + @Test void exportsAreDetachedUnmodifiableAndRetainOriginalKeyCasing() { + var nativeConfig = new MemoryConfiguration(); + var commands = new ArrayList<>(List.of("say original")); + nativeConfig.createSection("Rewards").set("Commands", commands); + var view = new CaseInsensitiveConfigView(new BukkitStructuredConfigView(nativeConfig)); + Map snapshot = view.snapshotValues(); + commands.add("say later"); + nativeConfig.set("Other", true); + var rewards = (Map) snapshot.get("Rewards"); + assertEquals(List.of("say original"), rewards.get("Commands")); + assertFalse(snapshot.containsKey("Other")); + assertThrows(UnsupportedOperationException.class, () -> snapshot.put("bad", true)); + assertThrows(UnsupportedOperationException.class, () -> ((List) rewards.get("Commands")).add("bad")); + } + + @Test void defaultsAndMixedListsKeepBackingGetterSemantics() { + var defaults = new MemoryConfiguration(); defaults.set("Amount", 12); + var nativeConfig = new MemoryConfiguration(defaults); + nativeConfig.set("Values", Arrays.asList("1", 2, 3.5, true, "bad")); + var view = new BukkitStructuredConfigView(nativeConfig); + assertEquals(nativeConfig.getInt("Amount", -1), view.getInt("Amount", -1)); + assertEquals(Kind.NUMBER, view.kind("Amount")); + assertEquals(nativeConfig.get("Amount"), view.valueAt("Amount")); + assertEquals(nativeConfig.getIntegerList("Values"), view.getIntegerList("Values")); + assertEquals(nativeConfig.getStringList("Values"), view.getStringList("Values")); + assertEquals(nativeConfig.getKeys(false), view.snapshotValues().keySet()); + var oldCase = new CaseInsensitiveSection(nativeConfig); + var newCase = new CaseInsensitiveConfigView(view); + assertEquals(oldCase.getInt("amount", -1), newCase.getInt("amount", -1)); + } + + @Test void rejectsNativeObjectsInsteadOfSilentlySerializingThem() { + var nativeConfig = new MemoryConfiguration(); + Object opaque = new Object(); nativeConfig.set("Rewards.Native", opaque); + var view = new BukkitStructuredConfigView(nativeConfig); + assertEquals(Kind.OTHER, view.kind("Rewards.Native")); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, view::snapshotValues); + assertTrue(failure.getMessage().contains("Native")); + assertSame(opaque, nativeConfig.get("Rewards.Native")); + } + + @Test void configurateSnapshotsPreserveNumericKeysAndRejectAmbiguousKeys() throws Exception { + var node = YamlConfigurationLoader.builder().buildAndLoadString("rewards:\n 10:\n Commands: [say ten]\n"); + var view = new ConfigurateStructuredConfigView(node); + assertEquals(List.of("say ten"), view.valueAt("rewards", "10", "Commands")); + assertEquals(Set.of("10"), view.at("rewards").snapshotValues().keySet()); + var ambiguous = new LinkedHashMap(); + ambiguous.put(10, "number"); ambiguous.put("10", "text"); + node.node("bad").raw(ambiguous); + assertThrows(IllegalArgumentException.class, view::snapshotValues); + } + + static class RewardOptions { + @ConfigDataInt(path = "Points") int points = 7; + @ConfigDataListString(path = "Commands") ArrayList commands = new ArrayList<>(List.of("fallback")); + } + + @Test void sharedBinderUsesTheNewViewWithoutNewAnnotationRules() throws Exception { + var node = YamlConfigurationLoader.builder().buildAndLoadString("points: 0\ncommands: [say voted]\n"); + var view = new CaseInsensitiveConfigView(new ConfigurateStructuredConfigView(node)); + var target = new RewardOptions(); + new AnnotationBinder().load(view, target); + assertEquals(0, target.points); + assertEquals(List.of("say voted"), target.commands); + assertEquals(Map.of("points", 0, "commands", List.of("say voted")), view.snapshotValues()); + } +} diff --git a/docs/structured-reward-configuration.md b/docs/structured-reward-configuration.md new file mode 100644 index 0000000..c2f54d4 --- /dev/null +++ b/docs/structured-reward-configuration.md @@ -0,0 +1,79 @@ +# Structured reward configuration + +This is an additive configuration boundary for the AdvancedCore extraction. It +uses the existing single SimpleAPI project; no modules or workflows are added. +Existing `ConfigView`, annotation binding, file persistence, Bukkit wrappers and +SQL APIs are unchanged. + +## Choose the adapter + +- Bukkit: `bukkit.config.BukkitStructuredConfigView(ConfigurationSection)`. +- Configurate: `core.config.ConfigurateStructuredConfigView(ConfigurationNode)`. +- Existing Configurate document view: construct the structured adapter from the + existing `core.config.ConfigurateConfigView` instance. The read view shares the + same backing node; it does not create a second parser or mutable document. +- For legacy reward-key matching, wrap either adapter in + `core.config.CaseInsensitiveConfigView`. + +The new `core.config.StructuredConfigView` extends the existing `ConfigView`, so +existing third-party ConfigView implementations acquire no new abstract methods. +The existing binder accepts the new views without a new overload. + +## Reward shape and values + +`kind(path)` distinguishes missing values, strings, numbers, booleans, lists, +native sections, raw maps, and other objects. In particular, an explicitly empty +list remains LIST rather than becoming indistinguishable from a missing reward. +A raw Bukkit map is MAP, not SECTION: do not accidentally change the old +`isConfigurationSection` branch when migrating reward execution. + +`value(path)` and `snapshotValues()` return detached, recursively unmodifiable +plain-data trees. Native objects such as ItemStack are rejected with a location +rather than silently dropped, serialized, or changed into text. The platform's +item adapter must handle such values explicitly. Copying is bounded to 64 levels +and 100,000 visited nodes, rejects cycles and ambiguous stringified map keys, and +preserves ordering, scalar types, null list entries and original key casing. + +`kindAt`, `valueAt`, and `at` accept literal segments. For example: + +```java +StructuredConfigView rewards = new CaseInsensitiveConfigView( + new BukkitStructuredConfigView(configuration)); +Object commands = rewards.valueAt("VoteSites", "example.site", "Commands"); +Map detached = rewards.snapshotValues(); +``` + +A path string uses the backing API's separator. Literal segments never change the +root's path separator. With a non-default separator, pass that same separator to +the case-insensitive wrapper. It resolves one native section at a time. + +## Compatibility boundaries + +Ordinary typed getters delegate to the existing adapters. The new wrapper follows +the old CaseInsensitiveSection's key-enumeration order: the first matching key +wins even when a later key matches the requested spelling exactly. It retains +original key casing and does not mutate the original section. + +Defaults follow the backing getter/key-enumeration rules. A snapshot exports +what that section enumerates; it does not materialize unenumerated Bukkit default +keys. Case-insensitive lookup follows the same enumeration limitation as the old +wrapper. This is deliberate and covered by comparison tests. Configurate does +not gain a Bukkit mutable-default-tree implementation. + +These are local configuration reads, not a hostile-YAML upload interface. Keep +configuration thread ownership in the caller. The existing document validation, +revision and persistence rules are not changed. + +## Validation + +Run the unchanged build: + +```sh +mvn -B -f SimpleAPI/pom.xml clean package +``` + +The new structured-view and plain-copy tests cover reward shapes, empty/missing +values, legacy case collisions, literal keys, defaults, native object rejection, +copy isolation, cycles, bounds, numeric keys and annotation binding. The existing +package-phase shared-artifact test also scans and links the new core classes from +the shared classifier without Bukkit. No new publication or CI pipeline is needed.