+ DateTime

This commit is contained in:
Sergey Chernov 2026-01-16 09:06:42 +03:00
parent b7dfda2f5d
commit 52a3a96e3f
7 changed files with 538 additions and 148 deletions

View File

@ -2,166 +2,99 @@
Lyng date and time support requires importing `lyng.time` packages. Lyng uses simple yet modern time object models:
- `Instant` class for time stamps with platform-dependent resolution
- `Duration` to represent amount of time not depending on the calendar, e.g. in absolute units (milliseconds, seconds,
hours, days)
- `Instant` class for absolute time stamps with platform-dependent resolution.
- `DateTime` class for calendar-aware points in time within a specific time zone.
- `Duration` to represent amount of time not depending on the calendar (e.g., milliseconds, seconds).
## Time instant: `Instant`
Represent some moment of time not depending on the calendar (calendar for example may b e changed, daylight saving time
can be for example introduced or dropped). It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin. Some moment of
time; not the calendar date.
Represent some moment of time not depending on the calendar. It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin.
Instant is comparable to other Instant. Subtracting instants produce `Duration`, period in time that is not dependent on
the calendar, e.g. absolute time period.
It is possible to add or subtract `Duration` to and from `Instant`, that gives another `Instant`.
Instants are converted to and from `Real` number of seconds before or after Unix Epoch, 01.01.1970. Constructor with
single number parameter constructs from such number of seconds,
and any instance provide `.epochSeconds` member:
### Constructing and converting
import lyng.time
// default constructor returns time now:
val t1 = Instant()
val t2 = Instant()
assert( t2 - t1 < 1.millisecond )
assert( t2.epochSeconds - t1.epochSeconds < 0.001 )
>>> void
## Constructing
// constructing from a number is treated as seconds since unix epoch:
val t2 = Instant(1704110400) // 2024-01-01T12:00:00Z
import lyng.time
// from RFC3339 string:
val t3 = Instant("2024-01-01T12:00:00Z")
// empty constructor gives current time instant using system clock:
val now = Instant()
// to localized DateTime (uses system default TZ if not specified):
val dt = t3.toDateTime("+02:00")
assertEquals(dt.hour, 14)
// constructor with Instant instance makes a copy:
assertEquals( now, Instant(now) )
// constructing from a number is trated as seconds since unix epoch:
val copyOfNow = Instant( now.epochSeconds )
// note that instant resolution is higher that Real can hold
// so reconstructed from real slightly differs:
assert( abs( (copyOfNow - now).milliseconds ) < 0.01 )
>>> void
The resolution of system clock could be more precise and double precision real number of `Real`, keep it in mind.
## Comparing and calculating periods
import lyng.time
val now = Instant()
// you cam add or subtract periods, and compare
assert( now - 5.minutes < now )
val oneHourAgo = now - 1.hour
assertEquals( now, oneHourAgo + 1.hour)
>>> void
## Getting the max precision
Normally, subtracting instants gives precision to microseconds, which is well inside the jitter
the language VM adds. Still `Instant()` or `Instant.now()` capture most precise system timer at hand and provide inner
value of 12 bytes, up to nanoseconds (hopefully). To access it use:
import lyng.time
// capture time
val now = Instant.now()
// this is Int value, number of whole epoch
// milliseconds to the moment, it fits 8 bytes Int well
val seconds = now.epochWholeSeconds
assert(seconds is Int)
// and this is Int value of nanoseconds _since_ the epochMillis,
// it effectively add 4 more mytes int:
val nanos = now.nanosecondsOfSecond
assert(nanos is Int)
assert( nanos in 0..999_999_999 )
// we can construct epochSeconds from these parts:
assertEquals( now.epochSeconds, nanos * 1e-9 + seconds )
>>> void
## Truncating to more realistic precision
Full precision Instant is way too long and impractical to store, especially when serializing,
so it is possible to truncate it to milliseconds, microseconds or seconds:
import lyng.time
import lyng.serialization
// max supported size (now microseconds for serialized value):
// note that encoding return _bit array_ and this is a _bit size_:
val s0 = Lynon.encode(Instant.now()).size
// shorter: milliseconds only
val s1 = Lynon.encode(Instant.now().truncateToMillisecond()).size
// truncated to seconds, good for file mtime, etc:
val s2 = Lynon.encode(Instant.now().truncateToSecond()).size
assert( s1 < s0 )
assert( s2 < s1 )
>>> void
## Formatting instants
You can freely use `Instant` in string formatting. It supports usual sprintf-style formats:
import lyng.time
val now = Instant()
// will be something like "now: 12:10:05"
val currentTimeOnly24 = "now: %tT"(now)
// we can extract epoch second with formatting too,
// this was since early C time
// get epoch while seconds from formatting
val unixEpoch = "Now is %ts since unix epoch"(now)
// and it is the same as now.epochSeconds, int part:
assertEquals( unixEpoch, "Now is %d since unix epoch"(now.epochSeconds.toInt()) )
>>> void
See
the [complete list of available formats](https://github.com/sergeych/mp_stools?tab=readme-ov-file#datetime-formatting)
and the [formatting reference](https://github.com/sergeych/mp_stools?tab=readme-ov-file#printf--sprintf): it all works
in Lyng as `"format"(args...)`!
## Instant members
### Instant members
| member | description |
|--------------------------------|---------------------------------------------------------|
| epochSeconds: Real | positive or negative offset in seconds since Unix epoch |
| epochWholeSeconds: Int | same, but in _whole seconds_. Slightly faster |
| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos (1) |
| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos |
| isDistantFuture: Bool | true if it `Instant.distantFuture` |
| isDistantPast: Bool | true if it `Instant.distantPast` |
| truncateToSecond: Intant | create new instnce truncated to second |
| truncateToMillisecond: Instant | truncate new instance with to millisecond |
| truncateToSecond: Instant | create new instance truncated to second |
| truncateToMillisecond: Instant | truncate new instance to millisecond |
| truncateToMicrosecond: Instant | truncate new instance to microsecond |
| toRFC3339(): String | format as RFC3339 string (UTC) |
| toDateTime(tz?): DateTime | localize to a TimeZone (ID string or offset seconds) |
(1)
: The value of nanoseconds is to be added to `epochWholeSeconds` to get exact time point. It is in 0..999_999_999 range.
The precise time instant value therefore needs as for now 12 bytes integer; we might use bigint later (it is planned to
be added)
## Calendar time: `DateTime`
## Class members
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components like year, month, and day.
| member | description |
|--------------------------------|----------------------------------------------|
| Instant.now() | create new instance with current system time |
| Instant.distantPast: Instant | most distant instant in past |
| Instant.distantFuture: Instant | most distant instant in future |
### Constructing
# `Duraion` class
import lyng.time
// Current time in system default timezone
val now = DateTime.now()
// Specific timezone
val offsetTime = DateTime.now("+02:00")
// From Instant
val dt = Instant().toDateTime("Z")
// By components (year, month, day, hour=0, minute=0, second=0, timeZone="UTC")
val dt2 = DateTime(2024, 1, 1, 12, 0, 0, "Z")
// From RFC3339 string
val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00")
### DateTime members
| member | description |
|-------------------|------------------------------------------------------|
| year: Int | year component |
| month: Int | month component (1..12) |
| day: Int | day of month (alias `dayOfMonth`) |
| hour: Int | hour component (0..23) |
| minute: Int | minute component (0..59) |
| second: Int | second component (0..59) |
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
| timeZone: String | timezone ID string |
| toInstant(): Instant | convert back to absolute Instant |
| toUTC(): DateTime | shortcut to convert to UTC |
| toTimeZone(tz): DateTime | convert to another timezone |
| addMonths(n): DateTime | add/subtract months (normalizes end of month) |
| addYears(n): DateTime | add/subtract years |
| toRFC3339(): String | format with timezone offset |
| static now(tz?): DateTime | create DateTime with current time |
| static parseRFC3339(s): DateTime | parse RFC3339 string |
### Arithmetic and normalization
`DateTime` handles calendar arithmetic correctly:
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
val nextYear = leapDay.addYears(1)
assertEquals(nextYear.day, 28) // Feb 29, 2024 -> Feb 28, 2025
# `Duration` class
Represent absolute time distance between two `Instant`.

View File

@ -5,6 +5,7 @@ kotlin = "2.3.0"
android-minSdk = "24"
android-compileSdk = "34"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.6.1"
mp_bintools = "0.3.2"
firebaseCrashlyticsBuildtools = "3.0.3"
okioVersion = "3.10.2"
@ -16,6 +17,7 @@ clikt-markdown = { module = "com.github.ajalt.clikt:clikt-markdown", version.ref
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" }
firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" }
okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" }

View File

@ -97,6 +97,7 @@ kotlin {
kotlin.srcDir("$buildDir/generated/buildConfig/commonMain/kotlin")
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
//put your multiplatform dependencies here
api(libs.kotlinx.coroutines.core)
api(libs.mp.bintools)

View File

@ -399,6 +399,12 @@ class Script(
doc = "Point in time (epoch-based).",
type = type("lyng.Class")
)
it.addConstDoc(
name = "DateTime",
value = ObjDateTime.type,
doc = "Point in time in a specific time zone.",
type = type("lyng.Class")
)
it.addConstDoc(
name = "Duration",
value = ObjDuration.type,

View File

@ -0,0 +1,293 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* 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.
*
*/
package net.sergeych.lyng.obj
import kotlinx.datetime.*
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
import net.sergeych.lyng.miniast.addClassFnDoc
import net.sergeych.lyng.miniast.addFnDoc
import net.sergeych.lyng.miniast.addPropertyDoc
import net.sergeych.lyng.miniast.type
import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType
class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
override val objClass: ObjClass get() = type
val localDateTime: LocalDateTime by lazy {
instant.toLocalDateTime(timeZone)
}
override fun toString(): String {
return localDateTime.toString() + timeZone.toString()
}
override suspend fun readField(scope: Scope, name: String): ObjRecord {
for (cls in objClass.mro) {
val rec = cls.members[name]
if (rec != null) {
if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as? ObjProperty
?: (rec.value as? Statement)?.execute(scope) as? ObjProperty
if (prop != null) {
return ObjRecord(prop.callGetter(scope, this, rec.declaringClass ?: cls), rec.isMutable)
}
}
if (rec.type == ObjRecord.Type.Fun || rec.value is Statement) {
val s = rec.value as Statement
return ObjRecord(net.sergeych.lyng.statement { s.execute(this.createChildScope(newThisObj = this@ObjDateTime)) }, rec.isMutable)
}
return resolveRecord(scope, rec, name, rec.declaringClass ?: cls)
}
}
return super.readField(scope, name)
}
override suspend fun plus(scope: Scope, other: Obj): Obj {
return when (other) {
is ObjDuration -> ObjDateTime(instant + other.duration, timeZone)
else -> super.plus(scope, other)
}
}
override suspend fun minus(scope: Scope, other: Obj): Obj {
return when (other) {
is ObjDuration -> ObjDateTime(instant - other.duration, timeZone)
is ObjDateTime -> ObjDuration(instant - other.instant)
is ObjInstant -> ObjDuration(instant - other.instant)
else -> super.minus(scope, other)
}
}
override suspend fun compareTo(scope: Scope, other: Obj): Int {
if (other is ObjDateTime) {
return instant.compareTo(other.instant)
}
if (other is ObjInstant) {
return instant.compareTo(other.instant)
}
return super.compareTo(scope, other)
}
override suspend fun toKotlin(scope: Scope): Any {
return localDateTime
}
override fun hashCode(): Int {
return instant.hashCode() xor timeZone.hashCode()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ObjDateTime) return false
return instant == other.instant && timeZone == other.timeZone
}
override suspend fun lynonType(): LynonType = LynonType.Other
override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) {
encoder.encodeCached(this) {
encodeAny(scope, ObjInstant(instant))
encodeCached(timeZone.id) {
encodeBinaryData(timeZone.id.encodeToByteArray())
}
}
}
override suspend fun toJson(scope: Scope): JsonElement = JsonPrimitive(toRFC3339())
fun toRFC3339(): String {
val s = localDateTime.toString()
val tz = if (timeZone == TimeZone.UTC) "Z" else timeZone.id
return if (tz.startsWith("+") || tz.startsWith("-") || tz == "Z") s + tz else s + "[" + tz + "]"
}
companion object {
val type = object : ObjClass("DateTime") {
override suspend fun callOn(scope: Scope): Obj {
val args = scope.args
return when (val a0 = args.list.getOrNull(0)) {
is ObjInstant -> {
val tz = when (val a1 = args.list.getOrNull(1)) {
null -> TimeZone.currentSystemDefault()
is ObjString -> TimeZone.of(a1.value)
is ObjInt -> UtcOffset(seconds = a1.value.toInt()).asTimeZone()
else -> scope.raiseIllegalArgument("invalid timezone: $a1")
}
ObjDateTime(a0.instant, tz)
}
is ObjInt -> {
// DateTime(year, month, day, hour=0, minute=0, second=0, timeZone="UTC")
val year = a0.value.toInt()
val month = args.list.getOrNull(1)?.toInt() ?: scope.raiseIllegalArgument("month is required")
val day = args.list.getOrNull(2)?.toInt() ?: scope.raiseIllegalArgument("day is required")
val hour = args.list.getOrNull(3)?.toInt() ?: 0
val minute = args.list.getOrNull(4)?.toInt() ?: 0
val second = args.list.getOrNull(5)?.toInt() ?: 0
val tz = when (val a6 = args.list.getOrNull(6)) {
null -> TimeZone.UTC
is ObjString -> TimeZone.of(a6.value)
is ObjInt -> UtcOffset(seconds = a6.value.toInt()).asTimeZone()
else -> scope.raiseIllegalArgument("invalid timezone: $a6")
}
val ldt = LocalDateTime(year, month, day, hour, minute, second)
ObjDateTime(ldt.toInstant(tz), tz)
}
is ObjString -> {
val instant = Instant.parse(a0.value)
ObjDateTime(instant, TimeZone.UTC)
}
else -> scope.raiseIllegalArgument("can't construct DateTime from ${args.inspect(scope)}")
}
}
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
return decoder.decodeCached {
val instant = (decoder.decodeAny(scope) as ObjInstant).instant
val tzId = decoder.decodeCached { decoder.unpackBinaryData().decodeToString() }
ObjDateTime(instant, TimeZone.of(tzId))
}
}
}.apply {
addPropertyDoc("year", "The year component.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.year.toObj() })
addPropertyDoc("month", "The month component (1..12).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.monthNumber.toObj() })
addPropertyDoc("dayOfMonth", "The day of month component.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.dayOfMonth.toObj() })
addPropertyDoc("day", "Alias to dayOfMonth.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.dayOfMonth.toObj() })
addPropertyDoc("hour", "The hour component (0..23).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.hour.toObj() })
addPropertyDoc("minute", "The minute component (0..59).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.minute.toObj() })
addPropertyDoc("second", "The second component (0..59).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.second.toObj() })
addPropertyDoc("dayOfWeek", "The day of week (1=Monday, 7=Sunday).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().localDateTime.dayOfWeek.isoDayNumber.toObj() })
addPropertyDoc("timeZone", "The time zone ID (e.g. 'Z', '+02:00', 'Europe/Prague').", type("lyng.String"), moduleName = "lyng.time",
getter = { thisAs<ObjDateTime>().timeZone.id.toObj() })
addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") {
ObjInstant(thisAs<ObjDateTime>().instant)
}
addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") {
thisAs<ObjDateTime>().instant.epochSeconds.toObj()
}
addFnDoc("toRFC3339", "Return the RFC3339 string representation of this date time, including its timezone offset.", returns = type("lyng.String"), moduleName = "lyng.time") {
thisAs<ObjDateTime>().toRFC3339().toObj()
}
addFnDoc("toSortableString", "Alias to toRFC3339.", returns = type("lyng.String"), moduleName = "lyng.time") {
thisAs<ObjDateTime>().toRFC3339().toObj()
}
addFnDoc("toEpochMilliseconds", "Return the number of milliseconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") {
thisAs<ObjDateTime>().instant.toEpochMilliseconds().toObj()
}
addFnDoc("toTimeZone", "Return a new DateTime representing the same instant but in a different time zone. " +
"Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds.",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val tz = when (val a = args.list.getOrNull(0)) {
is ObjString -> TimeZone.of(a.value)
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
else -> raiseIllegalArgument("invalid timezone: $a")
}
ObjDateTime(thisAs<ObjDateTime>().instant, tz)
}
addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") {
ObjDateTime(thisAs<ObjDateTime>().instant, TimeZone.UTC)
}
addFnDoc("addMonths", "Return a new DateTime with the specified number of months added (or subtracted if negative). " +
"Normalizes the day of month if necessary (e.g., Jan 31 + 1 month = Feb 28/29).",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("months", type = type("lyng.Int"))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val n = args.list.getOrNull(0)?.toInt() ?: 0
val res = thisAs<ObjDateTime>().instant.plus(n, DateTimeUnit.MONTH, thisAs<ObjDateTime>().timeZone)
ObjDateTime(res, thisAs<ObjDateTime>().timeZone)
}
addFnDoc("addYears", "Return a new DateTime with the specified number of years added (or subtracted if negative).",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("years", type = type("lyng.Int"))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val n = args.list.getOrNull(0)?.toInt() ?: 0
val res = thisAs<ObjDateTime>().instant.plus(n, DateTimeUnit.YEAR, thisAs<ObjDateTime>().timeZone)
ObjDateTime(res, thisAs<ObjDateTime>().timeZone)
}
addClassFn("now") {
val tz = when (val a = args.list.getOrNull(0)) {
null -> TimeZone.currentSystemDefault()
is ObjString -> TimeZone.of(a.value)
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
else -> raiseIllegalArgument("invalid timezone: $a")
}
ObjDateTime(kotlin.time.Clock.System.now(), tz)
}
addClassFnDoc("parseRFC3339",
"Parse an RFC3339 string into a DateTime object. " +
"Note: if the string does not specify a timezone, UTC is assumed.",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("string", type = type("lyng.String"))),
returns = type("lyng.DateTime"),
moduleName = "lyng.time") {
val s = (args.firstAndOnly() as ObjString).value
// kotlinx-datetime's Instant.parse handles RFC3339
// But we want to preserve the offset if present for DateTime.
// However, Instant.parse("...") always gives an Instant.
// If we want the specific offset from the string, we might need a more complex parse.
// For now, let's stick to parsing it as Instant and converting to UTC or specified TZ.
// Actually, if the string has an offset, Instant.parse handles it but returns UTC instant.
// Let's try to detect if there is an offset in the string.
// If not, use UTC.
val instant = Instant.parse(s)
// RFC3339 can have Z or +/-HH:mm or +/-HHmm or +/-HH
val tz = try {
if (s.endsWith("Z", ignoreCase = true)) {
TimeZone.of("Z")
} else {
// Look for the last + or - which is likely the start of the offset
val lastPlus = s.lastIndexOf('+')
val lastMinus = s.lastIndexOf('-')
val offsetStart = if (lastPlus > lastMinus) lastPlus else lastMinus
if (offsetStart > s.lastIndexOf('T')) {
// Likely an offset
val offsetStr = s.substring(offsetStart)
TimeZone.of(offsetStr)
} else {
TimeZone.UTC
}
}
} catch (e: Exception) {
TimeZone.UTC
}
ObjDateTime(instant, tz)
}
}
}
}

View File

@ -17,6 +17,10 @@
package net.sergeych.lyng.obj
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.asTimeZone
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.Scope
@ -28,7 +32,6 @@ import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonSettings
import net.sergeych.lynon.LynonType
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.isDistantFuture
import kotlin.time.isDistantPast
@ -122,6 +125,7 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru
val nanos = (a0.value - seconds) * 1e9
Instant.fromEpochSeconds(seconds, nanos.toLong())
}
is ObjString -> Instant.parse(a0.value)
is ObjInstant -> a0.instant
else -> {
@ -222,6 +226,43 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru
LynonSettings.InstantTruncateMode.Microsecond
)
}
addFnDoc(
name = "toRFC3339",
doc = "Return the RFC3339 string representation of this instant in UTC (e.g., '1970-01-01T00:00:00Z').",
returns = type("lyng.String"),
moduleName = "lyng.time"
) {
thisAs<ObjInstant>().instant.toString().toObj()
}
addFnDoc(
name = "toSortableString",
doc = "Alias to toRFC3339.",
returns = type("lyng.String"),
moduleName = "lyng.time"
) {
thisAs<ObjInstant>().instant.toString().toObj()
}
addFnDoc(
name = "toDateTime",
doc = "Convert this absolute instant to a localized DateTime object in the specified time zone. " +
"Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds. " +
"If no argument is provided, the system's current default time zone is used.",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any", true))),
returns = type("lyng.DateTime"),
moduleName = "lyng.time"
) {
val tz = when (val a = args.list.getOrNull(0)) {
null -> TimeZone.currentSystemDefault()
is ObjString -> TimeZone.of(a.value)
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
else -> raiseIllegalArgument("invalid timezone: $a")
}
ObjDateTime(thisAs<ObjInstant>().instant, tz)
}
// class members
addClassConst("distantFuture", distantFuture)

View File

@ -3209,21 +3209,135 @@ class ScriptTest {
)
}
@Test
fun testDateTimeComprehensive() = runTest {
eval("""
import lyng.time
import lyng.serialization
// 1. Timezone variations
val t1 = Instant("2024-01-01T12:00:00Z")
val dtZ = t1.toDateTime("Z")
assertEquals(dtZ.timeZone, "Z")
assertEquals(dtZ.hour, 12)
val dtP2 = t1.toDateTime("+02:00")
assertEquals(dtP2.timeZone, "+02:00")
assertEquals(dtP2.hour, 14)
val dtM330 = t1.toDateTime("-03:30")
assertEquals(dtM330.timeZone, "-03:30")
assertEquals(dtM330.hour, 8)
assertEquals(dtM330.minute, 30)
// 2. RFC3339 representations
// Note: ObjDateTime.toString() currently uses localDateTime.toString() + timeZone.toString()
// We should verify what it actually produces.
val s1 = dtP2.toRFC3339()
// kotlinx-datetime LocalDateTime.toString() is ISO8601
// TimeZone.toString() for offsets is usually the offset string itself
println("dtP2 RFC3339: " + s1)
// 3. Parsing
val t2 = Instant("2024-02-29T10:00:00+01:00")
assertEquals(t2.toDateTime("Z").hour, 9)
// val dt3 = DateTime(t1, "Europe/Prague")
// assertEquals(dt3.timeZone, "Europe/Prague")
// 4. Serialization (Lynon)
val bin = Lynon.encode(dtP2)
val dtP2_dec = Lynon.decode(bin)
assertEquals(dtP2, dtP2_dec)
assertEquals(dtP2_dec.hour, 14)
assertEquals(dtP2_dec.timeZone, "+02:00")
// 5. Serialization (JSON)
// val json = Lynon.toJson(dtM330) // toJson is not on Lynon yet
// println("JSON: " + json)
// 6. Arithmetic edge cases
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
val nextYear = leapDay.addYears(1)
assertEquals(nextYear.year, 2025)
assertEquals(nextYear.month, 2)
assertEquals(nextYear.day, 28) // Normalized
val monthEnd = Instant("2024-01-31T12:00:00Z").toDateTime("Z")
val nextMonth = monthEnd.addMonths(1)
assertEquals(nextMonth.month, 2)
assertEquals(nextMonth.day, 29) // 2024 is leap year
// 7. Day of week
assertEquals(Instant("2024-01-01T12:00:00Z").toDateTime("Z").dayOfWeek, 1) // Monday
assertEquals(Instant("2024-01-07T12:00:00Z").toDateTime("Z").dayOfWeek, 7) // Sunday
// 8. DateTime to/from Instant
val inst = dtP2.toInstant()
assertEquals(inst, t1)
assertEquals(dtP2.toEpochSeconds(), t1.epochWholeSeconds)
// 9. toUTC and toTimeZone
val dtUTC = dtP2.toUTC()
assertEquals(dtUTC.timeZone, "UTC")
assertEquals(dtUTC.hour, 12)
val dtPrague = dtUTC.toTimeZone("+01:00")
// Equivalent to Prague winter
assertEquals(dtPrague.hour, 13)
// 10. Component-based constructor
val dtComp = DateTime(2024, 5, 20, 15, 30, 45, "+02:00")
assertEquals(dtComp.year, 2024)
assertEquals(dtComp.month, 5)
assertEquals(dtComp.day, 20)
assertEquals(dtComp.hour, 15)
assertEquals(dtComp.minute, 30)
assertEquals(dtComp.second, 45)
assertEquals(dtComp.timeZone, "+02:00")
// 11. parseRFC3339
val dtParsed = DateTime.parseRFC3339("2024-05-20T15:30:45+02:00")
assertEquals(dtParsed.year, 2024)
assertEquals(dtParsed.hour, 15)
assertEquals(dtParsed.timeZone, "+02:00")
val dtParsedZ = DateTime.parseRFC3339("2024-05-20T15:30:45Z")
assertEquals(dtParsedZ.timeZone, "Z")
assertEquals(dtParsedZ.hour, 15)
""".trimIndent())
}
@Test
fun testInstantComponents() = runTest {
// This is a proposal
"""
val t1 = Instant.fromRFC3339("1970-05-06T07:11:56Z")
// components use default system calendar or modern
assertEquals(t1.year, 1970)
assertEquals(t1.month, 5)
assertEquals(t1.dayOfMonth, 6)
assertEquals(t1.hour, 7)
assertEquals(t1.minute, 11)
assertEquals(t1.second, 56)
eval("""
import lyng.time
val t1 = Instant("1970-05-06T07:11:56Z")
val dt = t1.toDateTime("Z")
assertEquals(dt.year, 1970)
assertEquals(dt.month, 5)
assertEquals(dt.day, 6)
assertEquals(dt.hour, 7)
assertEquals(dt.minute, 11)
assertEquals(dt.second, 56)
assertEquals(dt.dayOfWeek, 3) // 1970-05-06 was Wednesday
assertEquals("1970-05-06T07:11:56Z", t1.toRFC3339())
assertEquals("1970-05-06T07:11:56Z", t1.toSortableString())
""".trimIndent()
val dt2 = dt.toTimeZone("+02:00")
assertEquals(dt2.hour, 9)
assertEquals(dt2.timeZone, "+02:00")
val dt3 = dt.addMonths(1)
assertEquals(dt3.month, 6)
assertEquals(dt3.day, 6)
val dt4 = dt.addYears(1)
assertEquals(dt4.year, 1971)
assertEquals(dt.toInstant(), t1)
""".trimIndent())
}
@Test