diff --git a/libraries/stdlib/api/js-v1/kotlin.time.kt b/libraries/stdlib/api/js-v1/kotlin.time.kt index 3c196b1420f..b8a9a347f8c 100644 --- a/libraries/stdlib/api/js-v1/kotlin.time.kt +++ b/libraries/stdlib/api/js-v1/kotlin.time.kt @@ -342,6 +342,18 @@ public final inline class Duration : kotlin.Comparable { @kotlin.SinceKotlin(version = "1.5") public final fun nanoseconds(value: kotlin.Long): kotlin.time.Duration + @kotlin.SinceKotlin(version = "1.5") + public final fun parse(value: kotlin.String): kotlin.time.Duration + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseIsoString(value: kotlin.String): kotlin.time.Duration + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseIsoStringOrNull(value: kotlin.String): kotlin.time.Duration? + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseOrNull(value: kotlin.String): kotlin.time.Duration? + @kotlin.SinceKotlin(version = "1.5") public final fun seconds(value: kotlin.Double): kotlin.time.Duration diff --git a/libraries/stdlib/api/js/kotlin.time.kt b/libraries/stdlib/api/js/kotlin.time.kt index 3c196b1420f..b8a9a347f8c 100644 --- a/libraries/stdlib/api/js/kotlin.time.kt +++ b/libraries/stdlib/api/js/kotlin.time.kt @@ -342,6 +342,18 @@ public final inline class Duration : kotlin.Comparable { @kotlin.SinceKotlin(version = "1.5") public final fun nanoseconds(value: kotlin.Long): kotlin.time.Duration + @kotlin.SinceKotlin(version = "1.5") + public final fun parse(value: kotlin.String): kotlin.time.Duration + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseIsoString(value: kotlin.String): kotlin.time.Duration + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseIsoStringOrNull(value: kotlin.String): kotlin.time.Duration? + + @kotlin.SinceKotlin(version = "1.5") + public final fun parseOrNull(value: kotlin.String): kotlin.time.Duration? + @kotlin.SinceKotlin(version = "1.5") public final fun seconds(value: kotlin.Double): kotlin.time.Duration diff --git a/libraries/stdlib/src/kotlin/time/Duration.kt b/libraries/stdlib/src/kotlin/time/Duration.kt index 634b1d0256f..2f167a2c24f 100644 --- a/libraries/stdlib/src/kotlin/time/Duration.kt +++ b/libraries/stdlib/src/kotlin/time/Duration.kt @@ -174,6 +174,63 @@ public value class Duration internal constructor(private val rawValue: Long) : C @SinceKotlin("1.5") public fun days(value: Double): Duration = value.toDuration(DurationUnit.DAYS) + /** + * Parses a string that represents a duration and returns the parsed [Duration] value. + * + * The following formats are accepted: + * + * - ISO-8601 Duration format, e.g. `P1DT2H3M4.058S`, see [toIsoString] and [parseIsoString]. + * - The format of string returned by the default [Duration.toString] and `toString` in a specific unit, + * e.g. `10s`, `1h 30m` or `-(1h 30m)`. + * + * @throws IllegalArgumentException if the string doesn't represent a duration in any of the supported formats. + */ + @SinceKotlin("1.5") + public fun parse(value: String): Duration = try { + parseDuration(value, strictIso = false) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid duration string format: $value", e.takeIf { it.message != null }) + } + + /** + * Parses a string that represents a duration in ISO-8601 format and returns the parsed [Duration] value. + * + * @throws IllegalArgumentException if the string doesn't represent a duration in ISO-8601 format. + */ + @SinceKotlin("1.5") + public fun parseIsoString(value: String): Duration = try { + parseDuration(value, strictIso = true) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid ISO duration string format: $value", e.takeIf { it.message != null }) + } + + /** + * Parses a string that represents a duration and returns the parsed [Duration] value, + * or `null` if the string doesn't represent a duration in any of the supported formats. + * + * The following formats are accepted: + * + * - ISO-8601 Duration format, e.g. `P1DT2H3M4.058S`, see [toIsoString] and [parseIsoString]. + * - The format of string returned by the default [Duration.toString] and `toString` in a specific unit, + * e.g. `10s`, `1h 30m` or `-(1h 30m)`. + */ + @SinceKotlin("1.5") + public fun parseOrNull(value: String): Duration? = try { + parseDuration(value, strictIso = false) + } catch (e: IllegalArgumentException) { + null + } + + /** + * Parses a string that represents a duration in ISO-8601 format and returns the parsed [Duration] value, + * or `null` if the string doesn't represent a duration in ISO-8601 format. + */ + @SinceKotlin("1.5") + public fun parseIsoStringOrNull(value: String): Duration? = try { + parseDuration(value, strictIso = true) + } catch (e: IllegalArgumentException) { + null + } } // arithmetic operators @@ -1008,6 +1065,98 @@ public inline operator fun Int.times(duration: Duration): Duration = duration * public inline operator fun Double.times(duration: Duration): Duration = duration * this + +@ExperimentalTime +private fun parseDuration(value: String, strictIso: Boolean): Duration { + val length = value.length + if (length == 0) throw IllegalArgumentException("The string is empty") + var index = 0 + var result = Duration.ZERO + val infinityString = "Infinity" + when (value[index]) { + '+', '-' -> index++ + } + when { + length <= index -> + throw IllegalArgumentException() + value[index] == 'P' -> { + index++ + val signedDigits = "+-0123456789." + var isTimeComponent = false + var prevUnit: DurationUnit? = null + while (index < length) { + if (value[index] == 'T') { + if (isTimeComponent || index++ == length) throw IllegalArgumentException() + isTimeComponent = true + continue + } + val component = value.substringWhile(index) { it in signedDigits } + if (component.isEmpty()) throw IllegalArgumentException() + index += component.length + val unitChar = value.getOrElse(index) { throw IllegalArgumentException("Missing unit for value $component") } + index++ + val unit = durationUnitByIsoChar(unitChar, isTimeComponent) + if (prevUnit != null && prevUnit <= unit) throw IllegalArgumentException("Unexpected order of duration components") + prevUnit = unit + if (unit == DurationUnit.SECONDS && '.' in component) { + // TODO: parse fractional part separately + result += component.toDouble().toDuration(unit) + } else { + result += component.toLong().toDuration(unit) + } + } + } + strictIso -> + throw IllegalArgumentException() + value.regionMatches(index, infinityString, 0, length = maxOf(length - index, infinityString.length), ignoreCase = true) -> { + result = Duration.INFINITE + } + else -> { + // parse default string format + val digits = "0123456789." + var prevUnit: DurationUnit? = null + var afterFirst = false + while (index < length) { + if (afterFirst) { + index = value.skipWhile(index) { it == ' ' } + } + afterFirst = true + val component = value.substringWhile(index) { it in digits } + if (component.isEmpty()) throw IllegalArgumentException() + index += component.length + val unitName = value.substringWhile(index) { it in 'a'..'z' } + index += unitName.length + val unit = durationUnitByShortName(unitName) + if (prevUnit != null && prevUnit <= unit) throw IllegalArgumentException("Unexpected order of duration components") + prevUnit = unit + if ('.' in component) { + // TODO: parse fractional part separately + result += component.toDouble().toDuration(unit) + if (index < length) throw IllegalArgumentException("Fractional component must be last") + } else { + result += component.toLong().toDuration(unit) + } + } + } + } + return if (value.startsWith('-')) -result else result +} + + + +private inline fun String.substringWhile(startIndex: Int, predicate: (Char) -> Boolean): String = + substring(startIndex, skipWhile(startIndex, predicate)) + +private inline fun String.skipWhile(startIndex: Int, predicate: (Char) -> Boolean): Int { + var i = startIndex + while (i < length && predicate(this[i])) i++ + return i +} + + + + + // The ranges are chosen so that they are: // - symmetric relative to zero: this greatly simplifies operations with sign, e.g. unaryMinus and minus. // - non-overlapping, but adjacent: the first value that doesn't fit in nanos range, can be exactly represented in millis. diff --git a/libraries/stdlib/src/kotlin/time/DurationUnit.kt b/libraries/stdlib/src/kotlin/time/DurationUnit.kt index 0fef53256eb..8b6af010cc9 100644 --- a/libraries/stdlib/src/kotlin/time/DurationUnit.kt +++ b/libraries/stdlib/src/kotlin/time/DurationUnit.kt @@ -76,3 +76,36 @@ internal fun DurationUnit.shortName(): String = when (this) { DurationUnit.DAYS -> "d" else -> error("Unknown unit: $this") } + +@SinceKotlin("1.5") +@ExperimentalTime +internal fun durationUnitByShortName(shortName: String): DurationUnit = when (shortName) { + "ns" -> DurationUnit.NANOSECONDS + "us" -> DurationUnit.MICROSECONDS + "ms" -> DurationUnit.MILLISECONDS + "s" -> DurationUnit.SECONDS + "m" -> DurationUnit.MINUTES + "h" -> DurationUnit.HOURS + "d" -> DurationUnit.DAYS + else -> throw IllegalArgumentException("Unknown duration unit short name: $shortName") +} + +@SinceKotlin("1.5") +@ExperimentalTime +internal fun durationUnitByIsoChar(isoChar: Char, isTimeComponent: Boolean): DurationUnit = + when { + !isTimeComponent -> { + when (isoChar) { + 'D' -> DurationUnit.DAYS + else -> throw IllegalArgumentException("Invalid or unsupported duration ISO non-time unit: $isoChar") + } + } + else -> { + when (isoChar) { + 'H' -> DurationUnit.HOURS + 'M' -> DurationUnit.MINUTES + 'S' -> DurationUnit.SECONDS + else -> throw IllegalArgumentException("Invalid duration ISO time unit: $isoChar") + } + } + } \ No newline at end of file diff --git a/libraries/stdlib/test/time/DurationTest.kt b/libraries/stdlib/test/time/DurationTest.kt index 4b9e8744db0..ef158e551db 100644 --- a/libraries/stdlib/test/time/DurationTest.kt +++ b/libraries/stdlib/test/time/DurationTest.kt @@ -454,38 +454,51 @@ class DurationTest { } @Test - fun toIsoString() { + fun isoStringFormatAndParse() { + fun test(duration: Duration, vararg isoStrings: String) { + assertEquals(isoStrings.first(), duration.toIsoString()) + for (isoString in isoStrings) { + assertEquals(duration, Duration.parseIsoString(isoString), isoString) + assertEquals(duration, Duration.parse(isoString), isoString) + assertEquals(duration, Duration.parseIsoStringOrNull(isoString), isoString) + assertEquals(duration, Duration.parseOrNull(isoString), isoString) + } + } + // zero - assertEquals("PT0S", Duration.ZERO.toIsoString()) + test(Duration.ZERO, "PT0S", "P0D", "PT0H", "PT0M", "P0DT0H", "PT0H0M", "PT0H0S") // single unit - assertEquals("PT24H", Duration.days(1).toIsoString()) - assertEquals("PT1H", Duration.hours(1).toIsoString()) - assertEquals("PT1M", Duration.minutes(1).toIsoString()) - assertEquals("PT1S", Duration.seconds(1).toIsoString()) - assertEquals("PT0.001S", Duration.milliseconds(1).toIsoString()) - assertEquals("PT0.000001S", Duration.microseconds(1).toIsoString()) - assertEquals("PT0.000000001S", Duration.nanoseconds(1).toIsoString()) - assertEquals("PT0.000000001S", Duration.nanoseconds(0.9).toIsoString()) + test(Duration.days(1), "PT24H", "P1D", "PT1440M", "PT86400S") + test(Duration.hours(1), "PT1H") + test(Duration.minutes(1), "PT1M") + test(Duration.seconds(1), "PT1S") + test(Duration.milliseconds(1), "PT0.001S") + test(Duration.microseconds(1), "PT0.000001S") + test(Duration.nanoseconds(1), "PT0.000000001S", "PT0.0000000009S") + test(Duration.nanoseconds(0.9), "PT0.000000001S") // rounded to zero - assertEquals("PT0S", Duration.nanoseconds(0.1).toIsoString()) + test(Duration.nanoseconds(0.1), "PT0S") + test(Duration.ZERO, "PT0S", "PT0.0000000004S") // several units combined - assertEquals("PT24H1M", (Duration.days(1) + Duration.minutes(1)).toIsoString()) - assertEquals("PT24H0M1S", (Duration.days(1) + Duration.seconds(1)).toIsoString()) - assertEquals("PT24H0M0.001S", (Duration.days(1) + Duration.milliseconds(1)).toIsoString()) - assertEquals("PT1H30M", (Duration.hours(1) + Duration.minutes(30)).toIsoString()) - assertEquals("PT1H0M0.500S", (Duration.hours(1) + Duration.milliseconds(500)).toIsoString()) - assertEquals("PT2M0.500S", (Duration.minutes(2) + Duration.milliseconds(500)).toIsoString()) - assertEquals("PT1M30.500S", (Duration.milliseconds(90_500)).toIsoString()) + test(Duration.days(1) + Duration.minutes(1), "PT24H1M") + test(Duration.days(1) + Duration.seconds(1), "PT24H0M1S") + test(Duration.days(1) + Duration.milliseconds(1), "PT24H0M0.001S") + test(Duration.hours(1) + Duration.minutes(30), "PT1H30M") + test(Duration.hours(1) + Duration.milliseconds(500), "PT1H0M0.500S") + test(Duration.minutes(2) + Duration.milliseconds(500), "PT2M0.500S") + test(Duration.milliseconds(90_500), "PT1M30.500S") // negative - assertEquals("-PT23H45M", (-Duration.days(1) + Duration.minutes(15)).toIsoString()) - assertEquals("-PT24H15M", (-Duration.days(1) - Duration.minutes(15)).toIsoString()) + test(-Duration.days(1) + Duration.minutes(15), "-PT23H45M", "PT-23H-45M", "PT-24H15M") + test(-Duration.days(1) - Duration.minutes(15), "-PT24H15M", "PT-24H-15M", "-PT25H-45M") + test(Duration.ZERO, "PT0S", "P1DT-24H", "PT-1H60M", "-PT1M-60S") // infinite - assertEquals("PT9999999999999H", Duration.INFINITE.toIsoString()) + test(Duration.INFINITE, "PT9999999999999H", "PT10000000000000H") + test(-Duration.INFINITE, "-PT9999999999999H", "-PT10000000000000H") } @Test diff --git a/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt b/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt index a0c2ebdc94c..a60ed5bf0b0 100644 --- a/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt +++ b/libraries/tools/binary-compatibility-validator/reference-public-api/kotlin-stdlib-runtime-merged.txt @@ -5730,6 +5730,10 @@ public final class kotlin/time/Duration$Companion { public final fun nanoseconds-UwyO8pc (D)J public final fun nanoseconds-UwyO8pc (I)J public final fun nanoseconds-UwyO8pc (J)J + public final fun parse-UwyO8pc (Ljava/lang/String;)J + public final fun parseIsoString-UwyO8pc (Ljava/lang/String;)J + public final fun parseIsoStringOrNull-FghU774 (Ljava/lang/String;)Lkotlin/time/Duration; + public final fun parseOrNull-FghU774 (Ljava/lang/String;)Lkotlin/time/Duration; public final fun seconds-UwyO8pc (D)J public final fun seconds-UwyO8pc (I)J public final fun seconds-UwyO8pc (J)J