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.
|
- 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.
|
- 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.
|
- 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
|
## Kotlin/Wasm generation guardrails
|
||||||
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.
|
- 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(...)`.
|
- For arbitrary receivers, use casts: `(expr as Type).member(...)` or `(expr as? Type)?.member(...)`.
|
||||||
- Qualified access does not relax visibility.
|
- 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
|
- 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).
|
- 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.
|
- 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 |
|
| Operator | Method Name |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
|
| `+a` | `unaryPlus()` |
|
||||||
| `-a` | `negate()` |
|
| `-a` | `negate()` |
|
||||||
| `!a` | `logicalNot()` |
|
| `!a` | `logicalNot()` |
|
||||||
| `~a` | `bitNot()` |
|
| `~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
|
||||||
|
|
||||||
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`).
|
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)
|
## 4. Operators (implemented)
|
||||||
- Assignment: `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `?=`.
|
- Assignment: `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `?=`.
|
||||||
- Logical: `||`, `&&`, unary `!`.
|
- Logical: `||`, `&&`, unary `!`.
|
||||||
|
- Unary arithmetic/bitwise: unary `+`, unary `-`, `~`.
|
||||||
- Bitwise: `|`, `^`, `&`, `~`, shifts `<<`, `>>`.
|
- Bitwise: `|`, `^`, `&`, `~`, shifts `<<`, `>>`.
|
||||||
- Equality/comparison: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`, `<=>`, `=~`, `!~`.
|
- Equality/comparison: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`, `<=>`, `=~`, `!~`.
|
||||||
- Type/containment: `is`, `!is`, `in`, `!in`, `as`, `as?`.
|
- 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`.
|
- shorthand: `fun f(x) = expr`.
|
||||||
- generics: `fun f<T>(x: T): T`.
|
- generics: `fun f<T>(x: T): T`.
|
||||||
- extension functions: `fun Type.name(...) { ... }`.
|
- 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() = ...`.
|
- 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(...)`.
|
- static extension functions are callable on the type object: `static fun List<T>.fill(...)` -> `List.fill(...)`.
|
||||||
- delegated callable: `fun f(...) by delegate`.
|
- 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`
|
- unions `A | B`
|
||||||
- intersections `A & B`
|
- intersections `A & B`
|
||||||
- function types `(A, B)->R` and receiver form `Receiver.(A)->R`
|
- 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...`)
|
- 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:
|
- Generics:
|
||||||
- type params on classes/functions/type aliases
|
- type params on classes/functions/type aliases
|
||||||
- bounds via `:` with union/intersection expressions
|
- 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:
|
- Disambiguation helpers are supported:
|
||||||
- qualified this: `this@Base.member()`
|
- qualified this: `this@Base.member()`
|
||||||
- cast view: `(obj as 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:
|
- On unknown receiver types, compiler allows only Object-safe members:
|
||||||
- `toString`, `toInspectString`, `let`, `also`, `apply`, `run`
|
- `toString`, `toInspectString`, `let`, `also`, `apply`, `run`
|
||||||
- Other members require known receiver type or explicit cast.
|
- 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.process` (process execution API)
|
||||||
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events)
|
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events)
|
||||||
- `import lyng.io.http` (HTTP/HTTPS client API)
|
- `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.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.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
|
## 7. AI Generation Tips
|
||||||
- Assume `lyng.stdlib` APIs exist in regular script contexts.
|
- 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
|
- 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).
|
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)
|
Your ideas and bugreports are welcome on the [project gitea page](https://gitea.sergeych.net/SergeychWorks/lyng/issues)
|
||||||
|
|||||||
@ -1,9 +1,32 @@
|
|||||||
# Json support
|
# Json support
|
||||||
|
|
||||||
Since 1.0.5 we start adding JSON support. Versions 1,0,6* support serialization of the basic types, including lists and
|
Lyng now has two distinct JSON-facing layers:
|
||||||
maps, and simple classes. Multiple inheritance may produce incorrect results, it is work in progress.
|
|
||||||
|
|
||||||
## 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
|
// in lyng
|
||||||
assertEquals("{\"a\":1}", {a: 1}.toJsonString())
|
assertEquals("{\"a\":1}", {a: 1}.toJsonString())
|
||||||
@ -20,7 +43,8 @@ Simple classes serialization is supported:
|
|||||||
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
|
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
|
||||||
>>> void
|
>>> 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
|
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() )
|
assertEquals( "{\"bar\":2,\"visible\":100}", Point2(1,2).toJsonString() )
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
Note that if you override json serialization:
|
Note that if you override plain JSON serialization:
|
||||||
|
|
||||||
import lyng.serialization
|
import lyng.serialization
|
||||||
|
|
||||||
@ -46,8 +70,8 @@ Note that if you override json serialization:
|
|||||||
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
|
assertEquals( "{\"custom\":true}", Point2(1,2).toJsonString() )
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
Custom serialization of user classes is possible by overriding `toJsonObject` method. It must return an object which is
|
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, that makes it very flexible:
|
serializable to JSON. Most often it is a map, but any object is accepted:
|
||||||
|
|
||||||
import lyng.serialization
|
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
|
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.
|
`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
|
## Kotlin side interfaces
|
||||||
|
|
||||||
The "Batteries included" principle is also applied to serialization.
|
The "Batteries included" principle is also applied to serialization.
|
||||||
|
|
||||||
- `Obj.toJson()` provides Kotlin `JsonElement`
|
- `Obj.toJson()` provides Kotlin `JsonElement` for the plain JSON projection
|
||||||
- `Obj.toJsonString()` provides Json string representation
|
- `Obj.toJsonString()` provides plain JSON string representation
|
||||||
- `Obj.decodeSerializableWith()` and `Obj.decodeSerializable()` allows to decode Lyng classes as Kotlin objects using
|
- `Obj.decodeSerializableWith()` and `Obj.decodeSerializable()` allows to decode Lyng classes as Kotlin objects using
|
||||||
`kotlinx.serialization`:
|
`kotlinx.serialization`:
|
||||||
|
|
||||||
@ -104,10 +203,9 @@ suspend inline fun <reified T> Obj.decodeSerializable(scope: Scope = Scope()) =
|
|||||||
decodeSerializableWith<T>(serializer<T>(), scope)
|
decodeSerializableWith<T>(serializer<T>(), scope)
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that lyng-2-kotlin deserialization with `kotlinx.serialization` uses JsonElement as information carrier without
|
Note that Lyng-to-Kotlin deserialization with `kotlinx.serialization` is based on the plain JSON projection,
|
||||||
formatting and parsing actual Json strings. This is why we use `Json.decodeFromJsonElement` instead of
|
not the canonical `Json.encode()` format. It uses `JsonElement` as the information carrier without formatting and
|
||||||
`Json.decodeFromString`. Such an approach gives satisfactory performance without writing and supporting custom
|
parsing actual JSON strings. This is why we use `Json.decodeFromJsonElement` instead of `Json.decodeFromString`.
|
||||||
`kotlinx.serialization` codecs.
|
|
||||||
|
|
||||||
### Pitfall: JSON objects and Map<String, Any?>
|
### 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" }`?
|
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
|
~~~kotlin
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -154,26 +253,71 @@ fun deserializeAnyMapWithJsonTest() = runTest {
|
|||||||
~~~
|
~~~
|
||||||
|
|
||||||
|
|
||||||
# List of supported types
|
## Supported shapes
|
||||||
|
|
||||||
|
### Plain JSON projection
|
||||||
|
|
||||||
| Lyng type | JSON type | notes |
|
| Lyng type | JSON type | notes |
|
||||||
|-----------|-----------|-------------|
|
|-----------|-----------|-------------|
|
||||||
| `Int` | number | |
|
| `Int` | number | |
|
||||||
| `Real` | number | |
|
| `Real` | number | finite values only as plain numbers |
|
||||||
| `String` | string | |
|
| `String` | string | |
|
||||||
| `Bool` | boolean | |
|
| `Bool` | boolean | |
|
||||||
| `null` | null | |
|
| `null` | null | |
|
||||||
| `Instant` | string | ISO8601 (1) |
|
| `Instant` | string | ISO8601 (1) |
|
||||||
| `List` | array | (2) |
|
| `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)
|
(1)
|
||||||
: ISO8601 flavor 1970-05-06T06:00:00.000Z in used; number of fractional digits depends on the truncation
|
: ISO8601 flavor `1970-05-06T06:00:00.000Z` is used; number of fractional digits depends on truncation on
|
||||||
on [Instant](time.md), see `Instant.truncateTo...` functions.
|
`Instant`, see `Instant.truncateTo...` functions.
|
||||||
|
|
||||||
(2)
|
(2)
|
||||||
: List may contain any objects serializable to Json.
|
: Lists may contain any values serializable by the selected JSON layer.
|
||||||
|
|
||||||
(3)
|
|
||||||
: Map keys must be strings, map values may be any objects serializable to Json.
|
|
||||||
|
|||||||
@ -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`.
|
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:
|
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:
|
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.
|
- `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.
|
- `select(clause, params...)` — execute a statement whose primary result is a row set.
|
||||||
- `execute(clause, params...)` — execute a side-effect statement and return `ExecutionResult`.
|
- `execute(clause, params...)` — execute a side-effect statement and return `ExecutionResult`.
|
||||||
- `transaction(block)` — nested transaction with real savepoint semantics.
|
- `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.
|
- `columns` — positional `SqlColumn` metadata, available before iteration.
|
||||||
- `size()` — result row count.
|
- `size()` — result row count.
|
||||||
- `isEmpty()` — fast emptiness check where possible.
|
- `isEmpty()` — fast emptiness check where possible.
|
||||||
- `iterator()` — normal row iteration while the transaction is active.
|
- `iterator()` — normal row iteration while the transaction is active.
|
||||||
- `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends.
|
- `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[index]` — zero-based positional access.
|
||||||
- `row["columnName"]` — case-insensitive lookup by output column label.
|
- `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.
|
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`
|
- `affectedRowsCount`
|
||||||
- `getGeneratedKeys()`
|
- `getGeneratedKeys()`
|
||||||
@ -224,7 +288,7 @@ Statements that return rows directly, such as `... returning ...`, should use `s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Value mapping
|
## Value mapping
|
||||||
|
|
||||||
Portable bind values:
|
Portable bind values:
|
||||||
|
|
||||||
@ -237,6 +301,88 @@ Portable bind values:
|
|||||||
|
|
||||||
Unsupported parameter values fail with `SqlUsageException`.
|
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:
|
Portable result metadata categories:
|
||||||
|
|
||||||
- `Binary`
|
- `Binary`
|
||||||
@ -249,11 +395,27 @@ Portable result metadata categories:
|
|||||||
- `DateTime`
|
- `DateTime`
|
||||||
- `Instant`
|
- `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).
|
For temporal types, see [time functions](time.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### SQLite provider
|
## SQLite provider
|
||||||
|
|
||||||
`lyng.io.db.sqlite` currently provides the first concrete backend.
|
`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`
|
- malformed URL or bad option shape -> `IllegalArgumentException`
|
||||||
- runtime open failure -> `DatabaseException`
|
- 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:
|
`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.
|
`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
|
- do not keep `ResultSet` objects after the transaction block returns
|
||||||
- materialize rows with `toList()` inside the transaction when they must outlive it
|
- 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.
|
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` — generic contract, available when host code installs it
|
||||||
- `lyng.io.db.sqlite` — implemented on JVM and Linux Native in the current release tree
|
- `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.
|
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.
|
> **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`:
|
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.
|
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:
|
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.
|
- `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.
|
- `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"`
|
- `MapEntry`, e.g. `"Accept" => "text/plain"`
|
||||||
- 2-item lists, e.g. `["Accept", "text/plain"]`
|
- 2-item lists, e.g. `["Accept", "text/plain"]`
|
||||||
|
|
||||||
##### `HttpRequest`
|
### `HttpRequest`
|
||||||
|
|
||||||
- `method: String`
|
- `method: String`
|
||||||
- `url: String`
|
- `url: String`
|
||||||
@ -110,7 +112,7 @@ For convenience methods, `headers...` accepts:
|
|||||||
|
|
||||||
Only one of `bodyText` and `bodyBytes` should be set.
|
Only one of `bodyText` and `bodyBytes` should be set.
|
||||||
|
|
||||||
##### `HttpResponse`
|
### `HttpResponse`
|
||||||
|
|
||||||
- `status: Int`
|
- `status: Int`
|
||||||
- `statusText: String`
|
- `statusText: String`
|
||||||
@ -120,7 +122,7 @@ Only one of `bodyText` and `bodyBytes` should be set.
|
|||||||
|
|
||||||
Response body decoding is cached inside the response object.
|
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:
|
`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.
|
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
|
- **JVM:** supported
|
||||||
- **Android:** supported via the Ktor CIO client backend
|
- **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.
|
> **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.
|
> **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.
|
> **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.
|
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.
|
> **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
|
```kotlin
|
||||||
import net.sergeych.lyng.EvalSession
|
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")
|
ws.sendText("ping")
|
||||||
val m: WsMessage = ws.receive()
|
val reply = ws.receive() ?: error("socket closed before reply")
|
||||||
|
println(reply.text)
|
||||||
|
} finally {
|
||||||
ws.close()
|
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
|
```lyng
|
||||||
import lyng.io.ws
|
import lyng.io.ws
|
||||||
|
|
||||||
val ws = Ws.connect(WS_TEST_BINARY_URL)
|
val ws = Ws.connect(WS_URL)
|
||||||
ws.sendBytes(Buffer(9, 8, 7))
|
|
||||||
val m: WsMessage = ws.receive()
|
try {
|
||||||
|
while (true) {
|
||||||
|
val msg = ws.receive() ?: break
|
||||||
|
if (msg.isText) {
|
||||||
|
println(msg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
ws.close()
|
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)
|
- `isSupported(): Bool` - whether WebSocket client support is available on the current runtime.
|
||||||
ws.sendText("ping")
|
- `connect(url: String, headers...): WsSession` - open a client websocket session.
|
||||||
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.
|
|
||||||
|
|
||||||
`headers...` accepts:
|
`headers...` accepts:
|
||||||
|
- `MapEntry`, for example `"Authorization" => "Bearer x"`
|
||||||
|
- 2-item lists, for example `["Authorization", "Bearer x"]`
|
||||||
|
|
||||||
- `MapEntry`, e.g. `"Authorization" => "Bearer x"`
|
### `WsSession`
|
||||||
- 2-item lists, e.g. `["Authorization", "Bearer x"]`
|
|
||||||
|
|
||||||
##### `WsSession`
|
|
||||||
|
|
||||||
- `isOpen(): Bool`
|
- `isOpen(): Bool`
|
||||||
- `url(): String`
|
- `url(): String`
|
||||||
@ -85,24 +215,27 @@ Secure websocket (`wss`) exchange:
|
|||||||
- `receive(): WsMessage?`
|
- `receive(): WsMessage?`
|
||||||
- `close(code: Int = 1000, reason: String = ""): void`
|
- `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`
|
- `isText: Bool`
|
||||||
- `text: String?`
|
- `text: String?`
|
||||||
- `data: Buffer?`
|
- `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.
|
The module uses `WsAccessPolicy` to authorize websocket operations.
|
||||||
|
|
||||||
- `WsAccessPolicy` — interface for custom policies
|
- `WsAccessPolicy` - interface for custom policies.
|
||||||
- `PermitAllWsAccessPolicy` — allows all websocket operations
|
- `PermitAllWsAccessPolicy` - allows all websocket operations.
|
||||||
- `WsAccessOp.Connect(url)`
|
- `WsAccessOp.Connect(url)`
|
||||||
- `WsAccessOp.Send(url, bytes, isText)`
|
- `WsAccessOp.Send(url, bytes, isText)`
|
||||||
- `WsAccessOp.Receive(url)`
|
- `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.
|
||||||
- **JVM:** supported
|
- **JS:** supported via the Ktor JS websocket client backend.
|
||||||
- **Android:** supported via the Ktor CIO websocket client backend
|
- **Linux native:** supported via the Ktor Curl websocket client backend.
|
||||||
- **JS:** supported via the Ktor JS websocket client backend
|
- **Windows native:** supported via the Ktor WinHttp websocket client backend.
|
||||||
- **Linux native:** supported via the Ktor Curl websocket client backend
|
- **Apple native:** supported via the Ktor Darwin websocket client backend.
|
||||||
- **Windows native:** supported via the Ktor WinHttp websocket client backend
|
- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access.
|
||||||
- **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.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information.
|
||||||
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
|
- **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events.
|
||||||
- **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`.
|
- **[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.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`.
|
- **[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)
|
- [Process Security Details](lyng.io.process.md#security-policy)
|
||||||
- [Console Module Details](lyng.io.console.md)
|
- [Console Module Details](lyng.io.console.md)
|
||||||
- [HTTP Module Details](lyng.io.http.md)
|
- [HTTP Module Details](lyng.io.http.md)
|
||||||
|
- [HTTP Server Module Details](lyng.io.http.server.md)
|
||||||
- [Transport Networking Details](lyng.io.net.md)
|
- [Transport Networking Details](lyng.io.net.md)
|
||||||
- [WebSocket Module Details](lyng.io.ws.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:
|
Benchmark target:
|
||||||
- [examples/pi-bench.py](/home/sergeych/dev/lyng/examples/pi-bench.py)
|
- [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:
|
Execution path:
|
||||||
- Python: `python3 examples/pi-bench.py`
|
- 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
|
// Sample: Operator Overloading in Lyng
|
||||||
|
|
||||||
class Vector<T>(val x: T, val y: T) {
|
class Vector<T>(val x: T, val y: T) {
|
||||||
|
// Overload unary +
|
||||||
|
fun unaryPlus() = this
|
||||||
|
|
||||||
// Overload +
|
// Overload +
|
||||||
fun plus(other: Vector<U>) = Vector(x + other.x, y + other.y)
|
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("v1: " + v1)
|
||||||
println("v2: " + v2)
|
println("v2: " + v2)
|
||||||
|
|
||||||
|
// Test unary +
|
||||||
|
val v0 = +v1
|
||||||
|
println("+v1 = " + v0)
|
||||||
|
assertEquals(Vector(10, 20), v0)
|
||||||
|
|
||||||
// Test binary +
|
// Test binary +
|
||||||
val v3 = v1 + v2
|
val v3 = v1 + v2
|
||||||
println("v1 + v2 = " + v3)
|
println("v1 + v2 = " + v3)
|
||||||
|
|||||||
@ -1,6 +1,40 @@
|
|||||||
# Lyng serialization
|
# 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:
|
It is as simple as:
|
||||||
|
|
||||||
@ -20,7 +54,8 @@ It is as simple as:
|
|||||||
assert( text.length > (encodedBits.toBuffer() as Buffer).size )
|
assert( text.length > (encodedBits.toBuffer() as Buffer).size )
|
||||||
>>> void
|
>>> 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
|
## Transient Fields
|
||||||
|
|
||||||
@ -40,7 +75,7 @@ class MyData(@Transient val tempSecret, val publicData) {
|
|||||||
|
|
||||||
Transient fields:
|
Transient fields:
|
||||||
- Are **omitted** from Lynon binary streams.
|
- 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 (`==`).
|
- 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`.
|
- 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.
|
- 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`.
|
- **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`).
|
- **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:
|
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:
|
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()
|
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)
|
assertEquals(3, sum)
|
||||||
>>> void
|
>>> 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
|
## run
|
||||||
|
|
||||||
Executes a block after it returning the value passed by the block. for example, can be used with elvis operator:
|
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
|
```lyng
|
||||||
object Config {
|
object Config {
|
||||||
val version = "1.5.5"
|
val version = "1.5.6-SNAPSHOT"
|
||||||
fun show() = println("Config version: " + version)
|
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
|
package net.sergeych
|
||||||
|
|
||||||
import com.github.ajalt.clikt.core.CoreCliktCommand
|
|
||||||
import com.github.ajalt.clikt.core.Context
|
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.main
|
||||||
import com.github.ajalt.clikt.core.subcommands
|
import com.github.ajalt.clikt.core.subcommands
|
||||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
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.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.sergeych.lyng.EvalSession
|
import net.sergeych.lyng.EvalSession
|
||||||
|
import net.sergeych.lyng.ExecutionError
|
||||||
import net.sergeych.lyng.LyngVersion
|
import net.sergeych.lyng.LyngVersion
|
||||||
import net.sergeych.lyng.Pos
|
import net.sergeych.lyng.Pos
|
||||||
import net.sergeych.lyng.Scope
|
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.jdbc.createJdbcModule
|
||||||
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
import net.sergeych.lyng.io.db.sqlite.createSqliteModule
|
||||||
import net.sergeych.lyng.io.fs.createFs
|
import net.sergeych.lyng.io.fs.createFs
|
||||||
|
import net.sergeych.lyng.io.html.createHtmlModule
|
||||||
import net.sergeych.lyng.io.http.createHttpModule
|
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.net.createNetModule
|
||||||
import net.sergeych.lyng.io.ws.createWsModule
|
import net.sergeych.lyng.io.ws.createWsModule
|
||||||
import net.sergeych.lyng.obj.*
|
import net.sergeych.lyng.obj.*
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
import net.sergeych.lyng.pacman.ImportManager
|
||||||
import net.sergeych.lyngio.net.shutdownSystemNetEngine
|
|
||||||
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
|
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
|
||||||
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
|
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
|
||||||
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
|
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
|
||||||
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
|
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
|
||||||
|
import net.sergeych.lyngio.net.shutdownSystemNetEngine
|
||||||
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
|
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
|
||||||
import net.sergeych.mp_tools.globalDefer
|
import net.sergeych.mp_tools.globalDefer
|
||||||
import okio.*
|
import okio.*
|
||||||
@ -144,7 +147,9 @@ private fun ImportManager.invalidateCliModuleCaches() {
|
|||||||
invalidatePackageCache("lyng.io.console")
|
invalidatePackageCache("lyng.io.console")
|
||||||
invalidatePackageCache("lyng.io.db.jdbc")
|
invalidatePackageCache("lyng.io.db.jdbc")
|
||||||
invalidatePackageCache("lyng.io.db.sqlite")
|
invalidatePackageCache("lyng.io.db.sqlite")
|
||||||
|
invalidatePackageCache("lyng.io.html")
|
||||||
invalidatePackageCache("lyng.io.http")
|
invalidatePackageCache("lyng.io.http")
|
||||||
|
invalidatePackageCache("lyng.io.http.server")
|
||||||
invalidatePackageCache("lyng.io.ws")
|
invalidatePackageCache("lyng.io.ws")
|
||||||
invalidatePackageCache("lyng.io.net")
|
invalidatePackageCache("lyng.io.net")
|
||||||
}
|
}
|
||||||
@ -153,8 +158,8 @@ val baseScopeDefer = globalDefer {
|
|||||||
baseCliImportManagerDefer.await().copy().apply {
|
baseCliImportManagerDefer.await().copy().apply {
|
||||||
invalidateCliModuleCaches()
|
invalidateCliModuleCaches()
|
||||||
}.newStdScope().apply {
|
}.newStdScope().apply {
|
||||||
installCliDeclarations()
|
|
||||||
installCliBuiltins()
|
installCliBuiltins()
|
||||||
|
installCliDeclarations()
|
||||||
addConst("ARGV", ObjList(mutableListOf()))
|
addConst("ARGV", ObjList(mutableListOf()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,7 +239,9 @@ private fun installCliModules(manager: ImportManager) {
|
|||||||
createDbModule(manager)
|
createDbModule(manager)
|
||||||
createJdbcModule(manager)
|
createJdbcModule(manager)
|
||||||
createSqliteModule(manager)
|
createSqliteModule(manager)
|
||||||
|
createHtmlModule(manager)
|
||||||
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
||||||
|
createHttpServerModule(PermitAllNetAccessPolicy, manager)
|
||||||
createWsModule(PermitAllWsAccessPolicy, manager)
|
createWsModule(PermitAllWsAccessPolicy, manager)
|
||||||
createNetModule(PermitAllNetAccessPolicy, 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 =
|
private suspend fun ImportManager.newCliScope(argv: List<String>): Scope =
|
||||||
newStdScope().apply {
|
newStdScope().apply {
|
||||||
installCliDeclarations()
|
|
||||||
installCliBuiltins()
|
installCliBuiltins()
|
||||||
|
installCliDeclarations()
|
||||||
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
|
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,6 +554,15 @@ suspend fun executeSource(source: Source, initialScope: Scope? = null) {
|
|||||||
evalOnCliDispatcher(session, source)
|
evalOnCliDispatcher(session, source)
|
||||||
} catch (e: CliExitRequested) {
|
} catch (e: CliExitRequested) {
|
||||||
requestedExitCode = e.code
|
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 {
|
} finally {
|
||||||
shutdownHooks.uninstall()
|
shutdownHooks.uninstall()
|
||||||
|
|||||||
@ -73,10 +73,12 @@ class CliNetworkJvmTest {
|
|||||||
try {
|
try {
|
||||||
val script = """
|
val script = """
|
||||||
import lyng.io.http
|
import lyng.io.http
|
||||||
|
import lyng.io.http.server
|
||||||
import lyng.io.ws
|
import lyng.io.ws
|
||||||
import lyng.io.net
|
import lyng.io.net
|
||||||
|
|
||||||
assert(Http.isSupported())
|
assert(Http.isSupported())
|
||||||
|
assert(HttpServer() is HttpServer)
|
||||||
println("ws=" + Ws.isSupported())
|
println("ws=" + Ws.isSupported())
|
||||||
println("net=" + Net.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.HttpAccessOp
|
||||||
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
import net.sergeych.lyngio.http.security.HttpAccessPolicy
|
||||||
import net.sergeych.lyngio.stdlib_included.httpLyng
|
import net.sergeych.lyngio.stdlib_included.httpLyng
|
||||||
|
import net.sergeych.lyngio.stdlib_included.http_typesLyng
|
||||||
|
|
||||||
private const val HTTP_MODULE_NAME = "lyng.io.http"
|
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 =
|
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
|
||||||
createHttpModule(policy, scope.importManager)
|
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 createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
|
||||||
|
|
||||||
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createHttpTypesModule(manager)
|
||||||
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
|
||||||
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
manager.addPackage(HTTP_MODULE_NAME) { module ->
|
||||||
buildHttpModule(module, policy)
|
buildHttpModule(module, policy)
|
||||||
@ -64,6 +67,19 @@ fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean
|
|||||||
|
|
||||||
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
|
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) {
|
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
|
||||||
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
|
||||||
val engine = getSystemHttpEngine()
|
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(),
|
singleValueHeaders: Map<String, String> = emptyMap(),
|
||||||
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
private val allHeaders: Map<String, List<String>> = emptyMap(),
|
||||||
) : Obj() {
|
) : Obj() {
|
||||||
@ -201,6 +217,11 @@ private class ObjHttpHeaders(
|
|||||||
).invokeInstanceMethod(requireScope(), "iterator")
|
).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()
|
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.NetAccessOp
|
||||||
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
import net.sergeych.lyngio.net.security.NetAccessPolicy
|
||||||
import net.sergeych.lyngio.stdlib_included.netLyng
|
import net.sergeych.lyngio.stdlib_included.netLyng
|
||||||
|
import net.sergeych.lyngio.stdlib_included.net_typesLyng
|
||||||
|
|
||||||
private const val NET_MODULE_NAME = "lyng.io.net"
|
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 =
|
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
|
||||||
createNetModule(policy, scope.importManager)
|
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 createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
|
||||||
|
|
||||||
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createNetTypesModule(manager)
|
||||||
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
|
||||||
manager.addPackage(NET_MODULE_NAME) { module ->
|
manager.addPackage(NET_MODULE_NAME) { module ->
|
||||||
buildNetModule(module, policy)
|
buildNetModule(module, policy)
|
||||||
@ -65,6 +68,21 @@ fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
|
|||||||
|
|
||||||
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
|
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) {
|
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
|
||||||
module.eval(Source(NET_MODULE_NAME, netLyng))
|
module.eval(Source(NET_MODULE_NAME, netLyng))
|
||||||
val engine = getSystemNetEngine()
|
val engine = getSystemNetEngine()
|
||||||
@ -164,10 +182,12 @@ private class ObjSocketAddress(
|
|||||||
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
|
||||||
|
|
||||||
companion object {
|
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 =
|
fun type(enumValues: NetEnumValues): ObjClass =
|
||||||
types.getOrPut(enumValues) {
|
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||||
object : ObjClass("SocketAddress") {
|
object : ObjClass("SocketAddress") {
|
||||||
override suspend fun callOn(scope: Scope): Obj {
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
scope.raiseError("SocketAddress cannot be created directly")
|
scope.raiseError("SocketAddress cannot be created directly")
|
||||||
@ -191,10 +211,12 @@ private class ObjDatagram(
|
|||||||
get() = type(enumValues)
|
get() = type(enumValues)
|
||||||
|
|
||||||
companion object {
|
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 =
|
fun type(enumValues: NetEnumValues): ObjClass =
|
||||||
types.getOrPut(enumValues) {
|
types.getOrPut(EnumKey(enumValues.ipv4, enumValues.ipv6)) {
|
||||||
object : ObjClass("Datagram") {
|
object : ObjClass("Datagram") {
|
||||||
override suspend fun callOn(scope: Scope): Obj {
|
override suspend fun callOn(scope: Scope): Obj {
|
||||||
scope.raiseError("Datagram cannot be created directly")
|
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.requireNoArgs
|
||||||
import net.sergeych.lyng.requireScope
|
import net.sergeych.lyng.requireScope
|
||||||
import net.sergeych.lyngio.stdlib_included.wsLyng
|
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.LyngWsEngine
|
||||||
import net.sergeych.lyngio.ws.LyngWsMessage
|
import net.sergeych.lyngio.ws.LyngWsMessage
|
||||||
import net.sergeych.lyngio.ws.LyngWsSession
|
import net.sergeych.lyngio.ws.LyngWsSession
|
||||||
@ -46,6 +47,7 @@ import net.sergeych.lyngio.ws.security.WsAccessOp
|
|||||||
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
import net.sergeych.lyngio.ws.security.WsAccessPolicy
|
||||||
|
|
||||||
private const val WS_MODULE_NAME = "lyng.io.ws"
|
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 =
|
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
|
||||||
createWsModule(policy, scope.importManager)
|
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 createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
|
||||||
|
|
||||||
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
||||||
|
createWsTypesModule(manager)
|
||||||
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
|
||||||
manager.addPackage(WS_MODULE_NAME) { module ->
|
manager.addPackage(WS_MODULE_NAME) { module ->
|
||||||
buildWsModule(module, policy)
|
buildWsModule(module, policy)
|
||||||
@ -62,6 +65,19 @@ fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
|
|||||||
|
|
||||||
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
|
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) {
|
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
|
||||||
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
module.eval(Source(WS_MODULE_NAME, wsLyng))
|
||||||
val engine = getSystemWsEngine()
|
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,
|
private val message: LyngWsMessage,
|
||||||
) : Obj() {
|
) : Obj() {
|
||||||
override val objClass: ObjClass
|
override val objClass: ObjClass
|
||||||
@ -112,6 +128,8 @@ private class ObjWsMessage(
|
|||||||
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
|
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") {
|
addFn("receive") {
|
||||||
val self = thisAs<ObjWsSession>()
|
val self = thisAs<ObjWsSession>()
|
||||||
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
self.policy.require(WsAccessOp.Receive(self.targetUrl))
|
||||||
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
|
self.session.receive()?.let(ObjWsMessage::from) ?: ObjNull
|
||||||
}
|
}
|
||||||
addFn("close") {
|
addFn("close") {
|
||||||
val self = thisAs<ObjWsSession>()
|
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)
|
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
|
@Test
|
||||||
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
||||||
val scope = Script.newScope()
|
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 kotlinx.coroutines.withContext
|
||||||
import net.sergeych.lyng.Compiler
|
import net.sergeych.lyng.Compiler
|
||||||
import net.sergeych.lyng.ExecutionError
|
import net.sergeych.lyng.ExecutionError
|
||||||
|
import net.sergeych.lyng.Pos
|
||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyngio.fs.security.AccessContext
|
import net.sergeych.lyngio.fs.security.AccessContext
|
||||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||||
@ -35,11 +36,25 @@ import java.net.ServerSocket
|
|||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertSame
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class LyngNetModuleTest {
|
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
|
@Test
|
||||||
fun testResolveAndCapabilities() = runBlocking {
|
fun testResolveAndCapabilities() = runBlocking {
|
||||||
val scope = Script.newScope()
|
val scope = Script.newScope()
|
||||||
|
|||||||
@ -19,11 +19,13 @@ package net.sergeych.lyng.io.db.sqlite
|
|||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import net.sergeych.lyng.Compiler
|
||||||
import net.sergeych.lyng.ExecutionError
|
import net.sergeych.lyng.ExecutionError
|
||||||
import net.sergeych.lyng.ModuleScope
|
import net.sergeych.lyng.ModuleScope
|
||||||
import net.sergeych.lyng.Pos
|
import net.sergeych.lyng.Pos
|
||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
|
import net.sergeych.lyng.Source
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
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"))))
|
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
|
@Test
|
||||||
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
||||||
val scope = Script.newScope()
|
val scope = Script.newScope()
|
||||||
|
|||||||
@ -20,6 +20,38 @@ extern class SqlColumn {
|
|||||||
val nativeType: String
|
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 {
|
extern class SqlRow {
|
||||||
/* Number of columns in the row */
|
/* Number of columns in the row */
|
||||||
val size: Int
|
val size: Int
|
||||||
@ -34,6 +66,22 @@ extern class SqlRow {
|
|||||||
names and invalid indexes should also fail.
|
names and invalid indexes should also fail.
|
||||||
*/
|
*/
|
||||||
override fun getAt(indexOrName: String | Int): Object?
|
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.
|
internally, but this must not change visible later iteration behavior.
|
||||||
*/
|
*/
|
||||||
override fun isEmpty(): Bool
|
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 {
|
extern class ExecutionResult {
|
||||||
@ -133,6 +191,29 @@ extern class SqlTransaction {
|
|||||||
- Date, DateTime, Instant
|
- Date, DateTime, Instant
|
||||||
|
|
||||||
Unsupported parameter values should fail with `SqlUsageException`.
|
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
|
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
|
package lyng.io.http
|
||||||
|
|
||||||
/*
|
import 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mutable request descriptor for programmatic HTTP calls. */
|
/* Mutable request descriptor for programmatic HTTP calls. */
|
||||||
extern class HttpRequest {
|
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
|
package lyng.io.net
|
||||||
|
|
||||||
/* Address family for resolved or bound endpoints. */
|
import lyng.io.net.types
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Connected TCP socket. */
|
/* Connected TCP socket. */
|
||||||
extern class TcpSocket {
|
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
|
package lyng.io.ws
|
||||||
|
|
||||||
/* Received WebSocket message. */
|
import lyng.io.ws.types
|
||||||
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?
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active WebSocket client session. */
|
/* Active WebSocket client session. */
|
||||||
extern class WsSession {
|
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
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
group = "net.sergeych"
|
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
|
// 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,
|
a.visibility ?: defaultVisibility,
|
||||||
recordType = recordType,
|
recordType = recordType,
|
||||||
declaringClass = declaringClass,
|
declaringClass = declaringClass,
|
||||||
isTransient = a.isTransient
|
isTransient = a.isTransient,
|
||||||
|
annotations = a.annotations
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -108,7 +109,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
|
|||||||
a.visibility ?: defaultVisibility,
|
a.visibility ?: defaultVisibility,
|
||||||
recordType = recordType,
|
recordType = recordType,
|
||||||
declaringClass = declaringClass,
|
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 accessType: AccessType? = null,
|
||||||
val visibility: Visibility? = null,
|
val visibility: Visibility? = null,
|
||||||
val isTransient: Boolean = false,
|
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>,
|
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(
|
internal suspend fun executeClassDecl(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
spec: ClassDeclSpec,
|
spec: ClassDeclSpec,
|
||||||
@ -60,7 +71,8 @@ internal suspend fun executeClassDecl(
|
|||||||
|
|
||||||
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray())
|
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray())
|
||||||
newClass.isAnonymous = spec.isAnonymous
|
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) {
|
for (i in parentClasses.indices) {
|
||||||
val argsList = spec.baseSpecs[i].args
|
val argsList = spec.baseSpecs[i].args
|
||||||
if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList
|
if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList
|
||||||
@ -85,6 +97,7 @@ internal suspend fun executeClassDecl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (spec.isExtern) {
|
if (spec.isExtern) {
|
||||||
|
val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs)
|
||||||
val parentClasses = spec.baseSpecs.mapNotNull { baseSpec ->
|
val parentClasses = spec.baseSpecs.mapNotNull { baseSpec ->
|
||||||
val rec = scope[baseSpec.name]
|
val rec = scope[baseSpec.name]
|
||||||
val cls = rec?.value as? ObjClass
|
val cls = rec?.value as? ObjClass
|
||||||
@ -105,8 +118,8 @@ internal suspend fun executeClassDecl(
|
|||||||
}
|
}
|
||||||
val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply {
|
val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply {
|
||||||
this.isAbstract = true
|
this.isAbstract = true
|
||||||
constructorMeta = spec.constructorArgs
|
constructorMeta = evaluatedConstructorArgs
|
||||||
spec.constructorArgs?.params?.forEach { p ->
|
evaluatedConstructorArgs?.params?.forEach { p ->
|
||||||
if (p.accessType != null) {
|
if (p.accessType != null) {
|
||||||
createField(
|
createField(
|
||||||
p.name,
|
p.name,
|
||||||
@ -117,6 +130,7 @@ internal suspend fun executeClassDecl(
|
|||||||
pos = Pos.builtIn,
|
pos = Pos.builtIn,
|
||||||
isTransient = p.isTransient,
|
isTransient = p.isTransient,
|
||||||
type = ObjRecord.Type.ConstructorField,
|
type = ObjRecord.Type.ConstructorField,
|
||||||
|
annotations = p.annotations,
|
||||||
fieldId = spec.constructorFieldIds?.get(p.name)
|
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 {
|
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).also {
|
||||||
it.isAbstract = spec.isAbstract
|
it.isAbstract = spec.isAbstract
|
||||||
it.isClosed = spec.isClosed
|
it.isClosed = spec.isClosed
|
||||||
it.instanceConstructor = constructorCode
|
it.instanceConstructor = constructorCode
|
||||||
it.constructorMeta = spec.constructorArgs
|
it.constructorMeta = evaluatedConstructorArgs
|
||||||
for (i in parentClasses.indices) {
|
for (i in parentClasses.indices) {
|
||||||
val argsList = spec.baseSpecs[i].args
|
val argsList = spec.baseSpecs[i].args
|
||||||
if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList
|
if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList
|
||||||
}
|
}
|
||||||
spec.constructorArgs?.params?.forEach { p ->
|
evaluatedConstructorArgs?.params?.forEach { p ->
|
||||||
if (p.accessType != null) {
|
if (p.accessType != null) {
|
||||||
it.createField(
|
it.createField(
|
||||||
p.name,
|
p.name,
|
||||||
@ -180,6 +195,7 @@ internal suspend fun executeClassDecl(
|
|||||||
pos = Pos.builtIn,
|
pos = Pos.builtIn,
|
||||||
isTransient = p.isTransient,
|
isTransient = p.isTransient,
|
||||||
type = ObjRecord.Type.ConstructorField,
|
type = ObjRecord.Type.ConstructorField,
|
||||||
|
annotations = p.annotations,
|
||||||
fieldId = spec.constructorFieldIds?.get(p.name)
|
fieldId = spec.constructorFieldIds?.get(p.name)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class ClassInstanceFieldDeclStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
|
||||||
val fieldId: Int?,
|
val fieldId: Int?,
|
||||||
val initStatement: Statement?,
|
val initStatement: Statement?,
|
||||||
override val pos: Pos,
|
override val pos: Pos,
|
||||||
@ -56,6 +57,7 @@ class ClassInstancePropertyDeclStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
|
||||||
val prop: ObjProperty,
|
val prop: ObjProperty,
|
||||||
val methodId: Int?,
|
val methodId: Int?,
|
||||||
val initStatement: Statement?,
|
val initStatement: Statement?,
|
||||||
@ -75,6 +77,7 @@ class ClassInstanceDelegatedDeclStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
|
||||||
val methodId: Int?,
|
val methodId: Int?,
|
||||||
val initStatement: Statement?,
|
val initStatement: Statement?,
|
||||||
override val pos: Pos,
|
override val pos: Pos,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class ClassStaticFieldInitStatement(
|
|||||||
val initializer: Statement?,
|
val initializer: Statement?,
|
||||||
val isDelegated: Boolean,
|
val isDelegated: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
|
||||||
private val startPos: Pos,
|
private val startPos: Pos,
|
||||||
) : Statement() {
|
) : Statement() {
|
||||||
override val pos: Pos = startPos
|
override val pos: Pos = startPos
|
||||||
@ -39,6 +40,7 @@ class ClassStaticFieldInitStatement(
|
|||||||
override suspend fun execute(scope: Scope): Obj {
|
override suspend fun execute(scope: Scope): Obj {
|
||||||
val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy()
|
val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy()
|
||||||
?: ObjNull
|
?: ObjNull
|
||||||
|
val annotations = annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("static field init requires class scope")
|
?: scope.raiseIllegalState("static field init requires class scope")
|
||||||
return if (isDelegated) {
|
return if (isDelegated) {
|
||||||
@ -61,7 +63,8 @@ class ClassStaticFieldInitStatement(
|
|||||||
writeVisibility,
|
writeVisibility,
|
||||||
startPos,
|
startPos,
|
||||||
isTransient = isTransient,
|
isTransient = isTransient,
|
||||||
type = ObjRecord.Type.Delegated
|
type = ObjRecord.Type.Delegated,
|
||||||
|
annotations = annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
delegate = finalDelegate
|
||||||
}
|
}
|
||||||
@ -72,7 +75,8 @@ class ClassStaticFieldInitStatement(
|
|||||||
visibility,
|
visibility,
|
||||||
writeVisibility,
|
writeVisibility,
|
||||||
recordType = ObjRecord.Type.Delegated,
|
recordType = ObjRecord.Type.Delegated,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
delegate = finalDelegate
|
||||||
}
|
}
|
||||||
@ -85,7 +89,8 @@ class ClassStaticFieldInitStatement(
|
|||||||
visibility,
|
visibility,
|
||||||
writeVisibility,
|
writeVisibility,
|
||||||
startPos,
|
startPos,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
scope.addItem(
|
scope.addItem(
|
||||||
name,
|
name,
|
||||||
@ -94,7 +99,8 @@ class ClassStaticFieldInitStatement(
|
|||||||
visibility,
|
visibility,
|
||||||
writeVisibility,
|
writeVisibility,
|
||||||
recordType = ObjRecord.Type.Field,
|
recordType = ObjRecord.Type.Field,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
initValue
|
initValue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,13 +22,16 @@ sealed class CodeContext {
|
|||||||
class Function(
|
class Function(
|
||||||
val name: String,
|
val name: String,
|
||||||
val implicitThisMembers: Boolean = false,
|
val implicitThisMembers: Boolean = false,
|
||||||
val implicitThisTypeName: String? = null,
|
val implicitReceiverTypeNames: List<String> = emptyList(),
|
||||||
val typeParams: Set<String> = emptySet(),
|
val typeParams: Set<String> = emptySet(),
|
||||||
val typeParamDecls: List<TypeDecl.TypeParam> = emptyList(),
|
val typeParamDecls: List<TypeDecl.TypeParam> = emptyList(),
|
||||||
/** True for static methods and top-level functions: they have no implicit `this`,
|
/** 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. */
|
* so class-body field initializers inside them should not inherit the class name. */
|
||||||
val noImplicitThis: Boolean = false
|
val noImplicitThis: Boolean = false
|
||||||
): CodeContext()
|
): CodeContext() {
|
||||||
|
val implicitThisTypeName: String?
|
||||||
|
get() = implicitReceiverTypeNames.firstOrNull()
|
||||||
|
}
|
||||||
class ClassBody(val name: String, val isExtern: Boolean = false): CodeContext() {
|
class ClassBody(val name: String, val isExtern: Boolean = false): CodeContext() {
|
||||||
var typeParams: Set<String> = emptySet()
|
var typeParams: Set<String> = emptySet()
|
||||||
var typeParamDecls: List<TypeDecl.TypeParam> = emptyList()
|
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 property: ObjProperty,
|
||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val setterVisibility: Visibility?,
|
val setterVisibility: Visibility?,
|
||||||
|
val getterTypeDecl: TypeDecl?,
|
||||||
|
val setterTypeDecl: TypeDecl?,
|
||||||
private val startPos: Pos,
|
private val startPos: Pos,
|
||||||
) : Statement() {
|
) : Statement() {
|
||||||
override val pos: Pos = startPos
|
override val pos: Pos = startPos
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class InstanceFieldInitStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<DeclAnnotation> = emptyList(),
|
||||||
val isLateInitVal: Boolean,
|
val isLateInitVal: Boolean,
|
||||||
val initializer: Statement?,
|
val initializer: Statement?,
|
||||||
override val pos: Pos,
|
override val pos: Pos,
|
||||||
@ -50,7 +51,8 @@ class InstanceFieldInitStatement(
|
|||||||
isAbstract = isAbstract,
|
isAbstract = isAbstract,
|
||||||
isClosed = isClosed,
|
isClosed = isClosed,
|
||||||
isOverride = isOverride,
|
isOverride = isOverride,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
return ObjVoid
|
return ObjVoid
|
||||||
}
|
}
|
||||||
@ -74,6 +76,7 @@ class InstancePropertyInitStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<DeclAnnotation> = emptyList(),
|
||||||
val prop: ObjProperty,
|
val prop: ObjProperty,
|
||||||
override val pos: Pos,
|
override val pos: Pos,
|
||||||
) : Statement() {
|
) : Statement() {
|
||||||
@ -88,7 +91,8 @@ class InstancePropertyInitStatement(
|
|||||||
isAbstract = isAbstract,
|
isAbstract = isAbstract,
|
||||||
isClosed = isClosed,
|
isClosed = isClosed,
|
||||||
isOverride = isOverride,
|
isOverride = isOverride,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
return ObjVoid
|
return ObjVoid
|
||||||
}
|
}
|
||||||
@ -104,6 +108,7 @@ class InstanceDelegatedInitStatement(
|
|||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<DeclAnnotation> = emptyList(),
|
||||||
val accessTypeLabel: String,
|
val accessTypeLabel: String,
|
||||||
val initializer: Statement,
|
val initializer: Statement,
|
||||||
override val pos: Pos,
|
override val pos: Pos,
|
||||||
@ -130,7 +135,8 @@ class InstanceDelegatedInitStatement(
|
|||||||
isAbstract = isAbstract,
|
isAbstract = isAbstract,
|
||||||
isClosed = isClosed,
|
isClosed = isClosed,
|
||||||
isOverride = isOverride,
|
isOverride = isOverride,
|
||||||
isTransient = isTransient
|
isTransient = isTransient,
|
||||||
|
annotations = annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
delegate = finalDelegate
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,6 +99,20 @@ open class Scope(
|
|||||||
extensions.getOrPut(cls) { mutableMapOf() }[name] = record
|
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? {
|
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
|
||||||
var s: Scope? = this
|
var s: Scope? = this
|
||||||
var hops = 0
|
var hops = 0
|
||||||
@ -106,7 +120,9 @@ open class Scope(
|
|||||||
// Proximity rule: check all extensions in the current scope before going to parent.
|
// Proximity rule: check all extensions in the current scope before going to parent.
|
||||||
// Priority within scope: more specific class in MRO wins.
|
// Priority within scope: more specific class in MRO wins.
|
||||||
for (cls in receiverClass.mro) {
|
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) {
|
if (s is BytecodeClosureScope) {
|
||||||
s.closureScope.findExtension(receiverClass, name)?.let { return it }
|
s.closureScope.findExtension(receiverClass, name)?.let { return it }
|
||||||
@ -718,6 +734,7 @@ open class Scope(
|
|||||||
isTransient: Boolean = false,
|
isTransient: Boolean = false,
|
||||||
callSignature: CallSignature? = null,
|
callSignature: CallSignature? = null,
|
||||||
typeDecl: TypeDecl? = null,
|
typeDecl: TypeDecl? = null,
|
||||||
|
annotations: List<DeclAnnotation> = emptyList(),
|
||||||
fieldId: Int? = null,
|
fieldId: Int? = null,
|
||||||
methodId: Int? = null
|
methodId: Int? = null
|
||||||
): ObjRecord {
|
): ObjRecord {
|
||||||
@ -731,6 +748,7 @@ open class Scope(
|
|||||||
isTransient = isTransient,
|
isTransient = isTransient,
|
||||||
callSignature = callSignature,
|
callSignature = callSignature,
|
||||||
typeDecl = typeDecl,
|
typeDecl = typeDecl,
|
||||||
|
annotations = annotations,
|
||||||
memberName = name,
|
memberName = name,
|
||||||
fieldId = fieldId,
|
fieldId = fieldId,
|
||||||
methodId = methodId
|
methodId = methodId
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import net.sergeych.lyng.bytecode.CmdVm
|
|||||||
import net.sergeych.lyng.bytecode.BytecodeLambdaCallable
|
import net.sergeych.lyng.bytecode.BytecodeLambdaCallable
|
||||||
import net.sergeych.lyng.miniast.*
|
import net.sergeych.lyng.miniast.*
|
||||||
import net.sergeych.lyng.obj.*
|
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.pacman.ImportManager
|
||||||
import net.sergeych.lyng.stdlib_included.complexLyng
|
import net.sergeych.lyng.stdlib_included.complexLyng
|
||||||
import net.sergeych.lyng.stdlib_included.decimalLyng
|
import net.sergeych.lyng.stdlib_included.decimalLyng
|
||||||
@ -949,11 +951,13 @@ class Script(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
addPackage("lyng.serialization") {
|
addPackage("lyng.serialization") {
|
||||||
it.addConstDoc(
|
it.bindSerializationFormat(
|
||||||
name = "Lynon",
|
ObjLynonClass,
|
||||||
value = ObjLynonClass,
|
doc = "Lynon serialization utilities: encode/decode data structures to a portable binary format."
|
||||||
doc = "Lynon serialization utilities: encode/decode data structures to a portable binary/text form.",
|
)
|
||||||
type = type("lyng.Class")
|
it.bindSerializationFormat(
|
||||||
|
ObjJsonClass,
|
||||||
|
doc = "Universal JSON serialization utilities with bidirectional Lyng object support."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addPackage("lyng.time") {
|
addPackage("lyng.time") {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ sealed class TypeDecl(val isNullable:Boolean = false) {
|
|||||||
// ??
|
// ??
|
||||||
data class Function(
|
data class Function(
|
||||||
val receiver: TypeDecl?,
|
val receiver: TypeDecl?,
|
||||||
|
val contextReceivers: List<TypeDecl> = emptyList(),
|
||||||
val params: List<TypeDecl>,
|
val params: List<TypeDecl>,
|
||||||
val returnType: TypeDecl,
|
val returnType: TypeDecl,
|
||||||
val nullable: Boolean = false
|
val nullable: Boolean = false
|
||||||
|
|||||||
@ -43,6 +43,7 @@ class BytecodeCompiler(
|
|||||||
private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
|
private val callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
|
||||||
private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
|
private val callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
|
||||||
private val callSignatureByName: Map<String, CallSignature> = emptyMap(),
|
private val callSignatureByName: Map<String, CallSignature> = emptyMap(),
|
||||||
|
private val extensionContextReceiversByWrapperName: Map<String, List<String>> = emptyMap(),
|
||||||
private val externCallableNames: Set<String> = emptySet(),
|
private val externCallableNames: Set<String> = emptySet(),
|
||||||
private val externBindingNames: Set<String> = emptySet(),
|
private val externBindingNames: Set<String> = emptySet(),
|
||||||
private val preparedModuleBindingNames: Set<String> = emptySet(),
|
private val preparedModuleBindingNames: Set<String> = emptySet(),
|
||||||
@ -1146,56 +1147,95 @@ class BytecodeCompiler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun compileUnary(ref: UnaryOpRef): CompiledValue? {
|
private fun compileUnary(ref: UnaryOpRef): CompiledValue? {
|
||||||
val a = compileRef(unaryOperand(ref)) ?: return null
|
|
||||||
val out = allocSlot()
|
|
||||||
return when (unaryOp(ref)) {
|
return when (unaryOp(ref)) {
|
||||||
UnaryOp.NEGATE -> when (a.type) {
|
UnaryOp.POSITIVE -> {
|
||||||
SlotType.INT -> {
|
val operandRef = unaryOperand(ref)
|
||||||
builder.emit(Opcode.NEG_INT, a.slot, out)
|
if (hasUnaryCallable(operandRef, "unaryPlus")) {
|
||||||
CompiledValue(out, SlotType.INT)
|
return compileMethodCall(MethodCallRef(operandRef, "unaryPlus", emptyList(), false, false))
|
||||||
}
|
}
|
||||||
SlotType.REAL -> {
|
val a = compileRef(operandRef) ?: return null
|
||||||
builder.emit(Opcode.NEG_REAL, a.slot, out)
|
return when (a.type) {
|
||||||
CompiledValue(out, SlotType.REAL)
|
SlotType.INT, SlotType.REAL -> a
|
||||||
}
|
else -> {
|
||||||
else -> compileObjUnaryOp(unaryOperand(ref), a, "negate", Pos.builtIn)
|
val obj = ensureObjSlot(a)
|
||||||
}
|
val out = allocSlot()
|
||||||
UnaryOp.NOT -> {
|
builder.emit(Opcode.POS_OBJ, obj.slot, out)
|
||||||
when (a.type) {
|
updateSlotType(out, SlotType.OBJ)
|
||||||
SlotType.BOOL -> builder.emit(Opcode.NOT_BOOL, a.slot, out)
|
slotObjClass[obj.slot]?.let { slotObjClass[out] = it }
|
||||||
SlotType.INT -> {
|
CompiledValue(out, SlotType.OBJ)
|
||||||
val tmp = allocSlot()
|
|
||||||
builder.emit(Opcode.INT_TO_BOOL, a.slot, tmp)
|
|
||||||
builder.emit(Opcode.NOT_BOOL, tmp, out)
|
|
||||||
}
|
}
|
||||||
SlotType.OBJ, SlotType.UNKNOWN -> {
|
|
||||||
val objSlot = ensureObjSlot(a)
|
|
||||||
val tmp = allocSlot()
|
|
||||||
builder.emit(Opcode.OBJ_TO_BOOL, objSlot.slot, tmp)
|
|
||||||
builder.emit(Opcode.NOT_BOOL, tmp, out)
|
|
||||||
updateSlotType(tmp, SlotType.BOOL)
|
|
||||||
}
|
|
||||||
else -> return null
|
|
||||||
}
|
}
|
||||||
CompiledValue(out, SlotType.BOOL)
|
|
||||||
}
|
}
|
||||||
UnaryOp.BITNOT -> {
|
else -> {
|
||||||
if (a.type == SlotType.INT) {
|
val a = compileRef(unaryOperand(ref)) ?: return null
|
||||||
builder.emit(Opcode.INV_INT, a.slot, out)
|
val out = allocSlot()
|
||||||
return CompiledValue(out, SlotType.INT)
|
when (unaryOp(ref)) {
|
||||||
|
UnaryOp.NEGATE -> when (a.type) {
|
||||||
|
SlotType.INT -> {
|
||||||
|
builder.emit(Opcode.NEG_INT, a.slot, out)
|
||||||
|
CompiledValue(out, SlotType.INT)
|
||||||
|
}
|
||||||
|
SlotType.REAL -> {
|
||||||
|
builder.emit(Opcode.NEG_REAL, a.slot, out)
|
||||||
|
CompiledValue(out, SlotType.REAL)
|
||||||
|
}
|
||||||
|
else -> compileObjUnaryOp(unaryOperand(ref), a, "negate", Pos.builtIn)
|
||||||
|
}
|
||||||
|
UnaryOp.NOT -> {
|
||||||
|
when (a.type) {
|
||||||
|
SlotType.BOOL -> builder.emit(Opcode.NOT_BOOL, a.slot, out)
|
||||||
|
SlotType.INT -> {
|
||||||
|
val tmp = allocSlot()
|
||||||
|
builder.emit(Opcode.INT_TO_BOOL, a.slot, tmp)
|
||||||
|
builder.emit(Opcode.NOT_BOOL, tmp, out)
|
||||||
|
}
|
||||||
|
SlotType.OBJ, SlotType.UNKNOWN -> {
|
||||||
|
val objSlot = ensureObjSlot(a)
|
||||||
|
val tmp = allocSlot()
|
||||||
|
builder.emit(Opcode.OBJ_TO_BOOL, objSlot.slot, tmp)
|
||||||
|
builder.emit(Opcode.NOT_BOOL, tmp, out)
|
||||||
|
updateSlotType(tmp, SlotType.BOOL)
|
||||||
|
}
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
CompiledValue(out, SlotType.BOOL)
|
||||||
|
}
|
||||||
|
UnaryOp.BITNOT -> {
|
||||||
|
if (a.type == SlotType.INT) {
|
||||||
|
builder.emit(Opcode.INV_INT, a.slot, out)
|
||||||
|
return CompiledValue(out, SlotType.INT)
|
||||||
|
}
|
||||||
|
return compileObjUnaryOp(unaryOperand(ref), a, "bitNot", Pos.builtIn)
|
||||||
|
}
|
||||||
|
UnaryOp.POSITIVE -> error("unreachable")
|
||||||
}
|
}
|
||||||
return compileObjUnaryOp(unaryOperand(ref), a, "bitNot", Pos.builtIn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private fun compileObjUnaryOp(
|
||||||
ref: ObjRef,
|
ref: ObjRef,
|
||||||
value: CompiledValue,
|
value: CompiledValue,
|
||||||
memberName: String,
|
memberName: String,
|
||||||
pos: Pos
|
pos: Pos,
|
||||||
|
defaultIdentity: Boolean = false
|
||||||
): CompiledValue? {
|
): CompiledValue? {
|
||||||
val receiverClass = resolveReceiverClass(ref)
|
val receiverClass = resolveReceiverClass(ref) ?: slotObjClass[value.slot]
|
||||||
val methodId = receiverClass?.instanceMethodIdMap(includeAbstract = true)?.get(memberName)
|
val methodId = receiverClass?.instanceMethodIdMap(includeAbstract = true)?.get(memberName)
|
||||||
if (methodId != null) {
|
if (methodId != null) {
|
||||||
val receiverObj = ensureObjSlot(value)
|
val receiverObj = ensureObjSlot(value)
|
||||||
@ -1204,6 +1244,19 @@ class BytecodeCompiler(
|
|||||||
updateSlotType(dst, SlotType.OBJ)
|
updateSlotType(dst, SlotType.OBJ)
|
||||||
return CompiledValue(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" &&
|
if (memberName == "negate" &&
|
||||||
(receiverClass == null || isDelegateClass(receiverClass) || receiverClass in setOf(ObjInt.type, ObjReal.type))
|
(receiverClass == null || isDelegateClass(receiverClass) || receiverClass in setOf(ObjInt.type, ObjReal.type))
|
||||||
) {
|
) {
|
||||||
@ -1217,6 +1270,9 @@ class BytecodeCompiler(
|
|||||||
updateSlotType(dst, SlotType.OBJ)
|
updateSlotType(dst, SlotType.OBJ)
|
||||||
return CompiledValue(dst, SlotType.OBJ)
|
return CompiledValue(dst, SlotType.OBJ)
|
||||||
}
|
}
|
||||||
|
if (defaultIdentity) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
throw BytecodeCompileException(
|
throw BytecodeCompileException(
|
||||||
"Unknown member $memberName on ${receiverClass?.className ?: "unknown"}",
|
"Unknown member $memberName on ${receiverClass?.className ?: "unknown"}",
|
||||||
pos
|
pos
|
||||||
@ -4473,6 +4529,20 @@ class BytecodeCompiler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun compileElvis(ref: ElvisRef): CompiledValue? {
|
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 leftValue = compileRefWithFallback(ref.left, null, Pos.builtIn) ?: return null
|
||||||
val leftObj = ensureObjSlot(leftValue)
|
val leftObj = ensureObjSlot(leftValue)
|
||||||
val resultSlot = allocSlot()
|
val resultSlot = allocSlot()
|
||||||
@ -4489,9 +4559,12 @@ class BytecodeCompiler(
|
|||||||
emitMove(leftObj, resultSlot)
|
emitMove(leftObj, resultSlot)
|
||||||
builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel)))
|
builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel)))
|
||||||
builder.mark(rightLabel)
|
builder.mark(rightLabel)
|
||||||
|
val rightIsAbruptControl = isAbruptControlRef(ref.right)
|
||||||
val rightValue = compileRefWithFallback(ref.right, null, Pos.builtIn) ?: return null
|
val rightValue = compileRefWithFallback(ref.right, null, Pos.builtIn) ?: return null
|
||||||
val rightObj = ensureObjSlot(rightValue)
|
if (!rightIsAbruptControl) {
|
||||||
emitMove(rightObj, resultSlot)
|
val rightObj = ensureObjSlot(rightValue)
|
||||||
|
emitMove(rightObj, resultSlot)
|
||||||
|
}
|
||||||
builder.mark(endLabel)
|
builder.mark(endLabel)
|
||||||
updateSlotType(resultSlot, SlotType.OBJ)
|
updateSlotType(resultSlot, SlotType.OBJ)
|
||||||
return CompiledValue(resultSlot, SlotType.OBJ)
|
return CompiledValue(resultSlot, SlotType.OBJ)
|
||||||
@ -5955,6 +6028,7 @@ class BytecodeCompiler(
|
|||||||
): String? {
|
): String? {
|
||||||
for (receiverName in extensionReceiverTypeNames(receiverClass)) {
|
for (receiverName in extensionReceiverTypeNames(receiverClass)) {
|
||||||
val candidate = wrapperName(receiverName, memberName)
|
val candidate = wrapperName(receiverName, memberName)
|
||||||
|
if (!extensionContextReceiversSatisfied(candidate)) continue
|
||||||
if (allowedScopeNames != null &&
|
if (allowedScopeNames != null &&
|
||||||
!allowedScopeNames.contains(candidate) &&
|
!allowedScopeNames.contains(candidate) &&
|
||||||
!localSlotIndexByName.containsKey(candidate)
|
!localSlotIndexByName.containsKey(candidate)
|
||||||
@ -5966,6 +6040,31 @@ class BytecodeCompiler(
|
|||||||
return null
|
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(
|
private fun resolveUniqueExtensionWrapperName(
|
||||||
memberName: String,
|
memberName: String,
|
||||||
wrapperPrefix: String
|
wrapperPrefix: String
|
||||||
@ -5974,12 +6073,12 @@ class BytecodeCompiler(
|
|||||||
val candidates = LinkedHashSet<String>()
|
val candidates = LinkedHashSet<String>()
|
||||||
for (name in localSlotIndexByName.keys) {
|
for (name in localSlotIndexByName.keys) {
|
||||||
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
|
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
|
||||||
candidates.add(name)
|
if (extensionContextReceiversSatisfied(name)) candidates.add(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (name in scopeSlotIndexByName.keys) {
|
for (name in scopeSlotIndexByName.keys) {
|
||||||
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
|
if (name.startsWith(wrapperPrefix) && name.endsWith(suffix)) {
|
||||||
candidates.add(name)
|
if (extensionContextReceiversSatisfied(name)) candidates.add(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates.singleOrNull()
|
return candidates.singleOrNull()
|
||||||
@ -6360,7 +6459,8 @@ class BytecodeCompiler(
|
|||||||
isMutable = stmt.isMutable,
|
isMutable = stmt.isMutable,
|
||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient
|
isTransient = stmt.isTransient,
|
||||||
|
annotationSpecs = stmt.annotationSpecs
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -6370,7 +6470,8 @@ class BytecodeCompiler(
|
|||||||
isMutable = stmt.isMutable,
|
isMutable = stmt.isMutable,
|
||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient
|
isTransient = stmt.isTransient,
|
||||||
|
annotationSpecs = stmt.annotationSpecs
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -6397,6 +6498,7 @@ class BytecodeCompiler(
|
|||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
typeDecl = stmt.typeDecl,
|
typeDecl = stmt.typeDecl,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotationSpecs = stmt.annotationSpecs,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride,
|
isOverride = stmt.isOverride,
|
||||||
@ -6419,6 +6521,7 @@ class BytecodeCompiler(
|
|||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotationSpecs = stmt.annotationSpecs,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride,
|
isOverride = stmt.isOverride,
|
||||||
@ -6442,6 +6545,7 @@ class BytecodeCompiler(
|
|||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotationSpecs = stmt.annotationSpecs,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride,
|
isOverride = stmt.isOverride,
|
||||||
@ -6475,6 +6579,7 @@ class BytecodeCompiler(
|
|||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotations = stmt.annotations,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride
|
isOverride = stmt.isOverride
|
||||||
@ -6497,6 +6602,7 @@ class BytecodeCompiler(
|
|||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotations = stmt.annotations,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride
|
isOverride = stmt.isOverride
|
||||||
@ -6517,6 +6623,7 @@ class BytecodeCompiler(
|
|||||||
visibility = stmt.visibility,
|
visibility = stmt.visibility,
|
||||||
writeVisibility = stmt.writeVisibility,
|
writeVisibility = stmt.writeVisibility,
|
||||||
isTransient = stmt.isTransient,
|
isTransient = stmt.isTransient,
|
||||||
|
annotations = stmt.annotations,
|
||||||
isAbstract = stmt.isAbstract,
|
isAbstract = stmt.isAbstract,
|
||||||
isClosed = stmt.isClosed,
|
isClosed = stmt.isClosed,
|
||||||
isOverride = stmt.isOverride,
|
isOverride = stmt.isOverride,
|
||||||
@ -7995,7 +8102,9 @@ class BytecodeCompiler(
|
|||||||
stmt.extTypeName,
|
stmt.extTypeName,
|
||||||
stmt.property,
|
stmt.property,
|
||||||
stmt.visibility,
|
stmt.visibility,
|
||||||
stmt.setterVisibility
|
stmt.setterVisibility,
|
||||||
|
stmt.getterTypeDecl,
|
||||||
|
stmt.setterTypeDecl
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val slot = allocSlot()
|
val slot = allocSlot()
|
||||||
@ -8286,6 +8395,19 @@ class BytecodeCompiler(
|
|||||||
is ObjChar -> ObjChar.type
|
is ObjChar -> ObjChar.type
|
||||||
else -> null
|
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())
|
is CastRef -> resolveTypeRefClass(ref.castTypeRef())
|
||||||
?: resolveReceiverClass(ref.castValueRef())
|
?: resolveReceiverClass(ref.castValueRef())
|
||||||
is FieldRef -> {
|
is FieldRef -> {
|
||||||
@ -8636,29 +8758,73 @@ class BytecodeCompiler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun inferCallReturnClass(ref: CallRef): ObjClass? {
|
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) {
|
return when (val target = ref.target) {
|
||||||
is LocalSlotRef -> {
|
is LocalSlotRef -> {
|
||||||
callableReturnTypeByScopeId[target.scopeId]?.get(target.slot)
|
val mappedSlot = resolveLocalSlotByRefOrName(target)
|
||||||
?: run {
|
callableResultClassOrNull(
|
||||||
val nameClass = nameObjClass[target.name]
|
directReturnClass = mappedSlot?.let { callableReturnClassFromSlot(it) }
|
||||||
if (nameClass == ObjClassType) {
|
?: exactLambdaRefByScopeId[target.scopeId]?.get(target.slot)?.inferredReturnClass
|
||||||
resolveTypeNameClass(target.name) ?: ObjDynamic.type
|
?: callableReturnTypeByScopeId[target.scopeId]?.get(target.slot),
|
||||||
} else {
|
directTypeDecl = mappedSlot?.let { typeDeclForSlot(it) }
|
||||||
nameClass ?: resolveTypeNameClass(target.name)
|
?: slotTypeDeclByScopeId[target.scopeId]?.get(target.slot),
|
||||||
}
|
nameClass = nameObjClass[target.name],
|
||||||
}
|
typeNameFallback = target.name
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is LocalVarRef -> {
|
is LocalVarRef -> {
|
||||||
callableReturnTypeByName[target.name]
|
val directSlot = resolveDirectNameSlot(target.name)?.slot
|
||||||
?: run {
|
callableResultClassOrNull(
|
||||||
val nameClass = nameObjClass[target.name]
|
directReturnClass = directSlot?.let { callableReturnClassFromSlot(it) }
|
||||||
if (nameClass == ObjClassType) {
|
?: callableReturnTypeByName[target.name],
|
||||||
resolveTypeNameClass(target.name) ?: ObjDynamic.type
|
directTypeDecl = directSlot?.let { typeDeclForSlot(it) },
|
||||||
} else {
|
nameClass = nameObjClass[target.name],
|
||||||
nameClass ?: resolveTypeNameClass(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
|
is ConstRef -> target.constValue as? ObjClass
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
package net.sergeych.lyng.bytecode
|
package net.sergeych.lyng.bytecode
|
||||||
|
|
||||||
import net.sergeych.lyng.ArgsDeclaration
|
import net.sergeych.lyng.ArgsDeclaration
|
||||||
|
import net.sergeych.lyng.ParsedDeclAnnotation
|
||||||
import net.sergeych.lyng.Pos
|
import net.sergeych.lyng.Pos
|
||||||
import net.sergeych.lyng.TypeDecl
|
import net.sergeych.lyng.TypeDecl
|
||||||
import net.sergeych.lyng.Visibility
|
import net.sergeych.lyng.Visibility
|
||||||
@ -65,6 +66,8 @@ sealed class BytecodeConst {
|
|||||||
val property: ObjProperty,
|
val property: ObjProperty,
|
||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val setterVisibility: Visibility?,
|
val setterVisibility: Visibility?,
|
||||||
|
val getterTypeDecl: TypeDecl?,
|
||||||
|
val setterTypeDecl: TypeDecl?,
|
||||||
) : BytecodeConst()
|
) : BytecodeConst()
|
||||||
data class LocalDecl(
|
data class LocalDecl(
|
||||||
val name: String,
|
val name: String,
|
||||||
@ -85,6 +88,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation>,
|
||||||
) : BytecodeConst()
|
) : BytecodeConst()
|
||||||
data class ClassDelegatedDecl(
|
data class ClassDelegatedDecl(
|
||||||
val name: String,
|
val name: String,
|
||||||
@ -92,6 +96,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation>,
|
||||||
) : BytecodeConst()
|
) : BytecodeConst()
|
||||||
data class ClassInstanceInitDecl(
|
data class ClassInstanceInitDecl(
|
||||||
val initStatement: Obj,
|
val initStatement: Obj,
|
||||||
@ -103,6 +108,7 @@ sealed class BytecodeConst {
|
|||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val typeDecl: TypeDecl?,
|
val typeDecl: TypeDecl?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
@ -116,6 +122,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
@ -130,6 +137,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotationSpecs: List<ParsedDeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
@ -143,6 +151,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
@ -153,6 +162,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
@ -164,6 +174,7 @@ sealed class BytecodeConst {
|
|||||||
val visibility: Visibility,
|
val visibility: Visibility,
|
||||||
val writeVisibility: Visibility?,
|
val writeVisibility: Visibility?,
|
||||||
val isTransient: Boolean,
|
val isTransient: Boolean,
|
||||||
|
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
|
||||||
val isAbstract: Boolean,
|
val isAbstract: Boolean,
|
||||||
val isClosed: Boolean,
|
val isClosed: Boolean,
|
||||||
val isOverride: Boolean,
|
val isOverride: Boolean,
|
||||||
|
|||||||
@ -107,6 +107,7 @@ class BytecodeStatement private constructor(
|
|||||||
callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
|
callableReturnTypeByScopeId: Map<Int, Map<Int, ObjClass>> = emptyMap(),
|
||||||
callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
|
callableReturnTypeByName: Map<String, ObjClass> = emptyMap(),
|
||||||
callSignatureByName: Map<String, CallSignature> = emptyMap(),
|
callSignatureByName: Map<String, CallSignature> = emptyMap(),
|
||||||
|
extensionContextReceiversByWrapperName: Map<String, List<String>> = emptyMap(),
|
||||||
externCallableNames: Set<String> = emptySet(),
|
externCallableNames: Set<String> = emptySet(),
|
||||||
externBindingNames: Set<String> = emptySet(),
|
externBindingNames: Set<String> = emptySet(),
|
||||||
preparedModuleBindingNames: Set<String> = emptySet(),
|
preparedModuleBindingNames: Set<String> = emptySet(),
|
||||||
@ -148,6 +149,7 @@ class BytecodeStatement private constructor(
|
|||||||
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
|
callableReturnTypeByScopeId = callableReturnTypeByScopeId,
|
||||||
callableReturnTypeByName = callableReturnTypeByName,
|
callableReturnTypeByName = callableReturnTypeByName,
|
||||||
callSignatureByName = callSignatureByName,
|
callSignatureByName = callSignatureByName,
|
||||||
|
extensionContextReceiversByWrapperName = extensionContextReceiversByWrapperName,
|
||||||
externCallableNames = externCallableNames,
|
externCallableNames = externCallableNames,
|
||||||
externBindingNames = externBindingNames,
|
externBindingNames = externBindingNames,
|
||||||
preparedModuleBindingNames = preparedModuleBindingNames,
|
preparedModuleBindingNames = preparedModuleBindingNames,
|
||||||
@ -365,6 +367,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.initializer?.let { unwrapDeep(it) },
|
stmt.initializer?.let { unwrapDeep(it) },
|
||||||
stmt.isDelegated,
|
stmt.isDelegated,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotationSpecs,
|
||||||
stmt.pos
|
stmt.pos
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -385,6 +388,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotationSpecs,
|
||||||
stmt.fieldId,
|
stmt.fieldId,
|
||||||
stmt.initStatement?.let { unwrapDeep(it) },
|
stmt.initStatement?.let { unwrapDeep(it) },
|
||||||
stmt.pos
|
stmt.pos
|
||||||
@ -400,6 +404,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotationSpecs,
|
||||||
stmt.prop,
|
stmt.prop,
|
||||||
stmt.methodId,
|
stmt.methodId,
|
||||||
stmt.initStatement?.let { unwrapDeep(it) },
|
stmt.initStatement?.let { unwrapDeep(it) },
|
||||||
@ -416,6 +421,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotationSpecs,
|
||||||
stmt.methodId,
|
stmt.methodId,
|
||||||
stmt.initStatement?.let { unwrapDeep(it) },
|
stmt.initStatement?.let { unwrapDeep(it) },
|
||||||
stmt.pos
|
stmt.pos
|
||||||
@ -431,6 +437,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotations,
|
||||||
stmt.isLateInitVal,
|
stmt.isLateInitVal,
|
||||||
stmt.initializer?.let { unwrapDeep(it) },
|
stmt.initializer?.let { unwrapDeep(it) },
|
||||||
stmt.pos
|
stmt.pos
|
||||||
@ -446,6 +453,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotations,
|
||||||
stmt.prop,
|
stmt.prop,
|
||||||
stmt.pos
|
stmt.pos
|
||||||
)
|
)
|
||||||
@ -461,6 +469,7 @@ class BytecodeStatement private constructor(
|
|||||||
stmt.isClosed,
|
stmt.isClosed,
|
||||||
stmt.isOverride,
|
stmt.isOverride,
|
||||||
stmt.isTransient,
|
stmt.isTransient,
|
||||||
|
stmt.annotations,
|
||||||
stmt.accessTypeLabel,
|
stmt.accessTypeLabel,
|
||||||
unwrapDeep(stmt.initializer),
|
unwrapDeep(stmt.initializer),
|
||||||
stmt.pos
|
stmt.pos
|
||||||
|
|||||||
@ -143,7 +143,7 @@ class CmdBuilder {
|
|||||||
Opcode.UNBOX_INT_OBJ, Opcode.UNBOX_REAL_OBJ,
|
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.INT_TO_REAL, Opcode.REAL_TO_INT, Opcode.BOOL_TO_INT, Opcode.INT_TO_BOOL,
|
||||||
Opcode.OBJ_TO_BOOL, Opcode.GET_OBJ_CLASS,
|
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 ->
|
Opcode.ASSERT_IS ->
|
||||||
listOf(OperandKind.SLOT, OperandKind.SLOT)
|
listOf(OperandKind.SLOT, OperandKind.SLOT)
|
||||||
Opcode.CHECK_IS, Opcode.MAKE_QUALIFIED_VIEW ->
|
Opcode.CHECK_IS, Opcode.MAKE_QUALIFIED_VIEW ->
|
||||||
@ -698,6 +698,7 @@ class CmdBuilder {
|
|||||||
} else {
|
} else {
|
||||||
CmdNotBool(operands[0], operands[1])
|
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])) {
|
Opcode.AND_BOOL -> if (isFastLocal(operands[0]) && isFastLocal(operands[1]) && isFastLocal(operands[2])) {
|
||||||
CmdAndBoolLocal(operands[0] - scopeSlotCount, operands[1] - scopeSlotCount, operands[2] - scopeSlotCount)
|
CmdAndBoolLocal(operands[0] - scopeSlotCount, operands[1] - scopeSlotCount, operands[2] - scopeSlotCount)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -450,6 +450,7 @@ object CmdDisassembler {
|
|||||||
is CmdMulObj -> Opcode.MUL_OBJ to intArrayOf(cmd.a, cmd.b, cmd.dst)
|
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 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 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 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 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)
|
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.ADD_OBJ, Opcode.SUB_OBJ, Opcode.MUL_OBJ, Opcode.DIV_OBJ, Opcode.MOD_OBJ, Opcode.CONTAINS_OBJ,
|
||||||
Opcode.AND_BOOL, Opcode.OR_BOOL ->
|
Opcode.AND_BOOL, Opcode.OR_BOOL ->
|
||||||
listOf(OperandKind.SLOT, OperandKind.SLOT, OperandKind.SLOT)
|
listOf(OperandKind.SLOT, OperandKind.SLOT, OperandKind.SLOT)
|
||||||
|
Opcode.POS_OBJ ->
|
||||||
|
listOf(OperandKind.SLOT, OperandKind.SLOT)
|
||||||
Opcode.ASSIGN_OP_OBJ ->
|
Opcode.ASSIGN_OP_OBJ ->
|
||||||
listOf(OperandKind.ID, OperandKind.SLOT, OperandKind.SLOT, OperandKind.SLOT, OperandKind.CONST)
|
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 ->
|
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() {
|
class CmdAddObj(internal val a: Int, internal val b: Int, internal val dst: Int) : Cmd() {
|
||||||
override suspend fun perform(frame: CmdFrame) {
|
override suspend fun perform(frame: CmdFrame) {
|
||||||
val result = frame.slotToObj(a).plus(frame.ensureScope(), frame.slotToObj(b))
|
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
|
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassFieldDecl
|
||||||
?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId")
|
?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId")
|
||||||
val scope = frame.ensureScope()
|
val scope = frame.ensureScope()
|
||||||
|
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("class field init requires class scope")
|
?: scope.raiseIllegalState("class field init requires class scope")
|
||||||
val value = frame.slotToObj(slot).byValueCopy()
|
val value = frame.slotToObj(slot).byValueCopy()
|
||||||
@ -2815,7 +2824,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
|
|||||||
decl.visibility,
|
decl.visibility,
|
||||||
decl.writeVisibility,
|
decl.writeVisibility,
|
||||||
Pos.builtIn,
|
Pos.builtIn,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
scope.addItem(
|
scope.addItem(
|
||||||
decl.name,
|
decl.name,
|
||||||
@ -2824,7 +2834,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
|
|||||||
decl.visibility,
|
decl.visibility,
|
||||||
decl.writeVisibility,
|
decl.writeVisibility,
|
||||||
recordType = ObjRecord.Type.Field,
|
recordType = ObjRecord.Type.Field,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -2835,6 +2846,7 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
|
|||||||
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl
|
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl
|
||||||
?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId")
|
?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId")
|
||||||
val scope = frame.ensureScope()
|
val scope = frame.ensureScope()
|
||||||
|
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("class delegated init requires class scope")
|
?: scope.raiseIllegalState("class delegated init requires class scope")
|
||||||
val initValue = frame.slotToObj(slot)
|
val initValue = frame.slotToObj(slot)
|
||||||
@ -2857,7 +2869,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
|
|||||||
decl.writeVisibility,
|
decl.writeVisibility,
|
||||||
Pos.builtIn,
|
Pos.builtIn,
|
||||||
isTransient = decl.isTransient,
|
isTransient = decl.isTransient,
|
||||||
type = ObjRecord.Type.Delegated
|
type = ObjRecord.Type.Delegated,
|
||||||
|
annotations = annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
delegate = finalDelegate
|
||||||
}
|
}
|
||||||
@ -2868,7 +2881,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
|
|||||||
decl.visibility,
|
decl.visibility,
|
||||||
decl.writeVisibility,
|
decl.writeVisibility,
|
||||||
recordType = ObjRecord.Type.Delegated,
|
recordType = ObjRecord.Type.Delegated,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
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
|
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceFieldDecl
|
||||||
?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId")
|
?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId")
|
||||||
val scope = frame.ensureScope()
|
val scope = frame.ensureScope()
|
||||||
|
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("class instance field requires class scope")
|
?: scope.raiseIllegalState("class instance field requires class scope")
|
||||||
cls.createField(
|
cls.createField(
|
||||||
@ -2911,7 +2926,8 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In
|
|||||||
isTransient = decl.isTransient,
|
isTransient = decl.isTransient,
|
||||||
typeDecl = decl.typeDecl,
|
typeDecl = decl.typeDecl,
|
||||||
type = ObjRecord.Type.Field,
|
type = ObjRecord.Type.Field,
|
||||||
fieldId = decl.fieldId
|
fieldId = decl.fieldId,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
if (!decl.isAbstract) {
|
if (!decl.isAbstract) {
|
||||||
decl.initStatement?.let { cls.instanceInitializers += it }
|
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
|
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstancePropertyDecl
|
||||||
?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId")
|
?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId")
|
||||||
val scope = frame.ensureScope()
|
val scope = frame.ensureScope()
|
||||||
|
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("class instance property requires class scope")
|
?: scope.raiseIllegalState("class instance property requires class scope")
|
||||||
cls.addProperty(
|
cls.addProperty(
|
||||||
@ -2938,7 +2955,8 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot:
|
|||||||
isOverride = decl.isOverride,
|
isOverride = decl.isOverride,
|
||||||
pos = decl.pos,
|
pos = decl.pos,
|
||||||
prop = decl.prop,
|
prop = decl.prop,
|
||||||
methodId = decl.methodId
|
methodId = decl.methodId,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
if (!decl.isAbstract) {
|
if (!decl.isAbstract) {
|
||||||
decl.initStatement?.let { cls.instanceInitializers += it }
|
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
|
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceDelegatedDecl
|
||||||
?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId")
|
?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId")
|
||||||
val scope = frame.ensureScope()
|
val scope = frame.ensureScope()
|
||||||
|
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
|
||||||
val cls = scope.thisObj as? ObjClass
|
val cls = scope.thisObj as? ObjClass
|
||||||
?: scope.raiseIllegalState("class instance delegated requires class scope")
|
?: scope.raiseIllegalState("class instance delegated requires class scope")
|
||||||
cls.createField(
|
cls.createField(
|
||||||
@ -2968,7 +2987,8 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot
|
|||||||
isOverride = decl.isOverride,
|
isOverride = decl.isOverride,
|
||||||
isTransient = decl.isTransient,
|
isTransient = decl.isTransient,
|
||||||
type = ObjRecord.Type.Delegated,
|
type = ObjRecord.Type.Delegated,
|
||||||
methodId = decl.methodId
|
methodId = decl.methodId,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
if (!decl.isAbstract) {
|
if (!decl.isAbstract) {
|
||||||
decl.initStatement?.let { cls.instanceInitializers += it }
|
decl.initStatement?.let { cls.instanceInitializers += it }
|
||||||
@ -2994,7 +3014,8 @@ class CmdDeclInstanceField(internal val constId: Int, internal val slot: Int) :
|
|||||||
isAbstract = decl.isAbstract,
|
isAbstract = decl.isAbstract,
|
||||||
isClosed = decl.isClosed,
|
isClosed = decl.isClosed,
|
||||||
isOverride = decl.isOverride,
|
isOverride = decl.isOverride,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = decl.annotations
|
||||||
)
|
)
|
||||||
if (slot >= frame.fn.scopeSlotCount) {
|
if (slot >= frame.fn.scopeSlotCount) {
|
||||||
val localIndex = 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,
|
isAbstract = decl.isAbstract,
|
||||||
isClosed = decl.isClosed,
|
isClosed = decl.isClosed,
|
||||||
isOverride = decl.isOverride,
|
isOverride = decl.isOverride,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = decl.annotations
|
||||||
)
|
)
|
||||||
if (slot >= frame.fn.scopeSlotCount) {
|
if (slot >= frame.fn.scopeSlotCount) {
|
||||||
val localIndex = 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,
|
isAbstract = decl.isAbstract,
|
||||||
isClosed = decl.isClosed,
|
isClosed = decl.isClosed,
|
||||||
isOverride = decl.isOverride,
|
isOverride = decl.isOverride,
|
||||||
isTransient = decl.isTransient
|
isTransient = decl.isTransient,
|
||||||
|
annotations = decl.annotations
|
||||||
).apply {
|
).apply {
|
||||||
delegate = finalDelegate
|
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 getterName = extensionPropertyGetterName(decl.extTypeName, decl.property.name)
|
||||||
val getterWrapper = ObjExtensionPropertyGetterCallable(decl.property.name, decl.property)
|
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)
|
val getterLocal = resolveLocalSlotIndex(frame.fn, getterName, preferCapture = false)
|
||||||
if (getterLocal != null) {
|
if (getterLocal != null) {
|
||||||
frame.setObjUnchecked(frame.fn.scopeSlotCount + getterLocal, getterWrapper)
|
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 setterName = extensionPropertySetterName(decl.extTypeName, decl.property.name)
|
||||||
val setterWrapper = ObjExtensionPropertySetterCallable(decl.property.name, decl.property)
|
val setterWrapper = ObjExtensionPropertySetterCallable(decl.property.name, decl.property)
|
||||||
frame.ensureScope()
|
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)
|
val setterLocal = resolveLocalSlotIndex(frame.fn, setterName, preferCapture = false)
|
||||||
if (setterLocal != null) {
|
if (setterLocal != null) {
|
||||||
frame.setObjUnchecked(frame.fn.scopeSlotCount + setterLocal, setterWrapper)
|
frame.setObjUnchecked(frame.fn.scopeSlotCount + setterLocal, setterWrapper)
|
||||||
@ -3705,9 +3742,26 @@ class CmdGetClassScope(
|
|||||||
decl = declared
|
decl = declared
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val resolved = rec ?: scope.raiseSymbolNotFound(name)
|
val resolvedRec = if (rec != null) {
|
||||||
val declClass = decl ?: cls
|
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
|
val value = resolvedRec.value
|
||||||
frame.storeObjResult(dst, value)
|
frame.storeObjResult(dst, value)
|
||||||
return
|
return
|
||||||
@ -4130,6 +4184,15 @@ class BytecodeLambdaCallable(
|
|||||||
val context = callScope.applyClosureForBytecode(closureScope, preferredThisType).also {
|
val context = callScope.applyClosureForBytecode(closureScope, preferredThisType).also {
|
||||||
it.args = args
|
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) {
|
if (captureRecords != null) {
|
||||||
context.captureRecords = captureRecords
|
context.captureRecords = captureRecords
|
||||||
context.captureNames = captureNames
|
context.captureNames = captureNames
|
||||||
|
|||||||
@ -144,6 +144,7 @@ enum class Opcode(val code: Int) {
|
|||||||
MOD_OBJ(0x7B),
|
MOD_OBJ(0x7B),
|
||||||
CONTAINS_OBJ(0x7C),
|
CONTAINS_OBJ(0x7C),
|
||||||
ASSIGN_OP_OBJ(0x7D),
|
ASSIGN_OP_OBJ(0x7D),
|
||||||
|
POS_OBJ(0x7E),
|
||||||
|
|
||||||
JMP(0x80),
|
JMP(0x80),
|
||||||
JMP_IF_TRUE(0x81),
|
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. */
|
/** Extension that converts a [Pos] (line/column) into absolute character offset in the [Source] text. */
|
||||||
fun Source.offsetOf(pos: Pos): Int {
|
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
|
var off = 0
|
||||||
// Sum full preceding lines + one '\n' per line (lines[] were created by String.lines())
|
// Sum full preceding lines + one '\n' per line (lines[] were created by String.lines())
|
||||||
var i = 0
|
var i = 0
|
||||||
@ -31,8 +34,8 @@ fun Source.offsetOf(pos: Pos): Int {
|
|||||||
off += lines[i].length + 1 // assume \n as separator
|
off += lines[i].length + 1 // assume \n as separator
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
off += pos.column
|
off += pos.column.coerceIn(0, lines[pos.line].length)
|
||||||
return off
|
return off.coerceAtMost(text.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val reservedIdKeywords = setOf("constructor", "property")
|
private val reservedIdKeywords = setOf("constructor", "property")
|
||||||
@ -114,6 +117,28 @@ private fun mergeAdjacent(spans: List<HighlightSpan>): List<HighlightSpan> {
|
|||||||
return out
|
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). */
|
/** Simple highlighter using the existing Lyng lexer (no incremental support yet). */
|
||||||
class SimpleLyngHighlighter : LyngHighlighter {
|
class SimpleLyngHighlighter : LyngHighlighter {
|
||||||
override fun highlight(text: String): List<HighlightSpan> {
|
override fun highlight(text: String): List<HighlightSpan> {
|
||||||
@ -167,8 +192,8 @@ class SimpleLyngHighlighter : LyngHighlighter {
|
|||||||
val overridden = applyEnumConstantHeuristics(text, src, tokens, raw)
|
val overridden = applyEnumConstantHeuristics(text, src, tokens, raw)
|
||||||
// Adjust single-line comment spans to extend till EOL to compensate for lexer offset/length quirks
|
// Adjust single-line comment spans to extend till EOL to compensate for lexer offset/length quirks
|
||||||
val adjusted = extendSingleLineCommentsToEol(text, overridden)
|
val adjusted = extendSingleLineCommentsToEol(text, overridden)
|
||||||
// Spans are in order; merge adjacent of the same kind for compactness
|
// Normalize spans, then merge adjacent spans of the same kind for compactness.
|
||||||
return mergeAdjacent(adjusted)
|
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 TypeGenericDoc(val base: TypeNameDoc, val args: List<TypeDoc>, override val nullable: Boolean = false) : TypeDoc
|
||||||
data class TypeFunctionDoc(
|
data class TypeFunctionDoc(
|
||||||
val receiver: TypeDoc? = null,
|
val receiver: TypeDoc? = null,
|
||||||
|
val contextReceivers: List<TypeDoc> = emptyList(),
|
||||||
val params: List<TypeDoc>,
|
val params: List<TypeDoc>,
|
||||||
val returns: TypeDoc,
|
val returns: TypeDoc,
|
||||||
override val nullable: Boolean = false
|
override val nullable: Boolean = false
|
||||||
@ -45,8 +46,13 @@ data class TypeVarDoc(val name: String, override val nullable: Boolean = false)
|
|||||||
// Convenience builders
|
// Convenience builders
|
||||||
fun type(name: String, nullable: Boolean = false) = TypeNameDoc(name.split('.'), nullable)
|
fun type(name: String, nullable: Boolean = false) = TypeNameDoc(name.split('.'), nullable)
|
||||||
fun typeVar(name: String, nullable: Boolean = false) = TypeVarDoc(name, nullable)
|
fun typeVar(name: String, nullable: Boolean = false) = TypeVarDoc(name, nullable)
|
||||||
fun funType(params: List<TypeDoc>, returns: TypeDoc, receiver: TypeDoc? = null, nullable: Boolean = false) =
|
fun funType(
|
||||||
TypeFunctionDoc(receiver, params, returns, nullable)
|
params: List<TypeDoc>,
|
||||||
|
returns: TypeDoc,
|
||||||
|
receiver: TypeDoc? = null,
|
||||||
|
contextReceivers: List<TypeDoc> = emptyList(),
|
||||||
|
nullable: Boolean = false
|
||||||
|
) = TypeFunctionDoc(receiver, contextReceivers, params, returns, nullable)
|
||||||
|
|
||||||
// ---------------- Registry ----------------
|
// ---------------- Registry ----------------
|
||||||
|
|
||||||
@ -281,6 +287,7 @@ internal fun TypeDoc.toMiniTypeRef(): MiniTypeRef = when (this) {
|
|||||||
is TypeFunctionDoc -> MiniFunctionType(
|
is TypeFunctionDoc -> MiniFunctionType(
|
||||||
range = builtinRange(),
|
range = builtinRange(),
|
||||||
receiver = this.receiver?.toMiniTypeRef(),
|
receiver = this.receiver?.toMiniTypeRef(),
|
||||||
|
contextReceivers = this.contextReceivers.map { it.toMiniTypeRef() },
|
||||||
params = this.params.map { it.toMiniTypeRef() },
|
params = this.params.map { it.toMiniTypeRef() },
|
||||||
returnType = this.returns.toMiniTypeRef(),
|
returnType = this.returns.toMiniTypeRef(),
|
||||||
nullable = this.nullable
|
nullable = this.nullable
|
||||||
|
|||||||
@ -139,6 +139,7 @@ data class MiniGenericType(
|
|||||||
data class MiniFunctionType(
|
data class MiniFunctionType(
|
||||||
override val range: MiniRange,
|
override val range: MiniRange,
|
||||||
val receiver: MiniTypeRef?,
|
val receiver: MiniTypeRef?,
|
||||||
|
val contextReceivers: List<MiniTypeRef>,
|
||||||
val params: List<MiniTypeRef>,
|
val params: List<MiniTypeRef>,
|
||||||
val returnType: MiniTypeRef,
|
val returnType: MiniTypeRef,
|
||||||
val nullable: Boolean
|
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 {
|
open suspend fun mul(scope: Scope, other: Obj): Obj {
|
||||||
val otherValue = when (other) {
|
val otherValue = when (other) {
|
||||||
is FrameSlotRef -> other.read()
|
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)
|
// Simple id generator for class identities (not thread-safe; fine for scripts)
|
||||||
private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ }
|
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 {
|
val ObjClassType by lazy {
|
||||||
object : ObjClass("Class") {
|
object : ObjClass("Class") {
|
||||||
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
|
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
|
||||||
@ -98,6 +115,30 @@ val ObjClassType by lazy {
|
|||||||
val rec = cls.getInstanceMemberOrNull(name)
|
val rec = cls.getInstanceMemberOrNull(name)
|
||||||
rec?.value ?: ObjNull
|
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 isAbstract: Boolean = false
|
||||||
var isClosed: Boolean = false
|
var isClosed: Boolean = false
|
||||||
|
var isSingletonObject: Boolean = false
|
||||||
var logicalPackageNameOverride: String? = null
|
var logicalPackageNameOverride: String? = null
|
||||||
|
|
||||||
// Stable identity and simple structural version for PICs
|
// Stable identity and simple structural version for PICs
|
||||||
@ -850,6 +892,7 @@ open class ObjClass(
|
|||||||
methodId: Int? = null,
|
methodId: Int? = null,
|
||||||
typeDecl: net.sergeych.lyng.TypeDecl? = null,
|
typeDecl: net.sergeych.lyng.TypeDecl? = null,
|
||||||
callSignature: net.sergeych.lyng.CallSignature? = null,
|
callSignature: net.sergeych.lyng.CallSignature? = null,
|
||||||
|
annotations: List<DeclAnnotation> = emptyList(),
|
||||||
): ObjRecord {
|
): ObjRecord {
|
||||||
// Validation of override rules: only for non-system declarations
|
// Validation of override rules: only for non-system declarations
|
||||||
var existing: ObjRecord? = null
|
var existing: ObjRecord? = null
|
||||||
@ -952,6 +995,7 @@ open class ObjClass(
|
|||||||
type = type,
|
type = type,
|
||||||
callSignature = callSignature,
|
callSignature = callSignature,
|
||||||
typeDecl = typeDecl,
|
typeDecl = typeDecl,
|
||||||
|
annotations = annotations,
|
||||||
memberName = name,
|
memberName = name,
|
||||||
fieldId = effectiveFieldId,
|
fieldId = effectiveFieldId,
|
||||||
methodId = effectiveMethodId
|
methodId = effectiveMethodId
|
||||||
@ -978,7 +1022,8 @@ open class ObjClass(
|
|||||||
type: ObjRecord.Type = ObjRecord.Type.Field,
|
type: ObjRecord.Type = ObjRecord.Type.Field,
|
||||||
fieldId: Int? = null,
|
fieldId: Int? = null,
|
||||||
methodId: Int? = null,
|
methodId: Int? = null,
|
||||||
callSignature: net.sergeych.lyng.CallSignature? = null
|
callSignature: net.sergeych.lyng.CallSignature? = null,
|
||||||
|
annotations: List<DeclAnnotation> = emptyList()
|
||||||
): ObjRecord {
|
): ObjRecord {
|
||||||
initClassScope()
|
initClassScope()
|
||||||
val existing = classScope!!.objects[name]
|
val existing = classScope!!.objects[name]
|
||||||
@ -1020,6 +1065,7 @@ open class ObjClass(
|
|||||||
recordType = type,
|
recordType = type,
|
||||||
isTransient = isTransient,
|
isTransient = isTransient,
|
||||||
callSignature = callSignature,
|
callSignature = callSignature,
|
||||||
|
annotations = annotations,
|
||||||
fieldId = effectiveFieldId,
|
fieldId = effectiveFieldId,
|
||||||
methodId = effectiveMethodId
|
methodId = effectiveMethodId
|
||||||
)
|
)
|
||||||
@ -1066,7 +1112,8 @@ open class ObjClass(
|
|||||||
isOverride: Boolean = false,
|
isOverride: Boolean = false,
|
||||||
pos: Pos = Pos.builtIn,
|
pos: Pos = Pos.builtIn,
|
||||||
prop: ObjProperty? = null,
|
prop: ObjProperty? = null,
|
||||||
methodId: Int? = null
|
methodId: Int? = null,
|
||||||
|
annotations: List<DeclAnnotation> = emptyList()
|
||||||
) {
|
) {
|
||||||
val g = getter?.let { ObjExternCallable.fromBridge { it() } }
|
val g = getter?.let { ObjExternCallable.fromBridge { it() } }
|
||||||
val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } }
|
val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } }
|
||||||
@ -1075,7 +1122,8 @@ open class ObjClass(
|
|||||||
name, finalProp, false, visibility, writeVisibility, pos, declaringClass,
|
name, finalProp, false, visibility, writeVisibility, pos, declaringClass,
|
||||||
isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride,
|
isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride,
|
||||||
type = ObjRecord.Type.Property,
|
type = ObjRecord.Type.Property,
|
||||||
methodId = methodId
|
methodId = methodId,
|
||||||
|
annotations = annotations
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
package net.sergeych.lyng.obj
|
package net.sergeych.lyng.obj
|
||||||
|
import net.sergeych.lyng.DeclAnnotation
|
||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.Visibility
|
import net.sergeych.lyng.Visibility
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ data class ObjRecord(
|
|||||||
var receiver: Obj? = null,
|
var receiver: Obj? = null,
|
||||||
val callSignature: net.sergeych.lyng.CallSignature? = null,
|
val callSignature: net.sergeych.lyng.CallSignature? = null,
|
||||||
val typeDecl: net.sergeych.lyng.TypeDecl? = null,
|
val typeDecl: net.sergeych.lyng.TypeDecl? = null,
|
||||||
|
val annotations: List<DeclAnnotation> = emptyList(),
|
||||||
val memberName: String? = null,
|
val memberName: String? = null,
|
||||||
val fieldId: Int? = null,
|
val fieldId: Int? = null,
|
||||||
val methodId: Int? = null,
|
val methodId: Int? = null,
|
||||||
|
|||||||
@ -73,7 +73,7 @@ class ClassOperatorRef(val target: ObjRef, val pos: Pos) : ObjRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Unary operations supported by ObjRef. */
|
/** Unary operations supported by ObjRef. */
|
||||||
enum class UnaryOp { NOT, NEGATE, BITNOT }
|
enum class UnaryOp { NOT, POSITIVE, NEGATE, BITNOT }
|
||||||
|
|
||||||
/** Binary operations supported by ObjRef. */
|
/** Binary operations supported by ObjRef. */
|
||||||
enum class BinOp {
|
enum class BinOp {
|
||||||
|
|||||||
@ -206,7 +206,17 @@ private fun typeDeclKey(type: TypeDecl): String = when (type) {
|
|||||||
TypeDecl.TypeNullableAny -> "Any?"
|
TypeDecl.TypeNullableAny -> "Any?"
|
||||||
is TypeDecl.Simple -> "S:${type.name}"
|
is TypeDecl.Simple -> "S:${type.name}"
|
||||||
is TypeDecl.Generic -> "G:${type.name}<${type.args.joinToString(",") { typeDeclKey(it) }}>"
|
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.Ellipsis -> "E:${typeDeclKey(type.elementType)}"
|
||||||
is TypeDecl.TypeVar -> "V:${type.name}"
|
is TypeDecl.TypeVar -> "V:${type.name}"
|
||||||
is TypeDecl.Union -> "U:${type.options.joinToString("|") { typeDeclKey(it) }}"
|
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
|
package net.sergeych.lynon
|
||||||
|
|
||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.requireOnlyArg
|
import net.sergeych.lyng.serialization.ObjSerializationFormatClass
|
||||||
import net.sergeych.lyng.requireScope
|
|
||||||
import net.sergeych.lyng.obj.*
|
import net.sergeych.lyng.obj.*
|
||||||
|
|
||||||
// Most often used types:
|
// Most often used types:
|
||||||
|
|
||||||
|
|
||||||
object ObjLynonClass : ObjClass("Lynon") {
|
object ObjLynonClass : ObjSerializationFormatClass("Lynon") {
|
||||||
|
|
||||||
suspend fun encodeAny(scope: Scope, obj: Obj): ObjBitBuffer {
|
suspend fun encodeAny(scope: Scope, obj: Obj): ObjBitBuffer {
|
||||||
val bout = MemoryBitOutput()
|
val bout = MemoryBitOutput()
|
||||||
@ -41,15 +40,9 @@ object ObjLynonClass : ObjClass("Lynon") {
|
|||||||
return deserializer.decodeAny(scope)
|
return deserializer.decodeAny(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
override suspend fun encodeValue(scope: Scope, value: Obj): Obj = encodeAny(scope, value)
|
||||||
addClassConst("test", ObjString("test_const"))
|
|
||||||
addClassFn("encode") {
|
override suspend fun decodeValue(scope: Scope, encoded: Obj): Obj = decodeAny(scope, encoded)
|
||||||
encodeAny(requireScope(), requireOnlyArg<Obj>())
|
|
||||||
}
|
|
||||||
addClassFn("decode") {
|
|
||||||
decodeAny(requireScope(), requireOnlyArg<Obj>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 kotlinx.coroutines.withTimeout
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import net.sergeych.lyng.eval as lyngEval
|
import net.sergeych.lyng.eval as lyngEval
|
||||||
|
|
||||||
class LaunchPoolTest {
|
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
|
@Test
|
||||||
fun testBasicExecution() = runBlocking<Unit> {
|
fun testBasicExecution() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
val d1 = pool.launch { 1 + 1 }
|
val d1 = pool.launch { 1 + 1 }
|
||||||
@ -37,7 +41,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testResultsCollected() = runBlocking<Unit> {
|
fun testResultsCollected() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(4)
|
val pool = LaunchPool(4)
|
||||||
val jobs = (1..10).map { n -> pool.launch { n * n } }
|
val jobs = (1..10).map { n -> pool.launch { n * n } }
|
||||||
@ -48,7 +52,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testConcurrencyLimit() = runBlocking<Unit> {
|
fun testConcurrencyLimit() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
// With maxWorkers=2, at most 2 tasks run at the same time.
|
// With maxWorkers=2, at most 2 tasks run at the same time.
|
||||||
val mu = Mutex()
|
val mu = Mutex()
|
||||||
@ -70,7 +74,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testExceptionCapturedInDeferred() = runBlocking<Unit> {
|
fun testExceptionCapturedInDeferred() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
val good = pool.launch { 42 }
|
val good = pool.launch { 42 }
|
||||||
@ -83,7 +87,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testPoolContinuesAfterLambdaException() = runBlocking<Unit> {
|
fun testPoolContinuesAfterLambdaException() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(1)
|
val pool = LaunchPool(1)
|
||||||
val bad = pool.launch { throw IllegalArgumentException("fail") }
|
val bad = pool.launch { throw IllegalArgumentException("fail") }
|
||||||
@ -96,7 +100,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLaunchAfterCloseAndJoinThrows() = runBlocking<Unit> {
|
fun testLaunchAfterCloseAndJoinThrows() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
pool.launch { 1 }
|
pool.launch { 1 }
|
||||||
@ -107,7 +111,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testLaunchAfterCancelThrows() = runBlocking<Unit> {
|
fun testLaunchAfterCancelThrows() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
pool.cancel()
|
pool.cancel()
|
||||||
@ -117,7 +121,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testCancelAndJoinWaitsForWorkers() = runBlocking<Unit> {
|
fun testCancelAndJoinWaitsForWorkers() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
pool.launch { delay(5) }
|
pool.launch { delay(5) }
|
||||||
@ -128,7 +132,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testCloseAndJoinDrainsQueue() = runBlocking<Unit> {
|
fun testCloseAndJoinDrainsQueue() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val mu = Mutex()
|
val mu = Mutex()
|
||||||
val results = []
|
val results = []
|
||||||
@ -147,7 +151,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBoundedQueueSuspendsProducer() = runBlocking<Unit> {
|
fun testBoundedQueueSuspendsProducer() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
// queue of 2 + 1 worker; producer can only be 1 ahead of what's running
|
// queue of 2 + 1 worker; producer can only be 1 ahead of what's running
|
||||||
val pool = LaunchPool(1, 2)
|
val pool = LaunchPool(1, 2)
|
||||||
@ -165,7 +169,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testUnlimitedQueueDefault() = runBlocking<Unit> {
|
fun testUnlimitedQueueDefault() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(4)
|
val pool = LaunchPool(4)
|
||||||
val jobs = (1..50).map { n -> pool.launch { n } }
|
val jobs = (1..50).map { n -> pool.launch { n } }
|
||||||
@ -177,7 +181,7 @@ class LaunchPoolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testIdempotentClose() = runBlocking<Unit> {
|
fun testIdempotentClose() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
val pool = LaunchPool(2)
|
val pool = LaunchPool(2)
|
||||||
pool.closeAndJoin()
|
pool.closeAndJoin()
|
||||||
|
|||||||
@ -473,6 +473,29 @@ class MiniAstTest {
|
|||||||
assertEquals("b", fn.params[1].name)
|
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
|
@Test
|
||||||
fun miniAst_captures_dokka_tags() = runTest {
|
fun miniAst_captures_dokka_tags() = runTest {
|
||||||
val code = """
|
val code = """
|
||||||
|
|||||||
@ -190,4 +190,63 @@ class ScriptImportPreparationTest {
|
|||||||
session.cancelAndJoin()
|
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.*
|
||||||
import net.sergeych.lyng.obj.*
|
import net.sergeych.lyng.obj.*
|
||||||
import net.sergeych.lyng.pacman.InlineSourcesImportProvider
|
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.lyng.thisAs
|
||||||
import net.sergeych.mp_tools.globalDefer
|
import net.sergeych.mp_tools.globalDefer
|
||||||
import net.sergeych.tools.bm
|
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
|
@Serializable
|
||||||
data class TestJson2(
|
data class TestJson2(
|
||||||
val value: Int,
|
val value: Int,
|
||||||
@ -4722,6 +4961,34 @@ class ScriptTest {
|
|||||||
assertEquals(TestJson4(TestEnum.One), x)
|
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
|
@Test
|
||||||
fun testStringLast() = runTest {
|
fun testStringLast() = runTest {
|
||||||
eval(
|
eval(
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.test.runTest
|
||||||
import net.sergeych.lyng.eval
|
import net.sergeych.lyng.eval
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ class TypeInferenceTest {
|
|||||||
|
|
||||||
/** Channel field type inferred from constructor — accessed in a launch closure */
|
/** Channel field type inferred from constructor — accessed in a launch closure */
|
||||||
@Test
|
@Test
|
||||||
fun testChannelFieldInLaunchClosure() = runBlocking<Unit> {
|
fun testChannelFieldInLaunchClosure() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
class Foo {
|
class Foo {
|
||||||
private val ch = Channel(Channel.UNLIMITED)
|
private val ch = Channel(Channel.UNLIMITED)
|
||||||
@ -52,7 +52,7 @@ class TypeInferenceTest {
|
|||||||
|
|
||||||
/** Mutex field type inferred from constructor — used directly in a method body */
|
/** Mutex field type inferred from constructor — used directly in a method body */
|
||||||
@Test
|
@Test
|
||||||
fun testMutexFieldDirectUse() = runBlocking<Unit> {
|
fun testMutexFieldDirectUse() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
class Bar {
|
class Bar {
|
||||||
private val mu = Mutex()
|
private val mu = Mutex()
|
||||||
@ -69,7 +69,7 @@ class TypeInferenceTest {
|
|||||||
|
|
||||||
/** CompletableDeferred field type inferred — complete/await used directly */
|
/** CompletableDeferred field type inferred — complete/await used directly */
|
||||||
@Test
|
@Test
|
||||||
fun testCompletableDeferredFieldDirectUse() = runBlocking<Unit> {
|
fun testCompletableDeferredFieldDirectUse() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
class Baz {
|
class Baz {
|
||||||
private val d = CompletableDeferred()
|
private val d = CompletableDeferred()
|
||||||
@ -84,7 +84,7 @@ class TypeInferenceTest {
|
|||||||
|
|
||||||
/** Channel field accessed inside a map closure within class initializer */
|
/** Channel field accessed inside a map closure within class initializer */
|
||||||
@Test
|
@Test
|
||||||
fun testChannelFieldInMapAndLaunchClosure() = runBlocking<Unit> {
|
fun testChannelFieldInMapAndLaunchClosure() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
class Pool(n) {
|
class Pool(n) {
|
||||||
private val ch = Channel(Channel.UNLIMITED)
|
private val ch = Channel(Channel.UNLIMITED)
|
||||||
@ -104,4 +104,39 @@ class TypeInferenceTest {
|
|||||||
Pool(2).closeAll()
|
Pool(2).closeAll()
|
||||||
""".trimIndent())
|
""".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())
|
""".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
|
@Test
|
||||||
fun testPlusAssignOverloading() = runTest {
|
fun testPlusAssignOverloading() = runTest {
|
||||||
eval("""
|
eval("""
|
||||||
|
|||||||
@ -134,4 +134,152 @@ class OptTest {
|
|||||||
assertEquals((1..10).toSet(), result)
|
assertEquals((1..10).toSet(), result)
|
||||||
""".trimIndent())
|
""".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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
@ -18,6 +18,7 @@
|
|||||||
package net.sergeych.lyng.highlight
|
package net.sergeych.lyng.highlight
|
||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class HighlightMappingTest {
|
class HighlightMappingTest {
|
||||||
@ -72,6 +73,38 @@ class HighlightMappingTest {
|
|||||||
assertTrue(labeled.any { it.first == "\"s\"" && it.second == HighlightKind.String })
|
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
|
@Test
|
||||||
fun commentsHighlighted() {
|
fun commentsHighlighted() {
|
||||||
val text = "// line\n/* block */"
|
val text = "// line\n/* block */"
|
||||||
|
|||||||
@ -14,6 +14,24 @@ extern class NotImplementedException
|
|||||||
/* Raised when an awaited asynchronous task was cancelled before producing a value. */
|
/* Raised when an awaited asynchronous task was cancelled before producing a value. */
|
||||||
extern class CancellationException : Exception
|
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. */
|
/* A handle to a running asynchronous task. */
|
||||||
extern class Deferred {
|
extern class Deferred {
|
||||||
/* Cancel the task if it is still active. Safe to call multiple times. */
|
/* Cancel the task if it is still active. Safe to call multiple times. */
|
||||||
@ -648,4 +666,4 @@ class LaunchPool(maxWorkers, maxQueueSize = Channel.UNLIMITED) {
|
|||||||
w.await()
|
w.await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// 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 {
|
val generateDocsIndex by tasks.registering {
|
||||||
group = "documentation"
|
group = "documentation"
|
||||||
description = "Generates docs-index.json listing all Markdown files under /docs"
|
description = "Generates docs-index.json listing all Markdown files under /docs"
|
||||||
|
|
||||||
val docsDir = rootProject.projectDir.resolve("docs")
|
val docsDir = rootProject.projectDir.resolve("docs")
|
||||||
|
val generatedDocsDir = layout.buildDirectory.dir("generated-sample-docs/docs")
|
||||||
val outDir = layout.buildDirectory.dir("generated-resources")
|
val outDir = layout.buildDirectory.dir("generated-resources")
|
||||||
|
|
||||||
inputs.dir(docsDir)
|
inputs.dir(docsDir)
|
||||||
|
inputs.dir(generatedDocsDir)
|
||||||
outputs.dir(outDir)
|
outputs.dir(outDir)
|
||||||
|
|
||||||
|
dependsOn(generateSampleDocPages)
|
||||||
|
|
||||||
doLast {
|
doLast {
|
||||||
val docs = mutableListOf<String>()
|
val docs = linkedSetOf<String>()
|
||||||
if (docsDir.exists()) {
|
if (docsDir.exists()) {
|
||||||
docsDir.walkTopDown()
|
docsDir.walkTopDown()
|
||||||
.filter { it.isFile && it.extension.equals("md", ignoreCase = true) }
|
.filter { it.isFile && it.extension.equals("md", ignoreCase = true) }
|
||||||
@ -100,6 +155,16 @@ val generateDocsIndex by tasks.registering {
|
|||||||
docs += "docs/$rel"
|
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
|
val out = outDir.get().asFile
|
||||||
out.mkdirs()
|
out.mkdirs()
|
||||||
val file = out.resolve("docs-index.json")
|
val file = out.resolve("docs-index.json")
|
||||||
@ -113,7 +178,7 @@ val generateDocsIndex by tasks.registering {
|
|||||||
append(']')
|
append(']')
|
||||||
}
|
}
|
||||||
file.writeText(json)
|
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
|
// Ensure any ProcessResources task depends on docs index generation so the JSON is packaged
|
||||||
tasks.configureEach {
|
tasks.configureEach {
|
||||||
if (name.endsWith("ProcessResources")) {
|
if (name.endsWith("ProcessResources")) {
|
||||||
dependsOn(generateDocsIndex, generateSiteVersion)
|
dependsOn(generateSampleDocPages, generateDocsIndex, generateSiteVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,16 +213,20 @@ listOf(
|
|||||||
"jsProcessResources"
|
"jsProcessResources"
|
||||||
).forEach { taskName ->
|
).forEach { taskName ->
|
||||||
tasks.matching { it.name == taskName }.configureEach {
|
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
|
// Copy Markdown docs into the "docs/" folder in the final resources, so paths in docs-index.json match files
|
||||||
tasks.named<Copy>("jsProcessResources").configure {
|
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
|
// 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")) {
|
from(rootProject.projectDir.resolve("docs")) {
|
||||||
into("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
|
// 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") }) {
|
Ul({ classes("list-unstyled", "mb-0") }) {
|
||||||
toc.forEach { item ->
|
toc.forEach { item ->
|
||||||
Li({ classes("mb-1") }) {
|
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 routeNoFrag = route.substringBefore('#')
|
||||||
val tocHref = "#/$routeNoFrag#${item.id}"
|
val tocHref = "#/$routeNoFrag#${item.id}"
|
||||||
A(attrs = {
|
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