Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a basic KSP Symbol Processor for Glide #4835

Merged
merged 2 commits into from
Jul 19, 2022
Merged
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
15 changes: 15 additions & 0 deletions annotation/ksp/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'com.google.devtools.ksp'
}

dependencies {
implementation("com.squareup:kotlinpoet:1.12.0")
implementation project(":annotation")
implementation project(":glide")
implementation 'com.google.devtools.ksp:symbol-processing-api:1.7.0-1.0.6'
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.0.0")
implementation("com.google.auto.service:auto-service-annotations:1.0.1")
}

apply from: "${rootProject.projectDir}/scripts/upload.gradle"
6 changes: 6 additions & 0 deletions annotation/ksp/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kotlin.code.style=official

POM_NAME=Glide KSP Annotation Processor
POM_ARTIFACT_ID=ksp
POM_PACKAGING=jar
POM_DESCRIPTION=Glide's KSP based annotation processor. Should be included in all Kotlin applications and libraries that use Glide's modules for configuration and do not require the more advanced features of the Java based compiler.
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package com.bumptech.glide.annotation.ksp

import com.bumptech.glide.annotation.Excludes
import com.google.devtools.ksp.getConstructors
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import kotlin.reflect.KClass

// This class is visible only for testing
// TODO(b/174783094): Add @VisibleForTesting when internal is supported.
object AppGlideModuleConstants {
// This variable is visible only for testing
// TODO(b/174783094): Add @VisibleForTesting when internal is supported.
const val INVALID_MODULE_MESSAGE =
"Your AppGlideModule must have at least one constructor that has either no parameters or " +
"accepts only a Context."

private const val CONTEXT_NAME = "Context"
internal const val CONTEXT_PACKAGE = "android.content"
internal const val GLIDE_PACKAGE_NAME = "com.bumptech.glide"
internal const val CONTEXT_QUALIFIED_NAME = "$CONTEXT_PACKAGE.$CONTEXT_NAME"
internal const val GENERATED_ROOT_MODULE_PACKAGE_NAME = GLIDE_PACKAGE_NAME

internal val CONTEXT_CLASS_NAME = ClassName(CONTEXT_PACKAGE, CONTEXT_NAME)
}

internal data class AppGlideModuleData(
val name: ClassName,
val constructor: Constructor,
) {
internal data class Constructor(val hasContext: Boolean)
}

/**
* Given a [com.bumptech.glide.module.AppGlideModule] class declaration provided by the developer,
* validate the class and produce a fully parsed [AppGlideModuleData] that allows us to generate a
* valid [com.bumptech.glide.GeneratedAppGlideModule] implementation without further introspection.
*/
internal class AppGlideModuleParser(
private val environment: SymbolProcessorEnvironment,
private val resolver: Resolver,
private val appGlideModuleClass: KSClassDeclaration,
) {

fun parseAppGlideModule(): AppGlideModuleData {
val constructor = parseAppGlideModuleConstructorOrThrow()
val name = ClassName.bestGuess(appGlideModuleClass.qualifiedName!!.asString())

return AppGlideModuleData(name = name, constructor = constructor)
}

private fun parseAppGlideModuleConstructorOrThrow(): AppGlideModuleData.Constructor {
val hasEmptyConstructors = appGlideModuleClass.getConstructors().any { it.parameters.isEmpty() }
val hasContextParamOnlyConstructor =
appGlideModuleClass.getConstructors().any { it.hasSingleContextParameter() }
if (!hasEmptyConstructors && !hasContextParamOnlyConstructor) {
throw InvalidGlideSourceException(AppGlideModuleConstants.INVALID_MODULE_MESSAGE)
}
return AppGlideModuleData.Constructor(hasContextParamOnlyConstructor)
}

private fun KSFunctionDeclaration.hasSingleContextParameter() =
parameters.size == 1 &&
AppGlideModuleConstants.CONTEXT_QUALIFIED_NAME ==
parameters.single().type.resolve().declaration.qualifiedName?.asString()

private data class IndexFilesAndLibraryModuleNames(
val indexFiles: List<KSDeclaration>,
val libraryModuleNames: List<String>,
)

private fun extractGlideModulesFromIndexAnnotation(
index: KSDeclaration,
): List<String> {
val indexAnnotation: KSAnnotation = index.atMostOneIndexAnnotation() ?: return emptyList()
environment.logger.info("Found index annotation: $indexAnnotation")
return indexAnnotation.getModuleArgumentValues().toList()
}

private fun KSAnnotation.getModuleArgumentValues(): List<String> {
val result = arguments.find { it.name?.getShortName().equals("modules") }?.value
if (result is List<*> && result.all { it is String }) {
@Suppress("UNCHECKED_CAST") return result as List<String>
}
throw InvalidGlideSourceException("Found an invalid internal Glide index: $this")
}

private fun KSDeclaration.atMostOneIndexAnnotation() = atMostOneAnnotation(Index::class)

private fun KSDeclaration.atMostOneExcludesAnnotation() = atMostOneAnnotation(Excludes::class)

private fun KSDeclaration.atMostOneAnnotation(
annotation: KClass<out Annotation>,
): KSAnnotation? {
val matchingAnnotations: List<KSAnnotation> =
annotations
.filter {
annotation.qualifiedName?.equals(
it.annotationType.resolve().declaration.qualifiedName?.asString()
)
?: false
}
.toList()
if (matchingAnnotations.size > 1) {
throw InvalidGlideSourceException(
"""Expected 0 or 1 $annotation annotations on ${this.qualifiedName}, but found:
${matchingAnnotations.size}"""
)
}
return matchingAnnotations.singleOrNull()
}
}

