From 79429d5f2da0a1c6a54b538d3eb7b7cf83b5ef21 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 26 Apr 2026 20:57:46 +0300 Subject: [PATCH] added nullable ?: break support with proper inference ;) --- docs/ai_notes_docs_headings.md | 10 + docs/lyng.io.http.server.md | 204 ++++++++++---- docs/lyng.io.ws.md | 249 +++++++++++++----- .../kotlin/net/sergeych/lyng/Compiler.kt | 39 +++ .../kotlin/net/sergeych/lyng/OptTest.kt | 19 ++ 5 files changed, 417 insertions(+), 104 deletions(-) create mode 100644 docs/ai_notes_docs_headings.md diff --git a/docs/ai_notes_docs_headings.md b/docs/ai_notes_docs_headings.md new file mode 100644 index 0000000..2f454fa --- /dev/null +++ b/docs/ai_notes_docs_headings.md @@ -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 `###`. diff --git a/docs/lyng.io.http.server.md b/docs/lyng.io.http.server.md index d68c944..514054c 100644 --- a/docs/lyng.io.http.server.md +++ b/docs/lyng.io.http.server.md @@ -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. It supports: - - HTTP/1.1 request parsing - keep-alive - 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. ---- - -#### Install the module into a Lyng session +## Install The Module Into A Lyng Session 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: @@ -48,11 +43,9 @@ Route handlers use `RequestContext` as the receiver, so inside handlers you norm - `request.path` - `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: @@ -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(...)`. -**Typed JSON POST** +### Typed JSON POST ```lyng import lyng.io.http.server @@ -85,7 +78,7 @@ server.postPath("/api/users") { server.listen(8080, "127.0.0.1") ``` -**JSON response with route params** +### JSON Response With Route Params ```lyng import lyng.io.http.server @@ -103,9 +96,7 @@ server.getPath("/api/users/{id}") { server.listen(8080, "127.0.0.1") ``` ---- - -#### Request and Route Data +## Request And Route Data `ServerRequest` exposes parsed HTTP request data: @@ -131,15 +122,13 @@ server.listen(8080, "127.0.0.1") - `addHeader(...)` - `acceptWebSocket(...)` -For exact routes, `routeMatch` is `null` and `routeParams` is empty. -For regex routes, `routeMatch` is set and `routeParams` is empty. +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 -#### 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`. ```lyng @@ -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. ---- +## 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 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"] ?: "" + 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`. @@ -198,7 +323,6 @@ server.getPath("/users/{userId}/posts/{postId}") { ``` Template rules: - - template must start with `/` - a segment is either literal text or `{name}` - parameter names must be valid identifiers @@ -208,9 +332,7 @@ Template rules: - `+` stays `+` - malformed `%` stays literal ---- - -#### Regex Routes +## Regex Routes 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 import lyng.io.http.server @@ -236,9 +356,7 @@ server.get("/hello") { server.listen(8080, "127.0.0.1") ``` ---- - -#### Route Precedence +## Route Precedence 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. ---- +## API Surface -#### API Surface - -`Router` route registration methods: +### `Router` Route Registration Methods - `get(path: String|Regex, 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)` - `mount(router)` -`HttpServer` route registration methods: +### `HttpServer` Route Registration Methods - `get(path: String|Regex, handler)` - `getPath(pathTemplate: String, handler)` diff --git a/docs/lyng.io.ws.md b/docs/lyng.io.ws.md index 7f58d7b..8b9dc71 100644 --- a/docs/lyng.io.ws.md +++ b/docs/lyng.io.ws.md @@ -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. @@ -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. ---- +## Install The Module Into A Lyng Session -#### Install the module into a Lyng session - -Kotlin (host) bootstrap example: +Kotlin host bootstrap example: ```kotlin import net.sergeych.lyng.EvalSession @@ -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") - val m: WsMessage = ws.receive() + val reply = ws.receive() ?: error("socket closed before reply") + println(reply.text) +} finally { ws.close() - [ws.url() == WS_TEST_URL, m.isText, m.text] - >>> [true,true,echo:ping] +} +``` -Binary message exchange: +For long-lived consumers: - import lyng.buffer - import lyng.io.ws +```lyng +import lyng.io.ws - val ws = Ws.connect(WS_TEST_BINARY_URL) - ws.sendBytes(Buffer(9, 8, 7)) - val m: WsMessage = ws.receive() +val ws = Ws.connect(WS_URL) + +try { + while (true) { + val msg = ws.receive() ?: break + if (msg.isText) { + println(msg.text) + } + } +} finally { ws.close() - [m.isText, (m.data as Buffer).hex] - >>> [false,010203090807] +} +``` -Secure websocket (`wss`) exchange: +## API Reference - import lyng.io.ws +### `Ws` - val ws = Ws.connect(WSS_TEST_URL) - ws.sendText("ping") - val m: WsMessage = ws.receive() - ws.close() - [ws.url() == WSS_TEST_URL, m.text] - >>> [true,secure:ping] - ---- - -#### API reference - -##### `Ws` (static methods) - -- `isSupported(): Bool` — Whether WebSocket client support is available on the current runtime. -- `connect(url: String, headers...): WsSession` — Open a client websocket session. +- `isSupported(): Bool` - whether WebSocket client support is available on the current runtime. +- `connect(url: String, headers...): WsSession` - open a client websocket session. `headers...` accepts: +- `MapEntry`, for example `"Authorization" => "Bearer x"` +- 2-item lists, for example `["Authorization", "Bearer x"]` -- `MapEntry`, e.g. `"Authorization" => "Bearer x"` -- 2-item lists, e.g. `["Authorization", "Bearer x"]` - -##### `WsSession` +### `WsSession` - `isOpen(): Bool` - `url(): String` @@ -87,24 +215,27 @@ Secure websocket (`wss`) exchange: - `receive(): WsMessage?` - `close(code: Int = 1000, reason: String = ""): void` -`receive()` returns `null` after a clean close. +Behavior summary: +- `receive()` returns `null` after close. +- `close()` is safe to call more than once. +- send operations require an open session. -##### `WsMessage` +### `WsMessage` - `isText: Bool` - `text: String?` - `data: Buffer?` -Text messages populate `text`; binary messages populate `data`. +Payload rules: +- Text messages populate `text` and leave `data == null`. +- Binary messages populate `data` and leave `text == null`. ---- - -#### Security policy +## Security Policy The module uses `WsAccessPolicy` to authorize websocket operations. -- `WsAccessPolicy` — interface for custom policies -- `PermitAllWsAccessPolicy` — allows all websocket operations +- `WsAccessPolicy` - interface for custom policies. +- `PermitAllWsAccessPolicy` - allows all websocket operations. - `WsAccessOp.Connect(url)` - `WsAccessOp.Send(url, bytes, isText)` - `WsAccessOp.Receive(url)` @@ -137,14 +268,12 @@ val allowLocalOnly = object : WsAccessPolicy { } ``` ---- +## Platform Support -#### Platform support - -- **JVM:** supported -- **Android:** supported via the Ktor CIO websocket client backend -- **JS:** supported via the Ktor JS websocket client backend -- **Linux native:** supported via the Ktor Curl websocket client backend -- **Windows native:** supported via the Ktor WinHttp websocket client backend -- **Apple native:** supported via the Ktor Darwin websocket client backend -- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access +- **JVM:** supported. +- **Android:** supported via the Ktor CIO websocket client backend. +- **JS:** supported via the Ktor JS websocket client backend. +- **Linux native:** supported via the Ktor Curl websocket client backend. +- **Windows native:** supported via the Ktor WinHttp websocket client backend. +- **Apple native:** supported via the Ktor Darwin websocket client backend. +- **Other targets:** may report unsupported; use `Ws.isSupported()` before relying on websocket client access. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 4831bb5..e82a8d4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -3245,6 +3245,21 @@ class Compiler( 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 -> { // Do not consume the keyword as part of a term; backtrack // and return null so outer parser handles it. @@ -4784,6 +4799,28 @@ class Compiler( 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? { resolveReceiverTypeDecl(ref)?.let { return it } return when (ref) { @@ -4792,6 +4829,7 @@ class Compiler( is MapLiteralRef -> inferMapLiteralTypeDecl(ref) is ConstRef -> inferTypeDeclFromConst(ref.constValue) is RangeRef -> TypeDecl.Simple("Range", false) + is ElvisRef -> inferElvisTypeDecl(ref) is CallRef -> { val targetDecl = resolveReceiverTypeDecl(ref.target) ?: seedTypeDeclFromRef(ref.target) val targetName = when (val target = ref.target) { @@ -5306,6 +5344,7 @@ class Compiler( } is CallRef -> callReturnTypeDeclByRef[ref] ?: inferCallReturnTypeDecl(ref) is BinaryOpRef -> inferBinaryOpReturnTypeDecl(ref) + is ElvisRef -> inferElvisTypeDecl(ref) is StatementRef -> (ref.statement as? ExpressionStatement)?.let { resolveReceiverTypeDecl(it.ref) } else -> null } diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OptTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OptTest.kt index 2287ce3..b7e0db7 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OptTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/OptTest.kt @@ -134,4 +134,23 @@ class OptTest { assertEquals((1..10).toSet(), result) """.trimIndent()) } + + @Test + fun testElvisBreak() = runTest { + eval(""" + fun t(x: Int?): Int? = + if( x == null || x == 3 ) null + else 100 + fun needInt(x: Int): Int = x + + var cnt = -1 + while( true ) { + val x = t(cnt++) ?: break + assertEquals(100, x) + assertEquals(100, needInt(x)) + } + assert( t(3) == null ) + assert( cnt == 4 ) + """.trimIndent()) + } }