Add Date type and database release docs

This commit is contained in:
Sergey Chernov 2026-04-15 23:20:23 +03:00
parent 14dc73db3e
commit b3be908242
19 changed files with 928 additions and 106 deletions

View File

@ -7,6 +7,27 @@ History note:
- Entries below are synchronized and curated for `1.5.x`. - Entries below are synchronized and curated for `1.5.x`.
- Earlier history may be incomplete and should be cross-checked with git tags/commits when needed. - Earlier history may be incomplete and should be cross-checked with git tags/commits when needed.
## Unreleased
### Database access
- Added the portable `lyng.io.db` SQL contract and the first concrete provider, `lyng.io.db.sqlite`.
- Added SQLite support on JVM and Linux Native with:
- generic `openDatabase("sqlite:...")` dispatch
- typed `openSqlite(...)` helper
- real nested transactions via savepoints
- generated keys through `ExecutionResult.getGeneratedKeys()`
- strict schema-driven value conversion for `Bool`, `Decimal`, `Date`, `DateTime`, and `Instant`
- documented option handling for `readOnly`, `createIfMissing`, `foreignKeys`, and `busyTimeoutMillis`
- Added public docs for database usage and SQLite provider behavior.
### Time
- Added `Date` to `lyng.time` and the core library as a first-class calendar-date type.
- Added `Instant.toDate(...)`, `DateTime.date`, `DateTime.toDate()`, `Date.toDateTime(...)`, and related date arithmetic.
- Added docs, stdlib reference updates, serialization support, and comprehensive tests for `Date`.
### Release notes
- Full `:lyngio:jvmTest` and `:lyngio:linuxX64Test` pass on the release tree after SQLite hardening.
## 1.5.4 (2026-04-03) ## 1.5.4 (2026-04-03)
### Runtime and compiler stability ### Runtime and compiler stability

View File

@ -52,6 +52,8 @@ assertEquals(A.E.One, A.One)
- [What's New in 1.5](docs/whats_new_1_5.md) - [What's New in 1.5](docs/whats_new_1_5.md)
- [Testing and Assertions](docs/Testing.md) - [Testing and Assertions](docs/Testing.md)
- [Filesystem and Processes (lyngio)](docs/lyngio.md) - [Filesystem and Processes (lyngio)](docs/lyngio.md)
- [SQL Databases (lyng.io.db)](docs/lyng.io.db.md)
- [Time and Calendar Types](docs/time.md)
- [Return Statement](docs/return_statement.md) - [Return Statement](docs/return_statement.md)
- [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md) - [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md)
- [Samples directory](docs/samples) - [Samples directory](docs/samples)

View File

@ -81,7 +81,7 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
- `import lyng.serialization` - `import lyng.serialization`
- `Lynon` serialization utilities. - `Lynon` serialization utilities.
- `import lyng.time` - `import lyng.time`
- `Instant`, `DateTime`, `Duration`, and module `delay`. - `Instant`, `Date`, `DateTime`, `Duration`, and module `delay`.
## 6. Optional (lyngio) Modules ## 6. Optional (lyngio) Modules
Requires installing `lyngio` into the import manager from host code. Requires installing `lyngio` into the import manager from host code.

240
docs/lyng.io.db.md Normal file
View File

@ -0,0 +1,240 @@
### lyng.io.db — SQL database access for Lyng scripts
This module provides the portable SQL database contract for Lyng. The first shipped provider is SQLite via `lyng.io.db.sqlite`.
> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes.
---
#### Install the module into a Lyng session
For SQLite-backed database access, install both the generic DB module and the SQLite provider:
```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
suspend fun bootstrapDb() {
val session = EvalSession()
val scope: Scope = session.getScope()
createDbModule(scope)
createSqliteModule(scope)
session.eval("""
import lyng.io.db
import lyng.io.db.sqlite
""".trimIndent())
}
```
`createSqliteModule(...)` also registers the `sqlite:` scheme for generic `openDatabase(...)`.
---
#### Using from Lyng scripts
Typed SQLite open helper:
```lyng
import lyng.io.db.sqlite
val db = openSqlite(":memory:")
val userCount = db.transaction { tx ->
tx.execute("create table user(id integer primary key autoincrement, name text not null)")
tx.execute("insert into user(name) values(?)", "Ada")
tx.execute("insert into user(name) values(?)", "Linus")
tx.select("select count(*) as count from user").toList()[0]["count"]
}
assertEquals(2, userCount)
```
Generic provider-based open:
```lyng
import lyng.io.db
import lyng.io.db.sqlite
val db = openDatabase(
"sqlite:./app.db",
Map(
"foreignKeys" => true,
"busyTimeoutMillis" => 5000
)
)
```
Nested transactions use real savepoint semantics:
```lyng
import lyng.io.db
import lyng.io.db.sqlite
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table item(id integer primary key autoincrement, name text not null)")
tx.execute("insert into item(name) values(?)", "outer")
try {
tx.transaction { inner ->
inner.execute("insert into item(name) values(?)", "inner")
throw IllegalStateException("rollback nested")
}
} catch (_: IllegalStateException) {
}
assertEquals(1, tx.select("select count(*) as count from item").toList()[0]["count"])
}
```
Intentional rollback without treating it as a backend failure:
```lyng
import lyng.io.db
import lyng.io.db.sqlite
val db = openSqlite(":memory:")
assertThrows(RollbackException) {
db.transaction { tx ->
tx.execute("create table item(id integer primary key autoincrement, name text not null)")
tx.execute("insert into item(name) values(?)", "temporary")
throw RollbackException("stop here")
}
}
```
---
#### Portable API
##### `Database`
- `transaction(block)` — opens a transaction, commits on normal exit, rolls back on uncaught failure.
##### `SqlTransaction`
- `select(clause, params...)` — execute a statement whose primary result is a row set.
- `execute(clause, params...)` — execute a side-effect statement and return `ExecutionResult`.
- `transaction(block)` — nested transaction with real savepoint semantics.
##### `ResultSet`
- `columns` — positional `SqlColumn` metadata, available before iteration.
- `size()` — result row count.
- `isEmpty()` — fast emptiness check where possible.
- `iterator()` / `toList()` — normal row iteration.
##### `SqlRow`
- `row[index]` — zero-based positional access.
- `row["columnName"]` — case-insensitive lookup by output column label.
Name-based access fails with `SqlUsageException` if the name is missing or ambiguous.
##### `ExecutionResult`
- `affectedRowsCount`
- `getGeneratedKeys()`
Statements that return rows directly, such as `... returning ...`, should use `select(...)`, not `execute(...)`.
---
#### Value mapping
Portable bind values:
- `null`
- `Bool`
- `Int`, `Double`, `Decimal`
- `String`
- `Buffer`
- `Date`, `DateTime`, `Instant`
Unsupported parameter values fail with `SqlUsageException`.
Portable result metadata categories:
- `Binary`
- `String`
- `Int`
- `Double`
- `Decimal`
- `Bool`
- `Date`
- `DateTime`
- `Instant`
For temporal types, see [time functions](time.md).
---
#### SQLite provider
`lyng.io.db.sqlite` currently provides the first concrete backend.
Typed helper:
```lyng
openSqlite(
path: String,
readOnly: Bool = false,
createIfMissing: Bool = true,
foreignKeys: Bool = true,
busyTimeoutMillis: Int = 5000
): Database
```
Accepted generic URL forms:
- `sqlite::memory:`
- `sqlite:relative/path.db`
- `sqlite:/absolute/path.db`
Supported `openDatabase(..., extraParams)` keys for SQLite:
- `readOnly: Bool`
- `createIfMissing: Bool`
- `foreignKeys: Bool`
- `busyTimeoutMillis: Int`
SQLite write/read policy in v1:
- `Bool` writes as `0` / `1`
- `Decimal` writes as canonical text
- `Date` writes as `YYYY-MM-DD`
- `DateTime` writes as ISO local timestamp text without timezone
- `Instant` writes as ISO UTC timestamp text with explicit timezone marker
- `TIME*` values stay `String`
- `TIMESTAMP` / `DATETIME` reject timezone-bearing stored text
Open-time validation failures:
- malformed URL or bad option shape -> `IllegalArgumentException`
- runtime open failure -> `DatabaseException`
---
#### Lifetime rules
Result sets and rows are valid only while their owning transaction is active.
This means:
- do not keep `ResultSet` or `SqlRow` objects after the transaction block returns
- copy the values you need into ordinary Lyng objects inside the transaction
The same lifetime rule applies to generated keys returned by `ExecutionResult.getGeneratedKeys()`.
---
#### Platform support
- `lyng.io.db` — generic contract, available when host code installs it
- `lyng.io.db.sqlite` — implemented on JVM and Linux Native in the current release tree
For the broader I/O overview, see [lyngio overview](lyngio.md).

