├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── bug-report.md └── CODE_OF_CONDUCT.md ├── .editorconfig ├── docs ├── src │ ├── orchid │ │ ├── resources │ │ │ ├── pages │ │ │ │ ├── license.md │ │ │ │ └── changelog.md │ │ │ ├── favicon.ico │ │ │ ├── templates │ │ │ │ ├── layouts │ │ │ │ │ ├── homepage.peb │ │ │ │ │ └── index.peb │ │ │ │ ├── includes │ │ │ │ │ ├── pagination.peb │ │ │ │ │ ├── navbar-search.peb │ │ │ │ │ ├── footer.peb │ │ │ │ │ └── navbar.peb │ │ │ │ └── components │ │ │ │ │ └── changelog.peb │ │ │ ├── assets │ │ │ │ ├── media │ │ │ │ │ ├── favicon-16x16.png │ │ │ │ │ ├── favicon-32x32.png │ │ │ │ │ ├── apple-touch-icon.png │ │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ │ ├── made-with-bulma--semiblack.png │ │ │ │ │ ├── hooks_multi-white-bg.svg │ │ │ │ │ ├── hooks_single-dark-bg.svg │ │ │ │ │ ├── hooks_multi-flat-dark-bg.svg │ │ │ │ │ ├── hooks_multi-flat-white-bg.svg │ │ │ │ │ ├── hooks_multi-3d-dark-bg.svg │ │ │ │ │ ├── logo.svg │ │ │ │ │ └── hooks_multi-3d-white-bg.svg │ │ │ │ └── css │ │ │ │ │ ├── changelog.scss │ │ │ │ │ ├── prismFixes.scss │ │ │ │ │ └── homepage.scss │ │ │ ├── wiki │ │ │ │ ├── summary.md │ │ │ │ ├── getting-started.md │ │ │ │ ├── using-hooks.md │ │ │ │ ├── plugin-architecture.md │ │ │ │ └── key-concepts.md │ │ │ ├── data.yml │ │ │ ├── homepage.peb │ │ │ └── config.yml │ │ └── kotlin │ │ │ └── com │ │ │ └── intuit │ │ │ └── hooks │ │ │ └── docs │ │ │ ├── HooksModule.kt │ │ │ └── HooksTheme.kt │ └── test │ │ ├── kotlin │ │ └── example │ │ │ ├── example-hooksinstallation-01.kt │ │ │ ├── test │ │ │ ├── HooksDSLTest.kt │ │ │ ├── SimpleHookTest.kt │ │ │ └── HookContextTest.kt │ │ │ ├── example-throwaway-01.kt │ │ │ ├── example-synchook-01.kt │ │ │ ├── example-untap-01.kt │ │ │ ├── example-context-01.kt │ │ │ ├── example-car-01.kt │ │ │ ├── example-dsl-01.kt │ │ │ ├── example-car-02.kt │ │ │ └── snippets.kt │ │ └── resources │ │ └── knit.test.template └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── processor ├── src │ ├── main │ │ ├── resources │ │ │ └── META-INF │ │ │ │ └── services │ │ │ │ └── com.google.devtools.ksp.processing.SymbolProcessorProvider │ │ └── kotlin │ │ │ └── com │ │ │ └── intuit │ │ │ └── hooks │ │ │ └── plugin │ │ │ ├── codegen │ │ │ ├── HookType.kt │ │ │ └── HookInfo.kt │ │ │ └── ksp │ │ │ ├── Text.kt │ │ │ ├── validation │ │ │ ├── HookPropertyValidations.kt │ │ │ ├── HookValidations.kt │ │ │ └── AnnotationValidations.kt │ │ │ └── HooksProcessor.kt │ └── test │ │ └── kotlin │ │ └── com │ │ └── intuit │ │ └── hooks │ │ └── plugin │ │ ├── KotlinCompilation.kt │ │ └── HookValidationErrors.kt ├── build.gradle.kts ├── api │ └── processor.api └── README.md ├── gradle-plugin ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── intuit │ │ │ └── hooks │ │ │ └── plugin │ │ │ └── gradle │ │ │ ├── HooksGradleExtension.kt │ │ │ └── HooksGradlePlugin.kt │ └── test │ │ └── kotlin │ │ └── HooksGradlePluginTest.kt ├── api │ └── gradle-plugin.api ├── README.md └── build.gradle.kts ├── knit.properties ├── hooks ├── build.gradle.kts ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── intuit │ │ │ └── hooks │ │ │ ├── SyncHook.kt │ │ │ ├── AsyncSeriesHook.kt │ │ │ ├── SyncWaterfallHook.kt │ │ │ ├── AsyncSeriesWaterfallHook.kt │ │ │ ├── AsyncParallelHook.kt │ │ │ ├── AsyncSeriesBailHook.kt │ │ │ ├── SyncBailHook.kt │ │ │ ├── AsyncParallelBailHook.kt │ │ │ ├── AsyncSeriesLoopHook.kt │ │ │ ├── SyncLoopHook.kt │ │ │ ├── utils │ │ │ └── Parallelism.kt │ │ │ ├── dsl │ │ │ └── Hooks.kt │ │ │ └── BaseHook.kt │ └── test │ │ └── kotlin │ │ └── com │ │ └── intuit │ │ └── hooks │ │ ├── AsyncSeriesTests.kt │ │ ├── AsyncParallelTests.kt │ │ ├── AsyncSeriesWaterfallHookTests.kt │ │ ├── AsyncParallelBailTests.kt │ │ ├── SyncWaterfallHookTests.kt │ │ ├── SyncLoopHookTests.kt │ │ ├── AsyncSeriesLoopHookTests.kt │ │ ├── SyncBailHookTests.kt │ │ ├── AsyncSeriesBailHookTests.kt │ │ └── SyncHookTests.kt └── README.md ├── .gitignore ├── .autorc ├── example-library ├── src │ ├── test │ │ └── kotlin │ │ │ ├── CarHooksTest.kt │ │ │ ├── CompilerPluginTest.kt │ │ │ └── GenericHookTests.kt │ └── main │ │ └── kotlin │ │ └── com │ │ └── intuit │ │ └── hooks │ │ └── example │ │ └── library │ │ ├── generic │ │ └── GenericHooks.kt │ │ └── car │ │ └── Car.kt └── build.gradle.kts ├── maven-plugin ├── build.gradle.kts ├── src │ └── main │ │ ├── resources │ │ └── META-INF │ │ │ └── plexus │ │ │ └── components.xml │ │ └── kotlin │ │ └── com │ │ └── intuit │ │ └── hooks │ │ └── plugin │ │ └── maven │ │ └── HooksMavenPlugin.kt ├── api │ └── maven-plugin.api └── README.md ├── example-application ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── intuit │ └── hooks │ └── example │ └── application │ └── Main.kt ├── .fossa.yml ├── LICENSE ├── .circleci └── config.yml ├── .all-contributorsrc ├── gradlew.bat ├── settings.gradle.kts └── gradlew /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jzucker @dstone3 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | disabled_rules=no-wildcard-imports -------------------------------------------------------------------------------- /docs/src/orchid/resources/pages/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | components: 3 | - type: 'license' 4 | --- -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /docs/src/orchid/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/favicon.ico -------------------------------------------------------------------------------- /docs/src/orchid/resources/pages/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | components: 3 | - type: 'changelog' 4 | extraCss: [ 'assets/css/changelog.scss' ] 5 | --- -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-XX:MaxMetaspaceSize=1G -Xmx2024m -XX:MaxPermSize=512m 2 | group=com.intuit.hooks 3 | version=0.15.1 4 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/layouts/homepage.peb: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/index' %} 2 | 3 | {% block contentMain %} 4 | {% page %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider: -------------------------------------------------------------------------------- 1 | com.intuit.hooks.plugin.ksp.HooksProcessor$Provider -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/favicon-16x16.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/favicon-32x32.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/made-with-bulma--semiblack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intuit/hooks/HEAD/docs/src/orchid/resources/assets/media/made-with-bulma--semiblack.png -------------------------------------------------------------------------------- /docs/src/orchid/resources/wiki/summary.md: -------------------------------------------------------------------------------- 1 | 2 | - [Getting Started](getting-started.md) 3 | - [Using Hooks](using-hooks.md) 4 | - [Key Concepts](key-concepts.md) 5 | - [Plugin Architecture](plugin-architecture.md) 6 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradleExtension.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.gradle 2 | 3 | /** Any options to be used to configure the hooks-plugin */ 4 | public abstract class HooksGradleExtension 5 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/css/changelog.scss: -------------------------------------------------------------------------------- 1 | .accordions .accordion .accordion-header { 2 | background-color: #f5f5f5; 3 | color: #363636; 4 | } 5 | 6 | .accordions .accordion .accordion-header + .accordion-body { 7 | border: 0; 8 | } -------------------------------------------------------------------------------- /knit.properties: -------------------------------------------------------------------------------- 1 | knit.dir=docs/src/test/kotlin/example/ 2 | knit.package=com.intuit.hooks.example 3 | 4 | test.dir=docs/src/test/kotlin/example/test 5 | test.package=com.intuit.hooks.example 6 | test.template=docs/src/test/resources/knit.test.template 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-hooksinstallation-01.kt: -------------------------------------------------------------------------------- 1 | /* 2 | // This file was automatically generated from README.md by Knit tool. Do not edit. 3 | package com.intuit.hooks.example.exampleHooksinstallation01 4 | 5 | implementation("com.intuit.hooks:hooks:$version") 6 | */ 7 | -------------------------------------------------------------------------------- /hooks/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.knit) 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlin.stdlib) 7 | implementation(libs.kotlin.coroutines.core) 8 | 9 | testImplementation(platform(libs.junit.bom)) 10 | testImplementation(libs.bundles.testing) 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/wiki/getting-started.md: -------------------------------------------------------------------------------- 1 | | What would you like to accomplish | What to read | 2 | | --- | --- | 3 | | _Learn more about hooks_ | [Key Concepts](../key-concepts), [Plugin Architecture](../plugin-architecture) | 4 | | _Use hooks in my project_ | [Using Hooks](../using-hooks) | 5 | | _Contribute to the project_ | [Github](https://github.com/intuit/hooks) | -------------------------------------------------------------------------------- /docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksModule.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.docs 2 | 3 | import com.eden.orchid.api.registration.OrchidModule 4 | import com.eden.orchid.api.theme.Theme 5 | import com.eden.orchid.utilities.addToSet 6 | 7 | class HooksModule : OrchidModule() { 8 | override fun configure() { 9 | addToSet() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/SyncHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class SyncHook> : SyncBaseHook("SyncHook") { 4 | protected fun call(invokeWithContext: (F, HookContext) -> Unit) { 5 | val context = setup(invokeWithContext) 6 | return taps.forEach { tapInfo -> invokeWithContext(tapInfo.f, context) } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 14 | hs_err_pid* 15 | 16 | build/ 17 | .gradle/ 18 | 19 | .idea/ 20 | .env 21 | **/.DS_Store 22 | 23 | local.properties 24 | -------------------------------------------------------------------------------- /gradle-plugin/api/gradle-plugin.api: -------------------------------------------------------------------------------- 1 | public abstract class com/intuit/hooks/plugin/gradle/HooksGradleExtension { 2 | public fun ()V 3 | } 4 | 5 | public final class com/intuit/hooks/plugin/gradle/HooksGradlePlugin : org/gradle/api/Plugin { 6 | public fun ()V 7 | public synthetic fun apply (Ljava/lang/Object;)V 8 | public fun apply (Lorg/gradle/api/Project;)V 9 | } 10 | 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | # What Changed 7 | 8 | ## Why 9 | 10 | Todo: 11 | 12 | - [ ] Add tests 13 | - [ ] Add docs 14 | - [ ] Add release notes 15 | 16 | # Release Notes 17 | -------------------------------------------------------------------------------- /processor/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation(libs.kotlin.stdlib) 3 | implementation(libs.ksp.spa) 4 | implementation(libs.ksp.poet) 5 | implementation(libs.arrow.core) 6 | 7 | testImplementation(project(":hooks")) 8 | testImplementation(platform(libs.junit.bom)) 9 | testImplementation(libs.bundles.testing) 10 | testImplementation(libs.ksp.testing) 11 | } 12 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class AsyncSeriesHook> : AsyncBaseHook("AsyncSeriesHook") { 4 | protected suspend fun call(invokeWithContext: suspend (F, HookContext) -> Unit) { 5 | val context = setup(invokeWithContext) 6 | return taps.forEach { tapInfo -> invokeWithContext(tapInfo.f, context) } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/SyncWaterfallHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class SyncWaterfallHook, R> : SyncBaseHook("SyncWaterfallHook") { 4 | protected fun call(initial: R, invokeTap: (F, R, HookContext) -> R, invokeInterceptor: (F, HookContext) -> Unit): R { 5 | val context = setup(invokeInterceptor) 6 | return taps.fold(initial) { r, tapInfo -> invokeTap(tapInfo.f, r, context) } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/test/HooksDSLTest.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from README.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example 3 | 4 | import org.junit.jupiter.api.Test 5 | import kotlinx.knit.test.* 6 | 7 | class HooksDSLTest { 8 | @Test 9 | fun testExampleDsl01() { 10 | captureOutput("ExampleDsl01") { com.intuit.hooks.example.exampleDsl01.main() }.verifyOutputLines( 11 | "newSpeed: 30" 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class AsyncSeriesWaterfallHook, R> : AsyncBaseHook("AsyncSeriesWaterfallHook") { 4 | protected suspend fun call(initial: R, invokeTap: suspend (F, R, HookContext) -> R, invokeInterceptor: suspend (F, HookContext) -> Unit): R { 5 | val context = setup(invokeInterceptor) 6 | return taps.fold(initial) { r, tapInfo -> invokeTap(tapInfo.f, r, context) } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/test/SimpleHookTest.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from README.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example 3 | 4 | import org.junit.jupiter.api.Test 5 | import kotlinx.knit.test.* 6 | 7 | class SimpleHookTest { 8 | @Test 9 | fun testExampleSynchook01() { 10 | captureOutput("ExampleSynchook01") { com.intuit.hooks.example.exampleSynchook01.main() }.verifyOutputLines( 11 | "my hook was called" 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.autorc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "gradle", 5 | { 6 | "gradleCommand": "./gradlew", 7 | "gradleOptions": [ 8 | "-Pgradle.publish.key=$GRADLE_PLUGIN_KEY", 9 | "-Pgradle.publish.secret=$GRADLE_PLUGIN_SECRET" 10 | ] 11 | } 12 | ], 13 | "released", 14 | "first-time-contributor", 15 | "all-contributors" 16 | ], 17 | "owner": "intuit", 18 | "repo": "hooks", 19 | "name": "Jeremiah Zucker", 20 | "email": "zucker.jeremiah@gmail.com" 21 | } 22 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-throwaway-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from README.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleThrowaway01 3 | 4 | /** Throwaway code for knit (would be really nice if I could just specify a start for knit or exclude for knit) 5 | 6 | // build.gradle(.kts) 7 | plugins { 8 | id("com.google.devtools.ksp") version KSP_VERSION // >= 1.0.5 9 | } 10 | 11 | dependencies { 12 | ksp("com.intuit.hooks", "processor", HOOKS_VERSION) 13 | } 14 | */ 15 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/test/HookContextTest.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from key-concepts.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example 3 | 4 | import org.junit.jupiter.api.Test 5 | import kotlinx.knit.test.* 6 | 7 | class HookContextTest { 8 | @Test 9 | fun testExampleContext01() { 10 | captureOutput("ExampleContext01") { com.intuit.hooks.example.exampleContext01.main() }.verifyOutputLines( 11 | "NoisePlugin is doing it's job", 12 | "Silence..." 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-synchook-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from README.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleSynchook01 3 | 4 | import com.intuit.hooks.HookContext 5 | import com.intuit.hooks.SyncHook 6 | 7 | class SimpleHook : SyncHook<(HookContext) -> Unit>() { 8 | fun call() = super.call { f, context -> f(context) } 9 | } 10 | 11 | fun main() { 12 | val hook = SimpleHook() 13 | hook.tap("logging") { context -> 14 | println("my hook was called") 15 | } 16 | hook.call() 17 | } 18 | -------------------------------------------------------------------------------- /example-library/src/test/kotlin/CarHooksTest.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.library 2 | 3 | import com.intuit.hooks.example.library.car.Car 4 | import org.junit.jupiter.api.Assertions.* 5 | import org.junit.jupiter.api.Test 6 | 7 | internal class CarHooksTest { 8 | 9 | @Test 10 | fun testCarAccelerateHooks() { 11 | val car = Car() 12 | 13 | var accelerateTo: Int? = null 14 | car.hooks.accelerate.tap("LoggerPlugin") { newSpeed -> accelerateTo = newSpeed } 15 | 16 | car.speed = 88 17 | assertEquals(88, accelerateTo) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example-library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.ksp) 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlin.stdlib) 7 | implementation(libs.kotlin.coroutines.core) 8 | 9 | api(project(":hooks")) 10 | ksp(project(":processor")) 11 | 12 | testImplementation(platform(libs.junit.bom)) 13 | testImplementation(libs.bundles.testing) 14 | } 15 | 16 | kotlin { 17 | explicitApi() 18 | 19 | sourceSets.main { 20 | kotlin.srcDir("build/generated/ksp/main/kotlin") 21 | } 22 | sourceSets.test { 23 | kotlin.srcDir("build/generated/ksp/test/kotlin") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncParallelHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import kotlinx.coroutines.coroutineScope 4 | import kotlinx.coroutines.launch 5 | 6 | public abstract class AsyncParallelHook> : AsyncBaseHook("AsyncParallelHook") { 7 | protected suspend fun call(invokeWithContext: suspend (F, HookContext) -> Unit) { 8 | val context = setup(invokeWithContext) 9 | 10 | coroutineScope { 11 | taps.forEach { tapInfo -> 12 | launch { 13 | invokeWithContext(tapInfo.f, context) 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-untap-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from key-concepts.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleUntap01 3 | 4 | import com.intuit.hooks.* 5 | 6 | class SimpleHook : SyncHook<(HookContext) -> Unit>() { 7 | fun call() = super.call { f, context -> f(context) } 8 | } 9 | 10 | fun main() { 11 | 12 | val simpleHook = SimpleHook() 13 | val tap1 = simpleHook.tap("tap1") { 14 | println("doing something") 15 | }!! 16 | 17 | // to remove previously tapped function 18 | simpleHook.untap(tap1) 19 | // or to override previously tapped function 20 | simpleHook.tap("tap1", tap1) { 21 | println("doing something else") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/layouts/index.peb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% head %} 5 | {% styles %} 6 | 7 | 8 | {% include 'includes/navbar' %} 9 |
10 |
11 |
12 |
13 | {% block contentMain %} 14 | {% breadcrumbs %} 15 |
16 | {% page %} 17 |
18 | {% endblock %} 19 | {% include 'includes/pagination' %} 20 |
21 |
22 |
23 |
24 | {% include 'includes/footer' %} 25 | {% scripts %} 26 | 27 | 28 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesBailHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class AsyncSeriesBailHook>, R> : AsyncBaseHook("AsyncSeriesBailHook") { 4 | protected suspend fun call(invokeWithContext: suspend (F, HookContext) -> BailResult, default: (suspend (HookContext) -> R)? = null): R? { 5 | val context = setup(invokeWithContext) 6 | 7 | taps.forEach { tapInfo -> 8 | when (val result = invokeWithContext(tapInfo.f, context)) { 9 | is BailResult.Bail -> return@call result.value 10 | is BailResult.Continue -> {} 11 | } 12 | } 13 | 14 | return default?.invoke(context) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/src/test/resources/knit.test.template: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from ${file.name} by Knit tool. Do not edit. 2 | package ${test.package} 3 | 4 | import org.junit.jupiter.api.Test 5 | import kotlinx.knit.test.* 6 | 7 | class ${test.name} { 8 | <#list cases as case><#assign method = test["mode.${case.param}"]!"custom"> 9 | @Test 10 | fun test${case.name}() { 11 | captureOutput("${case.name}") { ${case.knit.package}.${case.knit.name}.main() }<#if method != "custom">.${method}( 12 | <#list case.lines as line> 13 | "${line?j_string}"<#sep>, 14 | 15 | ) 16 | <#else>.also { lines -> 17 | check(${case.param}) 18 | } 19 | 20 | } 21 | <#sep> 22 | 23 | 24 | } -------------------------------------------------------------------------------- /maven-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val ksp: Configuration by configurations.creating 2 | 3 | dependencies { 4 | implementation(libs.kotlin.maven) 5 | implementation(libs.ksp.maven) 6 | ksp(project(":processor")) 7 | } 8 | 9 | tasks { 10 | jar { 11 | dependsOn(":processor:jar") 12 | fromConfiguration(ksp) { 13 | this.duplicatesStrategy = DuplicatesStrategy.EXCLUDE 14 | } 15 | 16 | from( 17 | configurations.compileClasspath.get().filter { dependency -> 18 | dependency.absolutePath.contains("kotlin-maven-symbol-processing") 19 | }.map(::zipTree) 20 | ) { 21 | this.duplicatesStrategy = DuplicatesStrategy.EXCLUDE 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/includes/pagination.peb: -------------------------------------------------------------------------------- 1 | {% if page.next is not empty or page.previous is not empty %} 2 | 16 | {% endif %} 17 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.jupiter.api.Test 6 | 7 | class AsyncSeriesTests { 8 | class AsyncHook1 : AsyncSeriesHook R>() { 9 | suspend fun call(p1: T1) = super.call { f, context -> f(context, p1) } 10 | } 11 | 12 | @Test 13 | fun `register interceptors`() = runBlocking { 14 | val h = AsyncHook1() 15 | h.tap("foo") { _, _ -> 16 | delay(1) 17 | 0 18 | } 19 | h.tap("foo") { _, _ -> 20 | delay(1) 21 | 0 22 | } 23 | 24 | h.call("Kian") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | 14 | **Describe the solution you'd like** 15 | 16 | 17 | 18 | **Describe alternatives you've considered** 19 | 20 | 21 | 22 | **Additional context** 23 | 24 | 25 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/components/changelog.peb: -------------------------------------------------------------------------------- 1 |

Changelog

2 |
3 | {% for version in component.model.versions %} 4 |
5 |
6 |

{{ version.version }} 7 |

8 | 9 |

10 | 11 | 12 | 13 |
14 |
15 |
{{ version.content | raw }}
16 |
17 |
18 |
19 | {% endfor %} 20 |
21 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/includes/navbar-search.peb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 9 |
10 |
11 | 12 |
13 |
14 |
15 | 23 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/SyncBailHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public sealed class BailResult { 4 | public class Bail(public val value: T) : BailResult() 5 | 6 | public class Continue : BailResult() 7 | } 8 | 9 | public abstract class SyncBailHook>, R> : SyncBaseHook("SyncBailHook") { 10 | protected fun call(invokeWithContext: (F, HookContext) -> BailResult, default: ((HookContext) -> R)? = null): R? { 11 | val context = setup(invokeWithContext) 12 | 13 | taps.forEach { tapInfo -> 14 | when (val result = invokeWithContext(tapInfo.f, context)) { 15 | is BailResult.Bail -> return@call result.value 16 | is BailResult.Continue -> {} 17 | } 18 | } 19 | 20 | return default?.invoke(context) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example-application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | application 3 | } 4 | 5 | // Resolvable configuration to help configure dependency resolution for the jar task 6 | val projectImplementation: Configuration by configurations.creating { 7 | configurations.implementation.get().extendsFrom(this) 8 | } 9 | 10 | dependencies { 11 | implementation(libs.kotlin.stdlib) 12 | implementation(libs.kotlin.coroutines.core) 13 | projectImplementation(project(":example-library")) 14 | } 15 | 16 | application { 17 | mainClass.set("com.intuit.hooks.example.application.MainKt") 18 | } 19 | 20 | tasks { 21 | jar { 22 | dependsOn(projectImplementation) 23 | 24 | manifest { 25 | attributes["Main-Class"] = "com.intuit.hooks.example.application.MainKt" 26 | } 27 | 28 | fromConfiguration(configurations.runtimeClasspath) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncParallelBailHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import com.intuit.hooks.utils.Parallelism.parallelMap 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.flow.asFlow 6 | import kotlinx.coroutines.flow.filterIsInstance 7 | import kotlinx.coroutines.flow.firstOrNull 8 | 9 | @ExperimentalCoroutinesApi 10 | public abstract class AsyncParallelBailHook>, R> : AsyncBaseHook("AsyncParallelBailHook") { 11 | protected suspend fun call(concurrency: Int, invokeWithContext: suspend (F, HookContext) -> BailResult): R? { 12 | val context = setup(invokeWithContext) 13 | return taps.asFlow() 14 | .parallelMap(concurrency) { invokeWithContext(it.f, context) } 15 | .filterIsInstance>() 16 | .firstOrNull()?.value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /gradle-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Gradle Plugin 2 | 3 | > **Warning** 4 | > 5 | > The Gradle plugin automatically bundles a specific version of the KSP plugin, which is tied to a specific version of Kotlin (can be found [here](./settings.gradle.kts#19)). This means the Gradle plugin is only compatible with projects that use that specific Kotlin version. At some point, this module will be upgraded to publish in accordance to the KSP/Kotlin version it bundles. 6 | 7 | Applying the hooks Gradle plugin automatically adds the appropriate dependencies to your project, configures the generated source directory, and registers the KSP plugin. 8 | 9 | ### Installation 10 | 11 | Add the following to your modules `build.gradle(.kts)`: 12 | 13 | ```kotlin 14 | // build.gradle(.kts) 15 | plugins { 16 | // other plugins 17 | 18 | // apply hooks gradle plugin 19 | id("com.intuit.hooks") version "$HOOKS_VERSION" 20 | } 21 | ``` 22 | -------------------------------------------------------------------------------- /.fossa.yml: -------------------------------------------------------------------------------- 1 | # Generated by FOSSA CLI (https://github.com/fossas/fossa-cli) 2 | # Visit https://fossa.com to learn more 3 | 4 | version: 2 5 | cli: 6 | server: https://app.fossa.com 7 | fetcher: custom 8 | project: git@github.com:intuit/hooks 9 | analyze: 10 | modules: 11 | - name: docs 12 | type: gradle 13 | target: 'docs:' 14 | path: . 15 | - name: example-application 16 | type: gradle 17 | target: 'example-application:' 18 | path: . 19 | - name: example-library 20 | type: gradle 21 | target: 'example-library:' 22 | path: . 23 | - name: gradle-plugin 24 | type: gradle 25 | target: 'gradle-plugin:' 26 | path: . 27 | - name: hooks 28 | type: gradle 29 | target: 'hooks:' 30 | path: . 31 | - name: maven-plugin 32 | type: gradle 33 | target: 'maven-plugin:' 34 | path: . 35 | - name: processor 36 | type: gradle 37 | target: 'processor:' 38 | path: . 39 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/wiki/using-hooks.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | At its core, this project exposes a base hooks library, which can be used by itself, but requires a somewhat verbose, redundant API to use. To limit the overhead of using hooks, we also expose a Kotlin symbol processor built with the [KSP API](https://kotlinlang.org/docs/ksp-overview.html), which provides a simple, type-driven DSL to enable consumers to create hooks. Kotlin symbol processors are relatively easy to integrate into Gradle projects, but to limit the configuration burden, we've built a Gradle plugin and a Maven Kotlin plugin extension to configure a project to use hooks. See the module documentation for more information on how to use hooks in your project: 4 | 5 | ##### Modules 6 | 7 | * [Hooks](/hooks/modules/hooks) 8 | * [Processor](/hooks/modules/processor) 9 | * [Gradle Plugin](/hooks/modules/gradle-plugin) 10 | * [Maven Kotlin Plugin Extension](/hooks/modules/maven-plugin) 11 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncParallelTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.jupiter.api.Assertions 6 | import org.junit.jupiter.api.Test 7 | 8 | class AsyncParallelTests { 9 | class AsyncParallelHook1 : AsyncParallelHook R>() { 10 | suspend fun call(p1: T1) = super.call { f, context -> f(context, p1) } 11 | } 12 | 13 | @Test 14 | fun `register interceptors`() = runBlocking { 15 | var count = 0 16 | val h = AsyncParallelHook1() 17 | h.tap("foo") { _, _ -> 18 | delay(1) 19 | count++ 20 | 0 21 | } 22 | h.tap("bar") { _, _ -> 23 | delay(2) 24 | count++ 25 | 0 26 | } 27 | 28 | h.call("Kian") 29 | Assertions.assertEquals(2, count) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | 14 | **To Reproduce** 15 | 16 | 17 | 18 | 19 | 20 | **Expected behavior** 21 | 22 | 23 | 24 | **Screenshots** 25 | 26 | 27 | 28 | **Environment information:** 29 | 30 | 31 | 32 | ```txt 33 | ``` 34 | 35 | **Additional context** 36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/data.yml: -------------------------------------------------------------------------------- 1 | homepageSections: 2 | - title: 'A variety of hooks' 3 | snippets: ['hook_types'] 4 | lang: 'text' 5 | tabs: 6 | - title: 'With extendable APIs' 7 | snippets: [ 'extendable_api' ] 8 | lang: 'kotlin' 9 | - title: 'And strongly typed methods' 10 | snippets: [ 'typed' ] 11 | lang: 'kotlin' 12 | - title: 'Asynchronous support built on coroutines' 13 | snippets: ['asynchronous'] 14 | lang: 'kotlin' 15 | 16 | - title: 'Kotlin symbol processor' 17 | snippets: ['processor'] 18 | lang: 'text' 19 | tabs: 20 | - title: 'Paired with a concise DSL that generates type-safe APIs' 21 | snippets: ['concise_dsl'] 22 | lang: 'kotlin' 23 | - title: 'Wrapped with a Gradle plugin...' 24 | snippets: [ 'gradle_plugin' ] 25 | lang: 'kotlin' 26 | - title: 'And a Maven Kotlin plugin extension' 27 | snippets: [ 'maven_plugin' ] 28 | lang: 'markup' 29 | -------------------------------------------------------------------------------- /maven-plugin/src/main/resources/META-INF/plexus/components.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | org.jetbrains.kotlin.maven.KotlinMavenPluginExtension 7 | hooks 8 | com.intuit.hooks.plugin.maven.HooksMavenPlugin 9 | 10 | false 11 | 12 | 13 | org.codehaus.plexus.logging.Logger 14 | logger 15 | 16 | 17 | org.apache.maven.repository.RepositorySystem 18 | system 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /example-library/src/test/kotlin/CompilerPluginTest.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.library 2 | 3 | import org.junit.jupiter.api.Assertions.assertTrue 4 | import org.junit.jupiter.api.Test 5 | import java.nio.file.Paths 6 | import kotlin.io.path.exists 7 | 8 | class CompilerPluginTest { 9 | 10 | @Test 11 | fun `sources are generated in specified directory`() { 12 | val generatedDirPath = Paths.get(System.getProperty("user.dir"), "build", "generated") 13 | listOf("car.Car", "generic.GenericHooks").map { 14 | "com.intuit.hooks.example.library.$it" 15 | }.map { 16 | Paths.get("", *it.split(".").toTypedArray()) 17 | }.map { 18 | Paths.get( 19 | generatedDirPath.toString(), 20 | "ksp", 21 | "main", 22 | "kotlin", 23 | ).resolve("${it}Hooks.kt") 24 | }.forEach { 25 | assertTrue(it.exists()) { "$it does not exist" } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookType.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.codegen 2 | 3 | internal sealed class HookProperty { 4 | object Bail : HookProperty() 5 | object Loop : HookProperty() 6 | object Async : HookProperty() 7 | object Waterfall : HookProperty() 8 | } 9 | 10 | internal enum class HookType(vararg val properties: HookProperty) { 11 | SyncHook, 12 | SyncBailHook(HookProperty.Bail), 13 | SyncWaterfallHook(HookProperty.Waterfall), 14 | SyncLoopHook(HookProperty.Loop), 15 | AsyncParallelHook(HookProperty.Async), 16 | AsyncParallelBailHook(HookProperty.Async, HookProperty.Bail), 17 | AsyncSeriesHook(HookProperty.Async), 18 | AsyncSeriesBailHook(HookProperty.Async, HookProperty.Bail), 19 | AsyncSeriesWaterfallHook(HookProperty.Async, HookProperty.Waterfall), 20 | AsyncSeriesLoopHook(HookProperty.Async, HookProperty.Loop); 21 | 22 | companion object { 23 | val annotationDslMarkers = values().map { 24 | it.name.dropLast(4) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-context-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from key-concepts.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleContext01 3 | 4 | import com.intuit.hooks.dsl.Hooks 5 | import com.intuit.hooks.Hook 6 | 7 | abstract class CarHooks : Hooks() { 8 | @Sync<(newSpeed: Int) -> Unit> abstract val accelerate: Hook 9 | } 10 | 11 | class Car { 12 | val hooks = CarHooksImpl() 13 | 14 | var speed: Int = 0 15 | set(value) { 16 | hooks.accelerate.call(value) 17 | } 18 | } 19 | 20 | fun main() { 21 | val car = Car() 22 | 23 | car.hooks.accelerate.interceptTap { context, tapInfo -> 24 | println("${tapInfo.name} is doing it's job") 25 | context["hasMuffler"] = true 26 | } 27 | 28 | car.hooks.accelerate.tap("NoisePlugin") { context, newSpeed -> 29 | println(if (context["hasMuffler"] == true) "Silence..." else "Vroom!") 30 | } 31 | 32 | car.speed = 20 33 | // NoisePlugin is doing it's job 34 | // Silence... 35 | } 36 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/css/prismFixes.scss: -------------------------------------------------------------------------------- 1 | /* Fix some conflicts with Prism and Bulma */ 2 | pre[class*="language-"] { 3 | margin: inherit; 4 | } 5 | 6 | code[class*="language-"], pre[class*="language-"], 7 | code, pre { 8 | font-family: 'Fira Code', 'Source Code Pro', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 9 | } 10 | 11 | :not(pre) > code[class*="language-"], pre[class*="language-"], 12 | pre { 13 | background: #363636; 14 | color: #ccc; 15 | } 16 | 17 | pre[class*="language-"] .number { 18 | align-items: inherit; 19 | background-color: inherit; 20 | border-radius: inherit; 21 | display: inherit; 22 | font-size: inherit; 23 | height: inherit; 24 | justify-content: inherit; 25 | margin-right: inherit; 26 | min-width: inherit; 27 | padding: inherit; 28 | text-align: inherit; 29 | vertical-align: inherit; 30 | } 31 | 32 | pre.notification { 33 | padding: 1em; 34 | } 35 | 36 | .tag:not(body) { 37 | background-color: transparent; 38 | color: #4a4a4a; 39 | font-size: 1em; 40 | padding-left: 0; 41 | padding-right: 0; 42 | } 43 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesWaterfallHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.jupiter.api.Assertions 6 | import org.junit.jupiter.api.Test 7 | 8 | class AsyncSeriesWaterfallHookTests { 9 | class Hook1 : AsyncSeriesWaterfallHook R, R>() { 10 | suspend fun call(p1: R) = super.call( 11 | p1, 12 | invokeTap = { f, r, context -> f(context, r) }, 13 | invokeInterceptor = { f, context -> f(context, p1) } 14 | ) 15 | } 16 | 17 | @Test 18 | fun `waterfall taps work`() = runBlocking { 19 | val h = Hook1() 20 | h.tap("continue") { _, x -> 21 | delay(1) 22 | "$x David" 23 | } 24 | h.tap("continue again") { _, x -> 25 | delay(1) 26 | "$x Jeremiah" 27 | } 28 | 29 | val result = h.call("Kian") 30 | Assertions.assertEquals("Kian David Jeremiah", result) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /processor/api/processor.api: -------------------------------------------------------------------------------- 1 | public final class com/intuit/hooks/plugin/ksp/HooksProcessor : com/google/devtools/ksp/processing/SymbolProcessor { 2 | public fun (Lcom/google/devtools/ksp/processing/CodeGenerator;Lcom/google/devtools/ksp/processing/KSPLogger;)V 3 | public fun process (Lcom/google/devtools/ksp/processing/Resolver;)Ljava/util/List; 4 | } 5 | 6 | public final class com/intuit/hooks/plugin/ksp/HooksProcessor$Exception : java/lang/Exception { 7 | public fun (Ljava/lang/String;Ljava/lang/Throwable;)V 8 | public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 9 | } 10 | 11 | public final class com/intuit/hooks/plugin/ksp/HooksProcessor$Provider : com/google/devtools/ksp/processing/SymbolProcessorProvider { 12 | public fun ()V 13 | public synthetic fun create (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)Lcom/google/devtools/ksp/processing/SymbolProcessor; 14 | public fun create (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)Lcom/intuit/hooks/plugin/ksp/HooksProcessor; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/Text.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.ksp 2 | 3 | import com.google.devtools.ksp.symbol.* 4 | 5 | internal val KSTypeArgument.text: String get() = when (variance) { 6 | Variance.STAR -> variance.label 7 | // type should always be defined if not star projected 8 | Variance.INVARIANT -> type!!.text 9 | else -> "${variance.label} ${type!!.text}" 10 | } 11 | 12 | internal val List.text: String get() = if (isEmpty()) "" else 13 | "<${joinToString(transform = KSTypeArgument::text)}>" 14 | 15 | internal val KSTypeReference.text: String get() = element?.let { 16 | when (it) { 17 | // Use lambda type shorthand 18 | is KSCallableReference -> "${if (this.modifiers.contains(Modifier.SUSPEND)) "suspend " else ""}(${ 19 | it.functionParameters.map(KSValueParameter::type).joinToString(transform = KSTypeReference::text) 20 | }) -> ${it.returnType.text}" 21 | else -> "$it${it.typeArguments.text}" 22 | } 23 | } ?: throw HooksProcessor.Exception("element was null, cannot translate KSTypeReference to code text: $this") 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Intuit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /maven-plugin/api/maven-plugin.api: -------------------------------------------------------------------------------- 1 | public final class com/intuit/hooks/plugin/maven/HooksMavenPlugin : org/jetbrains/kotlin/maven/KotlinMavenPluginExtension { 2 | public field logger Lorg/codehaus/plexus/logging/Logger; 3 | public field system Lorg/apache/maven/repository/RepositorySystem; 4 | public fun ()V 5 | public fun (Lcom/dyescape/ksp/maven/KotlinSymbolProcessingMavenPluginExtension;)V 6 | public synthetic fun (Lcom/dyescape/ksp/maven/KotlinSymbolProcessingMavenPluginExtension;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 7 | public fun getCompilerPluginId ()Ljava/lang/String; 8 | public final fun getLogger ()Lorg/codehaus/plexus/logging/Logger; 9 | public fun getPluginOptions (Lorg/apache/maven/project/MavenProject;Lorg/apache/maven/plugin/MojoExecution;)Ljava/util/List; 10 | public final fun getSystem ()Lorg/apache/maven/repository/RepositorySystem; 11 | public fun isApplicable (Lorg/apache/maven/project/MavenProject;Lorg/apache/maven/plugin/MojoExecution;)Z 12 | public final fun setLogger (Lorg/codehaus/plexus/logging/Logger;)V 13 | public final fun setSystem (Lorg/apache/maven/repository/RepositorySystem;)V 14 | } 15 | 16 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/AsyncSeriesLoopHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public abstract class AsyncSeriesLoopHook, FInterceptor : Function<*>> : AsyncBaseHook("AsyncSeriesLoopHook") { 4 | override val interceptors: LoopInterceptors = LoopInterceptors() 5 | 6 | protected suspend fun call( 7 | invokeTap: suspend (F, HookContext) -> LoopResult, 8 | invokeInterceptor: suspend (FInterceptor, HookContext) -> Unit 9 | ) { 10 | val context = setup(invokeTap, runTapInterceptors = false) 11 | 12 | do { 13 | interceptors.invokeTapInterceptors(taps, context) 14 | interceptors.loop.forEach { interceptor -> 15 | invokeInterceptor(interceptor, context) 16 | } 17 | 18 | val restartFound = taps.find { tapInfo -> 19 | val result = invokeTap(tapInfo.f, context) 20 | result == LoopResult.Restart 21 | } 22 | } while (restartFound != null) 23 | } 24 | 25 | public fun interceptLoop(f: FInterceptor) { 26 | interceptors.addLoopInterceptor(f) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // 1. Apply Orchid plugin 2 | plugins { 3 | alias(libs.plugins.orchid) 4 | alias(libs.plugins.knit) 5 | alias(libs.plugins.ksp) 6 | } 7 | 8 | // 2. Include Orchid dependencies 9 | dependencies { 10 | orchidImplementation(libs.orchid.core) 11 | orchidImplementation(libs.orchid.copper) 12 | orchidRuntimeOnly(libs.bundles.orchid.plugins) 13 | 14 | testImplementation(platform(libs.junit.bom)) 15 | testImplementation(libs.bundles.testing) 16 | 17 | // generated test dependencies 18 | testImplementation(libs.kotlin.stdlib) 19 | testImplementation(libs.kotlin.coroutines.core) 20 | testImplementation(libs.knit.testing) 21 | testImplementation(project(":hooks")) 22 | testImplementation(project(":processor")) 23 | ksp(project(":processor")) 24 | } 25 | 26 | // 4. Use the 'Editorial' theme, and set the URL it will have on Github Pages 27 | orchid { 28 | githubToken = System.getenv("GH_TOKEN") 29 | } 30 | 31 | kotlin { 32 | sourceSets.test { 33 | kotlin.srcDir("build/generated/ksp/test/kotlin") 34 | } 35 | } 36 | 37 | tasks { 38 | test { 39 | dependsOn(knit) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-car-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from plugin-architecture.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleCar01 3 | 4 | import com.intuit.hooks.dsl.Hooks 5 | import com.intuit.hooks.Hook 6 | 7 | abstract class CarHooks : Hooks() { 8 | @Sync<() -> Unit> 9 | abstract val brake: Hook 10 | 11 | @Sync<(newSpeed: Int) -> Unit> 12 | abstract val accelerate: Hook 13 | } 14 | 15 | class Car { 16 | 17 | val hooks = CarHooksImpl() 18 | 19 | var speed: Int = 0 20 | set(value) { 21 | if (value < field) hooks.brake.call() 22 | 23 | field = value 24 | hooks.accelerate.call(value) 25 | } 26 | 27 | } 28 | 29 | fun main() { 30 | val car = Car() 31 | car.hooks.brake.tap("logging-brake-hook") { 32 | println("Turning on brake lights") 33 | } 34 | 35 | car.hooks.accelerate.tap("logging-accelerate-hook") { newSpeed -> 36 | println("Accelerating to $newSpeed") 37 | } 38 | car.speed = 30 39 | // accelerating to 30 40 | car.speed = 22 41 | // turning on brake lights 42 | // accelerating to 22 43 | } 44 | -------------------------------------------------------------------------------- /example-library/src/main/kotlin/com/intuit/hooks/example/library/generic/GenericHooks.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.library.generic 2 | 3 | import com.intuit.hooks.* 4 | import com.intuit.hooks.dsl.Hooks 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | 7 | internal abstract class GenericHooks : Hooks() { 8 | @Sync<(newSpeed: Int) -> Unit> abstract val sync: Hook 9 | @SyncBail<(Boolean) -> BailResult> abstract val syncBail: Hook 10 | @SyncLoop<(foo: Boolean) -> LoopResult> abstract val syncLoop: Hook 11 | @SyncWaterfall<(name: String) -> String> abstract val syncWaterfall: Hook 12 | @ExperimentalCoroutinesApi 13 | @AsyncParallelBail BailResult> abstract val asyncParallelBail: Hook 14 | @AsyncParallel Int> abstract val asyncParallel: Hook 15 | @AsyncSeries Int> abstract val asyncSeries: Hook 16 | @AsyncSeriesBail BailResult> abstract val asyncSeriesBail: Hook 17 | @AsyncSeriesLoop LoopResult> abstract val asyncSeriesLoop: Hook 18 | @AsyncSeriesWaterfall String> abstract val asyncSeriesWaterfall: Hook 19 | } 20 | -------------------------------------------------------------------------------- /example-library/src/main/kotlin/com/intuit/hooks/example/library/car/Car.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.library.car 2 | 3 | import com.intuit.hooks.AsyncSeriesWaterfallHook 4 | import com.intuit.hooks.Hook 5 | import com.intuit.hooks.SyncHook 6 | import com.intuit.hooks.dsl.HooksDsl 7 | 8 | public abstract class Location 9 | 10 | public class Route 11 | 12 | public class Car { 13 | 14 | public abstract class Hooks : HooksDsl() { 15 | 16 | @Sync<(newSpeed: Int) -> Unit> 17 | public abstract val accelerate: Hook 18 | 19 | @Sync<() -> Unit> 20 | public abstract val brake: Hook 21 | 22 | @AsyncSeriesWaterfall, source: Location, target: Location) -> List> 23 | public abstract val calculateRoutes: Hook 24 | } 25 | 26 | public val hooks: CarHooksImpl = CarHooksImpl() 27 | 28 | public var speed: Int = 0 29 | set(value) { 30 | field = value 31 | hooks.accelerate.call(value) 32 | } 33 | 34 | public suspend fun useNavigationSystem(source: Location, target: Location): List { 35 | return hooks.calculateRoutes.call(emptyList(), source, target) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/includes/footer.peb: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-dsl-01.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from README.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleDsl01 3 | 4 | import com.intuit.hooks.* 5 | import com.intuit.hooks.dsl.Hooks 6 | 7 | internal abstract class GenericHooks : Hooks() { 8 | @Sync<(newSpeed: Int) -> Unit> abstract val sync: Hook 9 | @SyncBail<(Boolean) -> BailResult> abstract val syncBail: Hook 10 | @SyncLoop<(foo: Boolean) -> LoopResult> abstract val syncLoop: Hook 11 | @SyncWaterfall<(name: String) -> String> abstract val syncWaterfall: Hook 12 | @AsyncParallelBail BailResult> abstract val asyncParallelBail: Hook 13 | @AsyncParallel Int> abstract val asyncParallel: Hook 14 | @AsyncSeries Int> abstract val asyncSeries: Hook 15 | @AsyncSeriesBail BailResult> abstract val asyncSeriesBail: Hook 16 | @AsyncSeriesLoop LoopResult> abstract val asyncSeriesLoop: Hook 17 | @AsyncSeriesWaterfall String> abstract val asyncSeriesWaterfall: Hook 18 | } 19 | 20 | fun main() { 21 | val hooks = GenericHooksImpl() 22 | hooks.sync.tap("LoggerPlugin") { newSpeed: Int -> 23 | println("newSpeed: $newSpeed") 24 | } 25 | hooks.sync.call(30) 26 | // newSpeed: 30 27 | } 28 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncParallelBailTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.runBlocking 6 | import org.junit.jupiter.api.Assertions 7 | import org.junit.jupiter.api.Test 8 | 9 | @ExperimentalCoroutinesApi 10 | class AsyncParallelBailTests { 11 | class AsyncParallelBailHook1 : AsyncParallelBailHook BailResult, R>() { 12 | suspend fun call(concurrency: Int, p1: T1) = super.call(concurrency) { f, context -> f(context, p1) } 13 | } 14 | 15 | @Test 16 | fun `bail cancels others`() = runBlocking { 17 | val h = AsyncParallelBailHook1() 18 | h.tap("should never complete") { _, _ -> 19 | delay(100000) 20 | println("didn't work") 21 | BailResult.Bail("shouldn't resolve here") 22 | } 23 | h.tap("shouldn't be canceled") { _, _ -> 24 | delay(1) 25 | println("should print this") 26 | BailResult.Continue() 27 | } 28 | h.tap("cancel others") { _, _ -> 29 | delay(10) 30 | println("canceling others") 31 | BailResult.Bail("foo") 32 | } 33 | 34 | val result = h.call(10, "Kian") 35 | Assertions.assertEquals("foo", result) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Open source projects are “living.” Contributions in the form of issues and pull requests are welcomed and encouraged. When you contribute, you explicitly say you are part of the community and abide by its Code of Conduct. 2 | 3 | # The Code 4 | 5 | At Intuit, we foster a kind, respectful, harassment-free cooperative community. Our open source community works to: 6 | 7 | - Be kind and respectful; 8 | - Act as a global community; 9 | - Conduct ourselves professionally. 10 | 11 | As members of this community, we will not tolerate behaviors including, but not limited to: 12 | 13 | - Violent threats or language; 14 | - Discriminatory or derogatory jokes or language; 15 | - Public or private harassment of any kind; 16 | - Other conduct considered inappropriate in a professional setting. 17 | 18 | ## Reporting Concerns 19 | 20 | If you see someone violating the Code of Conduct please email TechOpenSource@intuit.com 21 | 22 | ## Scope 23 | 24 | This code of conduct applies to: 25 | 26 | All repos and communities for Intuit-managed projects, whether or not the text is included in a Intuit-managed project’s repository; 27 | 28 | Individuals or teams representing projects in official capacity, such as via official social media channels or at in-person meetups. 29 | 30 | ## Attribution 31 | 32 | This Code of Conduct is partly inspired by and based on those of Amazon, CocoaPods, GitHub, Microsoft, thoughtbot, and on the Contributor Covenant version 1.4.1. 33 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/css/homepage.scss: -------------------------------------------------------------------------------- 1 | .notification code, .notification pre { 2 | background-color: transparent; 3 | } 4 | 5 | .is-gapless .column .notification { 6 | border-top-left-radius: 0; 7 | border-top-right-radius: 0; 8 | border-bottom-left-radius: 0; 9 | border-bottom-right-radius: 0; 10 | } 11 | 12 | .box { 13 | padding: 0; 14 | } 15 | 16 | header.column h2 { 17 | margin-bottom: 0; 18 | } 19 | 20 | @media screen and (max-width: 1087px) { 21 | html.has-spaced-navbar-fixed-top { 22 | padding-top: 2.25rem; 23 | } 24 | } 25 | 26 | @media screen and (max-width: 768px) { 27 | .is-4:first-child .notification { 28 | border-top-left-radius: 4px; 29 | border-top-right-radius: 4px; 30 | } 31 | 32 | .is-8:last-child .notification { 33 | border-bottom-left-radius: 4px; 34 | border-bottom-right-radius: 4px; 35 | } 36 | 37 | header.column .notification { 38 | padding-bottom: 0; 39 | } 40 | } 41 | 42 | @media screen and (min-width: 769px) { 43 | .column .notification, 44 | .column .image { 45 | height: 100%; 46 | } 47 | 48 | .is-4:first-child .notification { 49 | border-top-left-radius: 4px; 50 | } 51 | 52 | .is-8:nth-child(2) .notification { 53 | border-top-right-radius: 4px; 54 | } 55 | 56 | .is-4:nth-last-child(2) .notification { 57 | border-bottom-left-radius: 4px; 58 | } 59 | 60 | .is-8:last-child .notification { 61 | border-bottom-right-radius: 4px; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/example-car-02.kt: -------------------------------------------------------------------------------- 1 | // This file was automatically generated from plugin-architecture.md by Knit tool. Do not edit. 2 | package com.intuit.hooks.example.exampleCar02 3 | 4 | import com.intuit.hooks.dsl.Hooks 5 | import com.intuit.hooks.Hook 6 | 7 | abstract class CarHooks : Hooks() { 8 | @Sync<() -> Unit> 9 | abstract val brake: Hook 10 | 11 | @Sync<(newSpeed: Int) -> Unit> 12 | abstract val accelerate: Hook 13 | } 14 | 15 | class Car(vararg plugins: Plugin) { 16 | 17 | val hooks = CarHooksImpl() 18 | 19 | var speed: Int = 0 20 | set(value) { 21 | if (value < field) hooks.brake.call() 22 | 23 | field = value 24 | hooks.accelerate.call(value) 25 | } 26 | 27 | init { 28 | plugins.forEach { it.apply(this) } 29 | } 30 | 31 | interface Plugin { 32 | fun apply(car: Car) 33 | } 34 | } 35 | 36 | object CarLoggerPlugin : Car.Plugin { 37 | override fun apply(car: Car) { 38 | car.hooks.brake.tap("logging-brake-hook") { 39 | println("Turning on brake lights") 40 | } 41 | 42 | car.hooks.accelerate.tap("logging-accelerate-hook") { newSpeed -> 43 | println("Accelerating to $newSpeed") 44 | } 45 | } 46 | } 47 | 48 | fun main() { 49 | val car = Car(CarLoggerPlugin) 50 | car.speed = 30 51 | // accelerating to 30 52 | car.speed = 22 53 | // turning on brake lights 54 | // accelerating to 22 55 | } 56 | -------------------------------------------------------------------------------- /maven-plugin/src/main/kotlin/com/intuit/hooks/plugin/maven/HooksMavenPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.maven 2 | 3 | import com.dyescape.ksp.maven.KotlinSymbolProcessingMavenPluginExtension 4 | import org.apache.maven.plugin.MojoExecution 5 | import org.apache.maven.project.MavenProject 6 | import org.apache.maven.repository.RepositorySystem 7 | import org.codehaus.plexus.component.annotations.Component 8 | import org.codehaus.plexus.component.annotations.Requirement 9 | import org.codehaus.plexus.logging.Logger 10 | import org.jetbrains.kotlin.maven.KotlinMavenPluginExtension 11 | import org.jetbrains.kotlin.maven.PluginOption 12 | 13 | /** Slim wrapper of [KotlinSymbolProcessingMavenPluginExtension] to apply additional plugin params */ 14 | @Component(role = KotlinMavenPluginExtension::class, hint = "hooks") 15 | public class HooksMavenPlugin( 16 | private val delegate: KotlinSymbolProcessingMavenPluginExtension = KotlinSymbolProcessingMavenPluginExtension() 17 | ) : KotlinMavenPluginExtension by delegate { 18 | 19 | @Requirement 20 | public lateinit var system: RepositorySystem 21 | 22 | @Requirement 23 | public lateinit var logger: Logger 24 | 25 | override fun getPluginOptions(project: MavenProject, execution: MojoExecution): List { 26 | delegate.system = system 27 | val options = delegate.getPluginOptions(project, execution) 28 | // TODO: call into delegate to get gen params to combine with hooks specific params 29 | return options 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/SyncWaterfallHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import org.junit.jupiter.api.Assertions 4 | import org.junit.jupiter.api.Test 5 | 6 | class SyncWaterfallHookTests { 7 | class Hook1 : SyncWaterfallHook<(HookContext, T1) -> T1, T1>() { 8 | fun call(p1: T1) = super.call( 9 | p1, 10 | invokeTap = { f, acc, context -> f(context, acc) }, 11 | invokeInterceptor = { f, context -> f(context, p1) } 12 | ) 13 | } 14 | 15 | class Hook2 : SyncWaterfallHook<(HookContext, T1, T2) -> T1, T1>() { 16 | fun call(p1: T1, p2: T2) = super.call( 17 | p1, 18 | invokeTap = { f, acc, context -> f(context, acc, p2) }, 19 | invokeInterceptor = { f, context -> f(context, p1, p2) } 20 | ) 21 | } 22 | 23 | @Test 24 | fun `waterfall taps work`() { 25 | val h = Hook1() 26 | h.tap("continue") { _, x -> "$x David" } 27 | h.tap("continue again") { _, x -> "$x Jeremiah" } 28 | 29 | val result = h.call("Kian") 30 | Assertions.assertEquals("Kian David Jeremiah", result) 31 | } 32 | 33 | @Test 34 | fun `waterfall taps work with arity 2`() { 35 | val h = Hook2() 36 | h.tap("continue") { _, x, _ -> "$x David" } 37 | h.tap("continue again") { _, x, _ -> "$x Jeremiah" } 38 | 39 | val result = h.call("Kian", 3) 40 | Assertions.assertEquals("Kian David Jeremiah", result) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/SyncLoopHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | public enum class LoopResult { 4 | Restart, Continue; 5 | 6 | public companion object { 7 | public fun fromNullable(x: Any?): LoopResult = if (x == null) Continue else Restart 8 | } 9 | } 10 | 11 | public class LoopInterceptors, FInterceptor : Function<*>> : Interceptors() { 12 | public var loop: List = emptyList(); private set 13 | 14 | public fun addLoopInterceptor(f: FInterceptor) { 15 | loop = loop + f 16 | } 17 | } 18 | 19 | public abstract class SyncLoopHook, FInterceptor : Function<*>> : SyncBaseHook("SyncLoopHook") { 20 | override val interceptors: LoopInterceptors = LoopInterceptors() 21 | 22 | protected fun call( 23 | invokeTap: (F, HookContext) -> LoopResult, 24 | invokeInterceptor: (FInterceptor, HookContext) -> Unit 25 | ) { 26 | val context = setup(invokeTap, runTapInterceptors = false) 27 | 28 | do { 29 | interceptors.invokeTapInterceptors(taps, context) 30 | interceptors.loop.forEach { interceptor -> 31 | invokeInterceptor(interceptor, context) 32 | } 33 | 34 | val restartFound = taps.find { tapInfo -> 35 | val result = invokeTap(tapInfo.f, context) 36 | result == LoopResult.Restart 37 | } 38 | } while (restartFound != null) 39 | } 40 | 41 | public fun interceptLoop(f: FInterceptor) { 42 | interceptors.addLoopInterceptor(f) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: cimg/openjdk:15.0.0 6 | 7 | resource_class: large 8 | steps: 9 | - run: | 10 | curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash 11 | - checkout 12 | - run: ./gradlew dependencies 13 | - run: ./gradlew assemble 14 | - run: fossa analyze 15 | - run: ./gradlew build 16 | - run: 17 | name: Save test results 18 | command: | 19 | mkdir -p ~/test-results/junit/ 20 | find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/test-results/junit/ \; 21 | mkdir -p ~/test-results/jacoco/ 22 | find . -type f -regex ".*/build/jacoco/.*exec" -exec cp {} ~/test-results/jacoco/ \; 23 | when: always 24 | - store_test_results: 25 | path: ~/test-results 26 | - store_artifacts: 27 | path: ~/test-results 28 | - run: fossa test 29 | 30 | release: 31 | docker: 32 | - image: circleci/openjdk:8-jdk 33 | 34 | resource_class: large 35 | steps: 36 | - run: curl -vkL -o - https://github.com/intuit/auto/releases/download/v10.36.5/auto-linux.gz | gunzip > ~/auto 37 | - run: chmod a+x ~/auto 38 | - checkout 39 | - run: 40 | name: Setup Environment Variables 41 | command: | 42 | echo 'export PATH=$JAVA_HOME/bin:$PATH' >> $BASH_ENV 43 | - run: ~/auto shipit -vvv 44 | workflows: 45 | version: 2 46 | build: 47 | jobs: 48 | - build 49 | - release: 50 | requires: 51 | - build 52 | -------------------------------------------------------------------------------- /docs/src/orchid/kotlin/com/intuit/hooks/docs/HooksTheme.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.docs 2 | 3 | import com.eden.orchid.api.OrchidContext 4 | import com.eden.orchid.api.options.annotations.Option 5 | import com.eden.orchid.api.resources.resourcesource.DelegatingResourceSource 6 | import com.eden.orchid.api.resources.resourcesource.OrchidResourceSource 7 | import com.eden.orchid.api.resources.resourcesource.ThemeResourceSource 8 | import com.eden.orchid.api.theme.Theme 9 | import com.eden.orchid.api.theme.assets.AssetManagerDelegate 10 | import com.eden.orchid.api.theme.models.Social 11 | import com.eden.orchid.copper.CopperTheme 12 | import com.eden.orchid.utilities.OrchidUtils 13 | import javax.inject.Inject 14 | 15 | class HooksTheme @Inject constructor(context: OrchidContext) : Theme(context, "HooksTheme", OrchidUtils.DEFAULT_PRIORITY + 1) { 16 | 17 | private val delegateTheme = CopperTheme(context) 18 | 19 | @Option 20 | lateinit var social: Social 21 | 22 | override fun loadAssets(delegate: AssetManagerDelegate) { 23 | delegate.addCss("assets/css/bulma.min.css") 24 | delegate.addCss("assets/css/bulma-tooltip.css") 25 | delegate.addCss("assets/css/bulma-accordion.min.css") 26 | 27 | delegate.addJs("https://use.fontawesome.com/releases/v5.4.0/js/all.js").apply { defer = true } 28 | delegate.addJs("assets/js/bulma.js") 29 | delegate.addJs("assets/js/bulma-accordion.min.js") 30 | delegate.addJs("assets/js/bulma-tabs.js") 31 | } 32 | 33 | override fun getResourceSource(): OrchidResourceSource = 34 | DelegatingResourceSource( 35 | listOfNotNull(super.getResourceSource(), delegateTheme.resourceSource), 36 | emptyList(), 37 | priority, 38 | ThemeResourceSource 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/com/intuit/hooks/plugin/gradle/HooksGradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.gradle 2 | 3 | import org.gradle.api.Plugin 4 | import org.gradle.api.Project 5 | import org.gradle.api.plugins.JavaPlugin 6 | import org.gradle.api.tasks.SourceSetContainer 7 | import java.util.Properties 8 | 9 | /** Wrap KSP plugin and provide Gradle extension for Hooks processor options */ 10 | public class HooksGradlePlugin : Plugin { 11 | 12 | private val properties by lazy { 13 | Properties().apply { 14 | HooksGradlePlugin::class.java.classLoader.getResourceAsStream("com/intuit/hooks/plugin/gradle/version.properties").let(::load) 15 | } 16 | } 17 | 18 | private val hooksVersion by lazy { 19 | properties["version"] as String 20 | } 21 | 22 | private fun Project.addDependency(configuration: String, dependencyNotation: String) = configurations 23 | .getByName(configuration).dependencies.add( 24 | dependencies.create(dependencyNotation) 25 | ) 26 | 27 | override fun apply(project: Project): Unit = with(project) { 28 | extensions.create( 29 | "hooks", 30 | HooksGradleExtension::class.java 31 | ) 32 | 33 | if (!pluginManager.hasPlugin("com.google.devtools.ksp")) 34 | pluginManager.apply("com.google.devtools.ksp") 35 | 36 | addDependency("api", "com.intuit.hooks:hooks:$hooksVersion") 37 | addDependency("ksp", "com.intuit.hooks:processor:$hooksVersion") 38 | 39 | // TODO: Maybe apply to Kotlin plugin to be compatible with MPP 40 | plugins.withType(JavaPlugin::class.java) { _ -> 41 | val sourceSets = extensions.getByType(SourceSetContainer::class.java) 42 | sourceSets.forEach { 43 | it.java.srcDir(buildDir.resolve("generated/ksp/${it.name}/kotlin")) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/utils/Parallelism.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.utils 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.channels.Channel 5 | import kotlinx.coroutines.channels.consumeEach 6 | import kotlinx.coroutines.channels.produce 7 | import kotlinx.coroutines.coroutineScope 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.launch 11 | 12 | @ExperimentalCoroutinesApi 13 | internal object Parallelism { 14 | // TODO: should we just pull in Reactor here? 15 | 16 | fun Flow.parallelMap( 17 | parallelism: Int, 18 | transform: suspend (value: T) -> R 19 | ): Flow { 20 | require(parallelism > 0) { "Expected concurrency level greater than 0, but had $parallelism" } 21 | 22 | return flow { 23 | coroutineScope { 24 | val inputChannel = produce { 25 | collect { send(it) } 26 | close() 27 | } 28 | 29 | val outputChannel = Channel(capacity = parallelism) 30 | 31 | // Launch $concurrency workers that consume from 32 | // input channel (fan-out) and publish to output channel (fan-in) 33 | val workers = (1..parallelism).map { 34 | launch { 35 | for (item in inputChannel) { 36 | outputChannel.send(transform(item)) 37 | } 38 | } 39 | } 40 | 41 | // Wait for all workers to finish and close the output channel 42 | launch { 43 | workers.forEach { it.join() } 44 | outputChannel.close() 45 | } 46 | 47 | // consume from output channel and emit 48 | outputChannel.consumeEach { emit(it) } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/templates/includes/navbar.peb: -------------------------------------------------------------------------------- 1 | 48 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookPropertyValidations.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.ksp.validation 2 | 3 | import arrow.core.ValidatedNel 4 | import arrow.core.invalidNel 5 | import arrow.core.valid 6 | import arrow.core.zip 7 | import com.google.devtools.ksp.symbol.KSPropertyDeclaration 8 | import com.intuit.hooks.plugin.codegen.HookInfo 9 | import com.intuit.hooks.plugin.codegen.HookProperty 10 | 11 | internal fun HookProperty.validate( 12 | info: HookInfo, 13 | property: KSPropertyDeclaration, 14 | ): ValidatedNel = when (this) { 15 | is HookProperty.Bail -> valid() 16 | is HookProperty.Loop -> valid() 17 | is HookProperty.Async -> validate(info, property) 18 | is HookProperty.Waterfall -> validate(info, property) 19 | } 20 | 21 | private fun HookProperty.Async.validate( 22 | info: HookInfo, 23 | property: KSPropertyDeclaration, 24 | ): ValidatedNel = 25 | if (info.hookSignature.isSuspend) valid() 26 | else HookValidationError.AsyncHookWithoutSuspend(property).invalidNel() 27 | 28 | private fun HookProperty.Waterfall.validate( 29 | info: HookInfo, 30 | property: KSPropertyDeclaration, 31 | ): ValidatedNel = 32 | arity(info, property).zip( 33 | parameters(info, property), 34 | ) { _, _ -> this } 35 | 36 | private fun HookProperty.Waterfall.arity( 37 | info: HookInfo, 38 | property: KSPropertyDeclaration, 39 | ): ValidatedNel { 40 | return if (!info.zeroArity) valid() 41 | else HookValidationError.WaterfallMustHaveParameters(property).invalidNel() 42 | } 43 | 44 | private fun HookProperty.Waterfall.parameters( 45 | info: HookInfo, 46 | property: KSPropertyDeclaration, 47 | ): ValidatedNel { 48 | return if (info.hookSignature.returnType == info.params.firstOrNull()?.type) valid() 49 | else HookValidationError.WaterfallParameterTypeMustMatch(property).invalidNel() 50 | } 51 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "hooks", 3 | "projectOwner": "intuit", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "badgeTemplate": "-orange.svg\" alt=\"All Contributors\" />", 10 | "imageSize": 100, 11 | "commit": true, 12 | "commitConvention": "none", 13 | "contributors": [ 14 | { 15 | "login": "sugarmanz", 16 | "name": "Jeremiah Zucker", 17 | "avatar_url": "https://avatars1.githubusercontent.com/u/9255651?v=4", 18 | "profile": "http://www.jeremiahzucker.com", 19 | "contributions": [ 20 | "test", 21 | "code", 22 | "doc", 23 | "infra" 24 | ] 25 | }, 26 | { 27 | "login": "stabbylambda", 28 | "name": "David Stone", 29 | "avatar_url": "https://avatars3.githubusercontent.com/u/124668?v=4", 30 | "profile": "https://github.com/stabbylambda", 31 | "contributions": [ 32 | "doc", 33 | "test", 34 | "code" 35 | ] 36 | }, 37 | { 38 | "login": "hipstersmoothie", 39 | "name": "Andrew Lisowski", 40 | "avatar_url": "https://avatars.githubusercontent.com/u/1192452?v=4", 41 | "profile": "http://hipstersmoothie.com/", 42 | "contributions": [ 43 | "doc", 44 | "infra", 45 | "test", 46 | "code" 47 | ] 48 | }, 49 | { 50 | "login": "kharrop", 51 | "name": "Kelly Harrop", 52 | "avatar_url": "https://avatars.githubusercontent.com/u/24794756?v=4", 53 | "profile": "https://github.com/kharrop", 54 | "contributions": [ 55 | "design" 56 | ] 57 | }, 58 | { 59 | "login": "brocollie08", 60 | "name": "brocollie08", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/13474011?v=4", 62 | "profile": "https://github.com/brocollie08", 63 | "contributions": [ 64 | "test", 65 | "code" 66 | ] 67 | } 68 | ], 69 | "contributorsPerLine": 7 70 | } 71 | -------------------------------------------------------------------------------- /docs/src/test/kotlin/example/snippets.kt: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import com.intuit.hooks.AsyncSeriesHook 4 | import com.intuit.hooks.Hook 5 | import com.intuit.hooks.HookContext 6 | import com.intuit.hooks.SyncHook 7 | import com.intuit.hooks.dsl.Hooks 8 | import kotlinx.coroutines.runBlocking 9 | 10 | val hook_types = 11 | """ 12 | // START hook_types 13 | Basic, Waterfall, Bail, Loop 14 | // END hook_types 15 | """ 16 | 17 | fun typed() { 18 | // START extendable_api 19 | class MyHook : SyncHook<((HookContext, Int) -> Unit)>() { 20 | fun call(value: Int) = 21 | super.call { f, context -> f(context, value) } 22 | 23 | fun tap(name: String, f: ((Int) -> Unit)) = 24 | super.tap(name) { _, newValue -> f(newValue) } 25 | } 26 | 27 | val myHook = MyHook() 28 | // END extendable_api 29 | 30 | // START typed 31 | myHook.tap("logger") { _, newValue: Int -> 32 | println("newValue: $newValue") 33 | } 34 | myHook.call(30) 35 | // END typed 36 | 37 | // START asynchronous 38 | class SimpleAsyncHook : 39 | AsyncSeriesHook<(suspend (HookContext) -> Unit)>() { 40 | 41 | suspend fun call() = 42 | super.call { f, context -> f(context) } 43 | } 44 | 45 | val simple = SimpleAsyncHook() 46 | runBlocking { 47 | simple.tap("some-network-plugin") { 48 | // do network call 49 | } 50 | 51 | simple.call() 52 | } 53 | // END asynchronous 54 | } 55 | 56 | // START concise_dsl 57 | abstract class SomeHooks : Hooks() { 58 | @Sync<() -> Unit> 59 | abstract val syncHook: Hook 60 | } 61 | // END concise_dsl 62 | 63 | val processor = 64 | """ 65 | // START processor 66 | To make hooks easier to use 67 | // END processor 68 | """ 69 | 70 | val gradle_plugin = 71 | """ 72 | // START gradle_plugin 73 | plugins { 74 | id("com.intuit.hooks") 75 | } 76 | // END gradle_plugin 77 | """ 78 | 79 | val maven_plugin = 80 | """ 81 | // START maven_plugin 82 | <compilerPlugins> 83 | <plugin>hooks</plugin> 84 | </compilerPlugins> 85 | // END maven_plugin 86 | """ 87 | -------------------------------------------------------------------------------- /gradle-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.konan.file.File 2 | import org.jetbrains.kotlin.konan.properties.Properties 3 | import org.jetbrains.kotlin.konan.properties.saveToFile 4 | 5 | plugins { 6 | `java-gradle-plugin` 7 | alias(libs.plugins.gradle.publish) 8 | } 9 | 10 | gradlePlugin { 11 | plugins { 12 | create("HooksGradlePlugin") { 13 | id = "com.intuit.hooks" 14 | implementationClass = "com.intuit.hooks.plugin.gradle.HooksGradlePlugin" 15 | } 16 | } 17 | 18 | testSourceSets(sourceSets.test.get()) 19 | } 20 | 21 | pluginBundle { 22 | website = "https://intuit.github.io/hooks/" 23 | vcsUrl = "https://github.com/intuit/hooks" 24 | description = "Gradle wrapper of the Kotlin symbol processor companion to the Intuit hooks module" 25 | tags = listOf("plugins", "hooks") 26 | 27 | plugins { 28 | named("HooksGradlePlugin") { 29 | displayName = "Gradle Hooks plugin" 30 | } 31 | } 32 | } 33 | 34 | dependencies { 35 | implementation(libs.kotlin.stdlib) 36 | implementation(libs.ksp.gradle) 37 | 38 | testImplementation(platform(libs.junit.bom)) 39 | testImplementation(libs.bundles.testing) 40 | } 41 | 42 | kotlin { 43 | explicitApi() 44 | } 45 | 46 | tasks { 47 | val createProperties by creating { 48 | dependsOn(processResources) 49 | 50 | doLast { 51 | val properties = "$buildDir/resources/main/com/intuit/hooks/plugin/gradle" 52 | .let(::File) 53 | .apply(File::mkdirs) 54 | .child("version.properties") 55 | 56 | Properties().apply { 57 | set("version", project.version.toString()) 58 | }.saveToFile(properties) 59 | } 60 | } 61 | 62 | classes { 63 | dependsOn(createProperties) 64 | } 65 | 66 | test { 67 | // TODO: Testing migration required the deps to be pulled from somewhere 68 | // Would be nice if they could just use the local built JARs 69 | dependsOn(":hooks:publishToMavenLocal", ":processor:publishToMavenLocal") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/SyncLoopHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import io.mockk.every 4 | import io.mockk.mockk 5 | import io.mockk.verify 6 | import org.junit.jupiter.api.Assertions 7 | import org.junit.jupiter.api.Test 8 | 9 | fun HookContext.increment(key: String) = 10 | this.compute(key) { _, v -> if (v == null) 1 else (v as Int) + 1 } 11 | 12 | class SyncLoopHookTests { 13 | class LoopHook1 : SyncLoopHook<(HookContext, T1) -> LoopResult, (HookContext, T1) -> Unit>() { 14 | fun call(p1: T1) = super.call( 15 | invokeTap = { f, context -> f(context, p1) }, 16 | invokeInterceptor = { f, context -> f(context, p1) } 17 | ) 18 | } 19 | 20 | @Test 21 | fun `interceptLoop allows you to intercept on every loop`() { 22 | val h = LoopHook1() 23 | val interceptor = mockk<(HookContext, String) -> Unit>() 24 | every { interceptor.invoke(any(), any()) } returns Unit 25 | 26 | h.interceptLoop(interceptor) 27 | h.tap("increment foo") { context, _ -> 28 | val count = context.increment("foo") 29 | if (count == 10) LoopResult.Continue else LoopResult.Restart 30 | } 31 | 32 | h.call("foo") 33 | 34 | verify(exactly = 10) { interceptor.invoke(any(), any()) } 35 | } 36 | 37 | @Test 38 | fun `loop taps bail early`() { 39 | var incrementedA = 0 40 | var incrementedB = 0 41 | 42 | val h = LoopHook1() 43 | h.tap("increment foo") { context, _ -> 44 | incrementedA += 1 45 | context.increment("foo") 46 | LoopResult.fromNullable(null) 47 | } 48 | 49 | h.tap("bail if foo is 6") { context, _ -> 50 | if (context["foo"] == 6) LoopResult.Restart else LoopResult.Continue 51 | } 52 | 53 | h.tap("read foo") { context, _ -> 54 | incrementedB += 1 55 | if ((context["foo"] as Int) < 10) LoopResult.Restart else LoopResult.Continue 56 | } 57 | 58 | h.call("Kian") 59 | 60 | Assertions.assertEquals(1, incrementedA - incrementedB) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/codegen/HookInfo.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.codegen 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | 6 | internal data class HooksContainer( 7 | val name: String, 8 | val originalClassName: ClassName, 9 | val typeSpecKind: TypeSpec.Kind, 10 | val visibilityModifier: KModifier, 11 | val typeArguments: List, 12 | val hooks: List 13 | ) { 14 | val superclass get() = originalClassName.let { 15 | if (typeArguments.isNotEmpty()) { 16 | it.parameterizedBy(typeArguments) 17 | } else 18 | it 19 | } 20 | } 21 | 22 | internal data class HookSignature( 23 | val hookFunctionSignatureTypeText: String, 24 | val isSuspend: Boolean, 25 | val returnType: TypeName, 26 | val returnTypeType: TypeName?, 27 | val hookFunctionSignatureType: TypeName, 28 | ) { 29 | val nullableReturnTypeType: TypeName get() { 30 | requireNotNull(returnTypeType) 31 | return returnTypeType.copy(nullable = true) 32 | } 33 | override fun toString(): String = hookFunctionSignatureTypeText 34 | } 35 | 36 | internal class HookParameter( 37 | val name: String?, 38 | val type: TypeName, 39 | val position: Int, 40 | ) { 41 | val withType get() = "$withoutType: $type" 42 | val withoutType get() = name ?: "p$position" 43 | } 44 | 45 | internal data class HookInfo( 46 | val property: String, 47 | val hookType: HookType, 48 | val hookSignature: HookSignature, 49 | val params: List, 50 | val propertyVisibility: KModifier 51 | ) { 52 | val zeroArity = params.isEmpty() 53 | val isAsync = hookType.properties.contains(HookProperty.Async) 54 | } 55 | 56 | internal val HookInfo.paramsWithTypes get() = params.joinToString(transform = HookParameter::withType) 57 | internal val HookInfo.paramsWithoutTypes get() = params.joinToString(transform = HookParameter::withoutType) 58 | internal val HookInfo.superType get() = this.hookType.toString() 59 | internal val HookInfo.className get() = "${property.replaceFirstChar(Char::titlecase)}$superType" 60 | -------------------------------------------------------------------------------- /example-application/src/main/kotlin/com/intuit/hooks/example/application/Main.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.application 2 | 3 | import com.intuit.hooks.example.library.car.Car 4 | import com.intuit.hooks.example.library.car.Location 5 | import com.intuit.hooks.example.library.car.Route 6 | import kotlinx.coroutines.runBlocking 7 | 8 | sealed class Navigation { 9 | abstract suspend fun calculateRoutes(source: Location, target: Location): List 10 | } 11 | 12 | object GoogleMapsService : Navigation() { 13 | override suspend fun calculateRoutes(source: Location, target: Location) = emptyList() 14 | } 15 | 16 | object BingMapsService : Navigation() { 17 | override suspend fun calculateRoutes(source: Location, target: Location) = emptyList() 18 | } 19 | 20 | object CachedRoutesService : Navigation() { 21 | private val cachedRoutes = hashMapOf, List>() 22 | override suspend fun calculateRoutes(source: Location, target: Location) = cachedRoutes[source to target] ?: emptyList() 23 | 24 | fun cacheRoutes(source: Location, target: Location, routes: List) { 25 | cachedRoutes[source to target] = routes 26 | } 27 | } 28 | 29 | fun main() { 30 | 31 | val car = Car() 32 | 33 | car.hooks.brake.tap("WarningLampPlugin") { /** Should turn on warning lamps */ } 34 | car.hooks.accelerate.tap("LoggerPlugin") { newSpeed -> println("Accelerating to $newSpeed") } 35 | 36 | car.hooks.calculateRoutes.tap("GoogleMapsPlugin") { routesList, source, target -> 37 | routesList + GoogleMapsService.calculateRoutes(source, target) 38 | } 39 | 40 | car.hooks.calculateRoutes.tap("BingMapsPlugin") { routesList, source, target -> 41 | routesList + BingMapsService.calculateRoutes(source, target) 42 | } 43 | 44 | car.hooks.calculateRoutes.tap("CachedRoutesPlugin") { routesList, source, target -> 45 | routesList + CachedRoutesService.calculateRoutes(source, target) 46 | } 47 | 48 | val source = object : Location() {} 49 | val target = object : Location() {} 50 | 51 | val routes = runBlocking { 52 | car.useNavigationSystem(source, target) 53 | } 54 | println(routes) 55 | 56 | car.speed = 88 57 | } 58 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesLoopHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import io.mockk.* 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.runBlocking 6 | import org.junit.jupiter.api.Assertions 7 | import org.junit.jupiter.api.Test 8 | 9 | class AsyncSeriesLoopHookTests { 10 | class LoopHook1 : AsyncSeriesLoopHook LoopResult, suspend (HookContext, T1) -> Unit>() { 11 | suspend fun call(p1: T1) = super.call( 12 | invokeTap = { f, context -> f(context, p1) }, 13 | invokeInterceptor = { f, context -> f(context, p1) } 14 | ) 15 | } 16 | 17 | @Test 18 | fun `interceptLoop allows you to intercept on every loop`() = runBlocking { 19 | val h = LoopHook1() 20 | val interceptor = mockk Unit>() 21 | coEvery { interceptor.invoke(any(), any()) } returns Unit 22 | 23 | h.interceptLoop(interceptor) 24 | h.tap("increment foo") { context, _ -> 25 | delay(1) 26 | val count = context.increment("foo") 27 | if (count == 10) LoopResult.Continue else LoopResult.Restart 28 | } 29 | 30 | h.call("foo") 31 | 32 | coVerify(exactly = 10) { interceptor.invoke(any(), any()) } 33 | } 34 | 35 | @Test 36 | fun `loop taps bail early`() = runBlocking { 37 | var incrementedA = 0 38 | var incrementedB = 0 39 | 40 | val h = LoopHook1() 41 | h.tap("increment foo") { context, _ -> 42 | delay(1) 43 | incrementedA += 1 44 | context.increment("foo") 45 | LoopResult.fromNullable(null) 46 | } 47 | 48 | h.tap("bail if foo is 6") { context, _ -> 49 | delay(1) 50 | if (context["foo"] == 6) LoopResult.Restart else LoopResult.Continue 51 | } 52 | 53 | h.tap("read foo") { context, _ -> 54 | delay(1) 55 | incrementedB += 1 56 | if ((context["foo"] as Int) < 10) LoopResult.Restart else LoopResult.Continue 57 | } 58 | 59 | h.call("Kian") 60 | 61 | Assertions.assertEquals(1, incrementedA - incrementedB) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /hooks/README.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | 3 | > **Note** 4 | > 5 | > These instructions are for using the base hooks library by itself. Under most circumstances, it is advised to use the hooks processor with the DSL to limit the code overhead. However, it is still possible to utilize the hooks library directly if necessary. 6 | 7 | ### Installation 8 | 9 | Add dependency to your `build.gradle(.kts)`: 10 | 11 | 16 | 17 | ```kotlin 18 | implementation("com.intuit.hooks:hooks:$version") 19 | ``` 20 | 21 | 22 | 23 | ### Creating a hook 24 | 25 | Each type of hook is exposed as an abstract class that can be subclassed to create a hook. Generally, the only additional functionality required for a hook is a public, typed `call` and `tap` method. These methods will effectively serve as the public API for your hook. Additionally, each of the base classes require a type parameter that represents the function signature for the `tap` method. 26 | 27 | For example, consider a basic synchronous hook that doesn't take any parameters. This essentially could represent a simple eventing pub-sub model. 28 | 29 | 30 | 31 | 35 | 36 | ```kotlin 37 | class SimpleHook : SyncHook<(HookContext) -> Unit>() { 38 | fun call() = super.call { f, context -> f(context) } 39 | } 40 | ``` 41 | 42 | > Note here that the type parameter for `SyncHook` requires `HookContext` as the first parameter even though this use case doesn't merit a parameter. This is the case for all hooks, regardless of the hooks' arity. 43 | 44 | `SimpleHook` can then be used directly to `tap` and `call`: 45 | 46 | ```kotlin 47 | fun main() { 48 | val hook = SimpleHook() 49 | hook.tap("logging") { context -> 50 | println("my hook was called") 51 | } 52 | hook.call() 53 | } 54 | ``` 55 | 56 | 57 | 58 | > You can get the full code [here](https://github.com/intuit/hooks/tree/main/docs/src/test/kotlin/example/example-synchook-01.kt). 59 | 60 | We should expect the `tapped` function to be executed once the hook is `called`, which would print the following: 61 | 62 | ```text 63 | my hook was called 64 | ``` 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/homepage.peb: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Hooks' 3 | extraCss: 4 | - 'assets/css/homepage.scss' 5 | layout: 'homepage' 6 | --- 7 | 8 |
9 |
10 |
11 | Hooks logo 12 |
13 |
14 |
15 | 16 |
17 | 20 |
21 | 22 |
23 |
24 |
25 |

26 | Hooks represent "pluggable" points in a software model. They provide a mechanism for tapping into such points to get updates, or apply additional functionality to some typed object. Included in this project are: 27 |

28 |
29 |
30 |
31 | 32 | {% for section in data.homepageSections %} 33 |
34 |
35 |
36 |

{{ section.title }}

37 |
38 |
39 | 40 | {% if section.snippets is not empty %} 41 |
42 |

43 |         {%- for name in section.snippets -%}
44 |           {%- snippet snippetName=name -%}
45 |         {%- endfor -%}
46 |       
47 |
48 | {% else %} 49 |
50 |
51 |
52 | {% endif %} 53 | 54 | {% for snippet in section.tabs %} 55 |
56 |
57 | {{ snippet.title }} 58 |
59 |
60 |
61 |

62 |         {%- for name in snippet.snippets -%}
63 |           {%- snippet snippetName=name -%}
64 |         {%- endfor -%}
65 |       
66 |
67 | {% endfor %} 68 | 69 |
70 | {% endfor %} 71 | -------------------------------------------------------------------------------- /gradle-plugin/src/test/kotlin/HooksGradlePluginTest.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.testkit.runner.GradleRunner 2 | import org.gradle.testkit.runner.TaskOutcome 3 | import org.intellij.lang.annotations.Language 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Assertions.assertTrue 6 | import org.junit.jupiter.api.BeforeEach 7 | import org.junit.jupiter.api.Test 8 | import org.junit.jupiter.api.assertDoesNotThrow 9 | import org.junit.jupiter.api.io.TempDir 10 | import java.io.File 11 | 12 | private fun File.appendKotlin(@Language("kotlin") content: String, trimIndent: Boolean = true) { 13 | require(extension.matches(Regex("kt(s)?"))) 14 | appendText("\n${if (trimIndent) content.trimIndent() else content}\n") 15 | } 16 | 17 | class HooksGradlePluginTest { 18 | 19 | @TempDir lateinit var workingDir: File 20 | 21 | lateinit var buildFile: File 22 | 23 | @BeforeEach fun setup() { 24 | buildFile = workingDir.resolve("build.gradle.kts").apply(File::createNewFile) 25 | buildFile.appendKotlin( 26 | """ 27 | repositories { 28 | mavenLocal() 29 | mavenCentral() 30 | } 31 | 32 | plugins { 33 | kotlin("jvm") 34 | id("com.intuit.hooks") 35 | } 36 | """ 37 | ) 38 | } 39 | 40 | @Test fun `can apply plugin`() { 41 | buildFile.appendKotlin( 42 | """ 43 | hooks {} 44 | """ 45 | ) 46 | 47 | assertDoesNotThrow { 48 | GradleRunner.create() 49 | .withProjectDir(workingDir) 50 | .withPluginClasspath() 51 | .build() 52 | } 53 | } 54 | 55 | @Test fun `can code gen`() { 56 | val testHooks = workingDir.resolve("src/main/kotlin/TestHooks.kt").apply { 57 | parentFile.mkdirs() 58 | createNewFile() 59 | } 60 | testHooks.appendKotlin( 61 | """ 62 | import com.intuit.hooks.* 63 | import com.intuit.hooks.dsl.Hooks 64 | 65 | internal abstract class TestHooks : Hooks() { 66 | @Sync<(String) -> Unit> 67 | abstract val testSyncHook: Hook 68 | } 69 | """ 70 | ) 71 | 72 | val runner = GradleRunner.create() 73 | .withProjectDir(workingDir) 74 | .withArguments("build") 75 | .withPluginClasspath() 76 | .forwardOutput() 77 | .build() 78 | 79 | assertEquals(TaskOutcome.SUCCESS, runner.task(":kspKotlin")?.outcome) 80 | assertTrue(workingDir.resolve("build/generated/ksp/main/kotlin/TestHooksHooks.kt").exists()) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /maven-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Maven Kotlin Plugin Extension 2 | 3 | > **Warning** 4 | > 5 | > The Maven Kotlin plugin automatically bundles a specific version of the KSP plugin, which is tied to a specific version of Kotlin (can be found [here](./settings.gradle.kts#19)). This means the Gradle plugin is only compatible with projects that use that specific Kotlin version. At some point, this module will be upgraded to publish in accordance to the KSP/Kotlin version it bundles. 6 | 7 | At the moment, the Maven extension is not complete and only helps to register the KSP plugin and partially configure the generated source directory. You will still be required to add the appropriate dependencies and add the generated source directory to your source sets. 8 | 9 | ### Installation 10 | 11 | ```xml 12 | 13 | 14 | latest version 15 | 16 | 17 | 18 | 19 | 20 | com.intuit.hooks 21 | hooks 22 | ${hooks.version} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | org.jetbrains.kotlin 32 | kotlin-maven-plugin 33 | ${kotlin.version} 34 | 35 | 36 | compile 37 | compile 38 | 39 | compile 40 | 41 | 42 | 43 | 44 | 45 | 46 | hooks 47 | 48 | 49 | 50 | ${project.basedir}/src/main/kotlin 51 | 52 | 53 | 54 | 55 | 56 | com.intuit.hooks 57 | maven-plugin 58 | ${hooks.version} 59 | 60 | 61 | 62 | 63 | 64 | 65 | ``` -------------------------------------------------------------------------------- /processor/src/test/kotlin/com/intuit/hooks/plugin/KotlinCompilation.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin 2 | 3 | import com.intuit.hooks.plugin.ksp.HooksProcessor 4 | import com.tschuchort.compiletesting.* 5 | import org.jetbrains.kotlin.konan.properties.suffix 6 | import org.jetbrains.kotlin.util.removeSuffixIfPresent 7 | import org.junit.jupiter.api.Assertions 8 | import java.io.File 9 | import java.lang.reflect.InvocationTargetException 10 | 11 | val KotlinCompilation.kspGeneratedSources get() = 12 | kspSourcesDir.walkTopDown().filter(File::isFile).toList() 13 | 14 | /** Assert that all [sources] were generated by the KSP processor */ 15 | fun KotlinCompilation.assertKspGeneratedSources(vararg sources: String) { 16 | sources.map { 17 | kspSourcesDir.resolve("kotlin").resolve( 18 | it.removeSuffixIfPresent(".kt").replace(".", "/").suffix("kt") 19 | ) 20 | }.forEach { 21 | Assertions.assertTrue(kspGeneratedSources.contains(it)) { "KSP processing did not generate file: $it" } 22 | } 23 | } 24 | 25 | /** Run patternized assertions from compiled classpath */ 26 | fun KotlinCompilation.Result.runCompiledAssertions(className: String = "AssertionsKt") { 27 | classLoader.loadClass(className).declaredMethods.forEach { 28 | it.isAccessible = true 29 | try { 30 | it.invoke(null) 31 | } catch (exception: InvocationTargetException) { 32 | Assertions.fail("Compiled assertion failed: ${it.name}", exception.targetException) 33 | } 34 | } 35 | } 36 | 37 | fun KotlinCompilation.Result.assertOk() = assertExitCode(KotlinCompilation.ExitCode.OK) 38 | fun KotlinCompilation.Result.assertCompilationError() = assertExitCode(KotlinCompilation.ExitCode.COMPILATION_ERROR) 39 | fun KotlinCompilation.Result.assertExitCode(code: KotlinCompilation.ExitCode) { 40 | Assertions.assertEquals(code, exitCode) 41 | } 42 | 43 | fun KotlinCompilation.Result.assertContainsMessages(vararg messages: String) { 44 | messages.forEach { 45 | Assertions.assertTrue(this.messages.contains(it)) { 46 | "Compilation result messages did not include: $it" 47 | } 48 | } 49 | } 50 | 51 | fun KotlinCompilation.Result.assertNoKspErrors() { 52 | Assertions.assertFalse(this.messages.contains("e: [ksp]")) { 53 | "Compilation result had a KSP error" 54 | } 55 | } 56 | 57 | /** Perform compilation on the [sources], utilizing the default configuration for the [HooksProcessor], if not explicitly configured */ 58 | fun compile( 59 | vararg sources: SourceFile, 60 | block: KotlinCompilation.() -> Unit = { 61 | symbolProcessorProviders = listOf(HooksProcessor.Provider()) 62 | inheritClassPath = true 63 | kspWithCompilation = true 64 | } 65 | ): Pair = KotlinCompilation().apply { 66 | this.sources = sources.toList() 67 | block() 68 | }.let { it to it.compile() } 69 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/HookValidations.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.ksp.validation 2 | 3 | import arrow.core.* 4 | import com.google.devtools.ksp.symbol.* 5 | import com.intuit.hooks.plugin.codegen.HookInfo 6 | import com.intuit.hooks.plugin.ksp.text 7 | import com.squareup.kotlinpoet.ksp.TypeParameterResolver 8 | 9 | // TODO: It'd be nice if the validations were codegen framework agnostic 10 | internal sealed class HookValidationError(val message: String, val symbol: KSNode) { 11 | class AsyncHookWithoutSuspend(symbol: KSNode) : HookValidationError("Async hooks must be defined with a suspend function signature", symbol) 12 | class WaterfallMustHaveParameters(symbol: KSNode) : HookValidationError("Waterfall hooks must take at least one parameter", symbol) 13 | class WaterfallParameterTypeMustMatch(symbol: KSNode) : HookValidationError("Waterfall hooks must specify the same types for the first parameter and the return type", symbol) 14 | class MustBeHookTypeSignature(annotation: HookAnnotation) : HookValidationError("$annotation property requires a hook type signature", annotation.symbol) 15 | class NoCodeGenerator(annotation: HookAnnotation) : HookValidationError("This hook plugin has no code generator for $annotation", annotation.symbol) 16 | class NoHookDslAnnotations(property: KSPropertyDeclaration) : HookValidationError("Hook property must be annotated with a DSL annotation", property) 17 | class TooManyHookDslAnnotations(annotations: List, property: KSPropertyDeclaration) : HookValidationError("This hook has more than a single hook DSL annotation: $annotations", property) 18 | class UnsupportedAbstractPropertyType(property: KSPropertyDeclaration) : HookValidationError("Abstract property type (${property.type.text}) not supported. Hook properties must be of type com.intuit.hooks.Hook", property) 19 | class NotAnAbstractProperty(property: KSPropertyDeclaration) : HookValidationError("Hooks can only be abstract properties", property) 20 | } 21 | 22 | /** main entrypoint for validating [KSPropertyDeclaration]s as valid annotated hook members */ 23 | internal fun validateProperty(property: KSPropertyDeclaration, parentResolver: TypeParameterResolver): ValidatedNel = with(property) { 24 | // validate property has the correct type 25 | validateHookType() 26 | .andThen { validateHookAnnotation(parentResolver) } 27 | // validate property against hook info with specific hook type validations 28 | .andThen { info -> validateHookProperties(info) } 29 | } 30 | 31 | private fun KSPropertyDeclaration.validateHookType(): ValidatedNel = 32 | if (type.text == "Hook") type.valid() 33 | else HookValidationError.UnsupportedAbstractPropertyType(this).invalidNel() 34 | 35 | private fun KSPropertyDeclaration.validateHookProperties(hookInfo: HookInfo) = 36 | hookInfo.hookType.properties.map { it.validate(hookInfo, this) } 37 | .sequence() 38 | .map { hookInfo } 39 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/SyncBailHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import io.mockk.every 4 | import io.mockk.mockk 5 | import io.mockk.verify 6 | import org.junit.jupiter.api.Assertions 7 | import org.junit.jupiter.api.Test 8 | 9 | class SyncBailHookTests { 10 | class Hook1 : SyncBailHook<(HookContext, T1) -> BailResult, R>() { 11 | fun call(p1: T1, default: ((HookContext, T1) -> R)? = null) = super.call( 12 | { f, context -> f(context, p1) }, 13 | default?.let { 14 | { context -> default(context, p1) } 15 | } 16 | ) 17 | 18 | fun call(p1: T1, default: ((T1) -> R)) = call(p1) { _, arg1 -> 19 | default.invoke(arg1) 20 | } 21 | } 22 | 23 | @Test 24 | fun `null bail taps work`() { 25 | val calledA = mockk<(HookContext, String) -> BailResult>() 26 | val calledB = mockk<(HookContext, String) -> BailResult>() 27 | 28 | every { calledA.invoke(any(), any()) } returns BailResult.Continue() 29 | every { calledB.invoke(any(), any()) } returns BailResult.Continue() 30 | 31 | val h = Hook1() 32 | h.tap("continue", calledA) 33 | h.tap("continue again", calledB) 34 | val result = h.call("Kian") 35 | 36 | Assertions.assertNull(result) 37 | 38 | verify(exactly = 1) { 39 | calledA.invoke(any(), any()) 40 | calledB.invoke(any(), any()) 41 | } 42 | } 43 | 44 | @Test 45 | fun `bail taps bail early`() { 46 | val h = Hook1() 47 | h.tap("bail") { _, _ -> BailResult.Bail("bail now") } 48 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 49 | 50 | val result = h.call("Kian") 51 | Assertions.assertEquals("bail now", result) 52 | } 53 | 54 | @Test 55 | fun `bail taps can bail without return value`() { 56 | val h = Hook1() 57 | h.tap("continue") { _, _ -> BailResult.Continue() } 58 | h.tap("bail") { _, _ -> BailResult.Bail(Unit) } 59 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 60 | 61 | Assertions.assertEquals(Unit, h.call("David")) 62 | } 63 | 64 | @Test 65 | fun `bail call with default handler invokes without taps bailing`() { 66 | val h = Hook1() 67 | h.tap("continue") { _, _ -> BailResult.Continue() } 68 | h.tap("continue again") { _, _ -> BailResult.Continue() } 69 | 70 | val result = h.call("David") { _, str -> 71 | str 72 | } 73 | 74 | Assertions.assertEquals("David", result) 75 | } 76 | 77 | @Test 78 | fun `bail call with default handler does not invoke with bail`() { 79 | val h = Hook1() 80 | h.tap("continue") { _, _ -> BailResult.Continue() } 81 | h.tap("bail") { _, _ -> BailResult.Bail("bailing") } 82 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 83 | 84 | val result = h.call("David") { str -> str } 85 | 86 | Assertions.assertEquals("bailing", result) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/AsyncSeriesBailHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import io.mockk.* 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.jupiter.api.Assertions 6 | import org.junit.jupiter.api.Test 7 | 8 | class AsyncSeriesBailHookTests { 9 | class Hook1 : AsyncSeriesBailHook BailResult, R>() { 10 | suspend fun call(p1: T1, default: (suspend (HookContext, T1) -> R)? = null): R? = super.call( 11 | { f, context -> f(context, p1) }, 12 | default?.let { 13 | { context -> default(context, p1) } 14 | } 15 | ) 16 | 17 | suspend fun call(p1: T1, default: (suspend (T1) -> R)) = call(p1) { _, arg1 -> 18 | default.invoke(arg1) 19 | } 20 | } 21 | 22 | @Test 23 | fun `null bail taps work`() = runBlocking { 24 | val calledA = mockk BailResult>() 25 | val calledB = mockk BailResult>() 26 | 27 | coEvery { calledA.invoke(any(), any()) } returns BailResult.Continue() 28 | coEvery { calledB.invoke(any(), any()) } returns BailResult.Continue() 29 | 30 | val h = Hook1() 31 | h.tap("continue", calledA) 32 | h.tap("continue again", calledB) 33 | val result = h.call("Kian") 34 | 35 | Assertions.assertNull(result) 36 | coVerify(exactly = 1) { 37 | calledA.invoke(any(), any()) 38 | calledB.invoke(any(), any()) 39 | } 40 | } 41 | 42 | @Test 43 | fun `bail taps bail early`() = runBlocking { 44 | val h = Hook1() 45 | h.tap("bail") { _, _ -> BailResult.Bail("bail now") } 46 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 47 | 48 | val result = h.call("Kian") 49 | Assertions.assertEquals("bail now", result) 50 | } 51 | 52 | @Test 53 | fun `bail taps can bail without return value`() = runBlocking { 54 | val h = Hook1() 55 | h.tap("continue") { _, _ -> BailResult.Continue() } 56 | h.tap("bail") { _, _ -> BailResult.Bail(Unit) } 57 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 58 | 59 | Assertions.assertEquals(Unit, h.call("David")) 60 | } 61 | 62 | @Test 63 | fun `bail call with default handler invokes without taps bailing`() = runBlocking { 64 | val h = Hook1() 65 | h.tap("continue") { _, _ -> BailResult.Continue() } 66 | h.tap("continue again") { _, _ -> BailResult.Continue() } 67 | 68 | val result = h.call("David") { _, str -> 69 | str 70 | } 71 | 72 | Assertions.assertEquals("David", result) 73 | } 74 | 75 | @Test 76 | fun `bail call with default handler does not invoke with bail`() = runBlocking { 77 | val h = Hook1() 78 | h.tap("continue") { _, _ -> BailResult.Continue() } 79 | h.tap("bail") { _, _ -> BailResult.Bail("bailing") } 80 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 81 | 82 | val result = h.call("David") { str -> str } 83 | 84 | Assertions.assertEquals("bailing", result) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/dsl/Hooks.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.dsl 2 | 3 | import com.intuit.hooks.* 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | 6 | private const val DEPRECATION_MESSAGE = "The migration to KSP requires DSL markers to be done outside of expression code." 7 | private inline fun stub(): Nothing = throw NotImplementedError("Compiler stub called!") 8 | 9 | public abstract class Hooks { 10 | // TODO: Make protected? 11 | protected annotation class Sync> 12 | 13 | @Deprecated( 14 | DEPRECATION_MESSAGE, 15 | ReplaceWith("@Hooks.Sync"), 16 | DeprecationLevel.ERROR, 17 | ) 18 | protected fun > syncHook(): SyncHook<*> = stub() 19 | 20 | protected annotation class SyncBail> 21 | 22 | @Deprecated( 23 | DEPRECATION_MESSAGE, 24 | ReplaceWith("@Hooks.SyncBail"), 25 | DeprecationLevel.ERROR, 26 | ) 27 | protected fun >> syncBailHook(): SyncBailHook<*, *> = stub() 28 | 29 | protected annotation class SyncWaterfall> 30 | 31 | @Deprecated( 32 | DEPRECATION_MESSAGE, 33 | ReplaceWith("@Hooks.SyncWaterfall"), 34 | DeprecationLevel.ERROR, 35 | ) 36 | protected fun > syncWaterfallHook(): SyncWaterfallHook<*, *> = stub() 37 | 38 | protected annotation class SyncLoop> 39 | 40 | @Deprecated( 41 | DEPRECATION_MESSAGE, 42 | ReplaceWith("@Hooks.SyncLoop"), 43 | DeprecationLevel.ERROR, 44 | ) 45 | protected fun > syncLoopHook(): SyncLoopHook<*, *> = stub() 46 | 47 | protected annotation class AsyncParallel> 48 | 49 | @Deprecated( 50 | DEPRECATION_MESSAGE, 51 | ReplaceWith("@Hooks.AsyncParallel"), 52 | DeprecationLevel.ERROR, 53 | ) 54 | protected fun > asyncParallelHook(): AsyncParallelHook<*> = stub() 55 | 56 | protected annotation class AsyncParallelBail> 57 | 58 | @Deprecated( 59 | DEPRECATION_MESSAGE, 60 | ReplaceWith("@Hooks.AsyncParallelBail"), 61 | DeprecationLevel.ERROR, 62 | ) 63 | @ExperimentalCoroutinesApi protected fun >> asyncParallelBailHook(): AsyncParallelBailHook<*, *> = stub() 64 | 65 | protected annotation class AsyncSeries> 66 | 67 | @Deprecated( 68 | DEPRECATION_MESSAGE, 69 | ReplaceWith("@Hooks.AsyncSeries"), 70 | DeprecationLevel.ERROR, 71 | ) 72 | protected fun > asyncSeriesHook(): AsyncSeriesHook<*> = stub() 73 | 74 | protected annotation class AsyncSeriesBail> 75 | 76 | @Deprecated( 77 | DEPRECATION_MESSAGE, 78 | ReplaceWith("@Hooks.AsyncSeriesBail"), 79 | DeprecationLevel.ERROR, 80 | ) 81 | protected fun >> asyncSeriesBailHook(): AsyncSeriesBailHook<*, *> = stub() 82 | 83 | protected annotation class AsyncSeriesWaterfall> 84 | 85 | @Deprecated( 86 | DEPRECATION_MESSAGE, 87 | ReplaceWith("@Hooks.AsyncSeriesWaterfall"), 88 | DeprecationLevel.ERROR, 89 | ) 90 | protected fun > asyncSeriesWaterfallHook(): AsyncSeriesWaterfallHook<*, *> = stub() 91 | 92 | protected annotation class AsyncSeriesLoop> 93 | 94 | @Deprecated( 95 | DEPRECATION_MESSAGE, 96 | ReplaceWith("@Hooks.AsyncSeriesLoop"), 97 | DeprecationLevel.ERROR, 98 | ) 99 | protected fun > asyncSeriesLoopHook(): AsyncSeriesLoopHook<*, *> = stub() 100 | } 101 | 102 | public typealias HooksDsl = Hooks 103 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/config.yml: -------------------------------------------------------------------------------- 1 | site: 2 | baseUrl: 'https://intuit.github.io/hooks/' 3 | theme: 'HooksTheme' 4 | about: 5 | siteName: Hooks 6 | siteDescription: Hooks is a little module for plugins, in Kotlin. 7 | 8 | theme: 9 | primaryColor: '#0077c5' 10 | social: 11 | other: 12 | - label: 'GitHub' 13 | icon: 'fa-github' 14 | link: 'https://github.com/intuit/hooks/' 15 | buttonColor: 'dark' 16 | menu: 17 | - type: 'page' 18 | title: 'Home' 19 | itemId: 'home' 20 | icon: 'home' 21 | - type: 'wiki' 22 | icon: 'graduation-cap' 23 | asSubmenu: true 24 | submenuTitle: 'User Guide' 25 | 26 | - type: 'submenu' 27 | title: 'API' 28 | icon: 'book' 29 | menu: 30 | - type: 'sourcedocModules' 31 | moduleType: 'kotlindoc' 32 | moduleGroup: 'library' 33 | - type: 'separator' 34 | - type: 'sourcedocModules' 35 | moduleType: 'kotlindoc' 36 | moduleGroup: 'plugins' 37 | 38 | - type: 'submenu' 39 | title: 'Information' 40 | icon: 'info-circle' 41 | menu: 42 | # - type: 'page' 43 | # title: 'About Hooks' 44 | # itemId: 'About Hooks' 45 | - type: 'page' 46 | itemId: 'Changelog' 47 | - type: 'page' 48 | itemId: 'License' 49 | 50 | metaComponents: 51 | - type: 'orchidSearch' 52 | extraCss: [ 'assets/css/orchidSearch.scss' ] 53 | 54 | changelog: 55 | adapter: 56 | type: 'file' 57 | baseDir: './../../../..' 58 | versionRegex: '^[\u0023]{1,2}[\s|v]*(\S*?)\s*?(?:\((.*?)\))?$' 59 | includeMinorVersions: true 60 | includeReleaseNotes: true 61 | 62 | kotlindoc: 63 | homePagePermalink: 'modules/:module' 64 | sourcePagePermalink: ':moduleType/:module/:sourceDocPath' 65 | modules: 66 | - name: 'Hooks Library' 67 | slug: 'hooks' 68 | moduleGroup: 'library' 69 | sourceDirs: 70 | - './../../../../hooks/src/main/kotlin' 71 | showRunnerLogs: true 72 | relatedModules: [ 'processor' ] 73 | 74 | - name: 'Hooks Processor' 75 | slug: 'processor' 76 | moduleGroup: 'plugins' 77 | sourceDirs: 78 | - './../../../../processor/src/main/kotlin' 79 | homePageOnly: true 80 | 81 | - name: 'Gradle Plugin' 82 | slug: 'gradle-plugin' 83 | moduleGroup: 'plugins' 84 | sourceDirs: 85 | - './../../../../gradle-plugin/src/main/kotlin' 86 | homePageOnly: true 87 | 88 | - name: 'Maven Kotlin Extension' 89 | slug: 'maven-plugin' 90 | moduleGroup: 'plugins' 91 | sourceDirs: 92 | - './../../../../maven-plugin/src/main/kotlin' 93 | homePageOnly: true 94 | 95 | services: 96 | publications: 97 | stages: 98 | - type: 'githubPages' 99 | username: 'sugarmanz' 100 | repo: 'intuit/hooks' 101 | 102 | allPages: 103 | metaComponents: 104 | - type: 'prism' 105 | theme: 'tomorrow' 106 | languages: 107 | - 'java' 108 | - 'kotlin' 109 | - 'groovy' 110 | - 'yaml' 111 | - 'markup' 112 | extraCss: [ 'assets/css/prismFixes.scss' ] 113 | 114 | snippets: 115 | sections: 116 | - tags: ['code_snippets'] 117 | adapter: 118 | type: 'embedded' 119 | baseDirs: 120 | - './../../../../hooks/src/' 121 | - './../../../../processor/src' 122 | - './../../../../gradle-plugin/src' 123 | - './../../../../maven-plugin/src' 124 | - './../../../../example-library/src' 125 | - './../../../../example-application/src' 126 | - './../../../../docs/src' 127 | recursive: true 128 | startPattern: '^.*?//.*?START(.+?)$' 129 | endPattern: '^.*?//.*?END(.+?)$' 130 | patternNameGroup: 1 -------------------------------------------------------------------------------- /hooks/src/test/kotlin/com/intuit/hooks/SyncHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import org.junit.jupiter.api.Assertions 4 | import org.junit.jupiter.api.Test 5 | 6 | class SyncHookTests { 7 | 8 | class Hook0 : SyncHook<(HookContext) -> R>() { 9 | fun call() = super.call { f, context -> f(context) } 10 | } 11 | 12 | class Hook1 : SyncHook<(HookContext, T1) -> R>() { 13 | fun call(p1: T1) = super.call { f, context -> f(context, p1) } 14 | } 15 | 16 | class Hook2 : SyncHook<(HookContext, T1, T2) -> R>() { 17 | fun call(p1: T1, p2: T2) = super.call { f, context -> f(context, p1, p2) } 18 | } 19 | 20 | @Test 21 | fun `register interceptors`() { 22 | val h = Hook1() 23 | h.interceptRegister { info -> 24 | val keepTap = if (info.name == "bad") null else info 25 | println("Register - ${info.name} - ${keepTap != null}") 26 | keepTap 27 | } 28 | 29 | h.interceptCall { context, _ -> context.putIfAbsent("intercept1", true) } 30 | h.interceptCall { context, _ -> context.putIfAbsent("intercept2", true) } 31 | 32 | h.interceptTap { context, info -> 33 | context.increment("count") 34 | println("Tap - ${info.name}") 35 | } 36 | 37 | h.tap("bad") { _, x -> println("Bad! $x") } 38 | h.tap("hi") { _, x -> println("Hi! $x") } 39 | h.tap("bye") { _, x -> println("Bye! $x") } 40 | h.tap("what was in the context?!") { context, _ -> println("count: $context") } 41 | 42 | h.call("Kian") 43 | } 44 | 45 | @Test 46 | fun `register interceptors with arity 2`() { 47 | val h = Hook2() 48 | h.interceptRegister { info -> 49 | val keepTap = if (info.name == "bad") null else info 50 | println("Register - ${info.name} - ${keepTap != null}") 51 | keepTap 52 | } 53 | 54 | h.interceptCall { context, _, _ -> context.putIfAbsent("intercept1", true) } 55 | h.interceptCall { context, _, _ -> context.putIfAbsent("intercept2", true) } 56 | 57 | h.interceptTap { context, info -> 58 | context.increment("count") 59 | println("Tap - ${info.name}") 60 | } 61 | 62 | h.tap("bad") { _, x, y -> println("Bad! $x $y") } 63 | h.tap("hi") { _, x, y -> println("Hi! $x $y") } 64 | h.tap("bye") { _, x, y -> println("Bye! $x $y") } 65 | h.tap("what was in the context?!") { context, _, _ -> println("count: $context") } 66 | 67 | h.call("Kian", "Jeremiah") 68 | } 69 | 70 | @Test 71 | fun `can untap`() { 72 | val output = mutableListOf() 73 | val hook = Hook0() 74 | 75 | hook.tap("first") { 76 | output.add(1) 77 | } 78 | val tap2 = hook.tap("second") { 79 | output.add(2) 80 | }!! 81 | hook.tap("third") { 82 | output.add(3) 83 | } 84 | 85 | hook.call() 86 | hook.untap(tap2) 87 | hook.call() 88 | 89 | Assertions.assertEquals(listOf(1, 2, 3, 1, 3), output) 90 | } 91 | 92 | @Test 93 | fun `can override when specifying an id`() { 94 | val output = mutableListOf() 95 | val hook = Hook0() 96 | 97 | hook.tap("first") { 98 | output.add(1) 99 | } 100 | val tap2 = hook.tap("second") { 101 | output.add(2) 102 | }!! 103 | hook.tap("third") { 104 | output.add(3) 105 | } 106 | 107 | hook.call() 108 | 109 | hook.tap("second", tap2) { 110 | output.add(4) 111 | } 112 | 113 | hook.call() 114 | 115 | Assertions.assertEquals(listOf(1, 2, 3, 1, 3, 4), output) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /processor/README.md: -------------------------------------------------------------------------------- 1 | # Hooks Processor 2 | 3 | Built on the [Kotlin Symbol Processing API](https://kotlinlang.org/docs/ksp-overview.html#0), the Hooks processor enables consumers to create hooks using the type-driven DSL provided in the hooks library. KSP based processors are generally easy to apply to Gradle projects using the provided [plugin](https://kotlinlang.org/docs/ksp-quickstart.html#use-your-own-processor-in-a-project). However, we also have Gradle and Maven wrappers to ease the burden of KSP configuration. If you'd like to use outside those build systems, the processor is published as `com.intuit.hooks:processor:$version`. If you do find yourself using the processor directly, we would love to hear about your use case and would appreciate any contributions to support other build systems. 4 | 5 | ### Manual Gradle KSP configuration 6 | 7 | 10 | 11 | ```kotlin 12 | // build.gradle(.kts) 13 | plugins { 14 | id("com.google.devtools.ksp") version KSP_VERSION // >= 1.0.5 15 | } 16 | 17 | dependencies { 18 | ksp("com.intuit.hooks", "processor", HOOKS_VERSION) 19 | } 20 | ``` 21 | 22 | 25 | 26 | 27 | 28 | ### Processor DSL 29 | 30 | With the processor configured in your project, you can now create hooks by defining a `Hooks` subclass. This gives you access to a collection of methods to create hook implementations based on the type signature passed into the method. 31 | 32 | 33 | 34 | 38 | 39 | ```kotlin 40 | internal abstract class GenericHooks : Hooks() { 41 | @Sync<(newSpeed: Int) -> Unit> abstract val sync: Hook 42 | @SyncBail<(Boolean) -> BailResult> abstract val syncBail: Hook 43 | @SyncLoop<(foo: Boolean) -> LoopResult> abstract val syncLoop: Hook 44 | @SyncWaterfall<(name: String) -> String> abstract val syncWaterfall: Hook 45 | @AsyncParallelBail BailResult> abstract val asyncParallelBail: Hook 46 | @AsyncParallel Int> abstract val asyncParallel: Hook 47 | @AsyncSeries Int> abstract val asyncSeries: Hook 48 | @AsyncSeriesBail BailResult> abstract val asyncSeriesBail: Hook 49 | @AsyncSeriesLoop LoopResult> abstract val asyncSeriesLoop: Hook 50 | @AsyncSeriesWaterfall String> abstract val asyncSeriesWaterfall: Hook 51 | } 52 | ``` 53 | 54 | The processor uses this class to create new hook implementations and instances. Currently, the processor generates a new class that subclasses `GenericHooks` and overrides the member properties, which is why they need to be `abstract`. This is important to note, as otherwise, the code will not compile because the member property is final. When using the hooks DSL, you must follow these constraints: 55 | 56 | 1. `Hooks` subclass _must_ be abstract 57 | 2. All member properties that use the hooks DSL methods _must_ be abstract 58 | 3. Hook property types can include star projection, but should be the same hook type 59 | 4. Any `async` hook must take a `suspend` typed method 60 | 5. Bail hooks must return a `BailResult` 61 | 6. Loop hooks must return a `LoopResult` 62 | 63 | Most of these constraints should give you an error when the processor runs, however others might result in a generic compiler error, like stated above. 64 | 65 | The generated class name will be `${name}Impl`, thus the snippet above could be used in the following manner: 66 | 67 | ```kotlin 68 | fun main() { 69 | val hooks = GenericHooksImpl() 70 | hooks.sync.tap("LoggerPlugin") { newSpeed: Int -> 71 | println("newSpeed: $newSpeed") 72 | } 73 | hooks.sync.call(30) 74 | // newSpeed: 30 75 | } 76 | ``` 77 | 78 | 79 | 80 | 83 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_multi-white-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_single-dark-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_multi-flat-dark-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_multi-flat-white-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/wiki/plugin-architecture.md: -------------------------------------------------------------------------------- 1 | # Plugin Architecture 2 | 3 | Hooks can be used adhoc, but the advantage become clearer when paired with a plugin architecture. Plugins are a really simple concept to understand. Essentially, some construct has some basic functionality that can be extended. Plugins are accepted by the construct to extend said functionality. Let's take a look at a simple example. 4 | 5 | ## Simple example 6 | 7 | Cars come with many features that can vary heavily depending on the make, model, trim, etc. Imagine a *very* simple car that has two features, braking and accelerating, however this car can come with different hardware peripherals, so it is hard to contain this logic within the base Car construct. This could be represented with hooks: 8 | 9 | 13 | 14 | ```kotlin 15 | abstract class CarHooks : Hooks() { 16 | @Sync<() -> Unit> 17 | abstract val brake: Hook 18 | 19 | @Sync<(newSpeed: Int) -> Unit> 20 | abstract val accelerate: Hook 21 | } 22 | 23 | ``` 24 | 25 | For simplicity's sake, say the car API exposes a `speed` API to change the speed: 26 | ```kotlin 27 | class Car { 28 | 29 | val hooks = CarHooksImpl() 30 | 31 | var speed: Int = 0 32 | set(value) { 33 | if (value < field) hooks.brake.call() 34 | 35 | field = value 36 | hooks.accelerate.call(value) 37 | } 38 | 39 | } 40 | ``` 41 | 42 | This essentially encapsulates the _core_ logic within the `Car` class, but delegates to the hook tappers to provide the actual implementation for braking and accelerating, with respect to the actual hardware or anything else that needs to respond to braking or accelerating. 43 | 44 | ```kotlin 45 | fun main() { 46 | val car = Car() 47 | car.hooks.brake.tap("logging-brake-hook") { 48 | println("Turning on brake lights") 49 | } 50 | 51 | car.hooks.accelerate.tap("logging-accelerate-hook") { newSpeed -> 52 | println("Accelerating to $newSpeed") 53 | } 54 | car.speed = 30 55 | // accelerating to 30 56 | car.speed = 22 57 | // turning on brake lights 58 | // accelerating to 22 59 | } 60 | ``` 61 | 62 | 63 | 64 | In the snippet above, loggers were tapped to each hook from the `car` reference. However, this does not ensure a good pattern for separation of logic because all tapped logic is contained where the `Car` was instantiated. Instead, we should organize this logic into various plugins that can be registered to the `Car` when its created. First, we modify the `Car` class to accept and handle plugins during instantiation. 65 | 66 | 78 | 79 | ```kotlin 80 | class Car(vararg plugins: Plugin) { 81 | 82 | val hooks = CarHooksImpl() 83 | 84 | var speed: Int = 0 85 | set(value) { 86 | if (value < field) hooks.brake.call() 87 | 88 | field = value 89 | hooks.accelerate.call(value) 90 | } 91 | 92 | init { 93 | plugins.forEach { it.apply(this) } 94 | } 95 | 96 | interface Plugin { 97 | fun apply(car: Car) 98 | } 99 | } 100 | ``` 101 | 102 | Now that we have an interface for a `Car.Plugin`, we can move the logger taps to its own class (`object` in this case because plugins *can* be idempotent): 103 | 104 | ```kotlin 105 | object CarLoggerPlugin : Car.Plugin { 106 | override fun apply(car: Car) { 107 | car.hooks.brake.tap("logging-brake-hook") { 108 | println("Turning on brake lights") 109 | } 110 | 111 | car.hooks.accelerate.tap("logging-accelerate-hook") { newSpeed -> 112 | println("Accelerating to $newSpeed") 113 | } 114 | } 115 | } 116 | ``` 117 | 118 | Then, just instantiate the `Car` with whatever plugins are desired: 119 | 120 | ```kotlin 121 | fun main() { 122 | val car = Car(CarLoggerPlugin) 123 | car.speed = 30 124 | // accelerating to 30 125 | car.speed = 22 126 | // turning on brake lights 127 | // accelerating to 22 128 | } 129 | ``` 130 | 131 | 132 | 133 | > You can get the full code [here](https://github.com/intuit/hooks/tree/main/docs/src/test/kotlin/example/example-car-02.kt). 134 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/validation/AnnotationValidations.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.ksp.validation 2 | 3 | import arrow.core.* 4 | import com.google.devtools.ksp.getVisibility 5 | import com.google.devtools.ksp.symbol.* 6 | import com.intuit.hooks.plugin.codegen.HookInfo 7 | import com.intuit.hooks.plugin.codegen.HookParameter 8 | import com.intuit.hooks.plugin.codegen.HookSignature 9 | import com.intuit.hooks.plugin.codegen.HookType 10 | import com.intuit.hooks.plugin.codegen.HookType.Companion.annotationDslMarkers 11 | import com.intuit.hooks.plugin.ksp.HooksProcessor 12 | import com.intuit.hooks.plugin.ksp.text 13 | import com.squareup.kotlinpoet.KModifier 14 | import com.squareup.kotlinpoet.ksp.TypeParameterResolver 15 | import com.squareup.kotlinpoet.ksp.toKModifier 16 | import com.squareup.kotlinpoet.ksp.toTypeName 17 | 18 | /** Wrapper for [KSAnnotation] when we're sure that the annotation is a hook annotation */ 19 | @JvmInline internal value class HookAnnotation(val symbol: KSAnnotation) { 20 | val hookFunctionSignatureType get() = symbol.annotationType.element?.typeArguments?.single()?.type 21 | ?: throw HooksProcessor.Exception("Could not determine hook function signature type for $symbol") 22 | 23 | val hookFunctionSignatureReference get() = hookFunctionSignatureType.element as? KSCallableReference 24 | ?: throw HooksProcessor.Exception("Hook type argument must be a function for $symbol") 25 | 26 | val type get() = toString().let(HookType::valueOf) 27 | 28 | override fun toString() = "${symbol.shortName.asString()}Hook" 29 | } 30 | 31 | /** Build [HookInfo] from the validated [HookAnnotation] found on the [property] */ 32 | internal fun KSPropertyDeclaration.validateHookAnnotation(parentResolver: TypeParameterResolver): ValidatedNel = 33 | onlyHasASingleDslAnnotation().andThen { annotation -> 34 | 35 | val hasCodeGenerator = hasCodeGenerator(annotation) 36 | val mustBeHookType = mustBeHookType(annotation, parentResolver) 37 | val validateParameters = validateParameters(annotation, parentResolver) 38 | val hookMember = simpleName.asString() 39 | val propertyVisibility = this.getVisibility().toKModifier() ?: KModifier.PUBLIC 40 | 41 | hasCodeGenerator.zip( 42 | mustBeHookType, 43 | validateParameters 44 | ) { hookType: HookType, hookSignature: HookSignature, hookParameters: List -> 45 | HookInfo(hookMember, hookType, hookSignature, hookParameters, propertyVisibility) 46 | } 47 | } 48 | 49 | private fun KSPropertyDeclaration.onlyHasASingleDslAnnotation(): ValidatedNel { 50 | val annotations = annotations.filter { it.shortName.asString() in annotationDslMarkers }.toList() 51 | return when (annotations.size) { 52 | 0 -> HookValidationError.NoHookDslAnnotations(this).invalidNel() 53 | 1 -> annotations.single().let(::HookAnnotation).valid() 54 | else -> HookValidationError.TooManyHookDslAnnotations(annotations, this).invalidNel() 55 | } 56 | } 57 | 58 | private fun validateParameters(annotation: HookAnnotation, parentResolver: TypeParameterResolver): ValidatedNel> = try { 59 | annotation.hookFunctionSignatureReference.functionParameters.mapIndexed { index: Int, parameter: KSValueParameter -> 60 | val name = parameter.name?.asString() 61 | val type = parameter.type.toTypeName(parentResolver) 62 | HookParameter(name, type, index) 63 | }.valid() 64 | } catch (exception: Exception) { 65 | HookValidationError.MustBeHookTypeSignature(annotation).invalidNel() 66 | } 67 | 68 | private fun hasCodeGenerator(annotation: HookAnnotation): ValidatedNel = try { 69 | annotation.type.valid() 70 | } catch (e: Exception) { 71 | HookValidationError.NoCodeGenerator(annotation).invalidNel() 72 | } 73 | 74 | private fun mustBeHookType(annotation: HookAnnotation, parentResolver: TypeParameterResolver): ValidatedNel = try { 75 | val isSuspend: Boolean = annotation.hookFunctionSignatureType.modifiers.contains(Modifier.SUSPEND) 76 | // I'm leaving this here because KSP knows that it's (String) -> Int, whereas once it gets to Poet, it's just kotlin.Function1 77 | val text = annotation.hookFunctionSignatureType.text 78 | val hookFunctionSignatureType = annotation.hookFunctionSignatureType.toTypeName(parentResolver) 79 | val returnType = annotation.hookFunctionSignatureReference.returnType.toTypeName(parentResolver) 80 | val returnTypeType = annotation.hookFunctionSignatureReference.returnType.element?.typeArguments?.firstOrNull()?.toTypeName(parentResolver) 81 | 82 | HookSignature( 83 | text, 84 | isSuspend, 85 | returnType, 86 | returnTypeType, 87 | hookFunctionSignatureType 88 | ).valid() 89 | } catch (exception: Exception) { 90 | HookValidationError.MustBeHookTypeSignature(annotation).invalidNel() 91 | } 92 | -------------------------------------------------------------------------------- /hooks/src/main/kotlin/com/intuit/hooks/BaseHook.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks 2 | 3 | import java.util.* 4 | import kotlin.collections.HashMap 5 | 6 | public typealias HookContext = HashMap 7 | 8 | public open class Interceptors> { 9 | public var register: List<(TapInfo) -> TapInfo?> = emptyList(); private set 10 | public var tap: List<(HookContext, TapInfo) -> Unit> = emptyList(); private set 11 | public var call: List = emptyList(); private set 12 | 13 | public fun addRegisterInterceptor(interceptor: (TapInfo) -> TapInfo?) { 14 | register = register + interceptor 15 | } 16 | 17 | public fun invokeRegisterInterceptors(info: TapInfo?): TapInfo? = register.fold(info) { acc, interceptor -> 18 | acc?.let(interceptor) 19 | } 20 | 21 | public fun addTapInterceptor(interceptor: (HookContext, TapInfo) -> Unit) { 22 | tap = tap + interceptor 23 | } 24 | 25 | public fun invokeTapInterceptors(taps: List>, context: HookContext): Unit = tap.forEach { interceptor -> 26 | taps.forEach { tap -> 27 | interceptor.invoke(context, tap) 28 | } 29 | } 30 | 31 | public fun addCallInterceptor(interceptor: F) { 32 | call = call + interceptor 33 | } 34 | } 35 | 36 | public data class TapInfo> internal constructor( 37 | public val name: String, 38 | public val id: String, 39 | public val type: String, 40 | public val f: FWithContext, 41 | // val stage: Int, // todo: maybe this should be forEachIndexed? 42 | // before?: string | Array // todo: do we even really need this? 43 | ) 44 | 45 | public abstract class AsyncBaseHook>(type: String) : BaseHook(type) { 46 | protected suspend fun setup(invokeCallInterceptor: suspend (F, HookContext) -> Any?, runTapInterceptors: Boolean = true): HookContext { 47 | val context: HookContext = hashMapOf() 48 | interceptors.call.forEach { interceptor -> 49 | invokeCallInterceptor(interceptor, context) 50 | } 51 | 52 | if (runTapInterceptors) { 53 | interceptors.invokeTapInterceptors(taps, context) 54 | } 55 | return context 56 | } 57 | } 58 | 59 | public abstract class SyncBaseHook>(type: String) : BaseHook(type) { 60 | protected fun setup(invokeWithContext: (F, HookContext) -> Any?, runTapInterceptors: Boolean = true): HookContext { 61 | val context: HookContext = hashMapOf() 62 | interceptors.call.forEach { interceptor -> 63 | invokeWithContext(interceptor, context) 64 | } 65 | 66 | if (runTapInterceptors) { 67 | interceptors.invokeTapInterceptors(taps, context) 68 | } 69 | return context 70 | } 71 | } 72 | 73 | public sealed class Hook 74 | 75 | public abstract class BaseHook>(private val type: String) : Hook() { 76 | protected var taps: List> = emptyList(); private set 77 | protected open val interceptors: Interceptors = Interceptors() 78 | 79 | /** 80 | * Tap the hook with [f]. 81 | * 82 | * @param name human-readable identifier to make debugging easier 83 | * 84 | * @return an auto generated identifier token that can be used to [untap], null if tap was 85 | * rejected by any of the register interceptors. 86 | */ 87 | public fun tap(name: String, f: F): String? = tap(name, generateRandomId(), f) 88 | 89 | /** 90 | * Tap the hook with [f]. 91 | * 92 | * @param name human-readable identifier to make debugging easier 93 | * @param id identifier token to register the [f] callback with. If another tap exists with 94 | * the same [id], it will be overridden, which essentially shortcuts an [untap] call. 95 | * 96 | * @return identifier token that can be used to [untap], null if tap was rejected by any of the 97 | * register interceptors. 98 | */ 99 | public fun tap(name: String, id: String, f: F): String? { 100 | untap(id) 101 | 102 | return TapInfo(name, id, type, f).let(interceptors::invokeRegisterInterceptors)?.also { 103 | taps = taps + it 104 | }?.id 105 | } 106 | 107 | /** Remove tapped callback associated with the [id] returned from [tap] */ 108 | public fun untap(id: String) { 109 | taps = taps.filter { 110 | it.id != id 111 | } 112 | } 113 | 114 | public fun interceptTap(f: (context: HookContext, tapInfo: TapInfo) -> Unit) { 115 | interceptors.addTapInterceptor(f) 116 | } 117 | 118 | public fun interceptCall(f: F) { 119 | interceptors.addCallInterceptor(f) 120 | } 121 | 122 | public fun interceptRegister(f: (TapInfo) -> TapInfo?) { 123 | interceptors.addRegisterInterceptor(f) 124 | } 125 | 126 | /** Method to generate a random identifier for managing [TapInfo]s */ 127 | protected open fun generateRandomId(): String = UUID.randomUUID().toString() 128 | } 129 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/wiki/key-concepts.md: -------------------------------------------------------------------------------- 1 | # Key Concepts 2 | 3 | ### Nomenclature 4 | 5 | To those new to this project, it might help to go over some keywords: 6 | 7 | * Hook - some functionality in a construct that can be extended 8 | * Tap - action taken by plugin to extend functionality 9 | * Call - action taken by hook owner to invoke plugins 10 | * Plugin - something that taps a hook 11 | 12 | > A **plugin** can **tap** into a **hook** to provide additional functionality 13 | 14 | ### Hooks 15 | 16 | The hooks library exposes a collection of different types of hooks that support different behavior. Each type of hook has some support for asynchronous evaluation through Kotlin coroutines. 17 | 18 | | Type | Behavior | Async Support | 19 | | ---- | -------- | ------------- | 20 | | **Basic** | Basic hooks simply calls every function it tapped in a row | `SERIES`, `PARALLEL` | 21 | | **Waterfall** | Waterfall hooks also call each tapped function in a row, however, it supports propagating return value from each function to the next function | `SERIES`, `PARALLEL` | 22 | | **Bail** | Bail hooks allow exiting early with a return value. When any of the tapped function bails, the bail hook will stop executing the remaining ones | `PARALLEL` | 23 | | **Loop** | When a plugin in a loop hook returns a non-undefined value the hook will restart from the first plugin. It will loop until all plugins return undefined. | `PARALLEL` | 24 | 25 | ### Untapping 26 | 27 | Hooks that are tapped return a unique ID that can be used to `untap` from a hook, effectively removing that `tap` from the hook. For convenience, this ID can be specified when tapping the hook to easily override if the callback needs to be updated. 28 | 29 | 32 | 33 | ```kotlin 34 | class SimpleHook : SyncHook<(HookContext) -> Unit>() { 35 | fun call() = super.call { f, context -> f(context) } 36 | } 37 | ``` 38 | 39 | 43 | 44 | ```kotlin 45 | val simpleHook = SimpleHook() 46 | val tap1 = simpleHook.tap("tap1") { 47 | println("doing something") 48 | }!! 49 | 50 | // to remove previously tapped function 51 | simpleHook.untap(tap1) 52 | // or to override previously tapped function 53 | simpleHook.tap("tap1", tap1) { 54 | println("doing something else") 55 | } 56 | ``` 57 | 58 | 61 | 62 | 63 | 64 | > With the register interceptors described below, calling `tap` is not guaranteed to actually tap the hook if the interceptor rejects it. In this case, the ID returned from `tap` will be `null`. 65 | 66 | ### Interceptors 67 | 68 | Every hook provides support to register interceptors for different events: 69 | 70 | | API | Description | 71 | | --- | ----------- | 72 | | `interceptCall` | Call interceptors will trigger when hooks are triggered and have access to the hook parameters, including the [`HookContext`](#hook-context) | 73 | | `interceptTap` | Tap interceptors will trigger for each tapped plugin when a the hook is called and have access to the corresponding [`TapInfo`](https://intuit.github.io/hooks/kotlindoc/hooks/com/intuit/hooks/tapinfo/) and the [`HookContext`](#hook-context) | 74 | | `interceptRegister` | Register interceptors will trigger when a plugin taps into a hook and have the opportunity to modify or remove the corresponding [`TapInfo`](https://intuit.github.io/hooks/kotlindoc/hooks/com/intuit/hooks/tapinfo/) | 75 | | `interceptLoop` | Loop interceptors share the same signature as call interceptors, but are only available for **Loop** hooks, and will be triggered each time the hook evaluation loops | 76 | 77 | ### Hook context 78 | 79 | Every plugin and some interceptors have access to a `HookContext`, which can be used to read or write arbitrary values for subsequent plugins and interceptors. 80 | 81 | 82 | 83 | 103 | 104 | ```kotlin 105 | car.hooks.accelerate.interceptTap { context, tapInfo -> 106 | println("${tapInfo.name} is doing it's job") 107 | context["hasMuffler"] = true 108 | } 109 | 110 | car.hooks.accelerate.tap("NoisePlugin") { context, newSpeed -> 111 | println(if (context["hasMuffler"] == true) "Silence..." else "Vroom!") 112 | } 113 | 114 | car.speed = 20 115 | // NoisePlugin is doing it's job 116 | // Silence... 117 | ``` 118 | 119 | 122 | 123 | > This snippet might make more sense with respect to the example laid out in [plugin architecture](../plugin-architecture). 124 | 125 | 126 | 127 | 131 | -------------------------------------------------------------------------------- /processor/src/test/kotlin/com/intuit/hooks/plugin/HookValidationErrors.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.intuit.hooks.plugin 3 | 4 | import com.tschuchort.compiletesting.SourceFile 5 | import org.junit.jupiter.api.Test 6 | 7 | class HookValidationErrors { 8 | 9 | @Test fun `abstract property type not supported`() { 10 | val testHooks = SourceFile.kotlin( 11 | "TestHooks.kt", 12 | """ 13 | import com.intuit.hooks.Hook 14 | import com.intuit.hooks.dsl.Hooks 15 | 16 | internal abstract class TestHooks : Hooks() { 17 | abstract val nonHookProperty: Int 18 | } 19 | """ 20 | ) 21 | 22 | val (_, result) = compile(testHooks) 23 | result.assertOk() 24 | result.assertContainsMessages("Abstract property type (Int) not supported") 25 | } 26 | 27 | @Test fun `hook property does not have any hook annotation`() { 28 | val testHooks = SourceFile.kotlin( 29 | "TestHooks.kt", 30 | """ 31 | import com.intuit.hooks.Hook 32 | import com.intuit.hooks.dsl.Hooks 33 | 34 | internal abstract class TestHooks : Hooks() { 35 | abstract val syncHook: Hook 36 | } 37 | """ 38 | ) 39 | 40 | val (_, result) = compile(testHooks) 41 | result.assertOk() 42 | result.assertContainsMessages("Hook property must be annotated with a DSL annotation") 43 | } 44 | 45 | @Test fun `hook property has too many hook annotations`() { 46 | val testHooks = SourceFile.kotlin( 47 | "TestHooks.kt", 48 | """ 49 | import com.intuit.hooks.BailResult 50 | import com.intuit.hooks.Hook 51 | import com.intuit.hooks.dsl.Hooks 52 | 53 | internal abstract class TestHooks : Hooks() { 54 | @Sync<() -> Unit> 55 | @SyncBail<() -> BailResult> 56 | abstract val syncHook: Hook 57 | } 58 | """ 59 | ) 60 | 61 | val (_, result) = compile(testHooks) 62 | result.assertOk() 63 | result.assertContainsMessages("This hook has more than a single hook DSL annotation: [@Sync, @SyncBail]") 64 | } 65 | 66 | @Test fun `async hooks must has suspend modifier`() { 67 | val testHooks = SourceFile.kotlin( 68 | "TestHooks.kt", 69 | """ 70 | import com.intuit.hooks.Hook 71 | import com.intuit.hooks.dsl.Hooks 72 | 73 | internal abstract class TestHooks : Hooks() { 74 | @AsyncSeries<() -> Unit> 75 | abstract val syncHook: Hook 76 | } 77 | """ 78 | ) 79 | 80 | val (_, result) = compile(testHooks) 81 | result.assertOk() 82 | result.assertContainsMessages("Async hooks must be defined with a suspend function signature") 83 | } 84 | 85 | @Test fun `waterfall hook must have at least one parameter`() { 86 | val testHooks = SourceFile.kotlin( 87 | "TestHooks.kt", 88 | """ 89 | import com.intuit.hooks.Hook 90 | import com.intuit.hooks.dsl.Hooks 91 | 92 | internal abstract class TestHooks : Hooks() { 93 | @SyncWaterfall<() -> String> 94 | abstract val syncHook: Hook 95 | } 96 | """ 97 | ) 98 | 99 | val (_, result) = compile(testHooks) 100 | result.assertOk() 101 | result.assertContainsMessages("Waterfall hooks must take at least one parameter") 102 | } 103 | 104 | @Test fun `waterfall hook must return the same type of the first parameter`() { 105 | val testHooks = SourceFile.kotlin( 106 | "TestHooks.kt", 107 | """ 108 | import com.intuit.hooks.Hook 109 | import com.intuit.hooks.dsl.Hooks 110 | 111 | internal abstract class TestHooks : Hooks() { 112 | @SyncWaterfall<(Int, Int) -> Unit> 113 | abstract val syncHook: Hook 114 | } 115 | """ 116 | ) 117 | 118 | val (_, result) = compile(testHooks) 119 | result.assertOk() 120 | result.assertContainsMessages("Waterfall hooks must specify the same types for the first parameter and the return type") 121 | } 122 | 123 | @Test fun `multiple validation errors report at the same time`() { 124 | val testHooks = SourceFile.kotlin( 125 | "TestHooks.kt", 126 | """ 127 | import com.intuit.hooks.Hook 128 | import com.intuit.hooks.dsl.Hooks 129 | 130 | internal abstract class TestHooks : Hooks() { 131 | @AsyncSeriesWaterfall<() -> String> 132 | abstract val realBad: Hook 133 | abstract val state: Int 134 | } 135 | """ 136 | ) 137 | 138 | val (_, result) = compile(testHooks) 139 | result.assertOk() 140 | result.assertContainsMessages( 141 | "Async hooks must be defined with a suspend function signature", 142 | "Waterfall hooks must take at least one parameter", 143 | "Waterfall hooks must specify the same types for the first parameter and the return type", 144 | "Abstract property type (Int) not supported", 145 | ) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "hooks-project" 2 | include( 3 | ":hooks", 4 | ":processor", 5 | ":gradle-plugin", 6 | ":maven-plugin", 7 | ":docs", 8 | ":example-library", 9 | ":example-application", 10 | ) 11 | enableFeaturePreview("ONE_LOCKFILE_PER_PROJECT") 12 | 13 | dependencyResolutionManagement { 14 | versionCatalogs { 15 | create("libs") { 16 | version("kotlin", "1.6.21") 17 | version("ktlint", "0.45.2") 18 | version("arrow", "1.1.2") 19 | version("ksp", "1.6.21-1.0.6") 20 | version("poet", "1.12.0") 21 | version("junit", "5.7.0") 22 | version("knit", "0.4.0") 23 | version("orchid", "0.21.1") 24 | 25 | plugin("kotlin.jvm", "org.jetbrains.kotlin.jvm").versionRef("kotlin") 26 | plugin("ksp", "com.google.devtools.ksp").versionRef("ksp") 27 | 28 | plugin("release", "net.researchgate.release").version("2.6.0") 29 | plugin("nexus", "io.github.gradle-nexus.publish-plugin").version("1.0.0") 30 | plugin("gradle.publish", "com.gradle.plugin-publish").version("0.13.0") 31 | 32 | plugin("ktlint", "org.jlleitschuh.gradle.ktlint").version("10.3.0") 33 | plugin("api", "org.jetbrains.kotlinx.binary-compatibility-validator").version("0.9.0") 34 | 35 | plugin("knit", "kotlinx-knit").versionRef("knit") 36 | plugin("dokka", "org.jetbrains.dokka").versionRef("kotlin") 37 | plugin("orchid", "com.eden.orchidPlugin").versionRef("orchid") 38 | 39 | // Kotlin 40 | library("kotlin.stdlib", "org.jetbrains.kotlin", "kotlin-stdlib").withoutVersion() 41 | library("kotlin.maven", "org.jetbrains.kotlin", "kotlin-maven-plugin").withoutVersion() 42 | library("kotlin.coroutines.core", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").version("1.6.1") 43 | 44 | // KSP 45 | library("ksp.spa", "com.google.devtools.ksp", "symbol-processing-api").versionRef("ksp") 46 | library("ksp.poet", "com.squareup", "kotlinpoet-ksp").versionRef("poet") 47 | library("ksp.gradle", "com.google.devtools.ksp", "com.google.devtools.ksp.gradle.plugin").versionRef("ksp") 48 | library("ksp.maven", "com.dyescape", "kotlin-maven-symbol-processing").version("1.3") 49 | library("ktlint.core", "com.pinterest.ktlint", "ktlint-core").versionRef("ktlint") 50 | library("ktlint.ruleset.standard", "com.pinterest.ktlint", "ktlint-ruleset-standard").versionRef("ktlint") 51 | 52 | // Arrow 53 | library("arrow.core", "io.arrow-kt", "arrow-core").versionRef("arrow") 54 | 55 | // Docs 56 | library("orchid.core", "io.github.javaeden.orchid", "OrchidCore").versionRef("orchid") 57 | library("orchid.copper", "io.github.javaeden.orchid", "OrchidCopper").versionRef("orchid") 58 | 59 | library("orchid.plugins.docs", "io.github.javaeden.orchid", "OrchidDocs").versionRef("orchid") 60 | library("orchid.plugins.kotlindoc", "io.github.javaeden.orchid", "OrchidKotlindoc").versionRef("orchid") 61 | library("orchid.plugins.plugindocs", "io.github.javaeden.orchid", "OrchidPluginDocs").versionRef("orchid") 62 | library("orchid.plugins.github", "io.github.javaeden.orchid", "OrchidGithub").versionRef("orchid") 63 | library("orchid.plugins.changelog", "io.github.javaeden.orchid", "OrchidChangelog").versionRef("orchid") 64 | library("orchid.plugins.syntaxHighlighter", "io.github.javaeden.orchid", "OrchidSyntaxHighlighter").versionRef("orchid") 65 | library("orchid.plugins.snippets", "io.github.javaeden.orchid", "OrchidSnippets").versionRef("orchid") 66 | library("orchid.plugins.copper", "io.github.javaeden.orchid", "OrchidCopper").versionRef("orchid") 67 | library("orchid.plugins.wiki", "io.github.javaeden.orchid", "OrchidWiki").versionRef("orchid") 68 | 69 | bundle( 70 | "orchid.plugins", 71 | listOf( 72 | "orchid.plugins.docs", 73 | "orchid.plugins.kotlindoc", 74 | "orchid.plugins.plugindocs", 75 | "orchid.plugins.github", 76 | "orchid.plugins.changelog", 77 | "orchid.plugins.syntaxHighlighter", 78 | "orchid.plugins.snippets", 79 | "orchid.plugins.copper", 80 | "orchid.plugins.wiki", 81 | ) 82 | ) 83 | 84 | // Testing 85 | // TODO: Swap to Kotlin testing library 86 | library("junit.bom", "org.junit", "junit-bom").version("5.7.0") 87 | library("junit.jupiter", "org.junit.jupiter", "junit-jupiter").withoutVersion() 88 | library("mockk", "io.mockk", "mockk").version("1.10.2") 89 | library("ksp.testing", "com.github.tschuchortdev", "kotlin-compile-testing-ksp").version("1.4.8") 90 | library("knit.testing", "org.jetbrains.kotlinx", "kotlinx-knit-test").versionRef("knit") 91 | 92 | bundle("testing", listOf("junit.jupiter", "mockk")) 93 | } 94 | } 95 | } 96 | 97 | pluginManagement { 98 | resolutionStrategy { 99 | eachPlugin { 100 | when (val id = requested.id.id) { 101 | "kotlinx-knit" -> useModule("org.jetbrains.kotlinx:$id:${requireNotNull(requested.version)}") 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_multi-3d-dark-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/src/orchid/resources/assets/media/hooks_multi-3d-white-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /processor/src/main/kotlin/com/intuit/hooks/plugin/ksp/HooksProcessor.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.plugin.ksp 2 | 3 | import arrow.core.* 4 | import arrow.typeclasses.Semigroup 5 | import com.google.devtools.ksp.getVisibility 6 | import com.google.devtools.ksp.processing.* 7 | import com.google.devtools.ksp.symbol.* 8 | import com.google.devtools.ksp.validate 9 | import com.google.devtools.ksp.visitor.KSDefaultVisitor 10 | import com.intuit.hooks.plugin.codegen.* 11 | import com.intuit.hooks.plugin.ksp.validation.HookValidationError 12 | import com.intuit.hooks.plugin.ksp.validation.validateProperty 13 | import com.squareup.kotlinpoet.* 14 | import com.squareup.kotlinpoet.ksp.* 15 | 16 | public class HooksProcessor( 17 | private val codeGenerator: CodeGenerator, 18 | private val logger: KSPLogger, 19 | ) : SymbolProcessor { 20 | 21 | override fun process(resolver: Resolver): List { 22 | resolver.getNewFiles().forEach { 23 | it.accept(HookFileVisitor(), Unit) 24 | } 25 | 26 | return emptyList() 27 | } 28 | 29 | private inner class HookPropertyVisitor : KSDefaultVisitor>() { 30 | override fun visitPropertyDeclaration(property: KSPropertyDeclaration, parentResolver: TypeParameterResolver): ValidatedNel { 31 | return if (property.modifiers.contains(Modifier.ABSTRACT)) 32 | validateProperty(property, parentResolver) 33 | else 34 | HookValidationError.NotAnAbstractProperty(property).invalidNel() 35 | } 36 | 37 | override fun defaultHandler(node: KSNode, data: TypeParameterResolver): ValidatedNel = 38 | TODO("Not yet implemented") 39 | } 40 | 41 | private inner class HookFileVisitor : KSVisitorVoid() { 42 | override fun visitFile(file: KSFile, data: Unit) { 43 | val hookContainers = file.declarations.filter { 44 | it is KSClassDeclaration 45 | }.flatMap { 46 | it.accept(HookContainerVisitor(), Unit) 47 | }.mapNotNull { v -> 48 | v.valueOr { errors -> 49 | errors.forEach { error -> logger.error(error.message, error.symbol) } 50 | null 51 | } 52 | }.toList() 53 | 54 | if (hookContainers.isEmpty()) return 55 | 56 | val packageName = file.packageName.asString() 57 | val name = file.fileName.split(".").first() 58 | 59 | generateFile(packageName, "${name}Hooks", hookContainers).writeTo(codeGenerator, aggregating = false, originatingKSFiles = listOf(file)) 60 | } 61 | } 62 | 63 | private inner class HookContainerVisitor : KSDefaultVisitor>>() { 64 | override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit): List> { 65 | val superTypeNames = classDeclaration.superTypes 66 | .filter { it.toString().contains("Hooks") } 67 | .toList() 68 | 69 | return if (superTypeNames.isEmpty()) { 70 | classDeclaration.declarations 71 | .filter { it is KSClassDeclaration && it.validate() } 72 | .flatMap { it.accept(this, Unit) } 73 | .toList() 74 | } else if (superTypeNames.any { it.resolve().declaration.qualifiedName?.getQualifier() == "com.intuit.hooks.dsl" }) { 75 | val parentResolver = classDeclaration.typeParameters.toTypeParameterResolver() 76 | 77 | classDeclaration.getAllProperties() 78 | .map { it.accept(HookPropertyVisitor(), parentResolver) } 79 | .sequence(Semigroup.nonEmptyList()) 80 | .map { hooks -> createHooksContainer(classDeclaration, hooks) } 81 | .let(::listOf) 82 | } else { 83 | emptyList() 84 | } 85 | } 86 | 87 | fun ClassKind.toTypeSpecKind(): TypeSpec.Kind = when (this) { 88 | ClassKind.CLASS -> TypeSpec.Kind.CLASS 89 | ClassKind.INTERFACE -> TypeSpec.Kind.INTERFACE 90 | ClassKind.OBJECT -> TypeSpec.Kind.OBJECT 91 | else -> throw NotImplementedError("Hooks in constructs other than class, interface, and object aren't supported") 92 | } 93 | 94 | fun createHooksContainer(classDeclaration: KSClassDeclaration, hooks: List): HooksContainer { 95 | val name = 96 | "${classDeclaration.parentDeclaration?.simpleName?.asString() ?: ""}${classDeclaration.simpleName.asString()}Impl" 97 | val visibilityModifier = classDeclaration.getVisibility().toKModifier() ?: KModifier.PUBLIC 98 | val typeArguments = classDeclaration.typeParameters.map { it.toTypeVariableName() } 99 | val className = classDeclaration.toClassName() 100 | val typeSpecKind = classDeclaration.classKind.toTypeSpecKind() 101 | 102 | return HooksContainer( 103 | name, 104 | className, 105 | typeSpecKind, 106 | visibilityModifier, 107 | typeArguments, 108 | hooks 109 | ) 110 | } 111 | 112 | override fun defaultHandler(node: KSNode, data: Unit): List> = 113 | TODO("Not yet implemented") 114 | } 115 | 116 | public class Provider : SymbolProcessorProvider { 117 | override fun create(environment: SymbolProcessorEnvironment): HooksProcessor = HooksProcessor( 118 | environment.codeGenerator, 119 | environment.logger, 120 | ) 121 | } 122 | 123 | public class Exception(message: String, cause: Throwable? = null) : kotlin.Exception(message, cause) 124 | } 125 | -------------------------------------------------------------------------------- /example-library/src/test/kotlin/GenericHookTests.kt: -------------------------------------------------------------------------------- 1 | package com.intuit.hooks.example.library 2 | 3 | import com.intuit.hooks.BailResult.* 4 | import com.intuit.hooks.HookContext 5 | import com.intuit.hooks.LoopResult 6 | import com.intuit.hooks.example.library.generic.GenericHooksImpl 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.runBlocking 12 | import org.junit.jupiter.api.Assertions 13 | import org.junit.jupiter.api.Test 14 | 15 | class GenericHookTests { 16 | 17 | @Test 18 | fun `sync hook`() { 19 | val h = GenericHooksImpl().sync 20 | h.interceptRegister { info -> 21 | val keepTap = if (info.name == "bad") null else info 22 | println("Register - ${info.name} - ${keepTap != null}") 23 | keepTap 24 | } 25 | 26 | h.interceptCall { context, _ -> context.putIfAbsent("intercept1", true) } 27 | h.interceptCall { context, _ -> context.putIfAbsent("intercept2", true) } 28 | 29 | h.interceptTap { context, info -> 30 | context.increment("count") 31 | println("Tap - ${info.name}") 32 | } 33 | 34 | h.tap("bad") { _, x -> println("Bad! $x") } 35 | h.tap("hi") { _, x -> println("Hi! $x") } 36 | h.tap("bye") { _, x -> println("Bye! $x") } 37 | h.tap("what was in the context?!") { context, _ -> println("count: $context") } 38 | 39 | h.call(88) 40 | } 41 | 42 | @Test 43 | fun `sync loop`() { 44 | val h = GenericHooksImpl().syncLoop 45 | val interceptor = mockk<(HookContext, Boolean) -> Unit>() 46 | every { interceptor.invoke(any(), any()) } returns Unit 47 | 48 | h.interceptLoop(interceptor) 49 | h.tap("increment foo") { context, _ -> 50 | val count = context.increment("foo") 51 | if (count == 10) LoopResult.Continue else LoopResult.Restart 52 | } 53 | 54 | h.call(false) 55 | 56 | verify(exactly = 10) { interceptor.invoke(any(), any()) } 57 | } 58 | 59 | @Test 60 | fun `sync bail`() { 61 | val h = GenericHooksImpl().syncBail 62 | h.tap("continue") { _, _ -> Continue() } 63 | h.tap("bail") { _, _ -> Bail(2) } 64 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 65 | 66 | val result = h.call(true) 67 | Assertions.assertEquals(2, result) 68 | } 69 | 70 | @Test 71 | fun `sync waterfall`() { 72 | val h = GenericHooksImpl().syncWaterfall 73 | h.tap("continue") { _, x -> "$x David" } 74 | h.tap("continue again") { _, x -> "$x Jeremiah" } 75 | 76 | val result = h.call("Kian") 77 | Assertions.assertEquals("Kian David Jeremiah", result) 78 | } 79 | 80 | @Test 81 | fun `async parallel bail hook`() = runBlocking { 82 | val h = GenericHooksImpl().asyncParallelBail 83 | h.tap("should never complete") { _, _ -> 84 | delay(100000) 85 | println("didn't work") 86 | Bail("shouldn't resolve here") 87 | } 88 | h.tap("shouldn't be canceled") { _, _ -> 89 | delay(1) 90 | println("should print this") 91 | Continue() 92 | } 93 | h.tap("cancel others") { _, _ -> 94 | delay(10) 95 | println("canceling others") 96 | Bail("foo") 97 | } 98 | 99 | val result = h.call(10, "Kian") 100 | Assertions.assertEquals("foo", result) 101 | } 102 | 103 | @Test 104 | fun `async parallel`() = runBlocking { 105 | val h = GenericHooksImpl().asyncParallel 106 | h.tap("foo") { _ -> 107 | delay(1) 108 | 0 109 | } 110 | h.tap("bar") { _ -> 111 | delay(1) 112 | 0 113 | } 114 | 115 | h.call("Kian") 116 | } 117 | 118 | @Test 119 | fun `async series`() = runBlocking { 120 | val h = GenericHooksImpl().asyncSeries 121 | h.tap("foo") { _, _ -> 122 | delay(1) 123 | 0 124 | } 125 | h.tap("foo") { _, _ -> 126 | delay(1) 127 | 0 128 | } 129 | 130 | h.call("Kian") 131 | } 132 | 133 | @Test 134 | fun `async series bail`() = runBlocking { 135 | val h = GenericHooksImpl().asyncSeriesBail 136 | h.tap("continue") { _, _ -> Continue() } 137 | h.tap("bail") { _, _ -> Bail("bail now") } 138 | h.tap("continue again") { _, _ -> Assertions.fail("Should never have gotten here!") } 139 | 140 | val result = h.call("Kian") 141 | Assertions.assertEquals("bail now", result) 142 | } 143 | @Test 144 | fun `async series loop`() = runBlocking { 145 | var incrementedA = 0 146 | var incrementedB = 0 147 | 148 | val h = GenericHooksImpl().asyncSeriesLoop 149 | h.tap("increment foo") { context, _ -> 150 | delay(1) 151 | incrementedA += 1 152 | context.increment("foo") 153 | LoopResult.fromNullable(null) 154 | } 155 | 156 | h.tap("bail if foo is 6") { context, _ -> 157 | delay(1) 158 | if (context["foo"] == 6) LoopResult.Restart else LoopResult.Continue 159 | } 160 | 161 | h.tap("read foo") { context, _ -> 162 | delay(1) 163 | incrementedB += 1 164 | if ((context["foo"] as Int) < 10) LoopResult.Restart else LoopResult.Continue 165 | } 166 | 167 | h.call("Kian") 168 | 169 | Assertions.assertEquals(1, incrementedA - incrementedB) 170 | } 171 | 172 | @Test 173 | fun `async series waterfall`() = runBlocking { 174 | val h = GenericHooksImpl().asyncSeriesWaterfall 175 | h.tap("continue") { _, x -> 176 | delay(1) 177 | "$x David" 178 | } 179 | h.tap("continue again") { _, x -> 180 | delay(1) 181 | "$x Jeremiah" 182 | } 183 | 184 | val result = h.call("Kian") 185 | Assertions.assertEquals("Kian David Jeremiah", result) 186 | } 187 | 188 | private fun HookContext.increment(key: String) = 189 | this.compute(key) { _, v -> if (v == null) 1 else (v as Int) + 1 } 190 | } 191 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | --------------------------------------------------------------------------------