/**
* Given valid [AppGlideModuleData], writes a Kotlin implementation of
* [com.bumptech.glide.GeneratedAppGlideModule].
*
* This class should obtain all of its data from [AppGlideModuleData] and should not interact with
* any ksp classes. In the long run, the restriction may allow us to share code between the Java and
* Kotlin processors.
*/
internal class AppGlideModuleGenerator(private val appGlideModuleData: AppGlideModuleData) {

fun generateAppGlideModule(): FileSpec {
val generatedAppGlideModuleClass = generateAppGlideModuleClass(appGlideModuleData)
return FileSpec.builder(
AppGlideModuleConstants.GLIDE_PACKAGE_NAME,
"GeneratedAppGlideModuleImpl"
)
.addType(generatedAppGlideModuleClass)
.build()
}

private fun generateAppGlideModuleClass(
data: AppGlideModuleData,
): TypeSpec {
return TypeSpec.classBuilder("GeneratedAppGlideModuleImpl")
.superclass(
ClassName(
AppGlideModuleConstants.GENERATED_ROOT_MODULE_PACKAGE_NAME,
"GeneratedAppGlideModule"
)
)
.addModifiers(KModifier.INTERNAL)
.addProperty("appGlideModule", data.name, KModifier.PRIVATE)
.primaryConstructor(generateConstructor(data))
.addFunction(generateRegisterComponents())
.addFunction(generateApplyOptions())
.addFunction(generateManifestParsingDisabled())
.build()
}

private fun generateConstructor(data: AppGlideModuleData): FunSpec {
val contextParameterBuilder =
ParameterSpec.builder("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME)
if (!data.constructor.hasContext) {
contextParameterBuilder.addAnnotation(
AnnotationSpec.builder(ClassName("kotlin", "Suppress"))
.addMember("%S", "UNUSED_VARIABLE")
.build()
)
}

return FunSpec.constructorBuilder()
.addParameter(contextParameterBuilder.build())
.addStatement(
"appGlideModule = %T(${if (data.constructor.hasContext) "context" else ""})",
data.name
)
.build()

// TODO(judds): Log the discovered modules here.
}

// TODO(judds): call registerComponents on LibraryGlideModules here.
private fun generateRegisterComponents() =
FunSpec.builder("registerComponents")
.addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE)
.addParameter("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME)
.addParameter("glide", ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "Glide"))
.addParameter("registry", ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "Registry"))
.addStatement("appGlideModule.registerComponents(context, glide, registry)")
.build()

private fun generateApplyOptions() =
FunSpec.builder("applyOptions")
.addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE)
.addParameter("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME)
.addParameter(
"builder",
ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "GlideBuilder")
)
.addStatement("appGlideModule.applyOptions(context, builder)")
.build()

private fun generateManifestParsingDisabled() =
FunSpec.builder("isManifestParsingEnabled")
.addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE)
.returns(Boolean::class)
.addStatement("return false")
.build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.bumptech.glide.annotation.ksp

import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFile
import com.google.devtools.ksp.validate
import com.squareup.kotlinpoet.FileSpec

/**
* Glide's KSP annotation processor.
*
* This class recognizes and parses [com.bumptech.glide.module.AppGlideModule]s and
* [com.bumptech.glide.module.LibraryGlideModule]s that are annotated with
* [com.bumptech.glide.annotation.GlideModule].
*
* `LibraryGlideModule`s are merged into indexes, or classes generated in Glide's package. When a
* `AppGlideModule` is found, we then generate Glide's configuration so that it calls the
* `AppGlideModule` and anay included `LibraryGlideModules`. Using indexes allows us to process
* `LibraryGlideModules` in multiple rounds and/or libraries.
*
* TODO(b/239086146): Finish implementing the behavior described here.
*/
class GlideSymbolProcessor(private val environment: SymbolProcessorEnvironment) : SymbolProcessor {
var isAppGlideModuleGenerated = false

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.bumptech.glide.annotation.GlideModule")
val (validSymbols, invalidSymbols) = symbols.partition { it.validate() }.toList()
return try {
processChecked(resolver, symbols, validSymbols, invalidSymbols)
} catch (e: InvalidGlideSourceException) {
environment.logger.error(e.userMessage)
invalidSymbols
}
}