View File

@ -12,6 +12,7 @@
#### Included Modules #### Included Modules
- **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, and SQLite support through `lyng.io.db.sqlite`.
- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing. - **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing.
- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information. - **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events. - **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
@ -43,6 +44,8 @@ To use `lyngio` modules in your scripts, you must install them into your Lyng sc
```kotlin ```kotlin
import net.sergeych.lyng.EvalSession import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.process.createProcessModule
import net.sergeych.lyng.io.console.createConsoleModule import net.sergeych.lyng.io.console.createConsoleModule
@ -61,6 +64,8 @@ suspend fun runMyScript() {
val scope = session.getScope() val scope = session.getScope()
// Install modules with policies // Install modules with policies
createDbModule(scope)
createSqliteModule(scope)
createFs(PermitAllAccessPolicy, scope) createFs(PermitAllAccessPolicy, scope)
createProcessModule(PermitAllProcessAccessPolicy, scope) createProcessModule(PermitAllProcessAccessPolicy, scope)
createConsoleModule(PermitAllConsoleAccessPolicy, scope) createConsoleModule(PermitAllConsoleAccessPolicy, scope)
@ -70,6 +75,8 @@ suspend fun runMyScript() {
// Now scripts can import them // Now scripts can import them
session.eval(""" session.eval("""
import lyng.io.db
import lyng.io.db.sqlite
import lyng.io.fs import lyng.io.fs
import lyng.io.process import lyng.io.process
import lyng.io.console import lyng.io.console
@ -77,6 +84,7 @@ suspend fun runMyScript() {
import lyng.io.net import lyng.io.net
import lyng.io.ws import lyng.io.ws
println("SQLite available: " + (openSqlite(":memory:") != null))
println("Working dir: " + Path(".").readUtf8()) println("Working dir: " + Path(".").readUtf8())
println("OS: " + Platform.details().name) println("OS: " + Platform.details().name)
println("TTY: " + Console.isTty()) println("TTY: " + Console.isTty())
@ -94,6 +102,7 @@ suspend fun runMyScript() {
`lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy. `lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy.
- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory). - **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory).
- **Database Installation:** Database access is still explicit-capability style. The host must install `lyng.io.db` and at least one provider such as `lyng.io.db.sqlite`; otherwise scripts cannot open databases.
- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely. - **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely.
- **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching. - **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching.
- **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send. - **HTTP Security:** Implement `HttpAccessPolicy` to restrict which requests scripts may send.
@ -101,6 +110,7 @@ suspend fun runMyScript() {
- **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow. - **WebSocket Security:** Implement `WsAccessPolicy` to restrict websocket connects and message flow.
For more details, see the specific module documentation: For more details, see the specific module documentation:
- [Database Module Details](lyng.io.db.md)
- [Filesystem Security Details](lyng.io.fs.md#access-policy-security) - [Filesystem Security Details](lyng.io.fs.md#access-policy-security)
- [Process Security Details](lyng.io.process.md#security-policy) - [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md) - [Console Module Details](lyng.io.console.md)
@ -112,16 +122,16 @@ For more details, see the specific module documentation:
#### Platform Support Overview #### Platform Support Overview
| Platform | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net | | Platform | lyng.io.db/sqlite | lyng.io.fs | lyng.io.process | lyng.io.console | lyng.io.http | lyng.io.ws | lyng.io.net |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **JVM** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Linux Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Apple Native** | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | | **Apple Native** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| **Windows Native** | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | | **Windows Native** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ |
| **Android** | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ | | **Android** | ⚠️ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Node** | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ | | **JS / Node** | ❌ | ✅ | ❌ | ⚠️ | ✅ | ✅ | ✅ |
| **JS / Browser** | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ | | **JS / Browser** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ✅ | ✅ | ❌ |
| **Wasm** | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ | | **Wasm** | ❌ | ✅ (in-memory) | ❌ | ⚠️ | ❌ | ❌ | ❌ |
Legend: Legend:
- `✅` supported - `✅` supported

View File

@ -1,74 +1,135 @@
# Lyng time functions # Lyng time functions
Lyng date and time support requires importing `lyng.time` packages. Lyng uses simple yet modern time object models: Lyng date and time support requires importing `lyng.time`. The module provides four related types:
- `Instant` class for absolute time stamps with platform-dependent resolution. - `Instant` for absolute timestamps.
- `DateTime` class for calendar-aware points in time within a specific time zone. - `Date` for calendar dates without time-of-day or timezone.
- `Duration` to represent amount of time not depending on the calendar (e.g., milliseconds, seconds). - `DateTime` for calendar-aware points in time in a specific timezone.
- `Duration` for absolute elapsed time.
## Time instant: `Instant` ## Time instant: `Instant`
Represent some moment of time not depending on the calendar. It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin. `Instant` represents some moment of time independently of the calendar. It is similar to SQL `TIMESTAMP`
or Kotlin `Instant`.
### Constructing and converting ### Constructing and converting
import lyng.time import lyng.time
// default constructor returns time now:
val t1 = Instant() val t1 = Instant()
val t2 = Instant(1704110400)
// constructing from a number is treated as seconds since unix epoch:
val t2 = Instant(1704110400) // 2024-01-01T12:00:00Z
// from RFC3339 string:
val t3 = Instant("2024-01-01T12:00:00.123456Z") val t3 = Instant("2024-01-01T12:00:00.123456Z")
// truncation: val t4 = t3.truncateToMinute()
val t4 = t3.truncateToMinute assertEquals("2024-01-01T12:00:00Z", t4.toRFC3339())
assertEquals(t4.toRFC3339(), "2024-01-01T12:00:00Z")
// to localized DateTime (uses system default TZ if not specified):
val dt = t3.toDateTime("+02:00") val dt = t3.toDateTime("+02:00")
assertEquals(dt.hour, 14) assertEquals(14, dt.hour)
val d = t3.toDate("Z")
assertEquals(Date(2024, 1, 1), d)
### Instant members ### Instant members
| member | description | | member | description |
|--------------------------------|---------------------------------------------------------| |--------------------------------|------------------------------------------------------|
| epochSeconds: Real | positive or negative offset in seconds since Unix epoch | | epochSeconds: Real | offset in seconds since Unix epoch |
| epochWholeSeconds: Int | same, but in _whole seconds_. Slightly faster | | epochWholeSeconds: Int | whole seconds since Unix epoch |
| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos | | nanosecondsOfSecond: Int | nanoseconds within the current second |
| isDistantFuture: Bool | true if it `Instant.distantFuture` | | isDistantFuture: Bool | true if it is `Instant.distantFuture` |
| isDistantPast: Bool | true if it `Instant.distantPast` | | isDistantPast: Bool | true if it is `Instant.distantPast` |
| truncateToMinute: Instant | create new instance truncated to minute | | truncateToMinute(): Instant | truncate to minute precision |
| truncateToSecond: Instant | create new instance truncated to second | | truncateToSecond(): Instant | truncate to second precision |
| truncateToMillisecond: Instant | truncate new instance to millisecond | | truncateToMillisecond(): Instant | truncate to millisecond precision |
| truncateToMicrosecond: Instant | truncate new instance to microsecond | | truncateToMicrosecond(): Instant | truncate to microsecond precision |
| toRFC3339(): String | format as RFC3339 string (UTC) | | toRFC3339(): String | format as RFC3339 string in UTC |
| toDateTime(tz?): DateTime | localize to a TimeZone (ID string or offset seconds) | | toDateTime(tz?): DateTime | localize to a timezone |
| toDate(tz?): Date | convert to a calendar date in a timezone |
## Calendar time: `DateTime` ## Calendar date: `Date`
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components like year, `Date` represents a pure calendar date. It has no time-of-day and no attached timezone. Use it for values
month, and day. like birthdays, due dates, invoice dates, and SQL `DATE` columns.
### Constructing
import lyng.time
val today = Date()
val d1 = Date(2026, 4, 15)
val d2 = Date("2024-02-29")
val d3 = Date.parseIso("2024-02-29")
val d4 = Date(DateTime(2024, 5, 20, 15, 30, 45, "+02:00"))
val d5 = Date(Instant("2024-01-01T23:30:00Z"), "+02:00")
### Date members
| member | description |
|--------------------------------|------------------------------------------------------------|
| year: Int | year component |
| month: Int | month component (1..12) |
| day: Int | day of month (alias `dayOfMonth`) |
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
| dayOfYear: Int | day of year (1..365/366) |
| isLeapYear: Bool | whether this date is in a leap year |
| lengthOfMonth: Int | number of days in this month |
| lengthOfYear: Int | 365 or 366 |
| toIsoString(): String | ISO `YYYY-MM-DD` string |
| toSortableString(): String | alias to `toIsoString()` |
| toDateTime(tz="Z"): DateTime | start-of-day `DateTime` in the specified timezone |
| atStartOfDay(tz="Z"): DateTime | alias to `toDateTime()` |
| addDays(n): Date | add or subtract calendar days |
| addMonths(n): Date | add or subtract months, normalizing end-of-month |
| addYears(n): Date | add or subtract years |
| daysUntil(other): Int | calendar days until `other` |
| daysSince(other): Int | calendar days since `other` |
| static today(tz?): Date | today in the specified timezone |
| static parseIso(s): Date | parse ISO `YYYY-MM-DD` |
### Date arithmetic
`Date` supports only whole-day arithmetic. This is deliberate: calendar dates should not silently accept
sub-day durations.
import lyng.time
val d1 = Date(2026, 4, 15)
val d2 = d1.addDays(10)
assertEquals(Date(2026, 4, 25), d2)
assertEquals(Date(2026, 4, 18), d1 + 3.days)
assertEquals(Date(2026, 4, 12), d1 - 3.days)
assertEquals(10, d1.daysUntil(d2))
assertEquals(10, d2.daysSince(d1))
assertEquals(10, d2 - d1)
### Date conversions
import lyng.time
val i = Instant("2024-01-01T23:30:00Z")
assertEquals(Date(2024, 1, 1), i.toDate("Z"))
assertEquals(Date(2024, 1, 2), i.toDate("+02:00"))
val dt = DateTime(2024, 5, 20, 15, 30, 45, "+02:00")
assertEquals(Date(2024, 5, 20), dt.date)
assertEquals(Date(2024, 5, 20), dt.toDate())
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "Z"), Date(2024, 5, 20).toDateTime("Z"))
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "+02:00"), Date(2024, 5, 20).atStartOfDay("+02:00"))
## Calendar time: `DateTime`
`DateTime` represents a point in time in a specific timezone. It provides access to calendar components
such as year, month, day, and hour.
### Constructing ### Constructing
import lyng.time import lyng.time
// Current time in system default timezone
val now = DateTime.now() val now = DateTime.now()
// Specific timezone
val offsetTime = DateTime.now("+02:00") val offsetTime = DateTime.now("+02:00")
// From Instant
val dt = Instant().toDateTime("Z") 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") val dt2 = DateTime(2024, 1, 1, 12, 0, 0, "Z")
// From RFC3339 string
val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00") val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00")
### DateTime members ### DateTime members
@ -83,7 +144,9 @@ month, and day.
| second: Int | second component (0..59) | | second: Int | second component (0..59) |
| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) | | dayOfWeek: Int | day of week (1=Monday, 7=Sunday) |
| timeZone: String | timezone ID string | | timeZone: String | timezone ID string |
| date: Date | calendar date component |
| toInstant(): Instant | convert back to absolute Instant | | toInstant(): Instant | convert back to absolute Instant |
| toDate(): Date | extract the calendar date in this timezone |
| toUTC(): DateTime | shortcut to convert to UTC | | toUTC(): DateTime | shortcut to convert to UTC |
| toTimeZone(tz): DateTime | convert to another timezone | | toTimeZone(tz): DateTime | convert to another timezone |
| addMonths(n): DateTime | add/subtract months (normalizes end of month) | | addMonths(n): DateTime | add/subtract months (normalizes end of month) |
@ -96,28 +159,27 @@ month, and day.
`DateTime` handles calendar arithmetic correctly: `DateTime` handles calendar arithmetic correctly:
import lyng.time
val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z") val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z")
val nextYear = leapDay.addYears(1) val nextYear = leapDay.addYears(1)
assertEquals(nextYear.day, 28) // Feb 29, 2024 -> Feb 28, 2025 assertEquals(28, nextYear.day)
# `Duration` class # `Duration` class
Represent absolute time distance between two `Instant`. `Duration` represents absolute elapsed time between two instants.
import lyng.time import lyng.time
val t1 = Instant() val t1 = Instant()
// yes we can delay to period, and it is not blocking. is suspends!
delay(1.millisecond) delay(1.millisecond)
val t2 = Instant() val t2 = Instant()
// be suspend, so actual time may vary:
assert( t2 - t1 >= 1.millisecond) assert(t2 - t1 >= 1.millisecond)
assert( t2 - t1 < 100.millisecond) assert(t2 - t1 < 100.millisecond)
>>> void >>> void
Duration can be converted from numbers, like `5.minutes` and so on. Extensions are created for Duration values can be created from numbers using extensions on `Int` and `Real`:
`Int` and `Real`, so for n as Real or Int it is possible to create durations::
- `n.millisecond`, `n.milliseconds` - `n.millisecond`, `n.milliseconds`
- `n.second`, `n.seconds` - `n.second`, `n.seconds`
@ -125,10 +187,9 @@ Duration can be converted from numbers, like `5.minutes` and so on. Extensions a
- `n.hour`, `n.hours` - `n.hour`, `n.hours`
- `n.day`, `n.days` - `n.day`, `n.days`
The bigger time units like months or years are calendar-dependent and can't be used with `Duration`. Larger units like months or years are calendar-dependent and are intentionally not part of `Duration`.
Each duration instance can be converted to number of any of these time units, as `Real` number, if `d` is a `Duration` Each duration instance can be converted to numbers in these units:
instance:
- `d.microseconds` - `d.microseconds`
- `d.milliseconds` - `d.milliseconds`
@ -137,18 +198,16 @@ instance:
- `d.hours` - `d.hours`
- `d.days` - `d.days`
for example Example:
import lyng.time import lyng.time
assertEquals( 60, 1.minute.seconds )
assertEquals( 10.milliseconds, 0.01.seconds )
assertEquals(60, 1.minute.seconds)
assertEquals(10.milliseconds, 0.01.seconds)
>>> void >>> void
# Utility functions # Utility functions
## delay(duration: Duration) ## `delay(duration: Duration)`
Suspends current coroutine for at least the specified duration.
Suspends the current coroutine for at least the specified duration.

