From c6930c441b948d046b431282ac2eac60fada9783 Mon Sep 17 00:00:00 2001 From: Michael Rittmeister Date: Wed, 13 Sep 2023 20:24:15 +0200 Subject: [PATCH] Expand ksp-processor - Add @OtherIfDefault - Add Either --- .idea/compiler.xml | 2 +- docs/topics/Annotation-Argument-Processor.md | 63 ++++++++++++++++--- docs/topics/about.topic | 2 - .../commonMain/kotlin/InlineConstructor.kt | 4 +- ksp/annotations/api/annotations.api | 4 ++ ksp/annotations/build.gradle.kts | 16 +++++ .../commonMain/kotlin/ProcessorAnnotation.kt | 30 ++++++++- .../generator/DataClassRepresentation.kt | 13 +++- .../main/kotlin/generator/FactoryFunction.kt | 41 +++++++++++- .../src/main/kotlin/generator/Generator.kt | 52 ++++++++++++++- ksp/src/main/kotlin/TypeResolvers.kt | 11 +++- 11 files changed, 216 insertions(+), 22 deletions(-) diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b72a5da..d91c92a 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -6,4 +6,4 @@ - + \ No newline at end of file diff --git a/docs/topics/Annotation-Argument-Processor.md b/docs/topics/Annotation-Argument-Processor.md index 3bed099..fc03160 100644 --- a/docs/topics/Annotation-Argument-Processor.md +++ b/docs/topics/Annotation-Argument-Processor.md @@ -1,6 +1,6 @@ # Annotation Argument Processor -Even though the [Annotation Argument API](Annotation-Argument-Processor.md) provides an easier to use API than the +Even though the [Annotation Argument API](Annotation-Argument-Processor.md) provides an easier to use API than the default KSP API, it can still require some boiler-plate to setup. For this reason an annotation processor is provided to generate that boiler plate for you @@ -51,12 +51,12 @@ Please note that the actual class returned is a "data class representation" of t ```kotlin public data class InlineConstructor private constructor( - public val forClass: KSType, // KClass turns into KSType - public val functionName: String, - public val nameMapping: List, // Array turns into List - public val ignoreBuilders: List, // Array turns into list - public val useQualifiedName: Boolean, - public val nameProperty: String?, // This becomes nullable, because it is annotated with @NullIfDefault + public val forClass: KSType, // KClass turns into KSType + public val functionName: String, + public val nameMapping: List, // Array turns into List + public val ignoreBuilders: List, // Array turns into list + public val useQualifiedName: Boolean, + public val nameProperty: String?, // This becomes nullable, because it is annotated with @NullIfDefault ) { public data class NameMapping private constructor( public val originalName: String, @@ -65,7 +65,52 @@ public data class InlineConstructor private constructor( } ``` -> You can find the full code [here](https://github.com/kordlib/codegen-kt/blob/main/ksp-annotations/src/commonMain/kotlin/InlineConstructor.kt) +> You can find the full +> code [here](https://github.com/kordlib/codegen-kt/blob/main/ksp-annotations/src/commonMain/kotlin/InlineConstructor.kt) -Please read the documentation of the `ProcessorAnnotation` annotation [here](https://codegen.kord.dev/api/ksp/dev.kord.codegen.ksp.annotations/-processor-annotation/index.html) +Please read the documentation of the `ProcessorAnnotation` +annotation [here](https://codegen.kord.dev/api/ksp/dev.kord.codegen.ksp.annotations/-processor-annotation/index.html) + +### Default values + +The processor also allows you to use some other default values than in you annotation, for example `null` or default to +another propery + +```kotlin +const val DEFAULT_VALUE = "DEFAULT!" + +annotation class TestAnnotation( + @NullIfDefault + val nullable: String = DEFAULT_VALUE // will be represented as "null" if not explicitly defined + // Will be the same as "nullable" if default + // Please note that since "nullable" is annotated with @NullIfDefault other will be nullable too + @OtherIfDefault("nullable") +val other: String = DEFAULT_VALUE +) +``` + +### Validation + +You are also able to validate that certain values are different, let's say we want either an int or a string value, +but not both or none + +```kotlin +const val NO_INT = -1 +const val NO_STRING = "" + +@Eihter(["intValue", "stringValue"], exclusive = true) +annotation class ExpressionValue( + @NullIfDefault + val intValue: Int = NO_INT, + @NullIfDefault + val stringValue: Int = NO_STRING, +) +``` + +This will throw an `IllegalStateException` in the [factory functions](#use-the-generated-code) when +none or both values are present + +> Note that all properties used in an `@Either` annotation must be annotated with `@NullIfDefault` +> +{style="warning"} diff --git a/docs/topics/about.topic b/docs/topics/about.topic index a5d45ca..050ca6e 100644 --- a/docs/topics/about.topic +++ b/docs/topics/about.topic @@ -26,6 +26,4 @@ - - diff --git a/kotlinpoet/annotations/src/commonMain/kotlin/InlineConstructor.kt b/kotlinpoet/annotations/src/commonMain/kotlin/InlineConstructor.kt index 4d17f7f..e1cabc2 100644 --- a/kotlinpoet/annotations/src/commonMain/kotlin/InlineConstructor.kt +++ b/kotlinpoet/annotations/src/commonMain/kotlin/InlineConstructor.kt @@ -49,7 +49,9 @@ public annotation class InlineConstructor( val ignoreBuilders: Array = [], val useQualifiedName: Boolean = false, @NullIfDefault - val nameProperty: String = NO_DELEGATION + val nameProperty: String = NO_DELEGATION, + @NullIfDefault + val nameProperty2: String = NO_DELEGATION ) { /** diff --git a/ksp/annotations/api/annotations.api b/ksp/annotations/api/annotations.api index df5ddee..17889ca 100644 --- a/ksp/annotations/api/annotations.api +++ b/ksp/annotations/api/annotations.api @@ -1,6 +1,10 @@ public abstract interface annotation class dev/kord/codegen/ksp/annotations/NullIfDefault : java/lang/annotation/Annotation { } +public abstract interface annotation class dev/kord/codegen/ksp/annotations/OtherIfDefault : java/lang/annotation/Annotation { + public abstract fun name ()Ljava/lang/String; +} + public abstract interface annotation class dev/kord/codegen/ksp/annotations/ProcessorAnnotation : java/lang/annotation/Annotation { public abstract fun packageName ()Ljava/lang/String; } diff --git a/ksp/annotations/build.gradle.kts b/ksp/annotations/build.gradle.kts index 127dce1..380585f 100644 --- a/ksp/annotations/build.gradle.kts +++ b/ksp/annotations/build.gradle.kts @@ -1,6 +1,7 @@ plugins { org.jetbrains.kotlin.multiplatform `kord-publishing` + com.google.devtools.ksp } base { @@ -26,4 +27,19 @@ kotlin { // tvos() // macosX64() // macosArm64() + + sourceSets { + named("jvmMain") { + kotlin.srcDirs("build/generated/ksp/jvm/jvmMain/kotlin") + dependencies { + compileOnly(libs.ksp.api) + compileOnly(libs.codegen.ksp) + } + } + } +} + +dependencies { + "kspJvm"(libs.codegen.ksp.processor) + } diff --git a/ksp/annotations/src/commonMain/kotlin/ProcessorAnnotation.kt b/ksp/annotations/src/commonMain/kotlin/ProcessorAnnotation.kt index 47c603c..c33838c 100644 --- a/ksp/annotations/src/commonMain/kotlin/ProcessorAnnotation.kt +++ b/ksp/annotations/src/commonMain/kotlin/ProcessorAnnotation.kt @@ -1,5 +1,7 @@ package dev.kord.codegen.ksp.annotations +private const val packageName = "dev.kord.codegen.ksp.processor" + /** * This annotation instructs the processor to generate a data class wrapper around the annotation with * factory functions to instantiate it from a `KSAnnotation`. @@ -32,14 +34,38 @@ package dev.kord.codegen.ksp.annotations @MustBeDocumented @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.ANNOTATION_CLASS) -@ProcessorAnnotation(packageName = "dev.kord.codegen.ksp.processor") +@ProcessorAnnotation(packageName) public annotation class ProcessorAnnotation( val packageName: String ) /** - * Instructs the processor to use null, if [AnnotationArguments.isDefault] returns `true` for this property. + * Instructs the processor to use null, if `AnnotationArguments.isDefault` returns `true` for this property. */ +@MustBeDocumented @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.FIELD) public annotation class NullIfDefault + +/** + * Instructs the processor to use value of [name], if `AnnotationArguments.isDefault` returns `true` for this property. + * + * @property name the name of the property to use as a default + */ +@MustBeDocumented +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.FIELD) +@ProcessorAnnotation(packageName) +public annotation class OtherIfDefault(val name: String) + +/** + * Instructs the processor to validate that either of [names] are present. + * + * @property exclusive whether there should be only once exclusive value + */ +@MustBeDocumented +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.ANNOTATION_CLASS) +@Repeatable +@ProcessorAnnotation(packageName) +public annotation class Either(val names: Array, val exclusive: Boolean = false) diff --git a/ksp/processor/src/main/kotlin/generator/DataClassRepresentation.kt b/ksp/processor/src/main/kotlin/generator/DataClassRepresentation.kt index 9e345a9..ba5bd1a 100644 --- a/ksp/processor/src/main/kotlin/generator/DataClassRepresentation.kt +++ b/ksp/processor/src/main/kotlin/generator/DataClassRepresentation.kt @@ -1,6 +1,8 @@ package dev.kord.codegen.ksp.processor.generator +import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.isAnnotationPresent import com.google.devtools.ksp.symbol.* import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy @@ -8,8 +10,10 @@ import com.squareup.kotlinpoet.ksp.addOriginatingKSFile import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.toTypeName import dev.kord.codegen.kotlinpoet.* -import dev.kord.codegen.ksp.processor.NULL_IF_DEFAULT +import dev.kord.codegen.ksp.annotations.NullIfDefault +import dev.kord.codegen.ksp.annotations.OtherIfDefault import dev.kord.codegen.ksp.processor.PROCESSOR_ANNOTATION +import dev.kord.codegen.ksp.processor.getOtherIfDefault fun KSType.isMappedAnnotation(rootType: KSClassDeclaration): Boolean { val declaration = declaration @@ -47,11 +51,16 @@ private fun KSTypeReference.dataClassType(rootType: KSClassDeclaration): TypeNam } } +@OptIn(KspExperimental::class) fun KSPropertyDeclaration.dataClassType(rootType: KSClassDeclaration): TypeName { val notNullType = type.dataClassType(rootType) - return if (annotations.any { it.annotationType.resolve().declaration.qualifiedName!!.asString() == NULL_IF_DEFAULT }) { + return if (isAnnotationPresent(NullIfDefault::class)) { notNullType.copy(nullable = true) + } else if (isAnnotationPresent(OtherIfDefault::class)) { + val annotation = getOtherIfDefault() + val otherField = rootType.getDeclaredProperties().first { it.simpleName.asString() == annotation.name } + otherField.dataClassType(rootType) } else { notNullType } diff --git a/ksp/processor/src/main/kotlin/generator/FactoryFunction.kt b/ksp/processor/src/main/kotlin/generator/FactoryFunction.kt index ea1fde5..f459849 100644 --- a/ksp/processor/src/main/kotlin/generator/FactoryFunction.kt +++ b/ksp/processor/src/main/kotlin/generator/FactoryFunction.kt @@ -1,18 +1,26 @@ package dev.kord.codegen.ksp.processor.generator +import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.isAnnotationPresent import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ksp.toClassName +import dev.kord.codegen.kotlinpoet.addCode import dev.kord.codegen.kotlinpoet.addFunction import dev.kord.codegen.kotlinpoet.addParameter import dev.kord.codegen.kotlinpoet.withControlFlow +import dev.kord.codegen.ksp.annotations.Either +import dev.kord.codegen.ksp.annotations.NullIfDefault +import dev.kord.codegen.ksp.annotations.OtherIfDefault import dev.kord.codegen.ksp.processor.ARGUMENTS import dev.kord.codegen.ksp.processor.ARGUMENTS_NOT_NULL -import dev.kord.codegen.ksp.processor.NULL_IF_DEFAULT +import dev.kord.codegen.ksp.processor.getEithers +import dev.kord.codegen.ksp.processor.getOtherIfDefault context(TypeSpec.Builder) +@OptIn(KspExperimental::class) fun KSClassDeclaration.factoryFunction(packageName: String) { addFunction(simpleName.asString()) { val type = ClassName(packageName, toClassName().simpleNames) @@ -41,10 +49,13 @@ fun KSClassDeclaration.factoryFunction(packageName: String) { add(".let(%1L.Companion::%1L)", argumentType.declaration.simpleName.asString()) } - if (it.annotations.any { it.annotationType.resolve().declaration.qualifiedName!!.asString() == NULL_IF_DEFAULT }) { + if (it.isAnnotationPresent(NullIfDefault::class) || it.isAnnotationPresent(OtherIfDefault::class)) { withControlFlow(".takeIf") { add("!arguments.isDefault(%T::%L)", toClassName(), it.simpleName.asString()) - add("\n") + } + + if (it.isAnnotationPresent(OtherIfDefault::class)) { + add("?:·%L", it.getOtherIfDefault().name) } } add("\n") @@ -52,6 +63,30 @@ fun KSClassDeclaration.factoryFunction(packageName: String) { addCode(property) CodeBlock.of("%L", it.simpleName.asString()) }.joinToString(", ") + + if (isAnnotationPresent(Either::class)) { + getEithers().forEach { (names, exclusive) -> + addCode { + val nameList = names.map { CodeBlock.of("%N", it) }.joinToCode(", ") + val condition = CodeBlock.of("it != null") + val check = buildCodeBlock { + add("listOf(%L)", nameList) + if (exclusive) { + add(".singleOrNull { %L }", condition) + add("·==·null") + } else { + add(".none { %L }", condition) + } + } + withControlFlow("if (%L)", check) { + add("error(%S)", "Either of these values must be present: $names") + add("\n") + } + add("\n") + } + } + } + addCode("return·%T(%L)", type, valueArguments) } } diff --git a/ksp/processor/src/main/kotlin/generator/Generator.kt b/ksp/processor/src/main/kotlin/generator/Generator.kt index 58c65c2..c42ec3e 100644 --- a/ksp/processor/src/main/kotlin/generator/Generator.kt +++ b/ksp/processor/src/main/kotlin/generator/Generator.kt @@ -1,13 +1,21 @@ package dev.kord.codegen.ksp.processor.generator +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.isAnnotationPresent import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.DelicateKotlinPoetApi import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import dev.kord.codegen.kotlinpoet.FileSpec +import dev.kord.codegen.ksp.annotations.NullIfDefault +import dev.kord.codegen.ksp.annotations.OtherIfDefault +import dev.kord.codegen.ksp.processor.getEithers +import dev.kord.codegen.ksp.processor.getOtherIfDefault import dev.kord.codegen.ksp.processor.getProcessorAnnotation data class ProcessingContext( @@ -17,8 +25,50 @@ data class ProcessingContext( val packageName: String ) -@OptIn(DelicateKotlinPoetApi::class) +private data class Property( + val index: Int, + val declaration: KSPropertyDeclaration +) : KSPropertyDeclaration by declaration + +@OptIn(DelicateKotlinPoetApi::class, KspExperimental::class) fun SymbolProcessorEnvironment.processAnnotation(declaration: KSClassDeclaration) { + val propertyTypes = declaration + .getDeclaredProperties() + .mapIndexed { index, it -> it.simpleName.asString() to Property(index, it) } + .toMap() + declaration.getEithers().forEach { + it.names.forEach { name -> + val property = propertyTypes[name] ?: run { + logger.error("Either type does not exist", declaration) + return + } + + if (!property.isAnnotationPresent(NullIfDefault::class)) { + logger.error("Either can only be used with @NullIfDefault values", declaration) + return + } + } + } + declaration.getDeclaredProperties().forEachIndexed { index, it -> + if (it.isAnnotationPresent(OtherIfDefault::class)) { + if (it.isAnnotationPresent(NullIfDefault::class)) { + logger.error("Usage of both @NullIfDefault and @OtherIfDefault is not allowed", it) + } else { + val name = it.getOtherIfDefault().name + val property = propertyTypes[name] ?: return run { + logger.error("Specified property name does not exist: $name", it) + } + if (!it.type.resolve().isAssignableFrom(property.type.resolve())) { + logger.error("Specified property has different type! Expected ${it.type} found $property", it) + return + } + if (index < property.index) { + logger.error("Specified property comes after current property", it) + return + } + } + } + } val packageName = declaration.getProcessorAnnotation().packageName val fileSpec = FileSpec(packageName, declaration.simpleName.asString()) { addKotlinDefaultImports(includeJs = false) diff --git a/ksp/src/main/kotlin/TypeResolvers.kt b/ksp/src/main/kotlin/TypeResolvers.kt index 27beae4..47c9ebe 100644 --- a/ksp/src/main/kotlin/TypeResolvers.kt +++ b/ksp/src/main/kotlin/TypeResolvers.kt @@ -1,7 +1,9 @@ package dev.kord.codegen.ksp +import com.google.devtools.ksp.KspExperimental import com.google.devtools.ksp.findActualType import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.isAnnotationPresent import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.* @@ -51,7 +53,8 @@ public inline fun KSTypeReference.isOfType(): Boolean = isOfType(T:: /** * Checks whether an [KSAnnotation] is of type by it's [qualifiedName]. */ -public fun KSAnnotation.isOfType(qualifiedName: String): Boolean = annotationType.isOfType(qualifiedName, canBeTypeAlias = false) +public fun KSAnnotation.isOfType(qualifiedName: String): Boolean = + annotationType.isOfType(qualifiedName, canBeTypeAlias = false) /** * Checks whether an [KSTypeReference] is of type by it's [qualifiedName]. @@ -74,6 +77,12 @@ public fun KSTypeReference.isOfType(qualifiedName: String, canBeTypeAlias: Boole return actualDeclaration.qualifiedName?.asString() == qualifiedName } +/** + * Checks whether [A] is present on [KSAnnotated]. + */ +@KspExperimental +public inline fun KSAnnotated.isAnnotationPresent(): Boolean = isAnnotationPresent(A::class) + /** * Fast check whether a [KSTypeReference] definitively does not match, this does not mean that it does it returning true */