Skip to content

Commit

Permalink
Add a basic KSP Symbol Processor for Glide
Browse files Browse the repository at this point in the history
This library only implements support for basic configuration of Glide. Like the
Java version it can detect and merge multiple LibraryGlideModules and a single
AppGlideModule. The merged output (GeneratedAppGlideModule) will then be
called via reflection to configure Glide when Glide is first used.

Unlike the Java version this processor has no support for:

1.  Extensions
2.  Including or Excluding LibraryGlideModules that are added via AndroidManifest registration
3.  Generated Glide, RequestOptions, RequestBuilder, and RequestManager
    overrides.
4.  Excluding LibraryGlideModules that are added via annotations

I suspect very few people use the first two missing features and so,
barring major objections, those features will be only available via the
Java processor and in the very long run, deprecated. Kotlin extension
functions can provide the same value with less magic and complexity as
Extensions. AndroidManifest registrtion has been deprecated for years.

For #3 ideally we do not support these generated overrides either. Their
only real purpose was to expose the functionality provided by
Extensions. The one caveat is that our documentation has encouraged
their use in the past. If we remove support instantly, it may complicate
migration.

I will support #4, but in a future change. This one is large enough
already.

PiperOrigin-RevId: 461943092
  • Loading branch information
sjudd authored and glide-copybara-robot committed Jul 19, 2022
1 parent 6640376 commit c35ad13
Show file tree
Hide file tree
Showing 15 changed files with 889 additions and 3 deletions.
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

0 comments on commit c35ad13

Please sign in to comment.