v1.5.5 release

This commit is contained in:
Sergey Chernov 2026-04-23 15:18:47 +03:00
parent 14214e91e1
commit 9735774efd
13 changed files with 269 additions and 34 deletions

View File

@ -9,24 +9,46 @@ History note:
## Unreleased ## Unreleased
### Database access - No unreleased entries yet.
- 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 ## 1.5.5 (2026-04-23)
- 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. ### Concurrency and collections
- Added docs, stdlib reference updates, serialization support, and comprehensive tests for `Date`. - Added coroutine coordination primitives and helpers for everyday parallel code:
- `Channel` for coroutine-to-coroutine communication
- `LaunchPool` for bounded-concurrency task execution
- `Iterable<Deferred>.joinAll()` to await a whole collection of deferreds in input order
- `CompletableDeferred.completeExceptionally(...)` and `Deferred.cancelAndJoin()`
- Added docs and examples for the new concurrency APIs, including `joinAll()` coverage in iterable and parallelism references.
### Database and time APIs
- Added the portable `lyng.io.db` SQL contract and the first concrete providers:
- `lyng.io.db.sqlite` on JVM and Linux Native
- `lyng.io.db.jdbc` on JVM
- Added SQLite/JDBC release hardening:
- nested transactions via savepoints
- detached materialized rows
- generated-key support through `ExecutionResult.getGeneratedKeys()`
- schema-driven value conversion for `Bool`, `Decimal`, `Date`, `DateTime`, and `Instant`
- portable SQLite linker/deployment fixes and documented runtime options
- Added `Date` to `lyng.time` and the core runtime as a first-class calendar-date type, plus conversions and arithmetic across `Instant`, `DateTime`, and `Date`.
### Language, stdlib, and tooling
- Added extensions on singleton `object` declarations, including object-scoped indexer overrides for bracket syntax.
- Added backtick string literals and formatter support.
- Added `lyng.legacy_digest` for SHA-1 compatibility work, `String.replace`, and `buffer.base64std`.
- Improved CLI/runtime behavior with `atExit` shutdown handlers, native release-binary work, and follow-up CLI packaging/import fixes.
- Expanded docs across the tutorial, stdlib references, database docs, networking docs, and release notes.
### Runtime/compiler stability and performance
- Extended exact-call and higher-order lambda inlining through the bytecode compiler, including compiled fast paths for simple lambdas, wrappers, captures, and common higher-order helpers.
- Fixed import caching and class/object bytecode dispatch on JVM.
- Fixed immutable `val` compound assignments so true mutating `*Assign` operations continue to work while fallback reassignments report the correct read-only error.
- Fixed closure/capture and import regressions across launched loops, singleton/object extensions, aliasing, transitive re-exports, and immutable capture escaping.
- Improved list-fill/list-append fast paths, nullable-let inference, Decimal/Complex interop, and related regression coverage.
### Release notes ### Release notes
- Full `:lyngio:jvmTest` and `:lyngio:linuxX64Test` pass on the release tree after SQLite hardening. - Release metadata, homepage samples, docs, and README now point to `1.5.5`.
## 1.5.4 (2026-04-03) ## 1.5.4 (2026-04-03)

View File

