From 5bad8ca0953a758857c3ae96e3d16b1aa938f731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 10 Oct 2024 08:59:28 +0200 Subject: [PATCH] 1.8.0: test extensions, no more arrow deps --- CHANGELOG.md | 7 +- README.md | 28 ++- build.gradle.kts | 23 ++- buildSrc/build.gradle.kts | 1 + gradle.properties | 2 +- kmmresult-test/build.gradle.kts | 175 ++++++++++++++++++ .../kotlin/at/asitplus/TestExtensions.kt | 40 ++++ kmmresult/build.gradle.kts | 37 ++-- .../kotlin/at/asitplus/KmmResult.kt | 6 +- .../commonMain/kotlin/at/asitplus/NonFatal.kt | 21 +++ .../src/commonTest/kotlin/KmmResultTest.kt | 19 ++ .../kotlin/at/asitplus/NonFatal.jvm.kt | 8 + .../kotlin/at/asitplus/NonFatal.nonJvm.kt | 8 + settings.gradle.kts | 3 +- 14 files changed, 336 insertions(+), 42 deletions(-) create mode 100644 kmmresult-test/build.gradle.kts create mode 100644 kmmresult-test/src/commonMain/kotlin/at/asitplus/TestExtensions.kt create mode 100644 kmmresult/src/commonMain/kotlin/at/asitplus/NonFatal.kt create mode 100644 kmmresult/src/jvmMain/kotlin/at/asitplus/NonFatal.jvm.kt create mode 100644 kmmresult/src/nonJvmMain/kotlin/at/asitplus/NonFatal.nonJvm.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ad670..9964a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,10 +45,13 @@ - add `callsInPlace` contracts - return result fon `onFailure` and `onSuccess` -## 1.7.1 +## 1.8.0 - migrate to dokka 2 for documentation - multi-module project setup -- introduce `kmmresult-test`, enabling +- introduce `kmmresult-test`, featuring - `result should succeed` - `result shouldNot succeed` - `result shouldSucceedWith expectedValue` + - `result.shouldSucceed()` returning the contained value +- remove Arrow dependency and import arrow's list of Fatal exceptions directly into our code +- Introduce [Result.nonFatalOrThrow] to mimic KmmResult's non-fatal-only behaviour, but without the object instantiation overhead \ No newline at end of file diff --git a/README.md b/README.md index 8a74d61..38a76b4 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,14 @@ var intResult = KmmResult.success(3) intResult = KmmResult.failure (NotImplementedError("Not Implemented")) ``` -Also provides `map()` to transform success types while passing through errors and `mapFailure` to transform error types -while passing through success cases. -In addition, the more generic `fold()` is available for conveniently operating on both success and failure branches. - - -There really is not much more to say, except for two things: - - `KmmResult` sports `unwrap()` to conveniently map it to the `kotlin.Result` equivalent - - It provides a `Result.wrap()` extension function to go the opposite way. +Convenience functions: +- `map()` transforms success types while passing through errors +- `mapFailure` transforms error types while passing through success cases +- the more generic `fold()` is available for conveniently operating on both success and failure branches +- `KmmResult` sports `unwrap()` to conveniently map it to the `kotlin.Result` equivalent +- `Result.wrap()` extension function goes the opposite way +- `mapCatching()` does what you'd expect +- `wrapping()` allows for wrapping the failure branch's exception unless it is of the specified type Refer to the [full documentation](https://a-sit-plus.github.io/kmmresult/) for more info. @@ -83,6 +83,18 @@ func funWithKotlin() -> KmmResult { } ``` +## Non-Fatal-Only `catching` +KmmResult comes with `catching`. This is a non-fatal-only-catching version of stdlib's `runCatching`, directly returning a `KmmResult`. +It re-throws any fatal exceptions, such as `OutOfMemoryError`. The underlying logic is borrowed from [Arrow's](https://arrow-kt.io)'s +[`nonFatalOrThrow`](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html). + +The only downside of `catching` is that it incurs instatiation overhead, because it creates a `KmmResult` instance. +Internally, though, only the behaviour is important, not Swift interop. Hence, you don't care for a `KmmResult` and you +certainly don't care for the cost of instantiating an object. Here, the `Result.nonFatalOrThrow()` extension shipped with KmmResult +comes to the rescue. It does exactly what the name suggest: It re-throws any fatal exception and leaved the `Result` object +untouched otherwise. + + Happy folding! ## Contributing diff --git a/build.gradle.kts b/build.gradle.kts index 5f8723c..9e14817 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ +import org.gradle.kotlin.dsl.support.listFilesOrdered + plugins {base - kotlin("multiplatform") version "2.0.0" apply false id("org.jetbrains.dokka") id("io.github.gradle-nexus.publish-plugin") version "1.3.0" } @@ -10,9 +11,29 @@ version = artifactVersion dependencies { dokka(project(":kmmresult")) + dokka(project(":kmmresult-test")) } +dokka { + val moduleDesc = File("$rootDir/dokka-tmp.md").also { it.createNewFile() } + val readme = + File("${rootDir}/README.md").readText() + moduleDesc.writeText("\n\n$readme") + moduleName.set("KmmResult") + + dokkaPublicationDirectory.set(file("${rootDir}/docs")) + dokkaPublications.html { + includes.from(moduleDesc) + } +} +tasks.dokkaGenerate { + doLast { + rootDir.listFilesOrdered { it.extension.lowercase() == "png" || it.extension.lowercase() == "svg" } + .forEach { it.copyTo(File("$rootDir/docs/html/${it.name}"), overwrite = true) } + + } +} nexusPublishing { repositories { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 24aaa6f..abb6a94 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,4 +9,5 @@ repositories { dependencies { implementation("org.jetbrains.dokka:dokka-gradle-plugin:2.0.0-Beta") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20") } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 44be32f..9dd40b5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,5 @@ kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.stability.nowarn=true kotlin.native.ignoreDisabledTargets=true -artifactVersion = 1.7.1 +artifactVersion = 1.8.0 org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled \ No newline at end of file diff --git a/kmmresult-test/build.gradle.kts b/kmmresult-test/build.gradle.kts new file mode 100644 index 0000000..4ed0908 --- /dev/null +++ b/kmmresult-test/build.gradle.kts @@ -0,0 +1,175 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.gradle.kotlin.dsl.support.listFilesOrdered +import java.lang.management.ManagementFactory +import java.net.URI + +plugins { + kotlin("multiplatform") + id("maven-publish") + id("signing") + id("org.jetbrains.dokka") + id("org.jetbrains.kotlinx.kover") version "0.8.0" + id("io.gitlab.arturbosch.detekt") version "1.23.6" +} + +val artifactVersion: String by extra +group = "at.asitplus" +version = artifactVersion + +repositories { + mavenCentral() +} + +val dokkaOutputDir = "$projectDir/docs" +dokka { + dokkaSourceSets { + + named("commonMain") { + sourceLink { + val path = "${projectDir}/src/$name/kotlin" + println(path) + localDirectory.set(file(path)) + remoteUrl.set( + URI("https://github.com/a-sit-plus/kmmresult/tree/main/src/$name/kotlin") + ) + // Suffix which is used to append the line number to the URL. Use #L for GitHub + remoteLineSuffix.set("#L") + } + } + } + pluginsConfiguration.html { + footerMessage = "© 2024 A-SIT Plus GmbH" + } +} +val deleteDokkaOutputDir by tasks.register("deleteDokkaOutputDirectory") { + delete(dokkaOutputDir) +} +val javadocJar = tasks.register("javadocJar") { + dependsOn(deleteDokkaOutputDir, tasks.dokkaGenerate) + archiveClassifier.set("javadoc") + from(dokkaOutputDir) +} + +tasks.getByName("check") { + dependsOn("detektMetadataMain") +} + + +//first sign everything, then publish! +tasks.withType() { + tasks.withType().forEach { + dependsOn(it) + } +} + +kotlin { + + macosArm64() + macosX64() + tvosArm64() + tvosX64() + tvosSimulatorArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + + + jvmToolchain(11) + jvm { + compilations.all { + kotlinOptions { + freeCompilerArgs = listOf( + "-Xjsr305=strict" + ) + } + } + withJava() //for Java Interop tests + } + + js(IR) { + browser { testTask { enabled = false } } + nodejs() + } + linuxX64() + linuxArm64() + mingwX64() + + sourceSets { + commonMain.dependencies { + implementation(project(":kmmresult")) + api("io.kotest:kotest-assertions-core:5.9.1") + } + } + + tasks.withType().configureEach { + reports { + xml.required.set(true) + html.required.set(false) + txt.required.set(false) + sarif.required.set(true) + md.required.set(true) + } + } +} + + + + +dependencies { + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.6") + } + + repositories { + mavenCentral() + } + + publishing { + publications { + withType { + artifact(javadocJar) + pom { + name.set("KmmResult Test") + description.set("Kotest helperrs for KmmResult") + url.set("https://github.com/a-sit-plus/kmmresult") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("JesusMcCloud") + name.set("Bernd Prünster") + email.set("bernd.pruenster@a-sit.at") + } + developer { + id.set("nodh") + name.set("Christian Kollmann") + email.set("christian.kollmann@a-sit.at") + } + } + scm { + connection.set("scm:git:git@github.com:a-sit-plus/kmmresult.git") + developerConnection.set("scm:git:git@github.com:a-sit-plus/kmmresult.git") + url.set("https://github.com/a-sit-plus/kmmresult") + } + } + } + } + repositories { + mavenLocal() { + signing.isRequired = false + } + } + } + + +signing { + val signingKeyId: String? by project + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign(publishing.publications) +} + diff --git a/kmmresult-test/src/commonMain/kotlin/at/asitplus/TestExtensions.kt b/kmmresult-test/src/commonMain/kotlin/at/asitplus/TestExtensions.kt new file mode 100644 index 0000000..3de0bc6 --- /dev/null +++ b/kmmresult-test/src/commonMain/kotlin/at/asitplus/TestExtensions.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2021 - 2023 A-SIT Plus GmbH. Obviously inspired and partially copy-pasted from kotlin.Result. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ +package at.asitplus + +import io.kotest.matchers.Matcher +import io.kotest.matchers.MatcherResult +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe + +/** + * Shorthand for `getOrThrow() shouldBe expected` + */ +infix fun KmmResult.shouldSucceedWith(expected: T): T = getOrThrow() shouldBe expected + +/** + * [KmmResult] matcher. Use as follows: `okResult should succeed`, `errResult shouldNot succeed` + */ +@Suppress("ClassNaming") +object succeed : Matcher> { + override fun test(value: KmmResult<*>) = + MatcherResult( + value.isSuccess, + failureMessageFn = { + "Should have succeeded, but failed:\n${ + value.exceptionOrNull()!!.stackTraceToString() + }" + }, + negatedFailureMessageFn = { "Should have failed, but succeeded with ${value.getOrNull()!!}" } + ) +} + +/** + * Asserts that this KmmResult should succeed and returns the contained value + */ +fun KmmResult.shouldSucceed(): T { + this should succeed + return getOrThrow() +} diff --git a/kmmresult/build.gradle.kts b/kmmresult/build.gradle.kts index f851c72..ceeea2a 100644 --- a/kmmresult/build.gradle.kts +++ b/kmmresult/build.gradle.kts @@ -1,5 +1,5 @@ import io.gitlab.arturbosch.detekt.Detekt -import org.gradle.kotlin.dsl.support.listFilesOrdered +import java.lang.management.ManagementFactory import java.net.URI plugins { @@ -21,43 +21,27 @@ repositories { val dokkaOutputDir = "$projectDir/docs" dokka { - val moduleDesc = File("$rootDir/dokka-tmp.md").also { it.createNewFile() } - val readme = - File("${rootDir}/README.md").readText() - val moduleTitle = project.name - moduleDesc.writeText("# Module ${project.name}\n\n$readme") - moduleName.set(moduleTitle) - - dokkaSourceSets { - register("commonMain") { - includes.from(moduleDesc) + + named("commonMain") { sourceLink { val path = "${projectDir}/src/$name/kotlin" println(path) localDirectory.set(file(path)) remoteUrl.set( - URI("https://github.com/a-sit-plus/kmmresult/tree/development/src/$name/kotlin") + URI("https://github.com/a-sit-plus/kmmresult/tree/main/src/$name/kotlin") ) // Suffix which is used to append the line number to the URL. Use #L for GitHub remoteLineSuffix.set("#L") } } } - dokkaPublicationDirectory.set(file("${rootDir}/docs")) pluginsConfiguration.html { footerMessage = "© 2024 A-SIT Plus GmbH" } - } -tasks.dokkaGenerate { - doLast { - rootDir.listFilesOrdered { it.extension.lowercase() == "png" || it.extension.lowercase() == "svg" } - .forEach { it.copyTo(File("$rootDir/docs/html/${it.name}"), overwrite = true) } - } -} val deleteDokkaOutputDir by tasks.register("deleteDokkaOutputDirectory") { delete(dokkaOutputDir) } @@ -117,20 +101,21 @@ kotlin { browser { testTask { enabled = false } } nodejs() } + linuxX64() linuxArm64() mingwX64() sourceSets { - commonMain.dependencies { - implementation("io.arrow-kt:arrow-core:1.2.4") - } - commonTest.dependencies { implementation(kotlin("test")) } } - + sourceSets.filterNot { it.name.startsWith("common") || it.name.startsWith("jvm") }.filter { it.name.endsWith("Main") }.forEach {srcSet-> + println(srcSet.name) + srcSet.kotlin.srcDir("$projectDir/src/nonJvmMain/kotlin") + } +} tasks.withType().configureEach { @@ -191,7 +176,7 @@ kotlin { } } } -} + signing { val signingKeyId: String? by project diff --git a/kmmresult/src/commonMain/kotlin/at/asitplus/KmmResult.kt b/kmmresult/src/commonMain/kotlin/at/asitplus/KmmResult.kt index d2a4249..875ffdc 100644 --- a/kmmresult/src/commonMain/kotlin/at/asitplus/KmmResult.kt +++ b/kmmresult/src/commonMain/kotlin/at/asitplus/KmmResult.kt @@ -7,7 +7,6 @@ package at.asitplus -import arrow.core.nonFatalOrThrow import kotlin.contracts.InvocationKind import kotlin.contracts.contract import kotlin.experimental.ExperimentalObjCName @@ -235,6 +234,7 @@ private constructor( @OptIn(ExperimentalObjCRefinement::class) companion object { + @HiddenFromObjC @JvmStatic fun success(value: T): KmmResult = KmmResult(value) @@ -268,7 +268,7 @@ inline fun KmmResult.recoverCatching(block: (error: Throwable) -> /** * Non-fatal-only-catching version of stdlib's [runCatching], directly returning a [KmmResult] -- * Re-throws any fatal exceptions, such as `OutOfMemoryError`. Relies on [Arrow](https://arrow-kt.io)'s - * [nonFatalOrThrow](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html) internally. + * [nonFatalOrThrow](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html) logic. */ @Suppress("TooGenericExceptionCaught") inline fun catching(block: () -> T): KmmResult { @@ -287,7 +287,7 @@ inline fun catching(block: () -> T): KmmResult { * Non-fatal-only-catching version of stdlib's [runCatching] (calling the specified function [block] with `this` value * as its receiver), directly returning a [KmmResult] -- * Re-throws any fatal exceptions, such as `OutOfMemoryError`. Relies on [Arrow](https://arrow-kt.io)'s - * [nonFatalOrThrow](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html) internally. + * [nonFatalOrThrow](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html) logic. */ @Suppress("TooGenericExceptionCaught") inline fun T.catching(block: T.() -> R): KmmResult { diff --git a/kmmresult/src/commonMain/kotlin/at/asitplus/NonFatal.kt b/kmmresult/src/commonMain/kotlin/at/asitplus/NonFatal.kt new file mode 100644 index 0000000..94e689a --- /dev/null +++ b/kmmresult/src/commonMain/kotlin/at/asitplus/NonFatal.kt @@ -0,0 +1,21 @@ +package at.asitplus + +/** + * Throws any fatal exceptions. This is a re-implementation taken from Arrow's + * [`nonFatalOrThrow`](https://apidocs.arrow-kt.io/arrow-core/arrow.core/non-fatal-or-throw.html) – + * not because it is bad, it is actually pretty much perfect. + * However, the arrow dependency triggered an obscure IDEA bug, resulting in `NoClasDefFoundErrors` instead of correct + * behaviour. * We therefore removed the dependency and added the functionality directly to KmmResult. + * + * Please note that this was never a problem building anything that depended on KmmResult, it only made debugging + * in IDEA a nightmare. + */ +expect inline fun Throwable.nonFatalOrThrow(): Throwable + +/** + * Helper to effectively convert stdlib's [runCatching] to behave like KmmResults Non-fatal-only [catching]. I.e. any + * fatal exceptions are thrown. + * The reason this exists is that [catching] incurs instantiation cost. + * This helper hence provides the best of both worlds. + */ +inline fun Result.nonFatalOrThrow(): Result = this.onFailure { it.nonFatalOrThrow() } diff --git a/kmmresult/src/commonTest/kotlin/KmmResultTest.kt b/kmmresult/src/commonTest/kotlin/KmmResultTest.kt index ae0222f..fd9d19e 100644 --- a/kmmresult/src/commonTest/kotlin/KmmResultTest.kt +++ b/kmmresult/src/commonTest/kotlin/KmmResultTest.kt @@ -216,4 +216,23 @@ class KmmResultTest { result.transform { catching { fn(it) }} } } + + @Test + fun testNonFatal() { + + + + runCatching { throw CancellationException() } + + assertFailsWith(CancellationException::class) { + runCatching { throw CancellationException() }.nonFatalOrThrow() + } + assertFailsWith(CancellationException::class) { + catching { throw CancellationException() } + } + + runCatching { throw IndexOutOfBoundsException() }.nonFatalOrThrow() + catching { throw IndexOutOfBoundsException() } + + } } diff --git a/kmmresult/src/jvmMain/kotlin/at/asitplus/NonFatal.jvm.kt b/kmmresult/src/jvmMain/kotlin/at/asitplus/NonFatal.jvm.kt new file mode 100644 index 0000000..9c3c0b2 --- /dev/null +++ b/kmmresult/src/jvmMain/kotlin/at/asitplus/NonFatal.jvm.kt @@ -0,0 +1,8 @@ +package at.asitplus + +import kotlin.coroutines.cancellation.CancellationException +//Taken from Arrow: https://github.com/arrow-kt/arrow/blob/99de6148320a4299a5aef20686a6063ca732026b/arrow-libs/core/arrow-core/src/jvmMain/kotlin/arrow/core/NonFatal.kt +actual inline fun Throwable.nonFatalOrThrow(): Throwable = when (this) { + is VirtualMachineError, is ThreadDeath, is InterruptedException, is LinkageError, is CancellationException -> throw this + else -> this +} diff --git a/kmmresult/src/nonJvmMain/kotlin/at/asitplus/NonFatal.nonJvm.kt b/kmmresult/src/nonJvmMain/kotlin/at/asitplus/NonFatal.nonJvm.kt new file mode 100644 index 0000000..592ad7a --- /dev/null +++ b/kmmresult/src/nonJvmMain/kotlin/at/asitplus/NonFatal.nonJvm.kt @@ -0,0 +1,8 @@ +package at.asitplus + +import kotlin.coroutines.cancellation.CancellationException +//Taken from Arrow: https://github.com/arrow-kt/arrow/blob/99de6148320a4299a5aef20686a6063ca732026b/arrow-libs/core/arrow-core/src/nonJvmMain/kotlin/arrow/core/NonFatal.kt +actual inline fun Throwable.nonFatalOrThrow(): Throwable = when (this) { + is CancellationException -> throw this + else -> this +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 175eddc..1fa33e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,4 +16,5 @@ dependencyResolutionManagement { } -include("kmmresult") \ No newline at end of file +include("kmmresult") +include("kmmresult-test") \ No newline at end of file