Compare commits

...

22 Commits

Author SHA1 Message Date
31fac1a73c site: fixed .md/lyng code display 2026-04-30 09:50:10 +03:00
53a9d21a19 docs improved 2026-04-29 22:39:43 +03:00
739fdfc94b Add HTTP respondHtml sugar 2026-04-29 22:28:37 +03:00
c8e03d69ad Add Lyng HTML DSL helpers 2026-04-29 22:07:30 +03:00
b2200e71ff Add context receiver extensions for DSLs 2026-04-29 20:49:50 +03:00
e107296bca Add receiver-stack function types 2026-04-29 12:59:24 +03:00
1bababa058 Normalize site TOC heading hierarchy 2026-04-27 15:21:44 +03:00
35f4c968a4 T? ?: break inference fixed 2026-04-26 21:42:36 +03:00
79429d5f2d added nullable ?: break support with proper inference ;) 2026-04-26 20:57:46 +03:00
fae9965bdf fix CLI boostrap of http server 2026-04-26 17:23:21 +03:00
2dc4fb8230 more http server docs 2026-04-26 16:16:59 +03:00
f74ed9afe4 Switch HTTP server API to RequestContext receivers 2026-04-26 15:57:57 +03:00
ca4a0d4b12 Extend HTTP server routing and JSON exchange helpers 2026-04-26 13:42:14 +03:00
b969edd30a Fix commonMain cancellation handling in HTTP server loop 2026-04-26 10:21:33 +03:00
01ceecd7df Add minimal HTTP server and shared network type packages 2026-04-26 10:09:25 +03:00
79b015ee56 Fix callable return inference regressions 2026-04-25 19:07:10 +03:00
eba7158330 Fix SPA sample links and sample doc publishing 2026-04-25 16:46:51 +03:00
66b8806b11 Add runnable DB serialization example 2026-04-25 16:23:08 +03:00
92e9325f40 Add SQL object expansion serialization support 2026-04-25 16:09:30 +03:00
50e34e520e Add DB decode annotations and preserved declaration metadata 2026-04-25 13:36:33 +03:00
2abe7e2f96 Add typed canonical JSON encoding 2026-04-25 00:36:18 +03:00
2bedaa0969 Add canonical Json format and external format binding 2026-04-25 00:03:00 +03:00
104 changed files with 11256 additions and 431 deletions

View File

@ -13,6 +13,7 @@
- Prefer defining Lyng entities (enums/classes/type shapes) in `.lyng` files; only define them in Kotlin when there is Kotlin/platform-specific implementation detail that cannot be expressed in Lyng.
- Avoid hardcoding Lyng API documentation in Kotlin registrars when it can be declared in `.lyng`; Kotlin-side docs should be fallback/bridge only.
- For mixed pluggable modules (Lyng + Kotlin), embed module `.lyng` sources as generated Kotlin string literals, evaluate them into module scope during registration, then attach Kotlin implementations/bindings.
- When a change adds or changes Lyng-visible runtime/module behavior, update the corresponding `.lyng` declaration in the same change, including declaration-level docs/comments for new API surface.
## Kotlin/Wasm generation guardrails
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.

View File

@ -454,6 +454,43 @@ Key rules and features:
- For arbitrary receivers, use casts: `(expr as Type).member(...)` or `(expr as? Type)?.member(...)`.
- Qualified access does not relax visibility.
### Receiver-stack lambdas
Qualified `this@Type` is also used outside inheritance when a lambda has multiple visible receivers.
This is common in DSL-style builders.
- `A & B` means one receiver value that implements both types.
- `context(A, B) C.()->R` means a receiver stack:
- primary `this` is `C`
- outer/context receivers are `A`, then `B`
- Unqualified lookup checks the primary receiver first.
- If the primary receiver does not define a member and several outer/context receivers do, Lyng reports a compile-time ambiguity. Use `this@Type` to select one explicitly.
Example:
```lyng
class Html { fun title() = "html" }
class Head { fun title() = "head" }
class Body
val block: context(Html, Head) Body.()->String = {
// title() // compile-time ambiguity: Html vs Head
this@Html.title()
}
```
Context receivers can also constrain extension functions. The extension is visible only when the required receiver is
already in the implicit receiver stack:
```lyng
class Tag { fun addText(text: String) { /* ... */ } }
context(Tag)
fun String.unaryPlus() {
this@Tag.addText(this)
}
```
- Field inheritance (`val`/`var`) and collisions
- Instance storage is kept per declaring class, internally disambiguated; unqualified read/write resolves to the first match in the resolution order (leftmost base).
- Qualified read/write (via `this@Type` or casts) targets the chosen ancestor’s storage.
@ -625,10 +662,14 @@ Unary operators are overloaded by defining methods with no arguments:
| Operator | Method Name |
| :--- | :--- |
| `+a` | `unaryPlus()` |
| `-a` | `negate()` |
| `!a` | `logicalNot()` |
| `~a` | `bitNot()` |
`unaryPlus()` is useful in DSL-style builders where `+"text"` should append text to
the current receiver. See [samples/html_builder_dsl.lyng](samples/html_builder_dsl.lyng).
### Assignment Operators
Assignment operators like `+=` first attempt to call a specific assignment method. If that method is not defined, they fall back to a combination of the binary operator and a regular assignment (e.g., `a = a + b`).

View File

@ -83,6 +83,7 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
## 4. Operators (implemented)
- Assignment: `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `?=`.
- Logical: `||`, `&&`, unary `!`.
- Unary arithmetic/bitwise: unary `+`, unary `-`, `~`.
- Bitwise: `|`, `^`, `&`, `~`, shifts `<<`, `>>`.
- Equality/comparison: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`, `<=>`, `=~`, `!~`.
- Type/containment: `is`, `!is`, `in`, `!in`, `as`, `as?`.
@ -119,6 +120,7 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
- shorthand: `fun f(x) = expr`.
- generics: `fun f<T>(x: T): T`.
- extension functions: `fun Type.name(...) { ... }`.
- context-aware extension functions: `context(Tag) fun String.unaryPlus() { this@Tag.addText(this) }`.
- named singleton `object` declarations can be extension receivers too: `fun Config.describe(...) { ... }`, `val Config.tag get() = ...`.
- static extension functions are callable on the type object: `static fun List<T>.fill(...)` -> `List.fill(...)`.
- delegated callable: `fun f(...) by delegate`.
@ -164,7 +166,12 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
- unions `A | B`
- intersections `A & B`
- function types `(A, B)->R` and receiver form `Receiver.(A)->R`
- receiver-stack function types via `context(A, B) Receiver.(P)->R`
- variadics in function type via ellipsis (`T...`)
- `A & B` means one value implementing both types.
- `context(A, B) Receiver.(P)->R` is different: it declares an ordered implicit-receiver stack where `Receiver` is primary `this`, then `A`, then `B`.
- Nested receiver lambdas keep outer receivers in scope; unqualified lookup prefers the innermost receiver, and `this@Type` can select an outer/context receiver explicitly.
- If the primary receiver does not provide a member and multiple outer/context receivers do, the lookup is a compile-time ambiguity and must be disambiguated with `this@Type`.
- Generics:
- type params on classes/functions/type aliases
- bounds via `:` with union/intersection expressions
@ -217,6 +224,7 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
- Disambiguation helpers are supported:
- qualified this: `this@Base.member()`
- cast view: `(obj as Base).member()`
- In nested receiver lambdas, `this@Type` can target any receiver visible through the receiver stack, not just inheritance ancestors.
- On unknown receiver types, compiler allows only Object-safe members:
- `toString`, `toInspectString`, `let`, `also`, `apply`, `run`
- Other members require known receiver type or explicit cast.

View File

