From 80d482fb308c9a51b453cac55f77e477ea728e98 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:31:47 +0200 Subject: [PATCH 01/32] Add initial support for type-safe messages --- .../internal/utils/ReflectionUtils.kt | 3 +- .../build.gradle.kts | 32 ++++ .../typesafe/messages/api/IMessageSource.kt | 6 + .../messages/api/IMessageSourceFactory.kt | 9 ++ .../ExperimentalTypesafeMessagesApi.kt | 17 ++ .../api/annotations/LocalizedContent.kt | 6 + .../api/annotations/MessageSourceFactory.kt | 6 + ...ractMessageSourceFactoryMethodException.kt | 3 + .../AbstractMessageSourceMethodException.kt | 3 + .../codegen/AbstractMessageSourceFactory.kt | 37 +++++ .../codegen/MessageSourceFactoryGenerator.kt | 96 +++++++++++ .../codegen/MessageSourceGenerator.kt | 151 ++++++++++++++++++ .../internal/codegen/utils/ClassDesc.kt | 21 +++ .../internal/codegen/utils/CodeBuilder.kt | 33 ++++ .../internal/codegen/utils/LineNumber.kt | 14 ++ ...MessageSourceFactoryClassGraphProcessor.kt | 45 ++++++ ...ourceFactoryClassGraphProcessorProvider.kt | 14 ++ .../messages/internal/utils/Reflection.kt | 13 ++ ...i.core.reflect.ClassGraphProcessorProvider | 1 + .../typesafe/messages/IntegrationTest.kt | 84 ++++++++++ .../MessageSourceFactoryGeneratorTest.kt | 81 ++++++++++ .../messages/MessageSourceGeneratorTest.kt | 96 +++++++++++ .../src/test/resources/logback-test.xml | 14 ++ settings.gradle.kts | 1 + 24 files changed, 784 insertions(+), 2 deletions(-) create mode 100644 BotCommands-typesafe-messages/build.gradle.kts create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt create mode 100644 BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt create mode 100644 BotCommands-typesafe-messages/src/test/resources/logback-test.xml diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionUtils.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionUtils.kt index 5af3afdb5..f97c2c964 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionUtils.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionUtils.kt @@ -167,8 +167,7 @@ internal fun KParameter.findDeclarationName(): String = internal val KFunction<*>.javaMethodInternal: Method get() = javaMethod ?: throwInternal(this, "Could not resolve Java method") -@PublishedApi -internal inline fun KClass<*>.superErasureAt(index: Int): KType = superErasureAt(index, T::class) +inline fun KClass<*>.superErasureAt(index: Int): KType = superErasureAt(index, T::class) @PublishedApi internal fun KClass<*>.superErasureAt(index: Int, targetType: KClass<*>): KType { diff --git a/BotCommands-typesafe-messages/build.gradle.kts b/BotCommands-typesafe-messages/build.gradle.kts new file mode 100644 index 000000000..122b503b0 --- /dev/null +++ b/BotCommands-typesafe-messages/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + // -------------------- CORE DEPENDENCIES -------------------- + + api(projects.botCommandsCore) + + // Logging + implementation(libs.kotlin.logging) + + // -------------------- TEST DEPENDENCIES -------------------- + + testImplementation(libs.mockk) + testImplementation(libs.logback.classic) +} + +java { + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_24 + optIn.add("dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi") + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt new file mode 100644 index 000000000..60d48017d --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt @@ -0,0 +1,6 @@ +package dev.freya02.botcommands.typesafe.messages.api + +import dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi + +@ExperimentalTypesafeMessagesApi +interface IMessageSource diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt new file mode 100644 index 000000000..fe29f4206 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt @@ -0,0 +1,9 @@ +package dev.freya02.botcommands.typesafe.messages.api + +import dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi +import net.dv8tion.jda.api.interactions.Interaction + +@ExperimentalTypesafeMessagesApi +interface IMessageSourceFactory { + fun create(interaction: Interaction): T +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt new file mode 100644 index 000000000..f2a3faa9b --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt @@ -0,0 +1,17 @@ +package dev.freya02.botcommands.typesafe.messages.api.annotations + +/** + * Opt-in marker annotation for the type-safe (localized) messages feature. + * + * The provided APIs have no guarantees and may change (including removals) at any time. + * + * Please create an issue or join the Discord server if you encounter a problem or want to submit feedback. + */ +@RequiresOptIn( + message = "This feature is experimental, please see the documentation of this opt-in annotation (@ExperimentalTypesafeMessagesApi) for more details.", + level = RequiresOptIn.Level.ERROR +) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_SETTER) +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +annotation class ExperimentalTypesafeMessagesApi diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt new file mode 100644 index 000000000..4dd63754d --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt @@ -0,0 +1,6 @@ +package dev.freya02.botcommands.typesafe.messages.api.annotations + +@ExperimentalTypesafeMessagesApi +@MustBeDocumented +@Target(AnnotationTarget.FUNCTION) +annotation class LocalizedContent(val templateKey: String) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt new file mode 100644 index 000000000..c48aa9a2d --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt @@ -0,0 +1,6 @@ +package dev.freya02.botcommands.typesafe.messages.api.annotations + +@ExperimentalTypesafeMessagesApi +@MustBeDocumented +@Target(AnnotationTarget.CLASS, AnnotationTarget.ANNOTATION_CLASS) +annotation class MessageSourceFactory(val bundleName: String) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt new file mode 100644 index 000000000..33470d99f --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class AbstractMessageSourceFactoryMethodException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt new file mode 100644 index 000000000..c5a4d618c --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class AbstractMessageSourceMethodException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt new file mode 100644 index 000000000..ae12bb9ae --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt @@ -0,0 +1,37 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider +import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider +import net.dv8tion.jda.api.interactions.Interaction +import kotlin.reflect.KClass + +internal abstract class AbstractMessageSourceFactory internal constructor( + private val params: Params, +) : IMessageSourceFactory { + + override fun create(interaction: Interaction): IMessageSource { + val (localizationService, bundle, guildLocaleProvider, userLocaleProvider, sourceType) = params + + val localizationContext = LocalizationContext.create( + localizationService = localizationService, + localizationBundle = bundle, + localizationPrefix = null, + guildLocale = guildLocaleProvider.getDiscordLocale(interaction), + userLocale = userLocaleProvider.getDiscordLocale(interaction), + ) + + return MessageSourceGenerator.create(sourceType, localizationContext) + } + + internal data class Params( + internal val localizationService: LocalizationService, + internal val bundle: String, + internal val guildLocaleProvider: GuildLocaleProvider, + internal val userLocaleProvider: UserLocaleProvider, + internal val sourceType: KClass, + ) +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt new file mode 100644 index 000000000..75c7f0c2c --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -0,0 +1,96 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber +import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc +import dev.freya02.botcommands.typesafe.messages.internal.utils.isAbstract +import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.api.core.utils.joinAsList +import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider +import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider +import net.dv8tion.jda.api.interactions.Interaction +import java.lang.classfile.ClassFile +import java.lang.classfile.ClassFile.* +import java.lang.constant.ClassDesc +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.ConstantDescs.INIT_NAME +import java.lang.constant.MethodTypeDesc +import java.lang.invoke.MethodHandles +import java.lang.reflect.AccessFlag +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName + +internal object MessageSourceFactoryGenerator { + + private val CD_AbstractMessageSourceFactory = classDesc() + private val CD_AbstractMessageSourceFactory_Params = classDesc() + + internal fun createFactory( + context: BContext, + annotation: MessageSourceFactory, + sourceFactoryType: KClass>, + sourceType: KClass, + ): IMessageSourceFactory<*> { + // The only abstract method should be the one we implement + sourceFactoryType.java.methods + // Look at abstract methods + .filter { it.isAbstract() } + // Remove methods we implement + .filterNot { it.name == "create" && it.parameterTypes.getOrNull(0) == Interaction::class.java && it.returnType == IMessageSource::class.java } + .also { unimplementedMethods -> + if (unimplementedMethods.isNotEmpty()) { + throw AbstractMessageSourceFactoryMethodException("${sourceFactoryType.jvmName} cannot contain abstract methods:\n${unimplementedMethods.joinAsList()}") + } + } + + val params = AbstractMessageSourceFactory.Params( + context.getService(), + annotation.bundleName, + context.getService(), + context.getService(), + sourceType, + ) + + val classFile = ClassFile.of() + val thisClass = ClassDesc.of("${MessageSourceFactoryGenerator::class.java.packageName}.${sourceFactoryType.simpleNestedBinaryName}Impl") + + // Simple class that extends [[AbstractMessageSourceFactory]] and the target [[IMessageSourceFactory]] subinterface + val factoryBytes = classFile.build(thisClass) { classBuilder -> + classBuilder.withFlags(AccessFlag.PUBLIC, AccessFlag.FINAL) + classBuilder.withSuperclass(CD_AbstractMessageSourceFactory) + classBuilder.withInterfaceSymbols(ClassDesc.of(sourceFactoryType.jvmName)) + + classBuilder.withField("params", CD_AbstractMessageSourceFactory_Params, ACC_PRIVATE or ACC_FINAL) + + classBuilder.withMethodBody( + INIT_NAME, + MethodTypeDesc.of(CD_void, CD_AbstractMessageSourceFactory_Params), + ACC_PUBLIC + ) { codeBuilder -> + val lineNumber = LineNumber(codeBuilder) + + val thisSlot = codeBuilder.receiverSlot() + val paramsSlot = codeBuilder.parameterSlot(0) + + // this.super(params) + lineNumber.setAndIncrement() + codeBuilder.aload(thisSlot) + codeBuilder.aload(paramsSlot) + codeBuilder.invokespecial(CD_AbstractMessageSourceFactory, INIT_NAME, MethodTypeDesc.of(CD_void, CD_AbstractMessageSourceFactory_Params)) + + // Required + codeBuilder.return_() + } + } + + return MethodHandles.lookup().defineClass(factoryBytes) + .declaredConstructors.single() + .newInstance(params) as IMessageSourceFactory<*> + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt new file mode 100644 index 000000000..cb391ccac --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -0,0 +1,151 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.* +import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName +import io.github.freya022.botcommands.api.core.utils.joinAsList +import io.github.freya022.botcommands.api.core.utils.mapToArray +import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import java.lang.classfile.ClassFile +import java.lang.classfile.TypeKind +import java.lang.classfile.attribute.SourceFileAttribute +import java.lang.constant.ClassDesc +import java.lang.constant.ConstantDescs.* +import java.lang.constant.MethodTypeDesc +import java.lang.invoke.MethodHandles +import java.lang.reflect.AccessFlag +import java.lang.reflect.Constructor +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberFunctions +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.jvmErasure +import kotlin.reflect.jvm.jvmName + +internal object MessageSourceGenerator { + + private val lock = ReentrantLock() + private val cache: MutableMap, Constructor> = hashMapOf() + + @Suppress("UNCHECKED_CAST") + internal fun create( + sourceType: KClass, + localizationContext: LocalizationContext, + ): T = lock.withLock { + return cache.getOrPut(sourceType) { + generateClass(sourceType).declaredConstructors.single() as Constructor + }.newInstance(localizationContext) as T + } + + private fun generateClass(sourceType: KClass): Class<*> { + val abstractMethods = sourceType.memberFunctions.filter { it.isAbstract } + val toImplement = abstractMethods.filter { it.hasAnnotation() } + + (abstractMethods - toImplement).also { unimplementedMethods -> + if (unimplementedMethods.isNotEmpty()) { + throw AbstractMessageSourceMethodException("Abstract methods in ${sourceType.jvmName} can only be implemented if annotated with @${LocalizedContent::class.java.simpleName}:\n${unimplementedMethods.joinAsList()}") + } + } + + val classFile = ClassFile.of() + val thisClass = ClassDesc.of("${MessageSourceGenerator::class.java.packageName}.${sourceType.simpleNestedBinaryName}Impl") + val sourceBytes = classFile.build(thisClass) { classBuilder -> + classBuilder.with(SourceFileAttribute.of(thisClass.displayName())) + classBuilder.withFlags(AccessFlag.PUBLIC, AccessFlag.FINAL) + classBuilder.withInterfaceSymbols(ClassDesc.of(sourceType.jvmName)) + + classBuilder.withField("localizationContext", CD_LocalizationContext, ClassFile.ACC_PRIVATE or ClassFile.ACC_FINAL) + + classBuilder.withMethodBody( + INIT_NAME, + MethodTypeDesc.of(CD_void, CD_LocalizationContext), + ClassFile.ACC_PUBLIC + ) { codeBuilder -> + val lineNumber = LineNumber(codeBuilder) + + val thisSlot = codeBuilder.receiverSlot() + val localizationContextSlot = codeBuilder.parameterSlot(0) + + // this.super() + lineNumber.setAndIncrement() + codeBuilder.aload(thisSlot) + codeBuilder.invokespecial(CD_Object, INIT_NAME, MethodTypeDesc.of(CD_void)) + + // this.localizationContext = localizationContext + lineNumber.setAndIncrement() + codeBuilder.aload(thisSlot) + codeBuilder.aload(localizationContextSlot) + codeBuilder.putfield(thisClass, "localizationContext", CD_LocalizationContext) + + // Required + codeBuilder.return_() + } + + toImplement.forEach { method -> + val annotation = method.findAnnotation() + ?: error("Method was to be implemented but annotation is absent") + val templateParameters = method.valueParameters + + // TODO make sure a fallback message exists for the given "templateKey" + + classBuilder.withMethodBody( + method.name, + MethodTypeDesc.of(method.returnType.jvmErasure.toClassDesc(), *method.valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() }), + ClassFile.ACC_PUBLIC or ClassFile.ACC_FINAL, + ) { codeBuilder -> + val lineNumber = LineNumber(codeBuilder) + + val thisSlot = codeBuilder.receiverSlot() + val localizationArgsSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val localizationEntrySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // var localizationArgs = new Localization.Entry[templateParameters.size]; + lineNumber.setAndIncrement() + codeBuilder.loadConstant(templateParameters.size) + codeBuilder.anewarray(CD_Localization_Entry) + codeBuilder.astore(localizationArgsSlot) + + templateParameters.withIndex().forEachIndexed { arrayIndex, (parameterIndex, parameter) -> + val paramName = parameter.name + ?: error("Parameter names are absent from $method ; see https://bc.freya02.dev/3.X/using-botcommands/parameter-names/") + + // localizationEntry = new Localization.Entry(paramName, value) + lineNumber.setAndIncrement() + codeBuilder.new_(CD_Localization_Entry) + codeBuilder.dup() // As doesn't return itself + codeBuilder.ldc(paramName) + codeBuilder.loadLocal(TypeKind.from(parameter.type.jvmErasure.java), codeBuilder.parameterSlot(parameterIndex)) + codeBuilder.boxIfNecessary(parameter.type.jvmErasure) + codeBuilder.invokespecial( + CD_Localization_Entry, + INIT_NAME, MethodTypeDesc.of(CD_void, CD_String, CD_Object)) + codeBuilder.astore(localizationEntrySlot) + + // localizationArgs[i] = localizationEntry + lineNumber.setAndIncrement() + codeBuilder.aload(localizationArgsSlot) + codeBuilder.loadConstant(arrayIndex) + codeBuilder.aload(localizationEntrySlot) + codeBuilder.aastore() + } + + // return this.localizationContext.localize("", localizationArgs) + lineNumber.setAndIncrement() + codeBuilder.aload(thisSlot) + codeBuilder.getfield(thisClass, "localizationContext", CD_LocalizationContext) + codeBuilder.ldc(annotation.templateKey) + codeBuilder.aload(localizationArgsSlot) + codeBuilder.invokeinterface(CD_LocalizationContext, "localize", MethodTypeDesc.of(CD_String, CD_String, CD_Localization_Entry.arrayType())) + codeBuilder.areturn() + } + } + } + + return MethodHandles.lookup().defineClass(sourceBytes) + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt new file mode 100644 index 000000000..bede6ea00 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt @@ -0,0 +1,21 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen.utils + +import io.github.freya022.botcommands.api.core.utils.shortQualifiedName +import io.github.freya022.botcommands.api.localization.Localization +import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import java.lang.constant.ClassDesc +import kotlin.jvm.optionals.getOrElse +import kotlin.reflect.KClass + +internal val CD_LocalizationContext = classDesc() +internal val CD_Localization_Entry = classDesc() + +internal inline fun classDesc(): ClassDesc { + return ClassDesc.of(T::class.java.name) +} + +internal fun KClass<*>.toClassDesc(): ClassDesc = java.toClassDesc() + +internal fun Class<*>.toClassDesc(): ClassDesc { + return describeConstable().getOrElse { error("Could not convert ${this.shortQualifiedName} to ClassDesc") } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt new file mode 100644 index 000000000..7495ad45f --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt @@ -0,0 +1,33 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen.utils + +import java.lang.classfile.CodeBuilder +import java.lang.constant.MethodTypeDesc +import kotlin.reflect.KClass + +internal fun CodeBuilder.ldc(string: String) { + ldc(string as java.lang.String) +} + +/** + * Boxes the value on the top of the stack if the [type] is a primitive, the boxed value is on the top of the stack. + */ +internal fun CodeBuilder.boxIfNecessary(type: KClass<*>) { + type.javaPrimitiveType?.let { javaPrimitive -> + when (javaPrimitive) { + Boolean::class.java -> boxTo() + Byte::class.java -> boxTo() + Char::class.java -> boxTo() + Short::class.java -> boxTo() + Int::class.java -> boxTo() + Long::class.java -> boxTo() + Float::class.java -> boxTo() + Double::class.java -> boxTo() + } + } +} + +private inline fun CodeBuilder.boxTo() { + val primitiveType = T::class.javaPrimitiveType!!.toClassDesc() + val objectType = T::class.javaObjectType.toClassDesc() + invokestatic(objectType, "valueOf", MethodTypeDesc.of(objectType, primitiveType)) +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt new file mode 100644 index 000000000..31284282c --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt @@ -0,0 +1,14 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen.utils + +import java.lang.classfile.CodeBuilder + +internal class LineNumber internal constructor( + private val codeBuilder: CodeBuilder, +) { + + private var line = 1 + + internal fun setAndIncrement() { + codeBuilder.lineNumber(line++) + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt new file mode 100644 index 000000000..728cf40f3 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -0,0 +1,45 @@ +package dev.freya02.botcommands.typesafe.messages.internal.processor + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator +import io.github.classgraph.ClassInfo +import io.github.freya022.botcommands.api.core.service.BCServiceContainer +import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.service.ServiceSupplier +import io.github.freya022.botcommands.api.core.utils.shortQualifiedName +import io.github.freya022.botcommands.internal.utils.superErasureAt +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmErasure + +internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { + + @Suppress("UNCHECKED_CAST") + override fun processClass(serviceContainer: ServiceContainer, classInfo: ClassInfo, kClass: KClass<*>, isService: Boolean) { + val annotation = classInfo.getAnnotationInfo(MessageSourceFactory::class.java)?.loadClassAndInstantiate() as MessageSourceFactory? ?: return + + require(classInfo.isInterface) { + "${classInfo.shortQualifiedName} must be an interface" + } + require(classInfo.implementsInterface(IMessageSourceFactory::class.java)) { + "${classInfo.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" + } + + // TODO add support in ServiceContainer + serviceContainer as BCServiceContainer + + val messageSourceFactoryType = kClass as KClass> + val messageSourceType = kClass.superErasureAt>(0).jvmErasure as KClass + + serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context -> + MessageSourceFactoryGenerator.createFactory( + context, + annotation, + messageSourceFactoryType, + messageSourceType + ) + }) + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt new file mode 100644 index 000000000..15793dbae --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt @@ -0,0 +1,14 @@ +package dev.freya02.botcommands.typesafe.messages.internal.processor + +import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider +import io.github.freya022.botcommands.api.core.reflect.annotations.ExperimentalReflectionApi +import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor + +@OptIn(ExperimentalReflectionApi::class) +internal class MessageSourceFactoryClassGraphProcessorProvider : ClassGraphProcessorProvider { + + override fun getProcessors(config: BConfig): Collection { + return listOf(MessageSourceFactoryClassGraphProcessor) + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt new file mode 100644 index 000000000..26aa6562b --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt @@ -0,0 +1,13 @@ +package dev.freya02.botcommands.typesafe.messages.internal.utils + +import io.github.freya022.botcommands.api.core.utils.simpleNestedName +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import kotlin.reflect.KClass + +internal fun Method.isAbstract(): Boolean { + return (modifiers and Modifier.ABSTRACT) == Modifier.ABSTRACT +} + +internal val KClass<*>.simpleNestedBinaryName: String + get() = simpleNestedName.replace('.', '$') diff --git a/BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider b/BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider new file mode 100644 index 000000000..144613716 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider @@ -0,0 +1 @@ +dev.freya02.botcommands.typesafe.messages.internal.processor.MessageSourceFactoryClassGraphProcessorProvider diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt new file mode 100644 index 000000000..312e95935 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt @@ -0,0 +1,84 @@ +package dev.freya02.botcommands.typesafe.messages + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap +import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate +import io.github.freya022.botcommands.api.localization.LocalizationMap +import io.github.freya022.botcommands.api.localization.LocalizationMapRequest +import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.interactions.DiscordLocale +import net.dv8tion.jda.api.interactions.Interaction +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag +import kotlin.test.Test +import kotlin.test.assertEquals + +class IntegrationTest { + + @BService + class FakeBot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} + } + + @BService + class MyBundleReader( + private val context: BContext, + ) : LocalizationMapReader { + + override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { + if (request.baseName != "myBundle") return null + + return DefaultLocalizationMap(request.requestedLocale, mapOf( + "whats.the.fox.doing" to DefaultLocalizationTemplate(context, "The fox quickly {action}", request.requestedLocale), + )) + } + } + + interface MyMessageSource : IMessageSource { + + @LocalizedContent("whats.the.fox.doing") + fun whatsTheFoxDoing(action: String): String + } + + @MessageSourceFactory("myBundle") + interface MyMessageSourceFactory : IMessageSourceFactory + + @Test + fun `Full test`() { + val context = BotCommands.create { + textCommands { enable = false } + applicationCommands { enable = false } + modals { enable = false } + + addClass() + addClass() + addClass() + } + + val interaction = mockk { + every { userLocale } returns DiscordLocale.FRENCH + every { guildLocale } returns DiscordLocale.FRENCH + } + val whatsTheFoxDoing = context.getService() + .create(interaction) + .whatsTheFoxDoing("jumps") + + assertEquals("The fox quickly jumps", whatsTheFoxDoing) + } +} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt new file mode 100644 index 000000000..d8fa3cff9 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt @@ -0,0 +1,81 @@ +package dev.freya02.botcommands.typesafe.messages + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider +import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test + +class MessageSourceFactoryGeneratorTest { + + interface Factory : IMessageSourceFactory + + interface FactoryWithoutAnnotationOnAbstract : IMessageSourceFactory { + + fun test(): String + } + + interface FactoryWithoutAnnotationOnConcrete : IMessageSourceFactory { + + fun test(): String = "test" + } + + @Test + fun `Generate IMessageSourceFactory`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = Factory::class, + sourceType = IMessageSource::class + ) + } + + @Test + fun `Cannot generate IMessageSourceFactory with abstract method`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + + assertThrows { + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = FactoryWithoutAnnotationOnAbstract::class, + sourceType = IMessageSource::class + ) + } + } + + @Test + fun `Generate IMessageSourceFactory with concrete method`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = FactoryWithoutAnnotationOnConcrete::class, + sourceType = IMessageSource::class + ) + } +} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt new file mode 100644 index 000000000..3b2a30b42 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -0,0 +1,96 @@ +package dev.freya02.botcommands.typesafe.messages + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator +import io.github.freya022.botcommands.api.localization.Localization +import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertEquals + +class MessageSourceGeneratorTest { + + interface SourceWithoutArgs : IMessageSource { + + @LocalizedContent("SourceWithoutArgs.key") + fun test(): String + } + + interface SourceWithArgs : IMessageSource { + + @LocalizedContent("SourceWithArgs.key") + fun test(string: String): String + } + + interface SourceWithPrimitiveArgs : IMessageSource { + + @LocalizedContent("SourceWithPrimitiveArgs.key") + fun test(integer: Int): String + } + + interface SourceWithoutAnnotationOnAbstract : IMessageSource { + + fun test(): String + } + + interface SourceWithoutAnnotationOnConcrete : IMessageSource { + + fun test(): String = "test" + } + + // TODO test works with interfaces only + // TODO test return type is String enforced + + @Test + fun `Generate IMessageSource without params`() { + val localizationContext = mockk { + every { localize(any()) } returns "expected" + } + val source = MessageSourceGenerator.create(SourceWithoutArgs::class, localizationContext) + + source.test() + verify(exactly = 1) { localizationContext.localize("SourceWithoutArgs.key") } + } + + @Test + fun `Generate IMessageSource with params`() { + val localizationContext = mockk { + every { localize(any(), any()) } returns "expected" + } + val source = MessageSourceGenerator.create(SourceWithArgs::class, localizationContext) + + source.test("42") + verify(exactly = 1) { localizationContext.localize("SourceWithArgs.key", Localization.Entry("string", "42")) } + } + + @Test + fun `Generate IMessageSource with primitive params`() { + val localizationContext = mockk { + every { localize(any(), any()) } returns "expected" + } + val source = MessageSourceGenerator.create(SourceWithPrimitiveArgs::class, localizationContext) + + source.test(42) + verify(exactly = 1) { localizationContext.localize("SourceWithPrimitiveArgs.key", Localization.Entry("integer", 42)) } + } + + @Test + fun `Cannot generate IMessageSource without annotation on abstract method`() { + val localizationContext = mockk() + assertThrows { + MessageSourceGenerator.create(SourceWithoutAnnotationOnAbstract::class, localizationContext) + } + } + + @Test + fun `Generate IMessageSource without annotation on concrete method`() { + val localizationContext = mockk() + val source = MessageSourceGenerator.create(SourceWithoutAnnotationOnConcrete::class, localizationContext) + assertEquals("test", source.test()) + } +} diff --git a/BotCommands-typesafe-messages/src/test/resources/logback-test.xml b/BotCommands-typesafe-messages/src/test/resources/logback-test.xml new file mode 100644 index 000000000..34bef9537 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %boldCyan(%-26.-26thread) %boldYellow(%-20.-20logger{0}) %highlight(%-6level) %msg%n%throwable + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 6642bc492..248114bc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,3 +5,4 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":BotCommands-core") include(":spring-properties-processor") include(":BotCommands-spring") +include(":BotCommands-typesafe-messages") From f20381a710560eda35ac6d0de8c795ecd9690f75 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:41:15 +0200 Subject: [PATCH 02/32] Move out integration test services --- .../typesafe/messages/IntegrationTest.kt | 84 ------------------- .../typesafe/messages/integration/FakeBot.kt | 17 ++++ .../messages/integration/IntegrationTest.kt | 36 ++++++++ .../messages/integration/MyBundleReader.kt | 29 +++++++ .../messages/integration/MyMessageSource.kt | 15 ++++ 5 files changed, 97 insertions(+), 84 deletions(-) delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt deleted file mode 100644 index 312e95935..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/IntegrationTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages - -import dev.freya02.botcommands.typesafe.messages.api.IMessageSource -import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory -import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent -import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.BotCommands -import io.github.freya022.botcommands.api.core.JDAService -import io.github.freya022.botcommands.api.core.events.BReadyEvent -import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.service.getService -import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap -import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate -import io.github.freya022.botcommands.api.localization.LocalizationMap -import io.github.freya022.botcommands.api.localization.LocalizationMapRequest -import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader -import io.mockk.every -import io.mockk.mockk -import net.dv8tion.jda.api.hooks.IEventManager -import net.dv8tion.jda.api.interactions.DiscordLocale -import net.dv8tion.jda.api.interactions.Interaction -import net.dv8tion.jda.api.requests.GatewayIntent -import net.dv8tion.jda.api.utils.cache.CacheFlag -import kotlin.test.Test -import kotlin.test.assertEquals - -class IntegrationTest { - - @BService - class FakeBot : JDAService() { - - override val intents: Set = emptySet() - override val cacheFlags: Set = emptySet() - - override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} - } - - @BService - class MyBundleReader( - private val context: BContext, - ) : LocalizationMapReader { - - override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { - if (request.baseName != "myBundle") return null - - return DefaultLocalizationMap(request.requestedLocale, mapOf( - "whats.the.fox.doing" to DefaultLocalizationTemplate(context, "The fox quickly {action}", request.requestedLocale), - )) - } - } - - interface MyMessageSource : IMessageSource { - - @LocalizedContent("whats.the.fox.doing") - fun whatsTheFoxDoing(action: String): String - } - - @MessageSourceFactory("myBundle") - interface MyMessageSourceFactory : IMessageSourceFactory - - @Test - fun `Full test`() { - val context = BotCommands.create { - textCommands { enable = false } - applicationCommands { enable = false } - modals { enable = false } - - addClass() - addClass() - addClass() - } - - val interaction = mockk { - every { userLocale } returns DiscordLocale.FRENCH - every { guildLocale } returns DiscordLocale.FRENCH - } - val whatsTheFoxDoing = context.getService() - .create(interaction) - .whatsTheFoxDoing("jumps") - - assertEquals("The fox quickly jumps", whatsTheFoxDoing) - } -} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt new file mode 100644 index 000000000..72369bebc --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt @@ -0,0 +1,17 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag + +@BService +class FakeBot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} +} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt new file mode 100644 index 000000000..50db0c973 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt @@ -0,0 +1,36 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.core.service.getService +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.interactions.DiscordLocale +import net.dv8tion.jda.api.interactions.Interaction +import kotlin.test.Test +import kotlin.test.assertEquals + +class IntegrationTest { + + @Test + fun `Full test`() { + val context = BotCommands.create { + textCommands { enable = false } + applicationCommands { enable = false } + modals { enable = false } + + addClass() + addClass() + addClass() + } + + val interaction = mockk { + every { userLocale } returns DiscordLocale.FRENCH + every { guildLocale } returns DiscordLocale.FRENCH + } + val whatsTheFoxDoing = context.getService() + .create(interaction) + .whatsTheFoxDoing("jumps") + + assertEquals("The fox quickly jumps", whatsTheFoxDoing) + } +} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt new file mode 100644 index 000000000..3d8dacf98 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt @@ -0,0 +1,29 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap +import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate +import io.github.freya022.botcommands.api.localization.LocalizationMap +import io.github.freya022.botcommands.api.localization.LocalizationMapRequest +import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader + +@BService +class MyBundleReader( + private val context: BContext, +) : LocalizationMapReader { + + override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { + if (request.baseName != "myBundle") return null + + return DefaultLocalizationMap( + request.requestedLocale, mapOf( + "whats.the.fox.doing" to DefaultLocalizationTemplate( + context, + "The fox quickly {action}", + request.requestedLocale + ), + ) + ) + } +} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt new file mode 100644 index 000000000..3de0e3c10 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory + +interface MyMessageSource : IMessageSource { + + @LocalizedContent("whats.the.fox.doing") + fun whatsTheFoxDoing(action: String): String +} + +@MessageSourceFactory("myBundle") +interface MyMessageSourceFactory : IMessageSourceFactory From 746cb4ea9af5e152e71d6f13c6803ec15667ec4c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:42:08 +0200 Subject: [PATCH 03/32] Use generic in MessageSourceFactoryGenerator --- .../internal/codegen/MessageSourceFactoryGenerator.kt | 11 ++++++----- .../MessageSourceFactoryClassGraphProcessor.kt | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 75c7f0c2c..7da89f64a 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -31,12 +31,13 @@ internal object MessageSourceFactoryGenerator { private val CD_AbstractMessageSourceFactory = classDesc() private val CD_AbstractMessageSourceFactory_Params = classDesc() - internal fun createFactory( + @Suppress("UNCHECKED_CAST") + internal fun , U : IMessageSource> createFactory( context: BContext, annotation: MessageSourceFactory, - sourceFactoryType: KClass>, - sourceType: KClass, - ): IMessageSourceFactory<*> { + sourceFactoryType: KClass, + sourceType: KClass, + ): T { // The only abstract method should be the one we implement sourceFactoryType.java.methods // Look at abstract methods @@ -91,6 +92,6 @@ internal object MessageSourceFactoryGenerator { return MethodHandles.lookup().defineClass(factoryBytes) .declaredConstructors.single() - .newInstance(params) as IMessageSourceFactory<*> + .newInstance(params) as T } } diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt index 728cf40f3..ada690141 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -31,7 +31,7 @@ internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { serviceContainer as BCServiceContainer val messageSourceFactoryType = kClass as KClass> - val messageSourceType = kClass.superErasureAt>(0).jvmErasure as KClass + val messageSourceType = kClass.superErasureAt>(0).jvmErasure as KClass serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context -> MessageSourceFactoryGenerator.createFactory( From ef8a7ab9256d0e838b61fae8a7cffa89bc41066e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:42:57 +0200 Subject: [PATCH 04/32] Limit MessageSourceFactoryClassGraphProcessor to BC DI --- .../processor/MessageSourceFactoryClassGraphProcessor.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt index ada690141..697058e74 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -18,6 +18,8 @@ internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { @Suppress("UNCHECKED_CAST") override fun processClass(serviceContainer: ServiceContainer, classInfo: ClassInfo, kClass: KClass<*>, isService: Boolean) { + if (serviceContainer !is BCServiceContainer) return + val annotation = classInfo.getAnnotationInfo(MessageSourceFactory::class.java)?.loadClassAndInstantiate() as MessageSourceFactory? ?: return require(classInfo.isInterface) { @@ -27,9 +29,6 @@ internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { "${classInfo.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" } - // TODO add support in ServiceContainer - serviceContainer as BCServiceContainer - val messageSourceFactoryType = kClass as KClass> val messageSourceType = kClass.superErasureAt>(0).jvmErasure as KClass From f656310c4f22014dcb6434af275fb06db5e807fb Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:05:43 +0200 Subject: [PATCH 05/32] Add Spring support --- .../build.gradle.kts | 6 ++ .../TypesafeMessagesAutoConfiguration.kt | 17 +++++ .../MessageSourceFactoryPostProcessor.kt | 67 +++++++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../integration/SpringIntegrationTest.kt | 34 ++++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 126 insertions(+) create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt create mode 100644 BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt create mode 100644 BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt diff --git a/BotCommands-typesafe-messages/build.gradle.kts b/BotCommands-typesafe-messages/build.gradle.kts index 122b503b0..e69c22d8f 100644 --- a/BotCommands-typesafe-messages/build.gradle.kts +++ b/BotCommands-typesafe-messages/build.gradle.kts @@ -13,10 +13,16 @@ dependencies { // Logging implementation(libs.kotlin.logging) + implementation(libs.spring.boot) + implementation(libs.spring.boot.autoconfigure) + // -------------------- TEST DEPENDENCIES -------------------- testImplementation(libs.mockk) testImplementation(libs.logback.classic) + + testImplementation(projects.botCommandsSpring) + testImplementation(libs.spring.boot.starter.test) } java { diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt new file mode 100644 index 000000000..11ac1ecb7 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt @@ -0,0 +1,17 @@ +package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure + +import dev.freya02.botcommands.typesafe.messages.internal.processor.MessageSourceFactoryPostProcessor +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean + +@AutoConfiguration +internal open class TypesafeMessagesAutoConfiguration { + + @Bean + internal open fun springMessageSourceFactoryProvider( + context: ApplicationContext, + ): MessageSourceFactoryPostProcessor { + return MessageSourceFactoryPostProcessor(context) + } +} diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt new file mode 100644 index 000000000..4c4b494c8 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt @@ -0,0 +1,67 @@ +package dev.freya02.botcommands.typesafe.messages.internal.processor + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive +import io.github.freya022.botcommands.api.core.utils.isSubclassOf +import io.github.freya022.botcommands.api.core.utils.shortQualifiedName +import io.github.freya022.botcommands.internal.utils.superErasureAt +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition +import org.springframework.beans.factory.getBean +import org.springframework.beans.factory.support.BeanDefinitionBuilder +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor +import org.springframework.boot.autoconfigure.AutoConfigurationPackages +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider +import org.springframework.core.type.filter.AnnotationTypeFilter +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmErasure + +internal class MessageSourceFactoryPostProcessor internal constructor( + private val context: ApplicationContext, +) : BeanDefinitionRegistryPostProcessor { + + @Suppress("UNCHECKED_CAST") + override fun postProcessBeanDefinitionRegistry(registry: BeanDefinitionRegistry) { + val provider = object : ClassPathScanningCandidateComponentProvider(false) { + override fun isCandidateComponent(beanDefinition: AnnotatedBeanDefinition): Boolean { + return beanDefinition.metadata.isIndependent + } + } + provider.addIncludeFilter(AnnotationTypeFilter(MessageSourceFactory::class.java, true, true)) + + for (pkg in AutoConfigurationPackages.get(context)) { + for (beanDefinition in provider.findCandidateComponents(pkg)) { + val messageSourceFactoryType = Class.forName(beanDefinition.beanClassName).kotlin as KClass> + + val annotation = messageSourceFactoryType.findAnnotationRecursive() + ?: error("Filter for MessageSourceFactory found a class without it") + + require(messageSourceFactoryType.java.isInterface) { + "${messageSourceFactoryType.shortQualifiedName} must be an interface" + } + require(messageSourceFactoryType.isSubclassOf>()) { + "${messageSourceFactoryType.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" + } + + val messageSourceType = messageSourceFactoryType.superErasureAt>(0).jvmErasure as KClass + + registry.registerBeanDefinition( + messageSourceFactoryType.java.simpleName.replaceFirstChar { it.lowercaseChar() }, + BeanDefinitionBuilder.genericBeanDefinition(messageSourceFactoryType.java) { + MessageSourceFactoryGenerator.createFactory( + context.getBean(), + annotation, + messageSourceFactoryType, + messageSourceType + ) + }.beanDefinition + ) + } + } + } +} diff --git a/BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..675b85a98 --- /dev/null +++ b/BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.freya02.botcommands.typesafe.messages.internal.autoconfigure.TypesafeMessagesAutoConfiguration diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt new file mode 100644 index 000000000..0168f79b2 --- /dev/null +++ b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt @@ -0,0 +1,34 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.interactions.DiscordLocale +import net.dv8tion.jda.api.interactions.Interaction +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@SpringBootTest(classes = [SpringIntegrationTest.Application::class]) +class SpringIntegrationTest { + + @SpringBootApplication(scanBasePackages = ["dev.freya02.botcommands.typesafe.messages.integration"]) + open class Application + + @Autowired + private lateinit var messageSourceFactory: MyMessageSourceFactory + + @Test + fun `Full test`() { + val interaction = mockk { + every { userLocale } returns DiscordLocale.FRENCH + every { guildLocale } returns DiscordLocale.FRENCH + } + val whatsTheFoxDoing = messageSourceFactory + .create(interaction) + .whatsTheFoxDoing("jumps") + + assertEquals("The fox quickly jumps", whatsTheFoxDoing) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d864b28fe..5dd906c56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -107,6 +107,7 @@ spring-boot = { module = "org.springframework.boot:spring-boot", version.ref = " spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "spring-boot" } spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } +spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } h2 = { module = "com.h2database:h2", version.ref = "h2" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } From e70ef4e6ce4b429c79f71e586a8c80c0674ed9b9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:30:52 +0200 Subject: [PATCH 06/32] Separate core, BC and Spring modules Also set up module publications --- .../bc/build.gradle.kts | 35 +++++++ ...MessageSourceFactoryClassGraphProcessor.kt | 0 ...ourceFactoryClassGraphProcessorProvider.kt | 0 ...i.core.reflect.ClassGraphProcessorProvider | 0 .../integration/DefaultIntegrationTest.kt | 90 ++++++++++++++++++ .../src/test/resources/logback-test.xml | 0 .../core/build.gradle.kts | 34 +++++++ .../typesafe/messages/api/IMessageSource.kt | 0 .../messages/api/IMessageSourceFactory.kt | 0 .../ExperimentalTypesafeMessagesApi.kt | 0 .../api/annotations/LocalizedContent.kt | 0 .../api/annotations/MessageSourceFactory.kt | 0 ...ractMessageSourceFactoryMethodException.kt | 0 .../AbstractMessageSourceMethodException.kt | 0 .../codegen/AbstractMessageSourceFactory.kt | 0 .../codegen/MessageSourceFactoryGenerator.kt | 4 +- .../codegen/MessageSourceGenerator.kt | 0 .../internal/codegen/utils/ClassDesc.kt | 0 .../internal/codegen/utils/CodeBuilder.kt | 0 .../internal/codegen/utils/LineNumber.kt | 0 .../messages/internal/utils/Reflection.kt | 0 .../MessageSourceFactoryGeneratorTest.kt | 0 .../messages/MessageSourceGeneratorTest.kt | 0 .../core/src/test/resources/logback-test.xml | 14 +++ .../{ => spring}/build.gradle.kts | 3 + .../MessageSourceFactoryPostProcessor.kt | 2 +- .../TypesafeMessagesAutoConfiguration.kt | 1 - ...ot.autoconfigure.AutoConfiguration.imports | 0 .../integration/SpringIntegrationTest.kt | 94 +++++++++++++++++++ .../src/test/resources/logback-test.xml | 14 +++ .../typesafe/messages/integration/FakeBot.kt | 17 ---- .../messages/integration/IntegrationTest.kt | 36 ------- .../messages/integration/MyBundleReader.kt | 29 ------ .../messages/integration/MyMessageSource.kt | 15 --- .../integration/SpringIntegrationTest.kt | 34 ------- build.gradle.kts | 3 + buildSrc/src/main/kotlin/tasks.kt | 28 ++++++ settings.gradle.kts | 6 +- 38 files changed, 323 insertions(+), 136 deletions(-) create mode 100644 BotCommands-typesafe-messages/bc/build.gradle.kts rename BotCommands-typesafe-messages/{ => bc}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt (100%) rename BotCommands-typesafe-messages/{ => bc}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt (100%) rename BotCommands-typesafe-messages/{ => bc}/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider (100%) create mode 100644 BotCommands-typesafe-messages/bc/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/DefaultIntegrationTest.kt rename BotCommands-typesafe-messages/{ => bc}/src/test/resources/logback-test.xml (100%) create mode 100644 BotCommands-typesafe-messages/core/build.gradle.kts rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt (97%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt (100%) rename BotCommands-typesafe-messages/{ => core}/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt (100%) create mode 100644 BotCommands-typesafe-messages/core/src/test/resources/logback-test.xml rename BotCommands-typesafe-messages/{ => spring}/build.gradle.kts (88%) rename BotCommands-typesafe-messages/{src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor => spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure}/MessageSourceFactoryPostProcessor.kt (98%) rename BotCommands-typesafe-messages/{ => spring}/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt (83%) rename BotCommands-typesafe-messages/{ => spring}/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (100%) create mode 100644 BotCommands-typesafe-messages/spring/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt create mode 100644 BotCommands-typesafe-messages/spring/src/test/resources/logback-test.xml delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt delete mode 100644 BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt create mode 100644 buildSrc/src/main/kotlin/tasks.kt diff --git a/BotCommands-typesafe-messages/bc/build.gradle.kts b/BotCommands-typesafe-messages/bc/build.gradle.kts new file mode 100644 index 000000000..13fd9a9c2 --- /dev/null +++ b/BotCommands-typesafe-messages/bc/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + // -------------------- CORE DEPENDENCIES -------------------- + + api(projects.botCommandsCore) + api(projects.botCommandsTypesafeMessages.core) + + // Logging + implementation(libs.kotlin.logging) + + // -------------------- TEST DEPENDENCIES -------------------- + + testImplementation(libs.mockk) + testImplementation(libs.logback.classic) +} + +java { + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_24 + optIn.add("dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi") + } +} + +configurePublishedArtifact("BotCommands-typesafe-messages-bc") diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt rename to BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt rename to BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessorProvider.kt diff --git a/BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider b/BotCommands-typesafe-messages/bc/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider similarity index 100% rename from BotCommands-typesafe-messages/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider rename to BotCommands-typesafe-messages/bc/src/main/resources/META-INF/services/io.github.freya022.botcommands.api.core.reflect.ClassGraphProcessorProvider diff --git a/BotCommands-typesafe-messages/bc/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/DefaultIntegrationTest.kt b/BotCommands-typesafe-messages/bc/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/DefaultIntegrationTest.kt new file mode 100644 index 000000000..0567fa19c --- /dev/null +++ b/BotCommands-typesafe-messages/bc/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/DefaultIntegrationTest.kt @@ -0,0 +1,90 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap +import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate +import io.github.freya022.botcommands.api.localization.LocalizationMap +import io.github.freya022.botcommands.api.localization.LocalizationMapRequest +import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.interactions.DiscordLocale +import net.dv8tion.jda.api.interactions.Interaction +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultIntegrationTest { + + @Test + fun `Full test`() { + val context = BotCommands.create { + textCommands { enable = false } + applicationCommands { enable = false } + modals { enable = false } + + addClass() + addClass() + addClass() + } + + val interaction = mockk { + every { userLocale } returns DiscordLocale.FRENCH + every { guildLocale } returns DiscordLocale.FRENCH + } + val whatsTheFoxDoing = context.getService() + .create(interaction) + .whatsTheFoxDoing("jumps") + + assertEquals("The fox quickly jumps", whatsTheFoxDoing) + } + + @BService + class FakeBot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} + } + + @BService + class MyBundleReader( + private val context: BContext, + ) : LocalizationMapReader { + + override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { + if (request.baseName != "myBundle") return null + + return DefaultLocalizationMap( + request.requestedLocale, mapOf( + "whats.the.fox.doing" to DefaultLocalizationTemplate( + context, + "The fox quickly {action}", + request.requestedLocale + ), + ) + ) + } + } + + interface MyMessageSource : IMessageSource { + + @LocalizedContent("whats.the.fox.doing") + fun whatsTheFoxDoing(action: String): String + } + + @MessageSourceFactory("myBundle") + interface MyMessageSourceFactory : IMessageSourceFactory +} diff --git a/BotCommands-typesafe-messages/src/test/resources/logback-test.xml b/BotCommands-typesafe-messages/bc/src/test/resources/logback-test.xml similarity index 100% rename from BotCommands-typesafe-messages/src/test/resources/logback-test.xml rename to BotCommands-typesafe-messages/bc/src/test/resources/logback-test.xml diff --git a/BotCommands-typesafe-messages/core/build.gradle.kts b/BotCommands-typesafe-messages/core/build.gradle.kts new file mode 100644 index 000000000..65cfb0b5a --- /dev/null +++ b/BotCommands-typesafe-messages/core/build.gradle.kts @@ -0,0 +1,34 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + // -------------------- CORE DEPENDENCIES -------------------- + + api(projects.botCommandsCore) + + // Logging + implementation(libs.kotlin.logging) + + // -------------------- TEST DEPENDENCIES -------------------- + + testImplementation(libs.mockk) + testImplementation(libs.logback.classic) +} + +java { + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_24 + optIn.add("dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi") + } +} + +configurePublishedArtifact("BotCommands-typesafe-messages-core") diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSource.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/ExperimentalTypesafeMessagesApi.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/LocalizedContent.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceFactoryMethodException.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/AbstractMessageSourceMethodException.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt similarity index 97% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 7da89f64a..5d0216a5f 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -26,13 +26,13 @@ import java.lang.reflect.AccessFlag import kotlin.reflect.KClass import kotlin.reflect.jvm.jvmName -internal object MessageSourceFactoryGenerator { +object MessageSourceFactoryGenerator { private val CD_AbstractMessageSourceFactory = classDesc() private val CD_AbstractMessageSourceFactory_Params = classDesc() @Suppress("UNCHECKED_CAST") - internal fun , U : IMessageSource> createFactory( + fun , U : IMessageSource> createFactory( context: BContext, annotation: MessageSourceFactory, sourceFactoryType: KClass, diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/ClassDesc.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/CodeBuilder.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/LineNumber.kt diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt similarity index 100% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt rename to BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt similarity index 100% rename from BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt rename to BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt similarity index 100% rename from BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt rename to BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt diff --git a/BotCommands-typesafe-messages/core/src/test/resources/logback-test.xml b/BotCommands-typesafe-messages/core/src/test/resources/logback-test.xml new file mode 100644 index 000000000..34bef9537 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %boldCyan(%-26.-26thread) %boldYellow(%-20.-20logger{0}) %highlight(%-6level) %msg%n%throwable + + + + + + + + + diff --git a/BotCommands-typesafe-messages/build.gradle.kts b/BotCommands-typesafe-messages/spring/build.gradle.kts similarity index 88% rename from BotCommands-typesafe-messages/build.gradle.kts rename to BotCommands-typesafe-messages/spring/build.gradle.kts index e69c22d8f..43cd0fb8a 100644 --- a/BotCommands-typesafe-messages/build.gradle.kts +++ b/BotCommands-typesafe-messages/spring/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { // -------------------- CORE DEPENDENCIES -------------------- api(projects.botCommandsCore) + api(projects.botCommandsTypesafeMessages.core) // Logging implementation(libs.kotlin.logging) @@ -36,3 +37,5 @@ kotlin { optIn.add("dev.freya02.botcommands.typesafe.messages.api.annotations.ExperimentalTypesafeMessagesApi") } } + +configurePublishedArtifact("BotCommands-typesafe-messages-spring") diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt similarity index 98% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt rename to BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt index 4c4b494c8..68021f104 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryPostProcessor.kt +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.typesafe.messages.internal.processor +package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory diff --git a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt similarity index 83% rename from BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt rename to BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt index 11ac1ecb7..f0c7b951a 100644 --- a/BotCommands-typesafe-messages/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt @@ -1,6 +1,5 @@ package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure -import dev.freya02.botcommands.typesafe.messages.internal.processor.MessageSourceFactoryPostProcessor import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean diff --git a/BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/BotCommands-typesafe-messages/spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports similarity index 100% rename from BotCommands-typesafe-messages/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename to BotCommands-typesafe-messages/spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/BotCommands-typesafe-messages/spring/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt b/BotCommands-typesafe-messages/spring/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt new file mode 100644 index 000000000..2d27267d0 --- /dev/null +++ b/BotCommands-typesafe-messages/spring/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt @@ -0,0 +1,94 @@ +package dev.freya02.botcommands.typesafe.messages.integration + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap +import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate +import io.github.freya022.botcommands.api.localization.LocalizationMap +import io.github.freya022.botcommands.api.localization.LocalizationMapRequest +import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader +import io.mockk.every +import io.mockk.mockk +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.interactions.DiscordLocale +import net.dv8tion.jda.api.interactions.Interaction +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.stereotype.Service +import kotlin.test.Test +import kotlin.test.assertEquals + +@SpringBootTest(classes = [ + SpringIntegrationTest.Application::class, + SpringIntegrationTest.FakeBot::class, + SpringIntegrationTest.MyBundleReader::class, + SpringIntegrationTest.MyMessageSource::class, + SpringIntegrationTest.MyMessageSourceFactory::class +]) +class SpringIntegrationTest { + + @Autowired + private lateinit var messageSourceFactory: MyMessageSourceFactory + + @Test + fun `Full test`() { + val interaction = mockk { + every { userLocale } returns DiscordLocale.FRENCH + every { guildLocale } returns DiscordLocale.FRENCH + } + val whatsTheFoxDoing = messageSourceFactory + .create(interaction) + .whatsTheFoxDoing("jumps") + + assertEquals("The fox quickly jumps", whatsTheFoxDoing) + } + + @SpringBootApplication(scanBasePackages = ["dev.freya02.botcommands.typesafe.messages.integration"]) + open class Application + + @Service + class FakeBot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} + } + + @Service + class MyBundleReader( + private val context: BContext, + ) : LocalizationMapReader { + + override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { + if (request.baseName != "myBundle") return null + + return DefaultLocalizationMap( + request.requestedLocale, mapOf( + "whats.the.fox.doing" to DefaultLocalizationTemplate( + context, + "The fox quickly {action}", + request.requestedLocale + ), + ) + ) + } + } + + interface MyMessageSource : IMessageSource { + + @LocalizedContent("whats.the.fox.doing") + fun whatsTheFoxDoing(action: String): String + } + + @MessageSourceFactory("myBundle") + interface MyMessageSourceFactory : IMessageSourceFactory +} diff --git a/BotCommands-typesafe-messages/spring/src/test/resources/logback-test.xml b/BotCommands-typesafe-messages/spring/src/test/resources/logback-test.xml new file mode 100644 index 000000000..34bef9537 --- /dev/null +++ b/BotCommands-typesafe-messages/spring/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %boldCyan(%-26.-26thread) %boldYellow(%-20.-20logger{0}) %highlight(%-6level) %msg%n%throwable + + + + + + + + + diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt deleted file mode 100644 index 72369bebc..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/FakeBot.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages.integration - -import io.github.freya022.botcommands.api.core.JDAService -import io.github.freya022.botcommands.api.core.events.BReadyEvent -import io.github.freya022.botcommands.api.core.service.annotations.BService -import net.dv8tion.jda.api.hooks.IEventManager -import net.dv8tion.jda.api.requests.GatewayIntent -import net.dv8tion.jda.api.utils.cache.CacheFlag - -@BService -class FakeBot : JDAService() { - - override val intents: Set = emptySet() - override val cacheFlags: Set = emptySet() - - override fun createJDA(event: BReadyEvent, eventManager: IEventManager) {} -} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt deleted file mode 100644 index 50db0c973..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/IntegrationTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages.integration - -import io.github.freya022.botcommands.api.core.BotCommands -import io.github.freya022.botcommands.api.core.service.getService -import io.mockk.every -import io.mockk.mockk -import net.dv8tion.jda.api.interactions.DiscordLocale -import net.dv8tion.jda.api.interactions.Interaction -import kotlin.test.Test -import kotlin.test.assertEquals - -class IntegrationTest { - - @Test - fun `Full test`() { - val context = BotCommands.create { - textCommands { enable = false } - applicationCommands { enable = false } - modals { enable = false } - - addClass() - addClass() - addClass() - } - - val interaction = mockk { - every { userLocale } returns DiscordLocale.FRENCH - every { guildLocale } returns DiscordLocale.FRENCH - } - val whatsTheFoxDoing = context.getService() - .create(interaction) - .whatsTheFoxDoing("jumps") - - assertEquals("The fox quickly jumps", whatsTheFoxDoing) - } -} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt deleted file mode 100644 index 3d8dacf98..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyBundleReader.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages.integration - -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.localization.DefaultLocalizationMap -import io.github.freya022.botcommands.api.localization.DefaultLocalizationTemplate -import io.github.freya022.botcommands.api.localization.LocalizationMap -import io.github.freya022.botcommands.api.localization.LocalizationMapRequest -import io.github.freya022.botcommands.api.localization.readers.LocalizationMapReader - -@BService -class MyBundleReader( - private val context: BContext, -) : LocalizationMapReader { - - override fun readLocalizationMap(request: LocalizationMapRequest): LocalizationMap? { - if (request.baseName != "myBundle") return null - - return DefaultLocalizationMap( - request.requestedLocale, mapOf( - "whats.the.fox.doing" to DefaultLocalizationTemplate( - context, - "The fox quickly {action}", - request.requestedLocale - ), - ) - ) - } -} diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt deleted file mode 100644 index 3de0e3c10..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/MyMessageSource.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages.integration - -import dev.freya02.botcommands.typesafe.messages.api.IMessageSource -import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory -import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent -import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory - -interface MyMessageSource : IMessageSource { - - @LocalizedContent("whats.the.fox.doing") - fun whatsTheFoxDoing(action: String): String -} - -@MessageSourceFactory("myBundle") -interface MyMessageSourceFactory : IMessageSourceFactory diff --git a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt b/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt deleted file mode 100644 index 0168f79b2..000000000 --- a/BotCommands-typesafe-messages/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/integration/SpringIntegrationTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.freya02.botcommands.typesafe.messages.integration - -import io.mockk.every -import io.mockk.mockk -import net.dv8tion.jda.api.interactions.DiscordLocale -import net.dv8tion.jda.api.interactions.Interaction -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.test.context.SpringBootTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@SpringBootTest(classes = [SpringIntegrationTest.Application::class]) -class SpringIntegrationTest { - - @SpringBootApplication(scanBasePackages = ["dev.freya02.botcommands.typesafe.messages.integration"]) - open class Application - - @Autowired - private lateinit var messageSourceFactory: MyMessageSourceFactory - - @Test - fun `Full test`() { - val interaction = mockk { - every { userLocale } returns DiscordLocale.FRENCH - every { guildLocale } returns DiscordLocale.FRENCH - } - val whatsTheFoxDoing = messageSourceFactory - .create(interaction) - .whatsTheFoxDoing("jumps") - - assertEquals("The fox quickly jumps", whatsTheFoxDoing) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 9e145918e..f8a8c954e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,9 @@ dependencies { // Aggregated docs dokka(projects.botCommandsCore) dokka(projects.botCommandsSpring) + dokka(projects.botCommandsTypesafeMessages.core) + dokka(projects.botCommandsTypesafeMessages.bc) + dokka(projects.botCommandsTypesafeMessages.spring) } mavenPublishing { diff --git a/buildSrc/src/main/kotlin/tasks.kt b/buildSrc/src/main/kotlin/tasks.kt new file mode 100644 index 000000000..7cd207006 --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks.kt @@ -0,0 +1,28 @@ +import org.gradle.api.Project +import org.gradle.kotlin.dsl.* +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.dokka.gradle.DokkaExtension +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +/** + * Sets the provided [artifactId] as the Kotlin module name, Dokka module name & path, and Maven artifact ID. + */ +fun Project.configurePublishedArtifact(artifactId: String) { + tasks.withType { + compilerOptions { + // Match up with Dokka's module path, as the wiki reconstructs wiki links with the module name + moduleName = artifactId + } + } + + extensions.configure("dokka") { + // For display in the nav sidebar + moduleName = artifactId + // For URLs consistent with the Kotlin module name + modulePath = artifactId + } + + extensions.configure("mavenPublishing") { + coordinates(artifactId = artifactId) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 248114bc5..c199bbfea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,4 +5,8 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":BotCommands-core") include(":spring-properties-processor") include(":BotCommands-spring") -include(":BotCommands-typesafe-messages") +include( + ":BotCommands-typesafe-messages:core", + ":BotCommands-typesafe-messages:bc", + ":BotCommands-typesafe-messages:spring", +) From 0b1b6aa27efb6b8eca2d841d938e117f24661666 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:48:34 +0200 Subject: [PATCH 07/32] Add `@Component` on `@MessageSourceFactory` So IJ doesn't think it can't be autowired --- BotCommands-typesafe-messages/core/build.gradle.kts | 3 +++ .../typesafe/messages/api/annotations/MessageSourceFactory.kt | 3 +++ gradle/libs.versions.toml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/BotCommands-typesafe-messages/core/build.gradle.kts b/BotCommands-typesafe-messages/core/build.gradle.kts index 65cfb0b5a..578a30778 100644 --- a/BotCommands-typesafe-messages/core/build.gradle.kts +++ b/BotCommands-typesafe-messages/core/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { // Logging implementation(libs.kotlin.logging) + // Spring annotations + compileOnly(libs.spring.context) + // -------------------- TEST DEPENDENCIES -------------------- testImplementation(libs.mockk) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt index c48aa9a2d..1a9d60cdf 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt @@ -1,5 +1,8 @@ package dev.freya02.botcommands.typesafe.messages.api.annotations +import org.springframework.stereotype.Component + +@Component @ExperimentalTypesafeMessagesApi @MustBeDocumented @Target(AnnotationTarget.CLASS, AnnotationTarget.ANNOTATION_CLASS) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5dd906c56..e288012ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ java-string-similarity = "2.0.0" jsr305 = "3.0.2" jetbrains-annotations = "26.0.2" +spring = "6.2.8" spring-boot = "3.5.3" h2 = "2.3.232" @@ -103,6 +104,7 @@ java-string-similarity = { module = "info.debatty:java-string-similarity", versi jsr305 = { module = "com.google.code.findbugs:jsr305", version.ref = "jsr305" } jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } +spring-context = { module = "org.springframework:spring-context", version.ref = "spring" } spring-boot = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot" } spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot" } spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "spring-boot" } From 3c512b63f0b33827b731952c300b171baad8c270 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:47:40 +0200 Subject: [PATCH 08/32] Add README.md --- BotCommands-typesafe-messages/README.md | 161 ++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 BotCommands-typesafe-messages/README.md diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md new file mode 100644 index 000000000..770c1d459 --- /dev/null +++ b/BotCommands-typesafe-messages/README.md @@ -0,0 +1,161 @@ +# BotCommands module - Typesafe messages +This module allows you to define functions which retrieves translated messages, +without having to implement anything, alongside a few other benefits: +- Checks if the template key exists in the root bundle +- Checks if function parameters exists in your template's arguments +- Checks if parameters can be formatted (on a best effort) +- Removes magic strings from your business logic + +## Example +> [!INFO] +> This example will use Kotlin but any other language should work. + +### Creating a localization bundle +Let's start by creating a localization bundle at `src/main/resources/bc_localization/MyBotMessages.json`, +for our example it will contain a single localization template where: + +- The key is `bot.info` +- The template is `I am in {guild_count, number} {guild_count, choice, 0#guilds|1#guild|1 [!NOTE] +> None of the interfaces defined below need to be implemented, they will be implemented and registered automatically. + +#### Message source +Create an interface which extends `IMessageSource`, each function annotated with `@LocalizedContent` needs to return `String`, +in that annotation you will need to put the key present in your localization bundle: + +```kt +interface MyBotMessages : IMessageSource { + + // The function can have any name you want + // Parameter names are converted to snake_case for use in the template + @LocalizedContent("bot.info") + fun botInfo(guildCount: Int, uptimeMs: Long): String +} +``` + +[//]: # (TODO use Duration instead of Long for the uptime, explain about converters) + +You can of course add more functions with different templates if necessary. + +#### Message source factory + +Then, you need a way to get instances of `MyBotMessages`, create an interface extending `IMessageSourceFactory`, +and annotate it with `@MessageSourceFactory("MyBotMessages")`: +```kt +// The base name of the localization bundles to look at, +// which files it actually loads is based on the available LocalizationMapReader(s) +// and the effective locale +@MessageSourceFactory("MyBotMessages") +interface MyBotMessagesFactory : IMessageSourceFactory +``` + +This interface will allow you to create `MyBotMessages` instances from different objects, +such as `Interaction`. + +[//]: # (TODO add more object types, probably Locale/DiscordLocale) + +### Usage + +```kt +@Command +class SlashInfo( + // Inject our factory, instances of it are created automatically + private val botMessagesFactory: MyBotMessagesFactory, +) : ApplicationCommand() { + + @JDASlashCommand( + name = "info", + description = "Sends info about the bot", + ) + fun onSlashFox(event: GuildSlashEvent) { + // Create an instance from the current interaction + val botMessages = botMessagesFactory.create(event) + val response = botMessages.botInfo( + // Use named parameters to make the arguments clearer! + guildCount = event.jda.guildCache.size(), + uptimeMs = ManagementFactory.getRuntimeMXBean().uptime, + ) + + event.reply(response) + .setEphemeral(true) + .queue() + } +} +``` + +Try out `/info`! + +## Installation +There are different dependencies based on what dependency injection you use: + +![](https://img.shields.io/maven-central/v/io.github.freya022/BotCommands-typesafe-messages-core?versionPrefix=3) + +
+Built-in + +### Maven +```xml + + + io.github.freya022 + BotCommands-typesafe-messages-bc + VERSION + + +``` + +### Gradle +```gradle +repositories { + mavenCentral() +} + +dependencies { + implementation("io.github.freya022:BotCommands-typesafe-messages-bc:VERSION") +} +``` + +
+ +
+Spring + +### Maven +```xml + + + io.github.freya022 + BotCommands-typesafe-messages-spring + VERSION + + +``` + +### Gradle +```gradle +repositories { + mavenCentral() +} + +dependencies { + implementation("io.github.freya022:BotCommands-typesafe-messages-spring:VERSION") +} +``` + +
+ +Alternatively, you can use Jitpack to use **snapshot** versions, +you can refer to [the JDA wiki](https://jda.wiki/using-jda/using-new-features/) for more information. From 1540aa691fbeca2f33ec9f9fd2dfff6b218ae671 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:52:17 +0200 Subject: [PATCH 09/32] Fix README.md --- BotCommands-typesafe-messages/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md index 770c1d459..4c4e20d20 100644 --- a/BotCommands-typesafe-messages/README.md +++ b/BotCommands-typesafe-messages/README.md @@ -7,7 +7,7 @@ without having to implement anything, alongside a few other benefits: - Removes magic strings from your business logic ## Example -> [!INFO] +> [!NOTE] > This example will use Kotlin but any other language should work. ### Creating a localization bundle From 4721a80ddc488ec7c3946de38fe62f7df3b834f3 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:53:13 +0200 Subject: [PATCH 10/32] Fix README.md --- BotCommands-typesafe-messages/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md index 4c4e20d20..c514c2085 100644 --- a/BotCommands-typesafe-messages/README.md +++ b/BotCommands-typesafe-messages/README.md @@ -80,7 +80,7 @@ class SlashInfo( name = "info", description = "Sends info about the bot", ) - fun onSlashFox(event: GuildSlashEvent) { + fun onSlashInfo(event: GuildSlashEvent) { // Create an instance from the current interaction val botMessages = botMessagesFactory.create(event) val response = botMessages.botInfo( From 04e0e1e89079858b5e85b9328e9b928c97e1d3a9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:55:07 +0200 Subject: [PATCH 11/32] Fix README.md --- BotCommands-typesafe-messages/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md index c514c2085..bf6a0a678 100644 --- a/BotCommands-typesafe-messages/README.md +++ b/BotCommands-typesafe-messages/README.md @@ -15,7 +15,7 @@ Let's start by creating a localization bundle at `src/main/resources/bc_localiza for our example it will contain a single localization template where: - The key is `bot.info` -- The template is `I am in {guild_count, number} {guild_count, choice, 0#guilds|1#guild|1 Date: Thu, 10 Jul 2025 17:56:30 +0200 Subject: [PATCH 12/32] Fix README.md --- BotCommands-typesafe-messages/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md index bf6a0a678..dafe11ea0 100644 --- a/BotCommands-typesafe-messages/README.md +++ b/BotCommands-typesafe-messages/README.md @@ -15,8 +15,8 @@ Let's start by creating a localization bundle at `src/main/resources/bc_localiza for our example it will contain a single localization template where: - The key is `bot.info` -- The template is `I am in {guild_count, number} {guild_count, choice, 0#guilds|1#guild|1 Date: Fri, 11 Jul 2025 12:16:52 +0200 Subject: [PATCH 13/32] Check factories and sources are interfaces --- .../IllegalMessageSourceClassTypeException.kt | 3 ++ ...lMessageSourceFactoryClassTypeException.kt | 3 ++ .../codegen/MessageSourceFactoryGenerator.kt | 10 +++++ .../MessageSourceFactoryGeneratorTest.kt | 42 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceClassTypeException.kt create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceFactoryClassTypeException.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceClassTypeException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceClassTypeException.kt new file mode 100644 index 000000000..b44cd7bf3 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceClassTypeException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class IllegalMessageSourceClassTypeException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceFactoryClassTypeException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceFactoryClassTypeException.kt new file mode 100644 index 000000000..5a346943d --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceFactoryClassTypeException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class IllegalMessageSourceFactoryClassTypeException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 5d0216a5f..2e48249a7 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -4,6 +4,8 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc import dev.freya02.botcommands.typesafe.messages.internal.utils.isAbstract @@ -38,6 +40,14 @@ object MessageSourceFactoryGenerator { sourceFactoryType: KClass, sourceType: KClass, ): T { + if (!sourceFactoryType.java.isInterface) { + throw IllegalMessageSourceFactoryClassTypeException("${sourceFactoryType.jvmName} must be an interface!") + } + + if (!sourceType.java.isInterface) { + throw IllegalMessageSourceClassTypeException("${sourceType.jvmName} must be an interface!") + } + // The only abstract method should be the one we implement sourceFactoryType.java.methods // Look at abstract methods diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt index d8fa3cff9..92a9bce15 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt @@ -4,6 +4,8 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.service.getService @@ -29,6 +31,12 @@ class MessageSourceFactoryGeneratorTest { fun test(): String = "test" } + abstract class SourceFactoryAsAbstractClass : IMessageSourceFactory + + interface SourceFactoryWithAbstractClassSource : IMessageSourceFactory { + abstract class SourceAsAbstractClass : IMessageSource + } + @Test fun `Generate IMessageSourceFactory`() { val context = mockk { @@ -78,4 +86,38 @@ class MessageSourceFactoryGeneratorTest { sourceType = IMessageSource::class ) } + + @Test + fun `Cannot generate IMessageSourceFactory as abstract class`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + assertThrows { + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = SourceFactoryAsAbstractClass::class, + sourceType = IMessageSource::class, + ) + } + } + + @Test + fun `Cannot generate IMessageSource as abstract class`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + assertThrows { + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = SourceFactoryWithAbstractClassSource::class, + sourceType = SourceFactoryWithAbstractClassSource.SourceAsAbstractClass::class, + ) + } + } } From 7cffa66f11444655a451178ced8cda11c750ea84 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:17:06 +0200 Subject: [PATCH 14/32] Check source methods return String --- ...IllegalMessageSourceReturnTypeException.kt | 3 ++ .../codegen/MessageSourceGenerator.kt | 6 ++++ .../messages/MessageSourceGeneratorTest.kt | 32 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceReturnTypeException.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceReturnTypeException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceReturnTypeException.kt new file mode 100644 index 000000000..0812f3f5b --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/IllegalMessageSourceReturnTypeException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class IllegalMessageSourceReturnTypeException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index cb391ccac..ca76ffe4d 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -3,8 +3,10 @@ package dev.freya02.botcommands.typesafe.messages.internal.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.* import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName +import io.github.freya022.botcommands.api.core.utils.getSignature import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.api.core.utils.mapToArray import io.github.freya022.botcommands.api.localization.context.LocalizationContext @@ -87,6 +89,10 @@ internal object MessageSourceGenerator { } toImplement.forEach { method -> + if (method.returnType.jvmErasure != String::class) { + throw IllegalMessageSourceReturnTypeException("Method must return a String: ${method.getSignature(qualifiedClass = true, source = false)}") + } + val annotation = method.findAnnotation() ?: error("Method was to be implemented but annotation is absent") val templateParameters = method.valueParameters diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index 3b2a30b42..49752532d 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -3,12 +3,14 @@ package dev.freya02.botcommands.typesafe.messages import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import io.github.freya022.botcommands.api.localization.Localization import io.github.freya022.botcommands.api.localization.context.LocalizationContext import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows import kotlin.test.Test import kotlin.test.assertEquals @@ -43,7 +45,18 @@ class MessageSourceGeneratorTest { fun test(): String = "test" } - // TODO test works with interfaces only + interface SourceWithAbstractWithDiffReturnType : IMessageSource { + + @LocalizedContent("SourceWithAbstractWithDiffReturnType.key") + fun test() + } + + interface SourceWithConcreteWithDiffReturnType : IMessageSource { + + @LocalizedContent("SourceWithConcreteWithDiffReturnType.key") + fun test() { } + } + // TODO test return type is String enforced @Test @@ -93,4 +106,21 @@ class MessageSourceGeneratorTest { val source = MessageSourceGenerator.create(SourceWithoutAnnotationOnConcrete::class, localizationContext) assertEquals("test", source.test()) } + + @Test + fun `Cannot generate IMessageSource with abstract method returning non-String`() { + val localizationContext = mockk() + assertThrows { + MessageSourceGenerator.create(SourceWithAbstractWithDiffReturnType::class, localizationContext) + } + } + + @Test + fun `Generate IMessageSource with concrete method returning non-String`() { + val localizationContext = mockk() + assertDoesNotThrow { + val source = MessageSourceGenerator.create(SourceWithConcreteWithDiffReturnType::class, localizationContext) + source.test() + } + } } From ffafdc40c36a88a720a743c144d342174ddf9ad8 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:36:07 +0200 Subject: [PATCH 15/32] Convert template variable names to camel_case --- .../internal/codegen/MessageSourceGenerator.kt | 16 ++++++++++++++-- .../messages/MessageSourceGeneratorTest.kt | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index ca76ffe4d..4229ae425 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -117,14 +117,14 @@ internal object MessageSourceGenerator { codeBuilder.astore(localizationArgsSlot) templateParameters.withIndex().forEachIndexed { arrayIndex, (parameterIndex, parameter) -> - val paramName = parameter.name + val templateVarName = parameter.name?.convertToCamelCase() ?: error("Parameter names are absent from $method ; see https://bc.freya02.dev/3.X/using-botcommands/parameter-names/") // localizationEntry = new Localization.Entry(paramName, value) lineNumber.setAndIncrement() codeBuilder.new_(CD_Localization_Entry) codeBuilder.dup() // As doesn't return itself - codeBuilder.ldc(paramName) + codeBuilder.ldc(templateVarName) codeBuilder.loadLocal(TypeKind.from(parameter.type.jvmErasure.java), codeBuilder.parameterSlot(parameterIndex)) codeBuilder.boxIfNecessary(parameter.type.jvmErasure) codeBuilder.invokespecial( @@ -154,4 +154,16 @@ internal object MessageSourceGenerator { return MethodHandles.lookup().defineClass(sourceBytes) } + + private fun String.convertToCamelCase(): String { + val builder = StringBuilder(this.length * 2) + for (char in this) { + if (char.isUpperCase()) { + builder.append('_').append(char.lowercaseChar()) + } else { + builder.append(char) + } + } + return builder.toString() + } } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index 49752532d..5b717f7e1 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -57,6 +57,12 @@ class MessageSourceGeneratorTest { fun test() { } } + interface SourceWithCamelCaseArg : IMessageSource { + + @LocalizedContent("SourceWithCamelCaseArg.key") + fun test(myArg: String): String + } + // TODO test return type is String enforced @Test @@ -123,4 +129,15 @@ class MessageSourceGeneratorTest { source.test() } } + + @Test + fun `Generate IMessageSource with camelCase param converts to snake_case`() { + val localizationContext = mockk { + every { localize(any(), any()) } returns "expected" + } + val source = MessageSourceGenerator.create(SourceWithCamelCaseArg::class, localizationContext) + source.test("arg") + + verify(exactly = 1) { localizationContext.localize("SourceWithCamelCaseArg.key", Localization.Entry("my_arg", "arg")) } + } } From 9aebdbba41b3f45a570b6fc494f027f86fae0d13 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:20:39 +0200 Subject: [PATCH 16/32] Generate IMessageSource implementations while making the IMessageSourceFactory This allows running checks early Moved related tests, mock no-op MessageSourceGenerator when testing MessageSourceFactoryGenerator --- .../codegen/AbstractMessageSourceFactory.kt | 8 +-- .../codegen/MessageSourceFactoryGenerator.kt | 7 +-- .../codegen/MessageSourceGenerator.kt | 32 +++++------ .../MessageSourceFactoryGeneratorTest.kt | 37 +++++-------- .../messages/MessageSourceGeneratorTest.kt | 55 ++++++++++++++++--- 5 files changed, 81 insertions(+), 58 deletions(-) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt index ae12bb9ae..e24ea297f 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt @@ -7,14 +7,14 @@ import io.github.freya022.botcommands.api.localization.context.LocalizationConte import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider import net.dv8tion.jda.api.interactions.Interaction -import kotlin.reflect.KClass +import java.lang.invoke.MethodHandle internal abstract class AbstractMessageSourceFactory internal constructor( private val params: Params, ) : IMessageSourceFactory { override fun create(interaction: Interaction): IMessageSource { - val (localizationService, bundle, guildLocaleProvider, userLocaleProvider, sourceType) = params + val (localizationService, bundle, guildLocaleProvider, userLocaleProvider, sourceHandle) = params val localizationContext = LocalizationContext.create( localizationService = localizationService, @@ -24,7 +24,7 @@ internal abstract class AbstractMessageSourceFactory internal constructor( userLocale = userLocaleProvider.getDiscordLocale(interaction), ) - return MessageSourceGenerator.create(sourceType, localizationContext) + return MessageSourceGenerator.instantiate(sourceHandle, localizationContext) } internal data class Params( @@ -32,6 +32,6 @@ internal abstract class AbstractMessageSourceFactory internal constructor( internal val bundle: String, internal val guildLocaleProvider: GuildLocaleProvider, internal val userLocaleProvider: UserLocaleProvider, - internal val sourceType: KClass, + internal val sourceHandle: MethodHandle, ) } diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 2e48249a7..3b9f4fd6b 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -4,7 +4,6 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc @@ -44,10 +43,6 @@ object MessageSourceFactoryGenerator { throw IllegalMessageSourceFactoryClassTypeException("${sourceFactoryType.jvmName} must be an interface!") } - if (!sourceType.java.isInterface) { - throw IllegalMessageSourceClassTypeException("${sourceType.jvmName} must be an interface!") - } - // The only abstract method should be the one we implement sourceFactoryType.java.methods // Look at abstract methods @@ -65,7 +60,7 @@ object MessageSourceFactoryGenerator { annotation.bundleName, context.getService(), context.getService(), - sourceType, + MessageSourceGenerator.create(sourceType), ) val classFile = ClassFile.of() diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index 4229ae425..01767aeaf 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -3,6 +3,7 @@ package dev.freya02.botcommands.typesafe.messages.internal.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.* import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName @@ -16,11 +17,9 @@ import java.lang.classfile.attribute.SourceFileAttribute import java.lang.constant.ClassDesc import java.lang.constant.ConstantDescs.* import java.lang.constant.MethodTypeDesc +import java.lang.invoke.MethodHandle import java.lang.invoke.MethodHandles import java.lang.reflect.AccessFlag -import java.lang.reflect.Constructor -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock import kotlin.reflect.KClass import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation @@ -31,20 +30,11 @@ import kotlin.reflect.jvm.jvmName internal object MessageSourceGenerator { - private val lock = ReentrantLock() - private val cache: MutableMap, Constructor> = hashMapOf() - - @Suppress("UNCHECKED_CAST") - internal fun create( - sourceType: KClass, - localizationContext: LocalizationContext, - ): T = lock.withLock { - return cache.getOrPut(sourceType) { - generateClass(sourceType).declaredConstructors.single() as Constructor - }.newInstance(localizationContext) as T - } + internal fun create(sourceType: KClass): MethodHandle { + if (!sourceType.java.isInterface) { + throw IllegalMessageSourceClassTypeException("${sourceType.jvmName} must be an interface!") + } - private fun generateClass(sourceType: KClass): Class<*> { val abstractMethods = sourceType.memberFunctions.filter { it.isAbstract } val toImplement = abstractMethods.filter { it.hasAnnotation() } @@ -152,7 +142,10 @@ internal object MessageSourceGenerator { } } - return MethodHandles.lookup().defineClass(sourceBytes) + val lookup = MethodHandles.lookup() + return lookup.defineClass(sourceBytes) + .getConstructor(LocalizationContext::class.java) + .let(lookup::unreflectConstructor) } private fun String.convertToCamelCase(): String { @@ -166,4 +159,9 @@ internal object MessageSourceGenerator { } return builder.toString() } + + @Suppress("UNCHECKED_CAST") + internal fun instantiate(handle: MethodHandle, localizationContext: LocalizationContext): T { + return handle.invoke(localizationContext) as T + } } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt index 92a9bce15..9c9a222b1 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt @@ -4,9 +4,9 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.api.localization.LocalizationService @@ -14,6 +14,7 @@ import io.github.freya022.botcommands.api.localization.interaction.GuildLocalePr import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import org.junit.jupiter.api.assertThrows import kotlin.test.Test @@ -33,10 +34,6 @@ class MessageSourceFactoryGeneratorTest { abstract class SourceFactoryAsAbstractClass : IMessageSourceFactory - interface SourceFactoryWithAbstractClassSource : IMessageSourceFactory { - abstract class SourceAsAbstractClass : IMessageSource - } - @Test fun `Generate IMessageSourceFactory`() { val context = mockk { @@ -45,6 +42,9 @@ class MessageSourceFactoryGeneratorTest { every { getService() } returns mockk() } + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + MessageSourceFactoryGenerator.createFactory( context = context, annotation = MessageSourceFactory("testBundle"), @@ -61,6 +61,9 @@ class MessageSourceFactoryGeneratorTest { every { getService() } returns mockk() } + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + assertThrows { MessageSourceFactoryGenerator.createFactory( context = context, @@ -79,6 +82,9 @@ class MessageSourceFactoryGeneratorTest { every { getService() } returns mockk() } + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + MessageSourceFactoryGenerator.createFactory( context = context, annotation = MessageSourceFactory("testBundle"), @@ -94,6 +100,10 @@ class MessageSourceFactoryGeneratorTest { every { getService() } returns mockk() every { getService() } returns mockk() } + + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + assertThrows { MessageSourceFactoryGenerator.createFactory( context = context, @@ -103,21 +113,4 @@ class MessageSourceFactoryGeneratorTest { ) } } - - @Test - fun `Cannot generate IMessageSource as abstract class`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } - assertThrows { - MessageSourceFactoryGenerator.createFactory( - context = context, - annotation = MessageSourceFactory("testBundle"), - sourceFactoryType = SourceFactoryWithAbstractClassSource::class, - sourceType = SourceFactoryWithAbstractClassSource.SourceAsAbstractClass::class, - ) - } - } } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index 5b717f7e1..f999b93aa 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -1,22 +1,37 @@ package dev.freya02.botcommands.typesafe.messages import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator.instantiate +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.api.localization.Localization +import io.github.freya022.botcommands.api.localization.LocalizationService import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider +import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows +import kotlin.reflect.KClass import kotlin.test.Test import kotlin.test.assertEquals class MessageSourceGeneratorTest { + interface SourceFactoryWithAbstractClassSource : IMessageSourceFactory { + abstract class SourceAsAbstractClass : IMessageSource + } + interface SourceWithoutArgs : IMessageSource { @LocalizedContent("SourceWithoutArgs.key") @@ -63,14 +78,29 @@ class MessageSourceGeneratorTest { fun test(myArg: String): String } - // TODO test return type is String enforced + @Test + fun `Cannot generate IMessageSource as abstract class`() { + val context = mockk { + every { getService() } returns mockk() + every { getService() } returns mockk() + every { getService() } returns mockk() + } + assertThrows { + MessageSourceFactoryGenerator.createFactory( + context = context, + annotation = MessageSourceFactory("testBundle"), + sourceFactoryType = SourceFactoryWithAbstractClassSource::class, + sourceType = SourceFactoryWithAbstractClassSource.SourceAsAbstractClass::class, + ) + } + } @Test fun `Generate IMessageSource without params`() { val localizationContext = mockk { every { localize(any()) } returns "expected" } - val source = MessageSourceGenerator.create(SourceWithoutArgs::class, localizationContext) + val source = createAndInstantiate(SourceWithoutArgs::class, localizationContext) source.test() verify(exactly = 1) { localizationContext.localize("SourceWithoutArgs.key") } @@ -81,7 +111,7 @@ class MessageSourceGeneratorTest { val localizationContext = mockk { every { localize(any(), any()) } returns "expected" } - val source = MessageSourceGenerator.create(SourceWithArgs::class, localizationContext) + val source = createAndInstantiate(SourceWithArgs::class, localizationContext) source.test("42") verify(exactly = 1) { localizationContext.localize("SourceWithArgs.key", Localization.Entry("string", "42")) } @@ -92,7 +122,7 @@ class MessageSourceGeneratorTest { val localizationContext = mockk { every { localize(any(), any()) } returns "expected" } - val source = MessageSourceGenerator.create(SourceWithPrimitiveArgs::class, localizationContext) + val source = createAndInstantiate(SourceWithPrimitiveArgs::class, localizationContext) source.test(42) verify(exactly = 1) { localizationContext.localize("SourceWithPrimitiveArgs.key", Localization.Entry("integer", 42)) } @@ -102,14 +132,14 @@ class MessageSourceGeneratorTest { fun `Cannot generate IMessageSource without annotation on abstract method`() { val localizationContext = mockk() assertThrows { - MessageSourceGenerator.create(SourceWithoutAnnotationOnAbstract::class, localizationContext) + createAndInstantiate(SourceWithoutAnnotationOnAbstract::class, localizationContext) } } @Test fun `Generate IMessageSource without annotation on concrete method`() { val localizationContext = mockk() - val source = MessageSourceGenerator.create(SourceWithoutAnnotationOnConcrete::class, localizationContext) + val source = createAndInstantiate(SourceWithoutAnnotationOnConcrete::class, localizationContext) assertEquals("test", source.test()) } @@ -117,7 +147,7 @@ class MessageSourceGeneratorTest { fun `Cannot generate IMessageSource with abstract method returning non-String`() { val localizationContext = mockk() assertThrows { - MessageSourceGenerator.create(SourceWithAbstractWithDiffReturnType::class, localizationContext) + createAndInstantiate(SourceWithAbstractWithDiffReturnType::class, localizationContext) } } @@ -125,7 +155,7 @@ class MessageSourceGeneratorTest { fun `Generate IMessageSource with concrete method returning non-String`() { val localizationContext = mockk() assertDoesNotThrow { - val source = MessageSourceGenerator.create(SourceWithConcreteWithDiffReturnType::class, localizationContext) + val source = createAndInstantiate(SourceWithConcreteWithDiffReturnType::class, localizationContext) source.test() } } @@ -135,9 +165,16 @@ class MessageSourceGeneratorTest { val localizationContext = mockk { every { localize(any(), any()) } returns "expected" } - val source = MessageSourceGenerator.create(SourceWithCamelCaseArg::class, localizationContext) + val source = createAndInstantiate(SourceWithCamelCaseArg::class, localizationContext) source.test("arg") verify(exactly = 1) { localizationContext.localize("SourceWithCamelCaseArg.key", Localization.Entry("my_arg", "arg")) } } + + private fun createAndInstantiate( + sourceType: KClass, + localizationContext: LocalizationContext, + ): T { + return instantiate(MessageSourceGenerator.create(sourceType), localizationContext) + } } From 77d76e2611f86b11d43d2beb0619b3c29bad2eb1 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:24:45 +0200 Subject: [PATCH 17/32] Check for unsupported optional parameters --- .../UnsupportedSuspendFunctionException.kt | 3 +++ .../internal/codegen/MessageSourceGenerator.kt | 10 +++++++--- .../messages/MessageSourceGeneratorTest.kt | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedSuspendFunctionException.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedSuspendFunctionException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedSuspendFunctionException.kt new file mode 100644 index 000000000..e1954568b --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedSuspendFunctionException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class UnsupportedSuspendFunctionException(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index 01767aeaf..40a7cb76b 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -2,9 +2,7 @@ package dev.freya02.botcommands.typesafe.messages.internal.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent -import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.* import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.* import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName import io.github.freya022.botcommands.api.core.utils.getSignature @@ -87,6 +85,12 @@ internal object MessageSourceGenerator { ?: error("Method was to be implemented but annotation is absent") val templateParameters = method.valueParameters + templateParameters.forEach { parameter -> + if (parameter.isOptional) { + throw UnsupportedOptionalParameterException("Optional parameters is not supported! $parameter") + } + } + // TODO make sure a fallback message exists for the given "templateKey" classBuilder.withMethodBody( diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index f999b93aa..fed8cc77e 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -7,6 +7,7 @@ import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFa import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.UnsupportedOptionalParameterException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator.instantiate @@ -78,6 +79,12 @@ class MessageSourceGeneratorTest { fun test(myArg: String): String } + interface SourceWithOptionalArg: IMessageSource { + + @LocalizedContent("SourceWithOptionalArg.key") + fun test(myArg: String = "test"): String + } + @Test fun `Cannot generate IMessageSource as abstract class`() { val context = mockk { @@ -171,6 +178,14 @@ class MessageSourceGeneratorTest { verify(exactly = 1) { localizationContext.localize("SourceWithCamelCaseArg.key", Localization.Entry("my_arg", "arg")) } } + @Test + fun `Cannot generate IMessageSource with optional parameters`() { + val localizationContext = mockk() + assertThrows { + createAndInstantiate(SourceWithOptionalArg::class, localizationContext) + } + } + private fun createAndInstantiate( sourceType: KClass, localizationContext: LocalizationContext, From 110c458401d278d4950944cfa4ab0ee18f52b56d Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 17:28:05 +0200 Subject: [PATCH 18/32] Check for unsupported suspend functions --- .../UnsupportedOptionalParameterException.kt | 3 +++ .../codegen/MessageSourceGenerator.kt | 4 ++++ .../messages/MessageSourceGeneratorTest.kt | 19 +++++++++++++++---- 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedOptionalParameterException.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedOptionalParameterException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedOptionalParameterException.kt new file mode 100644 index 000000000..33c07cf58 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedOptionalParameterException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class UnsupportedOptionalParameterException(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index 40a7cb76b..b18d377da 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -77,6 +77,10 @@ internal object MessageSourceGenerator { } toImplement.forEach { method -> + if (method.isSuspend) { + throw UnsupportedSuspendFunctionException("Suspend functions are not supported! ${method.getSignature(qualifiedClass = true, source = false)}") + } + if (method.returnType.jvmErasure != String::class) { throw IllegalMessageSourceReturnTypeException("Method must return a String: ${method.getSignature(qualifiedClass = true, source = false)}") } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index fed8cc77e..67144d5d9 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -4,10 +4,7 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory -import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceMethodException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceClassTypeException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceReturnTypeException -import dev.freya02.botcommands.typesafe.messages.api.exceptions.UnsupportedOptionalParameterException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.* import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator.instantiate @@ -85,6 +82,12 @@ class MessageSourceGeneratorTest { fun test(myArg: String = "test"): String } + interface SourceWithSuspendFunction: IMessageSource { + + @LocalizedContent("SourceWithSuspendFunction.key") + suspend fun test(): String + } + @Test fun `Cannot generate IMessageSource as abstract class`() { val context = mockk { @@ -186,6 +189,14 @@ class MessageSourceGeneratorTest { } } + @Test + fun `Cannot generate IMessageSource with suspend functions`() { + val localizationContext = mockk() + assertThrows { + createAndInstantiate(SourceWithSuspendFunction::class, localizationContext) + } + } + private fun createAndInstantiate( sourceType: KClass, localizationContext: LocalizationContext, From 46531e249ca856d4d9342a702a569831177f3fff Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:15:01 +0200 Subject: [PATCH 19/32] Refactor `@LocalizationContent` generator and checks --- .../codegen/MessageSourceGenerator.kt | 172 +++++++++--------- .../messages/internal/utils/Checks.kt | 16 ++ .../messages/internal/utils/Reflection.kt | 4 + .../messages/internal/utils/Strings.kt | 13 ++ 4 files changed, 118 insertions(+), 87 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Checks.kt create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Strings.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index b18d377da..968b9b7fa 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -4,11 +4,15 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.* import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.* +import dev.freya02.botcommands.typesafe.messages.internal.utils.convertToCamelCase +import dev.freya02.botcommands.typesafe.messages.internal.utils.isRequired +import dev.freya02.botcommands.typesafe.messages.internal.utils.require import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName import io.github.freya022.botcommands.api.core.utils.getSignature import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.api.core.utils.mapToArray import io.github.freya022.botcommands.api.localization.context.LocalizationContext +import java.lang.classfile.ClassBuilder import java.lang.classfile.ClassFile import java.lang.classfile.TypeKind import java.lang.classfile.attribute.SourceFileAttribute @@ -19,6 +23,7 @@ import java.lang.invoke.MethodHandle import java.lang.invoke.MethodHandles import java.lang.reflect.AccessFlag import kotlin.reflect.KClass +import kotlin.reflect.KFunction import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.memberFunctions @@ -29,16 +34,16 @@ import kotlin.reflect.jvm.jvmName internal object MessageSourceGenerator { internal fun create(sourceType: KClass): MethodHandle { - if (!sourceType.java.isInterface) { - throw IllegalMessageSourceClassTypeException("${sourceType.jvmName} must be an interface!") + require(sourceType.java.isInterface, ::IllegalMessageSourceClassTypeException) { + "${sourceType.jvmName} must be an interface!" } val abstractMethods = sourceType.memberFunctions.filter { it.isAbstract } val toImplement = abstractMethods.filter { it.hasAnnotation() } (abstractMethods - toImplement).also { unimplementedMethods -> - if (unimplementedMethods.isNotEmpty()) { - throw AbstractMessageSourceMethodException("Abstract methods in ${sourceType.jvmName} can only be implemented if annotated with @${LocalizedContent::class.java.simpleName}:\n${unimplementedMethods.joinAsList()}") + require(unimplementedMethods.isEmpty(), ::AbstractMessageSourceMethodException) { + "Abstract methods in ${sourceType.jvmName} can only be implemented if annotated with @${LocalizedContent::class.java.simpleName}:\n${unimplementedMethods.joinAsList()}" } } @@ -76,77 +81,8 @@ internal object MessageSourceGenerator { codeBuilder.return_() } - toImplement.forEach { method -> - if (method.isSuspend) { - throw UnsupportedSuspendFunctionException("Suspend functions are not supported! ${method.getSignature(qualifiedClass = true, source = false)}") - } - - if (method.returnType.jvmErasure != String::class) { - throw IllegalMessageSourceReturnTypeException("Method must return a String: ${method.getSignature(qualifiedClass = true, source = false)}") - } - - val annotation = method.findAnnotation() - ?: error("Method was to be implemented but annotation is absent") - val templateParameters = method.valueParameters - - templateParameters.forEach { parameter -> - if (parameter.isOptional) { - throw UnsupportedOptionalParameterException("Optional parameters is not supported! $parameter") - } - } - - // TODO make sure a fallback message exists for the given "templateKey" - - classBuilder.withMethodBody( - method.name, - MethodTypeDesc.of(method.returnType.jvmErasure.toClassDesc(), *method.valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() }), - ClassFile.ACC_PUBLIC or ClassFile.ACC_FINAL, - ) { codeBuilder -> - val lineNumber = LineNumber(codeBuilder) - - val thisSlot = codeBuilder.receiverSlot() - val localizationArgsSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) - val localizationEntrySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) - - // var localizationArgs = new Localization.Entry[templateParameters.size]; - lineNumber.setAndIncrement() - codeBuilder.loadConstant(templateParameters.size) - codeBuilder.anewarray(CD_Localization_Entry) - codeBuilder.astore(localizationArgsSlot) - - templateParameters.withIndex().forEachIndexed { arrayIndex, (parameterIndex, parameter) -> - val templateVarName = parameter.name?.convertToCamelCase() - ?: error("Parameter names are absent from $method ; see https://bc.freya02.dev/3.X/using-botcommands/parameter-names/") - - // localizationEntry = new Localization.Entry(paramName, value) - lineNumber.setAndIncrement() - codeBuilder.new_(CD_Localization_Entry) - codeBuilder.dup() // As doesn't return itself - codeBuilder.ldc(templateVarName) - codeBuilder.loadLocal(TypeKind.from(parameter.type.jvmErasure.java), codeBuilder.parameterSlot(parameterIndex)) - codeBuilder.boxIfNecessary(parameter.type.jvmErasure) - codeBuilder.invokespecial( - CD_Localization_Entry, - INIT_NAME, MethodTypeDesc.of(CD_void, CD_String, CD_Object)) - codeBuilder.astore(localizationEntrySlot) - - // localizationArgs[i] = localizationEntry - lineNumber.setAndIncrement() - codeBuilder.aload(localizationArgsSlot) - codeBuilder.loadConstant(arrayIndex) - codeBuilder.aload(localizationEntrySlot) - codeBuilder.aastore() - } - - // return this.localizationContext.localize("", localizationArgs) - lineNumber.setAndIncrement() - codeBuilder.aload(thisSlot) - codeBuilder.getfield(thisClass, "localizationContext", CD_LocalizationContext) - codeBuilder.ldc(annotation.templateKey) - codeBuilder.aload(localizationArgsSlot) - codeBuilder.invokeinterface(CD_LocalizationContext, "localize", MethodTypeDesc.of(CD_String, CD_String, CD_Localization_Entry.arrayType())) - codeBuilder.areturn() - } + toImplement.forEach { function -> + LocalizedContentFunctionGenerator.create(thisClass, classBuilder, function) } } @@ -156,20 +92,82 @@ internal object MessageSourceGenerator { .let(lookup::unreflectConstructor) } - private fun String.convertToCamelCase(): String { - val builder = StringBuilder(this.length * 2) - for (char in this) { - if (char.isUpperCase()) { - builder.append('_').append(char.lowercaseChar()) - } else { - builder.append(char) - } - } - return builder.toString() - } - @Suppress("UNCHECKED_CAST") internal fun instantiate(handle: MethodHandle, localizationContext: LocalizationContext): T { return handle.invoke(localizationContext) as T } } + +private object LocalizedContentFunctionGenerator { + + fun create(thisClass: ClassDesc, classBuilder: ClassBuilder, function: KFunction<*>) { + require(!function.isSuspend, ::UnsupportedSuspendFunctionException) { + "Suspend functions are not supported! ${function.getSignature(qualifiedClass = true, source = false)}" + } + + require(function.returnType.jvmErasure == String::class, ::IllegalMessageSourceReturnTypeException) { + "Function must return a String: ${function.getSignature(qualifiedClass = true, source = false)}" + } + + val annotation = function.findAnnotation() + ?: error("Function was to be implemented but annotation is absent") + val templateParameters = function.valueParameters.onEach { parameter -> + require(parameter.isRequired, ::UnsupportedOptionalParameterException) { + "Optional parameters are not supported! $parameter" + } + } + + // TODO make sure a fallback message exists for the given "templateKey" + + classBuilder.withMethodBody( + function.name, + MethodTypeDesc.of(function.returnType.jvmErasure.toClassDesc(), *function.valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() }), + ClassFile.ACC_PUBLIC or ClassFile.ACC_FINAL, + ) { codeBuilder -> + val lineNumber = LineNumber(codeBuilder) + + val thisSlot = codeBuilder.receiverSlot() + val localizationArgsSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val localizationEntrySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // var localizationArgs = new Localization.Entry[templateParameters.size]; + lineNumber.setAndIncrement() + codeBuilder.loadConstant(templateParameters.size) + codeBuilder.anewarray(CD_Localization_Entry) + codeBuilder.astore(localizationArgsSlot) + + templateParameters.withIndex().forEachIndexed { arrayIndex, (parameterIndex, parameter) -> + val templateVarName = parameter.name?.convertToCamelCase() + ?: error("Parameter names are absent from $function ; see https://bc.freya02.dev/3.X/using-botcommands/parameter-names/") + + // localizationEntry = new Localization.Entry(paramName, value) + lineNumber.setAndIncrement() + codeBuilder.new_(CD_Localization_Entry) + codeBuilder.dup() // As doesn't return itself + codeBuilder.ldc(templateVarName) + codeBuilder.loadLocal(TypeKind.from(parameter.type.jvmErasure.java), codeBuilder.parameterSlot(parameterIndex)) + codeBuilder.boxIfNecessary(parameter.type.jvmErasure) + codeBuilder.invokespecial( + CD_Localization_Entry, + INIT_NAME, MethodTypeDesc.of(CD_void, CD_String, CD_Object)) + codeBuilder.astore(localizationEntrySlot) + + // localizationArgs[i] = localizationEntry + lineNumber.setAndIncrement() + codeBuilder.aload(localizationArgsSlot) + codeBuilder.loadConstant(arrayIndex) + codeBuilder.aload(localizationEntrySlot) + codeBuilder.aastore() + } + + // return this.localizationContext.localize("", localizationArgs) + lineNumber.setAndIncrement() + codeBuilder.aload(thisSlot) + codeBuilder.getfield(thisClass, "localizationContext", CD_LocalizationContext) + codeBuilder.ldc(annotation.templateKey) + codeBuilder.aload(localizationArgsSlot) + codeBuilder.invokeinterface(CD_LocalizationContext, "localize", MethodTypeDesc.of(CD_String, CD_String, CD_Localization_Entry.arrayType())) + codeBuilder.areturn() + } + } +} diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Checks.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Checks.kt new file mode 100644 index 000000000..7a9fb02f8 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Checks.kt @@ -0,0 +1,16 @@ +@file:OptIn(ExperimentalContracts::class) + +package dev.freya02.botcommands.typesafe.messages.internal.utils + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +internal inline fun require(value: Boolean, exceptionFactory: (String) -> Throwable, lazyMessage: () -> String) { + contract { + returns() implies value + } + + if (!value) { + throw exceptionFactory(lazyMessage()) + } +} diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt index 26aa6562b..1c7c26300 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt @@ -4,6 +4,7 @@ import io.github.freya022.botcommands.api.core.utils.simpleNestedName import java.lang.reflect.Method import java.lang.reflect.Modifier import kotlin.reflect.KClass +import kotlin.reflect.KParameter internal fun Method.isAbstract(): Boolean { return (modifiers and Modifier.ABSTRACT) == Modifier.ABSTRACT @@ -11,3 +12,6 @@ internal fun Method.isAbstract(): Boolean { internal val KClass<*>.simpleNestedBinaryName: String get() = simpleNestedName.replace('.', '$') + +internal val KParameter.isRequired + inline get() = !isOptional diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Strings.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Strings.kt new file mode 100644 index 000000000..d50c7640b --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Strings.kt @@ -0,0 +1,13 @@ +package dev.freya02.botcommands.typesafe.messages.internal.utils + +internal fun String.convertToCamelCase(): String { + val builder = StringBuilder(this.length * 2) + for (char in this) { + if (char.isUpperCase()) { + builder.append('_').append(char.lowercaseChar()) + } else { + builder.append(char) + } + } + return builder.toString() +} From 95f45256618ded9e1fe256ca187a81a0bfa00d3f Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:15:12 +0200 Subject: [PATCH 20/32] Refactor MessageSourceFactoryGenerator checks --- .../internal/codegen/MessageSourceFactoryGenerator.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 3b9f4fd6b..f3cd1cb0d 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -8,6 +8,7 @@ import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSo import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc import dev.freya02.botcommands.typesafe.messages.internal.utils.isAbstract +import dev.freya02.botcommands.typesafe.messages.internal.utils.require import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.service.getService @@ -39,8 +40,8 @@ object MessageSourceFactoryGenerator { sourceFactoryType: KClass, sourceType: KClass, ): T { - if (!sourceFactoryType.java.isInterface) { - throw IllegalMessageSourceFactoryClassTypeException("${sourceFactoryType.jvmName} must be an interface!") + require(sourceFactoryType.java.isInterface, ::IllegalMessageSourceFactoryClassTypeException) { + "${sourceFactoryType.jvmName} must be an interface!" } // The only abstract method should be the one we implement @@ -50,8 +51,8 @@ object MessageSourceFactoryGenerator { // Remove methods we implement .filterNot { it.name == "create" && it.parameterTypes.getOrNull(0) == Interaction::class.java && it.returnType == IMessageSource::class.java } .also { unimplementedMethods -> - if (unimplementedMethods.isNotEmpty()) { - throw AbstractMessageSourceFactoryMethodException("${sourceFactoryType.jvmName} cannot contain abstract methods:\n${unimplementedMethods.joinAsList()}") + require(unimplementedMethods.isEmpty(), ::AbstractMessageSourceFactoryMethodException) { + "${sourceFactoryType.jvmName} cannot contain abstract methods:\n${unimplementedMethods.joinAsList()}" } } From 3ed22948c36f4d5baff3c054193a66ecebaf1f88 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:43:01 +0200 Subject: [PATCH 21/32] Check Localization.Entry arguments are non-null --- .../botcommands/api/localization/Localization.java | 7 +++++++ .../UnsupportedNullableParameterException.kt | 3 +++ .../internal/codegen/MessageSourceGenerator.kt | 4 ++++ .../messages/MessageSourceGeneratorTest.kt | 14 ++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedNullableParameterException.kt diff --git a/BotCommands-core/src/main/java/io/github/freya022/botcommands/api/localization/Localization.java b/BotCommands-core/src/main/java/io/github/freya022/botcommands/api/localization/Localization.java index edc35ea87..1348acb0b 100644 --- a/BotCommands-core/src/main/java/io/github/freya022/botcommands/api/localization/Localization.java +++ b/BotCommands-core/src/main/java/io/github/freya022/botcommands/api/localization/Localization.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.NotNull; import java.util.Locale; +import java.util.Objects; /** * Low-level interface for localization. @@ -20,6 +21,12 @@ */ public interface Localization extends LocalizationMap { record Entry(@NotNull String argumentName, @NotNull Object value) { + + public Entry { + Objects.requireNonNull(argumentName, "Argument name must not be null"); + Objects.requireNonNull(value, "Value must not be null"); + } + /** * Create a new localization entry, * this binds a {@link LocalizationTemplate localization template} argument with the value. diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedNullableParameterException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedNullableParameterException.kt new file mode 100644 index 000000000..10b70bbc9 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/UnsupportedNullableParameterException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class UnsupportedNullableParameterException(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index 968b9b7fa..dfbada239 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -115,6 +115,10 @@ private object LocalizedContentFunctionGenerator { require(parameter.isRequired, ::UnsupportedOptionalParameterException) { "Optional parameters are not supported! $parameter" } + + require(!parameter.type.isMarkedNullable, ::UnsupportedNullableParameterException) { + "Nullable parameters are not allowed! $parameter" + } } // TODO make sure a fallback message exists for the given "templateKey" diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index 67144d5d9..f71e01446 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -88,6 +88,12 @@ class MessageSourceGeneratorTest { suspend fun test(): String } + interface SourceWithNullableArg: IMessageSource { + + @LocalizedContent("SourceWithNullableArg.key") + fun test(arg: String?): String + } + @Test fun `Cannot generate IMessageSource as abstract class`() { val context = mockk { @@ -197,6 +203,14 @@ class MessageSourceGeneratorTest { } } + @Test + fun `Cannot generate IMessageSource with nullable parameters`() { + val localizationContext = mockk() + assertThrows { + createAndInstantiate(SourceWithNullableArg::class, localizationContext) + } + } + private fun createAndInstantiate( sourceType: KClass, localizationContext: LocalizationContext, From 675eca11d77bb3fec75a23cb500791e37e0e212b Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:24:37 +0200 Subject: [PATCH 22/32] Reduce redundancy in MessageSourceGeneratorTest --- ...MessageSourceFactoryClassGraphProcessor.kt | 2 +- .../messages/MessageSourceGeneratorTest.kt | 43 ++++--------------- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt index 697058e74..cbe201852 100644 --- a/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt +++ b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -35,7 +35,7 @@ internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context -> MessageSourceFactoryGenerator.createFactory( context, - annotation, + annotation.bundleName, messageSourceFactoryType, messageSourceType ) diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt index f71e01446..c307b7e5b 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt @@ -1,20 +1,12 @@ package dev.freya02.botcommands.typesafe.messages import dev.freya02.botcommands.typesafe.messages.api.IMessageSource -import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent -import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.* -import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator.instantiate -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.api.localization.Localization -import io.github.freya022.botcommands.api.localization.LocalizationService import io.github.freya022.botcommands.api.localization.context.LocalizationContext -import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider -import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -26,9 +18,7 @@ import kotlin.test.assertEquals class MessageSourceGeneratorTest { - interface SourceFactoryWithAbstractClassSource : IMessageSourceFactory { - abstract class SourceAsAbstractClass : IMessageSource - } + abstract class SourceAsAbstractClass : IMessageSource interface SourceWithoutArgs : IMessageSource { @@ -96,18 +86,8 @@ class MessageSourceGeneratorTest { @Test fun `Cannot generate IMessageSource as abstract class`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } assertThrows { - MessageSourceFactoryGenerator.createFactory( - context = context, - annotation = MessageSourceFactory("testBundle"), - sourceFactoryType = SourceFactoryWithAbstractClassSource::class, - sourceType = SourceFactoryWithAbstractClassSource.SourceAsAbstractClass::class, - ) + MessageSourceGenerator.create(SourceAsAbstractClass::class) } } @@ -146,9 +126,8 @@ class MessageSourceGeneratorTest { @Test fun `Cannot generate IMessageSource without annotation on abstract method`() { - val localizationContext = mockk() assertThrows { - createAndInstantiate(SourceWithoutAnnotationOnAbstract::class, localizationContext) + MessageSourceGenerator.create(SourceWithoutAnnotationOnAbstract::class) } } @@ -161,18 +140,15 @@ class MessageSourceGeneratorTest { @Test fun `Cannot generate IMessageSource with abstract method returning non-String`() { - val localizationContext = mockk() assertThrows { - createAndInstantiate(SourceWithAbstractWithDiffReturnType::class, localizationContext) + MessageSourceGenerator.create(SourceWithAbstractWithDiffReturnType::class) } } @Test fun `Generate IMessageSource with concrete method returning non-String`() { - val localizationContext = mockk() assertDoesNotThrow { - val source = createAndInstantiate(SourceWithConcreteWithDiffReturnType::class, localizationContext) - source.test() + MessageSourceGenerator.create(SourceWithConcreteWithDiffReturnType::class) } } @@ -189,25 +165,22 @@ class MessageSourceGeneratorTest { @Test fun `Cannot generate IMessageSource with optional parameters`() { - val localizationContext = mockk() assertThrows { - createAndInstantiate(SourceWithOptionalArg::class, localizationContext) + MessageSourceGenerator.create(SourceWithOptionalArg::class) } } @Test fun `Cannot generate IMessageSource with suspend functions`() { - val localizationContext = mockk() assertThrows { - createAndInstantiate(SourceWithSuspendFunction::class, localizationContext) + MessageSourceGenerator.create(SourceWithSuspendFunction::class) } } @Test fun `Cannot generate IMessageSource with nullable parameters`() { - val localizationContext = mockk() assertThrows { - createAndInstantiate(SourceWithNullableArg::class, localizationContext) + MessageSourceGenerator.create(SourceWithNullableArg::class) } } From bc8b208089f4d6426494b6af4f95bcdfc386c8ac Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:24:53 +0200 Subject: [PATCH 23/32] Pass bundle name directly to MessageSourceFactoryGenerator --- .../internal/codegen/MessageSourceFactoryGenerator.kt | 5 ++--- .../messages/MessageSourceFactoryGeneratorTest.kt | 9 ++++----- .../autoconfigure/MessageSourceFactoryPostProcessor.kt | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index f3cd1cb0d..8aedfeecc 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -2,7 +2,6 @@ package dev.freya02.botcommands.typesafe.messages.internal.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory -import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber @@ -36,7 +35,7 @@ object MessageSourceFactoryGenerator { @Suppress("UNCHECKED_CAST") fun , U : IMessageSource> createFactory( context: BContext, - annotation: MessageSourceFactory, + bundleName: String, sourceFactoryType: KClass, sourceType: KClass, ): T { @@ -58,7 +57,7 @@ object MessageSourceFactoryGenerator { val params = AbstractMessageSourceFactory.Params( context.getService(), - annotation.bundleName, + bundleName, context.getService(), context.getService(), MessageSourceGenerator.create(sourceType), diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt index 9c9a222b1..40c13c939 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt @@ -2,7 +2,6 @@ package dev.freya02.botcommands.typesafe.messages import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory -import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator @@ -47,7 +46,7 @@ class MessageSourceFactoryGeneratorTest { MessageSourceFactoryGenerator.createFactory( context = context, - annotation = MessageSourceFactory("testBundle"), + bundleName = "testBundle", sourceFactoryType = Factory::class, sourceType = IMessageSource::class ) @@ -67,7 +66,7 @@ class MessageSourceFactoryGeneratorTest { assertThrows { MessageSourceFactoryGenerator.createFactory( context = context, - annotation = MessageSourceFactory("testBundle"), + bundleName = "testBundle", sourceFactoryType = FactoryWithoutAnnotationOnAbstract::class, sourceType = IMessageSource::class ) @@ -87,7 +86,7 @@ class MessageSourceFactoryGeneratorTest { MessageSourceFactoryGenerator.createFactory( context = context, - annotation = MessageSourceFactory("testBundle"), + bundleName = "testBundle", sourceFactoryType = FactoryWithoutAnnotationOnConcrete::class, sourceType = IMessageSource::class ) @@ -107,7 +106,7 @@ class MessageSourceFactoryGeneratorTest { assertThrows { MessageSourceFactoryGenerator.createFactory( context = context, - annotation = MessageSourceFactory("testBundle"), + bundleName = "testBundle", sourceFactoryType = SourceFactoryAsAbstractClass::class, sourceType = IMessageSource::class, ) diff --git a/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt index 68021f104..c560e65cf 100644 --- a/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt @@ -55,7 +55,7 @@ internal class MessageSourceFactoryPostProcessor internal constructor( BeanDefinitionBuilder.genericBeanDefinition(messageSourceFactoryType.java) { MessageSourceFactoryGenerator.createFactory( context.getBean(), - annotation, + annotation.bundleName, messageSourceFactoryType, messageSourceType ) From 50ffd4d6180979c83e266ec81c61d91dd47abc5e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:48:57 +0200 Subject: [PATCH 24/32] Create a provider of MessageSourceFactory This allows creating the factory with the minimum details, and to make actual instances using the returned callback Allows actual applications to run the checks before the beans get created Also simplifies tests that don't need actual instances --- ...MessageSourceFactoryClassGraphProcessor.kt | 18 ++----- .../internal/MessageSourceFactoryProvider.kt | 8 +++ .../codegen/MessageSourceFactoryGenerator.kt | 37 ++++++++------ .../MessageSourceFactoryGeneratorTest.kt | 49 +++---------------- .../MessageSourceFactoryPostProcessor.kt | 19 ++----- 5 files changed, 49 insertions(+), 82 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/MessageSourceFactoryProvider.kt diff --git a/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt index cbe201852..76906cf24 100644 --- a/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt +++ b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -1,6 +1,5 @@ package dev.freya02.botcommands.typesafe.messages.internal.processor -import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator @@ -10,9 +9,7 @@ import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.ServiceSupplier import io.github.freya022.botcommands.api.core.utils.shortQualifiedName -import io.github.freya022.botcommands.internal.utils.superErasureAt import kotlin.reflect.KClass -import kotlin.reflect.jvm.jvmErasure internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { @@ -22,23 +19,18 @@ internal object MessageSourceFactoryClassGraphProcessor : ClassGraphProcessor { val annotation = classInfo.getAnnotationInfo(MessageSourceFactory::class.java)?.loadClassAndInstantiate() as MessageSourceFactory? ?: return - require(classInfo.isInterface) { - "${classInfo.shortQualifiedName} must be an interface" - } require(classInfo.implementsInterface(IMessageSourceFactory::class.java)) { "${classInfo.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" } val messageSourceFactoryType = kClass as KClass> - val messageSourceType = kClass.superErasureAt>(0).jvmErasure as KClass + val sourceFactoryProvider = MessageSourceFactoryGenerator.createProvider( + annotation.bundleName, + messageSourceFactoryType, + ) serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context -> - MessageSourceFactoryGenerator.createFactory( - context, - annotation.bundleName, - messageSourceFactoryType, - messageSourceType - ) + sourceFactoryProvider.get(context) }) } } diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/MessageSourceFactoryProvider.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/MessageSourceFactoryProvider.kt new file mode 100644 index 000000000..254f133ef --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/MessageSourceFactoryProvider.kt @@ -0,0 +1,8 @@ +package dev.freya02.botcommands.typesafe.messages.internal + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import io.github.freya022.botcommands.api.core.BContext + +fun interface MessageSourceFactoryProvider> { + fun get(context: BContext): T +} diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 8aedfeecc..ec33b1ed2 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -4,6 +4,7 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageSourceFactoryMethodException import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException +import dev.freya02.botcommands.typesafe.messages.internal.MessageSourceFactoryProvider import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc import dev.freya02.botcommands.typesafe.messages.internal.utils.isAbstract @@ -15,6 +16,7 @@ import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.api.localization.LocalizationService import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider +import io.github.freya022.botcommands.internal.utils.superErasureAt import net.dv8tion.jda.api.interactions.Interaction import java.lang.classfile.ClassFile import java.lang.classfile.ClassFile.* @@ -25,6 +27,7 @@ import java.lang.constant.MethodTypeDesc import java.lang.invoke.MethodHandles import java.lang.reflect.AccessFlag import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmName object MessageSourceFactoryGenerator { @@ -33,12 +36,10 @@ object MessageSourceFactoryGenerator { private val CD_AbstractMessageSourceFactory_Params = classDesc() @Suppress("UNCHECKED_CAST") - fun , U : IMessageSource> createFactory( - context: BContext, + fun > createProvider( bundleName: String, sourceFactoryType: KClass, - sourceType: KClass, - ): T { + ): MessageSourceFactoryProvider { require(sourceFactoryType.java.isInterface, ::IllegalMessageSourceFactoryClassTypeException) { "${sourceFactoryType.jvmName} must be an interface!" } @@ -55,14 +56,6 @@ object MessageSourceFactoryGenerator { } } - val params = AbstractMessageSourceFactory.Params( - context.getService(), - bundleName, - context.getService(), - context.getService(), - MessageSourceGenerator.create(sourceType), - ) - val classFile = ClassFile.of() val thisClass = ClassDesc.of("${MessageSourceFactoryGenerator::class.java.packageName}.${sourceFactoryType.simpleNestedBinaryName}Impl") @@ -95,8 +88,24 @@ object MessageSourceFactoryGenerator { } } - return MethodHandles.lookup().defineClass(factoryBytes) + val lookup = MethodHandles.lookup() + val factoryHandle = lookup.defineClass(factoryBytes) .declaredConstructors.single() - .newInstance(params) as T + .let(lookup::unreflectConstructor) + // Create it outside the factory to prevent duplicates and also validate early + val sourceType = sourceFactoryType.superErasureAt>(0).jvmErasure as KClass + val sourceHandle = MessageSourceGenerator.create(sourceType) + + return MessageSourceFactoryProvider { context: BContext -> + val params = AbstractMessageSourceFactory.Params( + context.getService(), + bundleName, + context.getService(), + context.getService(), + sourceHandle, + ) + + factoryHandle.invoke(params) as T + } } } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt index 40c13c939..67862f292 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt @@ -6,14 +6,10 @@ import dev.freya02.botcommands.typesafe.messages.api.exceptions.AbstractMessageS import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSourceFactoryClassTypeException import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.service.getService -import io.github.freya022.botcommands.api.localization.LocalizationService -import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider -import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.verify import org.junit.jupiter.api.assertThrows import kotlin.test.Test @@ -35,80 +31,51 @@ class MessageSourceFactoryGeneratorTest { @Test fun `Generate IMessageSourceFactory`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } - mockkObject(MessageSourceGenerator) every { MessageSourceGenerator.create(any()) } returns mockk() - MessageSourceFactoryGenerator.createFactory( - context = context, + MessageSourceFactoryGenerator.createProvider( bundleName = "testBundle", sourceFactoryType = Factory::class, - sourceType = IMessageSource::class ) + + // Make sure the factory generator also triggers MessageSourceGenerator checks + verify(exactly = 1) { MessageSourceGenerator.create(any()) } } @Test fun `Cannot generate IMessageSourceFactory with abstract method`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } - mockkObject(MessageSourceGenerator) every { MessageSourceGenerator.create(any()) } returns mockk() assertThrows { - MessageSourceFactoryGenerator.createFactory( - context = context, + MessageSourceFactoryGenerator.createProvider( bundleName = "testBundle", sourceFactoryType = FactoryWithoutAnnotationOnAbstract::class, - sourceType = IMessageSource::class ) } } @Test fun `Generate IMessageSourceFactory with concrete method`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } - mockkObject(MessageSourceGenerator) every { MessageSourceGenerator.create(any()) } returns mockk() - MessageSourceFactoryGenerator.createFactory( - context = context, + MessageSourceFactoryGenerator.createProvider( bundleName = "testBundle", sourceFactoryType = FactoryWithoutAnnotationOnConcrete::class, - sourceType = IMessageSource::class ) } @Test fun `Cannot generate IMessageSourceFactory as abstract class`() { - val context = mockk { - every { getService() } returns mockk() - every { getService() } returns mockk() - every { getService() } returns mockk() - } - mockkObject(MessageSourceGenerator) every { MessageSourceGenerator.create(any()) } returns mockk() assertThrows { - MessageSourceFactoryGenerator.createFactory( - context = context, + MessageSourceFactoryGenerator.createProvider( bundleName = "testBundle", sourceFactoryType = SourceFactoryAsAbstractClass::class, - sourceType = IMessageSource::class, ) } } diff --git a/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt index c560e65cf..3c29143e6 100644 --- a/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt @@ -1,6 +1,5 @@ package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure -import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.MessageSourceFactory import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator @@ -8,7 +7,6 @@ import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive import io.github.freya022.botcommands.api.core.utils.isSubclassOf import io.github.freya022.botcommands.api.core.utils.shortQualifiedName -import io.github.freya022.botcommands.internal.utils.superErasureAt import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition import org.springframework.beans.factory.getBean import org.springframework.beans.factory.support.BeanDefinitionBuilder @@ -19,7 +17,6 @@ import org.springframework.context.ApplicationContext import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider import org.springframework.core.type.filter.AnnotationTypeFilter import kotlin.reflect.KClass -import kotlin.reflect.jvm.jvmErasure internal class MessageSourceFactoryPostProcessor internal constructor( private val context: ApplicationContext, @@ -41,24 +38,18 @@ internal class MessageSourceFactoryPostProcessor internal constructor( val annotation = messageSourceFactoryType.findAnnotationRecursive() ?: error("Filter for MessageSourceFactory found a class without it") - require(messageSourceFactoryType.java.isInterface) { - "${messageSourceFactoryType.shortQualifiedName} must be an interface" - } require(messageSourceFactoryType.isSubclassOf>()) { "${messageSourceFactoryType.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" } - val messageSourceType = messageSourceFactoryType.superErasureAt>(0).jvmErasure as KClass - + val sourceFactoryProvider = MessageSourceFactoryGenerator.createProvider( + annotation.bundleName, + messageSourceFactoryType, + ) registry.registerBeanDefinition( messageSourceFactoryType.java.simpleName.replaceFirstChar { it.lowercaseChar() }, BeanDefinitionBuilder.genericBeanDefinition(messageSourceFactoryType.java) { - MessageSourceFactoryGenerator.createFactory( - context.getBean(), - annotation.bundleName, - messageSourceFactoryType, - messageSourceType - ) + sourceFactoryProvider.get(context.getBean()) }.beanDefinition ) } From c88af1b624acf8a1cc856ff93fa579b99cb77a63 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:16:09 +0200 Subject: [PATCH 25/32] Move tests --- .../messages/{ => codegen}/MessageSourceFactoryGeneratorTest.kt | 2 +- .../messages/{ => codegen}/MessageSourceGeneratorTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/{ => codegen}/MessageSourceFactoryGeneratorTest.kt (98%) rename BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/{ => codegen}/MessageSourceGeneratorTest.kt (99%) diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt similarity index 98% rename from BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt rename to BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt index 67862f292..c6946112a 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceFactoryGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.typesafe.messages +package dev.freya02.botcommands.typesafe.messages.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt similarity index 99% rename from BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt rename to BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt index c307b7e5b..4a807b1f0 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/MessageSourceGeneratorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.typesafe.messages +package dev.freya02.botcommands.typesafe.messages.codegen import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent From a49bafe20d84f5053b20252bdd82f528f2300086 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:03:50 +0200 Subject: [PATCH 26/32] Add type parameter to generated IMessageSourceFactory --- .../codegen/AbstractMessageSourceFactory.kt | 6 +++--- .../codegen/MessageSourceFactoryGenerator.kt | 8 ++++++-- .../messages/internal/codegen/utils/Signature.kt | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/Signature.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt index e24ea297f..7222d66d9 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt @@ -9,11 +9,11 @@ import io.github.freya022.botcommands.api.localization.interaction.UserLocalePro import net.dv8tion.jda.api.interactions.Interaction import java.lang.invoke.MethodHandle -internal abstract class AbstractMessageSourceFactory internal constructor( +internal abstract class AbstractMessageSourceFactory internal constructor( private val params: Params, -) : IMessageSourceFactory { +) : IMessageSourceFactory { - override fun create(interaction: Interaction): IMessageSource { + override fun create(interaction: Interaction): T { val (localizationService, bundle, guildLocaleProvider, userLocaleProvider, sourceHandle) = params val localizationContext = LocalizationContext.create( diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index ec33b1ed2..8d2cbbd65 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -7,6 +7,7 @@ import dev.freya02.botcommands.typesafe.messages.api.exceptions.IllegalMessageSo import dev.freya02.botcommands.typesafe.messages.internal.MessageSourceFactoryProvider import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.LineNumber import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.classDesc +import dev.freya02.botcommands.typesafe.messages.internal.codegen.utils.createSignature import dev.freya02.botcommands.typesafe.messages.internal.utils.isAbstract import dev.freya02.botcommands.typesafe.messages.internal.utils.require import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName @@ -20,6 +21,7 @@ import io.github.freya022.botcommands.internal.utils.superErasureAt import net.dv8tion.jda.api.interactions.Interaction import java.lang.classfile.ClassFile import java.lang.classfile.ClassFile.* +import java.lang.classfile.attribute.SignatureAttribute import java.lang.constant.ClassDesc import java.lang.constant.ConstantDescs.CD_void import java.lang.constant.ConstantDescs.INIT_NAME @@ -32,7 +34,7 @@ import kotlin.reflect.jvm.jvmName object MessageSourceFactoryGenerator { - private val CD_AbstractMessageSourceFactory = classDesc() + private val CD_AbstractMessageSourceFactory = classDesc>() private val CD_AbstractMessageSourceFactory_Params = classDesc() @Suppress("UNCHECKED_CAST") @@ -56,6 +58,8 @@ object MessageSourceFactoryGenerator { } } + val sourceType = sourceFactoryType.superErasureAt>(0).jvmErasure as KClass + val classFile = ClassFile.of() val thisClass = ClassDesc.of("${MessageSourceFactoryGenerator::class.java.packageName}.${sourceFactoryType.simpleNestedBinaryName}Impl") @@ -64,6 +68,7 @@ object MessageSourceFactoryGenerator { classBuilder.withFlags(AccessFlag.PUBLIC, AccessFlag.FINAL) classBuilder.withSuperclass(CD_AbstractMessageSourceFactory) classBuilder.withInterfaceSymbols(ClassDesc.of(sourceFactoryType.jvmName)) + classBuilder.with(SignatureAttribute.of(CD_AbstractMessageSourceFactory.createSignature(sourceType.java))) classBuilder.withField("params", CD_AbstractMessageSourceFactory_Params, ACC_PRIVATE or ACC_FINAL) @@ -93,7 +98,6 @@ object MessageSourceFactoryGenerator { .declaredConstructors.single() .let(lookup::unreflectConstructor) // Create it outside the factory to prevent duplicates and also validate early - val sourceType = sourceFactoryType.superErasureAt>(0).jvmErasure as KClass val sourceHandle = MessageSourceGenerator.create(sourceType) return MessageSourceFactoryProvider { context: BContext -> diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/Signature.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/Signature.kt new file mode 100644 index 000000000..ef71fe9b0 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/Signature.kt @@ -0,0 +1,14 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen.utils + +import io.github.freya022.botcommands.api.core.utils.mapToArray +import java.lang.classfile.Signature +import java.lang.constant.ClassDesc + +internal fun ClassDesc.createSignature(vararg typeArguments: Class<*>): Signature.ClassTypeSig { + return Signature.ClassTypeSig.of( + this, + *typeArguments.mapToArray { + Signature.TypeArg.of(Signature.ClassTypeSig.of(it.toClassDesc())) + } + ) +} From 49cad0b2e50d52640309af6963354f22ce2378b9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:05:08 +0200 Subject: [PATCH 27/32] Check root localization bundles and keys on PostLoad --- .../messages/api/IMessageSourceFactory.kt | 2 + .../api/exceptions/NoSuchBundleException.kt | 3 + .../exceptions/NoSuchTemplateKeyException.kt | 3 + .../messages/internal/PostLoadValidator.kt | 50 +++++++++++ .../codegen/AbstractMessageSourceFactory.kt | 2 + .../codegen/MessageSourceFactoryGenerator.kt | 1 + .../codegen/MessageSourceGenerator.kt | 12 ++- .../messages/PostLoadValidatorTest.kt | 82 +++++++++++++++++++ 8 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchBundleException.kt create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateKeyException.kt create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt create mode 100644 BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt index fe29f4206..d63ad2127 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt @@ -5,5 +5,7 @@ import net.dv8tion.jda.api.interactions.Interaction @ExperimentalTypesafeMessagesApi interface IMessageSourceFactory { + val bundleName: String + fun create(interaction: Interaction): T } diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchBundleException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchBundleException.kt new file mode 100644 index 000000000..542908446 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchBundleException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class NoSuchBundleException(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateKeyException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateKeyException.kt new file mode 100644 index 000000000..f47cf16f8 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateKeyException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class NoSuchTemplateKeyException(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt new file mode 100644 index 000000000..bc8380cd3 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt @@ -0,0 +1,50 @@ +package dev.freya02.botcommands.typesafe.messages.internal + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchBundleException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateKeyException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator +import dev.freya02.botcommands.typesafe.messages.internal.utils.require +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.events.PostLoadEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.utils.getSignature +import io.github.freya022.botcommands.api.core.utils.shortQualifiedName +import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.internal.utils.superErasureAt +import java.util.* +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.jvm.jvmErasure + +@BService +internal object PostLoadValidator { + + @BEventListener + @Suppress("UNCHECKED_CAST") + fun onPostLoad(event: PostLoadEvent, localizationService: LocalizationService, factories: List>) { + factories.forEach { factory -> + val factoryType = factory::class + + val bundleName = factory.bundleName + val localization = localizationService.getInstance(bundleName, Locale.ROOT) + require(localization != null, ::NoSuchBundleException) { + "No root localization bundle named '$bundleName' exists for ${factoryType.shortQualifiedName}" + } + + val sourceType = factoryType.superErasureAt>(0).jvmErasure as KClass + + MessageSourceGenerator.getImplementableFunctions(sourceType).forEach { function -> + val localizedContent = function.findAnnotation() + ?: error("Function was said to be implementable but does not have @LocalizedContent") + val templateKey = localizedContent.templateKey + + require(localization[templateKey] != null, ::NoSuchTemplateKeyException) { + "No template key '$templateKey' exists in the root bundle '$bundleName' for ${function.getSignature(qualifiedClass = true, source = false)}" + } + } + } + } +} diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt index 7222d66d9..37497ecd0 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt @@ -13,6 +13,8 @@ internal abstract class AbstractMessageSourceFactory int private val params: Params, ) : IMessageSourceFactory { + override val bundleName: String get() = params.bundle + override fun create(interaction: Interaction): T { val (localizationService, bundle, guildLocaleProvider, userLocaleProvider, sourceHandle) = params diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt index 8d2cbbd65..14fa014ea 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -52,6 +52,7 @@ object MessageSourceFactoryGenerator { .filter { it.isAbstract() } // Remove methods we implement .filterNot { it.name == "create" && it.parameterTypes.getOrNull(0) == Interaction::class.java && it.returnType == IMessageSource::class.java } + .filterNot { it.name == "getBundleName" && it.parameterTypes.isEmpty() } .also { unimplementedMethods -> require(unimplementedMethods.isEmpty(), ::AbstractMessageSourceFactoryMethodException) { "${sourceFactoryType.jvmName} cannot contain abstract methods:\n${unimplementedMethods.joinAsList()}" diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index dfbada239..c4ddd542f 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -25,8 +25,8 @@ import java.lang.reflect.AccessFlag import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.functions import kotlin.reflect.full.hasAnnotation -import kotlin.reflect.full.memberFunctions import kotlin.reflect.full.valueParameters import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmName @@ -38,8 +38,8 @@ internal object MessageSourceGenerator { "${sourceType.jvmName} must be an interface!" } - val abstractMethods = sourceType.memberFunctions.filter { it.isAbstract } - val toImplement = abstractMethods.filter { it.hasAnnotation() } + val abstractMethods = sourceType.functions.filter { it.isAbstract } + val toImplement = getImplementableFunctions(sourceType) (abstractMethods - toImplement).also { unimplementedMethods -> require(unimplementedMethods.isEmpty(), ::AbstractMessageSourceMethodException) { @@ -92,6 +92,12 @@ internal object MessageSourceGenerator { .let(lookup::unreflectConstructor) } + internal fun getImplementableFunctions(sourceType: KClass): List> { + return sourceType.functions + .filter { it.isAbstract } + .filter { it.hasAnnotation() } + } + @Suppress("UNCHECKED_CAST") internal fun instantiate(handle: MethodHandle, localizationContext: LocalizationContext): T { return handle.invoke(localizationContext) as T diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt new file mode 100644 index 000000000..aad145ed5 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt @@ -0,0 +1,82 @@ +package dev.freya02.botcommands.typesafe.messages + +import dev.freya02.botcommands.typesafe.messages.api.IMessageSource +import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory +import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchBundleException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateKeyException +import dev.freya02.botcommands.typesafe.messages.internal.PostLoadValidator +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.events.PostLoadEvent +import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.api.localization.interaction.GuildLocaleProvider +import io.github.freya022.botcommands.api.localization.interaction.UserLocaleProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.assertThrows +import java.util.* +import kotlin.test.Test + +class PostLoadValidatorTest { + + interface FactoryWithWrongBundle : IMessageSourceFactory + + interface Factory : IMessageSourceFactory { + + interface Source : IMessageSource { + + @LocalizedContent("factory.source.key") + fun test(): String + } + } + + @Test + fun `Validates root bundle exists`() { + val expectedBundleName = "wrongBundle" + + val event = mockk() + val localizationService = mockk { + every { getInstance(any(), any()) } returns null + } + val context = mockk { + every { getService() } returns localizationService + every { getService() } returns mockk() + every { getService() } returns mockk() + } + val factories = listOf>( + MessageSourceFactoryGenerator.createProvider(expectedBundleName, FactoryWithWrongBundle::class).get(context) + ) + + assertThrows { + PostLoadValidator.onPostLoad(event, localizationService, factories) + } + + verify(exactly = 1) { localizationService.getInstance(expectedBundleName, Locale.ROOT) } + } + + @Test + fun `Validates root bundle contains template key`() { + val event = mockk() + val localizationService = mockk { + every { getInstance(any(), any())!![any()] } returns null + } + val context = mockk { + every { getService() } returns localizationService + every { getService() } returns mockk() + every { getService() } returns mockk() + } + val factories = listOf>( + MessageSourceFactoryGenerator.createProvider("bundle", Factory::class).get(context) + ) + + assertThrows { + PostLoadValidator.onPostLoad(event, localizationService, factories) + } + + val localization = localizationService.getInstance("bundle", Locale.ROOT)!! + verify(exactly = 1) { localization[any()] } + } +} From e5f5b5704c97e9356c04725346e0b5b79fec566c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:12:02 +0200 Subject: [PATCH 28/32] Remove note --- .../messages/internal/codegen/MessageSourceGenerator.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index c4ddd542f..51c204803 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -127,8 +127,6 @@ private object LocalizedContentFunctionGenerator { } } - // TODO make sure a fallback message exists for the given "templateKey" - classBuilder.withMethodBody( function.name, MethodTypeDesc.of(function.returnType.jvmErasure.toClassDesc(), *function.valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() }), From dad989e0547c33cb4d4356036e30be3afe77d020 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:31:34 +0200 Subject: [PATCH 29/32] Small refactor --- .../internal/codegen/MessageSourceGenerator.kt | 7 ++----- .../internal/codegen/utils/MethodTypeDesc.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/MethodTypeDesc.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index 51c204803..e9d7a95bb 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -10,7 +10,6 @@ import dev.freya02.botcommands.typesafe.messages.internal.utils.require import dev.freya02.botcommands.typesafe.messages.internal.utils.simpleNestedBinaryName import io.github.freya022.botcommands.api.core.utils.getSignature import io.github.freya022.botcommands.api.core.utils.joinAsList -import io.github.freya022.botcommands.api.core.utils.mapToArray import io.github.freya022.botcommands.api.localization.context.LocalizationContext import java.lang.classfile.ClassBuilder import java.lang.classfile.ClassFile @@ -129,7 +128,7 @@ private object LocalizedContentFunctionGenerator { classBuilder.withMethodBody( function.name, - MethodTypeDesc.of(function.returnType.jvmErasure.toClassDesc(), *function.valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() }), + function.toMethodTypeDesc(), ClassFile.ACC_PUBLIC or ClassFile.ACC_FINAL, ) { codeBuilder -> val lineNumber = LineNumber(codeBuilder) @@ -155,9 +154,7 @@ private object LocalizedContentFunctionGenerator { codeBuilder.ldc(templateVarName) codeBuilder.loadLocal(TypeKind.from(parameter.type.jvmErasure.java), codeBuilder.parameterSlot(parameterIndex)) codeBuilder.boxIfNecessary(parameter.type.jvmErasure) - codeBuilder.invokespecial( - CD_Localization_Entry, - INIT_NAME, MethodTypeDesc.of(CD_void, CD_String, CD_Object)) + codeBuilder.invokespecial(CD_Localization_Entry, INIT_NAME, MethodTypeDesc.of(CD_void, CD_String, CD_Object)) codeBuilder.astore(localizationEntrySlot) // localizationArgs[i] = localizationEntry diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/MethodTypeDesc.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/MethodTypeDesc.kt new file mode 100644 index 000000000..5fab6519e --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/utils/MethodTypeDesc.kt @@ -0,0 +1,14 @@ +package dev.freya02.botcommands.typesafe.messages.internal.codegen.utils + +import io.github.freya022.botcommands.api.core.utils.mapToArray +import java.lang.constant.MethodTypeDesc +import kotlin.reflect.KFunction +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.jvmErasure + +internal fun KFunction<*>.toMethodTypeDesc(): MethodTypeDesc { + return MethodTypeDesc.of( + returnType.jvmErasure.toClassDesc(), + *valueParameters.mapToArray { it.type.jvmErasure.toClassDesc() } + ) +} From aa98da7ed3ccb98808a17178ec90d2061d7a0d84 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:09:53 +0200 Subject: [PATCH 30/32] Update README --- BotCommands-typesafe-messages/README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/BotCommands-typesafe-messages/README.md b/BotCommands-typesafe-messages/README.md index dafe11ea0..11304fd68 100644 --- a/BotCommands-typesafe-messages/README.md +++ b/BotCommands-typesafe-messages/README.md @@ -15,15 +15,15 @@ Let's start by creating a localization bundle at `src/main/resources/bc_localiza for our example it will contain a single localization template where: - The key is `bot.info` -- The template is `I am in {guild_count, number} {guild_count, choice, 0#guilds|1#guild|1 Date: Sun, 13 Jul 2025 00:28:31 +0200 Subject: [PATCH 31/32] Add LocalizationTemplate#arguments --- .../DefaultLocalizationTemplate.kt | 55 ++++++++++--------- .../api/localization/LocalizationTemplate.kt | 5 +- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/DefaultLocalizationTemplate.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/DefaultLocalizationTemplate.kt index 60c4e26c6..4b02152ef 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/DefaultLocalizationTemplate.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/DefaultLocalizationTemplate.kt @@ -29,45 +29,48 @@ private val alphanumericRegex = Regex("""\w+""") * Full example: `"There are {user_amount} {user_amount, choice, 0#users|1#user|1 = ArrayList() + + override val arguments: List init { val formattableArgumentFactories = context.getInterfacedServices() - var start = 0 - argumentRegex.findAll(template).forEach argumentsLoop@{ argumentMatch -> - val matchStart = argumentMatch.range.first - addRawArgument(template.substring(start, matchStart)) + fun MutableList.addRawArgument(substring: String) { + if (substring.isEmpty()) return + add(RawArgument(substring)) + } + + arguments = buildList { + var start = 0 + argumentRegex.findAll(template).forEach argumentsLoop@{ argumentMatch -> + val matchStart = argumentMatch.range.first + addRawArgument(template.substring(start, matchStart)) + + val formattableArgument = argumentMatch.groupValues[1] + // Try to match against each factory + formattableArgumentFactories.forEach { factory -> + factory.regex.matchEntire(formattableArgument)?.let { + this += factory.get(it, locale) + start = argumentMatch.range.last + 1 + return@argumentsLoop + } + } - val formattableArgument = argumentMatch.groups[1]?.value!! - // Try to match against each factory - formattableArgumentFactories.forEach { factory -> - factory.regex.matchEntire(formattableArgument)?.let { - localizableArguments += factory.get(it, locale) + // If the entire thing looks like a simple argument name + if (formattableArgument.matches(alphanumericRegex)) { + this += SimpleArgument(formattableArgument) start = argumentMatch.range.last + 1 return@argumentsLoop } - } - // If the entire thing looks like a simple argument name - if (formattableArgument.matches(alphanumericRegex)) { - localizableArguments += SimpleArgument(formattableArgument) - start = argumentMatch.range.last + 1 - return@argumentsLoop + throwArgument("Could not match formattable argument '$formattableArgument' against ${formattableArgumentFactories.map { it.javaClass.simpleNestedName }}") } - - throwArgument("Could not match formattable argument '$formattableArgument' against ${formattableArgumentFactories.map { it.javaClass.simpleNestedName }}") + addRawArgument(template.substring(start)) } - addRawArgument(template.substring(start)) - } - - private fun addRawArgument(substring: String) { - if (substring.isEmpty()) return - localizableArguments += RawArgument(substring) } override fun localize(vararg args: Localization.Entry): String { - return localizableArguments.joinToString("") { localizableArgument -> + return arguments.joinToString("") { localizableArgument -> when (localizableArgument) { is RawArgument -> localizableArgument.get() is FormattableArgument -> formatFormattableString(args, localizableArgument) @@ -91,6 +94,6 @@ class DefaultLocalizationTemplate(context: BContext, private val template: Strin } override fun toString(): String { - return "DefaultLocalizationTemplate(template='$template', localizableArguments=$localizableArguments)" + return "DefaultLocalizationTemplate(template='$template', arguments=$arguments)" } } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/LocalizationTemplate.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/LocalizationTemplate.kt index aac222cfc..78962f9a8 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/LocalizationTemplate.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/localization/LocalizationTemplate.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.api.localization import io.github.freya022.botcommands.api.core.utils.mapToArray +import io.github.freya022.botcommands.internal.localization.LocalizableArgument /** * Represents an entire localizable string, with parameters. @@ -10,6 +11,8 @@ import io.github.freya022.botcommands.api.core.utils.mapToArray * @see DefaultLocalizationTemplate */ interface LocalizationTemplate { + val arguments: List + /** * Processes the localization template and replaces the named parameters by their values */ @@ -20,4 +23,4 @@ interface LocalizationTemplate { * Processes the localization template and replaces the named parameters by their values */ fun LocalizationTemplate.localize(vararg args: Pair): String = - localize(*args.mapToArray { (k, v) -> Localization.Entry.entry(k, v) }) \ No newline at end of file + localize(*args.mapToArray { (k, v) -> Localization.Entry.entry(k, v) }) From b851c251b639573e892c916ca3d6242d4cfd7175 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 13 Jul 2025 00:53:00 +0200 Subject: [PATCH 32/32] Check parameters are present in template arguments --- .../NoSuchTemplateArgumentException.kt | 3 ++ .../messages/internal/PostLoadValidator.kt | 14 +++++++- .../codegen/MessageSourceGenerator.kt | 17 +++++++--- .../messages/PostLoadValidatorTest.kt | 32 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateArgumentException.kt diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateArgumentException.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateArgumentException.kt new file mode 100644 index 000000000..84c5e56df --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/exceptions/NoSuchTemplateArgumentException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.typesafe.messages.api.exceptions + +class NoSuchTemplateArgumentException internal constructor(message: String) : IllegalArgumentException(message) diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt index bc8380cd3..7d3e0d7bd 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt @@ -4,7 +4,9 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchBundleException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateArgumentException import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateKeyException +import dev.freya02.botcommands.typesafe.messages.internal.codegen.LocalizedContentFunctionGenerator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator import dev.freya02.botcommands.typesafe.messages.internal.utils.require import io.github.freya022.botcommands.api.core.annotations.BEventListener @@ -13,6 +15,7 @@ import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.utils.getSignature import io.github.freya022.botcommands.api.core.utils.shortQualifiedName import io.github.freya022.botcommands.api.localization.LocalizationService +import io.github.freya022.botcommands.api.localization.arguments.FormattableArgument import io.github.freya022.botcommands.internal.utils.superErasureAt import java.util.* import kotlin.reflect.KClass @@ -41,9 +44,18 @@ internal object PostLoadValidator { ?: error("Function was said to be implementable but does not have @LocalizedContent") val templateKey = localizedContent.templateKey - require(localization[templateKey] != null, ::NoSuchTemplateKeyException) { + val template = localization[templateKey] + require(template != null, ::NoSuchTemplateKeyException) { "No template key '$templateKey' exists in the root bundle '$bundleName' for ${function.getSignature(qualifiedClass = true, source = false)}" } + + val formattableArguments = template.arguments.filterIsInstance() + LocalizedContentFunctionGenerator.getTemplateArgumentParameters(function).forEach { parameter -> + val expectedArgName = LocalizedContentFunctionGenerator.getTemplateArgumentParameterName(parameter)!! + require(formattableArguments.any { it.argumentName == expectedArgName }, ::NoSuchTemplateArgumentException) { + "Template key '$templateKey' is missing argument '$expectedArgName' required by ${function.getSignature(qualifiedClass = true, source = false)}" + } + } } } } diff --git a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt index e9d7a95bb..25a4885e0 100644 --- a/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -23,6 +23,7 @@ import java.lang.invoke.MethodHandles import java.lang.reflect.AccessFlag import kotlin.reflect.KClass import kotlin.reflect.KFunction +import kotlin.reflect.KParameter import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.functions import kotlin.reflect.full.hasAnnotation @@ -103,9 +104,9 @@ internal object MessageSourceGenerator { } } -private object LocalizedContentFunctionGenerator { +internal object LocalizedContentFunctionGenerator { - fun create(thisClass: ClassDesc, classBuilder: ClassBuilder, function: KFunction<*>) { + internal fun create(thisClass: ClassDesc, classBuilder: ClassBuilder, function: KFunction<*>) { require(!function.isSuspend, ::UnsupportedSuspendFunctionException) { "Suspend functions are not supported! ${function.getSignature(qualifiedClass = true, source = false)}" } @@ -116,7 +117,7 @@ private object LocalizedContentFunctionGenerator { val annotation = function.findAnnotation() ?: error("Function was to be implemented but annotation is absent") - val templateParameters = function.valueParameters.onEach { parameter -> + val templateParameters = getTemplateArgumentParameters(function).onEach { parameter -> require(parameter.isRequired, ::UnsupportedOptionalParameterException) { "Optional parameters are not supported! $parameter" } @@ -144,7 +145,7 @@ private object LocalizedContentFunctionGenerator { codeBuilder.astore(localizationArgsSlot) templateParameters.withIndex().forEachIndexed { arrayIndex, (parameterIndex, parameter) -> - val templateVarName = parameter.name?.convertToCamelCase() + val templateVarName = getTemplateArgumentParameterName(parameter) ?: error("Parameter names are absent from $function ; see https://bc.freya02.dev/3.X/using-botcommands/parameter-names/") // localizationEntry = new Localization.Entry(paramName, value) @@ -175,4 +176,12 @@ private object LocalizedContentFunctionGenerator { codeBuilder.areturn() } } + + internal fun getTemplateArgumentParameters(function: KFunction<*>): List { + return function.valueParameters + } + + internal fun getTemplateArgumentParameterName(parameter: KParameter): String? { + return parameter.name?.convertToCamelCase() + } } diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt index aad145ed5..aa7452021 100644 --- a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt @@ -4,6 +4,7 @@ import dev.freya02.botcommands.typesafe.messages.api.IMessageSource import dev.freya02.botcommands.typesafe.messages.api.IMessageSourceFactory import dev.freya02.botcommands.typesafe.messages.api.annotations.LocalizedContent import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchBundleException +import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateArgumentException import dev.freya02.botcommands.typesafe.messages.api.exceptions.NoSuchTemplateKeyException import dev.freya02.botcommands.typesafe.messages.internal.PostLoadValidator import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceFactoryGenerator @@ -33,6 +34,15 @@ class PostLoadValidatorTest { } } + interface FactoryWithSourceWithUnknownArgName : IMessageSourceFactory { + + interface Source : IMessageSource { + + @LocalizedContent("factory.source.key") + fun test(unknownArg: String): String + } + } + @Test fun `Validates root bundle exists`() { val expectedBundleName = "wrongBundle" @@ -79,4 +89,26 @@ class PostLoadValidatorTest { val localization = localizationService.getInstance("bundle", Locale.ROOT)!! verify(exactly = 1) { localization[any()] } } + + @Test + fun `Validates root bundle template has parameters`() { + val event = mockk() + val localizationService = mockk { + every { getInstance("bundle", Locale.ROOT)!!["factory.source.key"]!!.arguments } returns emptyList() + } + val context = mockk { + every { getService() } returns localizationService + every { getService() } returns mockk() + every { getService() } returns mockk() + } + val factories = listOf>( + MessageSourceFactoryGenerator.createProvider("bundle", FactoryWithSourceWithUnknownArgName::class).get(context) + ) + + assertThrows { + PostLoadValidator.onPostLoad(event, localizationService, factories) + } + + verify(exactly = 1) { localizationService.getInstance("bundle", Locale.ROOT)!!["factory.source.key"]!!.arguments } + } }