diff --git a/BotCommands-core/build.gradle.kts b/BotCommands-core/build.gradle.kts index 95020f2ae..4584f3ad9 100644 --- a/BotCommands-core/build.gradle.kts +++ b/BotCommands-core/build.gradle.kts @@ -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 -------------------- @@ -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) @@ -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 { diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index 97557143e..950de3392 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -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 @@ -84,6 +85,22 @@ interface BConfigProps { ) val classes: Set> + /** + * 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. * @@ -180,6 +197,9 @@ class BConfigBuilder : BConfigProps { override val packages: MutableSet = HashSet() override val classes: MutableSet> = HashSet() + @ExperimentalCoreApi + override var usePreprocessedLibClassList: Boolean = false + override val predefinedOwnerIds: MutableSet = HashSet() @set:JvmName("disableExceptionsInDMs") @@ -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() diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index b1e88c77e..87a3eb4b4 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -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 @@ -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> + + fun filterLibClasses(libClasses: Collection): Collection +} + +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> { + 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): Collection { + 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> { + return scanResult.allClasses.partition { it.name in libClasses } + } + + override fun filterLibClasses(libClasses: Collection): Collection { + // Class list is already filtered + return libClasses + } +} + internal class ReflectionMetadata( private val classMetadataMap: Map, ClassMetadata>, private val methodMetadataMap: Map, @@ -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() @@ -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}" } @@ -151,65 +219,6 @@ private class ReflectionMetadataScanner private constructor( } } - private fun ClassInfo.isFromLib(libPackages: List): Boolean { - val pkgName = packageName - return libPackages.any { pkgName.startsWith(it) } - } - - private fun List.filterLibraryClasses(): List { - // 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.filterClasses(): List = 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.processClasses(): Unit = forEach { classInfo -> try { val clazz = tryGetClass(classInfo) ?: return@forEach @@ -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, diff --git a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/reflection/ReflectionMetadataScannerHelperTests.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/reflection/ReflectionMetadataScannerHelperTests.kt new file mode 100644 index 000000000..524fda865 --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/reflection/ReflectionMetadataScannerHelperTests.kt @@ -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 { + return ClasspathScannerHelper.classNamesToCheck + } +} diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index 03823574f..588336810 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -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 @@ -11,6 +12,7 @@ internal class BotCommandsCoreConfiguration( override val predefinedOwnerIds: Set = emptySet(), override val packages: Set = emptySet(), override val classes: Set> = emptySet(), + override val usePreprocessedLibClassList: Boolean = false, override val disableExceptionsInDMs: Boolean = false, override val enableOwnerBypass: Boolean = false, override val ignoredIntents: Set = emptySet(), @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 50d4e6d59..2dfde0f88 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,6 +58,10 @@ dependencies { dokka(projects.botCommandsRestarter) } +tasks.generateClassList { + enabled = false +} + tasks.withType { useJUnitPlatform() } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index ea5284ec6..76bde13a8 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(libs.dokka.plugin) implementation("dev.freya02:spring-configuration-metadata-generator") + implementation("dev.freya02:class-list-generator") } tasks.withType { diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index 7f4141c36..c245769f5 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -13,3 +13,9 @@ includeBuild("../spring-configuration-metadata-generator") { substitute(module("dev.freya02:spring-configuration-metadata-generator")).using(project(":")) } } + +includeBuild("../class-list-generator") { + dependencySubstitution { + substitute(module("dev.freya02:class-list-generator")).using(project(":")) + } +} diff --git a/buildSrc/src/main/kotlin/class-list-generator-conventions.gradle.kts b/buildSrc/src/main/kotlin/class-list-generator-conventions.gradle.kts new file mode 100644 index 000000000..dc0f3fe12 --- /dev/null +++ b/buildSrc/src/main/kotlin/class-list-generator-conventions.gradle.kts @@ -0,0 +1,32 @@ +import dev.freya02.botcommands.tasks.GenerateClassListTask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +val compileJava by tasks.getting(JavaCompile::class) +val compileKotlin by tasks.getting(KotlinCompile::class) + +val generateClassList by tasks.registering(GenerateClassListTask::class) { + buildDirs.from( + layout.buildDirectory.dir("classes/java/main"), + layout.buildDirectory.dir("classes/kotlin/main"), + ) + // This is necessary as meta-annotations from dependencies cannot be resolved without them on the classpath + classpath.from(configurations.compileClasspath) + + // Only regenerate list if classes changes + classes.from(compileJava.outputs, compileKotlin.outputs) + + outputRoot = layout.buildDirectory.dir("generated/sources/lib-class-list/main/resources") +} + +// Register our generated sources +sourceSets { + main { + resources { + srcDir(generateClassList) + } + } +} diff --git a/buildSrc/src/main/kotlin/dev/freya02/botcommands/tasks/GenerateClassListTask.kt b/buildSrc/src/main/kotlin/dev/freya02/botcommands/tasks/GenerateClassListTask.kt new file mode 100644 index 000000000..123affbfb --- /dev/null +++ b/buildSrc/src/main/kotlin/dev/freya02/botcommands/tasks/GenerateClassListTask.kt @@ -0,0 +1,40 @@ +package dev.freya02.botcommands.tasks + +import dev.freya02.bc.classlist.generator.ClassListGenerator +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.* +import java.io.File + +@CacheableTask +abstract class GenerateClassListTask : DefaultTask() { + + // For Gradle caching purposes and implicit dependency on compile tasks + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val classes: ConfigurableFileCollection + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val buildDirs: ConfigurableFileCollection + + @get:Classpath + abstract val classpath: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputRoot: DirectoryProperty + + @TaskAction + fun generate() { + val classList = ClassListGenerator.generate(buildDirs.files, classpath.files) + if (classList.isBlank()) { + return + } + + val outputFile = outputRoot.get().asFile.resolve("META-INF").resolve("bc.classes") + outputFile.parentFile.mkdirs() + outputFile.writeText(classList) + } +} diff --git a/buildSrc/src/main/kotlin/publish-conventions.gradle.kts b/buildSrc/src/main/kotlin/publish-conventions.gradle.kts index 419a21101..16ad31b9a 100644 --- a/buildSrc/src/main/kotlin/publish-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/publish-conventions.gradle.kts @@ -6,6 +6,8 @@ plugins { `java-library` signing id("com.vanniktech.maven.publish") + // Better run the generator and have it run unnecessarily than have bugs when it should had been run + id("class-list-generator-conventions") } val environment = project.extensions.create("publishedProjectEnvironment") diff --git a/class-list-generator/build.gradle.kts b/class-list-generator/build.gradle.kts new file mode 100644 index 000000000..4662d6f31 --- /dev/null +++ b/class-list-generator/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.kotlin) +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.classgraph) + implementation("dev.freya02:classpath-scanner-commons") +} diff --git a/class-list-generator/settings.gradle.kts b/class-list-generator/settings.gradle.kts new file mode 100644 index 000000000..ce4559add --- /dev/null +++ b/class-list-generator/settings.gradle.kts @@ -0,0 +1,15 @@ +rootProject.name = "class-list-generator" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +includeBuild("../classpath-scanner-commons") { + dependencySubstitution { + substitute(module("dev.freya02:classpath-scanner-commons")).using(project(":")) + } +} diff --git a/class-list-generator/src/main/kotlin/dev/freya02/bc/classlist/generator/ClassListGenerator.kt b/class-list-generator/src/main/kotlin/dev/freya02/bc/classlist/generator/ClassListGenerator.kt new file mode 100644 index 000000000..dbc18c69e --- /dev/null +++ b/class-list-generator/src/main/kotlin/dev/freya02/bc/classlist/generator/ClassListGenerator.kt @@ -0,0 +1,56 @@ +package dev.freya02.bc.classlist.generator + +import dev.freya02.bc.internal.reflection.classpath.ClasspathScannerHelper +import dev.freya02.bc.internal.reflection.classpath.ClasspathScannerHelper.Companion.BSERVICE_ANNOTATION +import io.github.classgraph.ClassGraph +import io.github.classgraph.ClassInfo +import io.github.classgraph.MethodInfo +import java.io.File + +object ClassListGenerator { + + private const val COMPONENT_ANNOTATION_NAME = "org.springframework.stereotype.Component" + private const val BEAN_ANNOTATION_NAME = "org.springframework.context.annotation.Bean" + + fun generate(buildDirs: Collection, classpath: Collection): String { + // We've set CG's classpath to the entire "compileClasspath" configuration, + // but we only need to make a filtered class list of the current project, + // so we can tell CG to only look at classes that we can find in the project's file tree + // while allowing CG to resolve necessary stuff from dependencies, like meta annotations + // which are crucial for this task to work. + val builtClassNames = buildDirs.asSequence() + .flatMap { buildDir -> + buildDir.walk() + .filter { it.extension == "class" } + .map { it.toRelativeString(buildDir) } + } + .map { it.replace('/', '.').removeSuffix(".class") } + .toList() + + val helper = ClasspathScannerHelper(::isService, ::isServiceFactory) + + ClassGraph() + // Classpath includes compiled project classes + dependencies (required for CG to discover all meta-annotations) + .overrideClasspath(buildDirs + classpath) + // Only compiled project classes + .acceptClasses(*builtClassNames.toTypedArray()) + .enableClassInfo() + .enableMethodInfo() + .enableAnnotationInfo() + .disableModuleScanning() + .scan().use { scanResult -> + return ClasspathScannerHelper + .filterClasses(scanResult.allClasses, onFileFacade = { /* noop */ }) + .let(helper::filterLibraryClasses) + .joinToString("\n") { it.name } + } + } + + private fun isService(classInfo: ClassInfo): Boolean { + return classInfo.hasAnnotation(BSERVICE_ANNOTATION) || classInfo.hasAnnotation(COMPONENT_ANNOTATION_NAME) + } + + private fun isServiceFactory(methodInfo: MethodInfo): Boolean { + return methodInfo.hasAnnotation(BSERVICE_ANNOTATION) || methodInfo.hasAnnotation(BEAN_ANNOTATION_NAME) + } +} diff --git a/classpath-scanner-commons/build.gradle.kts b/classpath-scanner-commons/build.gradle.kts new file mode 100644 index 000000000..f2fc571ff --- /dev/null +++ b/classpath-scanner-commons/build.gradle.kts @@ -0,0 +1,26 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.kotlin) +} + +group = "dev.freya02" + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.classgraph) +} + +tasks.named("compileJava") { + options.release = 17 +} + +tasks.named("compileKotlin") { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} diff --git a/classpath-scanner-commons/settings.gradle.kts b/classpath-scanner-commons/settings.gradle.kts new file mode 100644 index 000000000..96c76e729 --- /dev/null +++ b/classpath-scanner-commons/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "classpath-scanner-commons" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/classpath-scanner-commons/src/main/kotlin/dev/freya02/bc/internal/reflection/classpath/ClasspathScannerHelper.kt b/classpath-scanner-commons/src/main/kotlin/dev/freya02/bc/internal/reflection/classpath/ClasspathScannerHelper.kt new file mode 100644 index 000000000..2d20b657e --- /dev/null +++ b/classpath-scanner-commons/src/main/kotlin/dev/freya02/bc/internal/reflection/classpath/ClasspathScannerHelper.kt @@ -0,0 +1,80 @@ +package dev.freya02.bc.internal.reflection.classpath + +import io.github.classgraph.ClassInfo +import io.github.classgraph.MethodInfo + +class ClasspathScannerHelper( + private val isService: (ClassInfo) -> Boolean, + private val isServiceFactory: (MethodInfo) -> Boolean, +) { + + fun filterLibraryClasses(classes: Collection): List { + // Get types referenced by factories so we get metadata from those as well + val referencedTypes = classes.asSequence() + .flatMap { it.methodInfo } + .filter { isServiceFactory(it) } + .mapTo(hashSetOf()) { it.typeDescriptor.resultType.toString() } + + return classes.filter { classInfo -> filterLibraryClass(classInfo, referencedTypes) } + } + + private fun filterLibraryClass(classInfo: ClassInfo, referencedTypes: Set): Boolean { + if (classInfo.isServiceOrHasFactories()) + return true + + val interfaces = classInfo.interfaces + // 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 + interfaces + classInfo).mapTo(hashSetOf()) { it.name } + if (superclasses.containsAny(referencedTypes)) + return true + + if (classInfo.outerClasses.any { it.isServiceOrHasFactories() }) + return true + if (classInfo.hasAnnotation(CONDITION_ANNOTATION)) + return true + + if (interfaces.any { it.name == CONDITIONAL_SERVICE_CHECKER_ANNOTATION || it.name == CUSTOM_CONDITION_CHECKER_ANNOTATION }) + return true + + return false + } + + private fun ClassInfo.isServiceOrHasFactories(): Boolean { + return isService(this) || methodInfo.any { isServiceFactory(it) } + } + + private fun Set.containsAny(elements: Set): Boolean = elements.any { it in this } + + companion object { + private val lowercaseInnerClassRegex = Regex("\\$[a-z]") + + const val BSERVICE_ANNOTATION = "io.github.freya022.botcommands.api.core.service.annotations.BService" + private const val CONDITION_ANNOTATION = "io.github.freya022.botcommands.api.core.service.annotations.Condition" + private const val CONDITIONAL_SERVICE_CHECKER_ANNOTATION = "io.github.freya022.botcommands.api.core.service.ConditionalServiceChecker" + private const val CUSTOM_CONDITION_CHECKER_ANNOTATION = "io.github.freya022.botcommands.api.core.service.CustomConditionChecker" + + val classNamesToCheck = listOf( + BSERVICE_ANNOTATION, + CONDITION_ANNOTATION, + CONDITIONAL_SERVICE_CHECKER_ANNOTATION, + CUSTOM_CONDITION_CHECKER_ANNOTATION, + ) + + fun filterClasses(classes: Collection, onFileFacade: (ClassInfo) -> Unit): List = classes.filter { classInfo -> + classInfo.annotationInfo.directOnly()["kotlin.Metadata"]?.let { annotationInfo -> + //Only keep classes, not others such as file facades + val kind = annotationInfo.parameterValues["k"].value as Int + if (kind == 2) { // File facade + onFileFacade(classInfo) + return@filter false + } else if (kind != 1) { // Class + return@filter false + } + } + + if (lowercaseInnerClassRegex.containsMatchIn(classInfo.name)) return@filter false + return@filter !classInfo.isSynthetic && !classInfo.isEnum && !classInfo.isRecord + } + } +} diff --git a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt b/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt index f6ac96635..e84d94c18 100644 --- a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt +++ b/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt @@ -8,11 +8,13 @@ import dev.freya02.botcommands.restarter.api.BotCommandsRestarter import dev.freya02.botcommands.restarter.api.annotations.ExperimentalRestartApi import dev.freya02.botcommands.restarter.internal.utils.AppClasspath import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.core.annotations.ExperimentalCoreApi import io.github.freya022.botcommands.api.core.config.* import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.DiscordLocale import kotlin.io.path.absolutePathString +import kotlin.random.Random import kotlin.system.exitProcess import kotlin.time.Duration.Companion.milliseconds @@ -37,6 +39,11 @@ object Main { MethodAccessorsConfig.preferClassFileAccessors() BotCommands.create { + // This will produce a few (soft) errors as it will scan classes related to the Spring support, + // which requires some compile-only classes + @OptIn(ExperimentalCoreApi::class) + usePreprocessedLibClassList = Random.nextBoolean() + disableExceptionsInDMs = true addSearchPath("dev.freya02.botcommands.bot") diff --git a/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties b/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties index db676b9f4..5b0670f3e 100644 --- a/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties +++ b/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties @@ -8,6 +8,7 @@ restart.exclude.restarter-prod-res=BotCommands-restarter/build/resources/main restart.exclude.core-prod=BotCommands-core/build/classes/(?:kotlin|java)/main restart.exclude.core-prod-res=BotCommands-core/build/resources/main +restart.exclude.core-prod-generated-bins=BotCommands-core/build/generated/bins/classpath-scanner-commons restart.exclude.rate-limit-prod=BotCommands-rate-limit/build/classes/(?:kotlin|java)/main restart.exclude.rate-limit-prod-res=BotCommands-rate-limit/build/resources/main