@ -0,0 +1,14 @@
# AI notes: publish JVM CLI updates with `bin/local_jrelease`
[//]: # (excludeFromIndex)
When a change affects the JVM CLI launcher used as `jlyng`, refresh the installed local distribution with:
```bash
bin/local_jrelease
```
Why:
- `jlyng` in this repo is installed from `~/bin/jlyng-jvm/lyng-jvm`, not directly from `lyng/build/install`.
- Manual copying from Gradle build output can leave the actual launcher on `PATH` stale.
- `bin/local_jrelease` rebuilds `lyng/build/distributions/lyng-jvm.zip`, reinstalls it under `~/bin/jlyng-jvm`, and recreates the `~/bin/jlyng` symlink.

View File

@ -0,0 +1,10 @@
# AI notes: heading levels must be consecutive
[//]: # (excludeFromIndex)
When editing repository documentation:
- Use heading levels in order: `#`, then `##`, then `###`, and so on.
- Do not skip levels, for example `#` directly to `###`.
- Keep the heading tree balanced inside each document; sibling sections should use the same level.
- If you add a subsection and the parent is `##`, the child must be `###`.

View File

@ -90,8 +90,14 @@ Requires installing `lyngio` into the import manager from host code.
- `import lyng.io.process` (process execution API)
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events)
- `import lyng.io.http` (HTTP/HTTPS client API)
- `import lyng.io.http.server` (minimal HTTP/1.1 and WebSocket server API)
- `import lyng.io.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere)
- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
- `import lyng.io.html` (pure Lyng HTML builder DSL: `html { body { h3 { +"text" } } }`)
- Shared network value-type packages are also available when installed by host code:
- `import lyng.io.http.types` (`HttpHeaders`)
- `import lyng.io.ws.types` (`WsMessage`)
- `import lyng.io.net.types` (`IpVersion`, `SocketAddress`, `Datagram`)
## 7. AI Generation Tips
- Assume `lyng.stdlib` APIs exist in regular script contexts.

View File

@ -27,6 +27,6 @@ See `docs/lyng_d_files.md` for `.lyng.d` syntax and examples.
- Alternatively, if/when the plugin is published to a marketplace, you will be able to install it
directly from the “Marketplace” tab (not yet available).
### [Download plugin v0.0.2-SNAPSHOT](https://lynglang.com/distributables/lyng-idea-0.0.2-SNAPSHOT.zip)
### [Download plugin v0.0.5-SNAPSHOT](https://lynglang.com/distributables/lyng-idea-0.0.5-SNAPSHOT.zip)
Your ideas and bugreports are welcome on the [project gitea page](https://gitea.sergeych.net/SergeychWorks/lyng/issues)

View File

@ -1,9 +1,32 @@
# Json support
Since 1.0.5 we start adding JSON support. Versions 1,0,6* support serialization of the basic types, including lists and
maps, and simple classes. Multiple inheritance may produce incorrect results, it is work in progress.
Lyng now has two distinct JSON-facing layers:
## Serialization in Lyng
- plain JSON projection:
- `Obj.toJson()`
- `Obj.toJsonString()`
- canonical JSON round-trip format:
- `Json.encode(value)`
- `Json.decode(text)`
- typed canonical JSON round-trip format:
- `Json.encodeAs(Type, value)`
- `Json.decodeAs(Type, text)`
Use the first when you need ordinary JSON for interop.
Use the second when you need Lyng value round-trip semantics through JSON text with no schema.
Use the third when both sides already know the Lyng type and you want the same round-trip semantics with fewer type
tags in the JSON.
This distinction is intentional:
- plain JSON projection is optimized for compatibility with ordinary JSON tooling
- canonical `Json.encode()` is optimized for semantic fidelity to Lyng and Lynon and stays self-describing
- typed canonical `Json.encodeAs()` is optimized for the same fidelity when the schema is provided externally
- these goals conflict for values such as sets, exceptions, singleton objects, buffers, and maps with non-string keys
## Plain JSON projection in Lyng
// in lyng
assertEquals("{\"a\":1}", {a: 1}.toJsonString())
@ -20,7 +43,8 @@ Simple classes serialization is supported:
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
>>> void
Note that mutable members are serialized by default. You can exclude any member (including constructor parameters) from JSON serialization using the `@Transient` attribute:
Note that mutable members are serialized by default. You can exclude any member (including constructor parameters) from
JSON serialization using the `@Transient` attribute:
import lyng.serialization
@ -31,7 +55,7 @@ Note that mutable members are serialized by default. You can exclude any member
assertEquals( "{\"bar\":2,\"visible\":100}", Point2(1,2).toJsonString() )
>>> void
Note that if you override json serialization:
Note that if you override plain JSON serialization:
import lyng.serialization
@ -46,8 +70,8 @@ Note that if you override json serialization:
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
>>> void
Custom serialization of user classes is possible by overriding `toJsonObject` method. It must return an object which is
serializable to Json. Most often it is a map, but any object is accepted, that makes it very flexible:
Custom serialization of user classes is possible by overriding `toJsonObject`. It must return an object which is
serializable to JSON. Most often it is a map, but any object is accepted:
import lyng.serialization
@ -70,12 +94,87 @@ serializable to Json. Most often it is a map, but any object is accepted, that m
Please note that `toJsonString` should be used to get serialized string representation of the object. Don't call
`toJsonObject` directly, it is not intended to be used outside the serialization library.
## Canonical Json round-trip format
`Json.encode()` and `Json.decode()` are now the JSON equivalents of `Lynon.encode()` and `Lynon.decode()`.
They still use JSON text, but they add Lyng-specific type tags where plain JSON would otherwise lose information.
When a map already fits ordinary JSON object rules, canonical JSON keeps that traditional object shape. In particular,
maps with string keys are still serialized as JSON objects, not as tagged entry lists.
Example:
```lyng
import lyng.serialization
import lyng.time
enum Color { Red, Green }
class Point(x,y) { var z = 42 }
val p = Point(1,2)
p.z = 99
val value = List(
p,
Map([1, "one"], ["two", 2]),
Set(1,2,3),
"hello".encodeUtf8(),
Date(2026,4,15),
Color.Green
)
assertEquals(value, Json.decode(Json.encode(value)))
```
The canonical `Json` format is intended for Lyng-to-Lyng transfer through JSON text.
The plain `toJson()` projection is intended for ordinary JSON interop.
Canonical `Json.encode()` should be read as the JSON analogue of `Lynon.encode()`: when Lynon already preserves a
Lyng distinction, canonical JSON tries to preserve it too, using tags only where ordinary JSON is insufficient.
## Typed canonical Json round-trip format
`Json.encodeAs(Type, value)` and `Json.decodeAs(Type, text)` use the same canonical rules, but with a declared target
type available during the whole traversal.
This changes one thing only: type tags may be omitted when the declared type is already exact enough to restore the
value unambiguously.
The same map rule still applies here: `Map<String, T>` stays a normal JSON object, while non-string-key maps fall back
to canonical entry encoding.
Example:
```lyng
import lyng.serialization
closed class Point(x: Int, y: Int)
closed class Segment(a: Point, b: Point)
val value = Segment(Point(0, 1), Point(2, 3))
val encoded = Json.encodeAs(Segment, value)
assertEquals("{\"a\":{\"x\":0,\"y\":1},\"b\":{\"x\":2,\"y\":3}}", encoded)
assertEquals(value, Json.decodeAs(Segment, encoded))
```
Subtype information is still preserved when the declared type is wider than the runtime one. For example, if a field is
declared as `Base` but contains `Derived`, canonical subtype tags remain in that field.
This is why the APIs are split:
- `toJson()` stays plain and interop-friendly
- `Json.encode()` stays fully self-describing and safe to decode without a schema
- `Json.encodeAs()` uses the supplied schema to reduce noise, but only where that schema is sufficient
## Kotlin side interfaces
The "Batteries included" principle is also applied to serialization.
- `Obj.toJson()` provides Kotlin `JsonElement`
- `Obj.toJsonString()` provides Json string representation
- `Obj.toJson()` provides Kotlin `JsonElement` for the plain JSON projection
- `Obj.toJsonString()` provides plain JSON string representation
- `Obj.decodeSerializableWith()` and `Obj.decodeSerializable()` allows to decode Lyng classes as Kotlin objects using
`kotlinx.serialization`:
@ -104,10 +203,9 @@ suspend inline fun <reified T> Obj.decodeSerializable(scope: Scope = Scope()) =
decodeSerializableWith<T>(serializer<T>(), scope)
```
Note that lyng-2-kotlin deserialization with `kotlinx.serialization` uses JsonElement as information carrier without
formatting and parsing actual Json strings. This is why we use `Json.decodeFromJsonElement` instead of
`Json.decodeFromString`. Such an approach gives satisfactory performance without writing and supporting custom
`kotlinx.serialization` codecs.
Note that Lyng-to-Kotlin deserialization with `kotlinx.serialization` is based on the plain JSON projection,
not the canonical `Json.encode()` format. It uses `JsonElement` as the information carrier without formatting and
parsing actual JSON strings. This is why we use `Json.decodeFromJsonElement` instead of `Json.decodeFromString`.
### Pitfall: JSON objects and Map<String, Any?>
@ -134,7 +232,8 @@ fun deserializeMapWithJsonTest() = runTest {
But what if your map has objects of different types? The approach of using polymorphism is partially applicable, but what to do with `{ one: 1, two: "two" }`?
The answer is pretty simple: use `JsonObject` in your deserializable object. This class is capable of holding any JSON types and structures and is sort of a silver bullet for such cases:
The answer is simple: use `JsonObject` in your deserializable object. This class is capable of holding any JSON types
and structures:
~~~kotlin
@Serializable
@ -154,26 +253,71 @@ fun deserializeAnyMapWithJsonTest() = runTest {
~~~
# List of supported types
## Supported shapes
### Plain JSON projection
| Lyng type | JSON type | notes |
|-----------|-----------|-------------|
| `Int` | number | |
| `Real` | number | |
| `Real` | number | finite values only as plain numbers |
| `String` | string | |
| `Bool` | boolean | |
| `null` | null | |
| `Instant` | string | ISO8601 (1) |
| `List` | array | (2) |
| `Map` | object | (2) |
| `Map` | object | string keys only |
| simple class instance | object | constructor fields + mutable vars |
| enum | string | entry name |
### Canonical `Json.encode`
This format can also round-trip:
- maps with non-string keys
- sets
- immutable collections
- buffers and bit buffers
- class instances
- singleton objects
- enums
- exceptions
- `Date`, `Instant`, `DateTime`
- non-finite reals
- `void`
### Typed canonical `Json.encodeAs`
This format round-trips the same value space as canonical `Json.encode`, but it can emit simpler JSON for:
- closed classes and other exactly-known class fields
- enums when the enum type is known
- typed collections whose element types are known
- nested object graphs where declared field types are precise
It still falls back to canonical tagged encoding when exact runtime type information would otherwise be lost.
It does so by adding Lyng-specific type tags only when necessary.
## Kotlin-side extension point for more formats
Additional formats can be exported from Kotlin modules by subclassing `ObjSerializationFormatClass` and registering the
format in module scope with `bindSerializationFormat(...)`.
```kotlin
module.bindSerializationFormat(
object : ObjSerializationFormatClass("MyFormat") {
override suspend fun encodeValue(scope: Scope, value: Obj): Obj = ...
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj = ...
}
)
```
This makes `MyFormat.encode(...)` and `MyFormat.decode(...)` available from Lyng after importing the module.
(1)
: ISO8601 flavor 1970-05-06T06:00:00.000Z in used; number of fractional digits depends on the truncation
on [Instant](time.md), see `Instant.truncateTo...` functions.
: ISO8601 flavor `1970-05-06T06:00:00.000Z` is used; number of fractional digits depends on truncation on
`Instant`, see `Instant.truncateTo...` functions.
(2)
: List may contain any objects serializable to Json.
(3)
: Map keys must be strings, map values may be any objects serializable to Json.
: Lists may contain any values serializable by the selected JSON layer.

View File

@ -1,4 +1,4 @@
### lyng.io.db — SQL database access for Lyng scripts
# lyng.io.db — SQL database access for Lyng scripts
This module provides the portable SQL database contract for Lyng. The current shipped providers are SQLite via `lyng.io.db.sqlite` and a JVM-only JDBC bridge via `lyng.io.db.jdbc`.
@ -6,7 +6,7 @@ This module provides the portable SQL database contract for Lyng. The current sh
---
#### Install the module into a Lyng session
## Install the module into a Lyng session
For SQLite-backed database access, install both the generic DB module and the SQLite provider:
@ -54,7 +54,7 @@ suspend fun bootstrapJdbc() {
---
#### Using from Lyng scripts
## Using from Lyng scripts
Typed SQLite open helper:
@ -188,34 +188,98 @@ assertThrows(RollbackException) {
---
#### Portable API
## Runnable serialization sample
##### `Database`
A complete runnable example is in [examples/sqlite_serialization.lyng](../examples/sqlite_serialization.lyng).
It uses:
- `@DbJson`
- `@DbLynon`
- `@DbExcept`
- `@cols(...)`, `@vals(...)`, `@set(...)`
- `decodeAs<T>()`
The current direct read form that works under `jlyng` is:
```lyng
tx.select("select * from item where id = ?", 1).decodeAs<Item>().first
```
If we want a shorter form such as:
```lyng
tx.selectAllAs<Item>("item where id = ?", 1).first
```
it should be added as a built-in `SqlTransaction` API. A pure Lyng generic wrapper around `decodeAs<T>()` does not currently preserve `T` reliably enough under `jlyng`.
---
## Portable API
### `Database`
- `transaction(block)` — opens a transaction, commits on normal exit, rolls back on uncaught failure.
##### `SqlTransaction`
### `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`
`select(...)` and `execute(...)` also support SQL object-expansion macros for declaration-driven writes:
- `@cols(?1)` — expand object argument `?1` to a comma-separated column list
- `@vals(?1)` — expand object argument `?1` to matching placeholders and bind values
- `@set(?1)` — expand object argument `?1` to `column = ?` pairs and bind values
Each macro also supports an optional clause-local exclusion list:
```lyng
tx.execute("update item set @set(?1 except: \"id\", \"createdAt\") where id = ?2", item, item.id)
```
Example:
```lyng
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
tx.execute("update item set @set(?1) where id = ?2", item, item.id)
```
When a clause uses any of these macros, non-expanded scalar parameters in the same SQL string must use explicit indexed placeholders such as `?2`, `?3`, and so on.
### `ResultSet`
- `columns` — positional `SqlColumn` metadata, available before iteration.
- `size()` — result row count.
- `isEmpty()` — fast emptiness check where possible.
- `iterator()` — normal row iteration while the transaction is active.
- `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends.
- `decodeAs<T>()` — transaction-scoped iterable view that decodes each row into `T`.
##### `SqlRow`
### `SqlRow`
- `row[index]` — zero-based positional access.
- `row["columnName"]` — case-insensitive lookup by output column label.
- `row.decodeAs<T>()` — decode one row into a typed Lyng value.
Name-based access fails with `SqlUsageException` if the name is missing or ambiguous.
##### `ExecutionResult`
### `DbFieldAdapter`
Custom DB field projection hook used by `@DbDecodeWith(...)` and `@DbSerializeWith(...)`.
- `decode(rawValue, column, row, targetType)` — adapt one raw DB field value to a Lyng value for the requested target type.
- `encode(value, targetType)` — adapt one Lyng value to a direct DB-bindable value for SQL object expansion.
Use `@DbDecodeWith(adapter)` on class constructor parameters and class-body fields/properties that participate in `decodeAs<T>()`.
Use `@DbSerializeWith(adapter)` on constructor parameters and class-body fields/properties that participate in `@cols(...)`, `@vals(...)`, and `@set(...)` object expansion.
Annotation arguments are evaluated once when the declaration is created, and the resulting adapter instance is retained in declaration metadata.
### `ExecutionResult`
- `affectedRowsCount`
- `getGeneratedKeys()`
@ -224,7 +288,7 @@ Statements that return rows directly, such as `... returning ...`, should use `s
---
#### Value mapping
## Value mapping
Portable bind values:
@ -237,6 +301,88 @@ Portable bind values:
Unsupported parameter values fail with `SqlUsageException`.
SQL object-expansion write rules:
- constructor parameters participate in projection by declaration order
- matching serializable class-body fields/properties also participate
- `@Transient` fields are excluded automatically
- `@DbExcept` fields are excluded automatically
- `except:` excludes additional fields for one specific macro use
- direct DB-bindable values are written as-is
- `@DbJson` fields are encoded as canonical JSON text
- `@DbLynon` fields are encoded as Lynon binary
- `@DbSerializeWith(adapter)` fields are encoded through the adapter
- unannotated non-bindable object fields fail with `SqlUsageException`
Write-side encoding is intentionally explicit. The runtime does not try to infer target DB column types from SQL text or backend metadata during statement preparation.
Example:
```lyng
import lyng.io.db
import lyng.io.db.sqlite
class Payload(name: String, count: Int)
object TrimAdapter: DbFieldAdapter {
override fun encode(value, targetType) =
when(value) {
null -> null
else -> value.toString().trim()
}
}
class Item(
id: Int,
@DbSerializeWith(TrimAdapter) title: String,
@DbJson meta: Payload,
@DbLynon state: Payload
) {
var note: String = ""
@DbExcept var cache: String = ""
}
val db = openSqlite(":memory:")
val restored = db.transaction { tx ->
tx.execute(
"create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)"
)
val item = Item(1, " first ", Payload("json", 10), Payload("bin", 20))
item.note = "created"
item.cache = "not stored"
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
item.title = " second "
item.meta = Payload("json2", 11)
item.state = Payload("bin2", 21)
item.note = "updated"
tx.execute(
"update item set @set(?1 except: \"id\") where id = ?2",
item,
item.id
)
tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
}
assertEquals("second", restored.title)
assertEquals("json2", restored.meta.name)
assertEquals(21, restored.state.count)
assertEquals("updated", restored.note)
```
This example shows:
- `@DbSerializeWith(...)` trimming a string before write
- `@DbJson` storing structured data in a text column
- `@DbLynon` storing structured data in a binary column
- `@DbExcept` excluding a field from automatic projection
- `@set(... except: "id")` skipping one field for an update clause
- `decodeAs<Item>()` reconstructing the object on read
Portable result metadata categories:
- `Binary`
@ -249,11 +395,27 @@ Portable result metadata categories:
- `DateTime`
- `Instant`
Typed row decode rules:
- object/class targets map constructor parameters by column label, case-insensitively
- remaining matching serializable mutable fields are assigned after constructor call
- `@DbDecodeWith(adapter)` on a constructor parameter or class-body field/property takes precedence over built-in JSON/Lynon decoding
- `@DbDecodeWith(adapter)` must receive exactly one adapter instance implementing `DbFieldAdapter`
- adapter output must match the target member type or decoding fails with `SqlUsageException`
- missing required non-null constructor fields fail
- defaulted or nullable constructor fields may be omitted from the result
- extra result columns currently fail in strict mode
- if a row has exactly one column, that value may be decoded directly as the requested target type
- JSON-like native column types (`json`, `jsonb`) are decoded through typed canonical `Json` when the target type is not `String`
- binary columns are decoded through `Lynon` when the target type is not `Buffer`
- `Buffer` targets keep the raw binary payload without Lynon decoding
- plain text columns are not implicitly treated as JSON
For temporal types, see [time functions](time.md).
---
#### SQLite provider
## SQLite provider
`lyng.io.db.sqlite` currently provides the first concrete backend.
@ -297,7 +459,7 @@ Open-time validation failures:
- malformed URL or bad option shape -> `IllegalArgumentException`
- runtime open failure -> `DatabaseException`
#### JDBC provider
## JDBC provider
`lyng.io.db.jdbc` is currently implemented on the JVM target only. The `lyngio-jvm` artifact bundles and explicitly loads these JDBC drivers:
@ -369,7 +531,7 @@ PostgreSQL-specific notes:
---
#### Lifetime rules
## Lifetime rules
`ResultSet` is valid only while its owning transaction is active.
@ -387,12 +549,14 @@ This means:
- do not keep `ResultSet` objects after the transaction block returns
- materialize rows with `toList()` inside the transaction when they must outlive it
- the iterable returned by `decodeAs<T>()` is also transaction-scoped
- decoded objects produced while iterating `decodeAs<T>()` are detached ordinary Lyng values
The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached.
---
#### Platform support
## 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

164
docs/lyng.io.html.md Normal file
View File

@ -0,0 +1,164 @@
# lyng.io.html
`lyng.io.html` provides a pure Lyng HTML builder DSL. It uses Lyng context
receiver extensions, so text can be appended with `+"text"` inside tag blocks
without global builder state.
Host code installs the package from `lyngio` with `createHtmlModule(...)`:
```kotlin
val scope = Script.newScope()
createHtmlModule(scope.importManager)
```
Lyng code can then import it:
```lyng
import lyng.io.html
val page = html {
head {
title { +"Demo" }
}
body {
nav {
a(href: "/") { +"Home" }
}
h3 { +"Heading 3" }
p {
attr("data-id", 123)
+"Text is escaped: <safe>"
}
img(src: "/logo.png", alt: "Logo")
}
}
```
`html { ... }` returns a `String` beginning with `<!doctype html>`.
## Escaping
Text appended with unary `+` is HTML-escaped:
```lyng
html {
body {
p { +"Text & <more>" }
}
}
```
produces:
```html
<!doctype html><html><body><p>Text &amp; &lt;more&gt;</p></body></html>
```
Attribute values are escaped with HTML attribute rules:
```lyng
p {
attr("data-x", "\"quoted\" & <tag>")
+"content"
}
```
Use `raw(...)` only for trusted markup:
```lyng
div {
raw("<span>already escaped or trusted</span>")
}
```
## Tag Helpers
Current tag helpers cover common structural tags (`head`, `body`, `main`,
`section`, `article`, `header`, `footer`, `nav`, `div`, `span`, `p`), headings
(`h1` through `h6`), lists (`ul`, `ol`, `li`), and text/code tags (`strong`,
`em`, `code`, `pre`, `script`, `style`).
```lyng
body {
main {
section {
h2 { +"News" }
p { +"First item" }
}
}
}
```
Common void tags are also available: `meta`, `link`, `img`, `br`, and `input`.
```lyng
head {
meta { attr("charset", "utf-8") }
link {
attr("rel", "stylesheet")
attr("href", "/site.css")
}
}
```
## Attributes
Use `attr(name, value)` inside a tag block to set an escaped attribute value.
`id(...)` and `classes(...)` are small aliases:
```lyng
div {
id("root")
classes("app shell")
}
```
Use `flag(name)` for boolean attributes:
```lyng
input {
attr("type", "checkbox")
flag("checked")
}
```
## Convenience Helpers
Convenience helpers include `metaCharset()`, `stylesheet(href)`,
`a(href) { ... }`, `img(src, alt)`, and `input(type, name, value)`.
```lyng
head {
metaCharset()
stylesheet("/site.css")
}
body {
nav {
a(href: "/home") { +"Home" }
}
img(src: "/logo.png", alt: "Logo & mark")
input(type: "hidden", name: "token", value: "abc")
}
```
## Generic Elements
Use `tag(name) { ... }` and `voidTag(name) { ... }` for elements that do not
have dedicated helpers yet:
```lyng
body {
tag("custom-element") {
flag("hidden")
+"Secret"
}
voidTag("source") {
attr("srcset", "/image.webp")
attr("type", "image/webp")
}
}
```
These helpers are intentionally simple escape hatches. Prefer a dedicated helper
when one exists because it can encode safer defaults and clearer parameter names.

View File

@ -1,12 +1,14 @@
### lyng.io.http — HTTP/HTTPS client for Lyng scripts
# lyng.io.http — HTTP/HTTPS client for Lyng scripts
This module provides a compact HTTP client API for Lyng scripts. It is implemented in `lyngio` and backed by Ktor on supported runtimes.
> **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.
>
> **Shared type note:** `HttpHeaders` is also available from `lyng.io.http.types` when host code wants the reusable value type without relying on the HTTP client module itself.
---
#### Add the library to your project (Gradle)
## Add the library to your project (Gradle)
If you use this repository as a multi-module project, add a dependency on `:lyngio`:
@ -20,7 +22,7 @@ For external projects, ensure you also use the Lyng Maven repository described i
---
#### Install the module into a Lyng session
## Install the module into a Lyng session
The HTTP module is not installed automatically. Install it into the session scope and provide a policy.
@ -42,7 +44,7 @@ suspend fun bootstrapHttp() {
---
#### Using from Lyng scripts
## Using from Lyng scripts
Simple GET:
@ -84,9 +86,9 @@ HTTPS GET:
---
#### API reference
## API reference
##### `Http` (static methods)
### `Http` (static methods)
- `isSupported(): Bool` — Whether HTTP client support is available on the current runtime.
- `request(req: HttpRequest): HttpResponse` — Execute a request described by a mutable request object.
@ -99,7 +101,7 @@ For convenience methods, `headers...` accepts:
- `MapEntry`, e.g. `"Accept" => "text/plain"`
- 2-item lists, e.g. `["Accept", "text/plain"]`
##### `HttpRequest`
### `HttpRequest`
- `method: String`
- `url: String`
@ -110,7 +112,7 @@ For convenience methods, `headers...` accepts:
Only one of `bodyText` and `bodyBytes` should be set.
##### `HttpResponse`
### `HttpResponse`
- `status: Int`
- `statusText: String`
@ -120,7 +122,7 @@ Only one of `bodyText` and `bodyBytes` should be set.
Response body decoding is cached inside the response object.
##### `HttpHeaders`
### `HttpHeaders`
`HttpHeaders` behaves like `Map<String, String>` for the first value of each header name and additionally exposes:
@ -132,7 +134,7 @@ Header lookup is case-insensitive.
---
#### Security policy
## Security policy
The module uses `HttpAccessPolicy` to authorize requests before they are sent.
@ -168,7 +170,7 @@ val allowLocalOnly = object : HttpAccessPolicy {
---
#### Platform support
## Platform support
- **JVM:** supported
- **Android:** supported via the Ktor CIO client backend

446
docs/lyng.io.http.server.md Normal file
View File

@ -0,0 +1,446 @@
# `lyng.io.http.server` - Minimal HTTP/1.1 And WebSocket Server
This module provides a small server-side HTTP API for Lyng scripts. It is implemented in `lyngio` on top of the existing TCP layer and is intended for embedded tools, local services, test fixtures, and lightweight app backends.
It supports:
- HTTP/1.1 request parsing
- keep-alive
- exact-path routing
- regex routing
- path-template routing with named parameters
- websocket upgrade and server-side sessions
It does not aim to replace a full reverse proxy. Typical deployment is behind nginx, Caddy, or another frontend that handles TLS and public-facing edge concerns.
> **Security note:** this module uses the same `NetAccessPolicy` capability model as raw TCP sockets. If scripts are allowed to listen on TCP, they can host an HTTP server.
## Install The Module Into A Lyng Session
Kotlin bootstrap example:
```kotlin
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.Scope
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
suspend fun bootstrapHttpServer() {
val session = EvalSession()
val scope: Scope = session.getScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
session.eval("import lyng.io.http.server")
}
```
## RequestContext Sugar
Route handlers use `RequestContext` as the receiver, so inside handlers you normally write direct calls such as:
- `jsonBody<T>()`
- `respondJson(...)`
- `respondHtml { ... }`
- `respondText(...)`
- `setHeader(...)`
- `request.path`
- `routeParams["id"]`
This keeps ordinary HTTP endpoints compact and avoids passing an explicit request or exchange parameter through every route lambda.
## HTML Response Sugar
Use `respondHtml { ... }` to render an HTML document with the `lyng.io.html` DSL and send it as `text/html; charset=utf-8`.
```lyng
import lyng.io.http.server
import lyng.io.html
val server = HttpServer()
server.get("/") {
respondHtml {
head {
title { +"Lyng status" }
}
body {
h3 { +"Service is running" }
p { +"Path: ${request.path}" }
}
}
}
server.listen(8080, "127.0.0.1")
```
Pass `code:` when the route should return a non-200 status:
```lyng
server.get("/accepted") {
respondHtml(code: 202) {
body { h3 { +"Accepted" } }
}
}
```
## JSON API Sugar
For ordinary JSON APIs, `RequestContext` includes two primary helpers:
- `jsonBody<T>()` decodes the request body with typed `Json.decodeAs(...)`
- `respondJson(body, status = 200)` sets JSON content type and responds with plain `toJsonString()`
These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`.
### Typed JSON POST With Route Params
```lyng
import lyng.io.http.server
closed class CreateResultRequest(title: String, score: Int)
closed class CreateResultResponse(id: String, userId: String, title: String, score: Int)
val server = HttpServer()
server.postPath("/api/users/{userId}/results") {
val req = jsonBody<CreateResultRequest>()
if (req.title.isBlank()) {
respondJson({ error: "title must not be empty" }, 400)
return
}
respondJson(
CreateResultResponse("r-101", routeParams["userId"], req.title, req.score),
201
)
}
server.listen(8080, "127.0.0.1")
```
### JSON Response With Route Params
```lyng
import lyng.io.http.server
val server = HttpServer()
server.getPath("/api/users/{id}") {
respondJson({
id: routeParams["id"],
path: request.path,
ok: true
})
}
server.listen(8080, "127.0.0.1")
```
## Request And Route Data
`ServerRequest` exposes parsed HTTP request data:
- `method: String`
- `target: String`
- `path: String`
- `pathParts: List<String>`
- `queryString: String?`
- `query: Map<String, String>`
- `headers: HttpHeaders`
- `body: Buffer`
`RequestContext` exposes routing context and response controls:
- `request: ServerRequest`
- `routeMatch: RegexMatch?`
- `routeParams: Map<String, String>`
- `jsonBody<T>()`
- `respond(...)`
- `respondText(...)`
- `respondJson(body, status = 200)`
- `respondHtml(code: 200) { ... }`
- `setHeader(...)`
- `addHeader(...)`
- `acceptWebSocket(...)`
For exact routes, `routeMatch` is `null` and `routeParams` is empty.
For regex routes, `routeMatch` is set and `routeParams` is empty.
For path-template routes, both `routeMatch` and `routeParams` are set.
## Reusable Routers
`Router` collects the same route kinds as `HttpServer`, but does not listen on sockets by itself.
Mount it into `HttpServer` or another `Router`.
```lyng
import lyng.io.http.server
val api = Router()
api.get("/health") {
respondText(200, "ok")
}
val users = Router()
users.getPath("/users/{id}") {
respondJson({ id: routeParams["id"] })
}
api.mount(users)
val server = HttpServer()
server.mount(api)
server.listen(8080, "127.0.0.1")
```
Mounted routers reuse the built-in server router. They are configuration-time composition, not an extra per-request Lyng dispatch layer.
## WebSocket Routes
You can route websocket upgrades by exact path, regex, or path template.
```lyng
server.ws("/chat") { ws ->
ws.sendText("hello")
ws.close()
}
server.wsPath("/ws/{room}") { ws ->
ws.sendText("room=" + routeParams["room"])
ws.close()
}
```
A websocket handler runs only for requests that actually ask for websocket upgrade. Ordinary HTTP requests to the same path are not treated as websocket sessions.
### Choosing Between `ws(...)` And `acceptWebSocket(...)`
Use `server.ws(...)` or `server.wsPath(...)` when the route is always a websocket endpoint.
Use `acceptWebSocket(...)` inside a normal HTTP handler when the same route may inspect the request first and then decide whether to upgrade.
```lyng
server.get("/maybe-upgrade") {
if (!request.isWebSocketUpgrade()) {
respondText(400, "websocket upgrade required")
return
}
acceptWebSocket { ws ->
ws.sendText("connected")
ws.close()
}
}
```
### Reading Incoming Messages
Inside a websocket handler, call `ws.receive()` to wait for the next application message.
What `receive()` returns:
- `WsMessage` for the next text or binary message.
- `null` after the client sends a close frame.
- `null` after the socket is already closed and no more frames can arrive.
What reaches Lyng code:
- Text frames become `WsMessage(isText = true, text = ...)`.
- Binary frames become `WsMessage(isText = false, data = ...)`.
- Fragmented websocket messages are reassembled before they are returned.
- Ping and pong control frames are handled internally and do not appear in Lyng.
- A client close frame is answered by the server close handshake, then `receive()` returns `null`.
Typical server receive loop:
```lyng
import lyng.buffer
server.ws("/echo") { ws ->
while (true) {
val msg = ws.receive() ?: break
if (msg.isText) {
ws.sendText("echo:" + msg.text)
} else {
ws.sendBytes(msg.data as Buffer)
}
}
}
```
### Sending Outgoing Messages
Use:
- `ws.sendText(text)` for text messages.
- `ws.sendBytes(data)` for binary messages.
Example:
```lyng
import lyng.buffer
server.ws("/push") { ws ->
ws.sendText("ready")
ws.sendBytes(Buffer(1, 2, 3))
ws.close()
}
```
Send behavior:
- Each call sends one websocket message.
- The server API does not expose frame-by-frame streaming.
- Once the session is closed, send calls fail with a websocket error.
### What Happens When The Connection Closes
There are three practical cases:
1. The client closes first.
The runtime replies with a close frame, releases the socket, and `receive()` returns `null`.
2. Your handler closes first with `ws.close(...)`.
The runtime sends a close frame and releases the socket locally.
3. The transport disappears unexpectedly.
The session is released and no more messages can be received; subsequent sends fail.
What Lyng code should do:
- Treat `receive() == null` as end-of-session.
- Exit the handler or break the receive loop at that point.
- Do not keep sending after close has been observed.
The current server-side API does not expose the peer close code or close reason to Lyng.
### Closing The Connection Yourself
Call `ws.close()` when you want to terminate the websocket session.
```lyng
server.ws("/chat") { ws ->
ws.sendText("server shutting down")
ws.close(1000, "done")
}
```
Close semantics:
- `close()` sends a websocket close frame with the given code and reason.
- Defaults are `code = 1000` and `reason = ""`.
- `close()` is idempotent; calling it again after close does nothing.
- After local close, the session should be treated as unusable.
- After close, `isOpen()` becomes false and further sends fail.
### WebSocket Handler Pattern
```lyng
import lyng.io.http.server
val server = HttpServer()
server.wsPath("/rooms/{room}") { ws ->
val room = routeParams["room"] ?: "<unknown>"
ws.sendText("joined:" + room)
while (true) {
val msg = ws.receive() ?: break
if (msg.isText) {
ws.sendText(room + ":" + msg.text)
}
}
ws.close()
}
server.listen(8080, "127.0.0.1")
```
## Path-Template Routes
Path templates are sugar on top of regex routes. Template parameters are exposed as decoded `routeParams`.
```lyng
server.getPath("/users/{userId}/posts/{postId}") {
respondText(
200,
routeParams["userId"] + ":" + routeParams["postId"]
)
}
```
Template rules:
- template must start with `/`
- a segment is either literal text or `{name}`
- parameter names must be valid identifiers
- parameter values match one path segment only
- parameter values use path decoding rules:
- valid percent-encoding is decoded
- `+` stays `+`
- malformed `%` stays literal
## Regex Routes
Regex routes match the whole request path, not a substring.
```lyng
server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) {
val m = routeMatch!!
respondText(200, "user=" + m[1] + ", post=" + m[2])
}
```
## Basic Exact Route
```lyng
import lyng.io.http.server
val server = HttpServer()
server.get("/hello") {
setHeader("Content-Type", "text/plain")
respondText(200, "hello")
}
server.listen(8080, "127.0.0.1")
```
## Route Precedence
Dispatch order is:
1. exact method route
2. exact `any` route
3. regex method route, registration order
4. regex `any` route, registration order
5. fallback
This means exact routes stay fast and always win over template or regex routes for the same path.
## API Surface
### `Router` Route Registration Methods
- `get(path: String|Regex, handler)`
- `getPath(pathTemplate: String, handler)`
- `post(path: String|Regex, handler)`
- `postPath(pathTemplate: String, handler)`
- `put(path: String|Regex, handler)`
- `putPath(pathTemplate: String, handler)`
- `delete(path: String|Regex, handler)`
- `deletePath(pathTemplate: String, handler)`
- `any(path: String|Regex, handler)`
- `anyPath(pathTemplate: String, handler)`
- `ws(path: String|Regex, handler)`
- `wsPath(pathTemplate: String, handler)`
- `fallback(handler)`
- `mount(router)`
### `HttpServer` Route Registration Methods
- `get(path: String|Regex, handler)`
- `getPath(pathTemplate: String, handler)`
- `post(path: String|Regex, handler)`
- `postPath(pathTemplate: String, handler)`
- `put(path: String|Regex, handler)`
- `putPath(pathTemplate: String, handler)`
- `delete(path: String|Regex, handler)`
- `deletePath(pathTemplate: String, handler)`
- `any(path: String|Regex, handler)`
- `anyPath(pathTemplate: String, handler)`
- `ws(path: String|Regex, handler)`
- `wsPath(pathTemplate: String, handler)`
- `fallback(handler)`
- `mount(router)`
- `listen(port, host = null, backlog = 128)`

View File

@ -4,6 +4,8 @@ This module provides minimal raw transport networking for Lyng scripts. It is im
> **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.
>
> **Shared type note:** `IpVersion`, `SocketAddress`, and `Datagram` are also available from `lyng.io.net.types` when host code wants reusable transport value types without depending on the `Net` capability object itself.
>
> **Important native platform limit:** current native TCP/UDP support is backed by a selector with a per-process file descriptor ceiling. On Linux/macOS native targets this makes high-connection-count servers and same-process load tests unsuitable once the process approaches that limit.
>
> **Recommendation:** for serious HTTP/TCP servers, prefer the JVM target today. On native targets, keep concurrency bounded, batch local load tests in waves, and use multiple worker processes behind a reverse proxy if you need more throughput before the backend is reworked.

View File

@ -1,14 +1,14 @@
### lyng.io.ws — WebSocket client for Lyng scripts
# `lyng.io.ws` - WebSocket client for Lyng scripts
This module provides a compact WebSocket client API for Lyng scripts. It is implemented in `lyngio` and currently backed by Ktor WebSockets on the JVM.
> **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.
>
> **Shared type note:** `WsMessage` is also available from `lyng.io.ws.types` when host code wants the reusable message type without depending on the WebSocket client module itself.
---
## Install The Module Into A Lyng Session
#### Install the module into a Lyng session
Kotlin (host) bootstrap example:
Kotlin host bootstrap example:
```kotlin
import net.sergeych.lyng.EvalSession
@ -24,59 +24,189 @@ suspend fun bootstrapWs() {
}
```
---
## Using From Lyng Scripts
#### Using from Lyng scripts
### Text Exchange
Simple text message exchange:
```lyng
import lyng.io.ws
import lyng.io.ws
val ws = Ws.connect(WS_TEST_URL)
ws.sendText("ping")
val m: WsMessage = ws.receive()
ws.close()
[ws.url() == WS_TEST_URL, m.isText, m.text]
>>> [true,true,echo:ping]
```
val ws = Ws.connect(WS_TEST_URL)
### Binary Exchange
```lyng
import lyng.buffer
import lyng.io.ws
val ws = Ws.connect(WS_TEST_BINARY_URL)
ws.sendBytes(Buffer(9, 8, 7))
val m: WsMessage = ws.receive()
ws.close()
[m.isText, (m.data as Buffer).hex]
>>> [false,010203090807]
```
### Secure `wss` Exchange
```lyng
import lyng.io.ws
val ws = Ws.connect(WSS_TEST_URL)
ws.sendText("ping")
val m: WsMessage = ws.receive()
ws.close()
[ws.url() == WSS_TEST_URL, m.text]
>>> [true,secure:ping]
```
## Message Flow And Session Lifecycle
### Reading Incoming Messages
Call `ws.receive()` to wait for the next application message.
What `receive()` returns:
- `WsMessage` for the next text or binary message.
- `null` after the peer closes the connection cleanly.
- `null` after the transport has already been closed and no more messages can arrive.
What reaches Lyng code:
- Text frames are exposed as `WsMessage(isText = true, text = ...)`.
- Binary frames are exposed as `WsMessage(isText = false, data = ...)`.
- Fragmented websocket messages are reassembled before they are returned.
- Ping and pong control frames are handled internally and are not returned by `receive()`.
- Incoming close frames are handled internally; after that `receive()` returns `null`.
Typical receive loop:
```lyng
import lyng.buffer
import lyng.io.ws
val ws = Ws.connect(WS_URL)
while (true) {
val msg = ws.receive() ?: break
if (msg.isText) {
println("text=" + msg.text)
} else {
println("bytes=" + ((msg.data as Buffer).size))
}
}
println("peer closed the websocket")
```
### Sending Outgoing Messages
Use:
- `ws.sendText(text)` for UTF-8 text messages.
- `ws.sendBytes(data)` for binary messages.
Example:
```lyng
import lyng.buffer
import lyng.io.ws
val ws = Ws.connect(WS_URL)
ws.sendText("hello")
ws.sendBytes(Buffer(1, 2, 3, 4))
```
Send behavior:
- Each call sends one websocket message.
- The API does not expose partial-frame streaming; send the whole message in one call.
- If the session is already closed, `sendText(...)` and `sendBytes(...)` fail with a websocket error.
- If the transport breaks during send, the session is released and the send call fails.
### Detecting Closed Connections
Use both signals together:
- `ws.isOpen()` tells you whether the session is still considered open right now.
- `ws.receive() == null` tells you the receive side has reached the end of the websocket session.
Practical rule:
- If `receive()` returns `null`, stop reading and treat the session as closed.
- After close has been observed, do not attempt further sends.
The API does not currently expose the peer close code or close reason to Lyng code.
### Closing The Connection Yourself
Call `ws.close()` when you are done.
```lyng
import lyng.io.ws
val ws = Ws.connect(WS_URL)
ws.sendText("bye")
ws.close(1000, "done")
```
Close semantics:
- `close()` sends a websocket close frame with the given code and reason.
- Defaults are `code = 1000` and `reason = ""`.
- After `close()`, the session is released locally and should be treated as closed immediately.
- Calling `close()` on an already closed session is a no-op.
- After local close, `receive()` returns `null` and further sends fail.
### Recommended Usage Pattern
For request-response style exchanges:
```lyng
import lyng.io.ws
val ws = Ws.connect(WS_URL)
try {
ws.sendText("ping")
val m: WsMessage = ws.receive()
val reply = ws.receive() ?: error("socket closed before reply")
println(reply.text)
} finally {
ws.close()
[ws.url() == WS_TEST_URL, m.isText, m.text]
>>> [true,true,echo:ping]
}
```
Binary message exchange:
For long-lived consumers:
import lyng.buffer
import lyng.io.ws
```lyng
import lyng.io.ws
val ws = Ws.connect(WS_TEST_BINARY_URL)
ws.sendBytes(Buffer(9, 8, 7))
val m: WsMessage = ws.receive()
val ws = Ws.connect(WS_URL)
try {
while (true) {
val msg = ws.receive() ?: break
if (msg.isText) {
println(msg.text)
}
}
} finally {
ws.close()
[m.isText, (m.data as Buffer).hex]
>>> [false,010203090807]
}
```
Secure websocket (`wss`) exchange:
## API Reference
import lyng.io.ws
### `Ws`
val ws = Ws.connect(WSS_TEST_URL)
ws.sendText("ping")
val m: WsMessage = ws.receive()
ws.close()
[ws.url() == WSS_TEST_URL, m.text]
>>> [true,secure:ping]
---
#### API reference
##### `Ws` (static methods)
- `isSupported(): Bool` — Whether WebSocket client support is available on the current runtime.
- `connect(url: String, headers...): WsSession` — Open a client websocket session.
- `isSupported(): Bool` - whether WebSocket client support is available on the current runtime.
- `connect(url: String, headers...): WsSession` - open a client websocket session.
`headers...` accepts:
- `MapEntry`, for example `"Authorization" => "Bearer x"`
- 2-item lists, for example `["Authorization", "Bearer x"]`
- `MapEntry`, e.g. `"Authorization" => "Bearer x"`
- 2-item lists, e.g. `["Authorization", "Bearer x"]`
##### `WsSession`
### `WsSession`
- `isOpen(): Bool`
- `url(): String`
@ -85,24 +215,27 @@ Secure websocket (`wss`) exchange:
- `receive(): WsMessage?`
- `close(code: Int = 1000, reason: String = ""): void`
`receive()` returns `null` after a clean close.
Behavior summary:
- `receive()` returns `null` after close.
- `close()` is safe to call more than once.
- send operations require an open session.
##### `WsMessage`
### `WsMessage`
- `isText: Bool`
- `text: String?`
- `data: Buffer?`
Text messages populate `text`; binary messages populate `data`.
Payload rules:
- Text messages populate `text` and leave `data == null`.
- Binary messages populate `data` and leave `text == null`.
---
#### Security policy
## Security Policy
The module uses `WsAccessPolicy` to authorize websocket operations.
- `WsAccessPolicy` — interface for custom policies
- `PermitAllWsAccessPolicy` — allows all websocket operations
- `WsAccessPolicy` - interface for custom policies.
- `PermitAllWsAccessPolicy` - allows all websocket operations.
- `WsAccessOp.Connect(url)`
- `WsAccessOp.Send(url, bytes, isText)`
- `WsAccessOp.Receive(url)`
@ -135,14 +268,12 @@ val allowLocalOnly = object : WsAccessPolicy {
}
```
---
## Platform Support
#### Platform support
- **JVM:** supported
- **Android:** supported via the Ktor CIO websocket client backend
- **JS:** supported via the Ktor JS websocket client backend
- **Linux native:** supported via the Ktor Curl websocket client backend
- **Windows native:** supported via the Ktor WinHttp websocket client backend
- **Apple native:** supported via the Ktor Darwin websocket client backend
- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access
- **JVM:** supported.
- **Android:** supported via the Ktor CIO websocket client backend.
- **JS:** supported via the Ktor JS websocket client backend.
- **Linux native:** supported via the Ktor Curl websocket client backend.
- **Windows native:** supported via the Ktor WinHttp websocket client backend.
- **Apple native:** supported via the Ktor Darwin websocket client backend.
- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access.

View File

@ -17,8 +17,10 @@
- **[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.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
- **[lyng.io.http.server](lyng.io.http.server.md):** Minimal HTTP/1.1 and WebSocket server. Provides `HttpServer`, `Router`, `ServerRequest`, `RequestContext`, and `ServerWebSocket`.
- **[lyng.io.ws](lyng.io.ws.md):** WebSocket client access. Provides `Ws`, `WsSession`, and `WsMessage`.
- **[lyng.io.net](lyng.io.net.md):** Transport networking. Provides `Net`, `TcpSocket`, `TcpServer`, `UdpSocket`, and `SocketAddress`.
- **Shared networking type packages:** `lyng.io.http.types`, `lyng.io.ws.types`, and `lyng.io.net.types` expose reusable value types such as `HttpHeaders`, `WsMessage`, `IpVersion`, `SocketAddress`, and `Datagram` when host code wants type-only imports without installing the corresponding capability object module.
---
@ -119,6 +121,7 @@ For more details, see the specific module documentation:
- [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md)
- [HTTP Module Details](lyng.io.http.md)
- [HTTP Server Module Details](lyng.io.http.server.md)
- [Transport Networking Details](lyng.io.net.md)
- [WebSocket Module Details](lyng.io.ws.md)

View File

@ -4,7 +4,7 @@ Saved on April 4, 2026 before the `List<Int>` indexed-access follow-up fix.
Benchmark target:
- [examples/pi-bench.py](/home/sergeych/dev/lyng/examples/pi-bench.py)
- [examples/pi-bench.lyng](/home/sergeych/dev/lyng/examples/pi-bench.lyng)
- [examples/pi-bench.lyng](../examples/pi-bench.lyng)
Execution path:
- Python: `python3 examples/pi-bench.py`

View File

@ -0,0 +1,50 @@
class Tag(name: String) {
val name = name
var inner = ""
fun child(tagName: String, block: Tag.()->void) {
val child = Tag(tagName)
with(child) { block(this) }
inner += child.render()
}
fun head(block: Tag.()->void) { child("head", block) }
fun body(block: Tag.()->void) { child("body", block) }
fun title(block: Tag.()->void) { child("title", block) }
fun h1(block: Tag.()->void) { child("h1", block) }
fun addText(text: String) {
inner += text
}
fun render() {
"<" + name + ">" + inner + "</" + name + ">"
}
}
context(Tag)
fun String.unaryPlus() {
this@Tag.addText(this)
}
fun html(block: Tag.()->void) {
val root = Tag("html")
with(root) { block(this) }
root.render()
}
val page = html {
head {
title {
+"Demo"
}
}
body {
h1 {
+"Heading 1"
}
}
}
println(page)
assertEquals("<html><head><title>Demo</title></head><body><h1>Heading 1</h1></body></html>", page)

View File

@ -1,6 +1,9 @@
// Sample: Operator Overloading in Lyng
class Vector<T>(val x: T, val y: T) {
// Overload unary +
fun unaryPlus() = this
// Overload +
fun plus(other: Vector<U>) = Vector(x + other.x, y + other.y)
@ -28,6 +31,11 @@ val v2 = Vector(5, 5)
println("v1: " + v1)
println("v2: " + v2)
// Test unary +
val v0 = +v1
println("+v1 = " + v0)
assertEquals(Vector(10, 20), v0)
// Test binary +
val v3 = v1 + v2
println("v1 + v2 = " + v3)

View File

@ -1,6 +1,40 @@
# Lyng serialization
Lyng has builting binary bit-effective serialization format, called Lynon for LYng Object Notation. It is typed, binary, implements caching, automatic compression, variable-length ints, one-bit Booleans an many nice features.
Lyng has a built-in serialization module, `lyng.serialization`.
There are now two built-in formats with different goals:
- `Lynon`: the canonical binary format for Lyng values.
- `Json`: the canonical JSON-based round-trip format for Lyng values.
In addition, `Obj.toJson()` / `toJsonString()` remain available as a plain JSON projection for interoperability with
regular JSON tools and Kotlin `kotlinx.serialization`.
## Canonical formats
`Lynon` and `Json` are both exposed as format objects with the same surface:
- `Format.encode(value)`
- `Format.decode(encodedValue)`
For the built-in formats:
- `Lynon.encode(x)` returns `BitBuffer`
- `Lynon.decode(bitBuffer)` returns the original Lyng value
- `Json.encode(x)` returns `String`
- `Json.decode(jsonString)` returns the original Lyng value
`Json` also provides a typed canonical mode:
- `Json.encodeAs(Type, value)` returns `String`
- `Json.decodeAs(Type, jsonString)` returns the original Lyng value of the specified type
This is still canonical JSON, but it is schema-driven instead of fully self-describing.
## Lynon
Lynon is LYng Object Notation. It is typed, binary, bit-effective, implements caching, automatic compression,
variable-length integers, one-bit booleans, and preserves Lyng runtime structure well.
It is as simple as:
@ -20,7 +54,8 @@ It is as simple as:
assert( text.length > (encodedBits.toBuffer() as Buffer).size )
>>> void
Any class you create is serializable by default; lynon serializes first constructor fields, then any `var` member fields.
Any class you create is serializable by default; Lynon serializes first constructor fields, then any `var` member
fields.
## Transient Fields
@ -40,7 +75,7 @@ class MyData(@Transient val tempSecret, val publicData) {
Transient fields:
- Are **omitted** from Lynon binary streams.
- Are **omitted** from JSON output (via `toJson`).
- Are **omitted** from JSON output (`toJson`) and canonical `Json.encode(...)`.
- Are **ignored** during structural equality checks (`==`).
- If a transient constructor parameter has a **default value**, it will be restored to that default value during deserialization. Otherwise, it will be `null`.
- Class body fields marked as `@Transient` will keep their initial values (or values assigned in `init`) after deserialization.
@ -49,8 +84,131 @@ Transient fields:
- **Singleton Objects**: `object` declarations are serializable by name. Their state (mutable fields) is also serialized and restored, respecting `@Transient`.
- **Classes**: Class objects themselves can be serialized. They are serialized by their full qualified name. When converted to JSON, a class object includes its public static fields (excluding those marked `@Transient`).
- **Exceptions**: canonical formats preserve exception class, message, extra data, and captured stack trace.
## Custom Serialization
## Plain JSON projection vs canonical Json format
There are two JSON-related APIs and they serve different purposes:
- `Obj.toJson()` / `toJsonString()`
- produce ordinary JSON values
- best for interop with external JSON systems
- best for `Obj.decodeSerializable()` / `decodeSerializableWith()`
- may be lossy for Lyng-specific structures
- `Json.encode()` / `Json.decode()`
- produce JSON text too
- use Lyng-specific type tags so the payload is self-describing
- intended for round-tripping Lyng values
- intended to match Lynon semantics where JSON can carry them
- still keep ordinary string-key maps in traditional JSON object form
- can preserve values that plain JSON cannot represent directly, such as:
- maps with non-string keys
- sets
- buffers and bit buffers
- class instances
- singleton objects
- enums
- exceptions
- date/time objects
- non-finite reals
- `void`
- `Json.encodeAs(Type, value)` / `Json.decodeAs(Type, text)`
- also round-trip Lyng values through JSON text
- use the declared or requested type as decoding schema
- recursively omit type tags when the declared type is already exact enough
- keep canonical tags when the runtime value is more specific than the declared type
- produce less noisy JSON for closed and otherwise precisely-typed object graphs
- still keep ordinary `Map<String, ...>` values in traditional JSON object form
Why this split exists:
- plain `toJson()` must remain ordinary JSON so it stays convenient for external JSON systems and Kotlin
`kotlinx.serialization`
- canonical `Json.encode()` is for Lyng-to-Lyng transport through JSON text without any external schema, so it must
remain self-describing and preserve Lyng runtime
distinctions whenever possible
- `Json.encodeAs()` exists for the cases where a schema is known on both sides and we want canonical round-trip
behavior with fewer tags
- one API cannot optimize for both goals at once: either you get too many Lyng tags for ordinary JSON interop, or you
get lossy round-trips
Example:
import lyng.serialization
import lyng.time
enum Color { Red, Green }
class Point(x,y) { var z = 42 }
val p = Point(1,2)
p.z = 99
val x = List(
p,
Map([1, "one"], ["two", 2]),
Set(1,2,3),
"hello".encodeUtf8(),
Date(2026,4,15),
Color.Green
)
assertEquals(x, Json.decode(Json.encode(x)))
>>> void
Typed canonical example:
import lyng.serialization
closed class Point(x: Int, y: Int)
closed class Segment(a: Point, b: Point)
val value = Segment(Point(0,1), Point(2,3))
val json = Json.encodeAs(Segment, value)
assertEquals("{\"a\":{\"x\":0,\"y\":1},\"b\":{\"x\":2,\"y\":3}}", json)
assertEquals(value, Json.decodeAs(Segment, json))
>>> void
## Adding more formats from Kotlin modules
External modules can add new formats on the Kotlin side.
The common base class is:
```kotlin
abstract class ObjSerializationFormatClass(className: String) : ObjClass(className) {
abstract suspend fun encodeValue(scope: Scope, value: Obj): Obj
abstract suspend fun decodeValue(scope: Scope, encoded: Obj): Obj
}
```
To export a new format from a module:
```kotlin
im.addPackage("test.formats") { module ->
module.bindSerializationFormat(
object : ObjSerializationFormatClass("Reverse") {
override suspend fun encodeValue(scope: Scope, value: Obj): Obj =
ObjString(value.toString(scope).value.reversed())
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj =
ObjString((encoded as ObjString).value.reversed())
}
)
}
```
Then from Lyng, after importing the Kotlin module above, usage looks like:
```lyng
import test.formats
assertEquals("cba", Reverse.encode("abc"))
assertEquals("abc", Reverse.decode("cba"))
```
## Notes
Important is to understand that normally `Lynon.decode` wants [BitBuffer], as `Lynon.encode` produces. If you have the regular [Buffer], be sure to convert it:
@ -59,5 +217,3 @@ Important is to understand that normally `Lynon.decode` wants [BitBuffer], as `L
this possibly creates extra zero bits at the end, as bit content could be shorter than byte-grained but for the Lynon format it does not make sense. Note that when you serialize [BitBuffer], exact number of bits is written. To convert bit buffer to bytes:
Lynon.encode("hello").toBuffer()
(topic is incomplete and under construction)

View File

@ -352,6 +352,40 @@ Sets `this` to the first argument and executes the block. Returns the value retu
assertEquals(3, sum)
>>> void
Receiver lambdas can also keep outer receivers in scope. The primary receiver wins for unqualified lookup, and `this@Type`
selects an outer receiver explicitly:
class Html { fun lang() = "en" }
class Body { fun lang() = "body" }
fun html(block: Html.()->String) = with(Html()) { block(this) }
fun body(block: Body.()->String) = with(Body()) { block(this) }
val result = html {
body {
lang() + ":" + this@Html.lang()
}
}
assertEquals("body:en", result)
>>> void
You can declare the same requirement in a function type:
val block: context(Html) Body.()->String = {
lang() + ":" + this@Html.lang()
}
If the primary receiver does not define a member and multiple outer/context receivers do, Lyng reports an ambiguity instead of picking one silently:
class A { fun title() = "a" }
class B { fun title() = "b" }
class C
val block: context(A, B) C.()->String = {
// title() // compile-time ambiguity
this@A.title()
}
## run
Executes a block after it returning the value passed by the block. for example, can be used with elvis operator:

View File

@ -327,7 +327,7 @@ Singleton objects are declared using the `object` keyword. They provide a conven
```lyng
object Config {
val version = "1.5.5"
val version = "1.5.6-SNAPSHOT"
fun show() = println("Config version: " + version)
}

View File

19
examples/http_server.lyng Normal file
View File

@ -0,0 +1,19 @@
import lyng.io.http.server
closed class CreateUserRequest(name: String, age: Int)
closed class CreateUserResponse(id: Int, name: String, age: Int)
val server = HttpServer()
server.postPath("/api/users") {
val req = jsonBody<CreateUserRequest>()
if (req.name.isBlank()) {
respondJson({ error: "name must not be empty" }, 400)
return
}
respondJson(CreateUserResponse(101, req.name, req.age), 201)
}
server.listen(8080, "127.0.0.1")

View File

@ -0,0 +1,56 @@
import lyng.io.db.sqlite
println("SQLite serialization demo: write-side projection and decodeAs<T>()")
class Payload(name: String, count: Int)
class Item(
id: Int,
title: String,
@DbJson meta: Payload,
@DbLynon state: Payload
) {
var note: String = ""
@DbExcept var cache: String = ""
}
val restored = openSqlite(":memory:").transaction { tx ->
tx.execute(
"create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)"
)
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
item.note = "created"
item.cache = "not stored"
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
item.title = "second"
item.meta = Payload("json2", 11)
item.state = Payload("bin2", 21)
item.note = "updated"
tx.execute(
"update item set @set(?1 except: \"id\") where id = ?2",
item,
item.id
)
val restored = tx.select("select * from item where id = ?", 1).decodeAs<Item>().first
assertEquals("second", restored.title)
assertEquals("json2", restored.meta.name)
assertEquals(11, restored.meta.count)
assertEquals("bin2", restored.state.name)
assertEquals(21, restored.state.count)
assertEquals("updated", restored.note)
restored
}
println("Restored item:")
println(" id=" + restored.id)
println(" title=" + restored.title)
println(" meta=" + restored.meta.name + "/" + restored.meta.count)
println(" state=" + restored.state.name + "/" + restored.state.count)
println(" note=" + restored.note)
println("OK")

View File

@ -17,8 +17,8 @@
package net.sergeych
import com.github.ajalt.clikt.core.CoreCliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.CoreCliktCommand
import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
@ -32,6 +32,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.LyngVersion
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
@ -44,16 +45,18 @@ import net.sergeych.lyng.io.db.createDbModule
import net.sergeych.lyng.io.db.jdbc.createJdbcModule
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.html.createHtmlModule
import net.sergeych.lyng.io.http.createHttpModule
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyng.io.net.createNetModule
import net.sergeych.lyng.io.ws.createWsModule
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyngio.net.shutdownSystemNetEngine
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import net.sergeych.lyngio.net.shutdownSystemNetEngine
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
import net.sergeych.mp_tools.globalDefer
import okio.*
@ -144,7 +147,9 @@ private fun ImportManager.invalidateCliModuleCaches() {
invalidatePackageCache("lyng.io.console")
invalidatePackageCache("lyng.io.db.jdbc")
invalidatePackageCache("lyng.io.db.sqlite")
invalidatePackageCache("lyng.io.html")
invalidatePackageCache("lyng.io.http")
invalidatePackageCache("lyng.io.http.server")
invalidatePackageCache("lyng.io.ws")
invalidatePackageCache("lyng.io.net")
}
@ -153,8 +158,8 @@ val baseScopeDefer = globalDefer {
baseCliImportManagerDefer.await().copy().apply {
invalidateCliModuleCaches()
}.newStdScope().apply {
installCliDeclarations()
installCliBuiltins()
installCliDeclarations()
addConst("ARGV", ObjList(mutableListOf()))
}
}
@ -234,7 +239,9 @@ private fun installCliModules(manager: ImportManager) {
createDbModule(manager)
createJdbcModule(manager)
createSqliteModule(manager)
createHtmlModule(manager)
createHttpModule(PermitAllHttpAccessPolicy, manager)
createHttpServerModule(PermitAllNetAccessPolicy, manager)
createWsModule(PermitAllWsAccessPolicy, manager)
createNetModule(PermitAllNetAccessPolicy, manager)
}
@ -364,8 +371,8 @@ private fun registerLocalCliModules(manager: ImportManager, modules: List<LocalC
private suspend fun ImportManager.newCliScope(argv: List<String>): Scope =
newStdScope().apply {
installCliDeclarations()
installCliBuiltins()
installCliDeclarations()
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
}
@ -547,6 +554,15 @@ suspend fun executeSource(source: Source, initialScope: Scope? = null) {
evalOnCliDispatcher(session, source)
} catch (e: CliExitRequested) {
requestedExitCode = e.code
} catch (e: ExecutionError) {
val cliExit = generateSequence<Throwable>(e) { it.cause }
.filterIsInstance<CliExitRequested>()
.firstOrNull()
if (cliExit != null) {
requestedExitCode = cliExit.code
} else {
throw e
}
}
} finally {
shutdownHooks.uninstall()

View File

@ -73,10 +73,12 @@ class CliNetworkJvmTest {
try {
val script = """
import lyng.io.http
import lyng.io.http.server
import lyng.io.ws
import lyng.io.net
assert(Http.isSupported())
assert(HttpServer() is HttpServer)
println("ws=" + Ws.isSupported())
println("net=" + Net.isSupported())

View File

@ -0,0 +1,44 @@
/*
* 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.io.html
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Source
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyngio.stdlib_included.htmlLyng
private const val HTML_MODULE_NAME = "lyng.io.html"
fun createHtmlModule(scope: Scope): Boolean = createHtmlModule(scope.importManager)
fun createHtml(scope: Scope): Boolean = createHtmlModule(scope)
fun createHtmlModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(HTML_MODULE_NAME)) return false
manager.addPackage(HTML_MODULE_NAME) { module ->
buildHtmlModule(module)
}
return true
}
fun createHtml(manager: ImportManager): Boolean = createHtmlModule(manager)
private suspend fun buildHtmlModule(module: ModuleScope) {
module.eval(Source(HTML_MODULE_NAME, htmlLyng))
}

View File

@ -46,8 +46,10 @@ import net.sergeych.lyngio.http.security.HttpAccessDeniedException
import net.sergeych.lyngio.http.security.HttpAccessOp
import net.sergeych.lyngio.http.security.HttpAccessPolicy
import net.sergeych.lyngio.stdlib_included.httpLyng
import net.sergeych.lyngio.stdlib_included.http_typesLyng
private const val HTTP_MODULE_NAME = "lyng.io.http"
internal const val HTTP_TYPES_MODULE_NAME = "lyng.io.http.types"
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
createHttpModule(policy, scope.importManager)
@ -55,6 +57,7 @@ fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
createHttpTypesModule(manager)
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
manager.addPackage(HTTP_MODULE_NAME) { module ->
buildHttpModule(module, policy)
@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
internal fun createHttpTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(HTTP_TYPES_MODULE_NAME)) return false
manager.addPackage(HTTP_TYPES_MODULE_NAME) { module ->
buildHttpTypesModule(module)
}
return true
}
private suspend fun buildHttpTypesModule(module: ModuleScope) {
module.eval(Source(HTTP_TYPES_MODULE_NAME, http_typesLyng))
module.addConst("HttpHeaders", ObjHttpHeaders.type)
}
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
val engine = getSystemHttpEngine()
@ -139,7 +155,7 @@ private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -
}
}
private class ObjHttpHeaders(
internal class ObjHttpHeaders(
singleValueHeaders: Map<String, String> = emptyMap(),
private val allHeaders: Map<String, List<String>> = emptyMap(),
) : Obj() {
@ -201,6 +217,11 @@ private class ObjHttpHeaders(
).invokeInstanceMethod(requireScope(), "iterator")
}
}
internal fun fromHeaders(
singleValueHeaders: Map<String, String>,
allHeaders: Map<String, List<String>>,
): ObjHttpHeaders = ObjHttpHeaders(singleValueHeaders, allHeaders)
}
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()

View File

@ -47,8 +47,10 @@ import net.sergeych.lyngio.net.security.NetAccessDeniedException
import net.sergeych.lyngio.net.security.NetAccessOp
import net.sergeych.lyngio.net.security.NetAccessPolicy
import net.sergeych.lyngio.stdlib_included.netLyng
import net.sergeych.lyngio.stdlib_included.net_typesLyng
private const val NET_MODULE_NAME = "lyng.io.net"
internal const val NET_TYPES_MODULE_NAME = "lyng.io.net.types"
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
createNetModule(policy, scope.importManager)
@ -56,6 +58,7 @@ fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
createNetTypesModule(manager)
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
manager.addPackage(NET_MODULE_NAME) { module ->
buildNetModule(module, policy)
@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
internal fun createNetTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(NET_TYPES_MODULE_NAME)) return false
manager.addPackage(NET_TYPES_MODULE_NAME) { module ->
buildNetTypesModule(module)
}
return true
}
private suspend fun buildNetTypesModule(module: ModuleScope) {
module.eval(Source(NET_TYPES_MODULE_NAME, net_typesLyng))
val enumValues = NetEnumValues.load(module)
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
module.addConst("Datagram", ObjDatagram.type(enumValues))
}
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(NET_MODULE_NAME, netLyng))
val engine = getSystemNetEngine()
@ -164,10 +182,12 @@ private class ObjSocketAddress(
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
private val types = mutableMapOf<EnumKey, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
object : ObjClass("SocketAddress") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("SocketAddress cannot be created directly")
@ -191,10 +211,12 @@ private class ObjDatagram(
get() = type(enumValues)
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
private data class EnumKey(val ipv4: Obj, val ipv6: Obj)
private val types = mutableMapOf<EnumKey, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
object : ObjClass("Datagram") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("Datagram cannot be created directly")

View File

@ -37,6 +37,7 @@ import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.stdlib_included.wsLyng
import net.sergeych.lyngio.stdlib_included.ws_typesLyng
import net.sergeych.lyngio.ws.LyngWsEngine
import net.sergeych.lyngio.ws.LyngWsMessage
import net.sergeych.lyngio.ws.LyngWsSession
@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp
import net.sergeych.lyngio.ws.security.WsAccessPolicy
private const val WS_MODULE_NAME = "lyng.io.ws"
internal const val WS_TYPES_MODULE_NAME = "lyng.io.ws.types"
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
createWsModule(policy, scope.importManager)
@ -53,6 +55,7 @@ fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
createWsTypesModule(manager)
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
manager.addPackage(WS_MODULE_NAME) { module ->
buildWsModule(module, policy)
@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
internal fun createWsTypesModule(manager: ImportManager): Boolean {
if (manager.packageNames.contains(WS_TYPES_MODULE_NAME)) return false
manager.addPackage(WS_TYPES_MODULE_NAME) { module ->
buildWsTypesModule(module)
}
return true
}
private suspend fun buildWsTypesModule(module: ModuleScope) {
module.eval(Source(WS_TYPES_MODULE_NAME, ws_typesLyng))
module.addConst("WsMessage", ObjWsMessage.type)
}
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
module.eval(Source(WS_MODULE_NAME, wsLyng))
val engine = getSystemWsEngine()
@ -92,7 +108,7 @@ private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () ->
}
}
private class ObjWsMessage(
internal class ObjWsMessage(
private val message: LyngWsMessage,
) : Obj() {
override val objClass: ObjClass
@ -112,6 +128,8 @@ private class ObjWsMessage(
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
})
}
internal fun from(message: LyngWsMessage): ObjWsMessage = ObjWsMessage(message)
}
}
@ -152,7 +170,7 @@ private class ObjWsSession(
addFn("receive") {
val self = thisAs<ObjWsSession>()
self.policy.require(WsAccessOp.Receive(self.targetUrl))
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull
}
addFn("close") {
val self = thisAs<ObjWsSession>()

View File

@ -0,0 +1,50 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
internal class BufferedSocketReader(
private val socket: LyngTcpSocket,
) {
private var pending = ByteArray(0)
suspend fun readLine(
maxBytes: Int,
overflowStatus: Int,
overflowMessage: String,
): String? {
require(maxBytes > 0) { "maxBytes must be positive" }
val out = ByteArray(maxBytes)
var count = 0
while (true) {
val next = readByte() ?: return if (count == 0) null else out.copyOf(count).decodeToString()
if (next == '\n'.code.toByte()) {
return if (count > 0 && out[count - 1] == '\r'.code.toByte()) {
out.copyOf(count - 1).decodeToString()
} else {
out.copyOf(count).decodeToString()
}
}
if (count >= maxBytes) throw HttpProtocolException(overflowStatus, overflowMessage)
out[count++] = next
}
}
suspend fun readExact(byteCount: Int): ByteArray? {
require(byteCount >= 0) { "byteCount must be non-negative" }
if (byteCount == 0) return ByteArray(0)
while (pending.size < byteCount) {
val chunk = socket.read(maxOf(4096, byteCount - pending.size)) ?: break
if (chunk.isEmpty()) break
pending += chunk
}
if (pending.size < byteCount) return null
val result = pending.copyOfRange(0, byteCount)
pending = pending.copyOfRange(byteCount, pending.size)
return result
}
private suspend fun readByte(): Byte? {
val bytes = readExact(1) ?: return null
return bytes[0]
}
}

View File

@ -0,0 +1,181 @@
package net.sergeych.lyngio.http.server
internal class HttpProtocolException(
val status: Int,
message: String,
) : IllegalStateException(message)
internal suspend fun parseHttpRequest(
reader: BufferedSocketReader,
config: HttpServerConfig,
): HttpRequest? {
val requestLine = reader.readLine(
maxBytes = config.maxRequestLineBytes,
overflowStatus = 414,
overflowMessage = "request line is too long",
) ?: return null
val requestHead = parseRequestLine(requestLine, config)
val headerEntries = parseHeaders(reader, config)
val headers = HttpHeaders(headerEntries)
validateHost(headers)
val contentLength = parseContentLength(headers, config)
validateUnsupportedRequestFeatures(headers)
val wantsWebSocketUpgrade = isWebSocketUpgrade(requestHead.method, headers)
validateWebSocketUpgradeRequest(headers, requestHead.method, contentLength, wantsWebSocketUpgrade)
val body = if (contentLength != null) {
reader.readExact(contentLength)
?: throw HttpProtocolException(400, "unexpected EOF while reading request body")
} else {
ByteArray(0)
}
return HttpRequest(
head = HttpRequestHead(
method = requestHead.method,
target = requestHead.target,
path = requestHead.path,
queryString = requestHead.queryString,
version = requestHead.version,
headers = headers,
contentLength = contentLength,
wantsClose = headers.containsToken("Connection", "close"),
wantsWebSocketUpgrade = wantsWebSocketUpgrade,
),
body = body,
)
}
private data class ParsedRequestLine(
val method: String,
val target: String,
val path: String,
val queryString: String?,
val version: String,
)
private fun parseRequestLine(line: String, config: HttpServerConfig): ParsedRequestLine {
val firstSpace = line.indexOf(' ')
val lastSpace = line.lastIndexOf(' ')
if (firstSpace <= 0 || lastSpace <= firstSpace || lastSpace == line.lastIndex) {
throw HttpProtocolException(400, "malformed request line")
}
val method = line.substring(0, firstSpace)
val target = line.substring(firstSpace + 1, lastSpace)
val version = line.substring(lastSpace + 1)
if (!method.all(::isHttpTokenChar)) {
throw HttpProtocolException(400, "invalid HTTP method")
}
if (version != "HTTP/1.1") {
throw HttpProtocolException(505, "unsupported HTTP version: $version")
}
if (target.length > config.maxRequestLineBytes) {
throw HttpProtocolException(414, "request target is too long")
}
if (!target.startsWith('/')) {
throw HttpProtocolException(400, "only origin-form request targets are supported")
}
val queryAt = target.indexOf('?')
val path = if (queryAt >= 0) target.substring(0, queryAt) else target
val queryString = if (queryAt >= 0) target.substring(queryAt + 1) else null
return ParsedRequestLine(method = method, target = target, path = path, queryString = queryString, version = version)
}
private suspend fun parseHeaders(
reader: BufferedSocketReader,
config: HttpServerConfig,
): List<HttpHeader> {
val headers = ArrayList<HttpHeader>()
var totalBytes = 0
while (true) {
val line = reader.readLine(
maxBytes = config.maxHeaderBytes,
overflowStatus = 431,
overflowMessage = "request headers are too large",
)
?: throw HttpProtocolException(400, "unexpected EOF while reading headers")
totalBytes += line.length + 2
if (totalBytes > config.maxHeaderBytes) {
throw HttpProtocolException(431, "request headers are too large")
}
if (line.isEmpty()) return headers
if (line.firstOrNull() == ' ' || line.firstOrNull() == '\t') {
throw HttpProtocolException(400, "obsolete folded headers are not supported")
}
val colonAt = line.indexOf(':')
if (colonAt <= 0) throw HttpProtocolException(400, "invalid header syntax")
val name = line.substring(0, colonAt)
if (!name.all(::isHttpTokenChar)) {
throw HttpProtocolException(400, "invalid header name: $name")
}
val value = line.substring(colonAt + 1).trim(' ', '\t')
if (value.any { it == '\r' || it == '\n' || it.code < 0x20 && it != '\t' }) {
throw HttpProtocolException(400, "invalid header value")
}
headers += HttpHeader(name, value)
if (headers.size > config.maxHeaderCount) {
throw HttpProtocolException(431, "too many headers")
}
}
}
private fun validateHost(headers: HttpHeaders) {
val values = headers.all("Host").map(String::trim)
if (values.isEmpty()) throw HttpProtocolException(400, "Host header is required")
if (values.distinct().size > 1) throw HttpProtocolException(400, "conflicting Host header values")
}
private fun parseContentLength(headers: HttpHeaders, config: HttpServerConfig): Int? {
val values = headers.all("Content-Length")
if (values.isEmpty()) return null
val normalized = values.flatMap { raw -> raw.split(',').map(String::trim) }
if (normalized.any { it.isEmpty() }) throw HttpProtocolException(400, "invalid Content-Length")
val distinct = normalized.distinct()
if (distinct.size > 1) throw HttpProtocolException(400, "conflicting Content-Length values")
val parsed = distinct.single().toLongOrNull() ?: throw HttpProtocolException(400, "invalid Content-Length")
if (parsed < 0L || parsed > Int.MAX_VALUE.toLong()) throw HttpProtocolException(400, "invalid Content-Length")
if (parsed > config.maxBodyBytes.toLong()) throw HttpProtocolException(413, "request body is too large")
return parsed.toInt()
}
private fun validateUnsupportedRequestFeatures(headers: HttpHeaders) {
if (headers.all("Transfer-Encoding").isNotEmpty()) {
throw HttpProtocolException(501, "Transfer-Encoding is not supported")
}
if (headers.first("Expect")?.equals("100-continue", ignoreCase = true) == true) {
throw HttpProtocolException(501, "Expect: 100-continue is not supported")
}
val upgrade = headers.first("Upgrade")
if (upgrade != null && !upgrade.equals("websocket", ignoreCase = true)) {
throw HttpProtocolException(501, "unsupported Upgrade value")
}
}
private fun isWebSocketUpgrade(method: String, headers: HttpHeaders): Boolean =
method.equals("GET", ignoreCase = true) &&
headers.first("Upgrade")?.equals("websocket", ignoreCase = true) == true &&
headers.containsToken("Connection", "upgrade")
private fun validateWebSocketUpgradeRequest(
headers: HttpHeaders,
method: String,
contentLength: Int?,
wantsWebSocketUpgrade: Boolean,
) {
if (!wantsWebSocketUpgrade) return
if (!method.equals("GET", ignoreCase = true)) {
throw HttpProtocolException(400, "websocket upgrade requires GET")
}
if (contentLength != null && contentLength != 0) {
throw HttpProtocolException(400, "websocket upgrade request must not include a body")
}
if (headers.first("Sec-WebSocket-Key").isNullOrBlank()) {
throw HttpProtocolException(400, "missing Sec-WebSocket-Key")
}
if (headers.first("Sec-WebSocket-Version") != "13") {
throw HttpProtocolException(400, "unsupported Sec-WebSocket-Version")
}
}
private fun isHttpTokenChar(ch: Char): Boolean =
ch in '0'..'9' || ch in 'A'..'Z' || ch in 'a'..'z' || ch in setOf(
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'
)

View File

@ -0,0 +1,207 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.ws.LyngWsMessage
internal data class HttpServerConfig(
val host: String? = "127.0.0.1",
val port: Int = 0,
val backlog: Int = 128,
val reuseAddress: Boolean = true,
val maxRequestLineBytes: Int = 8 * 1024,
val maxHeaderBytes: Int = 32 * 1024,
val maxHeaderCount: Int = 100,
val maxBodyBytes: Int = 1024 * 1024,
val keepAliveTimeoutMillis: Long = 15_000,
)
internal data class HttpHeader(
val name: String,
val value: String,
)
internal class HttpHeaders(
private val headerEntries: List<HttpHeader>,
) {
fun first(name: String): String? =
headerEntries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.value
fun all(name: String): List<String> =
headerEntries.filter { it.name.equals(name, ignoreCase = true) }.map(HttpHeader::value)
fun containsToken(name: String, token: String): Boolean =
all(name).flatMap { value -> value.split(',') }
.any { it.trim().equals(token, ignoreCase = true) }
fun entries(): List<HttpHeader> = headerEntries
}
internal data class HttpRequestHead(
val method: String,
val target: String,
val path: String,
val queryString: String?,
val version: String,
val headers: HttpHeaders,
val contentLength: Int?,
val wantsClose: Boolean,
val wantsWebSocketUpgrade: Boolean,
) {
private var pathPartsParsed = false
private var pathPartsCache: List<String> = emptyList()
private var queryParsed = false
private var queryCache: Map<String, String> = emptyMap()
val pathParts: List<String>
get() {
if (!pathPartsParsed) {
pathPartsCache = parsePathParts(path)
pathPartsParsed = true
}
return pathPartsCache
}
val query: Map<String, String>
get() {
if (!queryParsed) {
queryCache = parseQueryParameters(queryString)
queryParsed = true
}
return queryCache
}
}
internal data class HttpRequest(
val head: HttpRequestHead,
val body: ByteArray,
)
internal data class HttpResponse(
val status: Int,
val reason: String = defaultReason(status),
val headers: List<HttpHeader> = emptyList(),
val body: ByteArray = ByteArray(0),
val close: Boolean = false,
)
internal interface HttpWebSocketSession {
fun isOpen(): Boolean
suspend fun sendText(text: String)
suspend fun sendBytes(data: ByteArray)
suspend fun receive(): LyngWsMessage?
suspend fun close(code: Int = 1000, reason: String = "")
}
internal sealed interface HttpHandlerResult {
data class Response(val response: HttpResponse) : HttpHandlerResult
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
}
internal fun interface HttpHandler {
suspend fun handle(request: HttpRequest): HttpHandlerResult
}
internal interface HttpServer {
fun isOpen(): Boolean
fun localAddress(): LyngSocketAddress
fun close()
}
internal fun parsePathParts(path: String): List<String> {
if (path.isEmpty() || path == "/") return emptyList()
val raw = if (path.startsWith('/')) path.substring(1) else path
if (raw.isEmpty()) return emptyList()
return raw.split('/').map(::decodePathSegment)
}
internal fun parseQueryParameters(queryString: String?): Map<String, String> {
if (queryString.isNullOrEmpty()) return emptyMap()
val result = linkedMapOf<String, String>()
var start = 0
while (start <= queryString.length) {
val nextAmp = queryString.indexOf('&', start).let { if (it >= 0) it else queryString.length }
if (nextAmp > start) {
val part = queryString.substring(start, nextAmp)
val eqAt = part.indexOf('=')
val rawKey = if (eqAt >= 0) part.substring(0, eqAt) else part
val rawValue = if (eqAt >= 0) part.substring(eqAt + 1) else ""
result[decodeQueryComponent(rawKey, plusAsSpace = true)] = decodeQueryComponent(rawValue, plusAsSpace = true)
}
if (nextAmp == queryString.length) break
start = nextAmp + 1
}
return result
}
internal fun decodePathSegment(value: String): String = decodeQueryComponent(value, plusAsSpace = false)
private fun decodeQueryComponent(value: String, plusAsSpace: Boolean): String {
if (value.isEmpty()) return value
val out = StringBuilder(value.length)
val bytes = ArrayList<Byte>()
fun flushBytes() {
if (bytes.isEmpty()) return
out.append(bytes.toByteArray().decodeToString())
bytes.clear()
}
var i = 0
while (i < value.length) {
when (val ch = value[i]) {
'+' -> {
flushBytes()
out.append(if (plusAsSpace) ' ' else '+')
i += 1
}
'%' -> {
val decoded = decodePercentByte(value, i)
if (decoded != null) {
bytes += decoded.first.toByte()
i = decoded.second
} else {
flushBytes()
out.append('%')
i += 1
}
}
else -> {
flushBytes()
out.append(ch)
i += 1
}
}
}
flushBytes()
return out.toString()
}
private fun decodePercentByte(value: String, offset: Int): Pair<Int, Int>? {
if (offset + 2 >= value.length) return null
val hi = value[offset + 1].hexDigitValueOrNull() ?: return null
val lo = value[offset + 2].hexDigitValueOrNull() ?: return null
return ((hi shl 4) or lo) to (offset + 3)
}
private fun Char.hexDigitValueOrNull(): Int? = when (this) {
in '0'..'9' -> code - '0'.code
in 'a'..'f' -> code - 'a'.code + 10
in 'A'..'F' -> code - 'A'.code + 10
else -> null
}
internal fun defaultReason(status: Int): String = when (status) {
101 -> "Switching Protocols"
200 -> "OK"
204 -> "No Content"
400 -> "Bad Request"
404 -> "Not Found"
413 -> "Payload Too Large"
414 -> "URI Too Long"
426 -> "Upgrade Required"
431 -> "Request Header Fields Too Large"
500 -> "Internal Server Error"
501 -> "Not Implemented"
505 -> "HTTP Version Not Supported"
else -> "HTTP $status"
}

View File

@ -0,0 +1,149 @@
package net.sergeych.lyngio.http.server
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import net.sergeych.lyngio.net.LyngNetEngine
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.net.LyngTcpServer
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.net.getSystemNetEngine
internal fun startHttpServer(
config: HttpServerConfig = HttpServerConfig(),
netEngine: LyngNetEngine = getSystemNetEngine(),
handler: HttpHandler,
): HttpServer {
if (!netEngine.isSupported || !netEngine.isTcpServerAvailable) {
throw UnsupportedOperationException("HTTP server is not supported on this runtime")
}
return StartedHttpServer(config, netEngine, handler)
}
private class StartedHttpServer(
private val config: HttpServerConfig,
private val netEngine: LyngNetEngine,
private val handler: HttpHandler,
) : HttpServer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var serverRef: LyngTcpServer? = null
private var open = true
init {
scope.launch {
val server = netEngine.tcpListen(
host = config.host,
port = config.port,
backlog = config.backlog,
reuseAddress = config.reuseAddress,
)
serverRef = server
acceptLoop(server)
}
}
override fun isOpen(): Boolean = open && (serverRef?.isOpen() ?: true)
override fun localAddress(): LyngSocketAddress =
serverRef?.localAddress() ?: throw IllegalStateException("server is not bound yet")
override fun close() {
if (!open) return
open = false
serverRef?.close()
scope.cancel()
}
private suspend fun acceptLoop(server: LyngTcpServer) {
try {
while (open && server.isOpen()) {
val socket = try {
server.accept()
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
if (!open || !server.isOpen()) break
continue
}
scope.launch {
handleConnection(socket)
}
}
} finally {
open = false
server.close()
}
}
private suspend fun handleConnection(socket: LyngTcpSocket) {
val reader = BufferedSocketReader(socket)
try {
while (socket.isOpen()) {
val request = try {
withTimeout(config.keepAliveTimeoutMillis) {
parseHttpRequest(reader, config)
}
} catch (e: CancellationException) {
throw e
} catch (e: HttpProtocolException) {
safeWriteError(socket, e.status, e.message ?: defaultReason(e.status))
break
} catch (_: Throwable) {
safeWriteError(socket, 400, defaultReason(400))
break
} ?: break
val result = try {
handler.handle(request)
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
HttpHandlerResult.Response(HttpResponse(status = 500, close = true))
}
when (result) {
is HttpHandlerResult.Response -> {
val close = request.head.wantsClose || result.response.close
writeHttpResponse(socket, result.response, closeConnection = close)
if (close) break
}
is HttpHandlerResult.WebSocket -> {
if (!request.head.wantsWebSocketUpgrade) {
writeHttpResponse(
socket,
HttpResponse(status = 400, close = true, body = "WebSocket upgrade required".encodeToByteArray()),
closeConnection = true,
)
break
}
val session = upgradeToWebSocket(socket, request)
try {
result.handler(session)
} finally {
session.close()
}
break
}
}
}
} catch (_: CancellationException) {
} finally {
socket.close()
}
}
private suspend fun safeWriteError(socket: LyngTcpSocket, status: Int, message: String) {
try {
writeHttpResponse(
socket,
HttpResponse(status = status, body = message.encodeToByteArray(), close = true),
closeConnection = true,
)
} catch (_: Throwable) {
}
}
}

View File

@ -0,0 +1,44 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
internal suspend fun writeHttpResponse(
socket: LyngTcpSocket,
response: HttpResponse,
closeConnection: Boolean,
) {
val body = response.body
val headerLines = LinkedHashMap<String, MutableList<String>>()
response.headers.forEach { header ->
headerLines.getOrPut(header.name) { mutableListOf() }.add(header.value)
}
if (headerLines.keys.none { it.equals("Content-Length", ignoreCase = true) }) {
headerLines["Content-Length"] = mutableListOf(body.size.toString())
}
if (closeConnection) {
val connectionKey = headerLines.keys.firstOrNull { it.equals("Connection", ignoreCase = true) }
if (connectionKey != null) {
headerLines.remove(connectionKey)
}
headerLines["Connection"] = mutableListOf("close")
}
val head = buildString {
append("HTTP/1.1 ")
append(response.status)
append(' ')
append(response.reason)
append("\r\n")
headerLines.forEach { (name, values) ->
values.forEach { value ->
append(name)
append(": ")
append(value)
append("\r\n")
}
}
append("\r\n")
}
socket.writeUtf8(head)
if (body.isNotEmpty()) socket.write(body)
socket.flush()
}

View File

@ -0,0 +1,295 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.ws.LyngWsMessage
import net.sergeych.mp_tools.encodeToBase64
internal suspend fun upgradeToWebSocket(
socket: LyngTcpSocket,
request: HttpRequest,
): HttpWebSocketSession {
val key = request.head.headers.first("Sec-WebSocket-Key")
?: throw HttpProtocolException(400, "missing Sec-WebSocket-Key")
val response = buildString {
append("HTTP/1.1 101 Switching Protocols\r\n")
append("Upgrade: websocket\r\n")
append("Connection: Upgrade\r\n")
append("Sec-WebSocket-Accept: ")
append(websocketAcceptKey(key))
append("\r\n\r\n")
}
socket.writeUtf8(response)
socket.flush()
return SocketHttpWebSocketSession(socket)
}
private class SocketHttpWebSocketSession(
private val socket: LyngTcpSocket,
) : HttpWebSocketSession {
private val reader = BufferedSocketReader(socket)
private var closed = false
private var fragmentedOpcode: Int? = null
private var fragmentedPayload = ByteArray(0)
private var closeSent = false
override fun isOpen(): Boolean = !closed && socket.isOpen()
override suspend fun sendText(text: String) {
ensureOpen()
sendFrame(OPCODE_TEXT, text.encodeToByteArray())
}
override suspend fun sendBytes(data: ByteArray) {
ensureOpen()
sendFrame(OPCODE_BINARY, data)
}
override suspend fun receive(): LyngWsMessage? {
while (!closed) {
val frame = readFrame() ?: run {
release()
return null
}
when (frame.opcode) {
OPCODE_CONTINUATION -> {
val opcode = fragmentedOpcode ?: throw IllegalStateException("unexpected websocket continuation frame")
fragmentedPayload += frame.payload
if (frame.fin) {
val payload = fragmentedPayload
fragmentedOpcode = null
fragmentedPayload = ByteArray(0)
return payload.toMessage(opcode)
}
}
OPCODE_TEXT, OPCODE_BINARY -> {
if (frame.fin) return frame.payload.toMessage(frame.opcode)
fragmentedOpcode = frame.opcode
fragmentedPayload = frame.payload
}
OPCODE_CLOSE -> {
if (!closeSent) {
sendFrame(OPCODE_CLOSE, frame.payload)
closeSent = true
}
release()
return null
}
OPCODE_PING -> sendFrame(OPCODE_PONG, frame.payload)
OPCODE_PONG -> Unit
else -> Unit
}
}
return null
}
override suspend fun close(code: Int, reason: String) {
if (closed) return
val reasonBytes = reason.encodeToByteArray()
val payload = ByteArray(reasonBytes.size + 2)
payload[0] = (code shr 8).toByte()
payload[1] = code.toByte()
reasonBytes.copyInto(payload, destinationOffset = 2)
try {
if (!closeSent) {
sendFrame(OPCODE_CLOSE, payload)
closeSent = true
}
} finally {
release()
}
}
private suspend fun sendFrame(opcode: Int, payload: ByteArray) {
socket.write(buildFrameHeader(opcode, payload.size, masked = false) + payload)
socket.flush()
}
private suspend fun readFrame(): WsFrame? {
val head = reader.readExact(2) ?: return null
val fin = (head[0].toInt() and 0x80) != 0
val opcode = head[0].toInt() and 0x0f
val masked = (head[1].toInt() and 0x80) != 0
val payloadLength = when (val lengthCode = head[1].toInt() and 0x7f) {
126 -> {
val extended = reader.readExact(2) ?: return null
((extended[0].toInt() and 0xff) shl 8) or (extended[1].toInt() and 0xff)
}
127 -> {
val extended = reader.readExact(8) ?: return null
var acc = 0L
extended.forEach { byte ->
acc = (acc shl 8) or (byte.toInt() and 0xff).toLong()
}
require(acc <= Int.MAX_VALUE.toLong()) { "websocket frame is too large" }
acc.toInt()
}
else -> lengthCode
}
if (!masked) throw IllegalStateException("client websocket frames must be masked")
val mask = reader.readExact(4) ?: return null
val payload = if (payloadLength > 0) reader.readExact(payloadLength) ?: return null else ByteArray(0)
payload.indices.forEach { index ->
payload[index] = (payload[index].toInt() xor mask[index % mask.size].toInt()).toByte()
}
return WsFrame(fin = fin, opcode = opcode, payload = payload)
}
private fun ensureOpen() {
if (!isOpen()) throw IllegalStateException("websocket session is closed")
}
private fun release() {
if (closed) return
closed = true
socket.close()
}
}
private data class WsFrame(
val fin: Boolean,
val opcode: Int,
val payload: ByteArray,
)
private fun ByteArray.toMessage(opcode: Int): LyngWsMessage = when (opcode) {
OPCODE_TEXT -> LyngWsMessage(isText = true, text = decodeToString())
OPCODE_BINARY -> LyngWsMessage(isText = false, data = copyOf())
else -> throw IllegalStateException("unsupported websocket opcode: $opcode")
}
private fun websocketAcceptKey(key: String): String =
sha1((key + WS_GUID).encodeToByteArray()).encodeToBase64()
private fun buildFrameHeader(opcode: Int, payloadSize: Int, masked: Boolean): ByteArray {
require(payloadSize >= 0) { "payload size must be non-negative" }
val firstByte = (0x80 or (opcode and 0x0f)).toByte()
val maskBit = if (masked) 0x80 else 0
return when {
payloadSize <= 125 -> byteArrayOf(firstByte, (maskBit or payloadSize).toByte())
payloadSize <= 0xffff -> byteArrayOf(
firstByte,
(maskBit or 126).toByte(),
((payloadSize ushr 8) and 0xff).toByte(),
(payloadSize and 0xff).toByte(),
)
else -> byteArrayOf(
firstByte,
(maskBit or 127).toByte(),
0,
0,
0,
0,
((payloadSize ushr 24) and 0xff).toByte(),
((payloadSize ushr 16) and 0xff).toByte(),
((payloadSize ushr 8) and 0xff).toByte(),
(payloadSize and 0xff).toByte(),
)
}
}
private fun sha1(input: ByteArray): ByteArray {
var h0 = 0x67452301
var h1 = 0xEFCDAB89.toInt()
var h2 = 0x98BADCFE.toInt()
var h3 = 0x10325476
var h4 = 0xC3D2E1F0.toInt()
val msgLen = input.size
val bitLen = msgLen.toLong() * 8L
val totalLen = ((msgLen + 1 + 8 + 63) / 64) * 64
val padded = ByteArray(totalLen).also { buf ->
input.copyInto(buf)
buf[msgLen] = 0x80.toByte()
for (i in 0..7) {
buf[totalLen - 8 + i] = ((bitLen ushr (56 - i * 8)) and 0xff).toByte()
}
}
val words = IntArray(80)
var blockStart = 0
while (blockStart < padded.size) {
for (i in 0..15) {
val off = blockStart + i * 4
words[i] = ((padded[off].toInt() and 0xff) shl 24) or
((padded[off + 1].toInt() and 0xff) shl 16) or
((padded[off + 2].toInt() and 0xff) shl 8) or
(padded[off + 3].toInt() and 0xff)
}
for (i in 16..79) {
val mixed = words[i - 3] xor words[i - 8] xor words[i - 14] xor words[i - 16]
words[i] = (mixed shl 1) or (mixed ushr 31)
}
var a = h0
var b = h1
var c = h2
var d = h3
var e = h4
for (i in 0..19) {
val f = (b and c) or (b.inv() and d)
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x5A827999 + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 20..39) {
val f = b xor c xor d
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x6ED9EBA1 + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 40..59) {
val f = (b and c) or (b and d) or (c and d)
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0x8F1BBCDC.toInt() + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
for (i in 60..79) {
val f = b xor c xor d
val temp = ((a shl 5) or (a ushr 27)) + f + e + 0xCA62C1D6.toInt() + words[i]
e = d
d = c
c = (b shl 30) or (b ushr 2)
b = a
a = temp
}
h0 += a
h1 += b
h2 += c
h3 += d
h4 += e
blockStart += 64
}
return ByteArray(20).also { out ->
fun putInt(offset: Int, value: Int) {
out[offset] = (value ushr 24).toByte()
out[offset + 1] = (value ushr 16).toByte()
out[offset + 2] = (value ushr 8).toByte()
out[offset + 3] = value.toByte()
}
putInt(0, h0)
putInt(4, h1)
putInt(8, h2)
putInt(12, h3)
putInt(16, h4)
}
}
private const val WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
private const val OPCODE_CONTINUATION = 0x0
private const val OPCODE_TEXT = 0x1
private const val OPCODE_BINARY = 0x2
private const val OPCODE_CLOSE = 0x8
private const val OPCODE_PING = 0x9
private const val OPCODE_PONG = 0xA

View File

@ -0,0 +1,196 @@
/*
* 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.io.html
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LyngHtmlModuleTest {
@Test
fun testModuleRegistrationIsIdempotent() = runTest {
val importManager = ImportManager()
assertTrue(createHtmlModule(importManager))
assertFalse(createHtmlModule(importManager))
}
@Test
fun testModuleCanBeImported() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-test>",
"""
import lyng.io.html
42
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals("42", result.inspect(scope))
}
@Test
fun testHtmlDslBuildsNestedDocument() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-dsl-test>",
"""
import lyng.io.html
html {
head {
title { +"Demo" }
}
body {
h3 { +"Heading 3" }
p {
attr("data-x", "\"quoted\" & <tag>")
+"Text & <more>"
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><title>Demo</title></head><body><h3>Heading 3</h3><p data-x=\"&quot;quoted&quot; &amp; &lt;tag&gt;\">Text &amp; &lt;more&gt;</p></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslSupportsRawAndVoidTags() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-void-test>",
"""
import lyng.io.html
html {
head {
meta { attr("charset", "utf-8") }
}
body {
div {
id("root")
classes("app shell")
raw("<span>trusted</span>")
br {}
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><meta charset=\"utf-8\"></head><body><div id=\"root\" class=\"app shell\"><span>trusted</span><br></div></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslTypedAttributeHelpers() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-typed-attrs-test>",
"""
import lyng.io.html
html {
head {
metaCharset()
stylesheet("/site.css")
}
body {
nav {
a(href: "/home") { +"Home" }
}
img(src: "/logo.png", alt: "Logo & mark")
input(type: "hidden", name: "token", value: "\"abc\"")
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"/site.css\"></head><body><nav><a href=\"/home\">Home</a></nav><img src=\"/logo.png\" alt=\"Logo &amp; mark\"><input type=\"hidden\" name=\"token\" value=\"&quot;abc&quot;\"></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslGenericTagsAndFlagAttributes() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-generic-tag-test>",
"""
import lyng.io.html
html {
body {
tag("custom-element") {
flag("hidden")
+"Secret"
}
voidTag("source") {
attr("srcset", "/image.webp")
attr("type", "image/webp")
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><body><custom-element hidden>Secret</custom-element><source srcset=\"/image.webp\" type=\"image/webp\"></body></html>",
(result as ObjString).value
)
}
}

View File

@ -0,0 +1,155 @@
package net.sergeych.lyngio.http.server
import net.sergeych.lyngio.net.LyngIpVersion
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.net.LyngTcpSocket
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class HttpParserTest {
@Test
fun tooLargeHeadersMapTo431() = kotlinx.coroutines.test.runTest {
val request = buildString {
append("GET / HTTP/1.1\r\n")
append("Host: localhost\r\n")
append("X-Big: ")
append("a".repeat(64))
append("\r\n\r\n")
}
val error = assertFailsWith<HttpProtocolException> {
parse(request, HttpServerConfig(maxHeaderBytes = 32))
}
assertEquals(431, error.status)
}
@Test
fun conflictingDuplicateHostIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse(
"GET / HTTP/1.1\r\n" +
"Host: one.example\r\n" +
"Host: two.example\r\n\r\n"
)
}
assertEquals(400, error.status)
}
@Test
fun conflictingDuplicateContentLengthIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse(
"POST /echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Length: 4\r\n" +
"Content-Length: 5\r\n\r\nping!"
)
}
assertEquals(400, error.status)
}
@Test
fun malformedRequestLineIsRejected() = kotlinx.coroutines.test.runTest {
val error = assertFailsWith<HttpProtocolException> {
parse("GET /only-two-parts\r\nHost: localhost\r\n\r\n")
}
assertEquals(400, error.status)
}
@Test
fun identicalDuplicateContentLengthIsAccepted() = kotlinx.coroutines.test.runTest {
val request = parse(
"POST /echo HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Length: 4\r\n" +
"Content-Length: 4\r\n\r\nping"
)
assertEquals("POST", request.head.method)
assertEquals("/echo", request.head.path)
assertEquals(4, request.head.contentLength)
assertEquals("ping", request.body.decodeToString())
}
@Test
fun queryStringAndDecodedQueryMapAreExposed() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /echo?a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals("a=1&b=hello+world&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag", request.head.queryString)
assertEquals("1", request.head.query["a"])
assertEquals("last", request.head.query["b"])
assertEquals("тест", request.head.query["utf"])
assertEquals("%GG%2", request.head.query["bad"])
assertEquals("", request.head.query["flag"])
}
@Test
fun missingQueryProducesEmptyMap() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /echo HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals(null, request.head.queryString)
assertEquals(emptyMap(), request.head.query)
}
@Test
fun pathPartsAreLazyDecodedWithoutPlusTranslation() = kotlinx.coroutines.test.runTest {
val request = parse(
"GET /one/two%20words/a+b/%GG/%D1%82%D0%B5%D1%81%D1%82 HTTP/1.1\r\n" +
"Host: localhost\r\n\r\n"
)
assertEquals(listOf("one", "two words", "a+b", "%GG", "тест"), request.head.pathParts)
}
private suspend fun parse(
rawRequest: String,
config: HttpServerConfig = HttpServerConfig(),
): HttpRequest {
val socket = FakeTcpSocket(rawRequest.encodeToByteArray())
val reader = BufferedSocketReader(socket)
return parseHttpRequest(reader, config) ?: error("expected parsed request")
}
}
private class FakeTcpSocket(
source: ByteArray,
) : LyngTcpSocket {
private var input = source
private var output = ByteArray(0)
private var open = true
override fun isOpen(): Boolean = open
override fun localAddress(): LyngSocketAddress =
LyngSocketAddress("127.0.0.1", 8080, LyngIpVersion.IPV4, resolved = true)
override fun remoteAddress(): LyngSocketAddress =
LyngSocketAddress("127.0.0.1", 12345, LyngIpVersion.IPV4, resolved = true)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!open || input.isEmpty()) return null
val count = minOf(maxBytes, input.size)
val chunk = input.copyOfRange(0, count)
input = input.copyOfRange(count, input.size)
return chunk
}
override suspend fun readLine(): String? = error("BufferedSocketReader should not call LyngTcpSocket.readLine()")
override suspend fun write(data: ByteArray) {
output += data
}
override suspend fun writeUtf8(text: String) {
output += text.encodeToByteArray()
}
override suspend fun flush() = Unit
override fun close() {
open = false
}
}

View File

@ -0,0 +1,242 @@
package net.sergeych.lyngio.http.server
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.net.getSystemNetEngine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HttpServerLoopbackTest {
@Test
fun simpleGetReturnsResponse() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(
status = 200,
headers = listOf(HttpHeader("Content-Type", "text/plain")),
body = "hello:${request.head.path}".encodeToByteArray(),
)
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /demo HTTP/1.1\r\nHost: localhost\r\n\r\n")
client.flush()
val text = readHttpResponse(client)
assertTrue(text.startsWith("HTTP/1.1 200 OK\r\n"), text)
assertTrue(text.contains("Content-Type: text/plain\r\n"), text)
assertTrue(text.endsWith("hello:/demo"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun keepAliveServesTwoRequestsOnOneSocket() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(status = 200, body = request.head.path.encodeToByteArray())
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"GET /one HTTP/1.1\r\nHost: localhost\r\n\r\n" +
"GET /two HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)
client.flush()
val first = readHttpResponse(client)
val second = readHttpResponse(client)
assertTrue(first.endsWith("/one"), first)
assertTrue(second.contains("Connection: close\r\n"), second)
assertTrue(second.endsWith("/two"), second)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun postWithContentLengthReadsBody() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
HttpHandlerResult.Response(
HttpResponse(status = 200, body = (request.head.method + ":" + request.body.decodeToString()).encodeToByteArray())
)
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nConnection: close\r\n\r\nping"
)
client.flush()
val text = readHttpResponse(client)
assertTrue(text.endsWith("POST:ping"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun transferEncodingIsRejected() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { _ ->
HttpHandlerResult.Response(HttpResponse(status = 200, body = "ok".encodeToByteArray()))
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"POST /x HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
)
client.flush()
val text = readHttpResponse(client)
assertTrue(text.startsWith("HTTP/1.1 501 Not Implemented\r\n"), text)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
@Test
fun websocketUpgradeEchoesText() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable || !engine.isTcpServerAvailable) return@runBlocking
withTimeout(10_000) {
val server = startHttpServer { request ->
if (request.head.path != "/ws") {
HttpHandlerResult.Response(HttpResponse(status = 404, close = true))
} else {
HttpHandlerResult.WebSocket { session ->
val message = session.receive() ?: return@WebSocket
session.sendText("echo:${message.text}")
}
}
}
try {
val port = waitForPort(server)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
val key = "dGhlIHNhbXBsZSBub25jZQ=="
client.writeUtf8(
"GET /ws HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Key: $key\r\n" +
"Sec-WebSocket-Version: 13\r\n\r\n"
)
client.flush()
val headers = ArrayList<String>()
while (true) {
val line = client.readLine() ?: break
if (line.isEmpty()) break
headers += line
}
assertEquals("HTTP/1.1 101 Switching Protocols", headers.first())
sendMaskedTextFrame(client, "ping")
val reply = readServerTextFrame(client)
assertEquals("echo:ping", reply)
} finally {
client.close()
}
} finally {
server.close()
}
}
}
private suspend fun waitForPort(server: HttpServer): Int {
repeat(100) {
runCatching { return server.localAddress().port }
kotlinx.coroutines.delay(10)
}
error("server did not bind in time")
}
private suspend fun readHttpResponse(client: LyngTcpSocket): String {
val statusLine = client.readLine() ?: error("missing status line")
val headers = linkedMapOf<String, String>()
while (true) {
val line = client.readLine() ?: error("unexpected EOF in response headers")
if (line.isEmpty()) break
val colonAt = line.indexOf(':')
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
}
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
return buildString {
append(statusLine).append("\r\n")
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
append("\r\n")
append(body)
}
}
private suspend fun sendMaskedTextFrame(client: LyngTcpSocket, text: String) {
val payload = text.encodeToByteArray()
val mask = byteArrayOf(1, 2, 3, 4)
val masked = payload.copyOf()
masked.indices.forEach { index ->
masked[index] = (masked[index].toInt() xor mask[index % mask.size].toInt()).toByte()
}
val frame = byteArrayOf(0x81.toByte(), (0x80 or payload.size).toByte()) + mask + masked
client.write(frame)
client.flush()
}
private suspend fun readServerTextFrame(client: LyngTcpSocket): String {
val head = readExact(client, 2)
val len = head[1].toInt() and 0x7f
val payload = if (len > 0) readExact(client, len) else ByteArray(0)
return payload.decodeToString()
}
private suspend fun readExact(client: LyngTcpSocket, size: Int): ByteArray {
var pending = ByteArray(0)
while (pending.size < size) {
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
pending += chunk
}
return pending
}
}

View File

@ -137,6 +137,433 @@ class LyngSqliteModuleTest {
assertEquals(2L, result.value)
}
@Test
fun testTransactionGenericReturnTypeFlowsToOuterVal() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
class Payload(name: String, count: Int)
class Item(id: Int, title: String, @DbJson meta: Payload, @DbLynon state: Payload) {
var note: String = ""
}
val restored = openSqlite(":memory:").transaction { tx ->
tx.execute("create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)")
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
item.note = "created"
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
item.title = "second"
item.meta = Payload("json2", 11)
item.state = Payload("bin2", 21)
item.note = "updated"
tx.execute("update item set @set(?1 except: \"id\") where id = ?2", item, item.id)
val restored = tx.select("select * from item where id = ?", 1).decodeAs<Item>().first
assertEquals("second", restored.title)
assertEquals("json2", restored.meta.name)
assertEquals(11, restored.meta.count)
assertEquals("bin2", restored.state.name)
assertEquals(21, restored.state.count)
assertEquals("updated", restored.note)
restored
}
restored.id
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-transaction-return-inference>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(1L, result.value)
}
@Test
fun testDecodeAsProjectsJsonColumnIntoObjectField() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
class Row(id: Int, payload: Point)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, payload json not null)")
tx.execute("insert into data(id, payload) values(?, ?)", 7, "{\"x\":4,\"y\":5}")
val row = tx.select("select id, payload from data").decodeAs<Row>().first
assertEquals(7, row.id)
assertEquals(4, row.payload.x)
assertEquals(5, row.payload.y)
row.payload.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload json not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(9, point.x)
assertEquals(11, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(20L, result.value)
}
@Test
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload text not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-json-text-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(6, point.x)
assertEquals(8, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(14L, result.value)
}
@Test
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimmedStringAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) =
when(rawValue) {
null -> null
else -> rawValue.toString().trim()
}
}
class User(
id: Int,
@DbDecodeWith(TrimmedStringAdapter) name: String
) {
@DbDecodeWith(TrimmedStringAdapter)
var note: String = ""
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, name text not null, note text not null)")
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
val user = tx.select("select id, name, note from data").decodeAs<User>().first
assertEquals(10, user.id)
assertEquals("Alice", user.name)
assertEquals("hello", user.note)
user.note.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object BadAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) = 42
}
class User(@DbDecodeWith(BadAdapter) name: String)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(name text not null)")
tx.execute("insert into data(name) values(?)", "Alice")
tx.select("select name from data").decodeAs<User>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
tx.execute("insert into data(payload) values(?)", encoded)
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
assertEquals(encoded.size, payload.size)
payload.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
assertTrue(result.value > 0)
}
@Test
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testSqlObjectExpansionInsertAndUpdateUseDbAnnotations() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Payload(name: String, count: Int)
class Item(
id: Int,
title: String,
@DbJson meta: Payload,
@DbLynon state: Payload
) {
var note: String = ""
@DbExcept var cache: String = "skip"
@Transient var transientNote: String = "temp"
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)")
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
item.note = "created"
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
item.title = "second"
item.meta = Payload("json2", 11)
item.state = Payload("bin2", 21)
item.note = "updated"
item.cache = "must-not-be-written"
item.transientNote = "must-not-be-written"
tx.execute("update item set @set(?1) where id = ?2", item, 1)
val restored = tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
assertEquals(1, restored.id)
assertEquals("second", restored.title)
assertEquals("json2", restored.meta.name)
assertEquals(11, restored.meta.count)
assertEquals("bin2", restored.state.name)
assertEquals(21, restored.state.count)
assertEquals("updated", restored.note)
restored.state.count
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-object-expansion>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(21L, result.value)
}
@Test
fun testSqlObjectExpansionSupportsDbSerializeWithAdapter() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimAdapter: DbFieldAdapter {
override fun encode(value, targetType) =
when(value) {
null -> null
else -> value.toString().trim()
}
}
class User(
id: Int,
@DbSerializeWith(TrimAdapter) name: String
)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table user(id integer not null, name text not null)")
tx.execute("insert into user(@cols(?1)) values(@vals(?1))", User(7, " Alice "))
val row = tx.select("select id, name from user").first
val storedName: String = row["name"] as String
assertEquals(7, row["id"])
assertEquals("Alice", storedName)
storedName.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-object-expansion-adapter>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testSqlObjectExpansionSupportsClauseLevelExceptFilter() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Item(id: Int, title: String, note: String) {
var stamp: String = ""
@DbExcept var cache: String = "skip"
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table item(id integer not null, title text not null, note text not null, stamp text not null default '')")
val item = Item(1, "first", "keep")
item.stamp = "created"
tx.execute(
"insert into item(@cols(?1 except: \"stamp\")) values(@vals(?1 except: \"stamp\"))",
item
)
item.title = "second"
item.note = "changed-but-excluded"
item.stamp = "updated"
item.cache = "still-skip"
tx.execute(
"update item set @set(?1 except: \"id\", \"note\") where id = ?2",
item,
1
)
val restored = tx.select("select id, title, note, stamp from item").decodeAs<Item>().first
assertEquals(1, restored.id)
assertEquals("second", restored.title)
assertEquals("keep", restored.note)
assertEquals("updated", restored.stamp)
restored.stamp.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-object-expansion-except>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(7L, result.value)
}
@Test
fun testSqlObjectExpansionRejectsUnannotatedComplexFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Payload(name: String)
class BadRecord(id: Int, payload: Payload)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table bad_record(id integer not null, payload text not null)")
tx.execute("insert into bad_record(@cols(?1)) values(@vals(?1))", BadRecord(1, Payload("x")))
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-object-expansion-bad-field>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
val scope = Script.newScope()

