diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1fbd6c9..745e49d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -38,21 +38,3 @@ jobs: path: | **/build/reports/ **/build/test-results/ - -# test-sbt: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# - name: Set up JDK 21 -# uses: actions/setup-java@v4 -# with: -# cache: 'sbt' -# java-version: 21 -# distribution: 'temurin' -# - name: Setup Gradle -# uses: gradle/actions/setup-gradle@v3 -# - name: Setup sbt -# uses: sbt/setup-sbt@v1 -# - name: Build and test -# run: sbt -v publishLocalGradleDependencies ++test -# working-directory: ./tasks-scala diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 95620da..602f6ea 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -2,6 +2,9 @@ name: Publish Release on: workflow_dispatch +permissions: + contents: write + jobs: publish-release: runs-on: ubuntu-latest @@ -12,7 +15,7 @@ jobs: with: java-version: 17 distribution: 'temurin' - - name: Publish Snapshot + - name: Publish to Maven Central run: ./gradlew -PbuildRelease=true build publish --no-daemon env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.gitignore b/.gitignore index 6b6a7b6..541cfbb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,8 @@ bin/ .DS_Store ### Kotlin 2? -/.kotlin -/kotlin-js-store/ +.kotlin +kotlin-js-store/ # sbt specific dist/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..862bfdc --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Alexandru Nedelcu + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index 1d18824..634b4da 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,3 @@ test-watch: test-coverage: ./gradlew clean build jacocoTestReport koverHtmlReportJvm open tasks-jvm/build/reports/jacoco/test/html/index.html - open ./tasks-kotlin/build/reports/kover/htmlJvm/index.html diff --git a/README.md b/README.md index 2fc4f9f..4d62730 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is a library meant for library authors that want to build libraries that wo ## Usage -Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/0.3.1/org/funfix/tasks/jvm/package-summary.html). +Read the [Javadoc](https://javadoc.io/doc/org.funfix/tasks-jvm/0.4.0/org/funfix/tasks/jvm/package-summary.html). Better documentation is coming. --- @@ -16,18 +16,18 @@ Maven: org.funfix tasks-jvm - 0.3.1 + 0.4.0 ``` Gradle: ```kotlin dependencies { - implementation("org.funfix:tasks-jvm:0.3.1") + implementation("org.funfix:tasks-jvm:0.4.0") } ``` sbt: ```scala -libraryDependencies += "org.funfix" % "tasks-jvm" % "0.3.1" +libraryDependencies += "org.funfix" % "tasks-jvm" % "0.4.0" ``` diff --git a/build.gradle.kts b/build.gradle.kts index 2d4f0db..01c0198 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask val projectVersion = property("project.version").toString() plugins { + id("org.jetbrains.dokka") id("com.github.ben-manes.versions") } @@ -10,6 +11,42 @@ repositories { mavenCentral() } +buildscript { + dependencies { + classpath("org.jetbrains.dokka:dokka-base:2.1.0") + // classpath("org.jetbrains.dokka:kotlin-as-java-plugin:2.0.0") + } +} + +//dokka { +// dokkaPublications.html { +// outputDirectory.set(rootDir.resolve("build/dokka")) +// outputDirectory.set(file("build/dokka")) +// } +//} + +dokka { + dokkaPublications.html { + includes.from("docs/introduction.md") + outputDirectory.set(file("build/dokka")) + } + + pluginsConfiguration.html { + customAssets.from( + "docs/funfix-512.png", + "docs/favicon.ico" + ) + customStyleSheets.from("docs/logo-styles.css") + templatesDir.set(file("docs/dokka-templates")) + footerMessage.set("© Alexandru Nedelcu") + } +} + +dependencies { + dokka(project(":tasks-jvm")) + dokka(project(":tasks-kotlin-coroutines")) +} + tasks.named("dependencyUpdates").configure { fun isNonStable(version: String): Boolean { val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 33e1ef6..f96debf 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -23,5 +23,10 @@ fun version(k: String) = dependencies { implementation(libs.gradle.versions.plugin) implementation(libs.vanniktech.publish.plugin) - implementation(libs.errorprone.gradle.plugin) + // removed errorprone plugin + // Provide plugins used by precompiled script plugins so their ids are available + implementation(libs.kotlin.gradle.plugin) + implementation(libs.kover.gradle.plugin) + implementation(libs.dokka.gradle.plugin) + implementation(libs.binary.compatibility.validator.plugin) } diff --git a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts index 7bdb8a4..bb787e5 100644 --- a/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts +++ b/buildSrc/src/main/kotlin/tasks.java-project.gradle.kts @@ -1,6 +1,20 @@ +import java.net.URI + plugins { `java-library` jacoco - id("net.ltgt.errorprone") + id("org.jetbrains.dokka") id("tasks.base") } + +dokka { + dokkaSourceSets.configureEach { + val tag = "v${project.version}" + val relativePath = project.projectDir.relativeTo(project.rootDir).invariantSeparatorsPath + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URI("https://github.com/funfix/tasks/tree/${tag}/${relativePath}/src")) + remoteLineSuffix.set("#L") + } + } +} diff --git a/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts new file mode 100644 index 0000000..814e22f --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks.kmp-project.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.net.URI + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.kotlinx.kover") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.binary-compatibility-validator") + id("tasks.base") +} + +val dokkaOutputDir = layout.buildDirectory.dir("dokka").get().asFile + +dokka { + dokkaPublications.html { + outputDirectory.set(dokkaOutputDir) + } + + dokkaSourceSets.configureEach { + val tag = "v${project.version}" + val relativePath = project.projectDir.relativeTo(project.rootDir).invariantSeparatorsPath + sourceLink { + localDirectory.set(file("src")) + remoteUrl.set(URI("https://github.com/funfix/tasks/tree/${tag}/${relativePath}/src")) + remoteLineSuffix.set("#L") + } + } +} + +val deleteDokkaOutputDir by tasks.register("deleteDokkaOutputDirectory") { + delete(dokkaOutputDir) +} + +val javadocJar = tasks.register("javadocJar") { + archiveClassifier.set("javadoc") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn(deleteDokkaOutputDir, tasks.named("dokkaGeneratePublicationHtml")) + from(dokkaOutputDir) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvm {} + + js(IR) { + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } + + tasks.withType { + sourceCompatibility = JavaVersion.VERSION_17.majorVersion + targetCompatibility = JavaVersion.VERSION_17.majorVersion + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_17.majorVersion)) + } + } + + tasks.withType { + compilerOptions { + // Set on a project-by-project basis + // explicitApiMode = ExplicitApiMode.Strict + // allWarningsAsErrors = true + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-jvm-default=enable") + } + kotlinJavaToolchain.toolchain.use( + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_17.majorVersion) + } + ) + } +} + +tasks.withType { + useJUnitPlatform() + javaLauncher = + javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_17.majorVersion) + } +} diff --git a/buildSrc/src/main/kotlin/tasks.versions.gradle.kts b/buildSrc/src/main/kotlin/tasks.versions.gradle.kts new file mode 100644 index 0000000..2dc36a2 --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks.versions.gradle.kts @@ -0,0 +1,24 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +plugins { + id("com.github.ben-manes.versions") +} + +// Configure the plugin's task with shared defaults for all projects that apply this precompiled plugin +tasks.named("dependencyUpdates").configure { + fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() + } + + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } + + checkForGradleUpdate = true + outputFormatter = "html" + outputDir = "build/dependencyUpdates" + reportfileName = "report" +} diff --git a/docs/dokka-templates/includes/page_metadata.ftl b/docs/dokka-templates/includes/page_metadata.ftl new file mode 100644 index 0000000..962e316 --- /dev/null +++ b/docs/dokka-templates/includes/page_metadata.ftl @@ -0,0 +1,6 @@ +<#macro display> + ${pageName} + <@template_cmd name="pathToRoot"> + + + diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..0977e87 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/funfix-512.png b/docs/funfix-512.png new file mode 100644 index 0000000..a2a84a2 Binary files /dev/null and b/docs/funfix-512.png differ diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..65eb2b0 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,3 @@ +# Funfix Tasks + +This is a library meant for library authors that want to build libraries that work across Java, Scala, or Kotlin, without having to worry about interoperability with whatever method of I/O that the library is using under the hood. diff --git a/docs/logo-styles.css b/docs/logo-styles.css new file mode 100644 index 0000000..0c47ff9 --- /dev/null +++ b/docs/logo-styles.css @@ -0,0 +1,6 @@ +/* Override Dokka logo to use Funfix PNG */ +:root { + --dokka-logo-image-url: url('../images/funfix-512.png'); + --dokka-logo-height: 32px; + --dokka-logo-width: 32px; +} diff --git a/gradle.properties b/gradle.properties index f185d4e..24b7c12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official # TO BE modified whenever a new version is released -project.version=0.3.1 +project.version=0.4.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0876f18..263078a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,30 @@ [versions] -versions-plugin = "0.51.0" -publish-plugin = "0.29.0" -errorprone-plugin = "4.3.0" -errorprone = "2.41.0" -errorprone-nullaway = "0.12.8" - +arrow = "2.2.1.1" +binary-compatibility-validator = "0.16.2" +dokka = "2.1.0" jetbrains-annotations = "26.0.2" jspecify = "1.0.0" +junit-jupiter = "6.0.2" +kotlin = "2.3.0" +kover = "0.9.5" +lombok = "1.18.36" +publish-plugin = "0.29.0" +versions-plugin = "0.51.0" +kotlinx-coroutines = "1.10.2" [libraries] # Plugins specified in buildSrc/build.gradle.kts +binary-compatibility-validator-plugin = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version.ref = "binary-compatibility-validator" } +dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } gradle-versions-plugin = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versions-plugin" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish-plugin" } -errorprone-gradle-plugin = { module = "net.ltgt.gradle:gradle-errorprone-plugin", version.ref = "errorprone-plugin" } -errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone"} -errorprone-nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "errorprone-nullaway" } # Actual libraries jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c8..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 98f724f..440904e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = "tasks" include("tasks-jvm") +include("tasks-kotlin-coroutines") pluginManagement { repositories { diff --git a/tasks-jvm/build.gradle.kts b/tasks-jvm/build.gradle.kts index 0076d9e..1df7cb5 100644 --- a/tasks-jvm/build.gradle.kts +++ b/tasks-jvm/build.gradle.kts @@ -1,8 +1,6 @@ -import net.ltgt.gradle.errorprone.CheckSeverity -import net.ltgt.gradle.errorprone.errorprone - plugins { id("tasks.java-project") + id("tasks.versions") } mavenPublishing { @@ -15,12 +13,9 @@ mavenPublishing { dependencies { api(libs.jspecify) - errorprone(libs.errorprone.core) - errorprone(libs.errorprone.nullaway) - compileOnly(libs.jetbrains.annotations) - testImplementation(platform("org.junit:junit-bom:5.12.1")) + testImplementation(platform("org.junit:junit-bom:6.0.2")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -41,12 +36,7 @@ tasks.withType { "-Xlint:deprecation", // "-Werror" )) - - options.errorprone { - disableAllChecks.set(true) - check("NullAway", CheckSeverity.ERROR) - option("NullAway:AnnotatedPackages", "org.funfix") - } + } tasks.register("testsOn21") { diff --git a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java index ec7b4ed..ff0acc7 100644 --- a/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java +++ b/tasks-jvm/src/main/java/org/funfix/tasks/jvm/Task.java @@ -509,9 +509,9 @@ public void invoke(Continuation continuation) { cancellableRef.set(() -> { try { - cancellable.cancel(); - } finally { future.cancel(true); + } finally { + cancellable.cancel(); } }); } catch (Throwable e) { @@ -526,7 +526,7 @@ private static CompletableFuture getCompletableFuture( ) { CompletableFuture future = cancellableFuture.future(); future.whenComplete((value, error) -> { - if (error instanceof InterruptedException || error instanceof TaskCancellationException) { + if (error instanceof InterruptedException || error instanceof TaskCancellationException || error instanceof CancellationException) { callback.onCancellation(); } else if (error instanceof ExecutionException) { callback.onFailure(error.getCause() != null ? error.getCause() : error); diff --git a/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api new file mode 100644 index 0000000..b8e5050 --- /dev/null +++ b/tasks-kotlin-coroutines/api/tasks-kotlin-coroutines.api @@ -0,0 +1,7 @@ +public final class org/funfix/tasks/kotlin/CoroutinesJvmKt { + public static final fun runSuspending (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun runSuspending$default (Lorg/funfix/tasks/jvm/Task;Ljava/util/concurrent/Executor;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun suspendAsTask (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)Lorg/funfix/tasks/jvm/Task; + public static synthetic fun suspendAsTask$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/funfix/tasks/jvm/Task; +} + diff --git a/tasks-kotlin-coroutines/build.gradle.kts b/tasks-kotlin-coroutines/build.gradle.kts new file mode 100644 index 0000000..268a8b8 --- /dev/null +++ b/tasks-kotlin-coroutines/build.gradle.kts @@ -0,0 +1,40 @@ +@file:OptIn(ExperimentalKotlinGradlePluginApi::class) + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("tasks.kmp-project") + id("tasks.versions") +} + +mavenPublishing { + pom { + name = "Tasks / Kotlin Coroutines" + description = "Integration with Kotlin's Coroutines" + } +} + +kotlin { + sourceSets { + val jvmMain by getting { + compilerOptions { + explicitApi = ExplicitApiMode.Strict + allWarningsAsErrors = true + } + + dependencies { + implementation(project(":tasks-jvm")) + implementation(libs.kotlinx.coroutines.core) + } + } + + val jvmTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.arrow.fx.coroutines) + } + } + } +} diff --git a/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt new file mode 100644 index 0000000..7b5929d --- /dev/null +++ b/tasks-kotlin-coroutines/src/jvmMain/kotlin/org/funfix/tasks/kotlin/coroutines.jvm.kt @@ -0,0 +1,135 @@ +@file:JvmName("CoroutinesJvmKt") +@file:OptIn(DelicateCoroutinesApi::class) + +package org.funfix.tasks.kotlin + +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.* +import kotlinx.coroutines.CancellableContinuation +import org.funfix.tasks.jvm.* + +/** + * Similar with `runBlocking`, however this is a "suspended" function, + * to be executed in the context of kotlinx.coroutines. + * + * NOTES: + * - The `kotlinx.coroutines.CoroutineDispatcher`, made available via the + * "coroutine context", is used to execute the task, being passed to + * the task's implementation as an `Executor`. + * - The coroutine's cancellation protocol cooperates with that of [Task], + * so cancelling the coroutine will also cancel the task (including the + * possibility for back-pressuring on the fiber's completion after + * cancellation). + * + * @param executor is an override of the `Executor` to be used for executing + * the task. If `null`, the `Executor` will be derived from the + * `CoroutineDispatcher` + */ +public suspend fun Task.runSuspending( + executor: Executor? = null +): T = run { + val executorOrDefault = executor ?: currentDispatcher().asExecutor() + suspendCancellableCoroutine { cont -> + val contCallback = CoroutineAsCompletionCallback(cont) + try { + val token = runAsync(executorOrDefault, contCallback) + cont.invokeOnCancellation { + token.cancel() + } + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + contCallback.onFailure(e) + } + } +} + +/** + * Creates a [Task] from a suspended block of code. + */ +public fun suspendAsTask( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> T +): Task = Task.fromAsync { executor, callback -> + val job = GlobalScope.launch( + executor.asCoroutineDispatcher() + coroutineContext + ) { + try { + val r = block() + callback.onSuccess(r) + } catch (e: Throwable) { + UncaughtExceptionHandler.rethrowIfFatal(e) + when (e) { + is CancellationException, + is TaskCancellationException, + is InterruptedException -> + callback.onCancellation() + else -> + callback.onFailure(e) + } + } + } + Cancellable { + job.cancel() + } +} + +/** + * Internal API: wraps a [CancellableContinuation] into a [CompletionCallback]. + */ +internal class CoroutineAsCompletionCallback( + private val cont: CancellableContinuation +) : CompletionCallback { + private val isActive = AtomicBoolean(true) + + private inline fun completeWith(crossinline block: () -> Unit): Boolean = + if (isActive.getAndSet(false)) { + block() + true + } else { + false + } + + override fun onOutcome(outcome: Outcome) { + when (outcome) { + is Outcome.Success -> onSuccess(outcome.value()) + is Outcome.Failure -> onFailure(outcome.exception()) + is Outcome.Cancellation -> onCancellation() + } + } + + override fun onSuccess(value: T) { + completeWith { + cont.resume(value) { _, _, _ -> + // on cancellation? + } + } + } + + override fun onFailure(e: Throwable) { + if (!completeWith { + cont.resumeWithException(e) + }) { + UncaughtExceptionHandler.logOrRethrow(e) + } + } + + override fun onCancellation() { + completeWith { + cont.resumeWithException(CancellationException()) + } + } +} + +/** + * Internal API: gets the current [kotlinx.coroutines.CoroutineDispatcher] from the coroutine context. + */ +internal suspend fun currentDispatcher(): CoroutineDispatcher { + val continuationInterceptor = currentCoroutineContext()[ContinuationInterceptor] + return continuationInterceptor as? CoroutineDispatcher ?: Dispatchers.Default +} diff --git a/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt new file mode 100644 index 0000000..6c6c84d --- /dev/null +++ b/tasks-kotlin-coroutines/src/jvmTest/kotlin/org/funfix/tasks/kotlin/CoroutinesJvmTest.kt @@ -0,0 +1,246 @@ +package org.funfix.tasks.kotlin + +import arrow.fx.coroutines.CountDownLatch +import java.util.concurrent.Executors +import kotlin.test.* +import kotlinx.coroutines.* +import kotlinx.coroutines.test.runTest +import org.funfix.tasks.jvm.Cancellable +import org.funfix.tasks.jvm.Outcome +import org.funfix.tasks.jvm.Task +import org.funfix.tasks.jvm.TaskCancellationException + +class CoroutinesJvmTest { + @Test + fun `runSuspending signals Success`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onSuccess(42) + Cancellable {} + } + + val result = task.runSuspending() + + assertEquals(42, result) + } + + @Test + fun `runSuspending signals Failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, cb -> + cb.onFailure(ex) + Cancellable {} + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + + @Test + fun `runSuspending cancels the task token`() = runTest { + val cancelled = CompletableDeferred() + val started = CompletableDeferred() + val task = Task.fromAsync { _, cb -> + started.complete(Unit) + Cancellable { + cancelled.complete(Unit) + cb.onCancellation() + } + } + + val deferred = async { task.runSuspending() } + started.await() + deferred.cancel() + + assertFailsWith { deferred.await() } + cancelled.await() + } + + @Test + fun `suspendAsTask signals Success`() = runTest { + val task = suspendAsTask { + 21 + 21 + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Success(42), deferred.await()) + } + + @Test + fun `suspendAsTask signals Failure`() = runTest { + val ex = RuntimeException("Boom") + val task = suspendAsTask { + throw ex + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Failure(ex), deferred.await()) + } + + @Test + fun `suspendAsTask signals Cancellation`() = runTest { + val started = CompletableDeferred() + val task = suspendAsTask { + started.complete(Unit) + awaitCancellation() + } + val deferred = CompletableDeferred>() + val cancel = task.runAsync { outcome -> deferred.complete(outcome) } + + started.await() + cancel.cancel() + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `runSuspending uses current Dispatcher when Executor is null`() = runTest { + val executor = Executors.newSingleThreadExecutor { r -> + val thread = Thread(r) + thread.isDaemon = true + thread.name = "coroutines-test-${thread.id}" + thread + } + val dispatcher = executor.asCoroutineDispatcher() + try { + val threadName = withContext(dispatcher) { + Task.fromBlockingIO { Thread.currentThread().name }.runSuspending() + } + + assertTrue(threadName.startsWith("coroutines-test-")) + } finally { + dispatcher.close() + executor.shutdown() + } + } + + @Test + fun `runSuspending translates async callback success`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onSuccess(42) + Cancellable {} + } + + assertEquals(42, task.runSuspending()) + } + + @Test + fun `runSuspending translates async callback failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, cb -> + cb.onFailure(ex) + Cancellable {} + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + @Test + fun `runSuspending resumes with task cancellation`() = runTest { + val task = Task.fromAsync { _, cb -> + cb.onCancellation() + Cancellable {} + } + + assertFailsWith { task.runSuspending() } + } + + @Test + fun `runSuspending forwards runAsync failure`() = runTest { + val ex = RuntimeException("Boom") + val task = Task.fromAsync { _, _ -> + throw ex + } + + val thrown = assertFailsWith { task.runSuspending() } + + assertEquals("Boom", thrown.message) + } + + @Test + fun `runSuspending cancels task when coroutine job is cancelled`() = runTest { + val started = CompletableDeferred() + val latch = java.util.concurrent.CountDownLatch(1) + val task = Task.fromBlockingIO { + started.complete(Unit) + latch.await() + 42 + } + val executor = Executors.newSingleThreadExecutor() + try { + val deferred = async { task.runSuspending(executor) } + + started.await() + deferred.cancel() + latch.countDown() + + deferred.join() + + assertFailsWith { deferred.await() } + } finally { + executor.shutdown() + } + } + + @Test + fun `suspendAsTask maps CancellationException to cancellation`() = runTest { + val task = suspendAsTask { + throw CancellationException("cancelled") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask maps TaskCancellationException to cancellation`() = runTest { + val task = suspendAsTask { + throw TaskCancellationException("stop") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask maps InterruptedException to cancellation`() = runTest { + val task = suspendAsTask { + throw InterruptedException("stop") + } + val deferred = CompletableDeferred>() + task.runAsync { outcome -> deferred.complete(outcome) } + + assertEquals(Outcome.Cancellation(), deferred.await()) + } + + @Test + fun `suspendAsTask cancels when coroutine is cancelled while blocked`() = runTest { + val started = CountDownLatch(1) + val latch = CountDownLatch(1) + val wasTriggered = CountDownLatch(1) + val task = suspendAsTask { + started.countDown() + try { + latch.await() + fail("should have been cancelled") + } catch (_: CancellationException) { + wasTriggered.countDown() + } + } + + val job = async { + task.runSuspending() + } + + started.await() + job.cancel() + wasTriggered.await() + } +}