Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31fac1a73c | |||
| 53a9d21a19 | |||
| 739fdfc94b | |||
| c8e03d69ad | |||
| b2200e71ff | |||
| e107296bca | |||
| 1bababa058 | |||
| 35f4c968a4 | |||
| 79429d5f2d | |||
| fae9965bdf | |||
| 2dc4fb8230 | |||
| f74ed9afe4 | |||
| ca4a0d4b12 | |||
| b969edd30a | |||
| 01ceecd7df | |||
| 79b015ee56 | |||
| eba7158330 | |||
| 66b8806b11 | |||
| 92e9325f40 | |||
| 50e34e520e | |||
| 2abe7e2f96 | |||
| 2bedaa0969 |
@ -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(...)`.
|
||||
|
||||
41
docs/OOP.md
41
docs/OOP.md
@ -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`).
|
||||
|
||||
@ -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.
|
||||
|
||||
14
docs/ai_notes_cli_release.md
Normal file
14
docs/ai_notes_cli_release.md
Normal 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.
|
||||
10
docs/ai_notes_docs_headings.md
Normal file
10
docs/ai_notes_docs_headings.md
Normal 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 `###`.
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
164
docs/lyng.io.html.md
Normal 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 & <more></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.
|
||||
@ -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
446
docs/lyng.io.http.server.md
Normal 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)`
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
50
docs/samples/html_builder_dsl.lyng
Normal file
50
docs/samples/html_builder_dsl.lyng
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
19
examples/http_server.lyng
Normal file
19
examples/http_server.lyng
Normal 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")
|
||||
56
examples/sqlite_serialization.lyng
Normal file
56
examples/sqlite_serialization.lyng
Normal 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")
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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))
|
||||
}
|
||||
@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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")
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'
|
||||
)
|
||||
@ -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"
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
@ -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=\""quoted" & <tag>\">Text & <more></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 & mark\"><input type=\"hidden\" name=\"token\" value=\""abc"\"></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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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&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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
142
lyngio/stdlib/lyng/io/html.lyng
Normal file
142
lyngio/stdlib/lyng/io/html.lyng
Normal file
@ -0,0 +1,142 @@
|
||||
package lyng.io.html
|
||||
|
||||
import lyng.stdlib
|
||||
|
||||
fun escapeHtml(text: String): String {
|
||||
val amp: String = text.replace("&", "&")
|
||||
val lt: String = amp.replace("<", "<")
|
||||
lt.replace(">", ">")
|
||||
}
|
||||
|
||||
fun escapeHtmlAttr(text: String): String {
|
||||
val escaped: String = escapeHtml(text)
|
||||
val quoted: String = escaped.replace("\"", """)
|
||||
quoted.replace("'", "'")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
88
lyngio/stdlib/lyng/io/http_server.lyng
Normal file
88
lyngio/stdlib/lyng/io/http_server.lyng
Normal 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
|
||||
}
|
||||
14
lyngio/stdlib/lyng/io/http_types.lyng
Normal file
14
lyngio/stdlib/lyng/io/http_types.lyng
Normal 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>
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
27
lyngio/stdlib/lyng/io/net_types.lyng
Normal file
27
lyngio/stdlib/lyng/io/net_types.lyng
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
11
lyngio/stdlib/lyng/io/ws_types.lyng
Normal file
11
lyngio/stdlib/lyng/io/ws_types.lyng
Normal 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?
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
@ -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())
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) }}"
|
||||
|
||||
@ -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'")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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("""
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 */"
|
||||
|
||||
@ -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. */
|
||||
|
||||
351
notes/db/resultset_decode_api.md
Normal file
351
notes/db/resultset_decode_api.md
Normal 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
|
||||
600
proposals/lyngio_minimal_http_server.md
Normal file
600
proposals/lyngio_minimal_http_server.md
Normal 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.
|
||||
25
proposals/serialization_format_registry.md
Normal file
25
proposals/serialization_format_registry.md
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user