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-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) }) 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/README.md b/BotCommands-typesafe-messages/README.md new file mode 100644 index 000000000..11304fd68 --- /dev/null +++ b/BotCommands-typesafe-messages/README.md @@ -0,0 +1,168 @@ +# 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 +> [!NOTE] +> 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 + @LocalizedContent("bot.info") + fun botInfo( + // Parameter names are converted to snake_case for use in the template + guildCount: Int, + // For simplicity this is a String, + // but you could define an "ArgumentFormatter" + // so you can pass a Timestamp, a Long, an Instant or anything you want + // and have it converted. + uptimeTimestamp: String + ): 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 onSlashInfo(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(), + uptimeTimestamp = TimeFormat.RELATIVE.format(ManagementFactory.getRuntimeMXBean().startTime), + ) + + 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. 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/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 new file mode 100644 index 000000000..76906cf24 --- /dev/null +++ b/BotCommands-typesafe-messages/bc/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/processor/MessageSourceFactoryClassGraphProcessor.kt @@ -0,0 +1,36 @@ +package dev.freya02.botcommands.typesafe.messages.internal.processor + +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 kotlin.reflect.KClass + +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.implementsInterface(IMessageSourceFactory::class.java)) { + "${classInfo.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" + } + + val messageSourceFactoryType = kClass as KClass> + + val sourceFactoryProvider = MessageSourceFactoryGenerator.createProvider( + annotation.bundleName, + messageSourceFactoryType, + ) + serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context -> + sourceFactoryProvider.get(context) + }) + } +} diff --git a/BotCommands-typesafe-messages/bc/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 new file mode 100644 index 000000000..15793dbae --- /dev/null +++ b/BotCommands-typesafe-messages/bc/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/bc/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 new file mode 100644 index 000000000..144613716 --- /dev/null +++ b/BotCommands-typesafe-messages/bc/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/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/bc/src/test/resources/logback-test.xml b/BotCommands-typesafe-messages/bc/src/test/resources/logback-test.xml new file mode 100644 index 000000000..34bef9537 --- /dev/null +++ b/BotCommands-typesafe-messages/bc/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/core/build.gradle.kts b/BotCommands-typesafe-messages/core/build.gradle.kts new file mode 100644 index 000000000..578a30778 --- /dev/null +++ b/BotCommands-typesafe-messages/core/build.gradle.kts @@ -0,0 +1,37 @@ +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) + + // Spring annotations + compileOnly(libs.spring.context) + + // -------------------- 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/core/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 new file mode 100644 index 000000000..60d48017d --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/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 new file mode 100644 index 000000000..d63ad2127 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/IMessageSourceFactory.kt @@ -0,0 +1,11 @@ +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 { + val bundleName: String + + fun create(interaction: Interaction): T +} diff --git a/BotCommands-typesafe-messages/core/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 new file mode 100644 index 000000000..f2a3faa9b --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/core/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 new file mode 100644 index 000000000..4dd63754d --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/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 new file mode 100644 index 000000000..1a9d60cdf --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/api/annotations/MessageSourceFactory.kt @@ -0,0 +1,9 @@ +package dev.freya02.botcommands.typesafe.messages.api.annotations + +import org.springframework.stereotype.Component + +@Component +@ExperimentalTypesafeMessagesApi +@MustBeDocumented +@Target(AnnotationTarget.CLASS, AnnotationTarget.ANNOTATION_CLASS) +annotation class MessageSourceFactory(val bundleName: String) diff --git a/BotCommands-typesafe-messages/core/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 new file mode 100644 index 000000000..33470d99f --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/core/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 new file mode 100644 index 000000000..c5a4d618c --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/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/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/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/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/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/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/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/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/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/PostLoadValidator.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt new file mode 100644 index 000000000..7d3e0d7bd --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/PostLoadValidator.kt @@ -0,0 +1,62 @@ +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.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 +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.api.localization.arguments.FormattableArgument +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 + + 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/AbstractMessageSourceFactory.kt b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt new file mode 100644 index 000000000..37497ecd0 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/AbstractMessageSourceFactory.kt @@ -0,0 +1,39 @@ +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 java.lang.invoke.MethodHandle + +internal abstract class AbstractMessageSourceFactory internal constructor( + 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 + + val localizationContext = LocalizationContext.create( + localizationService = localizationService, + localizationBundle = bundle, + localizationPrefix = null, + guildLocale = guildLocaleProvider.getDiscordLocale(interaction), + userLocale = userLocaleProvider.getDiscordLocale(interaction), + ) + + return MessageSourceGenerator.instantiate(sourceHandle, localizationContext) + } + + internal data class Params( + internal val localizationService: LocalizationService, + internal val bundle: String, + internal val guildLocaleProvider: GuildLocaleProvider, + internal val userLocaleProvider: UserLocaleProvider, + 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 new file mode 100644 index 000000000..14fa014ea --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceFactoryGenerator.kt @@ -0,0 +1,116 @@ +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.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.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 +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 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 +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 { + + private val CD_AbstractMessageSourceFactory = classDesc>() + private val CD_AbstractMessageSourceFactory_Params = classDesc() + + @Suppress("UNCHECKED_CAST") + fun > createProvider( + bundleName: String, + sourceFactoryType: KClass, + ): MessageSourceFactoryProvider { + require(sourceFactoryType.java.isInterface, ::IllegalMessageSourceFactoryClassTypeException) { + "${sourceFactoryType.jvmName} must be an interface!" + } + + // 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 } + .filterNot { it.name == "getBundleName" && it.parameterTypes.isEmpty() } + .also { unimplementedMethods -> + require(unimplementedMethods.isEmpty(), ::AbstractMessageSourceFactoryMethodException) { + "${sourceFactoryType.jvmName} cannot contain abstract methods:\n${unimplementedMethods.joinAsList()}" + } + } + + val sourceType = sourceFactoryType.superErasureAt>(0).jvmErasure as KClass + + 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.with(SignatureAttribute.of(CD_AbstractMessageSourceFactory.createSignature(sourceType.java))) + + 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_() + } + } + + val lookup = MethodHandles.lookup() + val factoryHandle = lookup.defineClass(factoryBytes) + .declaredConstructors.single() + .let(lookup::unreflectConstructor) + // Create it outside the factory to prevent duplicates and also validate early + 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/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 new file mode 100644 index 000000000..25a4885e0 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/codegen/MessageSourceGenerator.kt @@ -0,0 +1,187 @@ +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.* +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.localization.context.LocalizationContext +import java.lang.classfile.ClassBuilder +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.MethodHandle +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 +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.jvmErasure +import kotlin.reflect.jvm.jvmName + +internal object MessageSourceGenerator { + + internal fun create(sourceType: KClass): MethodHandle { + require(sourceType.java.isInterface, ::IllegalMessageSourceClassTypeException) { + "${sourceType.jvmName} must be an interface!" + } + + val abstractMethods = sourceType.functions.filter { it.isAbstract } + val toImplement = getImplementableFunctions(sourceType) + + (abstractMethods - toImplement).also { unimplementedMethods -> + require(unimplementedMethods.isEmpty(), ::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 { function -> + LocalizedContentFunctionGenerator.create(thisClass, classBuilder, function) + } + } + + val lookup = MethodHandles.lookup() + return lookup.defineClass(sourceBytes) + .getConstructor(LocalizationContext::class.java) + .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 + } +} + +internal object LocalizedContentFunctionGenerator { + + internal 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 = getTemplateArgumentParameters(function).onEach { parameter -> + require(parameter.isRequired, ::UnsupportedOptionalParameterException) { + "Optional parameters are not supported! $parameter" + } + + require(!parameter.type.isMarkedNullable, ::UnsupportedNullableParameterException) { + "Nullable parameters are not allowed! $parameter" + } + } + + classBuilder.withMethodBody( + function.name, + function.toMethodTypeDesc(), + 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 = 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) + 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() + } + } + + 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/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 new file mode 100644 index 000000000..bede6ea00 --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/core/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 new file mode 100644 index 000000000..7495ad45f --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/core/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 new file mode 100644 index 000000000..31284282c --- /dev/null +++ b/BotCommands-typesafe-messages/core/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/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() } + ) +} 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())) + } + ) +} 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 new file mode 100644 index 000000000..1c7c26300 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/utils/Reflection.kt @@ -0,0 +1,17 @@ +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 +import kotlin.reflect.KParameter + +internal fun Method.isAbstract(): Boolean { + return (modifiers and Modifier.ABSTRACT) == Modifier.ABSTRACT +} + +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() +} 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..aa7452021 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/PostLoadValidatorTest.kt @@ -0,0 +1,114 @@ +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.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 +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 + } + } + + interface FactoryWithSourceWithUnknownArgName : IMessageSourceFactory { + + interface Source : IMessageSource { + + @LocalizedContent("factory.source.key") + fun test(unknownArg: String): 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()] } + } + + @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 } + } +} diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt new file mode 100644 index 000000000..c6946112a --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceFactoryGeneratorTest.kt @@ -0,0 +1,82 @@ +package dev.freya02.botcommands.typesafe.messages.codegen + +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.codegen.MessageSourceFactoryGenerator +import dev.freya02.botcommands.typesafe.messages.internal.codegen.MessageSourceGenerator +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 + +class MessageSourceFactoryGeneratorTest { + + interface Factory : IMessageSourceFactory + + interface FactoryWithoutAnnotationOnAbstract : IMessageSourceFactory { + + fun test(): String + } + + interface FactoryWithoutAnnotationOnConcrete : IMessageSourceFactory { + + fun test(): String = "test" + } + + abstract class SourceFactoryAsAbstractClass : IMessageSourceFactory + + @Test + fun `Generate IMessageSourceFactory`() { + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + + MessageSourceFactoryGenerator.createProvider( + bundleName = "testBundle", + sourceFactoryType = Factory::class, + ) + + // Make sure the factory generator also triggers MessageSourceGenerator checks + verify(exactly = 1) { MessageSourceGenerator.create(any()) } + } + + @Test + fun `Cannot generate IMessageSourceFactory with abstract method`() { + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + + assertThrows { + MessageSourceFactoryGenerator.createProvider( + bundleName = "testBundle", + sourceFactoryType = FactoryWithoutAnnotationOnAbstract::class, + ) + } + } + + @Test + fun `Generate IMessageSourceFactory with concrete method`() { + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + + MessageSourceFactoryGenerator.createProvider( + bundleName = "testBundle", + sourceFactoryType = FactoryWithoutAnnotationOnConcrete::class, + ) + } + + @Test + fun `Cannot generate IMessageSourceFactory as abstract class`() { + mockkObject(MessageSourceGenerator) + every { MessageSourceGenerator.create(any()) } returns mockk() + + assertThrows { + MessageSourceFactoryGenerator.createProvider( + bundleName = "testBundle", + sourceFactoryType = SourceFactoryAsAbstractClass::class, + ) + } + } +} diff --git a/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt new file mode 100644 index 000000000..4a807b1f0 --- /dev/null +++ b/BotCommands-typesafe-messages/core/src/test/kotlin/dev/freya02/botcommands/typesafe/messages/codegen/MessageSourceGeneratorTest.kt @@ -0,0 +1,193 @@ +package dev.freya02.botcommands.typesafe.messages.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.* +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.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.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals + +class MessageSourceGeneratorTest { + + abstract class SourceAsAbstractClass : IMessageSource + + 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" + } + + interface SourceWithAbstractWithDiffReturnType : IMessageSource { + + @LocalizedContent("SourceWithAbstractWithDiffReturnType.key") + fun test() + } + + interface SourceWithConcreteWithDiffReturnType : IMessageSource { + + @LocalizedContent("SourceWithConcreteWithDiffReturnType.key") + fun test() { } + } + + interface SourceWithCamelCaseArg : IMessageSource { + + @LocalizedContent("SourceWithCamelCaseArg.key") + fun test(myArg: String): String + } + + interface SourceWithOptionalArg: IMessageSource { + + @LocalizedContent("SourceWithOptionalArg.key") + fun test(myArg: String = "test"): String + } + + interface SourceWithSuspendFunction: IMessageSource { + + @LocalizedContent("SourceWithSuspendFunction.key") + suspend fun test(): String + } + + interface SourceWithNullableArg: IMessageSource { + + @LocalizedContent("SourceWithNullableArg.key") + fun test(arg: String?): String + } + + @Test + fun `Cannot generate IMessageSource as abstract class`() { + assertThrows { + MessageSourceGenerator.create(SourceAsAbstractClass::class) + } + } + + @Test + fun `Generate IMessageSource without params`() { + val localizationContext = mockk { + every { localize(any()) } returns "expected" + } + val source = createAndInstantiate(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 = createAndInstantiate(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 = createAndInstantiate(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`() { + assertThrows { + MessageSourceGenerator.create(SourceWithoutAnnotationOnAbstract::class) + } + } + + @Test + fun `Generate IMessageSource without annotation on concrete method`() { + val localizationContext = mockk() + val source = createAndInstantiate(SourceWithoutAnnotationOnConcrete::class, localizationContext) + assertEquals("test", source.test()) + } + + @Test + fun `Cannot generate IMessageSource with abstract method returning non-String`() { + assertThrows { + MessageSourceGenerator.create(SourceWithAbstractWithDiffReturnType::class) + } + } + + @Test + fun `Generate IMessageSource with concrete method returning non-String`() { + assertDoesNotThrow { + MessageSourceGenerator.create(SourceWithConcreteWithDiffReturnType::class) + } + } + + @Test + fun `Generate IMessageSource with camelCase param converts to snake_case`() { + val localizationContext = mockk { + every { localize(any(), any()) } returns "expected" + } + val source = createAndInstantiate(SourceWithCamelCaseArg::class, localizationContext) + source.test("arg") + + verify(exactly = 1) { localizationContext.localize("SourceWithCamelCaseArg.key", Localization.Entry("my_arg", "arg")) } + } + + @Test + fun `Cannot generate IMessageSource with optional parameters`() { + assertThrows { + MessageSourceGenerator.create(SourceWithOptionalArg::class) + } + } + + @Test + fun `Cannot generate IMessageSource with suspend functions`() { + assertThrows { + MessageSourceGenerator.create(SourceWithSuspendFunction::class) + } + } + + @Test + fun `Cannot generate IMessageSource with nullable parameters`() { + assertThrows { + MessageSourceGenerator.create(SourceWithNullableArg::class) + } + } + + private fun createAndInstantiate( + sourceType: KClass, + localizationContext: LocalizationContext, + ): T { + return instantiate(MessageSourceGenerator.create(sourceType), localizationContext) + } +} 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/spring/build.gradle.kts b/BotCommands-typesafe-messages/spring/build.gradle.kts new file mode 100644 index 000000000..43cd0fb8a --- /dev/null +++ b/BotCommands-typesafe-messages/spring/build.gradle.kts @@ -0,0 +1,41 @@ +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) + + 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 { + 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-spring") 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 new file mode 100644 index 000000000..3c29143e6 --- /dev/null +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/MessageSourceFactoryPostProcessor.kt @@ -0,0 +1,58 @@ +package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure + +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 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 + +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.isSubclassOf>()) { + "${messageSourceFactoryType.shortQualifiedName} must implement ${IMessageSourceFactory::class.simpleName}" + } + + val sourceFactoryProvider = MessageSourceFactoryGenerator.createProvider( + annotation.bundleName, + messageSourceFactoryType, + ) + registry.registerBeanDefinition( + messageSourceFactoryType.java.simpleName.replaceFirstChar { it.lowercaseChar() }, + BeanDefinitionBuilder.genericBeanDefinition(messageSourceFactoryType.java) { + sourceFactoryProvider.get(context.getBean()) + }.beanDefinition + ) + } + } + } +} diff --git a/BotCommands-typesafe-messages/spring/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 new file mode 100644 index 000000000..f0c7b951a --- /dev/null +++ b/BotCommands-typesafe-messages/spring/src/main/kotlin/dev/freya02/botcommands/typesafe/messages/internal/autoconfigure/TypesafeMessagesAutoConfiguration.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.typesafe.messages.internal.autoconfigure + +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/spring/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 new file mode 100644 index 000000000..675b85a98 --- /dev/null +++ b/BotCommands-typesafe-messages/spring/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/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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index d864b28fe..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,10 +104,12 @@ 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" } 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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6642bc492..c199bbfea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,3 +5,8 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":BotCommands-core") include(":spring-properties-processor") include(":BotCommands-spring") +include( + ":BotCommands-typesafe-messages:core", + ":BotCommands-typesafe-messages:bc", + ":BotCommands-typesafe-messages:spring", +)