View File

@ -18,8 +18,9 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5
- Descending ranges and loops with `downTo` / `downUntil` - Descending ranges and loops with `downTo` / `downUntil`
- String interpolation with `$name` and `${expr}` - String interpolation with `$name` and `${expr}`
- Decimal arithmetic, matrices/vectors, and complex numbers - Decimal arithmetic, matrices/vectors, and complex numbers
- Calendar `Date` support in `lyng.time`
- Immutable collections and opt-in `ObservableList` - Immutable collections and opt-in `ObservableList`
- Rich `lyngio` modules for console, HTTP, WebSocket, TCP, and UDP - Rich `lyngio` modules for SQLite databases, console, HTTP, WebSocket, TCP, and UDP
- CLI improvements including the built-in formatter `lyng fmt` - CLI improvements including the built-in formatter `lyng fmt`
- Better IDE support and stronger docs around the released feature set - Better IDE support and stronger docs around the released feature set
@ -579,7 +580,7 @@ ws.close()
These modules are capability-gated and host-installed, keeping Lyng safe by default while making networked scripts practical when enabled. These modules are capability-gated and host-installed, keeping Lyng safe by default while making networked scripts practical when enabled.
See [lyngio overview](lyngio.md), [lyng.io.http](lyng.io.http.md), [lyng.io.ws](lyng.io.ws.md), and [lyng.io.net](lyng.io.net.md). See [lyngio overview](lyngio.md), [lyng.io.db](lyng.io.db.md), [lyng.io.http](lyng.io.http.md), [lyng.io.ws](lyng.io.ws.md), and [lyng.io.net](lyng.io.net.md).
### CLI: Formatting Command ### CLI: Formatting Command
A new `fmt` subcommand has been added to the Lyng CLI. A new `fmt` subcommand has been added to the Lyng CLI.

