diff --git a/buildSrc/src/main/kotlin/de.siphalor.tweed5.base-module.gradle.kts b/buildSrc/src/main/kotlin/de.siphalor.tweed5.base-module.gradle.kts
index 01ede73..44d40bc 100644
--- a/buildSrc/src/main/kotlin/de.siphalor.tweed5.base-module.gradle.kts
+++ b/buildSrc/src/main/kotlin/de.siphalor.tweed5.base-module.gradle.kts
@@ -11,8 +11,8 @@ group = rootProject.group
version = rootProject.version
java {
- sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get())
- targetCompatibility = JavaVersion.toVersion(libs.versions.java.get())
+ sourceCompatibility = JavaVersion.toVersion(libs.versions.java.main.get())
+ targetCompatibility = JavaVersion.toVersion(libs.versions.java.main.get())
}
repositories {
@@ -52,6 +52,10 @@ dependencies {
testImplementation(libs.assertj)
}
+tasks.compileTestJava {
+ sourceCompatibility = libs.versions.java.test.get()
+ targetCompatibility = libs.versions.java.test.get()
+}
tasks.test {
dependsOn(testAgentClasspath)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b3b417d..94671bc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,7 +2,8 @@
assertj = "3.26.3"
asm = "9.7"
autoservice = "1.1.1"
-java = "8"
+java-main = "8"
+java-test = "21"
jetbrains-annotations = "26.0.1"
jspecify = "1.0.0"
junit = "5.12.0"
diff --git a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/container/ConfigContainer.java b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/container/ConfigContainer.java
index 8acb90c..ebca342 100644
--- a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/container/ConfigContainer.java
+++ b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/container/ConfigContainer.java
@@ -9,6 +9,7 @@ import org.jspecify.annotations.Nullable;
import java.util.Collection;
import java.util.Map;
+import java.util.Optional;
/**
* The main wrapper for a config tree.
@@ -20,16 +21,14 @@ public interface ConfigContainer {
@SuppressWarnings("rawtypes")
TweedConstructFactory FACTORY = TweedConstructFactory.builder(ConfigContainer.class).build();
- default void registerExtensions(TweedExtension... extensions) {
- for (TweedExtension extension : extensions) {
- registerExtension(extension);
+ default void registerExtensions(Class extends TweedExtension>... extensionClasses) {
+ for (Class extends TweedExtension> extensionClass : extensionClasses) {
+ registerExtension(extensionClass);
}
}
+ void registerExtension(Class extends TweedExtension> extensionClass);
- void registerExtension(TweedExtension extension);
-
- @Nullable
- E extension(Class extensionClass);
+ Optional extension(Class extensionClass);
Collection extensions();
void finishExtensionSetup();
diff --git a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtension.java b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtension.java
index 9466143..256e9a6 100644
--- a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtension.java
+++ b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtension.java
@@ -1,11 +1,18 @@
package de.siphalor.tweed5.core.api.extension;
+import de.siphalor.tweed5.construct.api.TweedConstructFactory;
+import de.siphalor.tweed5.core.api.container.ConfigContainer;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
public interface TweedExtension {
+ TweedConstructFactory FACTORY = TweedConstructFactory.builder(TweedExtension.class)
+ .typedArg(ConfigContainer.class)
+ .typedArg(TweedExtensionSetupContext.class)
+ .build();
+
String getId();
- default void setup(TweedExtensionSetupContext context) {
+ default void extensionsFinalized() {
}
default void initEntry(ConfigEntry> configEntry) {
diff --git a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtensionSetupContext.java b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtensionSetupContext.java
index 55b75d8..ac73be1 100644
--- a/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtensionSetupContext.java
+++ b/tweed5-core/src/main/java/de/siphalor/tweed5/core/api/extension/TweedExtensionSetupContext.java
@@ -1,9 +1,6 @@
package de.siphalor.tweed5.core.api.extension;
-import de.siphalor.tweed5.core.api.container.ConfigContainer;
-
public interface TweedExtensionSetupContext {
- ConfigContainer> configContainer();
RegisteredExtensionData registerEntryExtensionData(Class dataClass);
- void registerExtension(TweedExtension extension);
+ void registerExtension(Class extends TweedExtension> extensionClass);
}
diff --git a/tweed5-core/src/main/java/de/siphalor/tweed5/core/impl/DefaultConfigContainer.java b/tweed5-core/src/main/java/de/siphalor/tweed5/core/impl/DefaultConfigContainer.java
index 93e0f7a..23f493c 100644
--- a/tweed5-core/src/main/java/de/siphalor/tweed5/core/impl/DefaultConfigContainer.java
+++ b/tweed5-core/src/main/java/de/siphalor/tweed5/core/impl/DefaultConfigContainer.java
@@ -3,7 +3,10 @@ package de.siphalor.tweed5.core.impl;
import de.siphalor.tweed5.core.api.container.ConfigContainer;
import de.siphalor.tweed5.core.api.container.ConfigContainerSetupPhase;
import de.siphalor.tweed5.core.api.entry.ConfigEntry;
-import de.siphalor.tweed5.core.api.extension.*;
+import de.siphalor.tweed5.core.api.extension.EntryExtensionsData;
+import de.siphalor.tweed5.core.api.extension.RegisteredExtensionData;
+import de.siphalor.tweed5.core.api.extension.TweedExtension;
+import de.siphalor.tweed5.core.api.extension.TweedExtensionSetupContext;
import de.siphalor.tweed5.patchwork.api.Patchwork;
import de.siphalor.tweed5.patchwork.api.PatchworkClassCreator;
import de.siphalor.tweed5.patchwork.impl.PatchworkClass;
@@ -12,56 +15,34 @@ import de.siphalor.tweed5.patchwork.impl.PatchworkClassPart;
import de.siphalor.tweed5.utils.api.collection.InheritanceMap;
import lombok.Getter;
import lombok.Setter;
-import org.jspecify.annotations.NullUnmarked;
import org.jspecify.annotations.Nullable;
import java.lang.invoke.MethodHandle;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
import java.util.*;
-@NullUnmarked
public class DefaultConfigContainer implements ConfigContainer {
@Getter
private ConfigContainerSetupPhase setupPhase = ConfigContainerSetupPhase.EXTENSIONS_SETUP;
+ private final Set> requestedExtensions = new HashSet<>();
private final InheritanceMap extensions = new InheritanceMap<>(TweedExtension.class);
- private ConfigEntry rootEntry;
- private PatchworkClass entryExtensionsDataPatchworkClass;
- private Map, RegisteredExtensionDataImpl> registeredEntryDataExtensions;
+ private @Nullable ConfigEntry rootEntry;
+ private @Nullable PatchworkClass entryExtensionsDataPatchworkClass;
+ private final Map, RegisteredExtensionDataImpl> registeredEntryDataExtensions
+ = new HashMap<>();
@Override
- public @Nullable E extension(Class extensionClass) {
- try {
- return extensions.getSingleInstance(extensionClass);
- } catch (InheritanceMap.NonUniqueResultException e) {
- return null;
- }
- }
-
- @Override
- public Collection extensions() {
- return Collections.unmodifiableCollection(extensions.values());
- }
-
- @Override
- public void registerExtension(TweedExtension extension) {
+ public void registerExtension(Class extends TweedExtension> extensionClass) {
requireSetupPhase(ConfigContainerSetupPhase.EXTENSIONS_SETUP);
-
- if (!extensions.putIfAbsent(extension)) {
- throw new IllegalArgumentException("Extension " + extension.getClass().getName() + " is already registered");
- }
+ requestedExtensions.add(extensionClass);
}
@Override
public void finishExtensionSetup() {
requireSetupPhase(ConfigContainerSetupPhase.EXTENSIONS_SETUP);
- registeredEntryDataExtensions = new HashMap<>();
- Collection additionalExtensions = new ArrayList<>();
TweedExtensionSetupContext extensionSetupContext = new TweedExtensionSetupContext() {
- @Override
- public ConfigContainer configContainer() {
- return DefaultConfigContainer.this;
- }
-
@Override
public RegisteredExtensionData registerEntryExtensionData(Class dataClass) {
if (registeredEntryDataExtensions.containsKey(dataClass)) {
@@ -73,24 +54,31 @@ public class DefaultConfigContainer implements ConfigContainer {
}
@Override
- public void registerExtension(TweedExtension extension) {
- if (!extensions.containsAnyInstanceForClass(extension.getClass())) {
- additionalExtensions.add(extension);
+ public void registerExtension(Class extends TweedExtension> extensionClass) {
+ if (!extensions.containsAnyInstanceForClass(extensionClass)) {
+ requestedExtensions.add(extensionClass);
}
}
};
- Collection extensionsToSetup = extensions.values();
- while (!extensionsToSetup.isEmpty()) {
- for (TweedExtension extension : extensionsToSetup) {
- extension.setup(extensionSetupContext);
- }
+ Set> abstractExtensionClasses = new HashSet<>();
- for (TweedExtension additionalExtension : additionalExtensions) {
- extensions.putIfAbsent(additionalExtension);
+ while (true) {
+ if (!requestedExtensions.isEmpty()) {
+ Class extends TweedExtension> extensionClass = popFromIterable(requestedExtensions);
+ if (isAbstractClass(extensionClass)) {
+ abstractExtensionClasses.add(extensionClass);
+ } else {
+ extensions.put(instantiateExtension(extensionClass, extensionSetupContext));
+ }
+ } else if (!abstractExtensionClasses.isEmpty()) {
+ Class extends TweedExtension> extensionClass = popFromIterable(abstractExtensionClasses);
+ if (!extensions.containsAnyInstanceForClass(extensionClass)) {
+ extensions.put(instantiateAbstractExtension(extensionClass, extensionSetupContext));
+ }
+ } else {
+ break;
}
- extensionsToSetup = new ArrayList<>(additionalExtensions);
- additionalExtensions.clear();
}
PatchworkClassCreator entryExtensionsDataGenerator = PatchworkClassCreator.builder()
@@ -109,16 +97,135 @@ public class DefaultConfigContainer implements ConfigContainer {
}
setupPhase = ConfigContainerSetupPhase.TREE_SETUP;
+
+ extensions.values().forEach(TweedExtension::extensionsFinalized);
+ }
+
+ private static T popFromIterable(Iterable iterable) {
+ Iterator iterator = iterable.iterator();
+ T value = iterator.next();
+ iterator.remove();
+ return value;
+ }
+
+ protected E instantiateAbstractExtension(
+ Class extensionClass,
+ TweedExtensionSetupContext setupContext
+ ) {
+ try {
+ Field defaultField = extensionClass.getDeclaredField("DEFAULT");
+ if (defaultField.getType() != Class.class) {
+ throw new IllegalStateException(createAbstractExtensionInstantiationExceptionMessage(
+ extensionClass,
+ "DEFAULT field has incorrect class " + defaultField.getType().getName() + "."
+ ));
+ }
+ if ((defaultField.getModifiers() & Modifier.STATIC) == 0) {
+ throw new IllegalStateException(createAbstractExtensionInstantiationExceptionMessage(
+ extensionClass,
+ "DEFAULT field is not static."
+ ));
+ }
+ if ((defaultField.getModifiers() & Modifier.PUBLIC) == 0) {
+ throw new IllegalStateException(createAbstractExtensionInstantiationExceptionMessage(
+ extensionClass,
+ "DEFAULT field is not public."
+ ));
+ }
+ Class> defaultClass = (Class>) defaultField.get(null);
+ if (!extensionClass.isAssignableFrom(defaultClass)) {
+ throw new IllegalStateException(createAbstractExtensionInstantiationExceptionMessage(
+ extensionClass,
+ "DEFAULT field contains class " + defaultClass.getName() + ", but that class "
+ + "does not inherit from " + extensionClass.getName()
+ ));
+ }
+ //noinspection unchecked
+ return instantiateExtension((Class extends E>) defaultClass, setupContext);
+ } catch (NoSuchFieldException ignored) {
+ throw new IllegalStateException(createAbstractExtensionInstantiationExceptionMessage(extensionClass, null));
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException(
+ createAbstractExtensionInstantiationExceptionMessage(
+ extensionClass,
+ "Couldn't access DEFAULT field."
+ ),
+ e
+ );
+ }
+ }
+
+ private String createAbstractExtensionInstantiationExceptionMessage(
+ Class> extensionClass,
+ @Nullable String detail
+ ) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Requested extension class ").append(extensionClass.getName()).append(" is ");
+ if (extensionClass.isInterface()) {
+ sb.append("an interface ");
+ } else {
+ sb.append("an abstract class ");
+ }
+ sb.append("and cannot be instantiated directly.\n");
+ sb.append("As the extension developer you can declare a public static DEFAULT field containing the class of ");
+ sb.append("a default implementation.\n");
+ sb.append("As a user you can try registering an implementation of the extension class directly.");
+ if (detail != null) {
+ sb.append("\n");
+ sb.append(detail);
+ }
+ return sb.toString();
+ }
+
+ protected E instantiateExtension(
+ Class extensionClass,
+ TweedExtensionSetupContext setupContext
+ ) {
+ if (isAbstractClass(extensionClass)) {
+ throw new IllegalStateException(
+ "Cannot instantiate extension class " + extensionClass.getName() + " as it is abstract"
+ );
+ }
+ return TweedExtension.FACTORY.construct(extensionClass)
+ .typedArg(TweedExtensionSetupContext.class, setupContext)
+ .typedArg(ConfigContainer.class, this)
+ .finish();
+ }
+
+ private boolean isAbstractClass(Class> clazz) {
+ return clazz.isInterface() || (clazz.getModifiers() & Modifier.ABSTRACT) != 0;
+ }
+
+ @Override
+ public Optional extension(Class extensionClass) {
+ requireSetupPhase(
+ ConfigContainerSetupPhase.TREE_SETUP,
+ ConfigContainerSetupPhase.TREE_SEALED,
+ ConfigContainerSetupPhase.READY
+ );
+ try {
+ return Optional.ofNullable(extensions.getSingleInstance(extensionClass));
+ } catch (InheritanceMap.NonUniqueResultException e) {
+ throw new IllegalStateException("Multiple extensions registered for class " + extensionClass.getName(), e);
+ }
+ }
+
+ @Override
+ public Collection extensions() {
+ requireSetupPhase(
+ ConfigContainerSetupPhase.TREE_SETUP,
+ ConfigContainerSetupPhase.TREE_SEALED,
+ ConfigContainerSetupPhase.READY
+ );
+ return Collections.unmodifiableCollection(extensions.values());
}
@Override
public void attachAndSealTree(ConfigEntry rootEntry) {
requireSetupPhase(ConfigContainerSetupPhase.TREE_SETUP);
- this.rootEntry = rootEntry;
- finishEntrySetup();
- }
- private void finishEntrySetup() {
+ this.rootEntry = rootEntry;
+
rootEntry.visitInOrder(entry -> {
if (!entry.sealed()) {
entry.seal(DefaultConfigContainer.this);
@@ -133,6 +240,7 @@ public class DefaultConfigContainer implements ConfigContainer {
requireSetupPhase(ConfigContainerSetupPhase.TREE_SETUP);
try {
+ assert entryExtensionsDataPatchworkClass != null;
return (EntryExtensionsData) entryExtensionsDataPatchworkClass.constructor().invoke();
} catch (Throwable e) {
throw new IllegalStateException("Failed to construct patchwork class for entry extensions' data", e);
@@ -141,7 +249,11 @@ public class DefaultConfigContainer implements ConfigContainer {
@Override
public Map, ? extends RegisteredExtensionData> entryDataExtensions() {
- requireSetupPhase(ConfigContainerSetupPhase.TREE_SEALED);
+ requireSetupPhase(
+ ConfigContainerSetupPhase.TREE_SETUP,
+ ConfigContainerSetupPhase.TREE_SEALED,
+ ConfigContainerSetupPhase.READY
+ );
return registeredEntryDataExtensions;
}
@@ -149,6 +261,7 @@ public class DefaultConfigContainer implements ConfigContainer {
public void initialize() {
requireSetupPhase(ConfigContainerSetupPhase.TREE_SEALED);
+ assert rootEntry != null;
rootEntry.visitInOrder(entry -> {
for (TweedExtension extension : extensions()) {
extension.initEntry(entry);
@@ -160,12 +273,32 @@ public class DefaultConfigContainer implements ConfigContainer {
@Override
public ConfigEntry rootEntry() {
+ requireSetupPhase(ConfigContainerSetupPhase.TREE_SEALED, ConfigContainerSetupPhase.READY);
+
+ assert rootEntry != null;
return rootEntry;
}
- private void requireSetupPhase(ConfigContainerSetupPhase required) {
- if (setupPhase != required) {
- throw new IllegalStateException("Config container is not in correct stage, expected " + required + ", but is in " + setupPhase);
+ private void requireSetupPhase(ConfigContainerSetupPhase... allowedPhases) {
+ for (ConfigContainerSetupPhase allowedPhase : allowedPhases) {
+ if (allowedPhase == setupPhase) {
+ return;
+ }
+ }
+ if (allowedPhases.length == 1) {
+ throw new IllegalStateException(
+ "Config container is not in correct phase, expected "
+ + allowedPhases[0]
+ + ", but is in "
+ + setupPhase
+ );
+ } else {
+ throw new IllegalStateException(
+ "Config container is not in correct phase, expected any of "
+ + Arrays.toString(allowedPhases)
+ + ", but is in "
+ + setupPhase
+ );
}
}
diff --git a/tweed5-core/src/test/java/de/siphalor/tweed5/core/impl/DefaultConfigContainerTest.java b/tweed5-core/src/test/java/de/siphalor/tweed5/core/impl/DefaultConfigContainerTest.java
new file mode 100644
index 0000000..1a8bb8c
--- /dev/null
+++ b/tweed5-core/src/test/java/de/siphalor/tweed5/core/impl/DefaultConfigContainerTest.java
@@ -0,0 +1,247 @@
+package de.siphalor.tweed5.core.impl;
+
+import de.siphalor.tweed5.core.api.container.ConfigContainerSetupPhase;
+import de.siphalor.tweed5.core.api.entry.ConfigEntry;
+import de.siphalor.tweed5.core.api.extension.EntryExtensionsData;
+import de.siphalor.tweed5.core.api.extension.RegisteredExtensionData;
+import de.siphalor.tweed5.core.api.extension.TweedExtension;
+import de.siphalor.tweed5.core.api.extension.TweedExtensionSetupContext;
+import de.siphalor.tweed5.core.impl.entry.SimpleConfigEntryImpl;
+import de.siphalor.tweed5.core.impl.entry.StaticMapCompoundConfigEntryImpl;
+import lombok.Getter;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DefaultConfigContainerTest {
+
+ @Test
+ void extensionSetup() {
+ var configContainer = new DefaultConfigContainer<>();
+ assertThat(configContainer.setupPhase()).isEqualTo(ConfigContainerSetupPhase.EXTENSIONS_SETUP);
+
+ configContainer.registerExtension(ExtensionA.class);
+ configContainer.finishExtensionSetup();
+
+ assertThat(configContainer.setupPhase()).isEqualTo(ConfigContainerSetupPhase.TREE_SETUP);
+ assertThat(configContainer.extensions())
+ .satisfiesExactlyInAnyOrder(
+ extension -> assertThat(extension).isInstanceOf(ExtensionA.class),
+ extension -> assertThat(extension).isInstanceOf(ExtensionBImpl.class)
+ );
+ }
+
+ @Test
+ void extensionSetupDefaultOverride() {
+ var configContainer = new DefaultConfigContainer<>();
+ assertThat(configContainer.setupPhase()).isEqualTo(ConfigContainerSetupPhase.EXTENSIONS_SETUP);
+
+ configContainer.registerExtensions(ExtensionA.class, ExtensionBNonDefaultImpl.class);
+ configContainer.finishExtensionSetup();
+
+ assertThat(configContainer.setupPhase()).isEqualTo(ConfigContainerSetupPhase.TREE_SETUP);
+ assertThat(configContainer.extensions())
+ .satisfiesExactlyInAnyOrder(
+ extension -> assertThat(extension).isInstanceOf(ExtensionA.class),
+ extension -> assertThat(extension).isInstanceOf(ExtensionBNonDefaultImpl.class)
+ );
+ }
+
+ @Test
+ void extensionSetupMissingDefault() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtension(ExtensionMissingDefault.class);
+ assertThatThrownBy(configContainer::finishExtensionSetup).hasMessageContaining("ExtensionMissingDefault");
+ }
+
+ @Test
+ void extensionSetupAbstractDefault() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtension(ExtensionSelfReferencingDefault.class);
+ assertThatThrownBy(configContainer::finishExtensionSetup).hasMessageContaining("ExtensionSelfReferencingDefault");
+ }
+
+ @Test
+ void extensionSetupWrongDefault() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtension(ExtensionWrongDefault.class);
+ assertThatThrownBy(configContainer::finishExtensionSetup)
+ .hasMessageContaining("ExtensionWrongDefault", "ExtensionBImpl");
+ }
+
+ @Test
+ void extensionSetupNonStaticDefault() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtension(ExtensionNonStaticDefault.class);
+ assertThatThrownBy(configContainer::finishExtensionSetup)
+ .hasMessageContaining("ExtensionNonStaticDefault", "static");
+ }
+
+ @Test
+ void extensionSetupNonPublicDefault() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtension(ExtensionNonPublicDefault.class);
+ assertThatThrownBy(configContainer::finishExtensionSetup)
+ .hasMessageContaining("ExtensionNonPublicDefault", "public");
+ }
+
+ @Test
+ void extension() {
+ var configContainer = new DefaultConfigContainer<>();
+ configContainer.registerExtensions(ExtensionA.class, ExtensionB.class);
+ configContainer.finishExtensionSetup();
+
+ assertThat(configContainer.extension(ExtensionA.class)).containsInstanceOf(ExtensionA.class);
+ assertThat(configContainer.extension(ExtensionB.class)).containsInstanceOf(ExtensionBImpl.class);
+ assertThat(configContainer.extension(ExtensionBImpl.class)).containsInstanceOf(ExtensionBImpl.class);
+ assertThat(configContainer.extension(ExtensionBNonDefaultImpl.class)).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void attachAndSealTree() {
+ var configContainer = new DefaultConfigContainer