View File

@ -0,0 +1,474 @@
package net.sergeych.lyng.io.http.server
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.http.server.createHttpServerModule
import net.sergeych.lyng.io.ws.createWsModule
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.io.http.createHttpModule
import net.sergeych.lyngio.net.getSystemNetEngine
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertTrue
class LyngHttpServerModuleTest {
@Test
fun serverModuleReusesSharedHttpHeadersRuntimeType() = runBlocking {
val scope = Script.newScope()
createHttpModule(PermitAllHttpAccessPolicy, scope)
createWsModule(PermitAllWsAccessPolicy, scope)
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val httpModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http")
val wsModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws")
val serverModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.server")
val sharedTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.http.types")
val sharedWsTypesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.ws.types")
assertSame(sharedTypesModule.get("HttpHeaders")?.value, httpModule.get("HttpHeaders")?.value)
assertSame(sharedTypesModule.get("HttpHeaders")?.value, serverModule.get("HttpHeaders")?.value)
assertSame(sharedWsTypesModule.get("WsMessage")?.value, wsModule.get("WsMessage")?.value)
assertSame(sharedWsTypesModule.get("WsMessage")?.value, serverModule.get("WsMessage")?.value)
}
@Test
fun exactRouteAndFallbackWork() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val server = HttpServer()
server.get("/hello") {
setHeader("Content-Type", "text/plain")
respondText(200, "hello from lyng")
}
server.fallback {
respondText(404, "miss:" + request.path)
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /hello HTTP/1.1\r\nHost: localhost\r\n\r\n")
client.flush()
val hello = readHttpResponse(client)
assertTrue(hello.contains("200 OK"), hello)
assertTrue(hello.endsWith("hello from lyng"), hello)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /other HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val miss = readHttpResponse(client2)
assertTrue(miss.contains("404"), miss)
assertTrue(miss.endsWith("miss:/other"), miss)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun requestQueryStringAndQueryMapAreAvailableToLyng() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val server = HttpServer()
server.get("/query") {
val q = request.query
respondText(
200,
(request.queryString ?: "<null>") +
"|" + q.size +
"|" + (q["a"] ?: "<null>") +
"|" + (q["b"] ?: "<null>") +
"|" + (q["utf"] ?: "<null>") +
"|" + (q["bad"] ?: "<null>") +
"|" + (q["flag"] ?: "<null>")
)
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8(
"GET /query?a=1&b=first&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag HTTP/1.1\r\n" +
"Host: localhost\r\nConnection: close\r\n\r\n"
)
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(
response.endsWith(
"a=1&b=first&b=last&utf=%D1%82%D0%B5%D1%81%D1%82&bad=%GG%2&flag|5|1|last|тест|%GG%2|"
),
response
)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /query HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("<null>|0|<null>|<null>|<null>|<null>|<null>"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun regexRoutesExposeMatchAndPathPartsToLyng() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val server = HttpServer()
server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) {
val m = routeMatch!!
respondText(
200,
m[1] +
"|" + m[2] +
"|" + request.pathParts[0] +
"," + request.pathParts[1] +
"," + request.pathParts[2] +
"," + request.pathParts[3]
)
}
server.get("/users/fixed/posts/9") {
respondText(200, "fixed|" + (routeMatch == null))
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /users/42/posts/7 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("42|7|users,42,posts,7"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/fixed/posts/9 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("fixed|true"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun pathTemplateRoutesExposeDecodedRouteParamsAndKeepExactRoutesFirst() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val server = HttpServer()
server.get("/users/fixed/posts/9") {
respondText(200, "fixed|" + routeParams.size)
}
server.getPath("/users/{userId}/posts/{postId}") {
respondText(
200,
routeParams["userId"] + "|" +
routeParams["postId"] + "|" +
request.pathParts[1] + "|" +
request.pathParts[3] + "|" +
(routeMatch != null)
)
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /users/alice%20bob/posts/c+d HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("alice bob|c+d|alice bob|c+d|true"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/fixed/posts/9 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("fixed|0"), response)
} finally {
client2.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun jsonBodyAndRespondJsonSupportTypedJsonPostHandlers() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
closed class CreateUserRequest(name: String, age: Int)
closed class CreateUserResponse(id: Int, name: String, age: Int)
val server = HttpServer()
server.postPath("/api/users") {
val req = jsonBody<CreateUserRequest>()
respondJson(CreateUserResponse(101, req.name, req.age), 201)
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
val body = """{"name":"alice","age":30}"""
client.writeUtf8(
"POST /api/users HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${body.encodeToByteArray().size}\r\n" +
"Connection: close\r\n\r\n" +
body
)
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("201"), response)
assertTrue(response.contains("Content-Type: application/json; charset=utf-8"), response)
assertTrue(response.endsWith("""{"id":101,"name":"alice","age":30}"""), response)
} finally {
client.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun respondHtmlRendersHtmlDslAndSetsContentType() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
import lyng.io.html
val server = HttpServer()
server.getPath("/html/{name}") {
respondHtml(code: 202) {
head { title { +"Greeting" } }
body {
h3 { +("Hello, " + routeParams["name"]) }
}
}
}
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /html/alice%26bob HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("202"), response)
assertTrue(response.contains("Content-Type: text/html; charset=utf-8"), response)
assertTrue(
response.endsWith(
"<!doctype html><html><head><title>Greeting</title></head><body><h3>Hello, alice&amp;bob</h3></body></html>"
),
response
)
} finally {
client.close()
}
handle.invokeInstanceMethod(scope, "close")
}
@Test
fun routerMountPreservesBuiltInRoutingSemantics() = runBlocking {
val engine = getSystemNetEngine()
if (!engine.isSupported || !engine.isTcpAvailable) return@runBlocking
val scope = Script.newScope()
createHttpServerModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.http.server
val api = Router()
api.get("/health") {
respondText(200, "ok")
}
val users = Router()
users.getPath("/users/{id}") {
respondText(200, "user:" + routeParams["id"])
}
users.fallback {
respondText(404, "router-miss:" + request.path)
}
api.mount(users)
val server = HttpServer()
server.mount(api)
server.listen(0, "127.0.0.1")
""".trimIndent()
val handle = Compiler.compile(code).execute(scope)
val port = waitForPort(handle, scope)
val client = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client.writeUtf8("GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client.flush()
val response = readHttpResponse(client)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("ok"), response)
} finally {
client.close()
}
val client2 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client2.writeUtf8("GET /users/alice HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client2.flush()
val response = readHttpResponse(client2)
assertTrue(response.contains("200 OK"), response)
assertTrue(response.endsWith("user:alice"), response)
} finally {
client2.close()
}
val client3 = engine.tcpConnect("127.0.0.1", port, 2_000, true)
try {
client3.writeUtf8("GET /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
client3.flush()
val response = readHttpResponse(client3)
assertTrue(response.contains("404"), response)
assertTrue(response.endsWith("router-miss:/missing"), response)
} finally {
client3.close()
}
handle.invokeInstanceMethod(scope, "close")
}
private suspend fun waitForPort(handle: Obj, scope: net.sergeych.lyng.Scope): Int {
repeat(100) {
val port = runCatching {
val value = handle.invokeInstanceMethod(scope, "localPort")
(value as ObjInt).value.toInt()
}.getOrNull()
if (port != null && port > 0) return port
delay(10)
}
error("server did not bind in time")
}
private suspend fun readHttpResponse(client: net.sergeych.lyngio.net.LyngTcpSocket): String {
val statusLine = client.readLine() ?: error("missing status line")
val headers = linkedMapOf<String, String>()
while (true) {
val line = client.readLine() ?: error("unexpected EOF in response headers")
if (line.isEmpty()) break
val colonAt = line.indexOf(':')
if (colonAt > 0) headers[line.substring(0, colonAt)] = line.substring(colonAt + 1).trim()
}
val bodyLength = headers["Content-Length"]?.toIntOrNull() ?: 0
val body = if (bodyLength > 0) readExact(client, bodyLength).decodeToString() else ""
return buildString {
append(statusLine).append("\r\n")
headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
append("\r\n")
append(body)
}
}
private suspend fun readExact(client: net.sergeych.lyngio.net.LyngTcpSocket, size: Int): ByteArray {
var pending = ByteArray(0)
while (pending.size < size) {
val chunk = client.read(size - pending.size) ?: error("unexpected EOF")
pending += chunk
}
return pending
}
}

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
@ -35,11 +36,25 @@ import java.net.ServerSocket
import java.net.Socket
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngNetModuleTest {
@Test
fun testSharedNetTypesModuleExportsCanonicalTypes() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val netModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net")
val typesModule = scope.importManager.createModuleScope(Pos.builtIn, "lyng.io.net.types")
assertSame(typesModule.get("IpVersion")?.value, netModule.get("IpVersion")?.value)
assertSame(typesModule.get("SocketAddress")?.value, netModule.get("SocketAddress")?.value)
assertSame(typesModule.get("Datagram")?.value, netModule.get("Datagram")?.value)
}
@Test
fun testResolveAndCapabilities() = runBlocking {
val scope = Script.newScope()

View File

@ -19,11 +19,13 @@ package net.sergeych.lyng.io.db.sqlite
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
@ -231,6 +233,392 @@ class LyngSqliteModuleNativeTest {
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
}
@Test
fun testDecodeAsProjectsJsonColumnIntoObjectField() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
class Row(id: Int, payload: Point)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, payload json not null)")
tx.execute("insert into data(id, payload) values(?, ?)", 7, "{\"x\":4,\"y\":5}")
val row = tx.select("select id, payload from data").decodeAs<Row>().first
assertEquals(7, row.id)
assertEquals(4, row.payload.x)
assertEquals(5, row.payload.y)
row.payload.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload json not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(9, point.x)
assertEquals(11, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(20L, result.value)
}
@Test
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload text not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-json-text-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(6, point.x)
assertEquals(8, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(14L, result.value)
}
@Test
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimmedStringAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) =
when(rawValue) {
null -> null
else -> rawValue.toString().trim()
}
}
class User(
id: Int,
@DbDecodeWith(TrimmedStringAdapter) name: String
) {
@DbDecodeWith(TrimmedStringAdapter)
var note: String = ""
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, name text not null, note text not null)")
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
val user = tx.select("select id, name, note from data").decodeAs<User>().first
assertEquals(10, user.id)
assertEquals("Alice", user.name)
assertEquals("hello", user.note)
user.note.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object BadAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) = 42
}
class User(@DbDecodeWith(BadAdapter) name: String)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(name text not null)")
tx.execute("insert into data(name) values(?)", "Alice")
tx.select("select name from data").decodeAs<User>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
tx.execute("insert into data(payload) values(?)", encoded)
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
assertEquals(encoded.size, payload.size)
payload.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
assertTrue(result.value > 0)
}
@Test
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testSqlObjectExpansionInsertAndUpdateUseDbAnnotations() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Payload(name: String, count: Int)
class Item(
id: Int,
title: String,
@DbJson meta: Payload,
@DbLynon state: Payload
) {
var note: String = ""
@DbExcept var cache: String = "skip"
@Transient var transientNote: String = "temp"
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)")
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
item.note = "created"
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
item.title = "second"
item.meta = Payload("json2", 11)
item.state = Payload("bin2", 21)
item.note = "updated"
item.cache = "must-not-be-written"
item.transientNote = "must-not-be-written"
tx.execute("update item set @set(?1) where id = ?2", item, 1)
val restored = tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
assertEquals(1, restored.id)
assertEquals("second", restored.title)
assertEquals("json2", restored.meta.name)
assertEquals(11, restored.meta.count)
assertEquals("bin2", restored.state.name)
assertEquals(21, restored.state.count)
assertEquals("updated", restored.note)
restored.state.count
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-object-expansion>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(21L, result.value)
}
@Test
fun testSqlObjectExpansionSupportsDbSerializeWithAdapter() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimAdapter: DbFieldAdapter {
override fun encode(value, targetType) =
when(value) {
null -> null
else -> value.toString().trim()
}
}
class User(
id: Int,
@DbSerializeWith(TrimAdapter) name: String
)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table user(id integer not null, name text not null)")
tx.execute("insert into user(@cols(?1)) values(@vals(?1))", User(7, " Alice "))
val row = tx.select("select id, name from user").first
val storedName: String = row["name"] as String
assertEquals(7, row["id"])
assertEquals("Alice", storedName)
storedName.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-object-expansion-adapter>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testSqlObjectExpansionSupportsClauseLevelExceptFilter() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Item(id: Int, title: String, note: String) {
var stamp: String = ""
@DbExcept var cache: String = "skip"
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table item(id integer not null, title text not null, note text not null, stamp text not null default '')")
val item = Item(1, "first", "keep")
item.stamp = "created"
tx.execute(
"insert into item(@cols(?1 except: \"stamp\")) values(@vals(?1 except: \"stamp\"))",
item
)
item.title = "second"
item.note = "changed-but-excluded"
item.stamp = "updated"
item.cache = "still-skip"
tx.execute(
"update item set @set(?1 except: \"id\", \"note\") where id = ?2",
item,
1
)
val restored = tx.select("select id, title, note, stamp from item").decodeAs<Item>().first
assertEquals(1, restored.id)
assertEquals("second", restored.title)
assertEquals("keep", restored.note)
assertEquals("updated", restored.stamp)
restored.stamp.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-object-expansion-except>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(7L, result.value)
}
@Test
fun testSqlObjectExpansionRejectsUnannotatedComplexFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Payload(name: String)
class BadRecord(id: Int, payload: Payload)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table bad_record(id integer not null, payload text not null)")
tx.execute("insert into bad_record(@cols(?1)) values(@vals(?1))", BadRecord(1, Payload("x")))
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-object-expansion-bad-field>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
val scope = Script.newScope()

View File

@ -20,6 +20,38 @@ extern class SqlColumn {
val nativeType: String
}
/*
Adapter interface for custom DB field projection.
Use it with `@DbDecodeWith(adapter)` on class constructor parameters or
class-body fields/properties participating in `decodeAs<T>()` projection,
and with `@DbSerializeWith(adapter)` on fields participating in SQL object
expansion macros such as `@vals(?1)` and `@set(?1)`.
`targetType` is the requested Lyng target type represented as a runtime
type object, such as a class or a type expression.
Default methods throw `NotImplementedException`.
*/
interface DbFieldAdapter {
/*
Decode one raw database field value into a Lyng value suitable for the
requested target type.
*/
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
throw NotImplementedException("DB field adapter decode is not implemented")
/*
Encode one Lyng value into a database field representation.
The result must be a direct DB-bindable value:
null, Bool, Int, Double/Real, Decimal, String, Buffer, Date, DateTime,
or Instant.
*/
fun encode(value: Object?, targetType: Object): Object? =
throw NotImplementedException("DB field adapter encode is not implemented")
}
extern class SqlRow {
/* Number of columns in the row */
val size: Int
@ -34,6 +66,22 @@ extern class SqlRow {
names and invalid indexes should also fail.
*/
override fun getAt(indexOrName: String | Int): Object?
/*
Decode this row into a typed Lyng value.
For object/class targets, constructor parameters are matched by column
label first and then remaining matching serializable mutable fields are
assigned.
If a constructor parameter or class-body field/property has
`@DbDecodeWith(adapter)`, the adapter is applied first and its result
must match the target member type.
For single-column rows, the column value may also be decoded directly to
the requested target type.
*/
fun decodeAs<T>(): T
}
/*
@ -69,6 +117,16 @@ extern class ResultSet : Iterable<SqlRow> {
internally, but this must not change visible later iteration behavior.
*/
override fun isEmpty(): Bool
/*
Return a transaction-scoped iterable view that decodes each row with
`SqlRow.decodeAs<T>()`.
The returned iterable itself must not be used after the owning
transaction ends. Materialized decoded objects may outlive the
transaction.
*/
fun decodeAs<T>(): Iterable<T>
}
extern class ExecutionResult {
@ -133,6 +191,29 @@ extern class SqlTransaction {
- Date, DateTime, Instant
Unsupported parameter values should fail with `SqlUsageException`.
SQL object expansion macros are also supported:
- `@cols(?1)` expands one object argument to a comma-separated column list
- `@vals(?1)` expands the same object to matching `?` placeholders and
generated bind values
- `@set(?1)` expands to `col = ?` pairs and generated bind values
- each macro also accepts an optional `except:` filter, for example
`@set(?1 except: "id", "updatedAt")`
When a clause uses `@cols`, `@vals`, or `@set`, any non-expanded scalar
parameters in the same clause must use explicit indexed placeholders such
as `?2`, `?3`, and so on.
Projection is declaration-driven:
- `@Transient` fields are excluded
- `@DbExcept` fields are excluded
- `except:` excludes additional fields for that specific macro use
- `@DbJson` fields are encoded as JSON text
- `@DbLynon` fields are encoded as Lynon binary
- `@DbSerializeWith(adapter)` fields are encoded through the adapter
- unannotated non-primitive fields fail with `SqlUsageException`
*/
fun select(clause: String, params...): ResultSet

View File

@ -0,0 +1,142 @@
package lyng.io.html
import lyng.stdlib
fun escapeHtml(text: String): String {
val amp: String = text.replace("&", "&amp;")
val lt: String = amp.replace("<", "&lt;")
lt.replace(">", "&gt;")
}
fun escapeHtmlAttr(text: String): String {
val escaped: String = escapeHtml(text)
val quoted: String = escaped.replace("\"", "&quot;")
quoted.replace("'", "&#39;")
}
class HtmlTag(name: String, isVoid: Bool = false) {
val name = name
val isVoid = isVoid
var attributes = ""
var inner = ""
fun attr(name: String, value: Object): HtmlTag {
attributes += " " + name + "=\"" + escapeHtmlAttr(value.toString()) + "\""
this
}
fun flag(name: String): HtmlTag {
attributes += " " + name
this
}
fun id(value: String): HtmlTag = attr("id", value)
fun classes(value: String): HtmlTag = attr("class", value)
fun addText(text: String): void {
inner += escapeHtml(text)
}
fun raw(html: String): void {
inner += html
}
fun child(tagName: String, block: HtmlTag.()->void): void {
val child = HtmlTag(tagName)
with(child) { block(this) }
inner += child.render()
}
fun voidChild(tagName: String, block: HtmlTag.()->void): void {
val child = HtmlTag(tagName, true)
with(child) { block(this) }
inner += child.render()
}
fun tag(name: String, block: HtmlTag.()->void): void { child(name, block) }
fun voidTag(name: String, block: HtmlTag.()->void): void { voidChild(name, block) }
fun render(): String {
if (isVoid) "<" + name + attributes + ">"
else "<" + name + attributes + ">" + inner + "</" + name + ">"
}
fun head(block: HtmlTag.()->void): void { child("head", block) }
fun body(block: HtmlTag.()->void): void { child("body", block) }
fun title(block: HtmlTag.()->void): void { child("title", block) }
fun main(block: HtmlTag.()->void): void { child("main", block) }
fun section(block: HtmlTag.()->void): void { child("section", block) }
fun article(block: HtmlTag.()->void): void { child("article", block) }
fun header(block: HtmlTag.()->void): void { child("header", block) }
fun footer(block: HtmlTag.()->void): void { child("footer", block) }
fun nav(block: HtmlTag.()->void): void { child("nav", block) }
fun div(block: HtmlTag.()->void): void { child("div", block) }
fun span(block: HtmlTag.()->void): void { child("span", block) }
fun p(block: HtmlTag.()->void): void { child("p", block) }
fun a(href: String, block: HtmlTag.()->void): void {
child("a") {
attr("href", href)
block(this)
}
}
fun ul(block: HtmlTag.()->void): void { child("ul", block) }
fun ol(block: HtmlTag.()->void): void { child("ol", block) }
fun li(block: HtmlTag.()->void): void { child("li", block) }
fun h1(block: HtmlTag.()->void): void { child("h1", block) }
fun h2(block: HtmlTag.()->void): void { child("h2", block) }
fun h3(block: HtmlTag.()->void): void { child("h3", block) }
fun h4(block: HtmlTag.()->void): void { child("h4", block) }
fun h5(block: HtmlTag.()->void): void { child("h5", block) }
fun h6(block: HtmlTag.()->void): void { child("h6", block) }
fun strong(block: HtmlTag.()->void): void { child("strong", block) }
fun em(block: HtmlTag.()->void): void { child("em", block) }
fun code(block: HtmlTag.()->void): void { child("code", block) }
fun pre(block: HtmlTag.()->void): void { child("pre", block) }
fun script(block: HtmlTag.()->void): void { child("script", block) }
fun style(block: HtmlTag.()->void): void { child("style", block) }
fun meta(block: HtmlTag.()->void): void { voidChild("meta", block) }
fun link(block: HtmlTag.()->void): void { voidChild("link", block) }
fun img(block: HtmlTag.()->void): void { voidChild("img", block) }
fun br(block: HtmlTag.()->void): void { voidChild("br", block) }
fun input(block: HtmlTag.()->void): void { voidChild("input", block) }
fun metaCharset(charset: String = "utf-8"): void {
meta { attr("charset", charset) }
}
fun stylesheet(href: String): void {
link {
attr("rel", "stylesheet")
attr("href", href)
}
}
fun img(src: String, alt: String = ""): void {
voidChild("img") {
attr("src", src)
if (alt != "") attr("alt", alt)
}
}
fun input(type: String, name: String = "", value: String = ""): void {
voidChild("input") {
attr("type", type)
if (name != "") attr("name", name)
if (value != "") attr("value", value)
}
}
}
context(HtmlTag)
fun String.unaryPlus(): void {
this@HtmlTag.addText(this)
}
fun html(block: HtmlTag.()->void): String {
val root = HtmlTag("html")
with(root) { block(this) }
"<!doctype html>" + root.render()
}

View File

@ -1,17 +1,6 @@
package lyng.io.http
/*
Response/header view that behaves like a map for the first value of each header name.
Multi-valued headers are exposed through `getAll`.
*/
extern class HttpHeaders : Map<String, String> {
/* Return the first value for the given header name, or null when absent. */
fun get(name: String): String?
/* Return all values for the given header name, preserving wire order when available. */
fun getAll(name: String): List<String>
/* Return distinct header names present in this response. */
fun names(): List<String>
}
import lyng.io.http.types
/* Mutable request descriptor for programmatic HTTP calls. */
extern class HttpRequest {

View File

@ -0,0 +1,88 @@
package lyng.io.http.server
import lyng.io.http.types
import lyng.serialization
import lyng.io.ws.types
import lyng.io.html
/* Immutable parsed incoming server request. */
extern class ServerRequest {
val method: String
val target: String
val path: String
val pathParts: List<String>
val queryString: String?
val query: Map<String, String>
val headers: HttpHeaders
val body: Buffer
fun text(): String
fun isWebSocketUpgrade(): Bool
}
/* Active server-side WebSocket session. */
extern class ServerWebSocket {
fun isOpen(): Bool
fun sendText(text: String): void
fun sendBytes(data: Buffer): void
fun receive(): WsMessage?
fun close(code: Int = 1000, reason: String = ""): void
}
/* Mutable exchange object for one incoming request. */
extern class RequestContext {
val request: ServerRequest
val routeMatch: RegexMatch?
val routeParams: Map<String, String>
fun jsonBody<T>(): T
fun respond(status: Int = 200, body: Buffer? = null): void
fun respondText(status: Int = 200, bodyText: String = ""): void
fun respondJson(body: Object?, status: Int = 200): void
fun respondHtml(code: Int = 200, builder: HtmlTag.()->void): void
fun setHeader(name: String, value: String): void
fun addHeader(name: String, value: String): void
fun acceptWebSocket(handler: RequestContext.(ServerWebSocket) -> Object?): void
fun isHandled(): Bool
}
/* Running listener handle. */
extern class HttpServerHandle {
fun localPort(): Int
fun close(): void
}
/* Reusable route collection mounted into HttpServer or other Router. */
extern class Router {
fun get(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun getPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun post(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun postPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun put(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun putPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun delete(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun deletePath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun any(path: String|Regex, handler: RequestContext.() -> Object?): Router
fun anyPath(pathTemplate: String, handler: RequestContext.() -> Object?): Router
fun ws(path: String|Regex, handler: RequestContext.(ServerWebSocket) -> Object?): Router
fun wsPath(pathTemplate: String, handler: RequestContext.(ServerWebSocket) -> Object?): Router
fun fallback(handler: RequestContext.() -> Object?): Router
fun mount(router: Router): Router
}
/* HTTP/WebSocket server with built-in router. */
extern class HttpServer {
fun get(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun getPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun post(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun postPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun put(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun putPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun delete(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun deletePath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun any(path: String|Regex, handler: RequestContext.() -> Object?): HttpServer
fun anyPath(pathTemplate: String, handler: RequestContext.() -> Object?): HttpServer
fun ws(path: String|Regex, handler: RequestContext.(ServerWebSocket) -> Object?): HttpServer
fun wsPath(pathTemplate: String, handler: RequestContext.(ServerWebSocket) -> Object?): HttpServer
fun fallback(handler: RequestContext.() -> Object?): HttpServer
fun mount(router: Router): HttpServer
fun listen(port: Int, host: String? = null, backlog: Int = 128): HttpServerHandle
}

View File

@ -0,0 +1,14 @@
package lyng.io.http.types
/*
Response/header view that behaves like a map for the first value of each header name.
Multi-valued headers are exposed through `getAll`.
*/
extern class HttpHeaders : Map<String, String> {
/* Return the first value for the given header name, or null when absent. */
fun get(name: String): String?
/* Return all values for the given header name, preserving wire order when available. */
fun getAll(name: String): List<String>
/* Return distinct header names present in this response. */
fun names(): List<String>
}

View File

@ -1,30 +1,6 @@
package lyng.io.net
/* Address family for resolved or bound endpoints. */
enum IpVersion {
IPV4,
IPV6
}
/* Concrete socket endpoint. */
extern class SocketAddress {
/* Numeric or host-form address string. */
val host: String
/* Transport port number. */
val port: Int
/* Address family. */
val ipVersion: IpVersion
/* True when obtained from DNS resolution rather than raw bind input. */
val resolved: Bool
/* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */
override fun toString(): String
}
/* Datagram payload paired with sender/peer address. */
extern class Datagram {
val data: Buffer
val address: SocketAddress
}
import lyng.io.net.types
/* Connected TCP socket. */
extern class TcpSocket {

View File

@ -0,0 +1,27 @@
package lyng.io.net.types
/* Address family for resolved or bound endpoints. */
enum IpVersion {
IPV4,
IPV6
}
/* Concrete socket endpoint. */
extern class SocketAddress {
/* Numeric or host-form address string. */
val host: String
/* Transport port number. */
val port: Int
/* Address family. */
val ipVersion: IpVersion
/* True when obtained from DNS resolution rather than raw bind input. */
val resolved: Bool
/* Stable printable form such as `127.0.0.1:4040` or `[::1]:4040`. */
override fun toString(): String
}
/* Datagram payload paired with sender/peer address. */
extern class Datagram {
val data: Buffer
val address: SocketAddress
}

View File

@ -1,14 +1,6 @@
package lyng.io.ws
/* Received WebSocket message. */
extern class WsMessage {
/* True when this message carries text payload. */
val isText: Bool
/* Text payload for text messages, otherwise null. */
val text: String?
/* Binary payload for binary messages, otherwise null. */
val data: Buffer?
}
import lyng.io.ws.types
/* Active WebSocket client session. */
extern class WsSession {

View File

@ -0,0 +1,11 @@
package lyng.io.ws.types
/* Received WebSocket message. */
extern class WsMessage {
/* True when this message carries text payload. */
val isText: Bool
/* Text payload for text messages, otherwise null. */
val text: String?
/* Binary payload for binary messages, otherwise null. */
val data: Buffer?
}

View File

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

View File

@ -88,7 +88,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
a.visibility ?: defaultVisibility,
recordType = recordType,
declaringClass = declaringClass,
isTransient = a.isTransient
isTransient = a.isTransient,
annotations = a.annotations
)
}
return
@ -108,7 +109,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
a.visibility ?: defaultVisibility,
recordType = recordType,
declaringClass = declaringClass,
isTransient = a.isTransient
isTransient = a.isTransient,
annotations = a.annotations
)
}
@ -505,5 +507,7 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
val accessType: AccessType? = null,
val visibility: Visibility? = null,
val isTransient: Boolean = false,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val annotations: List<DeclAnnotation> = emptyList(),
)
}

View File

@ -41,6 +41,17 @@ data class ClassDeclSpec(
val initScope: List<Statement>,
)
private suspend fun evaluateConstructorAnnotations(scope: Scope, args: ArgsDeclaration?): ArgsDeclaration? {
if (args == null) return null
if (args.params.none { it.annotationSpecs.isNotEmpty() }) return args
return args.copy(
params = args.params.map { item ->
if (item.annotationSpecs.isEmpty()) item
else item.copy(annotations = item.annotationSpecs.evaluateDeclAnnotations(scope))
}
)
}
internal suspend fun executeClassDecl(
scope: Scope,
spec: ClassDeclSpec,
@ -60,7 +71,8 @@ internal suspend fun executeClassDecl(
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray())
newClass.isAnonymous = spec.isAnonymous
newClass.constructorMeta = ArgsDeclaration(emptyList(), Token.Type.RPAREN)
newClass.isSingletonObject = true
newClass.constructorMeta = evaluateConstructorAnnotations(scope, ArgsDeclaration(emptyList(), Token.Type.RPAREN))
for (i in parentClasses.indices) {
val argsList = spec.baseSpecs[i].args
if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList
@ -85,6 +97,7 @@ internal suspend fun executeClassDecl(
}
if (spec.isExtern) {
val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs)
val parentClasses = spec.baseSpecs.mapNotNull { baseSpec ->
val rec = scope[baseSpec.name]
val cls = rec?.value as? ObjClass
@ -105,8 +118,8 @@ internal suspend fun executeClassDecl(
}
val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply {
this.isAbstract = true
constructorMeta = spec.constructorArgs
spec.constructorArgs?.params?.forEach { p ->
constructorMeta = evaluatedConstructorArgs
evaluatedConstructorArgs?.params?.forEach { p ->
if (p.accessType != null) {
createField(
p.name,
@ -117,6 +130,7 @@ internal suspend fun executeClassDecl(
pos = Pos.builtIn,
isTransient = p.isTransient,
type = ObjRecord.Type.ConstructorField,
annotations = p.annotations,
fieldId = spec.constructorFieldIds?.get(p.name)
)
}
@ -160,16 +174,17 @@ internal suspend fun executeClassDecl(
}
}
val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs)
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).also {
it.isAbstract = spec.isAbstract
it.isClosed = spec.isClosed
it.instanceConstructor = constructorCode
it.constructorMeta = spec.constructorArgs
it.constructorMeta = evaluatedConstructorArgs
for (i in parentClasses.indices) {
val argsList = spec.baseSpecs[i].args
if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList
}
spec.constructorArgs?.params?.forEach { p ->
evaluatedConstructorArgs?.params?.forEach { p ->
if (p.accessType != null) {
it.createField(
p.name,
@ -180,6 +195,7 @@ internal suspend fun executeClassDecl(
pos = Pos.builtIn,
isTransient = p.isTransient,
type = ObjRecord.Type.ConstructorField,
annotations = p.annotations,
fieldId = spec.constructorFieldIds?.get(p.name)
)
}

View File

@ -38,6 +38,7 @@ class ClassInstanceFieldDeclStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val fieldId: Int?,
val initStatement: Statement?,
override val pos: Pos,
@ -56,6 +57,7 @@ class ClassInstancePropertyDeclStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val prop: ObjProperty,
val methodId: Int?,
val initStatement: Statement?,
@ -75,6 +77,7 @@ class ClassInstanceDelegatedDeclStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val methodId: Int?,
val initStatement: Statement?,
override val pos: Pos,

View File

@ -32,6 +32,7 @@ class ClassStaticFieldInitStatement(
val initializer: Statement?,
val isDelegated: Boolean,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
private val startPos: Pos,
) : Statement() {
override val pos: Pos = startPos
@ -39,6 +40,7 @@ class ClassStaticFieldInitStatement(
override suspend fun execute(scope: Scope): Obj {
val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy()
?: ObjNull
val annotations = annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("static field init requires class scope")
return if (isDelegated) {
@ -61,7 +63,8 @@ class ClassStaticFieldInitStatement(
writeVisibility,
startPos,
isTransient = isTransient,
type = ObjRecord.Type.Delegated
type = ObjRecord.Type.Delegated,
annotations = annotations
).apply {
delegate = finalDelegate
}
@ -72,7 +75,8 @@ class ClassStaticFieldInitStatement(
visibility,
writeVisibility,
recordType = ObjRecord.Type.Delegated,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
).apply {
delegate = finalDelegate
}
@ -85,7 +89,8 @@ class ClassStaticFieldInitStatement(
visibility,
writeVisibility,
startPos,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
)
scope.addItem(
name,
@ -94,7 +99,8 @@ class ClassStaticFieldInitStatement(
visibility,
writeVisibility,
recordType = ObjRecord.Type.Field,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
)
initValue
}

View File

@ -22,13 +22,16 @@ sealed class CodeContext {
class Function(
val name: String,
val implicitThisMembers: Boolean = false,
val implicitThisTypeName: String? = null,
val implicitReceiverTypeNames: List<String> = emptyList(),
val typeParams: Set<String> = emptySet(),
val typeParamDecls: List<TypeDecl.TypeParam> = emptyList(),
/** True for static methods and top-level functions: they have no implicit `this`,
* so class-body field initializers inside them should not inherit the class name. */
val noImplicitThis: Boolean = false
): CodeContext()
): CodeContext() {
val implicitThisTypeName: String?
get() = implicitReceiverTypeNames.firstOrNull()
}
class ClassBody(val name: String, val isExtern: Boolean = false): CodeContext() {
var typeParams: Set<String> = emptySet()
var typeParamDecls: List<TypeDecl.TypeParam> = emptyList()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,129 @@
/*
* 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
import net.sergeych.lyng.bytecode.BytecodeStatement
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjString
/**
* Preserved declaration annotation evaluated at declaration-creation time.
*/
data class DeclAnnotation(
val name: String,
val positional: List<Obj> = emptyList(),
val named: Map<String, Obj> = emptyMap(),
)
/**
* Parsed declaration annotation awaiting declaration-time evaluation.
*/
data class ParsedDeclAnnotation(
val name: String,
val args: List<ParsedArgument> = emptyList(),
val tailBlockMode: Boolean = false,
val pos: Pos = Pos.builtIn,
) {
suspend fun evaluate(scope: Scope): DeclAnnotation {
val resolved = evaluateDeclAnnotationArguments(scope, args, tailBlockMode)
return DeclAnnotation(name, resolved.list, resolved.named)
}
fun toStatementAnnotation(): suspend (Scope, ObjString, Statement) -> Statement = { scope, declName, body ->
val extras = args.toArguments(scope, tailBlockMode).list
val required = listOf(declName, body)
val callArgs = if (extras.isEmpty()) required else required + extras
val fn = scope.get(name)?.value ?: scope.raiseSymbolNotFound("annotation not found: $name")
if (fn !is Statement) scope.raiseIllegalArgument("annotation must be callable, got ${fn.objClass}")
(fn.execute(scope.createChildScope(Arguments(callArgs))) as? Statement)
?: scope.raiseClassCastError("function annotation must return callable")
}
}
suspend fun Iterable<ParsedDeclAnnotation>.evaluateDeclAnnotations(scope: Scope): List<DeclAnnotation> {
val result = mutableListOf<DeclAnnotation>()
for (spec in this) {
result += spec.evaluate(scope)
}
return result
}
private suspend fun evaluateDeclAnnotationArguments(
scope: Scope,
args: List<ParsedArgument>,
tailBlockMode: Boolean,
): Arguments {
suspend fun eval(value: Obj): Obj = when (value) {
is BytecodeBodyProvider -> (value.bytecodeBody() ?: scope.raiseIllegalState("annotation argument requires bytecode body")).execute(scope)
is Statement -> BytecodeStatement.wrap(value, "@annotation", allowLocalSlots = true).execute(scope)
else -> value.callOn(scope)
}
val resolved = ArrayList<ParsedArgument>(args.size)
for (arg in args) {
resolved += arg.copy(value = eval(arg.value))
}
val positional: MutableList<Obj> = mutableListOf()
var named: MutableMap<String, Obj>? = null
var namedSeen = false
for ((idx, x) in resolved.withIndex()) {
if (x.name != null) {
if (named == null) named = linkedMapOf()
if (named.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set")
named[x.name] = x.value
namedSeen = true
continue
}
val value = x.value
if (x.isSplat) {
when {
value is ObjMap -> {
if (named == null) named = linkedMapOf()
for ((k, v) in value.map) {
if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys")
val key = k.value
if (named.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set")
named[key] = v
}
namedSeen = true
}
value is ObjList -> {
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
positional.addAll(value.list)
}
value.isInstanceOf(ObjIterable) -> {
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
val iterable = value.invokeInstanceMethod(scope, "toList") as ObjList
positional.addAll(iterable.list)
}
else -> scope.raiseClassCastError("expected list of objects for splat argument")
}
} else {
val isLast = idx == resolved.size - 1
if (namedSeen && !(isLast && tailBlockMode)) {
scope.raiseIllegalArgument("positional argument cannot follow named arguments")
}
positional.add(value)
}
}
return Arguments(positional, tailBlockMode, named ?: emptyMap())
}

View File

@ -24,6 +24,8 @@ class ExtensionPropertyDeclStatement(
val property: ObjProperty,
val visibility: Visibility,
val setterVisibility: Visibility?,
val getterTypeDecl: TypeDecl?,
val setterTypeDecl: TypeDecl?,
private val startPos: Pos,
) : Statement() {
override val pos: Pos = startPos

View File

@ -33,6 +33,7 @@ class InstanceFieldInitStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val isLateInitVal: Boolean,
val initializer: Statement?,
override val pos: Pos,
@ -50,7 +51,8 @@ class InstanceFieldInitStatement(
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
)
return ObjVoid
}
@ -74,6 +76,7 @@ class InstancePropertyInitStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val prop: ObjProperty,
override val pos: Pos,
) : Statement() {
@ -88,7 +91,8 @@ class InstancePropertyInitStatement(
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
)
return ObjVoid
}
@ -104,6 +108,7 @@ class InstanceDelegatedInitStatement(
val isClosed: Boolean,
val isOverride: Boolean,
val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val accessTypeLabel: String,
val initializer: Statement,
override val pos: Pos,
@ -130,7 +135,8 @@ class InstanceDelegatedInitStatement(
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride,
isTransient = isTransient
isTransient = isTransient,
annotations = annotations
).apply {
delegate = finalDelegate
}

View File

@ -99,6 +99,20 @@ open class Scope(
extensions.getOrPut(cls) { mutableMapOf() }[name] = record
}
private fun extensionContextReceiversSatisfied(record: ObjRecord): Boolean {
val fnType = record.typeDecl as? TypeDecl.Function ?: return true
if (fnType.contextReceivers.isEmpty()) return true
return fnType.contextReceivers.all { required ->
thisVariants.any { variant ->
when (required) {
is TypeDecl.Simple -> variant.isInstanceOf(required.name.substringAfterLast('.'))
is TypeDecl.Generic -> variant.isInstanceOf(required.name.substringAfterLast('.'))
else -> false
}
}
}
}
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
var s: Scope? = this
var hops = 0
@ -106,7 +120,9 @@ open class Scope(
// Proximity rule: check all extensions in the current scope before going to parent.
// Priority within scope: more specific class in MRO wins.
for (cls in receiverClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
s.extensions[cls]?.get(name)?.let {
if (extensionContextReceiversSatisfied(it)) return it
}
}
if (s is BytecodeClosureScope) {
s.closureScope.findExtension(receiverClass, name)?.let { return it }
@ -718,6 +734,7 @@ open class Scope(
isTransient: Boolean = false,
callSignature: CallSignature? = null,
typeDecl: TypeDecl? = null,
annotations: List<DeclAnnotation> = emptyList(),
fieldId: Int? = null,
methodId: Int? = null
): ObjRecord {
@ -731,6 +748,7 @@ open class Scope(
isTransient = isTransient,
callSignature = callSignature,
typeDecl = typeDecl,
annotations = annotations,
memberName = name,
fieldId = fieldId,
methodId = methodId

View File

@ -27,6 +27,8 @@ import net.sergeych.lyng.bytecode.CmdVm
import net.sergeych.lyng.bytecode.BytecodeLambdaCallable
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.serialization.ObjJsonClass
import net.sergeych.lyng.serialization.bindSerializationFormat
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.stdlib_included.complexLyng
import net.sergeych.lyng.stdlib_included.decimalLyng
@ -949,11 +951,13 @@ class Script(
)
}
addPackage("lyng.serialization") {
it.addConstDoc(
name = "Lynon",
value = ObjLynonClass,
doc = "Lynon serialization utilities: encode/decode data structures to a portable binary/text form.",
type = type("lyng.Class")
it.bindSerializationFormat(
ObjLynonClass,
doc = "Lynon serialization utilities: encode/decode data structures to a portable binary format."
)
it.bindSerializationFormat(
ObjJsonClass,
doc = "Universal JSON serialization utilities with bidirectional Lyng object support."
)
}
addPackage("lyng.time") {

View File

@ -26,6 +26,7 @@ sealed class TypeDecl(val isNullable:Boolean = false) {
// ??
data class Function(
val receiver: TypeDecl?,
val contextReceivers: List<TypeDecl> = emptyList(),
val params: List<TypeDecl>,
val returnType: TypeDecl,
val nullable: Boolean = false

View File

@ -43,6 +43,7 @@ class BytecodeCompiler(
private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
private val callSignatureByName: Map<String, CallSignature> = emptyMap(),
private val extensionContextReceiversByWrapperName: Map<String, List<String>> = emptyMap(),
private val externCallableNames: Set<String> = emptySet(),
private val externBindingNames: Set<String> = emptySet(),
private val preparedModuleBindingNames: Set<String> = emptySet(),
@ -1146,9 +1147,29 @@ class BytecodeCompiler(
}
private fun compileUnary(ref: UnaryOpRef): CompiledValue? {
return when (unaryOp(ref)) {
UnaryOp.POSITIVE -> {
val operandRef = unaryOperand(ref)
if (hasUnaryCallable(operandRef, "unaryPlus")) {
return compileMethodCall(MethodCallRef(operandRef, "unaryPlus", emptyList(), false, false))
}
val a = compileRef(operandRef) ?: return null
return when (a.type) {
SlotType.INT, SlotType.REAL -> a
else -> {
val obj = ensureObjSlot(a)
val out = allocSlot()
builder.emit(Opcode.POS_OBJ, obj.slot, out)
updateSlotType(out, SlotType.OBJ)
slotObjClass[obj.slot]?.let { slotObjClass[out] = it }
CompiledValue(out, SlotType.OBJ)
}
}
}
else -> {
val a = compileRef(unaryOperand(ref)) ?: return null
val out = allocSlot()
return when (unaryOp(ref)) {
when (unaryOp(ref)) {
UnaryOp.NEGATE -> when (a.type) {
SlotType.INT -> {
builder.emit(Opcode.NEG_INT, a.slot, out)
@ -1186,16 +1207,35 @@ class BytecodeCompiler(
}
return compileObjUnaryOp(unaryOperand(ref), a, "bitNot", Pos.builtIn)
}
UnaryOp.POSITIVE -> error("unreachable")
}
}
}
}
private fun hasUnaryCallable(ref: ObjRef, memberName: String): Boolean {
val receiverClass = resolveReceiverClass(ref) ?: return false
if (receiverClass == ObjDynamic.type) return false
if (receiverClass is ObjInstanceClass && !isThisReceiver(ref)) return true
val resolvedMember = receiverClass.resolveInstanceMember(memberName)
if (resolvedMember?.declaringClass?.className == "Obj") return false
val abstractRecord = receiverClass.members[memberName] ?: receiverClass.classScope?.objects?.get(memberName)
if (abstractRecord?.isAbstract == true) return false
val methodId = receiverClass.instanceMethodIdMap(includeAbstract = true)[memberName]
if (methodId != null && resolvedMember?.declaringClass?.className != "Obj") return true
val fieldId = if (resolvedMember != null) receiverClass.instanceFieldIdMap()[memberName] else null
if (fieldId != null) return true
return resolveExtensionCallableSlot(receiverClass, memberName) != null
}
private fun compileObjUnaryOp(
ref: ObjRef,
value: CompiledValue,
memberName: String,
pos: Pos
pos: Pos,
defaultIdentity: Boolean = false
): CompiledValue? {
val receiverClass = resolveReceiverClass(ref)
val receiverClass = resolveReceiverClass(ref) ?: slotObjClass[value.slot]
val methodId = receiverClass?.instanceMethodIdMap(includeAbstract = true)?.get(memberName)
if (methodId != null) {
val receiverObj = ensureObjSlot(value)
@ -1204,6 +1244,19 @@ class BytecodeCompiler(
updateSlotType(dst, SlotType.OBJ)
return CompiledValue(dst, SlotType.OBJ)
}
val extSlot = when {
receiverClass != null -> resolveExtensionCallableSlot(receiverClass, memberName)
else -> resolveUniqueExtensionWrapperSlot(memberName, "__ext__")
}
if (extSlot != null) {
val callee = ensureObjSlot(extSlot)
val args = compileCallArgsWithReceiver(value, emptyList(), false) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
val dst = allocSlot()
setPos(pos)
emitCallCompiled(callee, args.base, encodedCount, dst)
return CompiledValue(dst, SlotType.OBJ)
}
if (memberName == "negate" &&
(receiverClass == null || isDelegateClass(receiverClass) || receiverClass in setOf(ObjInt.type, ObjReal.type))
) {
@ -1217,6 +1270,9 @@ class BytecodeCompiler(
updateSlotType(dst, SlotType.OBJ)
return CompiledValue(dst, SlotType.OBJ)
}
if (defaultIdentity) {
return value
}
throw BytecodeCompileException(
"Unknown member $memberName on ${receiverClass?.className ?: "unknown"}",
pos
@ -4473,6 +4529,20 @@ class BytecodeCompiler(
}
private fun compileElvis(ref: ElvisRef): CompiledValue? {
fun isAbruptControlRef(candidate: ObjRef): Boolean {
var stmt = (candidate as? StatementRef)?.statement ?: return false
while (stmt is BytecodeStatement) {
stmt = stmt.original
}
return when (stmt) {
is net.sergeych.lyng.BreakStatement,
is net.sergeych.lyng.ContinueStatement,
is net.sergeych.lyng.ReturnStatement,
is net.sergeych.lyng.ThrowStatement -> true
else -> false
}
}
val leftValue = compileRefWithFallback(ref.left, null, Pos.builtIn) ?: return null
val leftObj = ensureObjSlot(leftValue)
val resultSlot = allocSlot()
@ -4489,9 +4559,12 @@ class BytecodeCompiler(
emitMove(leftObj, resultSlot)
builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel)))
builder.mark(rightLabel)
val rightIsAbruptControl = isAbruptControlRef(ref.right)
val rightValue = compileRefWithFallback(ref.right, null, Pos.builtIn) ?: return null
if (!rightIsAbruptControl) {
val rightObj = ensureObjSlot(rightValue)
emitMove(rightObj, resultSlot)
}
builder.mark(endLabel)
updateSlotType(resultSlot, SlotType.OBJ)
return CompiledValue(resultSlot, SlotType.OBJ)
@ -5955,6 +6028,7 @@ class BytecodeCompiler(
): String? {
for (receiverName in extensionReceiverTypeNames(receiverClass)) {
val candidate = wrapperName(receiverName, memberName)
if (!extensionContextReceiversSatisfied(candidate)) continue
if (allowedScopeNames != null &&
!allowedScopeNames.contains(candidate) &&
!localSlotIndexByName.containsKey(candidate)
@ -5966,6 +6040,31 @@ class BytecodeCompiler(
return null
}
private fun currentImplicitReceiverTypeNames(): List<String> {
val result = mutableListOf<String>()
inlineThisBindings.asReversed().forEach { binding ->
val typeName = binding.typeName ?: return@forEach
if (!result.contains(typeName)) result += typeName
}
implicitThisTypeName?.let {
if (!result.contains(it)) result += it
}
return result
}
private fun extensionContextReceiversSatisfied(wrapperName: String): Boolean {
val required = extensionContextReceiversByWrapperName[wrapperName].orEmpty()
if (required.isEmpty()) return true
val visible = currentImplicitReceiverTypeNames()
return required.all { req ->
visible.any { visibleName ->
visibleName == req || resolveTypeNameClass(visibleName)?.let { cls ->
cls.className == req || cls.mro.any { it.className == req }
} == true
}
}
}
private fun resolveUniqueExtensionWrapperName(
memberName: String,
wrapperPrefix: String
@ -5974,12 +6073,12 @@ class BytecodeCompiler(
val candidates = LinkedHashSet<String>()
for (name in localSlotIndexByName.keys) {
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
candidates.add(name)
if (extensionContextReceiversSatisfied(name)) candidates.add(name)
}
}
for (name in scopeSlotIndexByName.keys) {
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
candidates.add(name)
if (extensionContextReceiversSatisfied(name)) candidates.add(name)
}
}
return candidates.singleOrNull()
@ -6360,7 +6459,8 @@ class BytecodeCompiler(
isMutable = stmt.isMutable,
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient
isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs
)
)
} else {
@ -6370,7 +6470,8 @@ class BytecodeCompiler(
isMutable = stmt.isMutable,
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient
isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs
)
)
}
@ -6397,6 +6498,7 @@ class BytecodeCompiler(
writeVisibility = stmt.writeVisibility,
typeDecl = stmt.typeDecl,
isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride,
@ -6419,6 +6521,7 @@ class BytecodeCompiler(
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride,
@ -6442,6 +6545,7 @@ class BytecodeCompiler(
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride,
@ -6475,6 +6579,7 @@ class BytecodeCompiler(
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride
@ -6497,6 +6602,7 @@ class BytecodeCompiler(
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride
@ -6517,6 +6623,7 @@ class BytecodeCompiler(
visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed,
isOverride = stmt.isOverride,
@ -7995,7 +8102,9 @@ class BytecodeCompiler(
stmt.extTypeName,
stmt.property,
stmt.visibility,
stmt.setterVisibility
stmt.setterVisibility,
stmt.getterTypeDecl,
stmt.setterTypeDecl
)
)
val slot = allocSlot()
@ -8286,6 +8395,19 @@ class BytecodeCompiler(
is ObjChar -> ObjChar.type
else -> null
}
is UnaryOpRef -> when (ref.op) {
UnaryOp.NOT -> ObjBool.type
UnaryOp.POSITIVE -> resolveReceiverClass(ref.a)
UnaryOp.NEGATE -> when (val operandClass = resolveReceiverClass(ref.a)) {
ObjInt.type -> ObjInt.type
ObjReal.type -> ObjReal.type
else -> inferMethodCallReturnClass(operandClass, "negate")
}
UnaryOp.BITNOT -> when (val operandClass = resolveReceiverClass(ref.a)) {
ObjInt.type -> ObjInt.type
else -> inferMethodCallReturnClass(operandClass, "bitNot")
}
}
is CastRef -> resolveTypeRefClass(ref.castTypeRef())
?: resolveReceiverClass(ref.castValueRef())
is FieldRef -> {
@ -8636,29 +8758,73 @@ class BytecodeCompiler(
}
private fun inferCallReturnClass(ref: CallRef): ObjClass? {
fun exactLambdaReturnClass(slot: Int): ObjClass? =
exactLambdaRefBySlot[slot]?.inferredReturnClass
fun callableReturnClassFromSlot(slot: Int): ObjClass? {
exactLambdaReturnClass(slot)?.let { return it }
typeDeclForSlot(slot)?.let { decl ->
val functionDecl = decl as? TypeDecl.Function
if (functionDecl != null) {
resolveClassFromTypeDecl(functionDecl.returnType)?.let { return it }
}
}
return null
}
fun callableResultClassOrNull(
directReturnClass: ObjClass?,
directTypeDecl: TypeDecl?,
nameClass: ObjClass?,
typeNameFallback: String?
): ObjClass? {
if (directReturnClass != null) return directReturnClass
if (directTypeDecl is TypeDecl.Function) {
return null
}
if (nameClass == ObjClassType) {
return typeNameFallback?.let { resolveTypeNameClass(it) } ?: ObjDynamic.type
}
if (nameClass == Statement.type) {
return null
}
return nameClass ?: typeNameFallback?.let { resolveTypeNameClass(it) }
}
return when (val target = ref.target) {
is LocalSlotRef -> {
callableReturnTypeByScopeId[target.scopeId]?.get(target.slot)
?: run {
val nameClass = nameObjClass[target.name]
if (nameClass == ObjClassType) {
resolveTypeNameClass(target.name) ?: ObjDynamic.type
} else {
nameClass ?: resolveTypeNameClass(target.name)
}
}
val mappedSlot = resolveLocalSlotByRefOrName(target)
callableResultClassOrNull(
directReturnClass = mappedSlot?.let { callableReturnClassFromSlot(it) }
?: exactLambdaRefByScopeId[target.scopeId]?.get(target.slot)?.inferredReturnClass
?: callableReturnTypeByScopeId[target.scopeId]?.get(target.slot),
directTypeDecl = mappedSlot?.let { typeDeclForSlot(it) }
?: slotTypeDeclByScopeId[target.scopeId]?.get(target.slot),
nameClass = nameObjClass[target.name],
typeNameFallback = target.name
)
}
is LocalVarRef -> {
callableReturnTypeByName[target.name]
?: run {
val nameClass = nameObjClass[target.name]
if (nameClass == ObjClassType) {
resolveTypeNameClass(target.name) ?: ObjDynamic.type
} else {
nameClass ?: resolveTypeNameClass(target.name)
}
val directSlot = resolveDirectNameSlot(target.name)?.slot
callableResultClassOrNull(
directReturnClass = directSlot?.let { callableReturnClassFromSlot(it) }
?: callableReturnTypeByName[target.name],
directTypeDecl = directSlot?.let { typeDeclForSlot(it) },
nameClass = nameObjClass[target.name],
typeNameFallback = target.name
)
}
is FastLocalVarRef -> {
val directSlot = resolveDirectNameSlot(target.name)?.slot
callableResultClassOrNull(
directReturnClass = directSlot?.let { callableReturnClassFromSlot(it) }
?: callableReturnTypeByName[target.name],
directTypeDecl = directSlot?.let { typeDeclForSlot(it) },
nameClass = nameObjClass[target.name],
typeNameFallback = target.name
)
}
is BoundLocalVarRef -> callableReturnClassFromSlot(target.slotIndex())
is ConstRef -> target.constValue as? ObjClass
else -> null
}

View File

@ -18,6 +18,7 @@
package net.sergeych.lyng.bytecode
import net.sergeych.lyng.ArgsDeclaration
import net.sergeych.lyng.ParsedDeclAnnotation
import net.sergeych.lyng.Pos
import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.Visibility
@ -65,6 +66,8 @@ sealed class BytecodeConst {
val property: ObjProperty,
val visibility: Visibility,
val setterVisibility: Visibility?,
val getterTypeDecl: TypeDecl?,
val setterTypeDecl: TypeDecl?,
) : BytecodeConst()
data class LocalDecl(
val name: String,
@ -85,6 +88,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
) : BytecodeConst()
data class ClassDelegatedDecl(
val name: String,
@ -92,6 +96,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
) : BytecodeConst()
data class ClassInstanceInitDecl(
val initStatement: Obj,
@ -103,6 +108,7 @@ sealed class BytecodeConst {
val writeVisibility: Visibility?,
val typeDecl: TypeDecl?,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,
@ -116,6 +122,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,
@ -130,6 +137,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,
@ -143,6 +151,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,
@ -153,6 +162,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,
@ -164,6 +174,7 @@ sealed class BytecodeConst {
val visibility: Visibility,
val writeVisibility: Visibility?,
val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean,
val isClosed: Boolean,
val isOverride: Boolean,

View File

@ -107,6 +107,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
callSignatureByName: Map<String, CallSignature> = emptyMap(),
extensionContextReceiversByWrapperName: Map<String, List<String>> = emptyMap(),
externCallableNames: Set<String> = emptySet(),
externBindingNames: Set<String> = emptySet(),
preparedModuleBindingNames: Set<String> = emptySet(),
@ -148,6 +149,7 @@ class BytecodeStatement private constructor(
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
callableReturnTypeByName = callableReturnTypeByName,
callSignatureByName = callSignatureByName,
extensionContextReceiversByWrapperName = extensionContextReceiversByWrapperName,
externCallableNames = externCallableNames,
externBindingNames = externBindingNames,
preparedModuleBindingNames = preparedModuleBindingNames,
@ -365,6 +367,7 @@ class BytecodeStatement private constructor(
stmt.initializer?.let { unwrapDeep(it) },
stmt.isDelegated,
stmt.isTransient,
stmt.annotationSpecs,
stmt.pos
)
}
@ -385,6 +388,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotationSpecs,
stmt.fieldId,
stmt.initStatement?.let { unwrapDeep(it) },
stmt.pos
@ -400,6 +404,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotationSpecs,
stmt.prop,
stmt.methodId,
stmt.initStatement?.let { unwrapDeep(it) },
@ -416,6 +421,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotationSpecs,
stmt.methodId,
stmt.initStatement?.let { unwrapDeep(it) },
stmt.pos
@ -431,6 +437,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotations,
stmt.isLateInitVal,
stmt.initializer?.let { unwrapDeep(it) },
stmt.pos
@ -446,6 +453,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotations,
stmt.prop,
stmt.pos
)
@ -461,6 +469,7 @@ class BytecodeStatement private constructor(
stmt.isClosed,
stmt.isOverride,
stmt.isTransient,
stmt.annotations,
stmt.accessTypeLabel,
unwrapDeep(stmt.initializer),
stmt.pos

View File

@ -143,7 +143,7 @@ class CmdBuilder {
Opcode.UNBOX_INT_OBJ, Opcode.UNBOX_REAL_OBJ,
Opcode.INT_TO_REAL, Opcode.REAL_TO_INT, Opcode.BOOL_TO_INT, Opcode.INT_TO_BOOL,
Opcode.OBJ_TO_BOOL, Opcode.GET_OBJ_CLASS,
Opcode.NEG_INT, Opcode.NEG_REAL, Opcode.NOT_BOOL, Opcode.INV_INT,
Opcode.NEG_INT, Opcode.NEG_REAL, Opcode.NOT_BOOL, Opcode.INV_INT, Opcode.POS_OBJ,
Opcode.ASSERT_IS ->
listOf(OperandKind.SLOT, OperandKind.SLOT)
Opcode.CHECK_IS, Opcode.MAKE_QUALIFIED_VIEW ->
@ -698,6 +698,7 @@ class CmdBuilder {
} else {
CmdNotBool(operands[0], operands[1])
}
Opcode.POS_OBJ -> CmdPosObj(operands[0], operands[1])
Opcode.AND_BOOL -> if (isFastLocal(operands[0]) && isFastLocal(operands[1]) && isFastLocal(operands[2])) {
CmdAndBoolLocal(operands[0] - scopeSlotCount, operands[1] - scopeSlotCount, operands[2] - scopeSlotCount)
} else {

View File

@ -450,6 +450,7 @@ object CmdDisassembler {
is CmdMulObj -> Opcode.MUL_OBJ to intArrayOf(cmd.a, cmd.b, cmd.dst)
is CmdDivObj -> Opcode.DIV_OBJ to intArrayOf(cmd.a, cmd.b, cmd.dst)
is CmdModObj -> Opcode.MOD_OBJ to intArrayOf(cmd.a, cmd.b, cmd.dst)
is CmdPosObj -> Opcode.POS_OBJ to intArrayOf(cmd.a, cmd.dst)
is CmdContainsObj -> Opcode.CONTAINS_OBJ to intArrayOf(cmd.target, cmd.value, cmd.dst)
is CmdAssignOpObj -> Opcode.ASSIGN_OP_OBJ to intArrayOf(cmd.opId, cmd.targetSlot, cmd.valueSlot, cmd.dst, cmd.nameId)
is CmdJmp -> Opcode.JMP to intArrayOf(cmd.target)
@ -593,6 +594,8 @@ object CmdDisassembler {
Opcode.ADD_OBJ, Opcode.SUB_OBJ, Opcode.MUL_OBJ, Opcode.DIV_OBJ, Opcode.MOD_OBJ, Opcode.CONTAINS_OBJ,
Opcode.AND_BOOL, Opcode.OR_BOOL ->
listOf(OperandKind.SLOT, OperandKind.SLOT, OperandKind.SLOT)
Opcode.POS_OBJ ->
listOf(OperandKind.SLOT, OperandKind.SLOT)
Opcode.ASSIGN_OP_OBJ ->
listOf(OperandKind.ID, OperandKind.SLOT, OperandKind.SLOT, OperandKind.SLOT, OperandKind.CONST)
Opcode.INC_INT, Opcode.DEC_INT, Opcode.RET, Opcode.ITER_PUSH, Opcode.LOAD_THIS ->

View File

@ -1942,6 +1942,14 @@ class CmdCmpGteObj(internal val a: Int, internal val b: Int, internal val dst: I
}
}
class CmdPosObj(internal val a: Int, internal val dst: Int) : Cmd() {
override suspend fun perform(frame: CmdFrame) {
val result = frame.slotToObj(a).unaryPlus(frame.ensureScope())
frame.storeObjResult(dst, result)
return
}
}
class CmdAddObj(internal val a: Int, internal val b: Int, internal val dst: Int) : Cmd() {
override suspend fun perform(frame: CmdFrame) {
val result = frame.slotToObj(a).plus(frame.ensureScope(), frame.slotToObj(b))
@ -2805,6 +2813,7 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassFieldDecl
?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId")
val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class field init requires class scope")
val value = frame.slotToObj(slot).byValueCopy()
@ -2815,7 +2824,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
decl.visibility,
decl.writeVisibility,
Pos.builtIn,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = annotations
)
scope.addItem(
decl.name,
@ -2824,7 +2834,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
decl.visibility,
decl.writeVisibility,
recordType = ObjRecord.Type.Field,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = annotations
)
return
}
@ -2835,6 +2846,7 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl
?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId")
val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class delegated init requires class scope")
val initValue = frame.slotToObj(slot)
@ -2857,7 +2869,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
decl.writeVisibility,
Pos.builtIn,
isTransient = decl.isTransient,
type = ObjRecord.Type.Delegated
type = ObjRecord.Type.Delegated,
annotations = annotations
).apply {
delegate = finalDelegate
}
@ -2868,7 +2881,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
decl.visibility,
decl.writeVisibility,
recordType = ObjRecord.Type.Delegated,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = annotations
).apply {
delegate = finalDelegate
}
@ -2895,6 +2909,7 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceFieldDecl
?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId")
val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance field requires class scope")
cls.createField(
@ -2911,7 +2926,8 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In
isTransient = decl.isTransient,
typeDecl = decl.typeDecl,
type = ObjRecord.Type.Field,
fieldId = decl.fieldId
fieldId = decl.fieldId,
annotations = annotations
)
if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it }
@ -2926,6 +2942,7 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot:
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstancePropertyDecl
?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId")
val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance property requires class scope")
cls.addProperty(
@ -2938,7 +2955,8 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot:
isOverride = decl.isOverride,
pos = decl.pos,
prop = decl.prop,
methodId = decl.methodId
methodId = decl.methodId,
annotations = annotations
)
if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it }
@ -2953,6 +2971,7 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceDelegatedDecl
?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId")
val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance delegated requires class scope")
cls.createField(
@ -2968,7 +2987,8 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot
isOverride = decl.isOverride,
isTransient = decl.isTransient,
type = ObjRecord.Type.Delegated,
methodId = decl.methodId
methodId = decl.methodId,
annotations = annotations
)
if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it }
@ -2994,7 +3014,8 @@ class CmdDeclInstanceField(internal val constId: Int, internal val slot: Int) :
isAbstract = decl.isAbstract,
isClosed = decl.isClosed,
isOverride = decl.isOverride,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = decl.annotations
)
if (slot >= frame.fn.scopeSlotCount) {
val localIndex = slot - frame.fn.scopeSlotCount
@ -3023,7 +3044,8 @@ class CmdDeclInstanceProperty(internal val constId: Int, internal val slot: Int)
isAbstract = decl.isAbstract,
isClosed = decl.isClosed,
isOverride = decl.isOverride,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = decl.annotations
)
if (slot >= frame.fn.scopeSlotCount) {
val localIndex = slot - frame.fn.scopeSlotCount
@ -3076,7 +3098,8 @@ class CmdDeclInstanceDelegated(internal val constId: Int, internal val slot: Int
isAbstract = decl.isAbstract,
isClosed = decl.isClosed,
isOverride = decl.isOverride,
isTransient = decl.isTransient
isTransient = decl.isTransient,
annotations = decl.annotations
).apply {
delegate = finalDelegate
}
@ -3253,7 +3276,14 @@ class CmdDeclExtProperty(internal val constId: Int, internal val slot: Int) : Cm
)
val getterName = extensionPropertyGetterName(decl.extTypeName, decl.property.name)
val getterWrapper = ObjExtensionPropertyGetterCallable(decl.property.name, decl.property)
frame.ensureScope().addItem(getterName, false, getterWrapper, decl.visibility, recordType = ObjRecord.Type.Fun)
frame.ensureScope().addItem(
getterName,
false,
getterWrapper,
decl.visibility,
recordType = ObjRecord.Type.Fun,
typeDecl = decl.getterTypeDecl
)
val getterLocal = resolveLocalSlotIndex(frame.fn, getterName, preferCapture = false)
if (getterLocal != null) {
frame.setObjUnchecked(frame.fn.scopeSlotCount + getterLocal, getterWrapper)
@ -3262,7 +3292,14 @@ class CmdDeclExtProperty(internal val constId: Int, internal val slot: Int) : Cm
val setterName = extensionPropertySetterName(decl.extTypeName, decl.property.name)
val setterWrapper = ObjExtensionPropertySetterCallable(decl.property.name, decl.property)
frame.ensureScope()
.addItem(setterName, false, setterWrapper, decl.visibility, recordType = ObjRecord.Type.Fun)
.addItem(
setterName,
false,
setterWrapper,
decl.visibility,
recordType = ObjRecord.Type.Fun,
typeDecl = decl.setterTypeDecl
)
val setterLocal = resolveLocalSlotIndex(frame.fn, setterName, preferCapture = false)
if (setterLocal != null) {
frame.setObjUnchecked(frame.fn.scopeSlotCount + setterLocal, setterWrapper)
@ -3705,9 +3742,26 @@ class CmdGetClassScope(
decl = declared
break
}
val resolved = rec ?: scope.raiseSymbolNotFound(name)
val resolvedRec = if (rec != null) {
val declClass = decl ?: cls
val resolvedRec = cls.resolveRecord(scope, resolved, name, declClass)
cls.resolveRecord(scope, rec, name, declClass)
} else {
val metaRec = cls.objClass.getInstanceMemberOrNull(name)
if (metaRec == null || metaRec.isAbstract) {
scope.raiseSymbolNotFound(name)
}
val declClass = metaRec.declaringClass ?: cls.objClass
val resolved = cls.resolveRecord(scope, metaRec, name, declClass)
if (resolved.type == ObjRecord.Type.Fun) {
resolved.copy(
value = ObjExternCallable.fromBridge {
resolved.value.invoke(scope, cls, args, declClass)
}
)
} else {
resolved
}
}
val value = resolvedRec.value
frame.storeObjResult(dst, value)
return
@ -4130,6 +4184,15 @@ class BytecodeLambdaCallable(
val context = callScope.applyClosureForBytecode(closureScope, preferredThisType).also {
it.args = args
}
preferredThisType?.let { typeName ->
val receiverArg = args.list.firstOrNull { arg ->
arg.isInstanceOf(typeName) ||
((context[typeName]?.value as? ObjClass)?.let { typeClass -> arg.isInstanceOf(typeClass) } == true)
}
if (receiverArg != null && context.thisVariants.firstOrNull() !== receiverArg) {
context.setThisVariants(receiverArg, context.thisVariants)
}
}
if (captureRecords != null) {
context.captureRecords = captureRecords
context.captureNames = captureNames

View File

@ -144,6 +144,7 @@ enum class Opcode(val code: Int) {
MOD_OBJ(0x7B),
CONTAINS_OBJ(0x7C),
ASSIGN_OP_OBJ(0x7D),
POS_OBJ(0x7E),
JMP(0x80),
JMP_IF_TRUE(0x81),

View File

@ -24,6 +24,9 @@ import net.sergeych.lyng.parseLyng
/** Extension that converts a [Pos] (line/column) into absolute character offset in the [Source] text. */
fun Source.offsetOf(pos: Pos): Int {
if (lines.isEmpty()) return 0
if (pos.line < 0) return 0
if (pos.line >= lines.size) return text.length
var off = 0
// Sum full preceding lines + one '\n' per line (lines[] were created by String.lines())
var i = 0
@ -31,8 +34,8 @@ fun Source.offsetOf(pos: Pos): Int {
off += lines[i].length + 1 // assume \n as separator
i++
}
off += pos.column
return off
off += pos.column.coerceIn(0, lines[pos.line].length)
return off.coerceAtMost(text.length)
}
private val reservedIdKeywords = setOf("constructor", "property")
@ -114,6 +117,28 @@ private fun mergeAdjacent(spans: List<HighlightSpan>): List<HighlightSpan> {
return out
}
/**
* The parser expands interpolated strings into expression-like token streams whose
* synthetic tokens share source positions with the original string. Keep the
* widest source span at each offset and drop nested overlaps so renderers can
* rely on the public non-overlapping span contract.
*/
private fun removeOverlappingSpans(spans: List<HighlightSpan>): List<HighlightSpan> {
if (spans.size < 2) return spans
val sorted = spans.sortedWith(
compareBy<HighlightSpan> { it.range.start }
.thenByDescending { it.range.endExclusive - it.range.start }
)
val out = ArrayList<HighlightSpan>(sorted.size)
var coveredUntil = -1
for (span in sorted) {
if (span.range.start < coveredUntil) continue
out += span
coveredUntil = span.range.endExclusive
}
return out
}
/** Simple highlighter using the existing Lyng lexer (no incremental support yet). */
class SimpleLyngHighlighter : LyngHighlighter {
override fun highlight(text: String): List<HighlightSpan> {
@ -167,8 +192,8 @@ class SimpleLyngHighlighter : LyngHighlighter {
val overridden = applyEnumConstantHeuristics(text, src, tokens, raw)
// Adjust single-line comment spans to extend till EOL to compensate for lexer offset/length quirks
val adjusted = extendSingleLineCommentsToEol(text, overridden)
// Spans are in order; merge adjacent of the same kind for compactness
return mergeAdjacent(adjusted)
// Normalize spans, then merge adjacent spans of the same kind for compactness.
return mergeAdjacent(removeOverlappingSpans(adjusted))
}
}

View File

@ -36,6 +36,7 @@ data class TypeNameDoc(val segments: List<String>, override val nullable: Boolea
data class TypeGenericDoc(val base: TypeNameDoc, val args: List<TypeDoc>, override val nullable: Boolean = false) : TypeDoc
data class TypeFunctionDoc(
val receiver: TypeDoc? = null,
val contextReceivers: List<TypeDoc> = emptyList(),
val params: List<TypeDoc>,
val returns: TypeDoc,
override val nullable: Boolean = false
@ -45,8 +46,13 @@ data class TypeVarDoc(val name: String, override val nullable: Boolean = false)
// Convenience builders
fun type(name: String, nullable: Boolean = false) = TypeNameDoc(name.split('.'), nullable)
fun typeVar(name: String, nullable: Boolean = false) = TypeVarDoc(name, nullable)
fun funType(params: List<TypeDoc>, returns: TypeDoc, receiver: TypeDoc? = null, nullable: Boolean = false) =
TypeFunctionDoc(receiver, params, returns, nullable)
fun funType(
params: List<TypeDoc>,
returns: TypeDoc,
receiver: TypeDoc? = null,
contextReceivers: List<TypeDoc> = emptyList(),
nullable: Boolean = false
) = TypeFunctionDoc(receiver, contextReceivers, params, returns, nullable)
// ---------------- Registry ----------------
@ -281,6 +287,7 @@ internal fun TypeDoc.toMiniTypeRef(): MiniTypeRef = when (this) {
is TypeFunctionDoc -> MiniFunctionType(
range = builtinRange(),
receiver = this.receiver?.toMiniTypeRef(),
contextReceivers = this.contextReceivers.map { it.toMiniTypeRef() },
params = this.params.map { it.toMiniTypeRef() },
returnType = this.returns.toMiniTypeRef(),
nullable = this.nullable

View File

@ -139,6 +139,7 @@ data class MiniGenericType(
data class MiniFunctionType(
override val range: MiniRange,
val receiver: MiniTypeRef?,
val contextReceivers: List<MiniTypeRef>,
val params: List<MiniTypeRef>,
val returnType: MiniTypeRef,
val nullable: Boolean

View File

@ -317,6 +317,12 @@ open class Obj {
}
}
open suspend fun unaryPlus(scope: Scope): Obj {
return invokeInstanceMethod(scope, "unaryPlus", Arguments.EMPTY) {
this
}
}
open suspend fun mul(scope: Scope, other: Obj): Obj {
val otherValue = when (other) {
is FrameSlotRef -> other.read()

View File

@ -28,6 +28,23 @@ import net.sergeych.lynon.LynonType
// Simple id generator for class identities (not thread-safe; fine for scripts)
private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ }
private fun DeclAnnotation.toObj(): Obj {
val namedArgs = linkedMapOf<Obj, Obj>()
for ((k, v) in named) {
namedArgs[ObjString(k)] = v
}
return ObjMap(
linkedMapOf(
ObjString("name") to ObjString(name),
ObjString("positional") to ObjImmutableList(positional),
ObjString("named") to ObjMap(namedArgs)
)
)
}
private fun annotationListObj(annotations: List<DeclAnnotation>): Obj =
ObjImmutableList(annotations.map { it.toObj() })
val ObjClassType by lazy {
object : ObjClass("Class") {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
@ -98,6 +115,30 @@ val ObjClassType by lazy {
val rec = cls.getInstanceMemberOrNull(name)
rec?.value ?: ObjNull
}
addFnDoc(
name = "getConstructorAnnotations",
doc = "Return preserved annotations for a constructor parameter by name as descriptor maps with fields `name`, `positional`, and `named`.",
params = listOf(ParamDoc("name", type("lyng.String"))),
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Map"))),
moduleName = "lyng.stdlib"
) {
val cls = thisAs<ObjClass>()
val name = requiredArg<ObjString>(0).value
val param = cls.constructorMeta?.params?.firstOrNull { it.name == name }
annotationListObj(param?.annotations ?: emptyList())
}
addFnDoc(
name = "getMemberAnnotations",
doc = "Return preserved annotations for a member by name as descriptor maps with fields `name`, `positional`, and `named`.",
params = listOf(ParamDoc("name", type("lyng.String"))),
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Map"))),
moduleName = "lyng.stdlib"
) {
val cls = thisAs<ObjClass>()
val name = requiredArg<ObjString>(0).value
val rec = cls.getInstanceMemberOrNull(name) ?: cls.classScope?.objects?.get(name)
annotationListObj(rec?.annotations ?: emptyList())
}
}
}
@ -119,6 +160,7 @@ open class ObjClass(
var isAbstract: Boolean = false
var isClosed: Boolean = false
var isSingletonObject: Boolean = false
var logicalPackageNameOverride: String? = null
// Stable identity and simple structural version for PICs
@ -850,6 +892,7 @@ open class ObjClass(
methodId: Int? = null,
typeDecl: net.sergeych.lyng.TypeDecl? = null,
callSignature: net.sergeych.lyng.CallSignature? = null,
annotations: List<DeclAnnotation> = emptyList(),
): ObjRecord {
// Validation of override rules: only for non-system declarations
var existing: ObjRecord? = null
@ -952,6 +995,7 @@ open class ObjClass(
type = type,
callSignature = callSignature,
typeDecl = typeDecl,
annotations = annotations,
memberName = name,
fieldId = effectiveFieldId,
methodId = effectiveMethodId
@ -978,7 +1022,8 @@ open class ObjClass(
type: ObjRecord.Type = ObjRecord.Type.Field,
fieldId: Int? = null,
methodId: Int? = null,
callSignature: net.sergeych.lyng.CallSignature? = null
callSignature: net.sergeych.lyng.CallSignature? = null,
annotations: List<DeclAnnotation> = emptyList()
): ObjRecord {
initClassScope()
val existing = classScope!!.objects[name]
@ -1020,6 +1065,7 @@ open class ObjClass(
recordType = type,
isTransient = isTransient,
callSignature = callSignature,
annotations = annotations,
fieldId = effectiveFieldId,
methodId = effectiveMethodId
)
@ -1066,7 +1112,8 @@ open class ObjClass(
isOverride: Boolean = false,
pos: Pos = Pos.builtIn,
prop: ObjProperty? = null,
methodId: Int? = null
methodId: Int? = null,
annotations: List<DeclAnnotation> = emptyList()
) {
val g = getter?.let { ObjExternCallable.fromBridge { it() } }
val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } }
@ -1075,7 +1122,8 @@ open class ObjClass(
name, finalProp, false, visibility, writeVisibility, pos, declaringClass,
isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride,
type = ObjRecord.Type.Property,
methodId = methodId
methodId = methodId,
annotations = annotations
)
}

View File

@ -16,6 +16,7 @@
*/
package net.sergeych.lyng.obj
import net.sergeych.lyng.DeclAnnotation
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Visibility
@ -40,6 +41,7 @@ data class ObjRecord(
var receiver: Obj? = null,
val callSignature: net.sergeych.lyng.CallSignature? = null,
val typeDecl: net.sergeych.lyng.TypeDecl? = null,
val annotations: List<DeclAnnotation> = emptyList(),
val memberName: String? = null,
val fieldId: Int? = null,
val methodId: Int? = null,

View File

@ -73,7 +73,7 @@ class ClassOperatorRef(val target: ObjRef, val pos: Pos) : ObjRef {
}
/** Unary operations supported by ObjRef. */
enum class UnaryOp { NOT, NEGATE, BITNOT }
enum class UnaryOp { NOT, POSITIVE, NEGATE, BITNOT }
/** Binary operations supported by ObjRef. */
enum class BinOp {

View File

@ -206,7 +206,17 @@ private fun typeDeclKey(type: TypeDecl): String = when (type) {
TypeDecl.TypeNullableAny -> "Any?"
is TypeDecl.Simple -> "S:${type.name}"
is TypeDecl.Generic -> "G:${type.name}<${type.args.joinToString(",") { typeDeclKey(it) }}>"
is TypeDecl.Function -> "F:(${type.params.joinToString(",") { typeDeclKey(it) }})->${typeDeclKey(type.returnType)}"
is TypeDecl.Function -> buildString {
append("F:")
type.receiver?.let { append("recv=").append(typeDeclKey(it)).append(";") }
if (type.contextReceivers.isNotEmpty()) {
append("ctx=").append(type.contextReceivers.joinToString(",") { typeDeclKey(it) }).append(";")
}
append('(')
append(type.params.joinToString(",") { typeDeclKey(it) })
append(")->")
append(typeDeclKey(type.returnType))
}
is TypeDecl.Ellipsis -> "E:${typeDeclKey(type.elementType)}"
is TypeDecl.TypeVar -> "V:${type.name}"
is TypeDecl.Union -> "U:${type.options.joinToString("|") { typeDeclKey(it) }}"

View File

@ -0,0 +1,742 @@
/*
* 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.serialization
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope
import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBitBuffer
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjDate
import net.sergeych.lyng.obj.ObjDateTime
import net.sergeych.lyng.obj.ObjEnumClass
import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjImmutableList
import net.sergeych.lyng.obj.ObjImmutableMap
import net.sergeych.lyng.obj.ObjImmutableSet
import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstant
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjRecord
import net.sergeych.lyng.obj.ObjSet
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjTypeExpr
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.matchesTypeDecl
import net.sergeych.lyng.requireExactCount
import net.sergeych.lyng.requireScope
import net.sergeych.lynon.BitArray
import net.sergeych.mp_tools.decodeBase64Url
import net.sergeych.mp_tools.encodeToBase64Url
import kotlin.time.Instant
private const val TYPE_KEY = "@lyng"
private const val VALUE_KEY = "value"
private const val ITEMS_KEY = "items"
private const val ENTRIES_KEY = "entries"
private const val CLASS_KEY = "class"
private const val NAME_KEY = "name"
private const val ARGS_KEY = "args"
private const val VARS_KEY = "vars"
private const val BASE64_KEY = "base64"
private const val LAST_BYTE_BITS_KEY = "lastByteBits"
private const val MESSAGE_KEY = "message"
private const val EXTRA_DATA_KEY = "extraData"
private const val STACK_TRACE_KEY = "stackTrace"
object ObjJsonClass : ObjSerializationFormatClass("Json") {
init {
addClassFn("encodeAs") {
requireExactCount(2)
val targetType = typeDeclFromJsonTarget(requireScope(), args[0])
ObjString(encodeToJsonElement(requireScope(), args[1], targetType).toString())
}
addClassFn("decodeAs") {
requireExactCount(2)
val scope = requireScope()
val targetType = typeDeclFromJsonTarget(scope, args[0])
val text = when (val encoded = args[1]) {
is ObjString -> encoded.value
else -> encoded.toString(scope).value
}
decodeFromJsonElement(scope, Json.parseToJsonElement(text), targetType)
}
}
override suspend fun encodeValue(scope: Scope, value: Obj): Obj =
ObjString(encodeToJsonElement(scope, value).toString())
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj {
val text = when (encoded) {
is ObjString -> encoded.value
else -> encoded.toString(scope).value
}
return decodeFromJsonElement(scope, Json.parseToJsonElement(text))
}
suspend fun encodeToJsonElement(scope: Scope, value: Obj, expectedType: TypeDecl? = null): JsonElement =
UniversalJsonCodec.encode(scope, value, expectedType)
suspend fun decodeFromJsonElement(scope: Scope, element: JsonElement, expectedType: TypeDecl? = null): Obj =
UniversalJsonCodec.decode(scope, element, expectedType)
}
suspend fun Obj.toUniversalJsonElement(scope: Scope = Scope()): JsonElement =
ObjJsonClass.encodeToJsonElement(scope, this)
suspend fun decodeUniversalJsonElement(element: JsonElement, scope: Scope = Scope()): Obj =
ObjJsonClass.decodeFromJsonElement(scope, element)
private fun typeDeclFromJsonTarget(scope: Scope, target: Obj): TypeDecl = when (target) {
is ObjTypeExpr -> target.typeDecl
is ObjClass -> TypeDecl.Simple(target.className, false)
is ObjInstance -> TypeDecl.Simple(target.objClass.className, false)
is ObjString -> TypeDecl.Simple(target.value, false)
else -> scope.raiseClassCastError("Json.encodeAs/decodeAs expects a class or type expression")
}
private object UniversalJsonCodec {
suspend fun encode(scope: Scope, value: Obj, expectedType: TypeDecl? = null): JsonElement {
if (expectedType != null) {
encodeWithExpectedType(scope, value, expectedType)?.let { return it }
}
return encodeCanonical(scope, value)
}
suspend fun decode(scope: Scope, element: JsonElement, expectedType: TypeDecl? = null): Obj {
if (expectedType != null) {
if (element is JsonObject && TYPE_KEY in element) {
return ensureMatchesExpectedType(scope, decodeCanonical(scope, element), expectedType)
}
decodeWithExpectedType(scope, element, expectedType)?.let {
return ensureMatchesExpectedType(scope, it, expectedType)
}
}
return decodeCanonical(scope, element)
}
private suspend fun encodeCanonical(scope: Scope, value: Obj): JsonElement = when (value) {
ObjVoid -> tagged("void")
ObjNull -> JsonNull
is ObjBool -> JsonPrimitive(value.value)
is ObjInt -> JsonPrimitive(value.value)
is ObjReal -> if (value.value.isFinite()) {
JsonPrimitive(value.value)
} else {
tagged("real", VALUE_KEY to JsonPrimitive(value.value.toString()))
}
is ObjString -> JsonPrimitive(value.value)
is ObjDate -> tagged("date", VALUE_KEY to JsonPrimitive(value.date.toString()))
is ObjInstant -> tagged("instant", VALUE_KEY to JsonPrimitive(value.instant.toString()))
is ObjDateTime -> tagged("dateTime", VALUE_KEY to JsonPrimitive(value.toRFC3339()))
is ObjBuffer -> tagged("buffer", BASE64_KEY to JsonPrimitive(value.base64))
is ObjBitBuffer -> tagged(
"bitBuffer",
BASE64_KEY to JsonPrimitive(value.bitArray.asUByteArray().asByteArray().encodeToBase64Url()),
LAST_BYTE_BITS_KEY to JsonPrimitive(value.bitArray.lastByteBits)
)
is ObjImmutableList -> tagged("immutableList", ITEMS_KEY to JsonArray(value.toMutableList().map { encodeCanonical(scope, it) }))
is ObjList -> JsonArray(value.list.map { encodeCanonical(scope, it) })
is ObjImmutableSet -> tagged("immutableSet", ITEMS_KEY to JsonArray(value.toMutableSet().map { encodeCanonical(scope, it) }))
is ObjSet -> tagged("set", ITEMS_KEY to JsonArray(value.set.map { encodeCanonical(scope, it) }))
is ObjImmutableMap -> tagged("immutableMap", ENTRIES_KEY to encodeEntries(scope, value.map.entries.map { it.toPair() }))
is ObjMap -> encodeCanonicalMap(scope, value)
is ObjEnumEntry -> tagged(
"enum",
CLASS_KEY to JsonPrimitive(value.objClass.className),
NAME_KEY to JsonPrimitive(value.name.value)
)
is ObjException -> tagged(
"exception",
CLASS_KEY to JsonPrimitive(value.exceptionClass.className),
MESSAGE_KEY to encodeCanonical(scope, value.message),
EXTRA_DATA_KEY to encodeCanonical(scope, value.extraData),
STACK_TRACE_KEY to encodeCanonical(scope, value.getStackTrace())
)
is ObjClass -> tagged("class", NAME_KEY to JsonPrimitive(value.className))
is ObjInstance -> if (value.objClass.isSingletonObject) {
encodeCanonicalSingletonObject(scope, value)
} else {
encodeCanonicalInstance(scope, value)
}
else -> scope.raiseNotImplemented("Json.encode can't serialize ${value.objClass.className}")
}
private suspend fun decodeCanonical(scope: Scope, element: JsonElement): Obj = when (element) {
JsonNull -> ObjNull
is JsonPrimitive -> decodePrimitive(element)
is JsonArray -> ObjList(element.map { decodeCanonical(scope, it) }.toMutableList())
is JsonObject -> decodeCanonicalObject(scope, element)
}
private suspend fun encodeWithExpectedType(scope: Scope, value: Obj, expectedType: TypeDecl): JsonElement? {
if (value === ObjNull) return JsonNull
when (value) {
is ObjBool -> return JsonPrimitive(value.value)
is ObjInt -> return JsonPrimitive(value.value)
is ObjReal -> if (value.value.isFinite()) return JsonPrimitive(value.value)
is ObjString -> return JsonPrimitive(value.value)
is ObjDate -> if (isExpectedExactClass(scope, expectedType, value.objClass)) return JsonPrimitive(value.date.toString())
is ObjInstant -> if (isExpectedExactClass(scope, expectedType, value.objClass)) return JsonPrimitive(value.instant.toString())
is ObjDateTime -> if (isExpectedExactClass(scope, expectedType, value.objClass)) return JsonPrimitive(value.toRFC3339())
is ObjBuffer -> if (isExpectedExactClass(scope, expectedType, value.objClass)) return JsonPrimitive(value.base64)
is ObjBitBuffer -> if (isExpectedExactClass(scope, expectedType, value.objClass)) {
return JsonObject(
linkedMapOf(
BASE64_KEY to JsonPrimitive(value.bitArray.asUByteArray().asByteArray().encodeToBase64Url()),
LAST_BYTE_BITS_KEY to JsonPrimitive(value.bitArray.lastByteBits)
)
)
}
is ObjEnumEntry -> if (isExpectedExactClass(scope, expectedType, value.objClass)) {
return JsonPrimitive(value.name.value)
}
is ObjList -> if (expectedBaseName(expectedType) == "List") {
return JsonArray(value.list.map { encode(scope, it, expectedElementType(expectedType)) })
}
is ObjImmutableList -> if (expectedBaseName(expectedType) == "ImmutableList") {
return JsonArray(value.toMutableList().map { encode(scope, it, expectedElementType(expectedType)) })
}
is ObjSet -> if (expectedBaseName(expectedType) == "Set") {
return JsonArray(value.set.map { encode(scope, it, expectedElementType(expectedType)) })
}
is ObjImmutableSet -> if (expectedBaseName(expectedType) == "ImmutableSet") {
return JsonArray(value.toMutableSet().map { encode(scope, it, expectedElementType(expectedType)) })
}
is ObjMap -> if (expectedBaseName(expectedType) == "Map") {
return encodeTypedMap(scope, value.map, expectedKeyType(expectedType), expectedValueType(expectedType))
}
is ObjImmutableMap -> if (expectedBaseName(expectedType) == "ImmutableMap") {
return encodeTypedMap(scope, value.map, expectedKeyType(expectedType), expectedValueType(expectedType))
}
is ObjInstance -> if (isExpectedExactClass(scope, expectedType, value.objClass)) {
return if (value.objClass.isSingletonObject) encodeTypedSingletonObject(scope, value) else encodeTypedInstance(scope, value)
}
else -> Unit
}
return null
}
private suspend fun decodeWithExpectedType(scope: Scope, element: JsonElement, expectedType: TypeDecl): Obj? = when (element) {
JsonNull -> ObjNull
is JsonPrimitive -> decodePrimitiveWithExpectedType(scope, element, expectedType)
is JsonArray -> decodeArrayWithExpectedType(scope, element, expectedType)
is JsonObject -> decodeObjectWithExpectedType(scope, element, expectedType)
}
private suspend fun encodeCanonicalMap(scope: Scope, value: ObjMap): JsonElement {
if (value.map.keys.all { it is ObjString } && TYPE_KEY !in value.map.keys.map { (it as ObjString).value }) {
return JsonObject(
value.map.entries.associate { (k, v) ->
(k as ObjString).value to encodeCanonical(scope, v)
}
)
}
return tagged("map", ENTRIES_KEY to encodeEntries(scope, value.map.entries.map { it.toPair() }))
}
private suspend fun encodeTypedMap(
scope: Scope,
map: Map<Obj, Obj>,
keyType: TypeDecl?,
valueType: TypeDecl?
): JsonElement {
val stringKeys = keyType != null && expectedBaseName(keyType) == "String"
if (stringKeys && map.keys.all { it is ObjString } && TYPE_KEY !in map.keys.map { (it as ObjString).value }) {
return JsonObject(
map.entries.associate { (k, v) ->
(k as ObjString).value to encode(scope, v, valueType)
}
)
}
return JsonArray(
map.entries.map { (k, v) ->
JsonArray(listOf(encode(scope, k, keyType), encode(scope, v, valueType)))
}
)
}
private suspend fun encodeEntries(scope: Scope, entries: List<Pair<Obj, Obj>>): JsonArray =
JsonArray(entries.map { (k, v) -> JsonArray(listOf(encodeCanonical(scope, k), encodeCanonical(scope, v))) })
private suspend fun encodeCanonicalInstance(scope: Scope, value: ObjInstance): JsonElement {
val meta = value.objClass.constructorMeta
?: scope.raiseError("can't serialize non-serializable object (no constructor meta)")
val args = linkedMapOf<String, JsonElement>()
for (param in meta.params) {
if (!param.isTransient) {
args[param.name] = encodeCanonical(scope, value.readField(scope, param.name).value)
}
}
val vars = linkedMapOf<String, JsonElement>()
for ((key, record) in value.serializingVars) {
vars[key.substringAfterLast("::")] = encodeCanonical(scope, record.value)
}
return tagged(
"instance",
CLASS_KEY to JsonPrimitive(value.objClass.className),
ARGS_KEY to JsonObject(args),
VARS_KEY to JsonObject(vars)
)
}
private suspend fun encodeTypedInstance(scope: Scope, value: ObjInstance): JsonObject {
val meta = value.objClass.constructorMeta
?: scope.raiseError("can't serialize non-serializable object (no constructor meta)")
val fields = linkedMapOf<String, JsonElement>()
for (param in meta.params) {
if (!param.isTransient) {
fields[param.name] = encode(scope, value.readField(scope, param.name).value, param.type)
}
}
for ((key, record) in value.serializingVars) {
fields[key.substringAfterLast("::")] = encode(scope, record.value, record.typeDecl)
}
return JsonObject(fields)
}
private suspend fun encodeCanonicalSingletonObject(scope: Scope, value: ObjInstance): JsonElement {
val vars = linkedMapOf<String, JsonElement>()
for ((key, record) in value.serializingVars) {
vars[key.substringAfterLast("::")] = encodeCanonical(scope, record.value)
}
return tagged(
"object",
NAME_KEY to JsonPrimitive(value.objClass.className),
VARS_KEY to JsonObject(vars)
)
}
private suspend fun encodeTypedSingletonObject(scope: Scope, value: ObjInstance): JsonObject {
val vars = linkedMapOf<String, JsonElement>()
for ((key, record) in value.serializingVars) {
vars[key.substringAfterLast("::")] = encode(scope, record.value, record.typeDecl)
}
return JsonObject(vars)
}
private suspend fun decodeCanonicalObject(scope: Scope, element: JsonObject): Obj {
val tag = element[TYPE_KEY]?.jsonPrimitive?.content
if (tag == null) {
val map = linkedMapOf<Obj, Obj>()
for ((k, v) in element) {
map[ObjString(k)] = decodeCanonical(scope, v)
}
return ObjMap(map.toMutableMap())
}
return when (tag) {
"void" -> ObjVoid
"real" -> decodeTaggedReal(element)
"date" -> ObjDate(LocalDate.parse(requiredString(element, VALUE_KEY)))
"instant" -> ObjInstant(Instant.parse(requiredString(element, VALUE_KEY)))
"dateTime" -> ObjDateTime.type.invokeInstanceMethod(scope, "parseRFC3339", ObjString(requiredString(element, VALUE_KEY)))
"buffer" -> ObjBuffer(requiredString(element, BASE64_KEY).decodeBase64Url().asUByteArray())
"bitBuffer" -> ObjBitBuffer(
BitArray(
requiredString(element, BASE64_KEY).decodeBase64Url().asUByteArray(),
requiredInt(element, LAST_BYTE_BITS_KEY)
)
)
"immutableList" -> ObjImmutableList(requiredArray(element, ITEMS_KEY).map { decodeCanonical(scope, it) })
"set" -> ObjSet(requiredArray(element, ITEMS_KEY).map { decodeCanonical(scope, it) }.toMutableSet())
"immutableSet" -> ObjImmutableSet(requiredArray(element, ITEMS_KEY).map { decodeCanonical(scope, it) })
"map" -> decodeCanonicalMap(scope, requiredArray(element, ENTRIES_KEY), mutable = true)
"immutableMap" -> decodeCanonicalMap(scope, requiredArray(element, ENTRIES_KEY), mutable = false)
"class" -> resolveClass(scope, requiredString(element, NAME_KEY))
"enum" -> decodeCanonicalEnum(scope, element)
"instance" -> decodeCanonicalInstance(scope, element)
"object" -> decodeCanonicalSingletonObject(scope, element)
"exception" -> decodeCanonicalException(scope, element)
else -> scope.raiseIllegalArgument("unknown Json type tag '$tag'")
}
}
private fun decodePrimitive(element: JsonPrimitive): Obj {
element.booleanOrNull?.let { return ObjBool(it) }
if (element.isString) return ObjString(element.content)
val raw = element.content
return if (!raw.contains('.') && !raw.contains('e', ignoreCase = true)) {
element.longOrNull?.let { ObjInt.of(it) } ?: ObjReal(raw.toDouble())
} else {
ObjReal(element.doubleOrNull ?: raw.toDouble())
}
}
private suspend fun decodePrimitiveWithExpectedType(scope: Scope, element: JsonPrimitive, expectedType: TypeDecl): Obj? {
val expectedClass = expectedExactClass(scope, expectedType)
val baseName = expectedBaseName(expectedType)
return when {
expectedClass is ObjEnumClass && element.isString ->
expectedClass.invokeInstanceMethod(scope, "valueOf", ObjString(element.content))
baseName == "Bool" -> element.booleanOrNull?.let { ObjBool(it) }
baseName == "Int" -> if (!element.isString) element.longOrNull?.let { ObjInt.of(it) } else null
baseName == "Real" -> decodeExpectedReal(element)
baseName == "String" -> if (element.isString) ObjString(element.content) else null
baseName == "Date" && element.isString -> ObjDate(LocalDate.parse(element.content))
baseName == "Instant" && element.isString -> ObjInstant(Instant.parse(element.content))
baseName == "DateTime" && element.isString ->
ObjDateTime.type.invokeInstanceMethod(scope, "parseRFC3339", ObjString(element.content))
baseName == "Buffer" && element.isString -> ObjBuffer(element.content.decodeBase64Url().asUByteArray())
else -> decodePrimitive(element)
}
}
private fun decodeExpectedReal(element: JsonPrimitive): ObjReal? {
if (element.isString) {
return when (element.content) {
"NaN" -> ObjReal(Double.NaN)
"Infinity" -> ObjReal(Double.POSITIVE_INFINITY)
"-Infinity" -> ObjReal(Double.NEGATIVE_INFINITY)
else -> null
}
}
val raw = element.content
return ObjReal((element.doubleOrNull ?: raw.toDouble()))
}
private fun decodeTaggedReal(element: JsonObject): ObjReal {
val raw = requiredString(element, VALUE_KEY)
val value = when (raw) {
"NaN" -> Double.NaN
"Infinity" -> Double.POSITIVE_INFINITY
"-Infinity" -> Double.NEGATIVE_INFINITY
else -> raw.toDouble()
}
return ObjReal(value)
}
private suspend fun decodeArrayWithExpectedType(scope: Scope, element: JsonArray, expectedType: TypeDecl): Obj? {
val itemType = expectedElementType(expectedType)
return when (expectedBaseName(expectedType)) {
"List" -> ObjList(element.map { decode(scope, it, itemType) }.toMutableList())
"ImmutableList" -> ObjImmutableList(element.map { decode(scope, it, itemType) })
"Set" -> ObjSet(element.map { decode(scope, it, itemType) }.toMutableSet())
"ImmutableSet" -> ObjImmutableSet(element.map { decode(scope, it, itemType) })
"Map" -> decodeTypedMap(scope, element, mutable = true, keyType = expectedKeyType(expectedType), valueType = expectedValueType(expectedType))
"ImmutableMap" -> decodeTypedMap(scope, element, mutable = false, keyType = expectedKeyType(expectedType), valueType = expectedValueType(expectedType))
else -> null
}
}
private suspend fun decodeObjectWithExpectedType(scope: Scope, element: JsonObject, expectedType: TypeDecl): Obj? {
return when (expectedBaseName(expectedType)) {
"Map" -> decodeTypedMapObject(scope, element, mutable = true, valueType = expectedValueType(expectedType))
"ImmutableMap" -> decodeTypedMapObject(scope, element, mutable = false, valueType = expectedValueType(expectedType))
"BitBuffer" -> {
val base64 = element[BASE64_KEY]?.jsonPrimitive?.content ?: return null
val bits = element[LAST_BYTE_BITS_KEY]?.jsonPrimitive?.content?.toInt() ?: return null
ObjBitBuffer(BitArray(base64.decodeBase64Url().asUByteArray(), bits))
}
else -> {
val klass = expectedExactClass(scope, expectedType) ?: return null
when {
klass.isSingletonObject -> decodeTypedSingletonObject(scope, klass, element)
klass is ObjEnumClass -> null
else -> decodeTypedInstance(scope, klass, element)
}
}
}
}
private suspend fun decodeCanonicalMap(scope: Scope, entries: JsonArray, mutable: Boolean): Obj {
val pairs = entries.map { item ->
val pair = item as? JsonArray ?: scope.raiseIllegalArgument("map entry must be a JSON array")
if (pair.size != 2) scope.raiseIllegalArgument("map entry must contain exactly 2 items")
decodeCanonical(scope, pair[0]) to decodeCanonical(scope, pair[1])
}
return if (mutable) ObjMap(pairs.toMap().toMutableMap()) else ObjImmutableMap(pairs.toMap())
}
private suspend fun decodeTypedMap(
scope: Scope,
entries: JsonArray,
mutable: Boolean,
keyType: TypeDecl?,
valueType: TypeDecl?
): Obj {
val pairs = entries.map { item ->
val pair = item as? JsonArray ?: scope.raiseIllegalArgument("map entry must be a JSON array")
if (pair.size != 2) scope.raiseIllegalArgument("map entry must contain exactly 2 items")
decode(scope, pair[0], keyType) to decode(scope, pair[1], valueType)
}
return if (mutable) ObjMap(pairs.toMap().toMutableMap()) else ObjImmutableMap(pairs.toMap())
}
private suspend fun decodeTypedMapObject(
scope: Scope,
element: JsonObject,
mutable: Boolean,
valueType: TypeDecl?
): Obj {
val map = linkedMapOf<Obj, Obj>()
for ((k, v) in element) {
map[ObjString(k)] = decode(scope, v, valueType)
}
return if (mutable) ObjMap(map.toMutableMap()) else ObjImmutableMap(map)
}
private suspend fun decodeCanonicalEnum(scope: Scope, element: JsonObject): Obj {
val klass = resolveClass(scope, requiredString(element, CLASS_KEY))
if (klass !is ObjEnumClass) scope.raiseClassCastError("${klass.className} is not an enum")
return klass.invokeInstanceMethod(scope, "valueOf", ObjString(requiredString(element, NAME_KEY)))
}
private suspend fun decodeCanonicalInstance(scope: Scope, element: JsonObject): Obj {
val klass = resolveClass(scope, requiredString(element, CLASS_KEY))
return decodeCanonicalInstanceWithClass(scope, klass, requiredObject(element, ARGS_KEY), requiredObject(element, VARS_KEY))
}
private suspend fun decodeTypedInstance(scope: Scope, klass: ObjClass, element: JsonObject): Obj {
val meta = klass.constructorMeta
?: scope.raiseError("can't deserialize ${klass.className} from Json: no constructor meta")
val namedArgs = linkedMapOf<String, Obj>()
for (param in meta.params) {
if (param.isTransient) continue
val encoded = element[param.name]
if (encoded == null) {
if (param.defaultValue == null && !param.type.isNullable) {
scope.raiseIllegalArgument("missing constructor field '${param.name}' for ${klass.className}")
}
} else {
namedArgs[param.name] = decode(scope, encoded, param.type)
}
}
val callScope = scope.createChildScope(args = Arguments(list = emptyList(), named = namedArgs))
val instance = klass.callOn(callScope)
if (instance is ObjInstance) {
val ctorNames = meta.params.map { it.name }.toSet()
for ((name, encoded) in element) {
if (name in ctorNames) continue
val target = resolveSerializableVar(instance, name)
?: scope.raiseIllegalArgument("unknown serializable field '${klass.className}.$name'")
target.value = decode(scope, encoded, target.typeDecl)
}
}
return instance
}
private suspend fun decodeCanonicalInstanceWithClass(
scope: Scope,
klass: ObjClass,
argsObject: JsonObject,
varsObject: JsonObject
): Obj {
val meta = klass.constructorMeta
?: scope.raiseError("can't deserialize ${klass.className} from Json: no constructor meta")
val namedArgs = linkedMapOf<String, Obj>()
for (param in meta.params) {
if (param.isTransient) continue
val encoded = argsObject[param.name]
if (encoded == null) {
if (param.defaultValue == null && !param.type.isNullable) {
scope.raiseIllegalArgument("missing constructor field '${param.name}' for ${klass.className}")
}
} else {
namedArgs[param.name] = decodeCanonical(scope, encoded)
}
}
val callScope = scope.createChildScope(args = Arguments(list = emptyList(), named = namedArgs))
val instance = klass.callOn(callScope)
if (instance is ObjInstance) {
for ((name, encoded) in varsObject) {
val target = resolveSerializableVar(instance, name)
?: scope.raiseIllegalArgument("unknown serializable field '${klass.className}.$name'")
target.value = decodeCanonical(scope, encoded)
}
}
return instance
}
private suspend fun decodeCanonicalSingletonObject(scope: Scope, element: JsonObject): Obj {
val instance = resolveObject(scope, requiredString(element, NAME_KEY))
val varsObject = requiredObject(element, VARS_KEY)
for ((name, encoded) in varsObject) {
val target = resolveSerializableVar(instance, name)
?: scope.raiseIllegalArgument("unknown serializable field '${instance.objClass.className}.$name'")
target.value = decodeCanonical(scope, encoded)
}
return instance
}
private suspend fun decodeTypedSingletonObject(scope: Scope, klass: ObjClass, element: JsonObject): Obj {
val instance = resolveObject(scope, klass.className)
for ((name, encoded) in element) {
val target = resolveSerializableVar(instance, name)
?: scope.raiseIllegalArgument("unknown serializable field '${instance.objClass.className}.$name'")
target.value = decode(scope, encoded, target.typeDecl)
}
return instance
}
private suspend fun decodeCanonicalException(scope: Scope, element: JsonObject): Obj {
val klass = resolveClass(scope, requiredString(element, CLASS_KEY))
if (klass !is ObjException.Companion.ExceptionClass) {
scope.raiseClassCastError("${klass.className} is not an exception class")
}
val message = decodeCanonical(scope, requireElement(element, MESSAGE_KEY)) as? ObjString
?: scope.raiseClassCastError("exception message must be a string")
val extraData = decodeCanonical(scope, requireElement(element, EXTRA_DATA_KEY))
val stackTrace = decodeCanonical(scope, requireElement(element, STACK_TRACE_KEY)) as? ObjList
?: scope.raiseClassCastError("exception stackTrace must be a list")
return ObjException(klass, scope, message, extraData, stackTrace)
}
private suspend fun ensureMatchesExpectedType(scope: Scope, value: Obj, expectedType: TypeDecl): Obj {
if (!matchesTypeDecl(scope, value, expectedType)) {
scope.raiseClassCastError("decoded Json value of type ${value.objClass.className} does not match expected type ${typeName(expectedType)}")
}
return value
}
private fun resolveSerializableVar(instance: ObjInstance, name: String): ObjRecord? =
instance.serializingVars[name]
?: instance.serializingVars.entries.singleOrNull { it.key.substringAfterLast("::") == name }?.value
private suspend fun resolveClass(scope: Scope, className: String): ObjClass {
scope.get(className)?.value?.let {
if (it is ObjClass) return it
if (it is ObjInstance && it.objClass.className == className) return it.objClass
scope.raiseClassCastError("Expected class $className, got ${it.objClass.className}")
}
val resolved = scope.resolveQualifiedIdentifier(className)
if (resolved is ObjClass) return resolved
if (resolved is ObjInstance && resolved.objClass.className == className) return resolved.objClass
scope.raiseClassCastError("Expected class $className, got ${resolved.objClass.className}")
return resolved as ObjClass
}
private suspend fun resolveObject(scope: Scope, objectName: String): ObjInstance {
scope.get(objectName)?.value?.let {
if (it is ObjInstance) return it
scope.raiseClassCastError("Expected object $objectName, got ${it.objClass.className}")
}
if (objectName.contains('.')) {
val resolved = scope.resolveQualifiedIdentifier(objectName)
val inst = resolved as? ObjInstance
if (inst != null) return inst
scope.raiseClassCastError("Expected object $objectName, got ${resolved.objClass.className}")
}
scope.raiseSymbolNotFound(objectName)
}
private suspend fun expectedExactClass(scope: Scope, expectedType: TypeDecl): ObjClass? {
val nonNullable = nonNullableType(expectedType)
val className = when (nonNullable) {
is TypeDecl.Simple -> nonNullable.name
is TypeDecl.Generic -> nonNullable.name
else -> return null
}
return resolveClass(scope, className)
}
private suspend fun isExpectedExactClass(scope: Scope, expectedType: TypeDecl, actualClass: ObjClass): Boolean =
expectedExactClass(scope, expectedType) == actualClass
private fun expectedBaseName(expectedType: TypeDecl?): String? {
val nonNullable = expectedType?.let { nonNullableType(it) } ?: return null
return when (nonNullable) {
is TypeDecl.Simple -> nonNullable.name.substringAfterLast('.')
is TypeDecl.Generic -> nonNullable.name.substringAfterLast('.')
else -> null
}
}
private fun expectedTypeArgs(expectedType: TypeDecl?): List<TypeDecl> = when (val nonNullable = expectedType?.let { nonNullableType(it) }) {
is TypeDecl.Generic -> nonNullable.args
else -> emptyList()
}
private fun expectedElementType(expectedType: TypeDecl?): TypeDecl? = expectedTypeArgs(expectedType).getOrNull(0)
private fun expectedKeyType(expectedType: TypeDecl?): TypeDecl? = expectedTypeArgs(expectedType).getOrNull(0)
private fun expectedValueType(expectedType: TypeDecl?): TypeDecl? = expectedTypeArgs(expectedType).getOrNull(1)
private fun nonNullableType(type: TypeDecl): TypeDecl = when (type) {
is TypeDecl.Function -> type.copy(nullable = false)
is TypeDecl.Ellipsis -> type.copy(nullable = false)
is TypeDecl.TypeVar -> type.copy(nullable = false)
is TypeDecl.Union -> type.copy(nullable = false)
is TypeDecl.Intersection -> type.copy(nullable = false)
is TypeDecl.Simple -> TypeDecl.Simple(type.name, false)
is TypeDecl.Generic -> TypeDecl.Generic(type.name, type.args, false)
else -> type
}
private fun typeName(type: TypeDecl): String = when (type) {
TypeDecl.TypeAny -> "Any"
TypeDecl.TypeNullableAny -> "Any?"
is TypeDecl.Simple -> type.name + if (type.isNullable) "?" else ""
is TypeDecl.Generic -> buildString {
append(type.name)
append('<')
append(type.args.joinToString(",") { typeName(it) })
append('>')
if (type.isNullable) append('?')
}
is TypeDecl.Function -> "Callable"
is TypeDecl.Ellipsis -> typeName(type.elementType) + "..."
is TypeDecl.TypeVar -> type.name + if (type.isNullable) "?" else ""
is TypeDecl.Union -> type.options.joinToString(" | ") { typeName(it) }
is TypeDecl.Intersection -> type.options.joinToString(" & ") { typeName(it) }
}
private fun tagged(type: String, vararg fields: Pair<String, JsonElement>): JsonObject =
JsonObject(linkedMapOf(TYPE_KEY to JsonPrimitive(type), *fields))
private fun requiredString(element: JsonObject, key: String): String =
requireElement(element, key).jsonPrimitive.content
private fun requiredInt(element: JsonObject, key: String): Int =
requireElement(element, key).jsonPrimitive.content.toInt()
private fun requiredArray(element: JsonObject, key: String): JsonArray =
requireElement(element, key) as? JsonArray
?: throw IllegalArgumentException("field '$key' must be a JSON array")
private fun requiredObject(element: JsonObject, key: String): JsonObject =
requireElement(element, key) as? JsonObject
?: throw IllegalArgumentException("field '$key' must be a JSON object")
private fun requireElement(element: JsonObject, key: String): JsonElement =
element[key] ?: throw IllegalArgumentException("missing field '$key'")
}

View File

@ -0,0 +1,59 @@
/*
* 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.serialization
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.addConstDoc
import net.sergeych.lyng.miniast.type
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.requireOnlyArg
import net.sergeych.lyng.requireScope
abstract class ObjSerializationFormatClass(
className: String
) : ObjClass(className) {
abstract suspend fun encodeValue(scope: Scope, value: Obj): Obj
abstract suspend fun decodeValue(scope: Scope, encoded: Obj): Obj
init {
addClassFn("encode") {
encodeValue(requireScope(), requireOnlyArg())
}
addClassFn("decode") {
decodeValue(requireScope(), requireOnlyArg())
}
}
}
suspend fun ModuleScope.bindSerializationFormat(
format: ObjSerializationFormatClass,
exportName: String = format.className,
doc: String = "${format.className} serialization format."
): ObjSerializationFormatClass {
addConstDoc(
name = exportName,
value = format,
doc = doc,
type = type("lyng.Class")
)
return format
}

View File

@ -18,14 +18,13 @@
package net.sergeych.lynon
import net.sergeych.lyng.Scope
import net.sergeych.lyng.requireOnlyArg
import net.sergeych.lyng.requireScope
import net.sergeych.lyng.serialization.ObjSerializationFormatClass
import net.sergeych.lyng.obj.*
// Most often used types:
object ObjLynonClass : ObjClass("Lynon") {
object ObjLynonClass : ObjSerializationFormatClass("Lynon") {
suspend fun encodeAny(scope: Scope, obj: Obj): ObjBitBuffer {
val bout = MemoryBitOutput()
@ -41,15 +40,9 @@ object ObjLynonClass : ObjClass("Lynon") {
return deserializer.decodeAny(scope)
}
init {
addClassConst("test", ObjString("test_const"))
addClassFn("encode") {
encodeAny(requireScope(), requireOnlyArg<Obj>())
}
addClassFn("decode") {
decodeAny(requireScope(), requireOnlyArg<Obj>())
}
}
override suspend fun encodeValue(scope: Scope, value: Obj): Obj = encodeAny(scope, value)
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj = decodeAny(scope, encoded)
}
/**

View File

@ -15,17 +15,21 @@
*
*/
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import net.sergeych.lyng.eval as lyngEval
class LaunchPoolTest {
private suspend fun eval(code: String) = withTimeout(2_000L) { lyngEval(code) }
private suspend fun eval(code: String) = withContext(Dispatchers.Default) {
withTimeout(2_000L) { lyngEval(code) }
}
@Test
fun testBasicExecution() = runBlocking<Unit> {
fun testBasicExecution() = runTest {
eval("""
val pool = LaunchPool(2)
val d1 = pool.launch { 1 + 1 }
@ -37,7 +41,7 @@ class LaunchPoolTest {
}
@Test
fun testResultsCollected() = runBlocking<Unit> {
fun testResultsCollected() = runTest {
eval("""
val pool = LaunchPool(4)
val jobs = (1..10).map { n -> pool.launch { n * n } }
@ -48,7 +52,7 @@ class LaunchPoolTest {
}
@Test
fun testConcurrencyLimit() = runBlocking<Unit> {
fun testConcurrencyLimit() = runTest {
eval("""
// With maxWorkers=2, at most 2 tasks run at the same time.
val mu = Mutex()
@ -70,7 +74,7 @@ class LaunchPoolTest {
}
@Test
fun testExceptionCapturedInDeferred() = runBlocking<Unit> {
fun testExceptionCapturedInDeferred() = runTest {
eval("""
val pool = LaunchPool(2)
val good = pool.launch { 42 }
@ -83,7 +87,7 @@ class LaunchPoolTest {
}
@Test
fun testPoolContinuesAfterLambdaException() = runBlocking<Unit> {
fun testPoolContinuesAfterLambdaException() = runTest {
eval("""
val pool = LaunchPool(1)
val bad = pool.launch { throw IllegalArgumentException("fail") }
@ -96,7 +100,7 @@ class LaunchPoolTest {
}
@Test
fun testLaunchAfterCloseAndJoinThrows() = runBlocking<Unit> {
fun testLaunchAfterCloseAndJoinThrows() = runTest {
eval("""
val pool = LaunchPool(2)
pool.launch { 1 }
@ -107,7 +111,7 @@ class LaunchPoolTest {
}
@Test
fun testLaunchAfterCancelThrows() = runBlocking<Unit> {
fun testLaunchAfterCancelThrows() = runTest {
eval("""
val pool = LaunchPool(2)
pool.cancel()
@ -117,7 +121,7 @@ class LaunchPoolTest {
}
@Test
fun testCancelAndJoinWaitsForWorkers() = runBlocking<Unit> {
fun testCancelAndJoinWaitsForWorkers() = runTest {
eval("""
val pool = LaunchPool(2)
pool.launch { delay(5) }
@ -128,7 +132,7 @@ class LaunchPoolTest {
}
@Test
fun testCloseAndJoinDrainsQueue() = runBlocking<Unit> {
fun testCloseAndJoinDrainsQueue() = runTest {
eval("""
val mu = Mutex()
val results = []
@ -147,7 +151,7 @@ class LaunchPoolTest {
}
@Test
fun testBoundedQueueSuspendsProducer() = runBlocking<Unit> {
fun testBoundedQueueSuspendsProducer() = runTest {
eval("""
// queue of 2 + 1 worker; producer can only be 1 ahead of what's running
val pool = LaunchPool(1, 2)
@ -165,7 +169,7 @@ class LaunchPoolTest {
}
@Test
fun testUnlimitedQueueDefault() = runBlocking<Unit> {
fun testUnlimitedQueueDefault() = runTest {
eval("""
val pool = LaunchPool(4)
val jobs = (1..50).map { n -> pool.launch { n } }
@ -177,7 +181,7 @@ class LaunchPoolTest {
}
@Test
fun testIdempotentClose() = runBlocking<Unit> {
fun testIdempotentClose() = runTest {
eval("""
val pool = LaunchPool(2)
pool.closeAndJoin()

View File

@ -473,6 +473,29 @@ class MiniAstTest {
assertEquals("b", fn.params[1].name)
}
@Test
fun miniAst_captures_context_receiver_function_type() = runTest {
val code = """
val block: context(Html, Head) Body.()->String = { "ok" }
""".trimIndent()
val (_, sink) = compileWithMini(code)
val mini = sink.build()
assertNotNull(mini)
val vd = mini.declarations.filterIsInstance<MiniValDecl>().firstOrNull { it.name == "block" }
assertNotNull(vd)
val type = vd.type as MiniFunctionType
val receiver = type.receiver as MiniTypeName
assertEquals(listOf("Body"), receiver.segments.map { it.name })
assertEquals(2, type.contextReceivers.size)
val ctx0 = type.contextReceivers[0] as MiniTypeName
val ctx1 = type.contextReceivers[1] as MiniTypeName
assertEquals(listOf("Html"), ctx0.segments.map { it.name })
assertEquals(listOf("Head"), ctx1.segments.map { it.name })
assertTrue(type.params.isEmpty())
val ret = type.returnType as MiniTypeName
assertEquals(listOf("String"), ret.segments.map { it.name })
}
@Test
fun miniAst_captures_dokka_tags() = runTest {
val code = """

View File

@ -190,4 +190,63 @@ class ScriptImportPreparationTest {
session.cancelAndJoin()
}
}
@Test
fun importedContextReceiverExtensionIsAvailableInReceiverDsl() = runTest {
val manager = Script.defaultImportManager.copy().apply {
addTextPackages(
"""
package imported.ctxdsl
class Tag(name: String) {
val name = name
var inner = ""
fun child(tagName: String, block: Tag.()->void) {
val child = Tag(tagName)
child.apply { block(this) }
inner += child.render()
}
fun h3(block: Tag.()->void) { child("h3", block) }
fun addText(text: String) { inner += text }
fun render() = "<" + name + ">" + inner + "</" + name + ">"
}
context(Tag)
fun String.unaryPlus() {
this@Tag.addText(this)
}
""".trimIndent()
)
}
val script = Compiler.compile(
Source(
"<ctx-dsl-import>",
"""
import imported.ctxdsl
fun html(block: Tag.()->void) {
val root = Tag("html")
root.apply { block(this) }
root.render()
}
val page = html {
h3 {
+"Imported"
}
}
assertEquals("<html><h3>Imported</h3></html>", page)
assertEquals("plain", +"plain")
page
""".trimIndent()
),
manager
)
val result = script.execute(manager.newStdScope()) as ObjString
assertEquals("<html><h3>Imported</h3></html>", result.value)
}
}

View File

@ -32,6 +32,8 @@ import kotlinx.serialization.json.encodeToJsonElement
import net.sergeych.lyng.*
import net.sergeych.lyng.obj.*
import net.sergeych.lyng.pacman.InlineSourcesImportProvider
import net.sergeych.lyng.serialization.ObjSerializationFormatClass
import net.sergeych.lyng.serialization.bindSerializationFormat
import net.sergeych.lyng.thisAs
import net.sergeych.mp_tools.globalDefer
import net.sergeych.tools.bm
@ -4663,6 +4665,243 @@ class ScriptTest {
)
}
@Test
fun testUniversalJsonRoundTrip() = runTest {
eval(
"""
import lyng.serialization
import lyng.time
enum Color { Red, Green }
class Point(foo,bar) {
var z = 42
}
val point = Point(1,2)
val color = Color.Green
point.z = 99
val value = List(
point,
Map([1, "one"], ["two", 2]),
Set(1,2,3),
"hello".encodeUtf8(),
Date(2026,4,15),
color
)
val restored = Json.decode(Json.encode(value))
assertEquals(value, restored)
assertEquals(99, restored[0].z)
assertEquals("one", restored[1][1])
assertEquals(true, restored[2].contains(3))
assertEquals("hello", restored[3].decodeUtf8())
assertEquals("2026-04-15", restored[4].toString())
assertEquals(Color.Green, restored[5])
""".trimIndent()
)
}
@Test
fun testJsonDecodePlainJsonString() = runTest {
val decoded = eval(
"""
import lyng.serialization
Json.decode("{\"foo\":[1,true,\"bar\"]}")
""".trimIndent()
)
assertEquals("""{"foo":[1,true,"bar"]}""", decoded.toJson().toString())
}
@Test
fun testUniversalJsonExceptionRoundTrip() = runTest {
eval(
"""
import lyng.serialization
class MyException : Exception("boom")
val ex = MyException()
val restored = Json.decode(Json.encode(ex))
assert(restored is MyException)
assert(restored is Exception)
assertEquals(ex.message, restored.message)
assert(restored.stackTrace.size > 0)
""".trimIndent()
)
}
@Test
fun testUniversalJsonSingletonObjectRoundTrip() = runTest {
eval(
"""
import lyng.serialization
object Counter {
var value = 1
}
Counter.value = 77
val restored = Json.decode(Json.encode(Counter))
assertEquals(Counter, restored)
assertEquals(77, Counter.value)
assertEquals(77, restored.value)
""".trimIndent()
)
}
@Test
fun testCanonicalJsonUsesTraditionalObjectsForStringKeyMaps() = runTest {
eval(
"""
import lyng.serialization
val value = Map(["foo", 1], ["bar", 2])
val encoded = Json.encode(value)
assertEquals("{\"foo\":1,\"bar\":2}", encoded)
val restored = Json.decode(encoded)
assertEquals(value, restored)
assertEquals(1, restored["foo"])
""".trimIndent()
)
}
@Test
fun testTypedJsonRoundTripOmitsExactTypeInformation() = runTest {
eval(
"""
import lyng.serialization
closed class Point(x: Int, y: Int)
val point = Point(0, 1)
val encoded = Json.encodeAs(Point, point)
assertEquals("{\"x\":0,\"y\":1}", encoded)
val restored = Json.decodeAs(Point, encoded)
assertEquals(point, restored)
""".trimIndent()
)
}
@Test
fun testTypedJsonUsesTraditionalObjectsForStringKeyMaps() = runTest {
eval(
"""
import lyng.serialization
closed class Payload(values: Map<String, Int>)
val value = Payload(Map(["foo", 1], ["bar", 2]))
val encoded = Json.encodeAs(Payload, value)
assertEquals("{\"values\":{\"foo\":1,\"bar\":2}}", encoded)
val restored = Json.decodeAs(Payload, encoded)
assertEquals(value, restored)
assertEquals(2, restored.values["bar"])
""".trimIndent()
)
}
@Test
fun testTypedJsonRecursesUsingDeclaredFieldTypes() = runTest {
eval(
"""
import lyng.serialization
closed class Point(x: Int, y: Int)
closed class Segment(a: Point, b: Point)
val value = Segment(Point(0, 1), Point(2, 3))
val encoded = Json.encodeAs(Segment, value)
assertEquals("{\"a\":{\"x\":0,\"y\":1},\"b\":{\"x\":2,\"y\":3}}", encoded)
val restored = Json.decodeAs(Segment, encoded)
assertEquals(value, restored)
""".trimIndent()
)
}
@Test
fun testTypedJsonHandlesNullableFields() = runTest {
eval(
"""
import lyng.serialization
closed class Point(x: Int, y: Int)
closed class MaybePoint(point: Point?, label: String?)
val value = MaybePoint(null, "origin")
val encoded = Json.encodeAs(MaybePoint, value)
assertEquals("{\"point\":null,\"label\":\"origin\"}", encoded)
assertEquals(value, Json.decodeAs(MaybePoint, encoded))
val value2 = MaybePoint(Point(3, 4), null)
val encoded2 = Json.encodeAs(MaybePoint, value2)
assertEquals("{\"point\":{\"x\":3,\"y\":4},\"label\":null}", encoded2)
assertEquals(value2, Json.decodeAs(MaybePoint, encoded2))
""".trimIndent()
)
}
@Test
fun testTypedJsonKeepsSubtypeTagsWhenDeclaredTypeIsWider() = runTest {
eval(
"""
import lyng.serialization
class Base(baseX: Int)
class Derived(derivedX: Int, z: Int): Base(derivedX)
closed class Holder(item: Base)
val value = Holder(Derived(1, 2))
val encoded = Json.encodeAs(Holder, value)
val restored = Json.decodeAs(Holder, encoded)
assert(restored.item is Derived)
assertEquals(2, restored.item.z)
""".trimIndent()
)
}
@Test
fun testTypedJsonUsesEntriesForNonStringKeyMaps() = runTest {
eval(
"""
import lyng.serialization
closed class Numbered(values: Map<Int, String>)
val value = Numbered(Map([1, "one"], [2, "two"]))
val encoded = Json.encodeAs(Numbered, value)
assertEquals("{\"values\":[[1,\"one\"],[2,\"two\"]]}", encoded)
val restored = Json.decodeAs(Numbered, encoded)
assertEquals(value, restored)
assertEquals("one", restored.values[1])
""".trimIndent()
)
}
@Test
fun testTypedJsonOmitsEnumTagsWhenEnumTypeIsKnown() = runTest {
eval(
"""
import lyng.serialization
enum Color { Red, Green }
closed class Paint(color: Color)
val value = Paint(Color.Green)
val encoded = Json.encodeAs(Paint, value)
assertEquals("{\"color\":\"Green\"}", encoded)
val restored = Json.decodeAs(Paint, encoded)
assertEquals(value, restored)
assertEquals(Color.Green, restored.color)
""".trimIndent()
)
}
@Serializable
data class TestJson2(
val value: Int,
@ -4722,6 +4961,34 @@ class ScriptTest {
assertEquals(TestJson4(TestEnum.One), x)
}
@Test
fun testExternalSerializationFormatRegistration() = runTest {
val im = Script.defaultImportManager.copy()
im.addPackage("test.formats") { module ->
module.bindSerializationFormat(
object : ObjSerializationFormatClass("Reverse") {
override suspend fun encodeValue(scope: Scope, value: Obj): Obj =
ObjString(value.toString(scope).value.reversed())
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj {
val text = (encoded as? ObjString)?.value
?: scope.raiseClassCastError("Reverse.decode expects String")
return ObjString(text.reversed())
}
},
doc = "Simple test format that reverses strings."
)
}
val scope = im.newStdScope()
scope.eval(
"""
import test.formats
assertEquals("cba", Reverse.encode("abc"))
assertEquals("abc", Reverse.decode("cba"))
""".trimIndent()
)
}
@Test
fun testStringLast() = runTest {
eval(

View File

@ -15,7 +15,7 @@
*
*/
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.eval
import kotlin.test.Test
@ -30,7 +30,7 @@ class TypeInferenceTest {
/** Channel field type inferred from constructor — accessed in a launch closure */
@Test
fun testChannelFieldInLaunchClosure() = runBlocking<Unit> {
fun testChannelFieldInLaunchClosure() = runTest {
eval("""
class Foo {
private val ch = Channel(Channel.UNLIMITED)
@ -52,7 +52,7 @@ class TypeInferenceTest {
/** Mutex field type inferred from constructor — used directly in a method body */
@Test
fun testMutexFieldDirectUse() = runBlocking<Unit> {
fun testMutexFieldDirectUse() = runTest {
eval("""
class Bar {
private val mu = Mutex()
@ -69,7 +69,7 @@ class TypeInferenceTest {
/** CompletableDeferred field type inferred — complete/await used directly */
@Test
fun testCompletableDeferredFieldDirectUse() = runBlocking<Unit> {
fun testCompletableDeferredFieldDirectUse() = runTest {
eval("""
class Baz {
private val d = CompletableDeferred()
@ -84,7 +84,7 @@ class TypeInferenceTest {
/** Channel field accessed inside a map closure within class initializer */
@Test
fun testChannelFieldInMapAndLaunchClosure() = runBlocking<Unit> {
fun testChannelFieldInMapAndLaunchClosure() = runTest {
eval("""
class Pool(n) {
private val ch = Channel(Channel.UNLIMITED)
@ -104,4 +104,39 @@ class TypeInferenceTest {
Pool(2).closeAll()
""".trimIndent())
}
@Test
fun testIterableFirstPreservesElementTypeForBlockReturnInference() = runTest {
eval("""
class Item(title: String)
fun restored() {
val values = [Item("ok")]
values.first
}
val item = restored()
assertEquals("ok", item.title)
""".trimIndent())
}
@Test
fun testCallableLocalInitializedFromFunctionCallPreservesReturnType() = runTest {
eval("""
fun makeAdder(base) {
return { x -> x + base + 0.5 }
}
fun run() {
val add = makeAdder(2)
val value = add(3) + 4
assert(value is Real)
value
}
val result = run()
assert(result is Real)
assertEquals(9.5, result)
""".trimIndent())
}
}

View File

@ -0,0 +1,68 @@
/*
* 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
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.obj.ObjInt
import kotlin.test.Test
import kotlin.test.assertEquals
class DeclAnnotationIntrospectionTest {
@Test
fun classAnnotationQueriesExposeConstructorAndMemberAnnotations() = runTest {
val scope = Scope()
val result = scope.eval(
"""
val suffix = "!"
object Marker
class Sample(
@Transient @Tag(1, label: "ctor", extra: suffix) val x: Int
) {
@Transient @DbDecodeWith(Marker)
var y: Int = 10
}
val ctorAnnotations: ImmutableList<Map<String, Object>> = Sample.getConstructorAnnotations("x")
val ctorTag: Map<String, Object> = ctorAnnotations[1]
val ctorPositional: ImmutableList<Object> = ctorTag["positional"] as ImmutableList<Object>
val ctorNamed: Map<String, Object> = ctorTag["named"] as Map<String, Object>
assertEquals(2, ctorAnnotations.size)
assertEquals("Transient", ctorAnnotations[0]["name"])
assertEquals("Tag", ctorTag["name"])
assertEquals(1, ctorPositional[0])
assertEquals("ctor", ctorNamed["label"])
assertEquals("!", ctorNamed["extra"])
val memberAnnotations: ImmutableList<Map<String, Object>> = Sample.getMemberAnnotations("y")
val memberDecodeWith: Map<String, Object> = memberAnnotations[1]
val memberPositional: ImmutableList<Object> = memberDecodeWith["positional"] as ImmutableList<Object>
assertEquals(2, memberAnnotations.size)
assertEquals("Transient", memberAnnotations[0]["name"])
assertEquals("DbDecodeWith", memberDecodeWith["name"])
assertEquals(Marker, memberPositional[0])
memberAnnotations.size + ctorAnnotations.size
""".trimIndent()
) as ObjInt
assertEquals(4L, result.value)
}
}

View File

@ -53,6 +53,197 @@ class OperatorOverloadingTest {
""".trimIndent())
}
@Test
fun testUnaryPlusDefaultIdentity() = runTest {
eval("""
assertEquals(42, +42)
assertEquals(3.5, +3.5)
assertEquals("abc", +"abc")
class Box(val text: String) {
fun upper() = text.upper()
}
assertEquals("ABC", (+Box("abc")).upper())
""".trimIndent())
}
@Test
fun testUnaryPlusOverloading() = runTest {
eval("""
class Counter(val n: Int) {
fun unaryPlus() = Counter(this.n + 1)
fun equals(other: Counter) = this.n == other.n
}
assertEquals(Counter(6), Counter(5).unaryPlus())
assertEquals(Counter(6), +Counter(5))
""".trimIndent())
}
@Test
fun testUnaryPlusExtensionOverloading() = runTest {
eval("""
var out = ""
fun String.unaryPlus() {
out = out + this
}
"Hello".unaryPlus()
" ".unaryPlus()
"Lyng".unaryPlus()
assertEquals("Hello Lyng", out)
out = ""
+"Hello"
+" "
+"Lyng"
assertEquals("Hello Lyng", out)
""".trimIndent())
}
@Test
fun testUnaryPlusDslBuilderStyle() = runTest {
eval("""
class Tag(name: String) {
val name = name
var inner = ""
fun child(tagName: String, block: Tag.()->void) {
val child = Tag(tagName)
with(child) { block(this) }
inner += child.render()
}
fun head(block: Tag.()->void) { child("head", block) }
fun body(block: Tag.()->void) { child("body", block) }
fun title(block: Tag.()->void) { child("title", block) }
fun h1(block: Tag.()->void) { child("h1", block) }
fun addText(text: String) {
inner += text
}
fun render() {
"<" + name + ">" + inner + "</" + name + ">"
}
}
context(Tag)
fun String.unaryPlus() {
this@Tag.addText(this)
}
fun html(block: Tag.()->void) {
val root = Tag("html")
with(root) { block(this) }
root.render()
}
val page = html {
head {
title {
+"Demo"
}
}
body {
h1 {
+"Heading 1"
}
}
}
assertEquals("<html><head><title>Demo</title></head><body><h1>Heading 1</h1></body></html>", page)
""".trimIndent())
}
@Test
fun testContextReceiverUnaryPlusDslBuilderStyle() = runTest {
eval("""
class Tag(name: String) {
val name = name
var inner = ""
fun child(tagName: String, block: Tag.()->void) {
val child = Tag(tagName)
with(child) { block(this) }
inner += child.render()
}
fun h3(block: Tag.()->void) { child("h3", block) }
fun addText(text: String) {
inner += text
}
fun render() {
"<" + name + ">" + inner + "</" + name + ">"
}
}
context(Tag)
fun String.unaryPlus() {
this@Tag.addText(this)
}
fun html(block: Tag.()->void) {
val root = Tag("html")
with(root) { block(this) }
root.render()
}
val page = html {
h3 {
+"Heading 3"
}
}
assertEquals("<html><h3>Heading 3</h3></html>", page)
assertEquals("plain", +"plain")
""".trimIndent())
}
@Test
fun testContextReceiverExtensionIsHiddenOutsideContext() = runTest {
val ex = assertFailsWith<Throwable> {
eval("""
class Tag {
fun wrap(text: String) = "[" + text + "]"
}
context(Tag)
fun String.mark() = this@Tag.wrap(this)
"x".mark()
""".trimIndent())
}
assertContains(ex.message ?: "", "no such member: mark on String")
}
@Test
fun testContextReceiverExtensionIsHiddenInWrongContext() = runTest {
val ex = assertFailsWith<Throwable> {
eval("""
class Tag {
fun wrap(text: String) = "[" + text + "]"
}
class Other
context(Tag)
fun String.mark() = this@Tag.wrap(this)
fun other(block: Other.()->void) {
with(Other()) { block(this) }
}
other {
"x".mark()
}
""".trimIndent())
}
assertContains(ex.message ?: "", "no such member: mark on String")
}
@Test
fun testPlusAssignOverloading() = runTest {
eval("""

View File

@ -134,4 +134,152 @@ class OptTest {
assertEquals((1..10).toSet(), result)
""".trimIndent())
}
@Test
fun testElvisBreak() = runTest {
eval("""
fun t(x: Int?): Int? =
if( x == null || x == 3 ) null
else 100
fun needInt(x: Int): Int = x
var cnt = -1
while( true ) {
val x = t(cnt++) ?: break
assertEquals(100, x)
assertEquals(100, needInt(x))
assertEquals(101, x + 1)
}
assert( t(3) == null )
assert( cnt == 4 )
""".trimIndent())
}
@Test
fun testReceivers1() = runTest {
eval("""
class RA {
fun a() { println("a") }
}
class RB {
fun b() { println("b") }
}
fun ta( f: RA.()->Unit ) {
val instance = RA()
with(instance) { f(this) }
}
fun tb( f: RB.()->Unit ) {
val b = RB()
with(b) { f(this) }
}
ta {
a()
tb {
b()
// but important: a() must still be accessible
// because it is inner block, sort of closure:
a()
}
}
""".trimIndent())
}
@Test
fun testContextReceiverFunctionType() = runTest {
eval("""
class RA {
fun value(): Int = 10
}
class RB {
fun value(): Int = 20
}
fun ta(f: RA.()->Int): Int {
val instance = RA()
return with(instance) { f(this) }
}
fun tb(f: context(RA) RB.()->Int): Int {
val instance = RB()
return with(instance) { f(this) }
}
val result = ta {
val block: context(RA) RB.()->Int = {
value() + this@RA.value()
}
tb(block)
}
assertEquals(30, result)
""".trimIndent())
}
@Test
fun testNestedReceiverQualifiedThis() = runTest {
eval("""
class RA {
fun value(): Int = 1
}
class RB {
fun value(): Int = 2
}
fun ta(f: RA.()->Int): Int {
val instance = RA()
return with(instance) { f(this) }
}
fun tb(f: RB.()->Int): Int {
val instance = RB()
return with(instance) { f(this) }
}
val result = ta {
tb {
value() + this@RA.value()
}
}
assertEquals(3, result)
""".trimIndent())
}
@Test
fun testReceiverAmbiguityRequiresQualifiedThis() = runTest {
val ex = assertFailsWith<ScriptError> {
eval("""
class RA {
fun shared(): Int = 10
}
class RC {
fun shared(): Int = 30
}
class RB
fun ta(f: RA.()->Int): Int {
val instance = RA()
return with(instance) { f(this) }
}
fun tc(f: RC.()->Int): Int {
val instance = RC()
return with(instance) { f(this) }
}
fun tb(f: context(RA, RC) RB.()->Int): Int {
val instance = RB()
return with(instance) { f(this) }
}
ta {
tc {
val block: context(RA, RC) RB.()->Int = {
shared()
}
tb(block)
}
}
""".trimIndent())
}
assertContains(ex.message ?: "", "ambiguous between receivers RA, RC")
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* 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.
@ -18,6 +18,7 @@
package net.sergeych.lyng.highlight
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class HighlightMappingTest {
@ -72,6 +73,38 @@ class HighlightMappingTest {
assertTrue(labeled.any { it.first == "\"s\"" && it.second == HighlightKind.String })
}
@Test
fun interpolatedStringSpansDoNotOverlap() {
val text = """p { +"Path: ${'$'}{request.path}" }"""
val spans = SimpleLyngHighlighter().highlight(text)
spans.zipWithNext().forEach { (a, b) ->
assertTrue(
a.range.endExclusive <= b.range.start,
"Highlight spans must not overlap: $a then $b in $spans"
)
}
assertTrue(
spansToLabeled(text, spans).any {
it.first == "\"Path: ${'$'}{request.path}\"" && it.second == HighlightKind.String
}
)
}
@Test
fun interpolatedStringRenderingDoesNotDuplicateText() {
val text = """p { +"Path: ${'$'}{request.path}" }"""
val rendered = buildString {
var pos = 0
for (span in SimpleLyngHighlighter().highlight(text)) {
if (span.range.start > pos) append(text.substring(pos, span.range.start))
append(text.substring(span.range.start, span.range.endExclusive))
pos = span.range.endExclusive
}
if (pos < text.length) append(text.substring(pos))
}
assertEquals(text, rendered)
}
@Test
fun commentsHighlighted() {
val text = "// line\n/* block */"

View File

@ -14,6 +14,24 @@ extern class NotImplementedException
/* Raised when an awaited asynchronous task was cancelled before producing a value. */
extern class CancellationException : Exception
/* Runtime metaobject describing a class. */
extern class Class {
/* Full name of this class including package if available. */
val className: String
/* Simple name of this class (without package). */
val name: String
/* Declared instance fields of this class and its ancestors (C3 order), without duplicates. */
val fields: List<String>
/* Declared instance methods of this class and its ancestors (C3 order), without duplicates. */
val methods: List<String>
/* Lookup a member by name in this class (including ancestors) and return it, or null if absent. */
fun get(name: String): Object?
/* Preserved annotations for a constructor parameter as descriptor maps with keys `name`, `positional`, and `named`. */
fun getConstructorAnnotations(name: String): ImmutableList<Map<String, Object>>
/* Preserved annotations for a member as descriptor maps with keys `name`, `positional`, and `named`. */
fun getMemberAnnotations(name: String): ImmutableList<Map<String, Object>>
}
/* A handle to a running asynchronous task. */
extern class Deferred {
/* Cancel the task if it is still active. Safe to call multiple times. */

View File

@ -0,0 +1,351 @@
# ResultSet typed decode API
Status: draft design note
## Goal
Extend `lyng.io.db` with row deserialization into ordinary Lyng objects using the new typed serialization-style API naming.
Primary use case:
```lyng
class Point(x: Real, y: Real)
val point = db.transaction { tx ->
tx.select(
"select row as x, col as y from data where not is_deleted"
).decodeAs<Point>().first
}
```
## Agreed API
Use `decodeAs<T>()` as the only public API form in v1.
Rationale:
- matches the new typed serialization naming (`Json.decodeAs(...)`)
- communicates decoding/materialization, not casting
- keeps the common case strongly typed and chain-friendly
- avoids adding a second runtime-type overload before it is needed
Planned Lyng-facing declarations:
```lyng
extern class SqlRow {
fun decodeAs<T>(): T
}
extern class ResultSet : Iterable<SqlRow> {
fun decodeAs<T>(): Iterable<T>
}
```
## Lifetime semantics
`ResultSet.decodeAs<T>()` returns a transaction-scoped iterable view over the underlying result set.
Rules:
- the returned iterable must not be used after the owning transaction ends
- decoded objects created during iteration are detached ordinary Lyng objects
- to keep decoded values after the transaction, materialize them inside the transaction
- normal materialization forms are `toList()`, `first`, `findFirst`, or manual iteration
Valid:
```lyng
val points = db.transaction { tx ->
tx.select("select x, y from point")
.decodeAs<Point>()
.toList()
}
```
Invalid:
```lyng
val decoded = db.transaction { tx ->
tx.select("select x, y from point").decodeAs<Point>()
}
decoded.first
```
## ResultSet shape
`ResultSet.decodeAs<T>()` should preserve the current `ResultSet` paradigm:
- `ResultSet` stays the row-producing source
- `decodeAs<T>()` is a projection from `Iterable<SqlRow>` to `Iterable<T>`
- no new DB-specific collection type is introduced in v1
Implementation-wise, `ResultSet.decodeAs<T>()` can be defined as a lazy iterable that decodes each row via `SqlRow.decodeAs<T>()`.
## Mapping discussion to finalize
The following mapping behavior still needs explicit design decisions:
- how constructor parameters are matched from columns
- whether matching is case-insensitive
- whether mutable serializable fields are populated after constructor call
- treatment of default constructor values
- treatment of nullable vs non-nullable targets
- behavior for missing columns
- behavior for extra columns
- behavior for duplicate/ambiguous column labels
- whether `onDeserialized()` is called after row decode
- whether v1 supports only flat object decode or also nested shapes
## Current direction for mapping
Current likely direction, not finalized yet:
- constructor parameters map by column label
- matching is case-insensitive, consistent with `SqlRow["name"]`
- after constructor call, remaining matching serializable mutable fields may be assigned
- missing required non-null constructor values fail
- missing nullable constructor parameters become `null`
- defaulted constructor parameters use their defaults when the column is absent
- ambiguous duplicate column labels fail
- extra columns likely fail in strict mode for v1
- `onDeserialized()` likely should run after the object is fully populated
- v1 should likely stay flat and avoid nested/prefix-based mapping
## Projection/conversion rules
### General principle
Row decoding should be strict and predictable.
It should not globally treat every SQL string column as serialized JSON or every binary column as Lynon.
That would be too implicit:
- ordinary text columns are common and must stay ordinary text by default
- ordinary binary/blob columns are common and must stay raw binary by default
- automatic format decoding should happen only when there is a clear signal
### Proposed conversion precedence
For each constructor parameter or serializable mutable field:
1. resolve the source column by name
2. if the source value already matches the target type, use it directly
3. if an explicit DB decoding attribute is present on the target member, apply that decoding rule
4. otherwise, if the column metadata clearly indicates a special encoded DB type and the target is not the raw DB carrier type, apply the built-in format rule
5. otherwise fail with a decode/type mismatch error
### Direct match
Direct match means the row value is already assignable to the target type after the normal SQL backend conversion.
Examples:
- SQL numeric column already surfaced as `Int`/`Real`/`Decimal`
- SQL bool column surfaced as `Bool`
- SQL date/time column surfaced as `Date`, `DateTime`, `Instant`
- SQL text column surfaced as `String`
- SQL binary column surfaced as `Buffer`
These should not trigger any extra JSON/Lynon decoding.
### Built-in encoded-column rules
Current likely direction:
- JSON/JSONB-like columns should decode through typed canonical `Json` when the target is not `String`
- binary columns should decode through `Lynon` when the target is not `Buffer`
This implies the current default:
- string -> non-string is eligible for automatic typed `Json` decode only when the column metadata says the DB column is JSON-like
- binary -> non-binary is decoded through `Lynon`
- binary -> `Buffer` stays raw `Buffer`
Examples:
- PostgreSQL `json` / `jsonb` column into `Point` -> use typed `Json` decode
- PostgreSQL `jsonb` column into `Map<String, Object?>` -> use typed `Json` decode
- plain `text` / `varchar` column into `Point` -> fail unless explicitly annotated
- `bytea` / `blob` column into `Buffer` -> direct match, no Lynon decode
- `bytea` / `blob` column into `Point` -> decode with `Lynon`
### Attribute-based explicit decoding
Common explicit attributes look useful:
- `@DbJson`
- `@DbLynon`
Applied to constructor parameters and serializable mutable fields.
Meaning:
- `@DbJson` means decode the column value as typed canonical JSON into the target member type
- `@DbLynon` means decode the column value as Lynon into the target member type
Example:
```lyng
class Record(
id: Int,
@DbJson payload: Payload,
@DbLynon cachedState: CacheEntry
)
```
This keeps the common DB formats easy to use without making plain `String` or `Buffer` columns magical.
Implementation note:
- declaration metadata now preserves evaluated constructor-parameter and class-member annotation arguments
- annotation arguments are evaluated once at declaration creation time and retained for the lifetime of the declaration
- `@DbDecodeWith(...)` now uses that preserved metadata path
### Generic custom decoder hook
A generic hook is useful too, but it should be adapter-based, not lambda-based.
Planned shape:
- `@DbDecodeWith(adapter)`
- `adapter` should be an instance of a dedicated interface such as `DbFieldAdapter`
Reason:
- a named adapter interface is easier to document and evolve than arbitrary callables
- it gives us room for richer decoding context without baking ad-hoc callable signatures into annotations
- it keeps the DB mapping API explicit and self-describing
Current design direction:
```lyng
interface DbFieldAdapter {
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
throw NotImplementedException("DB field adapter decode is not implemented")
fun encode(value: Object?, targetType: Object): Object? =
throw NotImplementedException("DB field adapter encode is not implemented")
}
```
Decided:
- `decode(...)` should receive the target type
- adapters may be any ordinary instance, not only singleton objects
- the same abstraction should later support symmetric `encode(...)`
- adapter result must be checked against the target member type after decoding
Still open before full implementation:
- exact annotation shape for `@DbDecodeWith(...)`
- whether target member name should also be passed
- whether `targetType` should later get a more specific declaration type than plain `Object`
Implemented in the current design:
- `@DbDecodeWith(adapter)` on constructor parameters
- `@DbDecodeWith(adapter)` on class-body fields/properties participating in `decodeAs<T>()`
Future improvement:
- compiler warning when preserved annotation metadata captures runtime state/closures
- extend preserved annotation metadata beyond constructor parameters and class members to functions and top-level declarations
### Arrays and maps
Arrays and maps should not get DB-specific bespoke mapping in v1 unless they are coming through a recognized encoded format.
Reason:
- portable SQL array/map support is backend-specific and inconsistent
- JSON columns already give us a portable representation for `List` and `Map`
- adding DB-native array semantics now would complicate the contract too early
So in v1:
- if the backend already surfaces a value that directly matches the target type, use it
- otherwise `List` / `Map` reconstruction should happen via `@DbJson` or recognized JSON-like column metadata
### Recommended v1 policy
Current recommended projection policy:
- direct type match first
- then explicit member attribute (`@DbJson`, `@DbLynon`)
- then metadata-driven JSON decode for recognized JSON-like DB columns
- then Lynon decode for binary columns when the target is not `Buffer`
- no implicit JSON decode for arbitrary text columns
- fail on anything else
## Write-side SQL object expansion
The symmetric write-side convenience should be explicit and declaration-driven, but it should not attempt semantic SQL analysis.
Agreed v1 surface:
- `@cols(?1)` expands one object argument to projected column names
- `@vals(?1)` expands the same object argument to matching placeholders and encoded bind values
- `@set(?1)` expands the same object argument to `column = ?` pairs and encoded bind values
- each macro accepts an optional `except:` filter, for example `@set(?1 except: "id", "updatedAt")`
Examples:
```lyng
tx.execute(
"insert into item(@cols(?1)) values(@vals(?1))",
item
)
tx.execute(
"update item set @set(?1) where id = ?2",
item,
item.id
)
```
Rules:
- once a clause uses `@cols`, `@vals`, or `@set`, plain sequential `?` placeholders are not allowed in the same clause
- non-expanded parameters in macro clauses must use explicit indexed placeholders such as `?2`
- the same object argument may be referenced multiple times
- object expansion is based on declaration metadata, not SQL metadata
- v1 excludes `@Transient` and `@DbExcept` fields automatically
- `except:` excludes additional fields for one specific macro use
### Write-side field encoding policy
Write-side encoding cannot rely on DB column type inference, so non-trivial field serialization must be explicit.
For each projected field:
1. if the value is already directly DB-bindable, bind it as-is
2. else if `@DbJson` is present, encode to canonical JSON text
3. else if `@DbLynon` is present, encode to Lynon binary
4. else if `@DbSerializeWith(adapter)` is present, call `adapter.encode(value, targetType)`
5. else fail with `SqlUsageException`
Direct DB-bindable values in v1:
- `null`
- `Bool`
- `Int`, `Real`, `Decimal`
- `String`
- `Buffer`
- `Date`, `DateTime`, `Instant`
This is intentionally stricter than decode-side behavior. On writes, there is no portable, reliable way to infer the intended target DB representation from SQL text alone.
### Adapter role
`DbFieldAdapter` is now symmetric by design:
- `decode(rawValue, column, row, targetType)` is used by `decodeAs<T>()`
- `encode(value, targetType)` is used by SQL object expansion
The adapter instance is captured in preserved declaration annotation metadata, not passed ad hoc at the call site.
Future task:
- consider warnings or lints for risky annotation captures such as stateful adapters or closure-capturing instances

View File

@ -0,0 +1,600 @@
# Proposal: Minimal HTTP/1.1 + WebSocket Server For `lyngio`
Status: Draft
Date: 2026-04-26
Owner: `lyngio`
## Context
`lyngio` already provides:
- HTTP client support via `lyng.io.http`
- WebSocket client support via `lyng.io.ws`
- raw TCP/UDP transport via `lyng.io.net`
The current transport layer is already multiplatform and exposes a small common Kotlin interface:
- `LyngTcpSocket`
- `LyngTcpServer`
- `LyngNetEngine`
This makes it practical to add a minimal server implementation in pure Kotlin without introducing a second public networking model.
The intended deployment model for this server is:
- behind a frontend proxy such as nginx
- no TLS termination in `lyngio`
- no HTTP/2 in `lyngio` v1
- minimal, strict HTTP/1.1 subset
- classic HTTP/1.1 WebSocket upgrade support
This proposal deliberately does **not** attempt to implement HTTP/2. That work is substantially larger because it requires binary framing, stream multiplexing, HPACK, and flow control. For the intended deployment model, a frontend proxy can provide TLS and public HTTP/2 while `lyngio` speaks HTTP/1.1 on the backend.
## Goals
- Add a minimal HTTP server implementation in pure Kotlin.
- Keep the implementation compatible with Kotlin Multiplatform common code constraints.
- Reuse the existing `lyngio.net` TCP transport layer.
- Support a strict, useful HTTP/1.1 subset.
- Support classic WebSocket upgrade from HTTP/1.1.
- Keep the API and implementation small enough to be auditable and testable.
- Preserve room for later richer server APIs or JVM-specific backends.
## Non-goals
- HTTP/2
- TLS
- ALPN
- proxy protocol support
- request pipelining
- chunked request bodies
- HTTP trailers
- content compression
- multipart/form-data parsing
- range requests
- streaming request bodies in v1
- streaming response bodies in v1
- WebSocket extensions
- WebSocket subprotocol negotiation in v1
- exposing Ktor server APIs or types
## Design principles
### 1. Common-code first
The implementation should live primarily in `commonMain` and depend only on existing common abstractions built on top of `LyngTcpSocket` and `LyngTcpServer`.
### 2. Strict subset over broad tolerance
The server should reject unsupported or ambiguous protocol constructs instead of trying to be maximally permissive.
This reduces complexity, avoids parser edge cases, and makes connection reuse easier to reason about.
### 3. Small surface area
The first version should only implement what is needed for:
- ordinary backend HTTP request/response handling behind a proxy
- WebSocket upgrade and session handling
- persistent HTTP/1.1 connections when message framing is unambiguous
### 4. Frontend proxy assumption
The server is expected to run behind nginx or a similar reverse proxy that can provide:
- TLS termination
- public HTTP/2 if needed
- request filtering and size limiting
- buffering and slow-client protection
- optional compression and edge-specific behavior
## Proposed package
Add a new internal package:
- `net.sergeych.lyngio.http.server`
This proposal defines an internal Kotlin API first. Lyng-facing scripting bindings are explicitly out of scope for the first phase.
## Supported HTTP request subset
### Request line
Accepted format:
- `METHOD SP request-target SP HTTP/1.1`
Rules:
- request line must split into exactly 3 parts
- `METHOD` must be a non-empty HTTP token
- version must be exactly `HTTP/1.1`
- request target must be origin-form only
Accepted request-target examples:
- `/`
- `/hello`
- `/hello/world?x=1&y=2`
Rejected request-target forms:
- absolute-form: `http://example.com/x`
- authority-form
- asterisk-form: `*`
### Methods
The parser should accept any syntactically valid token as a method and expose it as a string.
The handler layer may then decide what to do with it.
This keeps the parser generic and avoids hardcoding a small method list.
### Headers
Rules:
- header section ends at the first empty line
- each header line must have `name:value` form
- header names are case-insensitive for lookup
- original header values are preserved
- repeated headers are preserved as repeated values
- obsolete line folding is rejected
- embedded CR or LF in header values is rejected
### Host header
Rules:
- `Host` is required on every request
- there must be exactly one effective host value after normalization
- duplicate `Host` values are allowed only if they are identical after trimming
- conflicting `Host` values are rejected
### Request bodies
v1 accepted request body framing:
- no body
- body with a valid `Content-Length`
v1 rejected request body framing:
- any `Transfer-Encoding`
- chunked request bodies
- ambiguous or conflicting body framing
### Keep-alive
HTTP/1.1 persistent connections are supported.
Rules:
- keep-alive is the default
- the server closes the connection if the client sends `Connection: close`
- the server may close the connection after any response if it chooses
- the server closes the connection on parse errors or framing errors
- after a successful WebSocket upgrade, the HTTP request loop ends for that socket
### WebSocket upgrade
v1 supports classic HTTP/1.1 upgrade to WebSocket.
Required request properties:
- method is `GET`
- `Upgrade: websocket`
- `Connection` contains token `upgrade`
- `Sec-WebSocket-Key` is present
- `Sec-WebSocket-Version: 13`
v1 behavior:
- no subprotocol negotiation
- no extension negotiation
- no HTTP/2 WebSocket support
- no fallback upgrade modes beyond the standard HTTP/1.1 handshake
## Rejection and error rules
### `400 Bad Request`
Return `400` for:
- malformed request line
- invalid HTTP token in method or header name
- unsupported request-target form
- missing `Host`
- conflicting `Host` values
- invalid header syntax
- obsolete folded headers
- invalid `Content-Length`
- conflicting duplicate `Content-Length`
- invalid WebSocket upgrade request
### `413 Payload Too Large`
Return `413` when request body exceeds configured maximum size.
### `414 URI Too Long`
Return `414` when the request-target exceeds configured limits.
### `431 Request Header Fields Too Large`
Return `431` when:
- total header bytes exceed the configured limit
- header count exceeds the configured limit
- an individual header line exceeds the configured limit if such a per-line limit is introduced
### `501 Not Implemented`
Return `501` for:
- `Transfer-Encoding` in requests
- chunked request bodies
- `Expect: 100-continue`
- unsupported `Upgrade` values
- request features intentionally excluded from v1
### `505 HTTP Version Not Supported`
Return `505` for any HTTP version other than `HTTP/1.1`.
### `500 Internal Server Error`
Return `500` when the request was parsed successfully but the application handler throws or otherwise fails unexpectedly.
## Response model
v1 responses should be fully materialized before writing.
Rules:
- always send a status line
- always send response headers
- prefer sending `Content-Length` on all normal responses
- do not emit chunked responses in v1
- if response framing is ambiguous, close the connection instead of attempting reuse
Connection closing rules:
- include `Connection: close` when the server intends to close after the response
- close after the response if the request asked for `Connection: close`
- close after protocol errors
- after `101 Switching Protocols`, the HTTP server loop yields ownership of the socket to the WebSocket session
## Suggested defaults and limits
Default operational limits:
- maximum request line bytes: `8 KiB`
- maximum total header bytes: `32 KiB`
- maximum header count: `100`
- maximum request body bytes: `1 MiB`
- keep-alive idle timeout: `15_000 ms`
These should be configurable per server instance.
## Internal Kotlin API
The following shape is recommended as the initial internal API.
```kotlin
data class HttpServerConfig(
val host: String? = "127.0.0.1",
val port: Int = 0,
val backlog: Int = 128,
val reuseAddress: Boolean = true,
val maxRequestLineBytes: Int = 8 * 1024,
val maxHeaderBytes: Int = 32 * 1024,
val maxHeaderCount: Int = 100,
val maxBodyBytes: Int = 1 * 1024 * 1024,
val keepAliveTimeoutMillis: Long = 15_000,
)
data class HttpHeader(
val name: String,
val value: String,
)
class HttpHeaders(
private val entries: List<HttpHeader>,
) {
fun first(name: String): String?
fun all(name: String): List<String>
fun containsToken(name: String, token: String): Boolean
fun entries(): List<HttpHeader>
}
data class HttpRequestHead(
val method: String,
val target: String,
val path: String,
val query: String?,
val version: String,
val headers: HttpHeaders,
val contentLength: Int?,
val wantsClose: Boolean,
val wantsWebSocketUpgrade: Boolean,
)
data class HttpRequest(
val head: HttpRequestHead,
val body: ByteArray,
)
data class HttpResponse(
val status: Int,
val reason: String = defaultReason(status),
val headers: List<HttpHeader> = emptyList(),
val body: ByteArray = ByteArray(0),
val close: Boolean = false,
)
interface HttpWebSocketSession {
fun isOpen(): Boolean
suspend fun sendText(text: String)
suspend fun sendBytes(data: ByteArray)
suspend fun receive(): net.sergeych.lyngio.ws.LyngWsMessage?
suspend fun close(code: Int = 1000, reason: String = "")
}
sealed interface HttpHandlerResult {
data class Response(val response: HttpResponse) : HttpHandlerResult
data class WebSocket(val handler: suspend (HttpWebSocketSession) -> Unit) : HttpHandlerResult
}
fun interface HttpHandler {
suspend fun handle(request: HttpRequest): HttpHandlerResult
}
interface HttpServer {
fun isOpen(): Boolean
fun localAddress(): net.sergeych.lyngio.net.LyngSocketAddress
fun close()
}
```
## Implementation architecture
The implementation should be split into a small number of focused components.
### 1. `HttpServer.kt`
Contains:
- public internal interfaces and data classes
- config and response models
- default reason phrase mapping
### 2. `BufferedSocketReader.kt`
A small internal reader built on top of `LyngTcpSocket`.
Responsibilities:
- buffered reads
- line reads with explicit limits
- exact byte reads for request bodies and WebSocket frames
- avoiding fragile mixing of raw `read()` and `readLine()` semantics
This reader should be internal and should not require changes to `LyngTcpSocket` in v1.
### 3. `HttpParser.kt`
Responsibilities:
- request line parsing
- target parsing into `path` and optional query
- header parsing and normalization
- validation of `Host`, `Content-Length`, and connection semantics
- mapping parse failures into typed HTTP errors
### 4. `HttpWriter.kt`
Responsibilities:
- writing status line and headers
- adding `Content-Length` where needed
- setting `Connection: close` when the server intends to close
- writing the response body
- flushing output
### 5. `HttpServerLoop.kt`
Responsibilities:
- accept loop over `LyngTcpServer`
- per-connection request loop
- keep-alive timeout handling
- error-to-response mapping
- handing off upgraded sockets to WebSocket session implementation
### 6. `ServerWebSocket.kt`
Responsibilities:
- validating upgrade request
- computing `Sec-WebSocket-Accept`
- writing `101 Switching Protocols`
- reading and writing WebSocket frames
- close handling
This should reuse the client-side frame and handshake logic already present in spirit, but server-side behavior should stay separate and explicit.
## Connection processing model
Per accepted TCP connection:
1. read request line
2. read headers
3. validate request
4. read request body if `Content-Length` is present
5. call the application handler
6. if handler returns HTTP response, write it and decide whether to continue
7. if handler returns WebSocket upgrade, send `101`, create a WebSocket session, and transfer ownership of the socket
8. continue until close, error, timeout, or upgrade
The server should process one request at a time per connection.
Pipelining is out of scope.
## Detailed parser rules
### Method parsing
- method must be a valid HTTP token
- parser does not enforce a fixed method allowlist
### Target parsing
- target must begin with `/`
- split on the first `?`
- `path` is the portion before `?`
- `query` is the portion after `?`, or `null`
- no URL decoding is required in v1; raw target text may be exposed
### Header parsing
- split each header line on the first `:`
- trim outer spaces and tabs from the value
- reject control characters other than horizontal tab if any are allowed at all
- do case-insensitive matching by normalized header name
- preserve the original values as supplied
### Content-Length rules
- absent means no request body
- one valid decimal value is accepted
- multiple values are accepted only if all normalized values are identical
- negative values are rejected
- values above configured maximum body size are rejected with `413`
### Connection token parsing
- `Connection` is tokenized case-insensitively on commas
- surrounding spaces are ignored
- helper methods should support `containsToken("Connection", "close")`
- helper methods should support `containsToken("Connection", "upgrade")`
## WebSocket v1 rules
### Upgrade acceptance
Accept only if all of the following are true:
- request method is `GET`
- request version is `HTTP/1.1`
- request body is empty
- `Upgrade` contains `websocket`
- `Connection` contains `upgrade`
- `Sec-WebSocket-Key` is present and syntactically valid
- `Sec-WebSocket-Version` equals `13`
Otherwise return a regular HTTP error response.
### WebSocket features in v1
Supported:
- text messages
- binary messages
- ping/pong handling
- close handshake
Not supported in v1:
- permessage-deflate
- subprotocol negotiation
- fragmented-message streaming to the application
- very large frame optimizations beyond a reasonable implementation limit
## Testing plan
A server like this should be tested at three levels.
### 1. Parser unit tests
Cases:
- valid request line parsing
- invalid request line parsing
- target parsing with and without query
- header case-insensitive lookup
- duplicate `Host` handling
- duplicate `Content-Length` handling
- oversized request line rejection
- oversized headers rejection
- `Transfer-Encoding` rejection
### 2. Engine-level loopback tests
Using existing TCP backends:
- simple `GET` request and response
- `POST` with `Content-Length`
- keep-alive with two sequential requests on one socket
- `Connection: close`
- malformed request closes connection
- handler exception becomes `500`
- body too large becomes `413`
### 3. WebSocket upgrade tests
Cases:
- successful upgrade handshake
- text echo
- binary echo
- ping/pong behavior
- clean close handshake
- invalid upgrade headers rejected as HTTP errors
## Implementation phases
### Phase 1: internal HTTP server core
Implement:
- config
- buffered reader
- parser
- writer
- request loop
- fixed-body responses
- keep-alive
### Phase 2: server-side WebSocket upgrade
Implement:
- upgrade validation
- `101 Switching Protocols`
- WebSocket frame IO
- session object
- close and ping/pong handling
### Phase 3: host integration and optional Lyng exposure
Possible future work:
- host-facing convenience factory APIs
- Lyng module exposure if there is a clear scripting use case
- route helpers or lightweight dispatching
- JVM-specific richer backends if requirements grow
## Open questions
1. Should the first version expose only a Kotlin host API, or should it also be surfaced to Lyng scripts immediately?
2. Should response headers be represented as repeated `HttpHeader` entries only, or should a convenience builder API be added from the start?
3. Should the first version include a small path router helper, or should routing stay entirely in host code?
4. Should very small chunked response support be added later if keep-alive plus unknown response length becomes a real need, or should v1 require fully materialized responses only?
## Recommendation
Proceed with this strict HTTP/1.1 + WebSocket subset.
It is small enough to finish in common Kotlin, fits the current `lyngio` transport architecture, and avoids turning the project into a full protocol-stack implementation effort.

View File

@ -0,0 +1,25 @@
# Serialization Format Registry
Current status:
- no global serialization-format registry
- formats are exported explicitly from modules and used explicitly, e.g. `Lynon.encode(...)`, `Json.decode(...)`, or `MyFormat.encode(...)`
Why no registry now:
- explicit module exports already solve the current use case
- a registry adds global mutable state and naming semantics we do not currently need
- there is no current runtime feature that needs format discovery by string name
When a registry may become worth adding:
- config-driven format selection, e.g. `"format": "json"`
- host-side introspection such as "list installed serialization formats"
- collision detection across independently loaded modules
- admin or tooling APIs that need to resolve a format without importing its module explicitly
If added later, the registry should be:
- optional, not required for normal explicit usage
- based on stable fully qualified ids, not just short export names
- designed as a host/tooling facility first, not as part of ordinary script-level serialization

View File

@ -78,18 +78,73 @@ kotlin {
}
// Generate an index of markdown documents under project /docs as a JSON array
val generateSampleDocPages by tasks.registering {
group = "documentation"
description = "Generates Markdown wrapper pages for Lyng sample files"
val examplesDir = rootProject.projectDir.resolve("examples")
val docsSamplesDir = rootProject.projectDir.resolve("docs/samples")
val outDir = layout.buildDirectory.dir("generated-sample-docs/docs")
inputs.dir(examplesDir)
inputs.dir(docsSamplesDir)
outputs.dir(outDir)
doLast {
val outRoot = outDir.get().asFile
outRoot.mkdirs()
fun generateFrom(sourceRoot: java.io.File, targetSubdir: String) {
if (!sourceRoot.exists()) return
sourceRoot.walkTopDown()
.filter { it.isFile && it.extension.equals("lyng", ignoreCase = true) }
.forEach { source ->
val rel = sourceRoot.toPath().relativize(source.toPath()).toString().replace('\\', '/')
val target = outRoot.resolve("$targetSubdir/$rel.md")
target.parentFile.mkdirs()
val title = source.name
val sourceText = source.readText()
val body = buildString {
append("# ").append(title).append("\n\n")
append("Generated from `")
append(
when (targetSubdir) {
"examples" -> "examples/$rel"
"samples" -> "docs/samples/$rel"
else -> "$targetSubdir/$rel"
}
)
append("` during site build.\n\n")
append("```lyng\n")
append(sourceText)
if (!sourceText.endsWith("\n")) append('\n')
append("```\n")
}
target.writeText(body)
}
}
generateFrom(examplesDir, "examples")
generateFrom(docsSamplesDir, "samples")
}
}
val generateDocsIndex by tasks.registering {
group = "documentation"
description = "Generates docs-index.json listing all Markdown files under /docs"
val docsDir = rootProject.projectDir.resolve("docs")
val generatedDocsDir = layout.buildDirectory.dir("generated-sample-docs/docs")
val outDir = layout.buildDirectory.dir("generated-resources")
inputs.dir(docsDir)
inputs.dir(generatedDocsDir)
outputs.dir(outDir)
dependsOn(generateSampleDocPages)
doLast {
val docs = mutableListOf<String>()
val docs = linkedSetOf<String>()
if (docsDir.exists()) {
docsDir.walkTopDown()
.filter { it.isFile && it.extension.equals("md", ignoreCase = true) }
@ -100,6 +155,16 @@ val generateDocsIndex by tasks.registering {
docs += "docs/$rel"
}
}
val generatedRoot = generatedDocsDir.get().asFile
if (generatedRoot.exists()) {
generatedRoot.walkTopDown()
.filter { it.isFile && it.extension.equals("md", ignoreCase = true) }
.forEach { f ->
val rel = generatedRoot.toPath().relativize(f.toPath()).toString()
.replace('\\', '/')
docs += "docs/$rel"
}
}
val out = outDir.get().asFile
out.mkdirs()
val file = out.resolve("docs-index.json")
@ -113,7 +178,7 @@ val generateDocsIndex by tasks.registering {
append(']')
}
file.writeText(json)
println("Generated ${'$'}{file.absolutePath} with ${'$'}{docs.size} entries")
println("Generated ${file.absolutePath} with ${docs.size} entries")
}
}
@ -137,7 +202,7 @@ val generateSiteVersion by tasks.registering(Copy::class) {
// Ensure any ProcessResources task depends on docs index generation so the JSON is packaged
tasks.configureEach {
if (name.endsWith("ProcessResources")) {
dependsOn(generateDocsIndex, generateSiteVersion)
dependsOn(generateSampleDocPages, generateDocsIndex, generateSiteVersion)
}
}
@ -148,16 +213,20 @@ listOf(
"jsProcessResources"
).forEach { taskName ->
tasks.matching { it.name == taskName }.configureEach {
dependsOn(generateDocsIndex)
dependsOn(generateSampleDocPages, generateDocsIndex)
}
}
// Copy Markdown docs into the "docs/" folder in the final resources, so paths in docs-index.json match files
tasks.named<Copy>("jsProcessResources").configure {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// Ensure we don't end up with two copies at root; we no longer add docs as a plain resources srcDir
from(rootProject.projectDir.resolve("docs")) {
into("docs")
}
from(layout.buildDirectory.dir("generated-sample-docs/docs")) {
into("docs")
}
}
// Optional: configure toolchain if needed by the project; uses root Kotlin version from version catalog

View File

@ -176,7 +176,7 @@ private fun TocNav(
Ul({ classes("list-unstyled", "mb-0") }) {
toc.forEach { item ->
Li({ classes("mb-1") }) {
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
val pad = "${(item.level - 1) * 0.75}rem"
val routeNoFrag = route.substringBefore('#')
val tocHref = "#/$routeNoFrag#${item.id}"
A(attrs = {

Some files were not shown because too many files have changed in this diff Show More