diff --git a/.editorconfig b/.editorconfig index 494a4e2..3d70a00 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1,4 @@ [*.{kt,kts}] -disabled_rules=no-wildcard-imports \ No newline at end of file +ktlint_code_style = ktlint_official +ktlint_disabled_rules = no-wildcard-imports +ij_kotlin_allow_trailing_comma_on_call_site = true diff --git a/build.gradle.kts b/build.gradle.kts index 37c159b..f86cf33 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,8 +60,9 @@ tasks { val publish by creating { group = "publishing" - if (!isSnapshot) + if (!isSnapshot) { finalizedBy("closeAndReleaseSonatypeStagingRepository", ":docs:orchidDeploy", ":gradle-plugin:publishPlugins") + } } val version by creating { @@ -176,6 +177,7 @@ subprojects { val configure: KotlinCompile.() -> Unit = { kotlinOptions { freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" + freeCompilerArgs += "-Xcontext-receivers" } } diff --git a/docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksTheme.kt b/docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksTheme.kt index 107adf1..09a1a20 100644 --- a/docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksTheme.kt +++ b/docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksTheme.kt @@ -35,6 +35,6 @@ class HooksTheme @Inject constructor(context: OrchidContext) : Theme(context, "H listOfNotNull(super.getResourceSource(), delegateTheme.resourceSource), emptyList(), priority, - ThemeResourceSource + ThemeResourceSource, ) } diff --git a/example-library/src/test/kotlin/CarHooksTest.kt b/example-library/src/test/kotlin/CarHooksTest.kt index 92882a0..cf81a25 100644 --- a/example-library/src/test/kotlin/CarHooksTest.kt +++ b/example-library/src/test/kotlin/CarHooksTest.kt @@ -1,7 +1,7 @@ package com.intuit.hooks.example.library import com.intuit.hooks.example.library.car.Car -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test internal class CarHooksTest { diff --git a/example-library/src/test/kotlin/GenericHookTests.kt b/example-library/src/test/kotlin/GenericHookTests.kt index 3389518..a66d848 100644 --- a/example-library/src/test/kotlin/GenericHookTests.kt +++ b/example-library/src/test/kotlin/GenericHookTests.kt @@ -1,6 +1,7 @@ package com.intuit.hooks.example.library -import com.intuit.hooks.BailResult.* +import com.intuit.hooks.BailResult.Bail +import com.intuit.hooks.BailResult.Continue import com.intuit.hooks.HookContext import com.intuit.hooks.LoopResult import com.intuit.hooks.example.library.generic.GenericHooksImpl @@ -140,6 +141,7 @@ class GenericHookTests { val result = h.call("Kian") Assertions.assertEquals("bail now", result) } + @Test fun `async series loop`() = runBlocking { var incrementedA = 0 diff --git a/gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradlePlugin.kt b/gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradlePlugin.kt index a70420e..1b2601f 100644 --- a/gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradlePlugin.kt +++ b/gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradlePlugin.kt @@ -21,17 +21,18 @@ public class HooksGradlePlugin : Plugin { private fun Project.addDependency(configuration: String, dependencyNotation: String) = configurations .getByName(configuration).dependencies.add( - dependencies.create(dependencyNotation) + dependencies.create(dependencyNotation), ) override fun apply(project: Project): Unit = with(project) { extensions.create( "hooks", - HooksGradleExtension::class.java + HooksGradleExtension::class.java, ) - if (!pluginManager.hasPlugin("com.google.devtools.ksp")) + if (!pluginManager.hasPlugin("com.google.devtools.ksp")) { pluginManager.apply("com.google.devtools.ksp") + } addDependency("api", "com.intuit.hooks:hooks:$hooksVersion") addDependency("ksp", "com.intuit.hooks:processor:$hooksVersion") diff --git a/gradle-plugin/src/test/kotlin/HooksGradlePluginTest.kt b/gradle-plugin/src/test/kotlin/HooksGradlePluginTest.kt index ee46eaa..344c9bb 100644 --- a/gradle-plugin/src/test/kotlin/HooksGradlePluginTest.kt +++ b/gradle-plugin/src/test/kotlin/HooksGradlePluginTest.kt @@ -33,7 +33,7 @@ class HooksGradlePluginTest { kotlin("jvm") id("com.intuit.hooks") } - """ + """, ) } @@ -41,7 +41,7 @@ class HooksGradlePluginTest { buildFile.appendKotlin( """ hooks {} - """ + """, ) assertDoesNotThrow { @@ -66,7 +66,7 @@ class HooksGradlePluginTest { @Sync<(String) -> Unit> abstract val testSyncHook: Hook } - """ + """, ) val runner = GradleRunner.create() diff --git a/hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesBailHook.kt b/hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesBailHook.kt index 658a915..845d1f1 100644 --- a/hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesBailHook.kt +++ b/hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesBailHook.kt @@ -7,6 +7,7 @@ public abstract class AsyncSeriesBailHook>, R> : Asyn taps.forEach { tapInfo -> when (val result = invokeWithContext(tapInfo.f, context)) { is BailResult.Bail -> return@call result.value + is BailResult.Continue<*> -> Unit } } diff --git a/hooks/src/main/kotlin/com/intuit/hooks/BaseHook.kt b/hooks/src/main/kotlin/com/intuit/hooks/BaseHook.kt index d858a2f..963d5f3 100644 --- a/hooks/src/main/kotlin/com/intuit/hooks/BaseHook.kt +++ b/hooks/src/main/kotlin/com/intuit/hooks/BaseHook.kt @@ -37,7 +37,7 @@ public data class TapInfo> internal constructor( public val name: String, public val id: String, public val type: String, - public val f: FWithContext, + public val f: FWithContext // val stage: Int, // todo: maybe this should be forEachIndexed? // before?: string | Array // todo: do we even really need this? ) diff --git a/hooks/src/main/kotlin/com/intuit/hooks/SyncBailHook.kt b/hooks/src/main/kotlin/com/intuit/hooks/SyncBailHook.kt index a8921d1..b029e97 100644 --- a/hooks/src/main/kotlin/com/intuit/hooks/SyncBailHook.kt +++ b/hooks/src/main/kotlin/com/intuit/hooks/SyncBailHook.kt @@ -13,6 +13,7 @@ public abstract class SyncBailHook>, R> : SyncBaseHoo taps.forEach { tapInfo -> when (val result = invokeWithContext(tapInfo.f, context)) { is BailResult.Bail -> return@call result.value + is BailResult.Continue<*> -> Unit } } diff --git a/hooks/src/main/kotlin/com/intuit/hooks/dsl/Hooks.kt b/hooks/src/main/kotlin/com/intuit/hooks/dsl/Hooks.kt index 9981c92..65e7b4d 100644 --- a/hooks/src/main/kotlin/com/intuit/hooks/dsl/Hooks.kt +++ b/hooks/src/main/kotlin/com/intuit/hooks/dsl/Hooks.kt @@ -60,7 +60,8 @@ public abstract class Hooks { ReplaceWith("@Hooks.AsyncParallelBail"), DeprecationLevel.ERROR, ) - @ExperimentalCoroutinesApi protected fun >> asyncParallelBailHook(): AsyncParallelBailHook<*, *> = stub() + @ExperimentalCoroutinesApi + protected fun >> asyncParallelBailHook(): AsyncParallelBailHook<*, *> = stub() protected annotation class AsyncSeries> diff --git a/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesLoopHookTests.kt b/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesLoopHookTests.kt index 3c91114..9241b0f 100644 --- a/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesLoopHookTests.kt +++ b/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesLoopHookTests.kt @@ -1,6 +1,8 @@ package com.intuit.hooks -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions @@ -10,7 +12,7 @@ class AsyncSeriesLoopHookTests { class LoopHook1 : AsyncSeriesLoopHook LoopResult, suspend (HookContext, T1) -> Unit>() { suspend fun call(p1: T1) = super.call( invokeTap = { f, context -> f(context, p1) }, - invokeInterceptor = { f, context -> f(context, p1) } + invokeInterceptor = { f, context -> f(context, p1) }, ) } diff --git a/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHookTests.kt b/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHookTests.kt index 129ce6d..daa5cc0 100644 --- a/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHookTests.kt +++ b/hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHookTests.kt @@ -10,7 +10,7 @@ class AsyncSeriesWaterfallHookTests { suspend fun call(p1: R) = super.call( p1, invokeTap = { f, r, context -> f(context, r) }, - invokeInterceptor = { f, context -> f(context, p1) } + invokeInterceptor = { f, context -> f(context, p1) }, ) } diff --git a/hooks/src/test/kotlin/com/intuit/hooks/SyncLoopHookTests.kt b/hooks/src/test/kotlin/com/intuit/hooks/SyncLoopHookTests.kt index a3c1a02..e6984df 100644 --- a/hooks/src/test/kotlin/com/intuit/hooks/SyncLoopHookTests.kt +++ b/hooks/src/test/kotlin/com/intuit/hooks/SyncLoopHookTests.kt @@ -13,7 +13,7 @@ class SyncLoopHookTests { class LoopHook1 : SyncLoopHook<(HookContext, T1) -> LoopResult, (HookContext, T1) -> Unit>() { fun call(p1: T1) = super.call( invokeTap = { f, context -> f(context, p1) }, - invokeInterceptor = { f, context -> f(context, p1) } + invokeInterceptor = { f, context -> f(context, p1) }, ) } diff --git a/hooks/src/test/kotlin/com/intuit/hooks/SyncWaterfallHookTests.kt b/hooks/src/test/kotlin/com/intuit/hooks/SyncWaterfallHookTests.kt index c79ca61..119e957 100644 --- a/hooks/src/test/kotlin/com/intuit/hooks/SyncWaterfallHookTests.kt +++ b/hooks/src/test/kotlin/com/intuit/hooks/SyncWaterfallHookTests.kt @@ -8,7 +8,7 @@ class SyncWaterfallHookTests { fun call(p1: T1) = super.call( p1, invokeTap = { f, acc, context -> f(context, acc) }, - invokeInterceptor = { f, context -> f(context, p1) } + invokeInterceptor = { f, context -> f(context, p1) }, ) } @@ -16,7 +16,7 @@ class SyncWaterfallHookTests { fun call(p1: T1, p2: T2) = super.call( p1, invokeTap = { f, acc, context -> f(context, acc, p2) }, - invokeInterceptor = { f, context -> f(context, p1, p2) } + invokeInterceptor = { f, context -> f(context, p1, p2) }, ) } diff --git a/maven-plugin/build.gradle.kts b/maven-plugin/build.gradle.kts index 8d9ad26..30bdda6 100644 --- a/maven-plugin/build.gradle.kts +++ b/maven-plugin/build.gradle.kts @@ -16,7 +16,7 @@ tasks { from( configurations.compileClasspath.get().filter { dependency -> dependency.absolutePath.contains("kotlin-maven-symbol-processing") - }.map(::zipTree) + }.map(::zipTree), ) { this.duplicatesStrategy = DuplicatesStrategy.EXCLUDE } diff --git a/processor/api/processor.api b/processor/api/processor.api index 74f2001..ffc7588 100644 --- a/processor/api/processor.api +++ b/processor/api/processor.api @@ -1,3 +1,14 @@ +public final class com/intuit/hooks/plugin/RaiseKt { + public static final fun accumulate (Larrow/core/raise/Raise;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;)V + public static final fun ensure (Larrow/core/raise/Raise;ZLkotlin/jvm/functions/Function0;)V + public static final fun mapOrAccumulate (Larrow/core/raise/Raise;Larrow/core/NonEmptyList;Lkotlin/jvm/functions/Function2;)Larrow/core/NonEmptyList; + public static final fun mapOrAccumulate (Larrow/core/raise/Raise;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;)Ljava/util/List; + public static final fun mapOrAccumulate (Larrow/core/raise/Raise;Lkotlin/sequences/Sequence;Lkotlin/jvm/functions/Function2;)Ljava/util/List; + public static final fun mapOrAccumulate-jkbboic (Larrow/core/raise/Raise;Ljava/util/Set;Lkotlin/jvm/functions/Function2;)Ljava/util/Set; + public static final fun raise (Larrow/core/raise/Raise;Ljava/lang/Object;)Ljava/lang/Void; + public static final fun raiseAll (Larrow/core/raise/Raise;Ljava/lang/Iterable;Lkotlin/jvm/functions/Function2;)Ljava/util/List; +} + public final class com/intuit/hooks/plugin/ksp/HooksProcessor : com/google/devtools/ksp/processing/SymbolProcessor { public fun (Lcom/google/devtools/ksp/processing/CodeGenerator;Lcom/google/devtools/ksp/processing/KSPLogger;)V public fun process (Lcom/google/devtools/ksp/processing/Resolver;)Ljava/util/List; diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/Raise.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/Raise.kt new file mode 100644 index 0000000..24e068a --- /dev/null +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/Raise.kt @@ -0,0 +1,83 @@ +@file:OptIn(ExperimentalTypeInference::class) + +package com.intuit.hooks.plugin + +import arrow.core.* +import arrow.core.raise.* +import kotlin.experimental.ExperimentalTypeInference + +// Collection of [Raise] helpers for accumulating errors from a single error context + +/** Helper for accumulating errors from single-error validators */ +@RaiseDSL +internal fun Raise>.ensure(@BuilderInference block: Raise.() -> A): A = + recover(block) { e: Error -> raise(e.nel()) } + +/** Helper for accumulating errors from single-error validators */ +@RaiseDSL +public inline fun Raise>.ensure(condition: Boolean, raise: () -> Error) { + recover({ ensure(condition, raise) }) { e: Error -> raise(e.nel()) } +} + +/** Raise a _logical failure_ of type [Error] in a multi-[Error] accumulator */ +@RaiseDSL +public inline fun Raise>.raise(r: Error): Nothing { + raise(r.nel()) +} + +@RaiseDSL +public inline fun Raise>.raiseAll( + iterable: Iterable, + @BuilderInference transform: Raise>.(A) -> Unit +): List = mapOrAccumulate(iterable) { arg -> + recover, Unit>({ transform(arg) }) { errors -> + this@raiseAll.raise(errors) + } +} + +/** Explicitly accumulate errors that may have been raised while processing each element */ +context(Raise>) +@RaiseDSL +public inline fun Iterable.accumulate( + @BuilderInference operation: Raise>.(A) -> Unit +) { + flatMap { + recover({ + operation(it); emptyList() + },) { it } + }.toNonEmptyListOrNull()?.let { raise(it) } +} + +/** [mapOrAccumulate] variant that accumulates errors from a validator that may raise multiple errors */ +context(Raise>) +@RaiseDSL +public inline fun Sequence.mapOrAccumulate( // TODO: Consider renaming + @BuilderInference operation: Raise>.(A) -> B +): List = toList().mapOrAccumulate(operation) + +/** [mapOrAccumulate] variant that accumulates errors from a validator that may raise multiple errors */ +context(Raise>) +@RaiseDSL +public inline fun Iterable.mapOrAccumulate( // TODO: Consider renaming + @BuilderInference operation: Raise>.(A) -> B +): List = recover({ + mapOrAccumulate(this@mapOrAccumulate) { operation(it) } +},) { errors -> raise(errors.flatMap { it }) } + +/** [mapOrAccumulate] variant that accumulates errors from a validator that may raise multiple errors */ +context(Raise>) +@RaiseDSL +public inline fun NonEmptyList.mapOrAccumulate( // TODO: Consider renaming + @BuilderInference operation: Raise>.(A) -> B +): NonEmptyList = recover({ + mapOrAccumulate(this@mapOrAccumulate) { operation(it) } +},) { errors -> raise(errors.flatMap { it }) } + +/** [mapOrAccumulate] variant that accumulates errors from a validator that may raise multiple errors */ +context(Raise>) +@RaiseDSL +public inline fun NonEmptySet.mapOrAccumulate( // TODO: Consider renaming + @BuilderInference operation: Raise>.(A) -> B +): NonEmptySet = recover({ + mapOrAccumulate(this@mapOrAccumulate) { operation(it) } +},) { errors -> raise(errors.flatMap { it }) } diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookInfo.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookInfo.kt index c8ba7de..3c80ed7 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookInfo.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookInfo.kt @@ -14,8 +14,9 @@ internal data class HooksContainer( val superclass get() = originalClassName.let { if (typeArguments.isNotEmpty()) { it.parameterizedBy(typeArguments) - } else + } else { it + } } } @@ -24,7 +25,7 @@ internal data class HookSignature( val isSuspend: Boolean, val returnType: TypeName, val returnTypeType: TypeName?, - val hookFunctionSignatureType: TypeName, + val hookFunctionSignatureType: TypeName ) { val nullableReturnTypeType: TypeName get() { requireNotNull(returnTypeType) @@ -36,7 +37,7 @@ internal data class HookSignature( internal class HookParameter( val name: String?, val type: TypeName, - val position: Int, + val position: Int ) { val withType get() = "$withoutType: $type" val withoutType get() = name ?: "p$position" diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookType.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookType.kt index aa28d30..78175af 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookType.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookType.kt @@ -7,7 +7,7 @@ internal sealed class HookProperty { object Waterfall : HookProperty() } -internal enum class HookType(vararg val properties: HookProperty) { +internal enum class HookType(val properties: Set) { SyncHook, SyncBailHook(HookProperty.Bail), SyncWaterfallHook(HookProperty.Waterfall), @@ -19,9 +19,13 @@ internal enum class HookType(vararg val properties: HookProperty) { AsyncSeriesWaterfallHook(HookProperty.Async, HookProperty.Waterfall), AsyncSeriesLoopHook(HookProperty.Async, HookProperty.Loop); + constructor(vararg properties: HookProperty) : this(properties.toSet()) + companion object { - val annotationDslMarkers = values().map { - it.name.dropLast(4) + val supportedHookTypes = values().map(HookType::name) + + val annotationDslMarkers = supportedHookTypes.map { + it.dropLast(4) } } } diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/Poet.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/Poet.kt index 0115eb1..359f910 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/Poet.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/Poet.kt @@ -44,8 +44,9 @@ internal fun HookInfo.generateClass(): TypeSpec { val callBuilder = FunSpec.builder("call") .addParameters(parameterSpecs) .apply { - if (this@generateClass.isAsync) + if (this@generateClass.isAsync) { addModifiers(KModifier.SUSPEND) + } } val (superclass, call) = when (hookType) { @@ -66,7 +67,7 @@ internal fun HookInfo.generateClass(): TypeSpec { .addCode( "return super.call(invokeTap = %L, invokeInterceptor = %L)", CodeBlock.of("{ f, context -> f(context, $paramsWithoutTypes) }"), - CodeBlock.of("{ f, context -> f(context, $paramsWithoutTypes) }") + CodeBlock.of("{ f, context -> f(context, $paramsWithoutTypes) }"), ) Pair(superclass, call) @@ -81,7 +82,7 @@ internal fun HookInfo.generateClass(): TypeSpec { "return super.call(%N, invokeTap = %L, invokeInterceptor = %L)", accumulatorName, CodeBlock.of("{ f, %N, context -> f(context, $paramsWithoutTypes) }", accumulatorName), - CodeBlock.of("{ f, context -> f(context, $paramsWithoutTypes) }") + CodeBlock.of("{ f, context -> f(context, $paramsWithoutTypes) }"), ) Pair(superclass, call) @@ -127,7 +128,7 @@ private val HookInfo.lambdaTypeName get() = createHookContextLambda(hookSignatur private fun HookInfo.createHookContextLambda(returnType: TypeName): LambdaTypeName { val get = LambdaTypeName.get( parameters = listOf(ParameterSpec.unnamed(hookContext)) + parameterSpecs, - returnType = returnType + returnType = returnType, ) return if (this.isAsync) get.copy(suspending = true) else get diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/HooksProcessor.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/HooksProcessor.kt index f8c5497..69c1ced 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/HooksProcessor.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/HooksProcessor.kt @@ -1,21 +1,26 @@ package com.intuit.hooks.plugin.ksp import arrow.core.* -import arrow.typeclasses.Semigroup +import arrow.core.raise.Raise +import arrow.core.raise.recover import com.google.devtools.ksp.getVisibility import com.google.devtools.ksp.processing.* import com.google.devtools.ksp.symbol.* import com.google.devtools.ksp.validate -import com.google.devtools.ksp.visitor.KSDefaultVisitor import com.intuit.hooks.plugin.codegen.* +import com.intuit.hooks.plugin.ksp.validation.* +import com.intuit.hooks.plugin.ksp.validation.EdgeCase import com.intuit.hooks.plugin.ksp.validation.HookValidationError +import com.intuit.hooks.plugin.ksp.validation.error import com.intuit.hooks.plugin.ksp.validation.validateProperty +import com.intuit.hooks.plugin.mapOrAccumulate +import com.intuit.hooks.plugin.raise import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ksp.* public class HooksProcessor( private val codeGenerator: CodeGenerator, - private val logger: KSPLogger, + private val logger: KSPLogger ) : SymbolProcessor { override fun process(resolver: Resolver): List { @@ -26,78 +31,75 @@ public class HooksProcessor( return emptyList() } - private inner class HookPropertyVisitor : KSDefaultVisitor>() { - override fun visitPropertyDeclaration(property: KSPropertyDeclaration, parentResolver: TypeParameterResolver): ValidatedNel { - return if (property.modifiers.contains(Modifier.ABSTRACT)) - validateProperty(property, parentResolver) - else - HookValidationError.NotAnAbstractProperty(property).invalidNel() - } - - override fun defaultHandler(node: KSNode, data: TypeParameterResolver): ValidatedNel = - TODO("Not yet implemented") + private inner class HookPropertyVisitor : KSRaiseVisitor() { + context(Raise>) + override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: TypeParameterResolver): HookInfo = + property.validateProperty(data) } private inner class HookFileVisitor : KSVisitorVoid() { override fun visitFile(file: KSFile, data: Unit) { - val hookContainers = file.declarations.filter { - it is KSClassDeclaration - }.flatMap { - it.accept(HookContainerVisitor(), Unit) - }.mapNotNull { v -> - v.valueOr { errors -> - errors.forEach { error -> logger.error(error.message, error.symbol) } - null - } - }.toList() - - if (hookContainers.isEmpty()) return - - val packageName = file.packageName.asString() - val name = file.fileName.split(".").first() - - generateFile(packageName, "${name}Hooks", hookContainers).writeTo(codeGenerator, aggregating = false, originatingKSFiles = listOf(file)) + recover({ + val containers = file.declarations + .filterIsInstance() + .flatMap { it.accept(HookContainerVisitor(), Unit) } + .ifEmpty { raise(EdgeCase.NoHooksDefined(file)) } + + val packageName = file.packageName.asString() + val name = file.fileName.split(".").first() + + // May raise some additional errors + generateFile(packageName, "${name}Hooks", containers.toList()) + .writeTo(codeGenerator, aggregating = false, originatingKSFiles = listOf(file)) + }, { errors: Nel -> + errors.filterIsInstance().forEach(logger::error) + }, { throwable: Throwable -> + logger.error("Uncaught exception while processing file: ${throwable.localizedMessage}", file) + logger.exception(throwable) + },) } } - private inner class HookContainerVisitor : KSDefaultVisitor>>() { - override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit): List> { + private inner class HookContainerVisitor : KSRaiseVisitor, HookValidationError>() { + + context(Raise>) + override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit): Sequence { val superTypeNames = classDeclaration.superTypes .filter { it.toString().contains("Hooks") } .toList() return if (superTypeNames.isEmpty()) { classDeclaration.declarations - .filter { it is KSClassDeclaration && it.validate() } - .flatMap { it.accept(this, Unit) } - .toList() + .filter { it is KSClassDeclaration && it.validate() /* TODO: Tie in validations to KSP */ } + .flatMap { it.accept(this@HookContainerVisitor, Unit) } } else if (superTypeNames.any { it.resolve().declaration.qualifiedName?.getQualifier() == "com.intuit.hooks.dsl" }) { val parentResolver = classDeclaration.typeParameters.toTypeParameterResolver() classDeclaration.getAllProperties() - .map { it.accept(HookPropertyVisitor(), parentResolver) } - .sequence(Semigroup.nonEmptyList()) - .map { hooks -> createHooksContainer(classDeclaration, hooks) } - .let(::listOf) + .mapOrAccumulate { it.accept(HookPropertyVisitor(), parentResolver) } + .let { createHooksContainer(classDeclaration, it) } + .let { sequenceOf(it) } } else { - emptyList() + emptySequence() } } - fun ClassKind.toTypeSpecKind(): TypeSpec.Kind = when (this) { + context(Raise>) + fun KSClassDeclaration.toTypeSpecKind(): TypeSpec.Kind = when (classKind) { ClassKind.CLASS -> TypeSpec.Kind.CLASS ClassKind.INTERFACE -> TypeSpec.Kind.INTERFACE ClassKind.OBJECT -> TypeSpec.Kind.OBJECT - else -> throw NotImplementedError("Hooks in constructs other than class, interface, and object aren't supported") + else -> raise(HookValidationError.UnsupportedContainer(this)) } + context(Raise>) fun createHooksContainer(classDeclaration: KSClassDeclaration, hooks: List): HooksContainer { val name = "${classDeclaration.parentDeclaration?.simpleName?.asString() ?: ""}${classDeclaration.simpleName.asString()}Impl" val visibilityModifier = classDeclaration.getVisibility().toKModifier() ?: KModifier.PUBLIC val typeArguments = classDeclaration.typeParameters.map { it.toTypeVariableName() } val className = classDeclaration.toClassName() - val typeSpecKind = classDeclaration.classKind.toTypeSpecKind() + val typeSpecKind = classDeclaration.toTypeSpecKind() return HooksContainer( name, @@ -105,12 +107,9 @@ public class HooksProcessor( typeSpecKind, visibilityModifier, typeArguments, - hooks + hooks, ) } - - override fun defaultHandler(node: KSNode, data: Unit): List> = - TODO("Not yet implemented") } public class Provider : SymbolProcessorProvider { diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/KSRaiseVisitor.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/KSRaiseVisitor.kt new file mode 100644 index 0000000..f463e50 --- /dev/null +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/KSRaiseVisitor.kt @@ -0,0 +1,257 @@ +package com.intuit.hooks.plugin.ksp + +import arrow.core.Nel +import arrow.core.raise.Raise +import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.visitor.KSDefaultVisitor +import com.intuit.hooks.plugin.ksp.RaiseContext.Companion.RaiseContext + +internal data class RaiseContext( + val raise: Raise>, + val data: D +) { + companion object { + fun Raise>.RaiseContext(data: D): RaiseContext = RaiseContext(this, data) + } +} + +/** Visitor extension to execute all visitations within the context of a [Raise] */ +internal abstract class KSRaiseVisitor : KSDefaultVisitor, R>() { + + context(Raise>) + open fun defaultHandler(node: KSNode, data: D): R = error("KSRaiseVisitor default implementation. This shouldn't happen unless the visitor doesn't provide the right overrides.") + + final override fun defaultHandler(node: KSNode, data: RaiseContext): R = with(data.raise) { + defaultHandler(node, data.data) + } + + context(Raise>) + open fun visitNode(node: KSNode, data: D): R { + return defaultHandler(node, data) + } + + final override fun visitNode(node: KSNode, data: RaiseContext): R = with(data.raise) { + visitNode(node, data.data) + } + + context(Raise>) + open fun visitAnnotated(annotated: KSAnnotated, data: D): R { + return defaultHandler(annotated, data) + } + + final override fun visitAnnotated(annotated: KSAnnotated, data: RaiseContext): R = with(data.raise) { + visitAnnotated(annotated, data.data) + } + + context(Raise>) + open fun visitAnnotation(annotation: KSAnnotation, data: D): R { + return defaultHandler(annotation, data) + } + + final override fun visitAnnotation(annotation: KSAnnotation, data: RaiseContext): R = with(data.raise) { + visitAnnotation(annotation, data.data) + } + + context(Raise>) + open fun visitModifierListOwner(modifierListOwner: KSModifierListOwner, data: D): R { + return defaultHandler(modifierListOwner, data) + } + + final override fun visitModifierListOwner(modifierListOwner: KSModifierListOwner, data: RaiseContext): R = with(data.raise) { + visitModifierListOwner(modifierListOwner, data.data) + } + + context(Raise>) + open fun visitDeclaration(declaration: KSDeclaration, data: D): R { + return defaultHandler(declaration, data) + } + + final override fun visitDeclaration(declaration: KSDeclaration, data: RaiseContext): R = with(data.raise) { + visitDeclaration(declaration, data.data) + } + + context(Raise>) + open fun visitDeclarationContainer(declarationContainer: KSDeclarationContainer, data: D): R { + return defaultHandler(declarationContainer, data) + } + + final override fun visitDeclarationContainer(declarationContainer: KSDeclarationContainer, data: RaiseContext): R = with(data.raise) { + visitDeclarationContainer(declarationContainer, data.data) + } + + context(Raise>) + open fun visitDynamicReference(reference: KSDynamicReference, data: D): R { + return defaultHandler(reference, data) + } + + final override fun visitDynamicReference(reference: KSDynamicReference, data: RaiseContext): R = with(data.raise) { + visitDynamicReference(reference, data.data) + } + + context(Raise>) + open fun visitFile(file: KSFile, data: D): R { + return defaultHandler(file, data) + } + + final override fun visitFile(file: KSFile, data: RaiseContext): R = with(data.raise) { + visitFile(file, data.data) + } + + context(Raise>) + open fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: D): R { + return defaultHandler(function, data) + } + + final override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: RaiseContext): R = with(data.raise) { + visitFunctionDeclaration(function, data.data) + } + + context(Raise>) + open fun visitCallableReference(reference: KSCallableReference, data: D): R { + return defaultHandler(reference, data) + } + + final override fun visitCallableReference(reference: KSCallableReference, data: RaiseContext): R = with(data.raise) { + visitCallableReference(reference, data.data) + } + + context(Raise>) + open fun visitParenthesizedReference(reference: KSParenthesizedReference, data: D): R { + return defaultHandler(reference, data) + } + + final override fun visitParenthesizedReference(reference: KSParenthesizedReference, data: RaiseContext): R = with(data.raise) { + visitParenthesizedReference(reference, data.data) + } + + context(Raise>) + open fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: D): R { + return defaultHandler(property, data) + } + + final override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: RaiseContext): R = with(data.raise) { + visitPropertyDeclaration(property, data.data) + } + + context(Raise>) + open fun visitPropertyAccessor(accessor: KSPropertyAccessor, data: D): R { + return defaultHandler(accessor, data) + } + + final override fun visitPropertyAccessor(accessor: KSPropertyAccessor, data: RaiseContext): R = with(data.raise) { + visitPropertyAccessor(accessor, data.data) + } + + context(Raise>) + open fun visitPropertyGetter(getter: KSPropertyGetter, data: D): R { + return defaultHandler(getter, data) + } + + final override fun visitPropertyGetter(getter: KSPropertyGetter, data: RaiseContext): R = with(data.raise) { + visitPropertyGetter(getter, data.data) + } + + context(Raise>) + open fun visitPropertySetter(setter: KSPropertySetter, data: D): R { + return defaultHandler(setter, data) + } + + final override fun visitPropertySetter(setter: KSPropertySetter, data: RaiseContext): R = with(data.raise) { + visitPropertySetter(setter, data.data) + } + + context(Raise>) + open fun visitClassifierReference(reference: KSClassifierReference, data: D): R { + return defaultHandler(reference, data) + } + + final override fun visitClassifierReference(reference: KSClassifierReference, data: RaiseContext): R = with(data.raise) { + visitClassifierReference(reference, data.data) + } + + context(Raise>) + open fun visitDefNonNullReference(reference: KSDefNonNullReference, data: D): R { + return defaultHandler(reference, data) + } + + final override fun visitDefNonNullReference(reference: KSDefNonNullReference, data: RaiseContext): R = with(data.raise) { + visitDefNonNullReference(reference, data.data) + } + + context(Raise>) + open fun visitReferenceElement(element: KSReferenceElement, data: D): R { + return defaultHandler(element, data) + } + + final override fun visitReferenceElement(element: KSReferenceElement, data: RaiseContext): R = with(data.raise) { + visitReferenceElement(element, data.data) + } + + context(Raise>) + open fun visitTypeAlias(typeAlias: KSTypeAlias, data: D): R { + return defaultHandler(typeAlias, data) + } + + final override fun visitTypeAlias(typeAlias: KSTypeAlias, data: RaiseContext): R = with(data.raise) { + visitTypeAlias(typeAlias, data.data) + } + + context(Raise>) + open fun visitTypeArgument(typeArgument: KSTypeArgument, data: D): R { + return defaultHandler(typeArgument, data) + } + + final override fun visitTypeArgument(typeArgument: KSTypeArgument, data: RaiseContext): R = with(data.raise) { + visitTypeArgument(typeArgument, data.data) + } + + context(Raise>) + open fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: D): R { + return defaultHandler(classDeclaration, data) + } + + final override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: RaiseContext): R = with(data.raise) { + visitClassDeclaration(classDeclaration, data.data) + } + + context(Raise>) + open fun visitTypeParameter(typeParameter: KSTypeParameter, data: D): R { + return defaultHandler(typeParameter, data) + } + + final override fun visitTypeParameter(typeParameter: KSTypeParameter, data: RaiseContext): R = with(data.raise) { + visitTypeParameter(typeParameter, data.data) + } + + context(Raise>) + open fun visitTypeReference(typeReference: KSTypeReference, data: D): R { + return defaultHandler(typeReference, data) + } + + final override fun visitTypeReference(typeReference: KSTypeReference, data: RaiseContext): R = with(data.raise) { + visitTypeReference(typeReference, data.data) + } + + context(Raise>) + open fun visitValueParameter(valueParameter: KSValueParameter, data: D): R { + return defaultHandler(valueParameter, data) + } + + final override fun visitValueParameter(valueParameter: KSValueParameter, data: RaiseContext): R = with(data.raise) { + visitValueParameter(valueParameter, data.data) + } + + context(Raise>) + open fun visitValueArgument(valueArgument: KSValueArgument, data: D): R { + return defaultHandler(valueArgument, data) + } + + final override fun visitValueArgument(valueArgument: KSValueArgument, data: RaiseContext): R = with(data.raise) { + visitValueArgument(valueArgument, data.data) + } +} + +context(Raise>) +internal fun KSNode.accept(visitor: KSVisitor, R>, data: D): R { + return accept(visitor, RaiseContext(data)) +} diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/Text.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/Text.kt index eef71f6..cd730bd 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/Text.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/Text.kt @@ -9,8 +9,11 @@ internal val KSTypeArgument.text: String get() = when (variance) { else -> "${variance.label} ${type!!.text}" } -internal val List.text: String get() = if (isEmpty()) "" else +internal val List.text: String get() = if (isEmpty()) { + "" +} else { "<${joinToString(transform = KSTypeArgument::text)}>" +} internal val KSTypeReference.text: String get() = element?.let { when (it) { diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/AnnotationValidations.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/AnnotationValidations.kt index 70dc0cb..acbcecc 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/AnnotationValidations.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/AnnotationValidations.kt @@ -1,6 +1,10 @@ package com.intuit.hooks.plugin.ksp.validation import arrow.core.* +import arrow.core.raise.Raise +import arrow.core.raise.ensure +import arrow.core.raise.recover +import arrow.core.raise.zipOrAccumulate import com.google.devtools.ksp.getVisibility import com.google.devtools.ksp.symbol.* import com.intuit.hooks.plugin.codegen.HookInfo @@ -8,6 +12,7 @@ import com.intuit.hooks.plugin.codegen.HookParameter import com.intuit.hooks.plugin.codegen.HookSignature import com.intuit.hooks.plugin.codegen.HookType import com.intuit.hooks.plugin.codegen.HookType.Companion.annotationDslMarkers +import com.intuit.hooks.plugin.ensure import com.intuit.hooks.plugin.ksp.HooksProcessor import com.intuit.hooks.plugin.ksp.text import com.squareup.kotlinpoet.KModifier @@ -23,69 +28,86 @@ import com.squareup.kotlinpoet.ksp.toTypeName val hookFunctionSignatureReference get() = hookFunctionSignatureType.element as? KSCallableReference ?: throw HooksProcessor.Exception("Hook type argument must be a function for $symbol") - val type get() = toString().let(HookType::valueOf) + // NOTE: THIS IS AMAZING - can provide typical nullable APIs for consumers who don't care about working with the explicit typed errors + val type get() = recover({ type }, { null }) + + // TODO: Maybe put in smart constructor, but this is so cool to be able to provide + // an alternative API for those who would prefer raise over exceptions + context(Raise) val type: HookType get() { + ensure(toString() in HookType.supportedHookTypes) { + HookValidationError.NoCodeGenerator(this) + } + + return HookType.valueOf(toString()) + } override fun toString() = "${symbol.shortName.asString()}Hook" } /** Build [HookInfo] from the validated [HookAnnotation] found on the [property] */ -internal fun KSPropertyDeclaration.validateHookAnnotation(parentResolver: TypeParameterResolver): ValidatedNel = - onlyHasASingleDslAnnotation().andThen { annotation -> +context(Raise>) +internal fun KSPropertyDeclaration.validateHookAnnotation(parentResolver: TypeParameterResolver): HookInfo { + // why is onlyHasASingleDslAnnotation wrapped in ensure while nothing else + // is? great question! this is because onlyHasASingleDslAnnotation is a + // singularly concerned validation function that has an explicitly matching + // raise context. onlyHasASingleDslAnnotation will _only_ ever raise a singular + // error, and therefore, shouldn't be treated as if it might have + // many to raise. We use ensure to narrow down the raise type param + // to what we expect, and then unwrap to explicitly re-raise within + // a non-empty-list context. - val hasCodeGenerator = hasCodeGenerator(annotation) - val mustBeHookType = mustBeHookType(annotation, parentResolver) - val validateParameters = validateParameters(annotation, parentResolver) - val hookMember = simpleName.asString() - val propertyVisibility = this.getVisibility().toKModifier() ?: KModifier.PUBLIC + val annotation = ensure { onlyHasASingleDslAnnotation() } - hasCodeGenerator.zip( - mustBeHookType, - validateParameters - ) { hookType: HookType, hookSignature: HookSignature, hookParameters: List -> - HookInfo(hookMember, hookType, hookSignature, hookParameters, propertyVisibility) - } - } + return zipOrAccumulate( + { simpleName.asString() }, + { annotation.hasCodeGenerator() }, + { annotation.mustBeHookType(parentResolver) }, + { annotation.validateParameters(parentResolver) }, + { getVisibility().toKModifier() ?: KModifier.PUBLIC }, + ::HookInfo, + ) +} -private fun KSPropertyDeclaration.onlyHasASingleDslAnnotation(): ValidatedNel { +// TODO: This'd be a good smart constructor use case +context(Raise) private fun KSPropertyDeclaration.onlyHasASingleDslAnnotation(): HookAnnotation { val annotations = annotations.filter { it.shortName.asString() in annotationDslMarkers }.toList() return when (annotations.size) { - 0 -> HookValidationError.NoHookDslAnnotations(this).invalidNel() - 1 -> annotations.single().let(::HookAnnotation).valid() - else -> HookValidationError.TooManyHookDslAnnotations(annotations, this).invalidNel() - } + 0 -> raise(HookValidationError.NoHookDslAnnotations(this)) + 1 -> annotations.single() + else -> raise(HookValidationError.TooManyHookDslAnnotations(annotations, this)) + }.let(::HookAnnotation) } -private fun validateParameters(annotation: HookAnnotation, parentResolver: TypeParameterResolver): ValidatedNel> = try { - annotation.hookFunctionSignatureReference.functionParameters.mapIndexed { index: Int, parameter: KSValueParameter -> +context(Raise) private fun HookAnnotation.validateParameters(parentResolver: TypeParameterResolver): List = try { + hookFunctionSignatureReference.functionParameters.mapIndexed { index: Int, parameter: KSValueParameter -> val name = parameter.name?.asString() val type = parameter.type.toTypeName(parentResolver) HookParameter(name, type, index) - }.valid() + } } catch (exception: Exception) { - HookValidationError.MustBeHookTypeSignature(annotation).invalidNel() + raise(HookValidationError.MustBeHookTypeSignature(this)) } -private fun hasCodeGenerator(annotation: HookAnnotation): ValidatedNel = try { - annotation.type.valid() -} catch (e: Exception) { - HookValidationError.NoCodeGenerator(annotation).invalidNel() -} +// TODO: This would be obsolete with smart constructor +context(Raise) private fun HookAnnotation.hasCodeGenerator(): HookType = type -private fun mustBeHookType(annotation: HookAnnotation, parentResolver: TypeParameterResolver): ValidatedNel = try { - val isSuspend: Boolean = annotation.hookFunctionSignatureType.modifiers.contains(Modifier.SUSPEND) +/** TODO: Another good smart constructor example */ +context(Raise) +private fun HookAnnotation.mustBeHookType(parentResolver: TypeParameterResolver): HookSignature = try { + val isSuspend: Boolean = hookFunctionSignatureType.modifiers.contains(Modifier.SUSPEND) // I'm leaving this here because KSP knows that it's (String) -> Int, whereas once it gets to Poet, it's just kotlin.Function1 - val text = annotation.hookFunctionSignatureType.text - val hookFunctionSignatureType = annotation.hookFunctionSignatureType.toTypeName(parentResolver) - val returnType = annotation.hookFunctionSignatureReference.returnType.toTypeName(parentResolver) - val returnTypeType = annotation.hookFunctionSignatureReference.returnType.element?.typeArguments?.firstOrNull()?.toTypeName(parentResolver) + val text = hookFunctionSignatureType.text + val hookFunctionSignatureType = hookFunctionSignatureType.toTypeName(parentResolver) + val returnType = hookFunctionSignatureReference.returnType.toTypeName(parentResolver) + val returnTypeType = hookFunctionSignatureReference.returnType.element?.typeArguments?.firstOrNull()?.toTypeName(parentResolver) HookSignature( text, isSuspend, returnType, returnTypeType, - hookFunctionSignatureType - ).valid() + hookFunctionSignatureType, + ) } catch (exception: Exception) { - HookValidationError.MustBeHookTypeSignature(annotation).invalidNel() + raise(HookValidationError.MustBeHookTypeSignature(this)) } diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookPropertyValidations.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookPropertyValidations.kt index 77b07b1..83a3bd5 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookPropertyValidations.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookPropertyValidations.kt @@ -1,50 +1,57 @@ package com.intuit.hooks.plugin.ksp.validation -import arrow.core.ValidatedNel -import arrow.core.invalidNel -import arrow.core.valid -import arrow.core.zip +import arrow.core.* +import arrow.core.raise.* import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.intuit.hooks.plugin.codegen.HookInfo import com.intuit.hooks.plugin.codegen.HookProperty +import com.intuit.hooks.plugin.ensure +context(Raise>) internal fun HookProperty.validate( info: HookInfo, - property: KSPropertyDeclaration, -): ValidatedNel = when (this) { - is HookProperty.Bail -> valid() - is HookProperty.Loop -> valid() - is HookProperty.Async -> validate(info, property) - is HookProperty.Waterfall -> validate(info, property) + property: KSPropertyDeclaration +) { + when (this) { + is HookProperty.Bail -> Unit + is HookProperty.Loop -> Unit + is HookProperty.Async -> ensure { + info.validateAsync(property) + } + is HookProperty.Waterfall -> validate(info, property) + } } -private fun HookProperty.Async.validate( - info: HookInfo, - property: KSPropertyDeclaration, -): ValidatedNel = - if (info.hookSignature.isSuspend) valid() - else HookValidationError.AsyncHookWithoutSuspend(property).invalidNel() +context(Raise) +private fun HookInfo.validateAsync(property: KSPropertyDeclaration) { + ensure(hookSignature.isSuspend) { HookValidationError.AsyncHookWithoutSuspend(property) } +} +context(Raise>) private fun HookProperty.Waterfall.validate( info: HookInfo, - property: KSPropertyDeclaration, -): ValidatedNel = - arity(info, property).zip( - parameters(info, property), - ) { _, _ -> this } + property: KSPropertyDeclaration +) { + zipOrAccumulate( + { arity(info, property) }, + { parameters(info, property) }, + ) { _, _ -> } +} +context(Raise) private fun HookProperty.Waterfall.arity( info: HookInfo, - property: KSPropertyDeclaration, -): ValidatedNel { - return if (!info.zeroArity) valid() - else HookValidationError.WaterfallMustHaveParameters(property).invalidNel() + property: KSPropertyDeclaration +) { + ensure(!info.zeroArity) { HookValidationError.WaterfallMustHaveParameters(property) } } +context(Raise) private fun HookProperty.Waterfall.parameters( info: HookInfo, - property: KSPropertyDeclaration, -): ValidatedNel { - return if (info.hookSignature.returnType == info.params.firstOrNull()?.type) valid() - else HookValidationError.WaterfallParameterTypeMustMatch(property).invalidNel() + property: KSPropertyDeclaration +) { + ensure(info.hookSignature.returnType == info.params.firstOrNull()?.type) { + HookValidationError.WaterfallParameterTypeMustMatch(property) + } } diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookValidations.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookValidations.kt index a2c39f0..231e5e3 100644 --- a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookValidations.kt +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookValidations.kt @@ -1,13 +1,25 @@ package com.intuit.hooks.plugin.ksp.validation -import arrow.core.* +import arrow.core.Nel +import arrow.core.mapOrAccumulate +import arrow.core.raise.Raise +import arrow.core.raise.ensure +import arrow.core.raise.zipOrAccumulate +import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.symbol.* import com.intuit.hooks.plugin.codegen.HookInfo +import com.intuit.hooks.plugin.ensure import com.intuit.hooks.plugin.ksp.text +import com.intuit.hooks.plugin.mapOrAccumulate import com.squareup.kotlinpoet.ksp.TypeParameterResolver +context(HookValidationError) +internal fun KSPLogger.error() { + error(message, symbol) +} + // TODO: It'd be nice if the validations were codegen framework agnostic -internal sealed class HookValidationError(val message: String, val symbol: KSNode) { +internal sealed class HookValidationError(override val message: String, val symbol: KSNode) : ErrorCase { class AsyncHookWithoutSuspend(symbol: KSNode) : HookValidationError("Async hooks must be defined with a suspend function signature", symbol) class WaterfallMustHaveParameters(symbol: KSNode) : HookValidationError("Waterfall hooks must take at least one parameter", symbol) class WaterfallParameterTypeMustMatch(symbol: KSNode) : HookValidationError("Waterfall hooks must specify the same types for the first parameter and the return type", symbol) @@ -15,24 +27,40 @@ internal sealed class HookValidationError(val message: String, val symbol: KSNod class NoCodeGenerator(annotation: HookAnnotation) : HookValidationError("This hook plugin has no code generator for $annotation", annotation.symbol) class NoHookDslAnnotations(property: KSPropertyDeclaration) : HookValidationError("Hook property must be annotated with a DSL annotation", property) class TooManyHookDslAnnotations(annotations: List, property: KSPropertyDeclaration) : HookValidationError("This hook has more than a single hook DSL annotation: $annotations", property) - class UnsupportedAbstractPropertyType(property: KSPropertyDeclaration) : HookValidationError("Abstract property type (${property.type.text}) not supported. Hook properties must be of type com.intuit.hooks.Hook", property) + class UnsupportedPropertyType(property: KSPropertyDeclaration) : HookValidationError("Property type (${property.type.text}) not supported. Hook properties must be of type com.intuit.hooks.Hook", property) class NotAnAbstractProperty(property: KSPropertyDeclaration) : HookValidationError("Hooks can only be abstract properties", property) + class UnsupportedContainer(declaration: KSClassDeclaration) : HookValidationError("Hooks in constructs other than class, interface, and object aren't supported", declaration) + + operator fun component1(): String = message + + operator fun component2(): KSNode = symbol } /** main entrypoint for validating [KSPropertyDeclaration]s as valid annotated hook members */ -internal fun validateProperty(property: KSPropertyDeclaration, parentResolver: TypeParameterResolver): ValidatedNel = with(property) { - // validate property has the correct type +context(Raise>) +internal fun KSPropertyDeclaration.validateProperty(parentResolver: TypeParameterResolver): HookInfo { + // 1. validate types validateHookType() - .andThen { validateHookAnnotation(parentResolver) } - // validate property against hook info with specific hook type validations - .andThen { info -> validateHookProperties(info) } + + // 2. validation annotation and + val info = validateHookAnnotation(parentResolver) + + // 3. validate properties against type + validateHookProperties(info) + + return info } -private fun KSPropertyDeclaration.validateHookType(): ValidatedNel = - if (type.text == "Hook") type.valid() - else HookValidationError.UnsupportedAbstractPropertyType(this).invalidNel() +context(Raise>) +private fun KSPropertyDeclaration.validateHookType() { + zipOrAccumulate( + { ensure(type.text == "Hook") { HookValidationError.UnsupportedPropertyType(this@validateHookType) } }, + { ensure(modifiers.contains(Modifier.ABSTRACT)) { HookValidationError.NotAnAbstractProperty(this@validateHookType) } }, + ) { _, _ -> } +} -private fun KSPropertyDeclaration.validateHookProperties(hookInfo: HookInfo) = - hookInfo.hookType.properties.map { it.validate(hookInfo, this) } - .sequence() - .map { hookInfo } +context(Raise>) private fun KSPropertyDeclaration.validateHookProperties(info: HookInfo) { + info.hookType.properties.mapOrAccumulate { + it.validate(info, this@validateHookProperties) + } +} diff --git a/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/LogicalFailure.kt b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/LogicalFailure.kt new file mode 100644 index 0000000..28afe66 --- /dev/null +++ b/processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/LogicalFailure.kt @@ -0,0 +1,16 @@ +package com.intuit.hooks.plugin.ksp.validation + +import com.google.devtools.ksp.symbol.KSFile + +/** Base construct to represent a reason to not execute happy-path logic */ +internal sealed interface LogicalFailure + +/** Logical failure that can be ignored, valid edge case */ +internal sealed interface EdgeCase : LogicalFailure { + class NoHooksDefined(val file: KSFile) : EdgeCase +} + +/** Logical failure that should probably be reported, something bad happened */ +internal sealed interface ErrorCase : LogicalFailure { + val message: String +} diff --git a/processor/src/test/kotlin/com/intuit/hooks/plugin/HookValidationErrors.kt b/processor/src/test/kotlin/com/intuit/hooks/plugin/HookValidationErrors.kt index 5c332fc..894cc7f 100644 --- a/processor/src/test/kotlin/com/intuit/hooks/plugin/HookValidationErrors.kt +++ b/processor/src/test/kotlin/com/intuit/hooks/plugin/HookValidationErrors.kt @@ -16,12 +16,12 @@ class HookValidationErrors { internal abstract class TestHooks : Hooks() { abstract val nonHookProperty: Int } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() - result.assertContainsMessages("Abstract property type (Int) not supported") + result.assertCompilationError() + result.assertContainsMessages("Property type (Int) not supported") } @Test fun `hook property does not have any hook annotation`() { @@ -34,11 +34,11 @@ class HookValidationErrors { internal abstract class TestHooks : Hooks() { abstract val syncHook: Hook } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages("Hook property must be annotated with a DSL annotation") } @@ -55,11 +55,11 @@ class HookValidationErrors { @SyncBail<() -> BailResult> abstract val syncHook: Hook } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages("This hook has more than a single hook DSL annotation: [@Sync, @SyncBail]") } @@ -74,11 +74,11 @@ class HookValidationErrors { @AsyncSeries<() -> Unit> abstract val syncHook: Hook } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages("Async hooks must be defined with a suspend function signature") } @@ -93,11 +93,11 @@ class HookValidationErrors { @SyncWaterfall<() -> String> abstract val syncHook: Hook } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages("Waterfall hooks must take at least one parameter") } @@ -112,11 +112,11 @@ class HookValidationErrors { @SyncWaterfall<(Int, Int) -> Unit> abstract val syncHook: Hook } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages("Waterfall hooks must specify the same types for the first parameter and the return type") } @@ -130,18 +130,19 @@ class HookValidationErrors { internal abstract class TestHooks : Hooks() { @AsyncSeriesWaterfall<() -> String> abstract val realBad: Hook - abstract val state: Int + val state: Int } - """ + """, ) val (_, result) = compile(testHooks) - result.assertOk() + result.assertCompilationError() result.assertContainsMessages( "Async hooks must be defined with a suspend function signature", "Waterfall hooks must take at least one parameter", "Waterfall hooks must specify the same types for the first parameter and the return type", - "Abstract property type (Int) not supported", + "Property type (Int) not supported", + "Hooks can only be abstract properties", ) } } diff --git a/processor/src/test/kotlin/com/intuit/hooks/plugin/HooksProcessorTest.kt b/processor/src/test/kotlin/com/intuit/hooks/plugin/HooksProcessorTest.kt index e07925a..14e8846 100644 --- a/processor/src/test/kotlin/com/intuit/hooks/plugin/HooksProcessorTest.kt +++ b/processor/src/test/kotlin/com/intuit/hooks/plugin/HooksProcessorTest.kt @@ -35,7 +35,7 @@ class HooksProcessorTest { public fun call(newSpeed: Int): Unit = super.call { f, context -> f(context, newSpeed) } } } - """ + """, ) val (compilation, result) = compile(testHooks) @@ -43,6 +43,7 @@ class HooksProcessorTest { compilation.assertKspGeneratedSources("TestHooksHooks.kt") result.assertNoKspErrors() } + @Test fun `multiple hook classes in a single file`() { val testHooks = SourceFile.kotlin( "TestHooks.kt", @@ -59,7 +60,7 @@ class HooksProcessorTest { @Sync<(String) -> Unit> abstract val testSyncHook: Hook } - """ + """, ) val assertions = SourceFile.kotlin( @@ -75,7 +76,7 @@ class HooksProcessorTest { hooks.testSyncHook.call("hello") assertTrue(tapCalled) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) @@ -95,7 +96,7 @@ class HooksProcessorTest { @Sync<(String) -> Unit> abstract val testSyncHook: Hook } - """ + """, ) val assertions = SourceFile.kotlin( @@ -110,7 +111,7 @@ class HooksProcessorTest { hooks.testSyncHook.call("hello") assertTrue(tapCalled) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) @@ -132,7 +133,7 @@ class HooksProcessorTest { @Sync<(String) -> Unit> abstract val testSyncHook: Hook } - """ + """, ) val (compilation, result) = compile(testHooks) @@ -151,7 +152,7 @@ class HooksProcessorTest { @Sync<(Map, List>) -> Unit> abstract val testSyncHook: Hook } - """ + """, ) val assertions = SourceFile.kotlin( @@ -167,7 +168,7 @@ class HooksProcessorTest { hooks.testSyncHook.call(item) assertEquals(item, tappedItem) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) @@ -187,7 +188,7 @@ class HooksProcessorTest { @AsyncSeriesWaterfall String> abstract val testAsyncSeriesWaterfallHook: Hook } - """ + """, ) val assertions = SourceFile.kotlin( @@ -208,7 +209,7 @@ class HooksProcessorTest { assertEquals(initialValue, tappedItem) assertEquals("hello world!", result) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) @@ -238,7 +239,7 @@ class HooksProcessorTest { @AsyncSeriesLoop LoopResult> abstract val asyncSeriesLoop: Hook @AsyncSeriesWaterfall String> abstract val asyncSeriesWaterfall: Hook } - """ + """, ) val (compilation, result) = compile(testHooks) @@ -257,7 +258,7 @@ class HooksProcessorTest { @Sync<(T) -> U> abstract val testSyncHook: Hook } - """ + """, ) val assertions = SourceFile.kotlin( @@ -273,7 +274,7 @@ class HooksProcessorTest { hooks.testSyncHook.call(item) assertEquals(item, tappedValue) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) @@ -297,7 +298,7 @@ class HooksProcessorTest { val hooks = ControllerHooksImpl() } - """ + """, ) val assertions = SourceFile.kotlin( @@ -313,7 +314,7 @@ class HooksProcessorTest { controller.hooks.testSyncHook.call(item) assertEquals(item, tappedValue) } - """ + """, ) val (compilation, result) = compile(testHooks, assertions) diff --git a/processor/src/test/kotlin/com/intuit/hooks/plugin/KotlinCompilation.kt b/processor/src/test/kotlin/com/intuit/hooks/plugin/KotlinCompilation.kt index 27de56d..684a97d 100644 --- a/processor/src/test/kotlin/com/intuit/hooks/plugin/KotlinCompilation.kt +++ b/processor/src/test/kotlin/com/intuit/hooks/plugin/KotlinCompilation.kt @@ -15,7 +15,7 @@ val KotlinCompilation.kspGeneratedSources get() = fun KotlinCompilation.assertKspGeneratedSources(vararg sources: String) { sources.map { kspSourcesDir.resolve("kotlin").resolve( - it.removeSuffixIfPresent(".kt").replace(".", "/").suffix("kt") + it.removeSuffixIfPresent(".kt").replace(".", "/").suffix("kt"), ) }.forEach { Assertions.assertTrue(kspGeneratedSources.contains(it)) { "KSP processing did not generate file: $it" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f38845c..56d0dc1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,10 +13,10 @@ enableFeaturePreview("ONE_LOCKFILE_PER_PROJECT") dependencyResolutionManagement { versionCatalogs { create("libs") { - version("kotlin", "1.6.21") - version("ktlint", "0.45.2") - version("arrow", "1.1.2") - version("ksp", "1.6.21-1.0.6") + version("kotlin", "1.8.10") + version("ktlint", "0.49.1") + version("arrow", "1.2.0-RC") + version("ksp", "1.8.10-1.0.9") version("poet", "1.12.0") version("junit", "5.7.0") version("knit", "0.4.0") @@ -29,8 +29,8 @@ dependencyResolutionManagement { plugin("nexus", "io.github.gradle-nexus.publish-plugin").version("1.0.0") plugin("gradle.publish", "com.gradle.plugin-publish").version("0.13.0") - plugin("ktlint", "org.jlleitschuh.gradle.ktlint").version("10.3.0") - plugin("api", "org.jetbrains.kotlinx.binary-compatibility-validator").version("0.9.0") + plugin("ktlint", "org.jlleitschuh.gradle.ktlint").version("11.3.2") + plugin("api", "org.jetbrains.kotlinx.binary-compatibility-validator").version("0.13.1") plugin("knit", "kotlinx-knit").versionRef("knit") plugin("dokka", "org.jetbrains.dokka").versionRef("kotlin") @@ -78,7 +78,7 @@ dependencyResolutionManagement { "orchid.plugins.snippets", "orchid.plugins.copper", "orchid.plugins.wiki", - ) + ), ) // Testing @@ -86,7 +86,7 @@ dependencyResolutionManagement { library("junit.bom", "org.junit", "junit-bom").version("5.7.0") library("junit.jupiter", "org.junit.jupiter", "junit-jupiter").withoutVersion() library("mockk", "io.mockk", "mockk").version("1.10.2") - library("ksp.testing", "com.github.tschuchortdev", "kotlin-compile-testing-ksp").version("1.4.8") + library("ksp.testing", "com.github.tschuchortdev", "kotlin-compile-testing-ksp").version("1.5.0") library("knit.testing", "org.jetbrains.kotlinx", "kotlinx-knit-test").versionRef("knit") bundle("testing", listOf("junit.jupiter", "mockk"))