Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions BotCommands-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ registerSourceSet(name = "kotlinDocExamples")

val byteBuddyAgent: Configuration by configurations.creating

val embedded: Configuration by configurations.creating {
isTransitive = false
}

configurations.compileOnly {
extendsFrom(embedded)
}

dependencies {
// -------------------- CORE DEPENDENCIES --------------------

Expand All @@ -31,6 +39,8 @@ dependencies {
api(libs.slf4j.api)
implementation(libs.kotlin.logging)

embedded("dev.freya02:classpath-scanner-commons")

// JDA
compileOnly(libs.jda)
implementation(projects.botCommandsJdaKtx)
Expand Down Expand Up @@ -110,6 +120,30 @@ dependencies {
testImplementation(projects.botCommandsLocalization)

testImplementation(libs.kotlin.metadata)

testCompileOnly("dev.freya02:classpath-scanner-commons")
}

val embeddedDepsDir = layout.buildDirectory.dir("generated/bins/classpath-scanner-commons")

val copyEmbeddedDependencies by tasks.registering(Copy::class) {
description = "Copies contents of embedded dependencies"

for (file in embedded.files) {
from(zipTree(file)) {
duplicatesStrategy = DuplicatesStrategy.FAIL

exclude("META-INF/MANIFEST.MF")
exclude("META-INF/*.kotlin_module")
}
}
into(embeddedDepsDir)
}

sourceSets {
main {
output.dir(copyEmbeddedDependencies)
}
}

tasks.withType<Test> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.github.freya022.botcommands.api.ReceiverConsumer
import io.github.freya022.botcommands.api.core.BContext
import io.github.freya022.botcommands.api.core.BotOwners
import io.github.freya022.botcommands.api.core.annotations.BEventListener
import io.github.freya022.botcommands.api.core.annotations.ExperimentalCoreApi
import io.github.freya022.botcommands.api.core.requests.PriorityGlobalRestRateLimiter
import io.github.freya022.botcommands.api.core.service.annotations.InjectedService
import io.github.freya022.botcommands.api.core.utils.enumSetOf
Expand Down Expand Up @@ -84,6 +85,22 @@ interface BConfigProps {
)
val classes: Set<Class<*>>

/**
* Instructs the classpath scanner to use a predefined list of library classes.
* This speeds up startup, but may log a few false positive exceptions,
* which do not affect the functionality of your application.
*
* Default: `false`
*
* Spring property: `botcommands.core.usePreprocessedLibClassList`
*/
@get:ConfigurationValue(
path = "botcommands.core.usePreprocessedLibClassList",
description = "Instructs the classpath scanner to use a predefined list of library classes. This speeds up startup, but may log a few false positive exceptions, which do not affect the functionality of your application.",
defaultValue = "false",
)
val usePreprocessedLibClassList: Boolean

/**
* Disables sending exceptions to the bot owners.
*
Expand Down Expand Up @@ -180,6 +197,9 @@ class BConfigBuilder : BConfigProps {
override val packages: MutableSet<String> = HashSet()
override val classes: MutableSet<Class<*>> = HashSet()

@ExperimentalCoreApi
override var usePreprocessedLibClassList: Boolean = false

override val predefinedOwnerIds: MutableSet<Long> = HashSet()

@set:JvmName("disableExceptionsInDMs")
Expand Down Expand Up @@ -324,6 +344,7 @@ class BConfigBuilder : BConfigProps {
override val predefinedOwnerIds = this@BConfigBuilder.predefinedOwnerIds.toImmutableSet()
override val packages = this@BConfigBuilder.packages.toImmutableSet()
override val classes = this@BConfigBuilder.classes.toImmutableSet()
override val usePreprocessedLibClassList = this@BConfigBuilder.usePreprocessedLibClassList
override val disableExceptionsInDMs = this@BConfigBuilder.disableExceptionsInDMs
override val enableOwnerBypass = this@BConfigBuilder.enableOwnerBypass
override val ignoredIntents = this@BConfigBuilder.ignoredIntents.toImmutableSet()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package io.github.freya022.botcommands.internal.utils

import dev.freya02.bc.internal.reflection.classpath.ClasspathScannerHelper
import io.github.classgraph.*
import io.github.freya022.botcommands.api.core.config.BConfig
import io.github.freya022.botcommands.api.core.config.BConfigBuilder
import io.github.freya022.botcommands.api.core.debugNull
import io.github.freya022.botcommands.api.core.reflect.annotations.ExperimentalReflectionApi
import io.github.freya022.botcommands.api.core.service.ConditionalServiceChecker
import io.github.freya022.botcommands.api.core.service.CustomConditionChecker
import io.github.freya022.botcommands.api.core.service.annotations.Condition
import io.github.freya022.botcommands.api.core.traceNull
import io.github.freya022.botcommands.api.core.utils.*
import io.github.freya022.botcommands.api.core.utils.javaMethodOrConstructor
import io.github.freya022.botcommands.api.core.utils.mapToArray
import io.github.freya022.botcommands.api.core.utils.simpleNestedName
import io.github.freya022.botcommands.api.core.utils.toImmutableMap
import io.github.freya022.botcommands.internal.core.ClassPathProcessor
import io.github.freya022.botcommands.internal.core.ClassPathProcessorProvider
import io.github.freya022.botcommands.internal.core.HandlersPresenceChecker
Expand All @@ -25,13 +26,75 @@ import kotlin.coroutines.Continuation
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.jvm.internal.impl.load.kotlin.header.KotlinClassHeader
import kotlin.streams.asSequence

private typealias IsNullableAnnotated = Boolean

private val logger = KotlinLogging.logger { }

private interface LibClassesStrategy {
fun configureClassGraph(classGraph: ClassGraph)

fun partitionClasses(scanResult: ScanResult): Pair<List<ClassInfo>, List<ClassInfo>>

fun filterLibClasses(libClasses: Collection<ClassInfo>): Collection<ClassInfo>
}

private class DefaultLibClassesStrategy(
private val helper: ClasspathScannerHelper,
private val bootstrap: BotCommandsBootstrap,
) : LibClassesStrategy {

private val libPackages = ReflectionMetadataScanner::class.java.classLoader
.resources("META-INF/bc.packages")
.asSequence()
.flatMap { it.readText().trim().lineSequence() }
.toList()

override fun configureClassGraph(classGraph: ClassGraph) {
classGraph.acceptPackages(*libPackages.toTypedArray())
}

override fun partitionClasses(scanResult: ScanResult): Pair<List<ClassInfo>, List<ClassInfo>> {
return scanResult.allClasses.partition(::isFromLib)
}

private fun isFromLib(classInfo: ClassInfo): Boolean {
val pkgName = classInfo.packageName
return libPackages.any { pkgName.startsWith(it) }
}

override fun filterLibClasses(libClasses: Collection<ClassInfo>): Collection<ClassInfo> {
return ClasspathScannerHelper.filterClasses(
helper.filterLibraryClasses(libClasses),
onFileFacade = { ReflectionMetadataScanner.checkFacadeFactories(it, bootstrap) }
)
}
}

private class PreprocessedLibClassesStrategy : LibClassesStrategy {

private val libClasses = ReflectionMetadataScanner::class.java.classLoader
.resources("META-INF/bc.classes")
.asSequence()
.flatMap { it.readText().lineSequence() }
.filter { it.isNotBlank() }
.toHashSet()

override fun configureClassGraph(classGraph: ClassGraph) {
classGraph.acceptClasses(*libClasses.toTypedArray())
}

override fun partitionClasses(scanResult: ScanResult): Pair<List<ClassInfo>, List<ClassInfo>> {
return scanResult.allClasses.partition { it.name in libClasses }
}

override fun filterLibClasses(libClasses: Collection<ClassInfo>): Collection<ClassInfo> {
// Class list is already filtered
return libClasses
}
}

internal class ReflectionMetadata(
private val classMetadataMap: Map<Class<*>, ClassMetadata>,
private val methodMetadataMap: Map<Executable, MethodMetadata>,
Expand Down Expand Up @@ -111,14 +174,15 @@ private class ReflectionMetadataScanner private constructor(
if (classes.isNotEmpty())
logger.debug { "Scanning classes: ${classes.joinToString { it.simpleNestedName }}" }

val libPackages = ReflectionMetadataScanner::class.java.classLoader
.resources("META-INF/bc.packages")
.asSequence()
.flatMap { it.readText().trim().lineSequence() }
.toList()
val helper = ClasspathScannerHelper(bootstrap::isService, bootstrap::isServiceFactory)
val libClassesStrategy: LibClassesStrategy = if (config.usePreprocessedLibClassList) {
PreprocessedLibClassesStrategy()
} else {
DefaultLibClassesStrategy(helper, bootstrap)
}

ClassGraph()
.acceptPackages(*libPackages.toTypedArray())
.also(libClassesStrategy::configureClassGraph)
.acceptPackages(*packages.toTypedArray())
.acceptClasses(*classes.mapToArray { it.name })
.enableClassInfo()
Expand All @@ -127,14 +191,18 @@ private class ReflectionMetadataScanner private constructor(
.disableModuleScanning()
.scan()
.use { scan ->
val (libClasses, userClasses) = scan.allClasses.partition { it.isFromLib(libPackages) }
val (libClasses, userClasses) = libClassesStrategy.partitionClasses(scan)
libClasses
.filterLibraryClasses()
.filterClasses()
.let(libClassesStrategy::filterLibClasses)
.processClasses()

userClasses
.filterClasses()
.let {
ClasspathScannerHelper.filterClasses(
it,
onFileFacade = { c -> checkFacadeFactories(c, bootstrap) },
)
}
.also {
if (userClasses.isEmpty()) {
logger.warn { "Found no user classes to scan, check the packages set in ${BConfigBuilder::packages.reference}" }
Expand All @@ -151,65 +219,6 @@ private class ReflectionMetadataScanner private constructor(
}
}

private fun ClassInfo.isFromLib(libPackages: List<String>): Boolean {
val pkgName = packageName
return libPackages.any { pkgName.startsWith(it) }
}

private fun List<ClassInfo>.filterLibraryClasses(): List<ClassInfo> {
// Get types referenced by factories so we get metadata from those as well
val referencedTypes = asSequence()
.flatMap { it.methodInfo }
.filter { bootstrap.isServiceFactory(it) }
.mapTo(hashSetOf()) { it.typeDescriptor.resultType.toString() }

fun ClassInfo.isServiceOrHasFactories(): Boolean {
return bootstrap.isService(this) || methodInfo.any { bootstrap.isServiceFactory(it) }
}

return filter { classInfo ->
if (classInfo.isServiceOrHasFactories()) return@filter true

// Get metadata from all classes that extend a referenced type
// As we can't know exactly what object a factory could return
val superclasses = (classInfo.superclasses + classInfo.interfaces + classInfo).mapTo(hashSetOf()) { it.name }
if (superclasses.containsAny(referencedTypes)) return@filter true

if (classInfo.outerClasses.any { it.isServiceOrHasFactories() }) return@filter true
if (classInfo.hasAnnotation(Condition::class.java)) return@filter true
if (classInfo.interfaces.containsAny(CustomConditionChecker::class.java, ConditionalServiceChecker::class.java)) return@filter true

return@filter false
}
}

private fun ClassInfoList.containsAny(vararg classes: Class<*>): Boolean = classes.any { containsName(it.name) }

private val lowercaseInnerClassRegex = Regex("\\$[a-z]")
private fun List<ClassInfo>.filterClasses(): List<ClassInfo> = filter {
it.annotationInfo.directOnly()["kotlin.Metadata"]?.let { annotationInfo ->
//Only keep classes, not others such as file facades
val kind = KotlinClassHeader.Kind.getById(annotationInfo.parameterValues["k"].value as Int)
if (kind == KotlinClassHeader.Kind.FILE_FACADE) {
it.checkFacadeFactories()
return@filter false
} else if (kind != KotlinClassHeader.Kind.CLASS) {
return@filter false
}
}

if (lowercaseInnerClassRegex.containsMatchIn(it.name)) return@filter false
return@filter !it.isSynthetic && !it.isEnum && !it.isRecord
}

private fun ClassInfo.checkFacadeFactories() {
this.declaredMethodInfo.forEach { methodInfo ->
check(!bootstrap.isServiceFactory(methodInfo)) {
"Top-level service factories are not supported: ${methodInfo.shortSignature}"
}
}
}

private fun Collection<ClassInfo>.processClasses(): Unit = forEach { classInfo ->
try {
val clazz = tryGetClass(classInfo) ?: return@forEach
Expand Down Expand Up @@ -307,6 +316,14 @@ private class ReflectionMetadataScanner private constructor(
get() = parameters.any { it.type == Continuation::class.java }

companion object {
fun checkFacadeFactories(classInfo: ClassInfo, bootstrap: BotCommandsBootstrap) {
classInfo.declaredMethodInfo.forEach { methodInfo ->
check(!bootstrap.isServiceFactory(methodInfo)) {
"Top-level service factories are not supported: ${methodInfo.shortSignature}"
}
}
}

fun scan(
config: BConfig,
bootstrap: BotCommandsBootstrap,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.github.freya022.botcommands.reflection

import dev.freya02.bc.internal.reflection.classpath.ClasspathScannerHelper
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource

object ReflectionMetadataScannerHelperTests {
@ParameterizedTest
@MethodSource("classNamesToCheck")
fun `Check class names exist`(className: String) {
assertDoesNotThrow { Class.forName(className, false, Thread.currentThread().contextClassLoader) }
}

@JvmStatic
fun classNamesToCheck(): List<String> {
return ClasspathScannerHelper.classNamesToCheck
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.freya022.botcommands.internal.core.config

import io.github.freya022.botcommands.api.core.annotations.ExperimentalCoreApi
import io.github.freya022.botcommands.api.core.config.*
import net.dv8tion.jda.api.requests.GatewayIntent
import org.springframework.boot.context.properties.ConfigurationProperties
Expand All @@ -11,6 +12,7 @@ internal class BotCommandsCoreConfiguration(
override val predefinedOwnerIds: Set<Long> = emptySet(),
override val packages: Set<String> = emptySet(),
override val classes: Set<Class<*>> = emptySet(),
override val usePreprocessedLibClassList: Boolean = false,
override val disableExceptionsInDMs: Boolean = false,
override val enableOwnerBypass: Boolean = false,
override val ignoredIntents: Set<GatewayIntent> = emptySet(),
Expand All @@ -23,6 +25,8 @@ internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfigurat
predefinedOwnerIds += configuration.predefinedOwnerIds
packages += configuration.packages
classes += configuration.classes
@OptIn(ExperimentalCoreApi::class)
usePreprocessedLibClassList = configuration.usePreprocessedLibClassList
disableExceptionsInDMs = configuration.disableExceptionsInDMs
enableOwnerBypass = configuration.enableOwnerBypass
ignoredIntents += configuration.ignoredIntents
Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ dependencies {
dokka(projects.botCommandsRestarter)
}

tasks.generateClassList {
enabled = false
}

tasks.withType<Test> {
useJUnitPlatform()
}
Expand Down
1 change: 1 addition & 0 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies {
implementation(libs.dokka.plugin)

implementation("dev.freya02:spring-configuration-metadata-generator")
implementation("dev.freya02:class-list-generator")
}

tasks.withType<JavaCompile> {
Expand Down
Loading