Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
80d482f
Add initial support for type-safe messages
freya022 Jul 7, 2025
f20381a
Move out integration test services
freya022 Jul 8, 2025
746cb4e
Use generic in MessageSourceFactoryGenerator
freya022 Jul 8, 2025
ef8a7ab
Limit MessageSourceFactoryClassGraphProcessor to BC DI
freya022 Jul 8, 2025
f656310
Add Spring support
freya022 Jul 9, 2025
e70ef4e
Separate core, BC and Spring modules
freya022 Jul 10, 2025
0b1b6aa
Add `@Component` on `@MessageSourceFactory`
freya022 Jul 10, 2025
3c512b6
Add README.md
freya022 Jul 10, 2025
1540aa6
Fix README.md
freya022 Jul 10, 2025
4721a80
Fix README.md
freya022 Jul 10, 2025
04e0e1e
Fix README.md
freya022 Jul 10, 2025
5b1a2e6
Fix README.md
freya022 Jul 10, 2025
bacc8ef
Check factories and sources are interfaces
freya022 Jul 11, 2025
7cffa66
Check source methods return String
freya022 Jul 11, 2025
ffafdc4
Convert template variable names to camel_case
freya022 Jul 11, 2025
9aebdbb
Generate IMessageSource implementations while making the IMessageSour…
freya022 Jul 11, 2025
77d76e2
Check for unsupported optional parameters
freya022 Jul 11, 2025
110c458
Check for unsupported suspend functions
freya022 Jul 11, 2025
46531e2
Refactor `@LocalizationContent` generator and checks
freya022 Jul 11, 2025
95f4525
Refactor MessageSourceFactoryGenerator checks
freya022 Jul 11, 2025
3ed2294
Check Localization.Entry arguments are non-null
freya022 Jul 11, 2025
675eca1
Reduce redundancy in MessageSourceGeneratorTest
freya022 Jul 11, 2025
bc8b208
Pass bundle name directly to MessageSourceFactoryGenerator
freya022 Jul 11, 2025
50ffd4d
Create a provider of MessageSourceFactory
freya022 Jul 11, 2025
c88af1b
Move tests
freya022 Jul 11, 2025
a49bafe
Add type parameter to generated IMessageSourceFactory
freya022 Jul 11, 2025
49cad0b
Check root localization bundles and keys on PostLoad
freya022 Jul 11, 2025
e5f5b57
Remove note
freya022 Jul 11, 2025
dad989e
Small refactor
freya022 Jul 11, 2025
aa98da7
Update README
freya022 Jul 11, 2025
cafa57a
Add LocalizationTemplate#arguments
freya022 Jul 12, 2025
b851c25
Check parameters are present in template arguments
freya022 Jul 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.jetbrains.annotations.NotNull;

import java.util.Locale;
import java.util.Objects;

/**
* Low-level interface for localization.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,45 +29,48 @@ private val alphanumericRegex = Regex("""\w+""")
* Full example: `"There are {user_amount} {user_amount, choice, 0#users|1#user|1<users} and my up-time is {uptime, number} seconds"`
*/
class DefaultLocalizationTemplate(context: BContext, private val template: String, locale: Locale) : LocalizationTemplate {
private val localizableArguments: MutableList<LocalizableArgument> = ArrayList()

override val arguments: List<LocalizableArgument>

init {
val formattableArgumentFactories = context.getInterfacedServices<FormattableArgumentFactory>()

var start = 0
argumentRegex.findAll(template).forEach argumentsLoop@{ argumentMatch ->
val matchStart = argumentMatch.range.first
addRawArgument(template.substring(start, matchStart))
fun MutableList<LocalizableArgument>.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)
Expand All @@ -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)"
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -10,6 +11,8 @@ import io.github.freya022.botcommands.api.core.utils.mapToArray
* @see DefaultLocalizationTemplate
*/
interface LocalizationTemplate {
val arguments: List<LocalizableArgument>

/**
* Processes the localization template and replaces the named parameters by their values
*/
Expand All @@ -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, Any>): String =
localize(*args.mapToArray { (k, v) -> Localization.Entry.entry(k, v) })
localize(*args.mapToArray { (k, v) -> Localization.Entry.entry(k, v) })
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified T : Any> KClass<*>.superErasureAt(index: Int): KType = superErasureAt(index, T::class)
inline fun <reified T : Any> KClass<*>.superErasureAt(index: Int): KType = superErasureAt(index, T::class)

@PublishedApi
internal fun KClass<*>.superErasureAt(index: Int, targetType: KClass<*>): KType {
Expand Down
168 changes: 168 additions & 0 deletions BotCommands-typesafe-messages/README.md
Original file line number Diff line number Diff line change
@@ -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<guilds} and I am up since {uptime_timestamp}.`
- `guild_count` and `uptime_timestamp` are variables
- `number` and `choice` are format types
- `0#guilds|1#guild|1<guilds` is a subformat pattern for [ChoiceFormat](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/text/ChoiceFormat.html)
- See [MessageFormat](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/text/MessageFormat.html) for more details

```json
{
"bot.info": "I am in {guild_count, number} {guild_count, choice, 0#guilds|1#guild|1<guilds} and I am up since {uptime_timestamp}."
}
```

### Base interfaces

> [!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<MyBotMessages>`,
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<MyBotMessages>
```

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)

<details>
<summary>Built-in</summary>

### Maven
```xml
<dependencies>
<dependency>
<groupId>io.github.freya022</groupId>
<artifactId>BotCommands-typesafe-messages-bc</artifactId>
<version>VERSION</version>
</dependency>
</dependencies>
```

### Gradle
```gradle
repositories {
mavenCentral()
}

dependencies {
implementation("io.github.freya022:BotCommands-typesafe-messages-bc:VERSION")
}
```

</details>

<details>
<summary>Spring</summary>

### Maven
```xml
<dependencies>
<dependency>
<groupId>io.github.freya022</groupId>
<artifactId>BotCommands-typesafe-messages-spring</artifactId>
<version>VERSION</version>
</dependency>
</dependencies>
```

### Gradle
```gradle
repositories {
mavenCentral()
}

dependencies {
implementation("io.github.freya022:BotCommands-typesafe-messages-spring:VERSION")
}
```

</details>

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.
35 changes: 35 additions & 0 deletions BotCommands-typesafe-messages/bc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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<IMessageSourceFactory<*>>

val sourceFactoryProvider = MessageSourceFactoryGenerator.createProvider(
annotation.bundleName,
messageSourceFactoryType,
)
serviceContainer.putSuppliedService(ServiceSupplier(messageSourceFactoryType) { context ->
sourceFactoryProvider.get(context)
})
}
}
Original file line number Diff line number Diff line change
@@ -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<ClassGraphProcessor> {
return listOf(MessageSourceFactoryClassGraphProcessor)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dev.freya02.botcommands.typesafe.messages.internal.processor.MessageSourceFactoryClassGraphProcessorProvider
Loading