Skip to content

Commit

Permalink
refactor(plugins-api): Separate plugin analysis from code generation
Browse files Browse the repository at this point in the history
Introduce an intermediary `PluginSpec` model that is used as input for
the code generation and move the code to create a `PluginSpec` to the
new class `PluginSpecFactory`.

This provides a better separation of concerns and makes it easier to
generate other output files based on the `PluginSpec`.

Signed-off-by: Martin Nonnenmacher <martin.nonnenmacher@bosch.com>
  • Loading branch information
mnonnenmacher committed Aug 29, 2024
1 parent 848e666 commit 4dd5a49
Show file tree
Hide file tree
Showing 6 changed files with 309 additions and 226 deletions.
1 change: 1 addition & 0 deletions plugins/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ dependencies {

implementation(libs.kotlinpoet)
implementation(libs.kotlinpoet.ksp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ksp)
}
266 changes: 57 additions & 209 deletions plugins/api/src/main/kotlin/PluginFactoryGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,8 @@

package org.ossreviewtoolkit.plugins.api

import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.getAnnotationsByType
import com.google.devtools.ksp.getConstructors
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.symbol.Modifier

import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
Expand All @@ -37,42 +30,25 @@ import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo

class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) {
fun generate(ortPlugin: OrtPlugin, pluginClass: KSClassDeclaration, pluginFactoryClass: KSClassDeclaration) {
val generatedFactory = generateFactoryClass(ortPlugin, pluginClass, pluginFactoryClass)
generateServiceLoaderFile(pluginClass, pluginFactoryClass, generatedFactory)
fun generate(pluginSpec: PluginSpec) {
val generatedFactory = generateFactoryClass(pluginSpec)
generateServiceLoaderFile(pluginSpec, generatedFactory)
}

/**
* Generate a factory class for the [ortPlugin] of type [pluginClass] that implements the [pluginFactoryClass]
* interface.
* Generate a factory class for the [pluginSpec].
*/
private fun generateFactoryClass(
ortPlugin: OrtPlugin,
pluginClass: KSClassDeclaration,
pluginFactoryClass: KSClassDeclaration
): TypeSpec {
val pluginType = pluginClass.asType(emptyList()).toTypeName()
val pluginFactoryType = pluginFactoryClass.asType(emptyList()).toTypeName()

val constructor = getPluginConstructor(pluginClass)
val (configClass, configType) = if (constructor.parameters.size == 2) {
val type = constructor.parameters[1].type
type.resolve().declaration as KSClassDeclaration to type.toTypeName()
} else {
null to null
}

val pluginOptions = configClass?.getPluginOptions().orEmpty()

private fun generateFactoryClass(pluginSpec: PluginSpec): TypeSpec {
// Create the initializer for the plugin config object.
val configInitializer = configType?.let { getConfigInitializer(it, pluginOptions) }
val configInitializer = pluginSpec.configClass?.let {
getConfigInitializer(it.typeName, pluginSpec.descriptor.options)
}

// Create the plugin descriptor property.
val descriptorInitializer = getDescriptorInitializer(ortPlugin, pluginClass, pluginOptions)
val descriptorInitializer = getDescriptorInitializer(pluginSpec.descriptor)
val descriptorProperty = PropertySpec.builder("descriptor", PluginDescriptor::class, KModifier.OVERRIDE)
.initializer(descriptorInitializer)
.build()
Expand All @@ -84,27 +60,27 @@ class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) {

if (configInitializer != null) {
addCode(configInitializer)
addCode("return %T(%N, configObject)", pluginType, descriptorProperty)
addCode("return %T(%N, configObject)", pluginSpec.typeName, descriptorProperty)
} else {
addCode("return %T(%N)", pluginType, descriptorProperty)
addCode("return %T(%N)", pluginSpec.typeName, descriptorProperty)
}

returns(pluginType)
returns(pluginSpec.typeName)
}.build()

// Create the factory class.
val className = "${pluginClass.simpleName.asString()}Factory"
val className = "${pluginSpec.descriptor.className}Factory"
val classSpec = TypeSpec.classBuilder(className)
.addSuperinterface(pluginFactoryType)
.addSuperinterface(pluginSpec.factory.typeName)
.addProperty(descriptorProperty)
.addFunction(createFunction)
.build()

// Write the factory class to a file.
FileSpec.builder(ClassName(pluginClass.packageName.asString(), className))
FileSpec.builder(ClassName(pluginSpec.packageName, "${pluginSpec.descriptor.className}Factory"))
.addType(classSpec)
.build()
.writeTo(codeGenerator, aggregating = true, originatingKSFiles = listOfNotNull(pluginClass.containingFile))
.writeTo(codeGenerator, aggregating = true, originatingKSFiles = listOfNotNull(pluginSpec.containingFile))

return classSpec
}
Expand Down Expand Up @@ -161,191 +137,63 @@ class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) {
/**
* Generate the code block to initialize the [PluginDescriptor] for the plugin.
*/
private fun getDescriptorInitializer(
ortPlugin: OrtPlugin,
pluginClass: KSClassDeclaration,
pluginOptions: List<PluginOption>
) = CodeBlock.builder().apply {
add(
"""
PluginDescriptor(
name = %S,
className = %S,
description = %S,
options = listOf(
""".trimIndent(),
ortPlugin.name,
pluginClass.simpleName.asString(),
ortPlugin.description
)

pluginOptions.forEach {
private fun getDescriptorInitializer(descriptor: PluginDescriptor) =
CodeBlock.builder().apply {
add(
"""
| %T(
| name = %S,
| description = %S,
| type = %T.%L,
| defaultValue = %S,
| isRequired = %L
| ),
|
""".trimMargin(),
PluginOption::class,
it.name,
it.description,
PluginOptionType::class,
it.type.name,
it.defaultValue,
it.isRequired
PluginDescriptor(
name = %S,
className = %S,
description = %S,
options = listOf(
""".trimIndent(),
descriptor.name,
descriptor.className,
descriptor.description
)
}

add(
"""
descriptor.options.forEach {
add(
"""
| %T(
| name = %S,
| description = %S,
| type = %T.%L,
| defaultValue = %S,
| isRequired = %L
| ),
|
""".trimMargin(),
PluginOption::class,
it.name,
it.description,
PluginOptionType::class,
it.type.name,
it.defaultValue,
it.isRequired
)
)
""".trimIndent()
)
}.build()

/**
* Get the constructor of the plugin class that has a [PluginDescriptor] and a config argument. Throw an
* [IllegalArgumentException] if more than one or no such constructor exists.
*/
private fun getPluginConstructor(pluginClass: KSClassDeclaration): KSFunctionDeclaration {
// TODO: Consider adding an @OrtPluginConstructor annotation to mark the constructor to use. This could be
// useful if a plugin needs multiple constructors for different purposes like testing.
val constructors = pluginClass.getConstructors().filterTo(mutableListOf()) {
if (it.parameters.size < 1 || it.parameters.size > 2) {
return@filterTo false
}

val firstArgumentIsDescriptor = it.parameters[0].name?.asString() == "descriptor" &&
it.parameters[0].type.resolve().declaration.qualifiedName?.asString() ==
"org.ossreviewtoolkit.plugins.api.PluginDescriptor"

val optionalSecondArgumentIsCalledConfig =
it.parameters.size == 1 || it.parameters[1].name?.asString() == "config"

firstArgumentIsDescriptor && optionalSecondArgumentIsCalledConfig
}

require(constructors.size == 1) {
"Plugin class $pluginClass must have exactly one constructor with a PluginDescriptor and a config " +
"argument."
}

return constructors.first()
}

/**
* Get the plugin options from the config class by mapping its properties to [PluginOption] instances.
*/
@OptIn(KspExperimental::class)
private fun KSClassDeclaration.getPluginOptions(): List<PluginOption> {
require(Modifier.DATA in modifiers) {
"Config class $this must be a data class."
}

require(getConstructors().toList().size == 1) {
"Config class $this must have exactly one constructor."
}

val constructor = getConstructors().single()

return constructor.parameters.map { param ->
val paramType = param.type.resolve()
val paramTypeString = getQualifiedNameWithTypeArguments(paramType)
val paramName = param.name?.asString()

requireNotNull(paramName) {
"Config class constructor parameter has no name."
}

require(param.isVal) {
"Config class constructor parameter $paramName must be a val."
}

require(!param.hasDefault) {
"Config class constructor parameter $paramName must not have a default value. Default values must be " +
"set via the @OrtPluginOption annotation."
}

val prop = getAllProperties().find { it.simpleName.asString() == paramName }

requireNotNull(prop) {
"Config class must have a property with the name $paramName."
}

val annotations = prop.getAnnotationsByType(OrtPluginOption::class).toList()

require(annotations.size <= 1) {
"Config class constructor parameter $paramName must have at most one @OrtPluginOption annotation."
}

val annotation = annotations.firstOrNull()

val type = when (paramTypeString) {
"kotlin.Boolean" -> PluginOptionType.BOOLEAN
"kotlin.Int" -> PluginOptionType.INTEGER
"kotlin.Long" -> PluginOptionType.LONG
"org.ossreviewtoolkit.plugins.api.Secret" -> PluginOptionType.SECRET
"kotlin.String" -> PluginOptionType.STRING
"kotlin.collections.List<kotlin.String>" -> PluginOptionType.STRING_LIST

else -> throw IllegalArgumentException(
"Config class constructor parameter ${param.name?.asString()} has unsupported type " +
"$paramTypeString."
add(
"""
)
)
}

val defaultValue = annotation?.defaultValue

PluginOption(
name = param.name?.asString().orEmpty(),
description = prop.docString?.trim().orEmpty(),
type = type,
defaultValue = defaultValue,
isRequired = !paramType.isMarkedNullable && defaultValue == null
""".trimIndent()
)
}
}

/**
* Get the qualified name of a [type] with its type arguments, for example,
* `kotlin.collections.List<kotlin.String>`.
*/
private fun getQualifiedNameWithTypeArguments(type: KSType): String =
buildString {
append(type.declaration.qualifiedName?.asString())
if (type.arguments.isNotEmpty()) {
append("<")
append(
type.arguments.joinToString(", ") { argument ->
argument.type?.resolve()?.let { getQualifiedNameWithTypeArguments(it) } ?: "Unknown"
}
)
append(">")
}
}
}.build()

/**
* Generate a service loader file for the plugin factory.
* Generate a service loader file for the [generatedFactory].
*/
private fun generateServiceLoaderFile(
pluginClass: KSClassDeclaration,
pluginFactoryClass: KSClassDeclaration,
generatedFactory: TypeSpec
) {
private fun generateServiceLoaderFile(pluginSpec: PluginSpec, generatedFactory: TypeSpec) {
codeGenerator.createNewFileByPath(
dependencies = Dependencies(aggregating = false, *listOfNotNull(pluginClass.containingFile).toTypedArray()),
path = "META-INF/services/${pluginFactoryClass.qualifiedName?.asString()}",
dependencies = Dependencies(aggregating = true, *listOfNotNull(pluginSpec.containingFile).toTypedArray()),
path = "META-INF/services/${pluginSpec.factory.qualifiedName}",
extensionName = ""
).use { output ->
output.writer().use { writer ->
writer.write("${pluginClass.packageName.asString()}.${generatedFactory.name}\n")
writer.write("${pluginSpec.packageName}.${generatedFactory.name}\n")
}
}
}
Expand Down
21 changes: 6 additions & 15 deletions plugins/api/src/main/kotlin/PluginProcessor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ import com.google.devtools.ksp.getAllSuperTypes
import com.google.devtools.ksp.getAnnotationsByType
import com.google.devtools.ksp.getClassDeclarationByName
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration

class PluginProcessor(private val codeGenerator: CodeGenerator, private val logger: KSPLogger) : SymbolProcessor {
class PluginProcessor(codeGenerator: CodeGenerator) : SymbolProcessor {
/**
* True, if the processor has been invoked in a previous run.
*/
private var invoked = false

private val specFactory = PluginSpecFactory()
private val factoryGenerator = PluginFactoryGenerator(codeGenerator)

/**
* Process all classes annotated with [OrtPlugin] to generate plugin factories for them.
*/
Expand All @@ -62,7 +64,8 @@ class PluginProcessor(private val codeGenerator: CodeGenerator, private val logg
val pluginParentClass = getPluginParentClass(pluginFactoryClass)
checkExtendsPluginClass(pluginClass, pluginParentClass)

createPluginFactory(pluginAnnotation, pluginClass, pluginFactoryClass)
val pluginSpec = specFactory.create(pluginAnnotation, pluginClass, pluginFactoryClass)
factoryGenerator.generate(pluginSpec)
}

invoked = true
Expand Down Expand Up @@ -129,16 +132,4 @@ class PluginProcessor(private val codeGenerator: CodeGenerator, private val logg
"Plugin class $pluginClass does not extend the required super type $pluginBaseClass."
}
}

/**
* Create the plugin factory for the given [ortPlugin].
*/
private fun createPluginFactory(
ortPlugin: OrtPlugin,
pluginClass: KSClassDeclaration,
pluginFactoryClass: KSClassDeclaration
) {
val generator = PluginFactoryGenerator(codeGenerator)
generator.generate(ortPlugin, pluginClass, pluginFactoryClass)
}
}
Loading

0 comments on commit 4dd5a49

Please sign in to comment.