private fun processChecked(
resolver: Resolver,
symbols: Sequence<KSAnnotated>,
validSymbols: List<KSAnnotated>,
invalidSymbols: List<KSAnnotated>,
): List<KSAnnotated> {
environment.logger.logging("Found symbols, valid: $validSymbols, invalid: $invalidSymbols")

val (appGlideModules, libraryGlideModules) = extractGlideModules(validSymbols)

if (libraryGlideModules.size + appGlideModules.size != validSymbols.count()) {
val invalidModules =
symbols
.filter { !libraryGlideModules.contains(it) && !appGlideModules.contains(it) }
.map { it.location.toString() }
.toList()

throw InvalidGlideSourceException(
GlideSymbolProcessorConstants.INVALID_ANNOTATED_CLASS.format(invalidModules)
)
}

if (appGlideModules.size > 1) {
throw InvalidGlideSourceException(
GlideSymbolProcessorConstants.SINGLE_APP_MODULE_ERROR.format(appGlideModules)
)
}

environment.logger.logging(
"Found AppGlideModules: $appGlideModules, LibraryGlideModules: $libraryGlideModules"
)
// TODO(judds): Add support for parsing LibraryGlideModules here.

if (appGlideModules.isNotEmpty()) {
parseAppGlideModuleAndWriteGeneratedAppGlideModule(resolver, appGlideModules.single())
}

return invalidSymbols
}

private fun parseAppGlideModuleAndWriteGeneratedAppGlideModule(
resolver: Resolver,
appGlideModule: KSClassDeclaration,
) {
val appGlideModuleData =
AppGlideModuleParser(environment, resolver, appGlideModule).parseAppGlideModule()
val appGlideModuleGenerator = AppGlideModuleGenerator(appGlideModuleData)
val appGlideModuleFileSpec: FileSpec = appGlideModuleGenerator.generateAppGlideModule()
writeFile(
appGlideModuleFileSpec,
listOfNotNull(appGlideModule.containingFile),
)
}

private fun writeFile(file: FileSpec, sources: List<KSFile>) {
environment.codeGenerator
.createNewFile(
Dependencies(
aggregating = false,
sources = sources.toTypedArray(),
),
file.packageName,
file.name
)
.writer()
.use { file.writeTo(it) }

environment.logger.logging("Wrote file: $file")
}

internal data class GlideModules(
val appModules: List<KSClassDeclaration>,
val libraryModules: List<KSClassDeclaration>,
)

private fun extractGlideModules(annotatedModules: List<KSAnnotated>): GlideModules {
val appAndLibraryModuleNames = listOf(APP_MODULE_QUALIFIED_NAME, LIBRARY_MODULE_QUALIFIED_NAME)
val modulesBySuperType: Map<String?, List<KSClassDeclaration>> =
annotatedModules.filterIsInstance<KSClassDeclaration>().groupBy { classDeclaration ->
appAndLibraryModuleNames.singleOrNull { classDeclaration.hasSuperType(it) }
}

val (appModules, libraryModules) =
appAndLibraryModuleNames.map { modulesBySuperType[it] ?: emptyList() }
return GlideModules(appModules, libraryModules)
}

private fun KSClassDeclaration.hasSuperType(superTypeQualifiedName: String) =
superTypes
.map { superType -> superType.resolve().declaration.qualifiedName!!.asString() }
.contains(superTypeQualifiedName)
}

// This class is visible only for testing
// TODO(b/174783094): Add @VisibleForTesting when internal is supported.
object GlideSymbolProcessorConstants {
// This variable is visible only for testing
// TODO(b/174783094): Add @VisibleForTesting when internal is supported.
val PACKAGE_NAME: String = GlideSymbolProcessor::class.java.`package`.name
const val SINGLE_APP_MODULE_ERROR = "You can have at most one AppGlideModule, but found: %s"
const val DUPLICATE_LIBRARY_MODULE_ERROR =
"LibraryGlideModules %s are included more than once, keeping only one!"
const val INVALID_ANNOTATED_CLASS =
"@GlideModule annotated classes must implement AppGlideModule or LibraryGlideModule: %s"
}

internal class InvalidGlideSourceException(val userMessage: String) : Exception(userMessage)

private const val APP_MODULE_QUALIFIED_NAME = "com.bumptech.glide.module.AppGlideModule"
private const val LIBRARY_MODULE_QUALIFIED_NAME = "com.bumptech.glide.module.LibraryGlideModule"
Loading