@ -48,7 +48,7 @@ assertEquals(A.E.One, A.One)
- [Language home](https://lynglang.com) - [Language home](https://lynglang.com)
- [introduction and tutorial](docs/tutorial.md) - start here please - [introduction and tutorial](docs/tutorial.md) - start here please
- [Latest release notes (1.5.4)](docs/whats_new.md) - [Latest release notes (1.5.5)](docs/whats_new.md)
- [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)
@ -66,7 +66,7 @@ assertEquals(A.E.One, A.One)
### Add dependency to your project ### Add dependency to your project
```kotlin ```kotlin
val lyngVersion = "1.5.4" val lyngVersion = "1.5.5"
repositories { repositories {
// ... // ...
@ -186,7 +186,7 @@ Designed to add scripting to kotlin multiplatform application in easy and effici
# Language Roadmap # Language Roadmap
The current stable release is **v1.5.4**: the 1.5 cycle is feature-complete, compiler/runtime stabilization work is in, and the language, tooling, and site are aligned around the current release. The current stable release is **v1.5.5**: the 1.5 cycle now includes the database/date/concurrency additions as well as the latest compiler/runtime stabilization work, and the language, tooling, and site are aligned around this release.
Ready features: Ready features:

View File

@ -55,6 +55,23 @@ Here is the sample:
assertEquals( (1..3).joinToString { it * 10 }, "10 20 30") assertEquals( (1..3).joinToString { it * 10 }, "10 20 30")
>>> void >>> void
## joinAll
`joinAll()` is an `Iterable<Deferred>` helper that awaits every deferred in iteration order and returns a `List`
with the collected results.
val jobs = (1..4).map { n ->
launch { n * n }
}
assertEquals([1, 4, 9, 16], jobs.joinAll())
>>> void
Notes:
- it does not start any task by itself; it only awaits the deferreds already present in the iterable.
- awaiting happens in iteration order, so the result list keeps the same order as the input iterable.
- if any deferred fails or was cancelled, that `await()` error is propagated from `joinAll()`.
## `sum` and `sumOf` ## `sum` and `sumOf`
These, again, does the thing: These, again, does the thing:
@ -184,6 +201,7 @@ Search for the first element that satisfies the given predicate:
| sortedWith(comparator) | sort using a comparator that compares elements (1) | | sortedWith(comparator) | sort using a comparator that compares elements (1) |
| sortedBy(predicate) | sort by comparing results of the predicate function | | sortedBy(predicate) | sort by comparing results of the predicate function |
| joinToString(s,t) | convert iterable to string, see (2) | | joinToString(s,t) | convert iterable to string, see (2) |
| joinAll() | for `Iterable<Deferred>`, await all items in order and collect results to [List] |
| reversed() | create a list containing items from this in reverse order | | reversed() | create a list containing items from this in reverse order |
| shuffled() | create a list of shuffled elements | | shuffled() | create a list of shuffled elements |

View File

@ -73,13 +73,13 @@ pool.closeAndJoin()
## Collecting all results ## Collecting all results
`launch` returns a `Deferred`, so you can collect results via `map`: `launch` returns a `Deferred`, so you can collect results with `joinAll()`:
```lyng ```lyng
val pool = LaunchPool(4) val pool = LaunchPool(4)
val jobs = (1..10).map { n -> pool.launch { n * n } } val jobs = (1..10).map { n -> pool.launch { n * n } }
pool.closeAndJoin() pool.closeAndJoin()
val results = jobs.map { (it as Deferred).await() } val results = jobs.joinAll()
// results == [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] // results == [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
``` ```

View File

@ -18,6 +18,7 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
- Async/concurrency: `launch`, `yield`, `flow`, `delay`. - Async/concurrency: `launch`, `yield`, `flow`, `delay`.
- `Deferred.cancel()` cancels an active task. - `Deferred.cancel()` cancels an active task.
- `Deferred.await()` throws `CancellationException` if that task was cancelled. - `Deferred.await()` throws `CancellationException` if that task was cancelled.
- `Iterable<Deferred>.joinAll()` awaits every deferred in iteration order and returns a `List` of results.
- Math: `floor`, `ceil`, `round`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, `exp`, `ln`, `log10`, `log2`, `pow`, `sqrt`, `abs`, `clamp`. - Math: `floor`, `ceil`, `round`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh`, `atanh`, `exp`, `ln`, `log10`, `log2`, `pow`, `sqrt`, `abs`, `clamp`.
- These helpers also accept `lyng.decimal.Decimal`. - These helpers also accept `lyng.decimal.Decimal`.
- Exact Decimal path today: `abs`, `floor`, `ceil`, `round`, and `pow` with integral exponent. - Exact Decimal path today: `abs`, `floor`, `ceil`, `round`, and `pow` with integral exponent.

View File

@ -36,6 +36,17 @@ This example shows how to launch a coroutine with `launch` which returns [Deferr
Launch has the only argument which should be a callable (lambda usually) that is run in parallel (or cooperatively in parallel), and return anything as the result. Launch has the only argument which should be a callable (lambda usually) that is run in parallel (or cooperatively in parallel), and return anything as the result.
When you have an iterable of deferreds, use `joinAll()` to await all of them and collect results in input order:
val jobs = (1..4).map { n ->
launch {
delay(1)
n * 10
}
}
assertEquals([10, 20, 30, 40], jobs.joinAll())
>>> void
If you no longer need the result, cancel the deferred. Awaiting a cancelled deferred throws `CancellationException`: If you no longer need the result, cancel the deferred. Awaiting a cancelled deferred throws `CancellationException`:
var reached = false var reached = false
@ -269,7 +280,7 @@ val jobs = (1..20).map { n ->
} }
pool.closeAndJoin() // wait for all tasks to complete pool.closeAndJoin() // wait for all tasks to complete
val results = jobs.map { (it as Deferred).await() } val results = jobs.joinAll()
``` ```
Exceptions thrown inside a submitted lambda are captured in the returned `Deferred` and do not crash the pool, so other tasks continue running normally. Exceptions thrown inside a submitted lambda are captured in the returned `Deferred` and do not crash the pool, so other tasks continue running normally.

View File

@ -1,26 +1,29 @@
# What's New in Lyng # What's New in Lyng
This document highlights the current Lyng release, **1.5.4**, and the broader additions from the 1.5 cycle. This document highlights the current Lyng release, **1.5.5**, and the broader additions from the 1.5 cycle.
It is intentionally user-facing: new language features, new modules, new tools, and the practical things you can build with them. It is intentionally user-facing: new language features, new modules, new tools, and the practical things you can build with them.
For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5.md`. For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5.md`.
## Release 1.5.4 Highlights ## Release 1.5.5 Highlights
- `1.5.4` is the stabilization release for the 1.5 feature set. - `1.5.5` extends the 1.5 line with practical database APIs, first-class calendar dates, and better coroutine building blocks.
- The 1.5 line now brings together richer ranges and loops, interpolation, math modules, immutable and observable collections, richer `lyngio`, and much better CLI/IDE support. - The 1.5 line now brings together richer ranges and loops, interpolation, math modules, immutable and observable collections, richer `lyngio`, and much better CLI/IDE support.
- `1.5.4` specifically fixes user-visible issues around decimal arithmetic, mixed numeric flows, list behavior, and observable list hooks. - `1.5.5` adds `Channel`, `LaunchPool`, and `joinAll()` so coroutine-heavy scripts can coordinate work more directly.
- `1.5.4` also fixes extension-member registration for named singleton `object` declarations, so `fun X.foo()` and `val X.bar` now work as expected. - `1.5.5` adds `Date`, the portable `lyng.io.db` layer, SQLite/JDBC providers, and a compatibility `lyng.legacy_digest` module.
- `1.5.4` also lets named singleton `object` declarations use scoped indexer extensions with bracket syntax, so patterns like `Storage["name"]` can be implemented with `override fun Storage.getAt(...)` / `putAt(...)`. - `1.5.5` also continues runtime/compiler hardening with better import dispatch, faster exact lambda calls, and correct `val +=`/`-=` behavior for mutating types versus real reassignment.
- The docs, homepage samples, and release metadata now point at the current stable version. - The docs, homepage samples, and release metadata now point at the current stable version.
## User Highlights Across 1.5.x ## User Highlights Across 1.5.x
- 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}`
- Backtick string literals for raw-ish string text
- Decimal arithmetic, matrices/vectors, and complex numbers - Decimal arithmetic, matrices/vectors, and complex numbers
- Calendar `Date` support in `lyng.time` - Calendar `Date` support in `lyng.time`
- `Channel`, `LaunchPool`, and `joinAll()` for coroutine workflows
- Immutable collections and opt-in `ObservableList` - Immutable collections and opt-in `ObservableList`
- Rich `lyngio` modules for SQLite databases, console, HTTP, WebSocket, TCP, and UDP - Rich `lyngio` modules for SQLite/JDBC databases, console, HTTP, WebSocket, TCP, and UDP
- Legacy SHA-1 compatibility helpers in `lyng.legacy_digest`
- 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
@ -324,7 +327,7 @@ Singleton objects are declared using the `object` keyword. They provide a conven
```lyng ```lyng
object Config { object Config {
val version = "1.5.4" val version = "1.5.5"
fun show() = println("Config version: " + version) fun show() = println("Config version: " + version)
} }

0
examples/error2.lyng Normal file
View File

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "1.5.5-SNAPSHOT" version = "1.5.5"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

@ -2881,14 +2881,18 @@ class BytecodeCompiler(
val slot = resolveCapturedOwnerScopeSlot(localTarget) ?: resolveSlot(localTarget) ?: return null val slot = resolveCapturedOwnerScopeSlot(localTarget) ?: resolveSlot(localTarget) ?: return null
val targetType = slotTypes[slot] ?: SlotType.OBJ val targetType = slotTypes[slot] ?: SlotType.OBJ
if (!localTarget.isMutable) { if (!localTarget.isMutable) {
if (targetType != SlotType.OBJ && targetType != SlotType.UNKNOWN) return compileEvalRef(ref)
val rhs = compileRef(ref.value) ?: return compileEvalRef(ref) val rhs = compileRef(ref.value) ?: return compileEvalRef(ref)
if (!shouldUseAssignOpForImmutableLocal(slot, targetType, ref.op)) {
emitImmutableLocalReassignError(localTarget.name, localTarget.pos())
return rhs
}
val rhsObj = ensureObjSlot(rhs) val rhsObj = ensureObjSlot(rhs)
val nameId = builder.addConst(BytecodeConst.StringVal(localTarget.name)) val nameId = builder.addConst(BytecodeConst.StringVal(localTarget.name))
if (nameId > 0xFFFF) return compileEvalRef(ref) if (nameId > 0xFFFF) return compileEvalRef(ref)
val dst = allocSlot() val dst = allocSlot()
builder.emit(Opcode.ASSIGN_OP_OBJ, ref.op.ordinal, slot, rhsObj.slot, dst, nameId) builder.emit(Opcode.ASSIGN_OP_OBJ, ref.op.ordinal, slot, rhsObj.slot, dst, nameId)
updateSlotType(dst, SlotType.OBJ) updateSlotType(dst, SlotType.OBJ)
slotObjClass[slot]?.let { slotObjClass[dst] = it }
return CompiledValue(dst, SlotType.OBJ) return CompiledValue(dst, SlotType.OBJ)
} }
var rhs = compileRef(ref.value) ?: return compileEvalRef(ref) var rhs = compileRef(ref.value) ?: return compileEvalRef(ref)
@ -8446,6 +8450,38 @@ class BytecodeCompiler(
builder.emit(Opcode.THROW, posId, msgSlot) builder.emit(Opcode.THROW, posId, msgSlot)
return msgSlot return msgSlot
} }
private fun assignOpMethodName(op: BinOp): String? = when (op) {
BinOp.PLUS -> "plusAssign"
BinOp.MINUS -> "minusAssign"
BinOp.STAR -> "mulAssign"
BinOp.SLASH -> "divAssign"
BinOp.PERCENT -> "modAssign"
else -> null
}
private fun knownBuiltinAssignOpSupport(cls: ObjClass, op: BinOp): Boolean? = when (cls) {
ObjList.type -> op == BinOp.PLUS || op == BinOp.MINUS
ObjObservableList.type -> op == BinOp.PLUS || op == BinOp.MINUS
ObjSet.type -> op == BinOp.PLUS
ObjMap.type -> op == BinOp.PLUS
ObjRingBuffer.type -> op == BinOp.PLUS
else -> null
}
private fun shouldUseAssignOpForImmutableLocal(slot: Int, targetType: SlotType, op: BinOp): Boolean {
return when (targetType) {
SlotType.INT, SlotType.REAL, SlotType.BOOL -> false
SlotType.OBJ, SlotType.UNKNOWN -> {
val cls = slotObjClass[slot] ?: return true
val methodName = assignOpMethodName(op)
if (methodName != null && cls.getInstanceMemberOrNull(methodName, includeStatic = false) != null) {
return true
}
knownBuiltinAssignOpSupport(cls, op) ?: true
}
}
}
private fun binaryLeft(ref: BinaryOpRef): ObjRef = ref.left private fun binaryLeft(ref: BinaryOpRef): ObjRef = ref.left
private fun binaryRight(ref: BinaryOpRef): ObjRef = ref.right private fun binaryRight(ref: BinaryOpRef): ObjRef = ref.right
private fun binaryOp(ref: BinaryOpRef): BinOp = ref.op private fun binaryOp(ref: BinaryOpRef): BinOp = ref.op

View File

@ -104,14 +104,14 @@ class WebsiteSamplesTest {
val name = "Lyng" val name = "Lyng"
val base = { id:, name: } // Shorthand for id: id, name: name val base = { id:, name: } // Shorthand for id: id, name: name
val full = { ...base, version: "1.5.4", status: "stable" } val full = { ...base, version: "1.5.5", status: "stable" }
full full
""".trimIndent()) """.trimIndent())
assertTrue(result is ObjMap) assertTrue(result is ObjMap)
val m = result.map val m = result.map
assertEquals(101L, (m[ObjString("id")] as ObjInt).value) assertEquals(101L, (m[ObjString("id")] as ObjInt).value)
assertEquals("Lyng", (m[ObjString("name")] as ObjString).value) assertEquals("Lyng", (m[ObjString("name")] as ObjString).value)
assertEquals("1.5.4", (m[ObjString("version")] as ObjString).value) assertEquals("1.5.5", (m[ObjString("version")] as ObjString).value)
assertEquals("stable", (m[ObjString("status")] as ObjString).value) assertEquals("stable", (m[ObjString("status")] as ObjString).value)
} }

View File

@ -19,6 +19,8 @@ package net.sergeych.lyng
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFailsWith
class OperatorOverloadingTest { class OperatorOverloadingTest {
@Test @Test
@ -63,6 +65,24 @@ class OperatorOverloadingTest {
""".trimIndent()) """.trimIndent())
} }
@Test
fun testBuiltinListPlusAssignOnVal() = runTest {
eval("""
val list = [1, 2]
list += 3
assertEquals([1, 2, 3], list)
""".trimIndent())
}
@Test
fun testBuiltinListMinusAssignOnVal() = runTest {
eval("""
val list = [1, 2, 3]
list -= 2
assertEquals([1, 3], list)
""".trimIndent())
}
@Test @Test
fun testPlusAssignFallback() = runTest { fun testPlusAssignFallback() = runTest {
eval(""" eval("""
@ -76,6 +96,130 @@ class OperatorOverloadingTest {
""".trimIndent()) """.trimIndent())
} }
@Test
fun testPlusAssignFallbackOnValReportsReadonlyError() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class Vector(var x: Int, var y: Int) {
fun plus(other: Vector) = Vector(this.x + other.x, this.y + other.y)
fun equals(other: Vector) = this.x == other.x && this.y == other.y
}
val v = Vector(1, 2)
v += Vector(3, 4)
""".trimIndent())
}
assertContains(ex.errorMessage, "can't reassign val v")
}
@Test
fun testMinusAssignOverloadingOnVal() = runTest {
eval("""
class Counter(var n: Int) {
fun minusAssign(x: Int) { this.n = this.n - x }
}
val c = Counter(10)
c -= 3
assertEquals(7, c.n)
""".trimIndent())
}
@Test
fun testMinusAssignFallbackOnValReportsReadonlyError() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class Counter(var n: Int) {
fun minus(x: Int) = Counter(this.n - x)
}
val c = Counter(10)
c -= 3
""".trimIndent())
}
assertContains(ex.errorMessage, "can't reassign val c")
}
@Test
fun testMulAssignOverloadingOnVal() = runTest {
eval("""
class Counter(var n: Int) {
fun mulAssign(x: Int) { this.n = this.n * x }
}
val c = Counter(10)
c *= 3
assertEquals(30, c.n)
""".trimIndent())
}
@Test
fun testMulAssignFallbackOnValReportsReadonlyError() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class Counter(var n: Int) {
fun times(x: Int) = Counter(this.n * x)
}
val c = Counter(10)
c *= 3
""".trimIndent())
}
assertContains(ex.errorMessage, "can't reassign val c")
}
@Test
fun testDivAssignOverloadingOnVal() = runTest {
eval("""
class Counter(var n: Int) {
fun divAssign(x: Int) { this.n = this.n / x }
}
val c = Counter(21)
c /= 3
assertEquals(7, c.n)
""".trimIndent())
}
@Test
fun testDivAssignFallbackOnValReportsReadonlyError() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class Counter(var n: Int) {
fun div(x: Int) = Counter(this.n / x)
}
val c = Counter(21)
c /= 3
""".trimIndent())
}
assertContains(ex.errorMessage, "can't reassign val c")
}
@Test
fun testModAssignOverloadingOnVal() = runTest {
eval("""
class Counter(var n: Int) {
fun modAssign(x: Int) { this.n = this.n % x }
}
val c = Counter(23)
c %= 5
assertEquals(3, c.n)
""".trimIndent())
}
@Test
fun testModAssignFallbackOnValReportsReadonlyError() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class Counter(var n: Int) {
fun mod(x: Int) = Counter(this.n % x)
}
val c = Counter(23)
c %= 5
""".trimIndent())
}
assertContains(ex.errorMessage, "can't reassign val c")
}
@Test @Test
fun testCompareOverloading() = runTest { fun testCompareOverloading() = runTest {
eval(""" eval("""

View File

@ -159,7 +159,7 @@ fun HomePage() {
val id = 101 val id = 101
val name = "Lyng" val name = "Lyng"
val base = { id:, name: } val base = { id:, name: }
val full = { ...base, version: "1.5.4", status: "stable", tags: ["typed", "portable"] } val full = { ...base, version: "1.5.5", status: "stable", tags: ["typed", "portable"] }
println(full) println(full)
""".trimIndent() """.trimIndent()