added nullable ?: break support with proper inference ;)

This commit is contained in:
Sergey Chernov 2026-04-26 20:57:46 +03:00
parent fae9965bdf
commit 79429d5f2d
5 changed files with 417 additions and 104 deletions

View File

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

View File

@ -1,9 +1,8 @@
### lyng.io.http.server — Minimal HTTP/1.1 and WebSocket server # `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. 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: It supports:
- HTTP/1.1 request parsing - HTTP/1.1 request parsing
- keep-alive - keep-alive
- exact-path routing - exact-path routing
@ -15,9 +14,7 @@ It does not aim to replace a full reverse proxy. Typical deployment is behind ng
> **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. > **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
#### Install the module into a Lyng session
Kotlin bootstrap example: Kotlin bootstrap example:
@ -35,9 +32,7 @@ suspend fun bootstrapHttpServer() {
} }
``` ```
--- ## RequestContext Sugar
#### RequestContext Sugar
Route handlers use `RequestContext` as the receiver, so inside handlers you normally write direct calls such as: Route handlers use `RequestContext` as the receiver, so inside handlers you normally write direct calls such as:
@ -48,11 +43,9 @@ Route handlers use `RequestContext` as the receiver, so inside handlers you norm
- `request.path` - `request.path`
- `routeParams["id"]` - `routeParams["id"]`
This keeps ordinary HTTP endpoints compact and avoids passing an explicit request/exchange parameter through every route lambda. This keeps ordinary HTTP endpoints compact and avoids passing an explicit request or exchange parameter through every route lambda.
--- ## JSON API Sugar
#### JSON API Sugar
For ordinary JSON APIs, `RequestContext` includes two primary helpers: For ordinary JSON APIs, `RequestContext` includes two primary helpers:
@ -61,7 +54,7 @@ For ordinary JSON APIs, `RequestContext` includes two primary helpers:
These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`. These helpers intentionally use ordinary JSON projection for HTTP interop, not canonical `Json.encode(...)`.
**Typed JSON POST** ### Typed JSON POST
```lyng ```lyng
import lyng.io.http.server import lyng.io.http.server
@ -85,7 +78,7 @@ server.postPath("/api/users") {
server.listen(8080, "127.0.0.1") server.listen(8080, "127.0.0.1")
``` ```
**JSON response with route params** ### JSON Response With Route Params
```lyng ```lyng
import lyng.io.http.server import lyng.io.http.server
@ -103,9 +96,7 @@ server.getPath("/api/users/{id}") {
server.listen(8080, "127.0.0.1") server.listen(8080, "127.0.0.1")
``` ```
--- ## Request And Route Data
#### Request and Route Data
`ServerRequest` exposes parsed HTTP request data: `ServerRequest` exposes parsed HTTP request data:
@ -135,9 +126,7 @@ For exact routes, `routeMatch` is `null` and `routeParams` is empty.
For regex routes, `routeMatch` is set and `routeParams` is empty. For regex routes, `routeMatch` is set and `routeParams` is empty.
For path-template routes, both `routeMatch` and `routeParams` are set. For path-template routes, both `routeMatch` and `routeParams` are set.
--- ## Reusable Routers
#### Reusable Routers
`Router` collects the same route kinds as `HttpServer`, but does not listen on sockets by itself. `Router` collects the same route kinds as `HttpServer`, but does not listen on sockets by itself.
Mount it into `HttpServer` or another `Router`. Mount it into `HttpServer` or another `Router`.
@ -164,11 +153,9 @@ 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. Mounted routers reuse the built-in server router. They are configuration-time composition, not an extra per-request Lyng dispatch layer.
--- ## WebSocket Routes
#### WebSocket Routes You can route websocket upgrades by exact path, regex, or path template.
You can route websocket upgrades by exact path, regex, or path template:
```lyng ```lyng
server.ws("/chat") { ws -> server.ws("/chat") { ws ->
@ -182,9 +169,147 @@ server.wsPath("/ws/{room}") { ws ->
} }
``` ```
--- 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.
#### Path-Template Routes ### 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`. Path templates are sugar on top of regex routes. Template parameters are exposed as decoded `routeParams`.
@ -198,7 +323,6 @@ server.getPath("/users/{userId}/posts/{postId}") {
``` ```
Template rules: Template rules:
- template must start with `/` - template must start with `/`
- a segment is either literal text or `{name}` - a segment is either literal text or `{name}`
- parameter names must be valid identifiers - parameter names must be valid identifiers
@ -208,9 +332,7 @@ Template rules:
- `+` stays `+` - `+` stays `+`
- malformed `%` stays literal - malformed `%` stays literal
--- ## Regex Routes
#### Regex Routes
Regex routes match the whole request path, not a substring. Regex routes match the whole request path, not a substring.
@ -221,9 +343,7 @@ server.get("^/users/([0-9]+)/posts/([0-9]+)$".re) {
} }
``` ```
--- ## Basic Exact Route
#### Basic Exact Route
```lyng ```lyng
import lyng.io.http.server import lyng.io.http.server
@ -236,9 +356,7 @@ server.get("/hello") {
server.listen(8080, "127.0.0.1") server.listen(8080, "127.0.0.1")
``` ```
--- ## Route Precedence
#### Route Precedence
Dispatch order is: Dispatch order is:
@ -250,11 +368,9 @@ Dispatch order is:
This means exact routes stay fast and always win over template or regex routes for the same path. This means exact routes stay fast and always win over template or regex routes for the same path.
--- ## API Surface
#### API Surface ### `Router` Route Registration Methods
`Router` route registration methods:
- `get(path: String|Regex, handler)` - `get(path: String|Regex, handler)`
- `getPath(pathTemplate: String, handler)` - `getPath(pathTemplate: String, handler)`
@ -271,7 +387,7 @@ This means exact routes stay fast and always win over template or regex routes f
- `fallback(handler)` - `fallback(handler)`
- `mount(router)` - `mount(router)`
`HttpServer` route registration methods: ### `HttpServer` Route Registration Methods
- `get(path: String|Regex, handler)` - `get(path: String|Regex, handler)`
- `getPath(pathTemplate: String, handler)` - `getPath(pathTemplate: String, handler)`

View File

@ -1,4 +1,4 @@
### 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.
@ -6,11 +6,9 @@ This module provides a compact WebSocket client API for Lyng scripts. It is impl
> >
> **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. > **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
@ -26,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`
@ -87,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)`
@ -137,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

View File

@ -3245,6 +3245,21 @@ class Compiler(
operand = StatementRef(s) operand = StatementRef(s)
} }
"break" -> {
val s = parseBreakStatement(t.pos)
operand = StatementRef(s)
}
"continue" -> {
val s = parseContinueStatement(t.pos)
operand = StatementRef(s)
}
"return" -> {
val s = parseReturnStatement(t.pos)
operand = StatementRef(s)
}
else -> { else -> {
// Do not consume the keyword as part of a term; backtrack // Do not consume the keyword as part of a term; backtrack
// and return null so outer parser handles it. // and return null so outer parser handles it.
@ -4784,6 +4799,28 @@ class Compiler(
return inferTypeDeclFromRef(directRef) return inferTypeDeclFromRef(directRef)
} }
private fun isAbruptControlRef(ref: ObjRef): Boolean {
val stmt = (ref as? StatementRef)?.statement ?: return false
return when (unwrapBytecodeDeep(stmt)) {
is BreakStatement, is ContinueStatement, is ReturnStatement, is ThrowStatement -> true
else -> false
}
}
private fun inferElvisTypeDecl(ref: ElvisRef): TypeDecl? {
val leftType = inferTypeDeclFromRef(ref.left)
?: inferObjClassFromRef(ref.left)?.let { TypeDecl.Simple(it.className, false) }
val nonNullLeftType = leftType?.let { makeTypeDeclNonNullable(it) }
if (isAbruptControlRef(ref.right)) return nonNullLeftType
val rightType = inferTypeDeclFromRef(ref.right)
?: inferObjClassFromRef(ref.right)?.let { TypeDecl.Simple(it.className, false) }
return when {
nonNullLeftType == null -> rightType
rightType == null -> nonNullLeftType
else -> mergeTypeDecls(nonNullLeftType, rightType)
}
}
private fun inferTypeDeclFromRef(ref: ObjRef): TypeDecl? { private fun inferTypeDeclFromRef(ref: ObjRef): TypeDecl? {
resolveReceiverTypeDecl(ref)?.let { return it } resolveReceiverTypeDecl(ref)?.let { return it }
return when (ref) { return when (ref) {
@ -4792,6 +4829,7 @@ class Compiler(
is MapLiteralRef -> inferMapLiteralTypeDecl(ref) is MapLiteralRef -> inferMapLiteralTypeDecl(ref)
is ConstRef -> inferTypeDeclFromConst(ref.constValue) is ConstRef -> inferTypeDeclFromConst(ref.constValue)
is RangeRef -> TypeDecl.Simple("Range", false) is RangeRef -> TypeDecl.Simple("Range", false)
is ElvisRef -> inferElvisTypeDecl(ref)
is CallRef -> { is CallRef -> {
val targetDecl = resolveReceiverTypeDecl(ref.target) ?: seedTypeDeclFromRef(ref.target) val targetDecl = resolveReceiverTypeDecl(ref.target) ?: seedTypeDeclFromRef(ref.target)
val targetName = when (val target = ref.target) { val targetName = when (val target = ref.target) {
@ -5306,6 +5344,7 @@ class Compiler(
} }
is CallRef -> callReturnTypeDeclByRef[ref] ?: inferCallReturnTypeDecl(ref) is CallRef -> callReturnTypeDeclByRef[ref] ?: inferCallReturnTypeDecl(ref)
is BinaryOpRef -> inferBinaryOpReturnTypeDecl(ref) is BinaryOpRef -> inferBinaryOpReturnTypeDecl(ref)
is ElvisRef -> inferElvisTypeDecl(ref)
is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) } is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) }
else -> null else -> null
} }

View File

@ -134,4 +134,23 @@ 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))
}
assert( t(3) == null )
assert( cnt == 4 )
""".trimIndent())
}
} }