diff --git a/build.gradle.kts b/build.gradle.kts index f9efecf..01923f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,13 +1,12 @@ import nl.littlerobots.vcu.plugin.versionCatalogUpdate +import nl.littlerobots.vcu.plugin.versionSelector import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockMismatchReport import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -@Suppress("DSL_SCOPE_VIOLATION") plugins { alias(libs.plugins.detekt) alias(libs.plugins.gradleDoctor) - alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) alias(libs.plugins.dokka) alias(libs.plugins.dependencyAnalysis) @@ -19,7 +18,6 @@ buildscript { dependencies { classpath(libs.android.gradle) classpath(libs.kotlin.gradle) - classpath(libs.version.gradle) classpath(libs.detekt.gradle) } } @@ -87,12 +85,14 @@ dependencies { } versionCatalogUpdate { - sortByKey.set(true) + sortByKey = true + + versionSelector { stabilityLevel(it.candidate.version) >= Config.minStabilityLevel } keep { - keepUnusedVersions.set(true) - keepUnusedLibraries.set(true) - keepUnusedPlugins.set(true) + keepUnusedVersions = true + keepUnusedLibraries = true + keepUnusedPlugins = true } } @@ -129,22 +129,6 @@ tasks { autoCorrect = false } - withType().configureEach { - outputFormatter = "json" - - fun stabilityLevel(version: String): Int { - Config.stabilityLevels.forEachIndexed { index, postfix -> - val regex = """.*[.\-]$postfix[.\-\d]*""".toRegex(RegexOption.IGNORE_CASE) - if (version.matches(regex)) return index - } - return Config.stabilityLevels.size - } - - rejectVersionIf { - stabilityLevel(currentVersion) > stabilityLevel(candidate.version) - } - } - wrapper { distributionType = Wrapper.DistributionType.BIN } diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index 195fc6c..da19709 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -18,7 +18,7 @@ object Config { const val majorRelease = 1 const val minorRelease = 3 - const val patch = 1 + const val patch = 2 const val postfix = "" const val versionName = "$majorRelease.$minorRelease.$patch$postfix" @@ -67,6 +67,7 @@ object Config { // build scripts val stabilityLevels = listOf("preview", "eap", "dev", "alpha", "beta", "m", "cr", "rc") + val minStabilityLevel = stabilityLevels.indexOf("beta") object Detekt { diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt index 0375c19..fa6bab7 100644 --- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt @@ -4,7 +4,10 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.getting import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +@OptIn(ExperimentalWasmDsl::class) +@Suppress("LongParameterList", "CyclomaticComplexMethod") fun Project.configureMultiplatform( ext: KotlinMultiplatformExtension, jvm: Boolean = true, @@ -14,7 +17,8 @@ fun Project.configureMultiplatform( js: Boolean = true, tvOs: Boolean = true, macOs: Boolean = true, - watchOs: Boolean = true + watchOs: Boolean = true, + wasmJs: Boolean = true, ) = ext.apply { val libs by versionCatalog explicitApi() @@ -41,6 +45,13 @@ fun Project.configureMultiplatform( if (jvm) jvm() + if (wasmJs) wasmJs { + moduleName = this@configureMultiplatform.name + nodejs() + browser() + binaries.library() + } + sequence { if (iOs) { yield(iosX64()) diff --git a/buildSrc/src/main/kotlin/Util.kt b/buildSrc/src/main/kotlin/Util.kt index ccef2f7..32d89d8 100644 --- a/buildSrc/src/main/kotlin/Util.kt +++ b/buildSrc/src/main/kotlin/Util.kt @@ -60,3 +60,11 @@ val Project.localProperties load(FileInputStream(File(rootProject.rootDir, "local.properties"))) } } + +fun stabilityLevel(version: String): Int { + Config.stabilityLevels.forEachIndexed { index, postfix -> + val regex = """.*[.\-]$postfix[.\-\d]*""".toRegex(RegexOption.IGNORE_CASE) + if (version.matches(regex)) return index + } + return Config.stabilityLevels.size +} diff --git a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CollectionExt.kt b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CollectionExt.kt index 0eb485f..cd09474 100644 --- a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CollectionExt.kt +++ b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CollectionExt.kt @@ -1,4 +1,5 @@ @file:OptIn(ExperimentalContracts::class) +@file:Suppress("TooManyFunctions") package pro.respawn.kmmutils.common @@ -88,6 +89,7 @@ public inline fun Iterable.reorderBy(order: List, crossinline selec /** * Swaps values [index1] with [index2] in place. + * Throws [IndexOutOfBoundsException] when one or both indices are not present in the collection */ public fun MutableList.swap(index1: Int, index2: Int): MutableList { val tmp = this[index1] @@ -96,15 +98,36 @@ public fun MutableList.swap(index1: Int, index2: Int): MutableList { return this } +/** + * Swaps values [index1] with [index2] in place. + * Returns the original collection if either [index1] or [index2] are not resent + */ +public fun MutableList.trySwap(index1: Int, index2: Int): MutableList { + val tmp = getOrNull(index1) ?: return this + this[index1] = getOrNull(index2) ?: return this + this[index2] = tmp + return this +} + /** * * Returns a shallow copy of this list with the items at [index1] and [index2] swapped. + * Throws [IndexOutOfBoundsException] when one or both indices are not present in the collection */ public fun List.swapped(index1: Int, index2: Int): List { val list = toMutableList() return list.swap(index1, index2) } +/** + * Returns a shallow copy of this list with the items at [index1] and [index2] swapped. + * Returns the original collection if either [index1] or [index2] are not resent + */ +public fun List.swappedOrDefault(index1: Int, index2: Int): List { + val list = toMutableList() + return list.trySwap(index1, index2) +} + /** * Returns a list of pairs, where each value corresponds to all possible pairings with values from [other]. * this: A, B, C @@ -157,6 +180,83 @@ public fun Collection.chunkedAverage(chunkSize: Int): List = ifEm public fun Iterable.filterBySubstring( substring: String?, ignoreCase: Boolean = false -): List = if (!substring.isValid) toList() else asSequence() - .filter { it.contains(substring!!, ignoreCase) } +): List = if (!substring.isValid()) toList() else asSequence() + .filter { it.contains(substring, ignoreCase) } .toList() + +/** + * A [sumOf] variation that returns a float instead of Double + */ +public inline fun Collection.sumOf(selector: (T) -> Float): Float { + var sum = 0f + for (element in this) { + sum += selector(element) + } + return sum +} + +/** + * Consume this iterator by taking the first [count] elements + */ +public fun Iterator.take(count: Int): List = List(count) { next() } + +/** + * Returns a [Map] containing key-value pairs provided by [key] and [value] functions + * applied to elements of the given sequence. + * + * If any of two pairs would have the same key the first one gets added to the map. + * + * The returned map preserves the entry iteration order of the original sequence. + * + * The operation is _terminal_. + */ +public inline fun Sequence.associateFirst( + key: (T) -> K, + value: (T) -> V, +): Map { + val dst = LinkedHashMap() + for (element in this) { + val newKey = key(element) + if (!dst.containsKey(newKey)) { + dst[newKey] = value(element) + } + } + return dst +} + +/** + * Returns a [Map] containing key-value pairs provided by [key] and [value] functions + * applied to elements of the given collection. + * + * If any of two pairs would have the same key the first one gets added to the map. + * + * The returned map preserves the entry iteration order of the original collection. + */ +public inline fun Iterable.associateFirst( + key: (T) -> K, + value: (T) -> V, +): Map { + val dst = LinkedHashMap() + for (element in this) { + val newKey = key(element) + if (!dst.containsKey(newKey)) { + dst[newKey] = value(element) + } + } + return dst +} + +/** + * Filters the values in this map, leaving only non-null entries + */ +@Suppress("UNCHECKED_CAST") +public fun Map.filterNotNullValues(): Map = filterValues { it != null } as Map + +/** + * The same as a regular [mapNotNull], but [transform] block contains the previous value of the list. + */ +public fun List.mapNotNull(transform: (prev: R?, value: T) -> R?): List { + val dst = ArrayList() + forEach { value -> transform(dst.lastOrNull(), value)?.let { dst.add(it) } } + return dst +} diff --git a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt index 894f059..a21904d 100644 --- a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt +++ b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/CommonExt.kt @@ -1,8 +1,10 @@ -@file:Suppress("unused") +@file:Suppress("unused", "NOTHING_TO_INLINE") package pro.respawn.kmmutils.common +import kotlin.contracts.contract import kotlin.enums.enumEntries +import kotlin.jvm.JvmName /** * @return Whether this string is valid @@ -14,14 +16,33 @@ import kotlin.enums.enumEntries * - "NULL" -> false * - " " -> false */ +@Deprecated("Use the function version as it allows for smart-casting", ReplaceWith("this.isValid()")) +@get:JvmName("getIsValid") public val String?.isValid: Boolean get() = !isNullOrBlank() && !equals("null", true) +/** + * @return Whether this string is valid + * + * Examples: + * - null -> false + * - "null" -> false + * - "" -> false + * - "NULL" -> false + * - " " -> false + */ +public inline fun String?.isValid(): Boolean { + contract { + returns(true) implies (this@isValid != null) + } + return !isNullOrBlank() && !equals("null", true) +} + /** * Takes this string only if it [isValid] * @see isValid */ -public fun String?.takeIfValid(): String? = if (isValid) this else null +public fun String?.takeIfValid(): String? = if (isValid()) this else null /** * Check if this String has length in [range] @@ -78,3 +99,24 @@ public inline val > Enum.previous: T */ @ExperimentalStdlibApi public inline val > Enum.previousOrNull: T? get() = enumEntries().getOrNull(ordinal - 1) + +/** + * Calls [requireNotNull] on this value and returns it + */ +public fun T?.requireNotNull(): T & Any = requireNotNull(this) + +/** + * Calls [requireNotNull] on this value and returns it + */ +public inline fun T?.requireNotNull(lazyMessage: () -> Unit): T & Any = requireNotNull(this, lazyMessage) + +/** + * If this is an [Error], throws it, otherwise returns [this] as an [Exception] + */ +public fun Throwable?.rethrowErrors(): Exception? = this?.let { it as? Exception? ?: throw it } + +/** + * If this is an [Error], throws it, otherwise returns [this] as an [Exception] + */ +@JvmName("rethrowErrorsNotNull") +public fun Throwable.rethrowErrors(): Exception = this as? Exception? ?: throw this diff --git a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/MathExt.kt b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/MathExt.kt index b87cfc5..fb64dc4 100644 --- a/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/MathExt.kt +++ b/common/src/commonMain/kotlin/pro/respawn/kmmutils/common/MathExt.kt @@ -93,6 +93,15 @@ public val Int.length: Int else -> log10(abs(this).toDouble()).toInt() + 1 } +/** + * @return The number of digits in this [Long] + */ +public val Long.length: Int + get() = when (this) { + 0L -> 1 + else -> log10(abs(this).toDouble()).toInt() + 1 + } + /** * @return 1 if this is `true`, and 0 otherwise. */ diff --git a/coroutines/src/commonMain/kotlin/pro/respawn/kmmutils/coroutines/RetryFlow.kt b/coroutines/src/commonMain/kotlin/pro/respawn/kmmutils/coroutines/RetryFlow.kt new file mode 100644 index 0000000..083c76a --- /dev/null +++ b/coroutines/src/commonMain/kotlin/pro/respawn/kmmutils/coroutines/RetryFlow.kt @@ -0,0 +1,44 @@ +package pro.respawn.kmmutils.coroutines + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * A [Flow] instance that can be retried by calling [retry]. + * Whatever block or flow emission was before will be re-evaluated (re-created) again. + */ +public interface RetryFlow : Flow { + + /** + * Retry the invocation of this flow. The flow that originally was used with this wrapper will be recreated + */ + public fun retry() +} + +/** + * Creates a new [RetryFlow] from [flow] builder + */ +public fun retryFlow(flow: suspend () -> Flow): RetryFlow = RetryFlowImpl(flow) + +/** + * Creates a new [RetryFlow] from this [call] function, evaluated as a cold flow + */ +public inline fun retry(crossinline call: suspend () -> T): RetryFlow = RetryFlowImpl({ flow { emit(call()) } }) + +@PublishedApi +internal class RetryFlowImpl( + producer: suspend () -> Flow, + private val delegate: Channel = Channel(Channel.CONFLATED), +) : RetryFlow, Flow by delegate.receiveAsFlow().flatMapLatest(transform = { producer() }) { + + init { + retry() + } + + override fun retry() { + delegate.trySend(Unit) + } +} diff --git a/datetime/src/commonMain/kotlin/pro/respawn/kmmutils/datetime/Time.kt b/datetime/src/commonMain/kotlin/pro/respawn/kmmutils/datetime/Time.kt deleted file mode 100644 index b2b7722..0000000 --- a/datetime/src/commonMain/kotlin/pro/respawn/kmmutils/datetime/Time.kt +++ /dev/null @@ -1,127 +0,0 @@ -@file:Suppress("unused", "MemberVisibilityCanBePrivate", "NewApi", "MagicNumber") - -package pro.respawn.kmmutils.datetime - -internal const val DeprecationMessage = """ -All functionality of the Time class was ported to kotlinx.datetime.LocalTime as extension functions and properties. -You can simply replace this with LocalTime and expect similar functionality to be present. -""" - -/** - * A class that represents time, of day as well as a asDuration. - * [hour] hour in 24h format - * [minute] minute - * [second] second - * [Time.toString] method returns a string representation for 24-hour format. If you want 12-hour - * time, use [asString]. - * @throws IllegalArgumentException if the specified values are invalid. Validation happens at - * object creation time - */ -@Deprecated(DeprecationMessage, ReplaceWith("LocalTime", "kotlinx.datetime.LocalTime")) -public data class Time @Throws(IllegalArgumentException::class) constructor( - val hour: Int, - val minute: Int, - val second: Int = 0, -) : Comparable