View File

@ -40,6 +40,8 @@ import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source import net.sergeych.lyng.Source
import net.sergeych.lyng.asFacade import net.sergeych.lyng.asFacade
import net.sergeych.lyng.io.console.createConsoleModule import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
import net.sergeych.lyng.io.fs.createFs import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.http.createHttpModule import net.sergeych.lyng.io.http.createHttpModule
import net.sergeych.lyng.io.net.createNetModule import net.sergeych.lyng.io.net.createNetModule
@ -138,6 +140,7 @@ private val baseCliImportManagerDefer = globalDefer {
private fun ImportManager.invalidateCliModuleCaches() { private fun ImportManager.invalidateCliModuleCaches() {
invalidatePackageCache("lyng.io.fs") invalidatePackageCache("lyng.io.fs")
invalidatePackageCache("lyng.io.console") invalidatePackageCache("lyng.io.console")
invalidatePackageCache("lyng.io.db.sqlite")
invalidatePackageCache("lyng.io.http") invalidatePackageCache("lyng.io.http")
invalidatePackageCache("lyng.io.ws") invalidatePackageCache("lyng.io.ws")
invalidatePackageCache("lyng.io.net") invalidatePackageCache("lyng.io.net")
@ -225,6 +228,8 @@ private fun installCliModules(manager: ImportManager) {
// Scripts still need to import the modules they use explicitly. // Scripts still need to import the modules they use explicitly.
createFs(PermitAllAccessPolicy, manager) createFs(PermitAllAccessPolicy, manager)
createConsoleModule(PermitAllConsoleAccessPolicy, manager) createConsoleModule(PermitAllConsoleAccessPolicy, manager)
createDbModule(manager)
createSqliteModule(manager)
createHttpModule(PermitAllHttpAccessPolicy, manager) createHttpModule(PermitAllHttpAccessPolicy, manager)
createWsModule(PermitAllWsAccessPolicy, manager) createWsModule(PermitAllWsAccessPolicy, manager)
createNetModule(PermitAllNetAccessPolicy, manager) createNetModule(PermitAllNetAccessPolicy, manager)

View File

@ -5003,9 +5003,16 @@ class Compiler(
is QualifiedThisRef -> resolveClassByName(ref.typeName) is QualifiedThisRef -> resolveClassByName(ref.typeName)
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverClassForMember(it.ref) } is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverClassForMember(it.ref) }
is MethodCallRef -> inferMethodCallReturnClass(ref) is MethodCallRef -> inferMethodCallReturnClass(ref)
is ImplicitThisMethodCallRef -> inferMethodCallReturnClass(ref.methodName()) is ImplicitThisMethodCallRef -> {
is ThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) val typeName = ref.preferredThisTypeName() ?: currentImplicitThisTypeName()
is QualifiedThisMethodSlotCallRef -> inferMethodCallReturnClass(ref.methodName()) inferMethodCallReturnClass(typeName?.let { resolveClassByName(it) }, ref.methodName())
}
is ThisMethodSlotCallRef -> {
val typeName = currentImplicitThisTypeName()
inferMethodCallReturnClass(typeName?.let { resolveClassByName(it) }, ref.methodName())
}
is QualifiedThisMethodSlotCallRef ->
inferMethodCallReturnClass(resolveClassByName(ref.receiverTypeName()), ref.methodName())
is CallRef -> inferCallReturnTypeDecl(ref)?.let { resolveTypeDeclObjClass(it) } ?: inferCallReturnClass(ref) is CallRef -> inferCallReturnTypeDecl(ref)?.let { resolveTypeDeclObjClass(it) } ?: inferCallReturnClass(ref)
is BinaryOpRef -> inferBinaryOpReturnClass(ref) is BinaryOpRef -> inferBinaryOpReturnClass(ref)
is FieldRef -> { is FieldRef -> {
@ -5127,6 +5134,8 @@ class Compiler(
leftClass == ObjInstant.type && rightClass == ObjDuration.type -> ObjInstant.type leftClass == ObjInstant.type && rightClass == ObjDuration.type -> ObjInstant.type
leftClass == ObjDuration.type && rightClass == ObjInstant.type && ref.op == BinOp.PLUS -> ObjInstant.type leftClass == ObjDuration.type && rightClass == ObjInstant.type && ref.op == BinOp.PLUS -> ObjInstant.type
leftClass == ObjDuration.type && rightClass == ObjDuration.type -> ObjDuration.type leftClass == ObjDuration.type && rightClass == ObjDuration.type -> ObjDuration.type
leftClass == ObjDate.type && rightClass == ObjDate.type && ref.op == BinOp.MINUS -> ObjInt.type
leftClass == ObjDate.type && rightClass == ObjDuration.type -> ObjDate.type
(leftClass == ObjBuffer.type || leftClass.allParentsSet.contains(ObjBuffer.type)) && (leftClass == ObjBuffer.type || leftClass.allParentsSet.contains(ObjBuffer.type)) &&
(rightClass == ObjBuffer.type || rightClass.allParentsSet.contains(ObjBuffer.type)) && (rightClass == ObjBuffer.type || rightClass.allParentsSet.contains(ObjBuffer.type)) &&
ref.op == BinOp.PLUS -> ObjBuffer.type ref.op == BinOp.PLUS -> ObjBuffer.type
@ -5198,7 +5207,37 @@ class Compiler(
if (receiverClass != null && isClassScopeCallableMember(receiverClass.className, ref.name)) { if (receiverClass != null && isClassScopeCallableMember(receiverClass.className, ref.name)) {
resolveClassByName("${receiverClass.className}.${ref.name}")?.let { return it } resolveClassByName("${receiverClass.className}.${ref.name}")?.let { return it }
} }
return inferMethodCallReturnClass(ref.name) return inferMethodCallReturnClass(receiverClass, ref.name)
}
private fun inferMethodCallReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
when (targetClass) {
ObjDate.type -> {
return when (name) {
"toIsoString", "toSortableString", "toString" -> ObjString.type
"toDateTime", "atStartOfDay" -> ObjDateTime.type
"addDays", "addMonths", "addYears", "today", "parseIso" -> ObjDate.type
"daysUntil", "daysSince" -> ObjInt.type
else -> inferMethodCallReturnClass(name)
}
}
ObjInstant.type -> {
return when (name) {
"toDateTime" -> ObjDateTime.type
"toDate" -> ObjDate.type
else -> inferMethodCallReturnClass(name)
}
}
ObjDateTime.type -> {
return when (name) {
"toDate" -> ObjDate.type
"toInstant" -> ObjInstant.type
"toUTC", "toTimeZone", "parseRFC3339", "addYears", "addMonths" -> ObjDateTime.type
else -> inferMethodCallReturnClass(name)
}
}
else -> return inferMethodCallReturnClass(name)
}
} }
private fun inferMethodCallReturnTypeDecl(ref: MethodCallRef): TypeDecl? { private fun inferMethodCallReturnTypeDecl(ref: MethodCallRef): TypeDecl? {
@ -5423,13 +5462,13 @@ class Compiler(
"truncateToSecond", "truncateToSecond",
"truncateToMinute", "truncateToMinute",
"truncateToMillisecond" -> ObjInstant.type "truncateToMillisecond" -> ObjInstant.type
"toDateTime", "today",
"parseIso" -> ObjDate.type
"daysUntil",
"daysSince" -> ObjInt.type
"toTimeZone", "toTimeZone",
"toUTC", "toUTC",
"parseRFC3339", "parseRFC3339",
"addYears",
"addMonths",
"addDays",
"addHours", "addHours",
"addMinutes", "addMinutes",
"addSeconds" -> ObjDateTime.type "addSeconds" -> ObjDateTime.type
@ -5486,6 +5525,20 @@ class Compiler(
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) { if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
return ObjInstant.type return ObjInstant.type
} }
if (targetClass == ObjDate.type) {
return when (name) {
"year",
"month",
"day",
"dayOfMonth",
"dayOfWeek",
"dayOfYear",
"lengthOfMonth",
"lengthOfYear" -> ObjInt.type
"isLeapYear" -> ObjBool.type
else -> null
}
}
if (targetClass == ObjInstant.type && name in listOf( if (targetClass == ObjInstant.type && name in listOf(
"truncateToMinute", "truncateToMinute",
"truncateToSecond", "truncateToSecond",
@ -5538,6 +5591,7 @@ class Compiler(
} }
if (targetClass == ObjDateTime.type) { if (targetClass == ObjDateTime.type) {
return when (name) { return when (name) {
"date" -> ObjDate.type
"year", "year",
"month", "month",
"day", "day",
@ -9677,6 +9731,7 @@ class Compiler(
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass "ChangeRejectionException" -> ObjChangeRejectionExceptionClass
"Exception" -> ObjException.Root "Exception" -> ObjException.Root
"Instant" -> ObjInstant.type "Instant" -> ObjInstant.type
"Date" -> ObjDate.type
"DateTime" -> ObjDateTime.type "DateTime" -> ObjDateTime.type
"Duration" -> ObjDuration.type "Duration" -> ObjDuration.type
"Buffer" -> ObjBuffer.type "Buffer" -> ObjBuffer.type

View File

@ -952,6 +952,12 @@ class Script(
doc = "Point in time (epoch-based).", doc = "Point in time (epoch-based).",
type = type("lyng.Class") type = type("lyng.Class")
) )
it.addConstDoc(
name = "Date",
value = ObjDate.type,
doc = "Calendar date without time-of-day or time zone.",
type = type("lyng.Class")
)
it.addConstDoc( it.addConstDoc(
name = "DateTime", name = "DateTime",
value = ObjDateTime.type, value = ObjDateTime.type,

View File

@ -1171,14 +1171,16 @@ class BytecodeCompiler(
ObjMap.type, ObjMap.type,
ObjBuffer.type, ObjBuffer.type,
ObjInstant.type, ObjInstant.type,
ObjDateTime.type ObjDateTime.type,
ObjDate.type
) )
BinOp.MINUS -> receiverClass in setOf( BinOp.MINUS -> receiverClass in setOf(
ObjInt.type, ObjInt.type,
ObjReal.type, ObjReal.type,
ObjSet.type, ObjSet.type,
ObjInstant.type, ObjInstant.type,
ObjDateTime.type ObjDateTime.type,
ObjDate.type
) )
BinOp.STAR -> receiverClass in setOf(ObjInt.type, ObjReal.type, ObjString.type) BinOp.STAR -> receiverClass in setOf(ObjInt.type, ObjReal.type, ObjString.type)
BinOp.SLASH, BinOp.PERCENT -> receiverClass in setOf(ObjInt.type, ObjReal.type) BinOp.SLASH, BinOp.PERCENT -> receiverClass in setOf(ObjInt.type, ObjReal.type)
@ -7290,7 +7292,7 @@ class BytecodeCompiler(
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) { if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
ObjRegex.type ObjRegex.type
} else { } else {
inferMethodCallReturnClass(ref.name) inferMethodCallReturnClass(targetClass, ref.name)
} }
} }
is CallRef -> inferCallReturnClass(ref) is CallRef -> inferCallReturnClass(ref)
@ -7463,7 +7465,7 @@ class BytecodeCompiler(
if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) { if (targetClass == ObjString.type && ref.name == "re" && ref.args.isEmpty() && !ref.isOptional) {
ObjRegex.type ObjRegex.type
} else { } else {
inferMethodCallReturnClass(ref.name) inferMethodCallReturnClass(targetClass, ref.name)
} }
} }
is CallRef -> inferCallReturnClass(ref) is CallRef -> inferCallReturnClass(ref)
@ -7546,6 +7548,7 @@ class BytecodeCompiler(
"ObservableList" -> ObjObservableList.type "ObservableList" -> ObjObservableList.type
"ChangeRejectionException" -> ObjChangeRejectionExceptionClass "ChangeRejectionException" -> ObjChangeRejectionExceptionClass
"Instant" -> ObjInstant.type "Instant" -> ObjInstant.type
"Date" -> ObjDate.type
"DateTime" -> ObjDateTime.type "DateTime" -> ObjDateTime.type
"Duration" -> ObjDuration.type "Duration" -> ObjDuration.type
"Exception" -> ObjException.Root "Exception" -> ObjException.Root
@ -7584,6 +7587,36 @@ class BytecodeCompiler(
} }
} }
private fun inferMethodCallReturnClass(targetClass: ObjClass?, name: String): ObjClass? {
when (targetClass) {
ObjDate.type -> {
return when (name) {
"toIsoString", "toSortableString", "toString" -> ObjString.type
"toDateTime", "atStartOfDay" -> ObjDateTime.type
"addDays", "addMonths", "addYears", "today", "parseIso" -> ObjDate.type
"daysUntil", "daysSince" -> ObjInt.type
else -> inferMethodCallReturnClass(name)
}
}
ObjInstant.type -> {
return when (name) {
"toDateTime" -> ObjDateTime.type
"toDate" -> ObjDate.type
else -> inferMethodCallReturnClass(name)
}
}
ObjDateTime.type -> {
return when (name) {
"toDate" -> ObjDate.type
"toInstant" -> ObjInstant.type
"toUTC", "toTimeZone", "parseRFC3339", "addYears", "addMonths" -> ObjDateTime.type
else -> inferMethodCallReturnClass(name)
}
}
else -> return inferMethodCallReturnClass(name)
}
}
private fun inferMethodCallReturnClass(name: String): ObjClass? = when (name) { private fun inferMethodCallReturnClass(name: String): ObjClass? = when (name) {
"map", "map",
"mapNotNull", "mapNotNull",
@ -7616,13 +7649,13 @@ class BytecodeCompiler(
"truncateToSecond", "truncateToSecond",
"truncateToMinute", "truncateToMinute",
"truncateToMillisecond" -> ObjInstant.type "truncateToMillisecond" -> ObjInstant.type
"toDateTime", "today",
"parseIso" -> ObjDate.type
"daysUntil",
"daysSince" -> ObjInt.type
"toTimeZone", "toTimeZone",
"toUTC", "toUTC",
"parseRFC3339", "parseRFC3339",
"addYears",
"addMonths",
"addDays",
"addHours", "addHours",
"addMinutes", "addMinutes",
"addSeconds" -> ObjDateTime.type "addSeconds" -> ObjDateTime.type
@ -7667,6 +7700,20 @@ class BytecodeCompiler(
if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) { if (targetClass == ObjInstant.type && (name == "distantFuture" || name == "distantPast")) {
return ObjInstant.type return ObjInstant.type
} }
if (targetClass == ObjDate.type) {
return when (name) {
"year",
"month",
"day",
"dayOfMonth",
"dayOfWeek",
"dayOfYear",
"lengthOfMonth",
"lengthOfYear" -> ObjInt.type
"isLeapYear" -> ObjBool.type
else -> null
}
}
if (targetClass == ObjString.type && name == "re") { if (targetClass == ObjString.type && name == "re") {
return ObjRegex.type return ObjRegex.type
} }
@ -7710,6 +7757,7 @@ class BytecodeCompiler(
} }
if (targetClass == ObjDateTime.type) { if (targetClass == ObjDateTime.type) {
return when (name) { return when (name) {
"date" -> ObjDate.type
"year", "year",
"month", "month",
"day", "day",

View File

@ -64,11 +64,13 @@ object StdlibDocsBootstrap {
val _buffer = net.sergeych.lyng.obj.ObjBuffer.type val _buffer = net.sergeych.lyng.obj.ObjBuffer.type
// Also touch time module types so their docs (moduleName = "lyng.time") are registered // Also touch time module types so their docs (moduleName = "lyng.time") are registered
// This enables completion/quick docs for symbols imported via `import lyng.time` (e.g., Instant, DateTime, Duration) // This enables completion/quick docs for symbols imported via `import lyng.time` (e.g., Instant, Date, DateTime, Duration)
try { try {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val _instant = net.sergeych.lyng.obj.ObjInstant.type val _instant = net.sergeych.lyng.obj.ObjInstant.type
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val _date = net.sergeych.lyng.obj.ObjDate.type
@Suppress("UNUSED_VARIABLE")
val _datetime = net.sergeych.lyng.obj.ObjDateTime.type val _datetime = net.sergeych.lyng.obj.ObjDateTime.type
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val _duration = net.sergeych.lyng.obj.ObjDuration.type val _duration = net.sergeych.lyng.obj.ObjDuration.type

View File

@ -0,0 +1,303 @@
/*
* 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.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.asTimeZone
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.asFacade
import net.sergeych.lyng.miniast.ParamDoc
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
import kotlin.math.absoluteValue
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
class ObjDate(val date: LocalDate) : Obj() {
override val objClass: ObjClass get() = type
override fun toString(): String = date.toString()
override suspend fun plus(scope: Scope, other: Obj): Obj {
return when (other) {
is ObjDuration -> ObjDate(addDays(date, requireWholeDays(scope, other.duration)))
else -> super.plus(scope, other)
}
}
override suspend fun minus(scope: Scope, other: Obj): Obj {
return when (other) {
is ObjDuration -> ObjDate(addDays(date, -requireWholeDays(scope, other.duration)))
is ObjDate -> ObjInt.of(daysBetween(other.date, date).toLong())
else -> super.minus(scope, other)
}
}
override suspend fun compareTo(scope: Scope, other: Obj): Int {
return if (other is ObjDate) {
date.compareTo(other.date)
} else super.compareTo(scope, other)
}
override suspend fun toKotlin(scope: Scope): Any = date
override fun hashCode(): Int = date.hashCode()
override fun equals(other: Any?): Boolean = other is ObjDate && date == other.date
override suspend fun lynonType(): LynonType = LynonType.Date
override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) {
encoder.encodeSigned(date.year.toLong())
encoder.encodeUnsigned(date.month.number.toULong())
encoder.encodeUnsigned(date.day.toULong())
}
override suspend fun toJson(scope: Scope): JsonElement = JsonPrimitive(date.toString())
companion object {
val type = object : ObjClass("Date") {
override suspend fun callOn(scope: Scope): Obj {
val args = scope.args
return when (val a0 = args.list.getOrNull(0)) {
null -> ObjDate(today(TimeZone.currentSystemDefault()))
is ObjDate -> a0
is ObjInt -> {
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")
ObjDate(createDate(scope, year, month, day))
}
is ObjString -> ObjDate(parseIso(scope, a0.value))
is ObjDateTime -> ObjDate(toDate(a0.instant, a0.timeZone))
is ObjInstant -> {
val tz = parseTimeZoneArg(scope, args.list.getOrNull(1), TimeZone.currentSystemDefault())
ObjDate(toDate(a0.instant, tz))
}
else -> scope.raiseIllegalArgument("can't construct Date from ${args.inspect(scope)}")
}
}
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
val year = decoder.unpackSigned().toInt()
val month = decoder.unpackUnsigned().toInt()
val day = decoder.unpackUnsigned().toInt()
return ObjDate(createDate(scope, year, month, day))
}
}.apply {
addPropertyDoc("year", "The year component.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.year.toObj() })
addPropertyDoc("month", "The month component (1..12).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.month.number.toObj() })
addPropertyDoc("dayOfMonth", "The day of month component.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.day.toObj() })
addPropertyDoc("day", "Alias to dayOfMonth.", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.day.toObj() })
addPropertyDoc("dayOfWeek", "The day of week (1=Monday, 7=Sunday).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.dayOfWeek.isoDayNumber.toObj() })
addPropertyDoc("dayOfYear", "The day of year (1..365/366).", type("lyng.Int"), moduleName = "lyng.time",
getter = { thisAs<ObjDate>().date.dayOfYear.toObj() })
addPropertyDoc("isLeapYear", "Whether this date is in a leap year.", type("lyng.Bool"), moduleName = "lyng.time",
getter = { isLeapYear(thisAs<ObjDate>().date.year).toObj() })
addPropertyDoc("lengthOfMonth", "Number of days in this date's month.", type("lyng.Int"), moduleName = "lyng.time",
getter = { monthLength(thisAs<ObjDate>().date.year, thisAs<ObjDate>().date.month.number).toObj() })
addPropertyDoc("lengthOfYear", "Number of days in this date's year.", type("lyng.Int"), moduleName = "lyng.time",
getter = { (if (isLeapYear(thisAs<ObjDate>().date.year)) 366 else 365).toObj() })
addFnDoc("toIsoString", "Return the canonical ISO date string representation (`YYYY-MM-DD`).",
returns = type("lyng.String"), moduleName = "lyng.time") {
thisAs<ObjDate>().date.toString().toObj()
}
addFnDoc("toSortableString", "Alias to toIsoString.", returns = type("lyng.String"), moduleName = "lyng.time") {
thisAs<ObjDate>().date.toString().toObj()
}
addFnDoc("toDateTime", "Convert this date to a DateTime at the start of day in the specified timezone.",
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.UTC)
toDateTime(thisAs<ObjDate>().date, tz)
}
addFnDoc("atStartOfDay", "Alias to toDateTime.",
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.UTC)
toDateTime(thisAs<ObjDate>().date, tz)
}
addFnDoc("addDays", "Return a new Date with the specified number of days added (or subtracted if negative).",
params = listOf(ParamDoc("days", type = type("lyng.Int"))),
returns = type("lyng.Date"), moduleName = "lyng.time") {
val n = args.list.getOrNull(0)?.toInt() ?: 0
ObjDate(addDays(thisAs<ObjDate>().date, n))
}
addFnDoc("addMonths", "Return a new Date with the specified number of months added (or subtracted if negative). End-of-month values are normalized.",
params = listOf(ParamDoc("months", type = type("lyng.Int"))),
returns = type("lyng.Date"), moduleName = "lyng.time") {
val n = args.list.getOrNull(0)?.toInt() ?: 0
ObjDate(addMonths(thisAs<ObjDate>().date, n))
}
addFnDoc("addYears", "Return a new Date with the specified number of years added (or subtracted if negative).",
params = listOf(ParamDoc("years", type = type("lyng.Int"))),
returns = type("lyng.Date"), moduleName = "lyng.time") {
val n = args.list.getOrNull(0)?.toInt() ?: 0
ObjDate(addYears(thisAs<ObjDate>().date, n))
}
addFnDoc("daysUntil", "Return the number of whole calendar days until the other date.",
params = listOf(ParamDoc("other", type = type("lyng.Date"))),
returns = type("lyng.Int"), moduleName = "lyng.time") {
val other = requiredArg<ObjDate>(0)
daysBetween(thisAs<ObjDate>().date, other.date).toObj()
}
addFnDoc("daysSince", "Return the number of whole calendar days since the other date.",
params = listOf(ParamDoc("other", type = type("lyng.Date"))),
returns = type("lyng.Int"), moduleName = "lyng.time") {
val other = requiredArg<ObjDate>(0)
daysBetween(other.date, thisAs<ObjDate>().date).toObj()
}
addClassFnDoc("today", "Return today's date in the specified timezone, or in the current system timezone if omitted.",
params = listOf(ParamDoc("tz", type = type("lyng.Any", true))),
returns = type("lyng.Date"), moduleName = "lyng.time") {
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
ObjDate(today(tz))
}
addClassFnDoc("parseIso", "Parse an ISO date string (`YYYY-MM-DD`) into a Date.",
params = listOf(ParamDoc("string", type = type("lyng.String"))),
returns = type("lyng.Date"), moduleName = "lyng.time") {
ObjDate(parseIso(this, requiredArg<ObjString>(0).value))
}
}
}
}
internal fun parseTimeZoneArg(scope: ScopeFacade, value: Obj?, default: TimeZone): TimeZone {
return when (value) {
null -> default
is ObjString -> TimeZone.of(value.value)
is ObjInt -> UtcOffset(seconds = value.value.toInt()).asTimeZone()
else -> scope.raiseIllegalArgument("invalid timezone: $value")
}
}
internal fun parseTimeZoneArg(scope: Scope, value: Obj?, default: TimeZone): TimeZone =
parseTimeZoneArg(scope.asFacade(), value, default)
internal fun toDate(instant: kotlin.time.Instant, tz: TimeZone): LocalDate {
val ldt = instant.toLocalDateTime(tz)
return LocalDate(ldt.year, ldt.month.number, ldt.day)
}
internal fun toDateTime(date: LocalDate, tz: TimeZone): ObjDateTime {
val ldt = LocalDateTime(date.year, date.month.number, date.day, 0, 0, 0)
return ObjDateTime(ldt.toInstant(tz), tz)
}
private fun parseIso(scope: ScopeFacade, value: String): LocalDate {
val match = DATE_REGEX.matchEntire(value.trim()) ?: scope.raiseIllegalArgument("invalid ISO date string: $value")
val year = match.groupValues[1].toInt()
val month = match.groupValues[2].toInt()
val day = match.groupValues[3].toInt()
return createDate(scope, year, month, day)
}
private fun parseIso(scope: Scope, value: String): LocalDate = parseIso(scope.asFacade(), value)
private fun createDate(scope: ScopeFacade, year: Int, month: Int, day: Int): LocalDate {
if (month !in 1..12) scope.raiseIllegalArgument("month must be in 1..12")
val maxDay = monthLength(year, month)
if (day !in 1..maxDay) scope.raiseIllegalArgument("day must be in 1..$maxDay")
return LocalDate(year, month, day)
}
private fun createDate(scope: Scope, year: Int, month: Int, day: Int): LocalDate =
createDate(scope.asFacade(), year, month, day)
private fun today(tz: TimeZone): LocalDate = toDate(Clock.System.now(), tz)
private fun addDays(date: LocalDate, days: Int): LocalDate {
val start = LocalDateTime(date.year, date.month.number, date.day, 0, 0, 0).toInstant(TimeZone.UTC)
return toDate(start + days.days, TimeZone.UTC)
}
private fun daysBetween(start: LocalDate, end: LocalDate): Int {
val startInstant = LocalDateTime(start.year, start.month.number, start.day, 0, 0, 0).toInstant(TimeZone.UTC)
val endInstant = LocalDateTime(end.year, end.month.number, end.day, 0, 0, 0).toInstant(TimeZone.UTC)
return ((endInstant.epochSeconds - startInstant.epochSeconds) / 86_400L).toInt()
}
private fun addMonths(date: LocalDate, months: Int): LocalDate {
if (months == 0) return date
val totalMonths = date.year.toLong() * 12L + (date.month.number - 1).toLong() + months.toLong()
val newYear = floorDiv(totalMonths, 12L).toInt()
val newMonth = floorMod(totalMonths, 12L).toInt() + 1
val newDay = minOf(date.day, monthLength(newYear, newMonth))
return LocalDate(newYear, newMonth, newDay)
}
private fun addYears(date: LocalDate, years: Int): LocalDate {
if (years == 0) return date
val newYear = date.year + years
val newDay = minOf(date.day, monthLength(newYear, date.month.number))
return LocalDate(newYear, date.month.number, newDay)
}
private fun requireWholeDays(scope: Scope, duration: Duration): Int {
val days = duration.inWholeDays
if (days.absoluteValue > Int.MAX_VALUE.toLong()) {
scope.raiseIllegalArgument("date arithmetic day count is too large")
}
if (duration != days.days) {
scope.raiseIllegalArgument("Date arithmetic supports only whole-day durations")
}
return days.toInt()
}
private fun isLeapYear(year: Int): Boolean =
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
private fun monthLength(year: Int, month: Int): Int = when (month) {
1, 3, 5, 7, 8, 10, 12 -> 31
4, 6, 9, 11 -> 30
2 -> if (isLeapYear(year)) 29 else 28
else -> error("invalid month: $month")
}
private fun floorDiv(a: Long, b: Long): Long {
var q = a / b
if ((a xor b) < 0 && q * b != a) q -= 1
return q
}
private fun floorMod(a: Long, b: Long): Long = a - floorDiv(a, b) * b
private val DATE_REGEX = Regex("""([+-]?\d{4,})-(\d{2})-(\d{2})""")

View File

@ -202,10 +202,15 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
getter = { thisAs<ObjDateTime>().localDateTime.dayOfWeek.isoDayNumber.toObj() }) 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", 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() }) getter = { thisAs<ObjDateTime>().timeZone.id.toObj() })
addPropertyDoc("date", "The calendar date component of this DateTime.", type("lyng.Date"), moduleName = "lyng.time",
getter = { ObjDate(toDate(thisAs<ObjDateTime>().instant, thisAs<ObjDateTime>().timeZone)) })
addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") { addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") {
ObjInstant(thisAs<ObjDateTime>().instant) ObjInstant(thisAs<ObjDateTime>().instant)
} }
addFnDoc("toDate", "Return the calendar date component of this DateTime.", returns = type("lyng.Date"), moduleName = "lyng.time") {
ObjDate(toDate(thisAs<ObjDateTime>().instant, thisAs<ObjDateTime>().timeZone))
}
addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") { 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() thisAs<ObjDateTime>().instant.epochSeconds.toObj()
} }
@ -223,11 +228,7 @@ class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() {
"Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds.", "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"))), params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))),
returns = type("lyng.DateTime"), moduleName = "lyng.time") { returns = type("lyng.DateTime"), moduleName = "lyng.time") {
val tz = when (val a = args.list.getOrNull(0)) { val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
is ObjString -> TimeZone.of(a.value)
is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone()
else -> raiseIllegalArgument("invalid timezone: $a")
}
ObjDateTime(thisAs<ObjDateTime>().instant, tz) 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") { addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") {

View File

@ -268,14 +268,19 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru
returns = type("lyng.DateTime"), returns = type("lyng.DateTime"),
moduleName = "lyng.time" moduleName = "lyng.time"
) { ) {
val tz = when (val a = args.list.getOrNull(0)) { val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
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) ObjDateTime(thisAs<ObjInstant>().instant, tz)
} }
addFnDoc(
name = "toDate",
doc = "Convert this instant to a calendar Date in the specified time zone. If omitted, the system default time zone is used.",
params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any", true))),
returns = type("lyng.Date"),
moduleName = "lyng.time"
) {
val tz = parseTimeZoneArg(this, args.list.getOrNull(0), TimeZone.currentSystemDefault())
ObjDate(toDate(thisAs<ObjInstant>().instant, tz))
}
// class members // class members

View File

@ -36,7 +36,8 @@ enum class LynonType(val objClass: ObjClass, val defaultFrequency: Int = 1) {
Buffer(ObjBuffer.type, 50), Buffer(ObjBuffer.type, 50),
Instant(ObjInstant.type, 30), Instant(ObjInstant.type, 30),
Duration(ObjDuration.type), Duration(ObjDuration.type),
Other(Obj.rootObjectType, 60); Other(Obj.rootObjectType, 60),
Date(ObjDate.type, 20);
fun generalizeTo(other: LynonType): LynonType? { fun generalizeTo(other: LynonType): LynonType? {
if (this == other) return this if (this == other) return this

View File

@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@ -3479,6 +3480,68 @@ class ScriptTest {
) )
} }
@Test
fun testDateComprehensive() = runTest {
eval(
"""
import lyng.time
import lyng.serialization
val d1 = Date(2026, 4, 15)
assertEquals(2026, d1.year)
assertEquals(4, d1.month)
assertEquals(15, d1.day)
assertEquals(15, d1.dayOfMonth)
assertEquals("2026-04-15", d1.toIsoString())
assertEquals("2026-04-15", d1.toSortableString())
val d2 = Date.parseIso("2024-02-29")
assertEquals(true, d2.isLeapYear)
assertEquals(29, d2.lengthOfMonth)
assertEquals(366, d2.lengthOfYear)
assertEquals(60, d2.dayOfYear)
assertEquals(4, d2.dayOfWeek) // Thursday
val d3 = d2.addYears(1)
assertEquals(Date(2025, 2, 28), d3)
val d4 = Date(2024, 1, 31).addMonths(1)
assertEquals(Date(2024, 2, 29), d4)
val d5 = d1.addDays(10)
assertEquals(Date(2026, 4, 25), d5)
assertEquals(Date(2026, 4, 18), d1 + 3.days)
assertEquals(Date(2026, 4, 12), d1 - 3.days)
assertEquals(10, d1.daysUntil(d5))
assertEquals(10, d5.daysSince(d1))
assertEquals(10, d5 - d1)
val i = Instant("2024-01-01T23:30:00Z")
assertEquals(Date(2024, 1, 1), i.toDate("Z"))
assertEquals(Date(2024, 1, 2), i.toDate("+02:00"))
val dt = DateTime(2024, 5, 20, 15, 30, 45, "+02:00")
assertEquals(Date(2024, 5, 20), dt.date)
assertEquals(Date(2024, 5, 20), dt.toDate())
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "Z"), Date(2024, 5, 20).toDateTime("Z"))
assertEquals(DateTime(2024, 5, 20, 0, 0, 0, "+02:00"), Date(2024, 5, 20).atStartOfDay("+02:00"))
val roundTrip = Lynon.decode(Lynon.encode(d1))
assertEquals(d1, roundTrip)
assertEquals("\"2026-04-15\"", d1.toJsonString())
""".trimIndent()
)
val decoded = eval(
"""
import lyng.time
Date(2026, 4, 15)
""".trimIndent()
).decodeSerializable<LocalDate>()
assertEquals(LocalDate(2026, 4, 15), decoded)
}
@Test @Test
fun testDoubleImports() = runTest { fun testDoubleImports() = runTest {
val s = Scope.new() val s = Scope.new()

View File