+added console spport for lyngio

+added console support to lyng/jlyng CLI
+added unicode escapes
+created tetris console sample
This commit is contained in:
Sergey Chernov 2026-03-19 01:09:32 +03:00
parent 1a080bc53e
commit d9d7cafec8
44 changed files with 3842 additions and 28 deletions

View File

@ -14,8 +14,13 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
## 2. Lexical Syntax
- Comments: `// line`, `/* block */`.
- Strings: `"..."` (supports escapes). Multiline string content is normalized by indentation logic.
- Supported escapes: `\n`, `\r`, `\t`, `\"`, `\\`, `\uXXXX` (4 hex digits).
- Unicode escapes use exactly 4 hex digits (for example: `"\u0416"` -> `Ж`).
- Unknown `\x` escapes in strings are preserved literally as two characters (`\` and `x`).
- Numbers: `Int` (`123`, `1_000`), `Real` (`1.2`, `1e3`), hex (`0xFF`).
- Char: `'a'`, escaped chars supported.
- Supported escapes: `\n`, `\r`, `\t`, `\'`, `\\`, `\uXXXX` (4 hex digits).
- Backslash character in a char literal must be written as `'\\'` (forms like `'\'` are invalid).
- Labels:
- statement label: `loop@ for (...) { ... }`
- label reference: `break@loop`, `continue@loop`, `return@fnLabel`

View File

@ -63,8 +63,9 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
Requires installing `lyngio` into the import manager from host code.
- `import lyng.io.fs` (filesystem `Path` API)
- `import lyng.io.process` (process execution API)
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events)
## 7. AI Generation Tips
- Assume `lyng.stdlib` APIs exist in regular script contexts.
- For platform-sensitive code (`fs`, `process`), gate assumptions and mention required module install.
- For platform-sensitive code (`fs`, `process`, `console`), gate assumptions and mention required module install.
- Prefer extension-method style (`items.filter { ... }`) and standard scope helpers (`let`/`also`/`apply`/`run`).

101
docs/lyng.io.console.md Normal file
View File

@ -0,0 +1,101 @@
### lyng.io.console
`lyng.io.console` provides optional rich console support for terminal applications.
> **Note:** this module is part of `lyngio`. It must be explicitly installed into the import manager by host code.
>
> **CLI note:** the `lyng` CLI now installs `lyng.io.console` in its base scope by default, so scripts can simply `import lyng.io.console`.
#### Install in host
```kotlin
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
suspend fun initScope() {
val scope = Script.newScope()
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
}
```
#### Use in Lyng script
```lyng
import lyng.io.console
println("supported = " + Console.isSupported())
println("tty = " + Console.isTty())
println("ansi = " + Console.ansiLevel())
println("geometry = " + Console.geometry())
Console.write("hello\n")
Console.home()
Console.clear()
Console.moveTo(1, 1)
Console.clearLine()
Console.enterAltScreen()
Console.leaveAltScreen()
Console.setCursorVisible(true)
Console.flush()
```
Interactive sample script in this repo:
```bash
lyng examples/tetris_console.lyng
```
#### API
- `Console.isSupported(): Bool` — whether console control is available on this platform/runtime.
- `Console.isTty(): Bool` — whether output is attached to a TTY.
- `Console.ansiLevel(): String``NONE`, `BASIC16`, `ANSI256`, `TRUECOLOR`.
- `Console.geometry(): ConsoleGeometry?``{columns, rows}` as typed object or `null`.
- `Console.details(): ConsoleDetails` — consolidated capability object.
- `Console.write(text: String)` — writes to console output.
- `Console.flush()` — flushes buffered output.
- `Console.home()` — moves cursor to top-left.
- `Console.clear()` — clears visible screen.
- `Console.moveTo(row: Int, column: Int)` — moves cursor to 1-based row/column.
- `Console.clearLine()` — clears current line.
- `Console.enterAltScreen()` — switch to alternate screen buffer.
- `Console.leaveAltScreen()` — return to normal screen buffer.
- `Console.setCursorVisible(visible: Bool)` — shows/hides cursor.
- `Console.events(): ConsoleEventStream` — endless iterable source of typed events: `ConsoleResizeEvent`, `ConsoleKeyEvent`.
- `Console.setRawMode(enabled: Bool): Bool` — requests raw input mode, returns `true` if changed.
#### Event Iteration
Use events from a loop, typically in a separate coroutine:
```lyng
launch {
for (ev in Console.events()) {
if (ev is ConsoleKeyEvent) {
// handle key
}
}
}
```
#### Event format
`Console.events()` emits `ConsoleEvent` with at least:
- `type: String``resize`, `keydown`, `keyup`
Additional fields:
- `ConsoleResizeEvent`: `columns`, `rows`
- `ConsoleKeyEvent`: `key`, `code`, `ctrl`, `alt`, `shift`, `meta`
#### Security policy
The module uses `ConsoleAccessPolicy` with operations:
- `WriteText(length)`
- `ReadEvents`
- `SetRawMode(enabled)`
For permissive mode, use `PermitAllConsoleAccessPolicy`.

View File

@ -6,12 +6,13 @@
1. **Security:** I/O and process execution are sensitive operations. By keeping them in a separate module, we ensure that the Lyng core remains 100% safe by default. You only enable what you explicitly need.
2. **Footprint:** Not every script needs filesystem or process access. Keeping these as a separate module helps minimize the dependency footprint for small embedded projects.
3. **Control:** `lyngio` provides fine-grained security policies (`FsAccessPolicy`, `ProcessAccessPolicy`) that allow you to control exactly what a script can do.
3. **Control:** `lyngio` provides fine-grained security policies (`FsAccessPolicy`, `ProcessAccessPolicy`, `ConsoleAccessPolicy`) that allow you to control exactly what a script can do.
#### Included Modules
- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing.
- **[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.
---
@ -39,8 +40,10 @@ To use `lyngio` modules in your scripts, you must install them into your Lyng sc
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.io.process.createProcessModule
import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
suspend fun runMyScript() {
val scope = Script.newScope()
@ -48,14 +51,17 @@ suspend fun runMyScript() {
// Install modules with policies
createFs(PermitAllAccessPolicy, scope)
createProcessModule(PermitAllProcessAccessPolicy, scope)
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
// Now scripts can import them
scope.eval("""
import lyng.io.fs
import lyng.io.process
import lyng.io.console
println("Working dir: " + Path(".").readUtf8())
println("OS: " + Platform.details().name)
println("TTY: " + Console.isTty())
""")
}
```
@ -68,20 +74,22 @@ suspend fun runMyScript() {
- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory).
- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely.
- **Console Security:** Implement `ConsoleAccessPolicy` to control output writes, event reads, and raw mode switching.
For more details, see the specific module documentation:
- [Filesystem Security Details](lyng.io.fs.md#access-policy-security)
- [Process Security Details](lyng.io.process.md#security-policy)
- [Console Module Details](lyng.io.console.md)
---
#### Platform Support Overview
| Platform | lyng.io.fs | lyng.io.process |
| :--- | :---: | :---: |
| **JVM** | ✅ | ✅ |
| **Native (Linux/macOS)** | ✅ | ✅ |
| **Native (Windows)** | ✅ | 🚧 (Planned) |
| **Android** | ✅ | ❌ |
| **NodeJS** | ✅ | ❌ |
| **Browser / Wasm** | ✅ (In-memory) | ❌ |
| Platform | lyng.io.fs | lyng.io.process | lyng.io.console |
| :--- | :---: | :---: | :---: |
| **JVM** | ✅ | ✅ | ✅ (baseline) |
| **Native (Linux/macOS)** | ✅ | ✅ | 🚧 |
| **Native (Windows)** | ✅ | 🚧 (Planned) | 🚧 |
| **Android** | ✅ | ❌ | ❌ |
| **NodeJS** | ✅ | ❌ | ❌ |
| **Browser / Wasm** | ✅ (In-memory) | ❌ | ❌ |

View File

@ -1555,18 +1555,21 @@ The type for the character objects is `Char`.
| \t | 0x07, tabulation |
| \\ | \ slash character |
| \" | " double quote |
| \uXXXX | unicode code point |
Unicode escape form is exactly 4 hex digits, e.g. `"\u263A"` -> `☺`.
Other `\c` combinations, where c is any char except mentioned above, are left intact, e.g.:
val s = "\a"
assert(s[0] == '\')
assert(s[0] == '\\')
assert(s[1] == 'a')
>>> void
same as:
val s = "\\a"
assert(s[0] == '\')
assert(s[0] == '\\')
assert(s[1] == 'a')
>>> void
@ -1581,6 +1584,9 @@ Are the same as in string literals with little difference:
| \t | 0x07, tabulation |
| \\ | \ slash character |
| \' | ' apostrophe |
| \uXXXX | unicode code point |
For char literals, use `'\\'` to represent a single backslash character; `'\'` is invalid.
### Char instance members

View File

@ -0,0 +1,664 @@
#!/usr/bin/env lyng
/*
* Lyng Console Tetris (interactive sample)
*
* Controls:
* - Left/Right arrows or A/D: move
* - Up arrow or W: rotate
* - Down arrow or S: soft drop
* - Space: hard drop
* - Q or Escape: quit
*/
import lyng.io.console
val MIN_COLS = 56
val MIN_ROWS = 24
val PANEL_WIDTH = 24
val BOARD_MARGIN_ROWS = 8
val BOARD_MIN_W = 10
val BOARD_MAX_W = 16
val BOARD_MIN_H = 16
val BOARD_MAX_H = 28
val LEVEL_LINES_STEP = 10
val DROP_FRAMES_BASE = 15
val DROP_FRAMES_MIN = 3
val FRAME_DELAY_MS = 35
val RESIZE_WAIT_MS = 250
val RNG_A = 1103515245
val RNG_C = 12345
val RNG_M = 2147483647
val ROTATION_KICKS = [0, -1, 1, -2, 2]
val ANSI_ESC = "\u001b["
val ANSI_RESET = ANSI_ESC + "0m"
val UNICODE_BLOCK = "██"
val UNICODE_TOP_LEFT = "┌"
val UNICODE_TOP_RIGHT = "┐"
val UNICODE_BOTTOM_LEFT = "└"
val UNICODE_BOTTOM_RIGHT = "┘"
val UNICODE_HORIZONTAL = "──"
val UNICODE_VERTICAL = "│"
val UNICODE_DOT = "· "
type Cell = List<Int>
type Rotation = List<Cell>
type Rotations = List<Rotation>
type Row = List<Int>
type Board = List<Row>
class Piece(val name: String, val rotations: Rotations) {}
class RotateResult(val ok: Bool, val rot: Int, val px: Int) {}
class GameState(
pieceId0: Int,
nextId0: Int,
next2Id0: Int,
px0: Int,
py0: Int,
) {
var pieceId = pieceId0
var nextId = nextId0
var next2Id = next2Id0
var rot = 0
var px = px0
var py = py0
var score = 0
var totalLines = 0
var level = 1
var running = true
var gameOver = false
}
class LoopFrame(val resized: Bool, val originRow: Int, val originCol: Int) {}
fun clearAndHome() {
Console.clear()
Console.home()
}
fun repeatText(s: String, n: Int): String {
var out: String = ""
var i = 0
while (i < n) {
out += s
i = i + 1
}
out
}
fun maxInt(a: Int, b: Int): Int {
if (a > b) a else b
}
fun minInt(a: Int, b: Int): Int {
if (a < b) a else b
}
fun clampInt(v: Int, lo: Int, hi: Int): Int {
minInt(hi, maxInt(lo, v))
}
fun emptyRow(width: Int): Row {
val r: Row = []
var x = 0
while (x < width) {
r.add(0)
x = x + 1
}
r
}
fun createBoard(width: Int, height: Int): Board {
val b: Board = []
var y = 0
while (y < height) {
b.add(emptyRow(width))
y = y + 1
}
b
}
fun colorize(text: String, sgr: String, useColor: Bool): String {
if (!useColor) return text
ANSI_ESC + sgr + "m" + text + ANSI_RESET
}
fun blockText(pieceId: Int, useColor: Bool): String {
if (pieceId <= 0) return " "
val sgr = when (pieceId) {
1 -> "36" // I
2 -> "33" // O
3 -> "35" // T
4 -> "32" // S
5 -> "31" // Z
6 -> "34" // J
7 -> "93" // L
else -> "37"
}
colorize(UNICODE_BLOCK, sgr, useColor)
}
fun emptyCellText(useColor: Bool): String {
colorize(UNICODE_DOT, "90", useColor)
}
fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool {
val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[rot]
for (cell in cells) {
val x = px + cell[0]
val y = py + cell[1]
if (x < 0 || x >= boardW) return false
if (y >= boardH) return false
if (y >= 0) {
val row = board[y]
if (row[x] != 0) return false
}
}
true
}
fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[rot]
for (cell in cells) {
val x = px + cell[0]
val y = py + cell[1]
if (y >= 0) {
val row = board[y]
row[x] = pieceId
}
}
}
fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int {
val b = board
var y = boardH - 1
var cleared = 0
while (y >= 0) {
val row = b[y]
var full = true
var x = 0
while (x < boardW) {
if (row[x] == 0) {
full = false
break
}
x = x + 1
}
if (full) {
b.removeAt(y)
b.insertAt(0, emptyRow(boardW))
cleared = cleared + 1
} else {
y = y - 1
}
}
cleared
}
fun activeCellId(pieceId: Int, rot: Int, px: Int, py: Int, x: Int, y: Int): Int {
val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[rot]
for (cell in cells) {
val ax = px + cell[0]
val ay = py + cell[1]
if (ax == x && ay == y) return pieceId
}
0
}
fun tryRotateCw(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): RotateResult {
val piece: Piece = PIECES[pieceId - 1]
val rotations = piece.rotations.size
val nr = (rot + 1) % rotations
for (kx in ROTATION_KICKS) {
val nx = px + kx
if (canPlace(board, boardW, boardH, pieceId, nr, nx, py)) {
return RotateResult(true, nr, nx)
}
}
RotateResult(false, rot, px)
}
fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> {
val out: List<String> = []
if (pieceId <= 0) {
out.add(" ")
out.add(" ")
out.add(" ")
out.add(" ")
return out
}
val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[0]
var y = 0
while (y < 4) {
var line = ""
var x = 0
while (x < 4) {
var filled = false
for (cell in cells) {
if (cell[0] == x && cell[1] == y) {
filled = true
break
}
}
line += if (filled) blockText(pieceId, useColor) else " "
x = x + 1
}
out.add(line)
y = y + 1
}
out
}
fun render(
state: GameState,
board: Board,
boardW: Int,
boardH: Int,
prevFrameLines: List<String>,
originRow: Int,
originCol: Int,
useColor: Bool,
): List<String> {
val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, boardW) + UNICODE_BOTTOM_RIGHT
val panel: List<String> = []
val nextPiece: Piece = PIECES[state.nextId - 1]
val next2Piece: Piece = PIECES[state.next2Id - 1]
val nextName = nextPiece.name
val next2Name = next2Piece.name
val preview = nextPreviewLines(state.nextId, useColor)
panel.add("Lyng Tetris")
panel.add("")
panel.add("Score: " + state.score)
panel.add("Lines: " + state.totalLines)
panel.add("Level: " + state.level)
panel.add("")
panel.add("Next: " + nextName)
panel.add("")
for (pl in preview) panel.add(pl)
panel.add("After: " + next2Name)
panel.add("")
panel.add("Keys:")
panel.add("A/D or arrows")
panel.add("W/Up: rotate")
panel.add("S/Down: drop")
panel.add("Space: hard drop")
panel.add("Q/Esc: quit")
val frameLines: List<String> = []
var y = 0
while (y < boardH) {
var line = UNICODE_VERTICAL
var x = 0
while (x < boardW) {
val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y)
val row = board[y]
val b = row[x]
val id = if (a > 0) a else b
line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor)
x = x + 1
}
line += UNICODE_VERTICAL
val p = y
if (p < panel.size) {
line += " " + panel[p]
}
frameLines.add(line)
y = y + 1
}
frameLines.add(bottomBorder)
val prev = prevFrameLines
var i = 0
while (i < frameLines.size) {
val line = frameLines[i]
val old = if (i < prev.size) prev[i] else null
if (old != line) {
Console.moveTo(originRow + i, originCol)
Console.clearLine()
Console.write(line)
}
i = i + 1
}
frameLines
}
fun waitForMinimumSize(minCols: Int, minRows: Int): Object {
while (true) {
val g = Console.geometry()
val cols = g?.columns ?: 0
val rows = g?.rows ?: 0
if (cols >= minCols && rows >= minRows) return g
clearAndHome()
println("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows))
println("Current: %sx%s"(cols, rows))
println("Resize the console window to continue...")
delay(RESIZE_WAIT_MS)
}
}
fun scoreForLines(cleared: Int, level: Int): Int {
when (cleared) {
1 -> 100 * level
2 -> 300 * level
3 -> 500 * level
4 -> 800 * level
else -> 0
}
}
// Classic 7 tetrominoes, minimal rotations per piece.
fun cell(x: Int, y: Int): Cell { [x, y] }
fun rot(a: Cell, b: Cell, c: Cell, d: Cell): Rotation {
val r: Rotation = []
r.add(a)
r.add(b)
r.add(c)
r.add(d)
r
}
val PIECES: List<Piece> = []
val iRots: Rotations = []
iRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(3,1)))
iRots.add(rot(cell(2,0), cell(2,1), cell(2,2), cell(2,3)))
PIECES.add(Piece("I", iRots))
val oRots: Rotations = []
oRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(2,1)))
PIECES.add(Piece("O", oRots))
val tRots: Rotations = []
tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(2,1)))
tRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(1,2)))
tRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(1,2)))
tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(1,2)))
PIECES.add(Piece("T", tRots))
val sRots: Rotations = []
sRots.add(rot(cell(1,0), cell(2,0), cell(0,1), cell(1,1)))
sRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(2,2)))
PIECES.add(Piece("S", sRots))
val zRots: Rotations = []
zRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(2,1)))
zRots.add(rot(cell(2,0), cell(1,1), cell(2,1), cell(1,2)))
PIECES.add(Piece("Z", zRots))
val jRots: Rotations = []
jRots.add(rot(cell(0,0), cell(0,1), cell(1,1), cell(2,1)))
jRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(1,2)))
jRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(2,2)))
jRots.add(rot(cell(1,0), cell(1,1), cell(0,2), cell(1,2)))
PIECES.add(Piece("J", jRots))
val lRots: Rotations = []
lRots.add(rot(cell(2,0), cell(0,1), cell(1,1), cell(2,1)))
lRots.add(rot(cell(1,0), cell(1,1), cell(1,2), cell(2,2)))
lRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(0,2)))
lRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(1,2)))
PIECES.add(Piece("L", lRots))
if (!Console.isSupported()) {
println("Console API is not supported in this runtime.")
void
} else if (!Console.isTty()) {
println("This sample needs an interactive terminal (TTY).")
void
} else {
waitForMinimumSize(MIN_COLS, MIN_ROWS)
val g0 = Console.geometry()
val cols = g0?.columns ?: MIN_COLS
val rows = g0?.rows ?: MIN_ROWS
val boardW = clampInt((cols - PANEL_WIDTH) / 2, BOARD_MIN_W, BOARD_MAX_W)
val boardH = clampInt(rows - BOARD_MARGIN_ROWS, BOARD_MIN_H, BOARD_MAX_H)
val board: Board = createBoard(boardW, boardH)
var rng = 1337
fun nextPieceId() {
rng = (rng * RNG_A + RNG_C) % RNG_M
(rng % PIECES.size) + 1
}
val state: GameState = GameState(
nextPieceId(),
nextPieceId(),
nextPieceId(),
(boardW / 2) - 2,
-1,
)
var prevFrameLines: List<String> = []
val gameMutex = Mutex()
var hasResizeEvent = false
val rawModeEnabled = Console.setRawMode(true)
if (!rawModeEnabled) {
println("Raw keyboard mode is not available in this terminal/runtime.")
println("Use jlyng in an interactive terminal with raw input support.")
void
} else {
val useColor = Console.ansiLevel() != "NONE"
Console.enterAltScreen()
Console.setCursorVisible(false)
clearAndHome()
fun applyKeyInput(s: GameState, key: String): Void {
if (key == "__CTRL_C__" || key == "q" || key == "Q" || key == "Escape") {
s.running = false
}
else if (key == "ArrowLeft" || key == "a" || key == "A") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px - 1, s.py)) s.px = s.px - 1
}
else if (key == "ArrowRight" || key == "d" || key == "D") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px + 1, s.py)) s.px = s.px + 1
}
else if (key == "ArrowUp" || key == "w" || key == "W") {
val rr: RotateResult = tryRotateCw(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)
if (rr.ok) {
s.rot = rr.rot
s.px = rr.px
}
}
else if (key == "ArrowDown" || key == "s" || key == "S") {
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
s.py = s.py + 1
s.score = s.score + 1
}
}
else if (key == " ") {
while (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
s.py = s.py + 1
s.score = s.score + 2
}
}
}
var inputRunning = true
launch {
while (inputRunning) {
try {
for (ev in Console.events()) {
if (!inputRunning) break
if (ev is ConsoleKeyEvent) {
val ke = ev as ConsoleKeyEvent
if (ke.type == "keydown") {
val key = ke.key
val ctrl = ke.ctrl
gameMutex.withLock {
val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key
applyKeyInput(state, mapped)
}
}
} else if (ev is ConsoleResizeEvent) {
gameMutex.withLock {
hasResizeEvent = true
prevFrameLines = []
}
}
}
} catch (err: Exception) {
// Keep game alive: transient console-event failures should not force quit.
if (!inputRunning) break
Console.setRawMode(true)
delay(50)
}
}
}
fun pollLoopFrame(): LoopFrame? {
val g = Console.geometry()
val c = g?.columns ?: 0
val r = g?.rows ?: 0
if (c < MIN_COLS || r < MIN_ROWS) {
waitForMinimumSize(MIN_COLS, MIN_ROWS)
clearAndHome()
prevFrameLines = []
return null
}
var resized = false
gameMutex.withLock {
resized = hasResizeEvent
hasResizeEvent = false
}
val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH
val contentRows = boardH + 1
val requiredCols = maxInt(MIN_COLS, contentCols)
val requiredRows = maxInt(MIN_ROWS, contentRows)
if (c < requiredCols || r < requiredRows) {
waitForMinimumSize(requiredCols, requiredRows)
clearAndHome()
prevFrameLines = []
return null
}
val originCol = maxInt(1, ((c - contentCols) / 2) + 1)
val originRow = maxInt(1, ((r - contentRows) / 2) + 1)
LoopFrame(resized, originRow, originCol)
}
fun advanceGravity(s: GameState, frame: Int): Int {
s.level = 1 + (s.totalLines / LEVEL_LINES_STEP)
val dropEvery = maxInt(DROP_FRAMES_MIN, DROP_FRAMES_BASE - s.level)
var nextFrame = frame + 1
if (nextFrame < dropEvery) return nextFrame
nextFrame = 0
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
s.py = s.py + 1
return nextFrame
}
lockPiece(board, s.pieceId, s.rot, s.px, s.py)
val cleared = clearCompletedLines(board, boardW, boardH)
if (cleared > 0) {
s.totalLines = s.totalLines + cleared
s.score = s.score + scoreForLines(cleared, s.level)
}
s.pieceId = s.nextId
s.nextId = s.next2Id
s.next2Id = nextPieceId()
s.rot = 0
s.px = (boardW / 2) - 2
s.py = -1
if (!canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)) {
s.gameOver = true
}
nextFrame
}
try {
if (!canPlace(board, boardW, boardH, state.pieceId, state.rot, state.px, state.py)) {
state.gameOver = true
}
var frame = 0
var shouldStop = false
var prevOriginRow = -1
var prevOriginCol = -1
while (!shouldStop) {
val frameData = pollLoopFrame()
if (frameData == null) {
frame = 0
continue
}
gameMutex.withLock {
if (!state.running || state.gameOver) {
shouldStop = true
} else {
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol
if (frameData.resized || movedOrigin) {
clearAndHome()
prevFrameLines = []
}
prevOriginRow = frameData.originRow
prevOriginCol = frameData.originCol
frame = advanceGravity(state, frame)
prevFrameLines = render(
state,
board,
boardW,
boardH,
prevFrameLines,
frameData.originRow,
frameData.originCol,
useColor
)
}
}
Console.flush()
delay(FRAME_DELAY_MS)
}
} finally {
inputRunning = false
Console.setRawMode(false)
Console.setCursorVisible(true)
Console.leaveAltScreen()
Console.flush()
}
if (state.gameOver) {
println("Game over.")
} else {
println("Bye.")
}
println("Score: %s"(state.score))
println("Lines: %s"(state.totalLines))
println("Level: %s"(state.level))
}
}

View File

@ -1,6 +1,7 @@
[versions]
agp = "8.5.2"
clikt = "5.0.3"
mordant = "3.0.2"
kotlin = "2.3.0"
android-minSdk = "24"
android-compileSdk = "34"
@ -14,6 +15,8 @@ compiler = "3.2.0-alpha11"
[libraries]
clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" }
clikt-markdown = { module = "com.github.ajalt.clikt:clikt-markdown", version.ref = "clikt" }
mordant-core = { module = "com.github.ajalt.mordant:mordant-core", version.ref = "mordant" }
mordant-jvm-jna = { module = "com.github.ajalt.mordant:mordant-jvm-jna", version.ref = "mordant" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
@ -28,4 +31,4 @@ compiler = { group = "androidx.databinding", name = "compiler", version.ref = "c
[plugins]
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.29.0" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.29.0" }

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -32,8 +32,10 @@ import net.sergeych.lyng.LyngVersion
import net.sergeych.lyng.Script
import net.sergeych.lyng.ScriptError
import net.sergeych.lyng.Source
import net.sergeych.lyng.io.console.createConsoleModule
import net.sergeych.lyng.io.fs.createFs
import net.sergeych.lyng.obj.*
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
import net.sergeych.mp_tools.globalDefer
import okio.FileSystem
@ -69,6 +71,9 @@ val baseScopeDefer = globalDefer {
// Install lyng.io.fs module with full access by default for the CLI tool's Scope.
// Scripts still need to `import lyng.io.fs` to use Path API.
createFs(PermitAllAccessPolicy, this)
// Install console access by default for interactive CLI scripts.
// Scripts still need to `import lyng.io.console` to use it.
createConsoleModule(PermitAllConsoleAccessPolicy, this)
}
}

View File

@ -78,6 +78,7 @@ kotlin {
api(project(":lynglib"))
api(libs.okio)
api(libs.kotlinx.coroutines.core)
api(libs.mordant.core)
}
}
val commonTest by getting {
@ -94,6 +95,13 @@ kotlin {
implementation("com.squareup.okio:okio-nodefilesystem:${libs.versions.okioVersion.get()}")
}
}
val jvmMain by getting {
dependencies {
implementation(libs.mordant.jvm.jna)
implementation("org.jline:jline-reader:3.29.0")
implementation("org.jline:jline-terminal:3.29.0")
}
}
// // For Wasm we use in-memory VFS for now
// val wasmJsMain by getting {
// dependencies {

View File

@ -0,0 +1,22 @@
/*
* 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.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on Android
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
actual fun getSystemConsole(): LyngConsole = MordantLyngConsole

View File

@ -0,0 +1,600 @@
/*
* 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.console
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjIterationFinishedException
import net.sergeych.lyng.obj.ObjIterator
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.console.ConsoleEvent
import net.sergeych.lyngio.console.ConsoleEventSource
import net.sergeych.lyngio.console.LyngConsole
import net.sergeych.lyngio.console.getSystemConsole
import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.LyngConsoleSecured
/**
* Install Lyng module `lyng.io.console` into the given scope's ImportManager.
*/
fun createConsoleModule(policy: ConsoleAccessPolicy, scope: Scope): Boolean =
createConsoleModule(policy, scope.importManager)
fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createConsoleModule(policy, scope)
/** Same as [createConsoleModule] but with explicit [ImportManager]. */
fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean {
val name = "lyng.io.console"
if (manager.packageNames.contains(name)) return false
manager.addPackage(name) { module ->
buildConsoleModule(module, policy)
}
return true
}
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy)
val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {}
consoleType.apply {
addClassFnDoc(
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
ObjBool(console.isSupported)
}
addClassFnDoc(
name = "isTty",
doc = "Whether current stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard {
ObjBool(console.isTty())
}
}
addClassFnDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
moduleName = module.packageName
) {
consoleGuard {
ObjString(console.ansiLevel().name)
}
}
addClassFnDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
moduleName = module.packageName
) {
consoleGuard {
console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull
}
}
addClassFnDoc(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
moduleName = module.packageName
) {
consoleGuard {
val tty = console.isTty()
val ansi = console.ansiLevel()
val geometry = console.geometry()
ObjConsoleDetails(
supported = console.isSupported,
isTty = tty,
ansiLevel = ansi.name,
geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) },
)
}
}
addClassFnDoc(
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
moduleName = module.packageName
) {
consoleGuard {
val text = requiredArg<ObjString>(0).value
console.write(text)
ObjVoid
}
}
addClassFnDoc(
name = "flush",
doc = "Flush console output buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.flush()
ObjVoid
}
}
addClassFnDoc(
name = "home",
doc = "Move cursor to home position (1,1).",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[H")
ObjVoid
}
}
addClassFnDoc(
name = "clear",
doc = "Clear the visible screen buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[2J")
ObjVoid
}
}
addClassFnDoc(
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
moduleName = module.packageName
) {
consoleGuard {
val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value
val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value
console.write("\u001B[${row};${col}H")
ObjVoid
}
}
addClassFnDoc(
name = "clearLine",
doc = "Clear the current line.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[2K")
ObjVoid
}
}
addClassFnDoc(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[?1049h")
ObjVoid
}
}
addClassFnDoc(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[?1049l")
ObjVoid
}
}
addClassFnDoc(
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
moduleName = module.packageName
) {
consoleGuard {
val visible = requiredArg<ObjBool>(0).value
console.write(if (visible) "\u001B[?25h" else "\u001B[?25l")
ObjVoid
}
}
addClassFnDoc(
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
moduleName = module.packageName
) {
consoleGuard {
console.events().toConsoleEventStream()
}
}
addClassFnDoc(
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard {
val enabled = requiredArg<ObjBool>(0).value
ObjBool(console.setRawMode(enabled))
}
}
}
module.addConstDoc(
name = "Console",
value = consoleType,
doc = "Console runtime API.",
type = type("Console"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleGeometry",
value = ObjConsoleGeometry.type,
doc = "Terminal geometry.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleDetails",
value = ObjConsoleDetails.type,
doc = "Consolidated console capability details.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEvent",
value = ObjConsoleEvent.type,
doc = "Base class for console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleResizeEvent",
value = ObjConsoleResizeEvent.type,
doc = "Terminal resize event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleKeyEvent",
value = ObjConsoleKeyEvent.typeObj,
doc = "Keyboard event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEventStream",
value = ObjConsoleEventStream.type,
doc = "Endless iterable stream of console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
}
private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: ConsoleAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "console access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "console error")
}
}
private fun ConsoleEventSource.toConsoleEventStream(): ObjConsoleEventStream {
return ObjConsoleEventStream(this)
}
private class ObjConsoleEventStream(
private val source: ConsoleEventSource,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply {
addFnDoc(
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator"),
moduleName = "lyng.io.console",
) {
val stream = thisAs<ObjConsoleEventStream>()
ObjConsoleEventIterator(stream.source)
}
}
}
}
private class ObjConsoleEventIterator(
private val source: ConsoleEventSource,
) : Obj() {
private var cached: Obj? = null
private var closed = false
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
private suspend fun ensureCached(): Boolean {
if (closed) return false
if (cached != null) return true
val event = source.nextEvent()
if (event == null) {
closeSource()
return false
}
cached = event.toObjEvent()
return true
}
private suspend fun closeSource() {
if (closed) return
closed = true
source.close()
}
suspend fun hasNext(): Boolean = ensureCached()
suspend fun next(scope: Scope): Obj {
if (!ensureCached()) {
scope.raiseError(ObjIterationFinishedException(scope))
}
val out = cached ?: scope.raiseError("console iterator internal error: missing cached event")
cached = null
return out
}
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply {
addFnDoc(
name = "hasNext",
doc = "Whether another console event is available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().hasNext().toObj()
}
addFnDoc(
name = "next",
doc = "Return the next console event.",
returns = type("ConsoleEvent"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().next(requireScope())
}
addFnDoc(
name = "cancelIteration",
doc = "Stop reading console events and release resources.",
returns = type("lyng.Void"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().closeSource()
ObjVoid
}
}
}
}
private fun ConsoleEvent.toObjEvent(): Obj = when (this) {
is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows)
is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = "keydown", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = "keyup", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
}
private abstract class ObjConsoleEventBase(
private val eventType: String,
final override val objClass: net.sergeych.lyng.obj.ObjClass,
) : Obj() {
fun eventTypeName(): String = eventType
}
private class ObjConsoleEvent : ObjConsoleEventBase("event", type) {
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply {
addPropertyDoc(
name = "type",
doc = "Event type string: resize, keydown, keyup.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleEventBase).eventTypeName()) }
)
}
}
}
private class ObjConsoleResizeEvent(
val columns: Int,
val rows: Int,
) : ObjConsoleEventBase("resize", type) {
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply {
addPropertyDoc(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() }
)
}
}
}
private class ObjConsoleKeyEvent(
type: String,
val key: String,
val code: String?,
val ctrl: Boolean,
val alt: Boolean,
val shift: Boolean,
val meta: Boolean,
) : ObjConsoleEventBase(type, typeObj) {
companion object {
val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply {
addPropertyDoc(
name = "key",
doc = "Logical key name (e.g. ArrowLeft, a, Escape).",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) }
)
addPropertyDoc(
name = "code",
doc = "Optional hardware/code identifier.",
type = type("lyng.String", nullable = true),
moduleName = "lyng.io.console",
getter = {
val code = (this.thisObj as ObjConsoleKeyEvent).code
code?.let(::ObjString) ?: ObjNull
}
)
addPropertyDoc(
name = "ctrl",
doc = "Whether Ctrl modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() }
)
addPropertyDoc(
name = "alt",
doc = "Whether Alt modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() }
)
addPropertyDoc(
name = "shift",
doc = "Whether Shift modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() }
)
addPropertyDoc(
name = "meta",
doc = "Whether Meta/Super modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() }
)
}
}
}
private class ObjConsoleGeometry(
val columns: Int,
val rows: Int,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply {
addPropertyDoc(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() }
)
}
}
}
private class ObjConsoleDetails(
val supported: Boolean,
val isTty: Boolean,
val ansiLevel: String,
val geometry: ObjConsoleGeometry?,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply {
addPropertyDoc(
name = "supported",
doc = "Whether console API is supported.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() }
)
addPropertyDoc(
name = "isTty",
doc = "Whether output is connected to a TTY.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() }
)
addPropertyDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability level.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleDetails).ansiLevel) }
)
addPropertyDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
type = type("ConsoleGeometry", nullable = true),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull }
)
}
}
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
internal expect fun consoleFlowDebug(message: String, error: Throwable? = null)

View File

@ -0,0 +1,132 @@
/*
* 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.lyngio.console
/**
* ANSI color support level detected for the active console.
*/
enum class ConsoleAnsiLevel {
NONE,
BASIC16,
ANSI256,
TRUECOLOR,
}
/**
* Console geometry in character cells.
*/
data class ConsoleGeometry(
val columns: Int,
val rows: Int,
)
/**
* Input/terminal events emitted by the console runtime.
*/
sealed interface ConsoleEvent {
data class Resize(
val columns: Int,
val rows: Int,
) : ConsoleEvent
data class KeyDown(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
data class KeyUp(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
}
/**
* Pull-based console event source.
*
* `nextEvent(timeoutMs)` returns:
* - next event when available,
* - `null` on timeout,
* - `null` after close.
*/
interface ConsoleEventSource {
suspend fun nextEvent(timeoutMs: Long = 0L): ConsoleEvent?
suspend fun close()
}
private object EmptyConsoleEventSource : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {
// no-op
}
}
/**
* Platform-independent console runtime surface.
*/
interface LyngConsole {
val isSupported: Boolean
suspend fun isTty(): Boolean
suspend fun geometry(): ConsoleGeometry?
suspend fun ansiLevel(): ConsoleAnsiLevel
suspend fun write(text: String)
suspend fun flush()
fun events(): ConsoleEventSource
/**
* Set terminal raw input mode. Returns true when mode was changed.
*/
suspend fun setRawMode(enabled: Boolean): Boolean
}
object UnsupportedLyngConsole : LyngConsole {
override val isSupported: Boolean = false
override suspend fun isTty(): Boolean = false
override suspend fun geometry(): ConsoleGeometry? = null
override suspend fun ansiLevel(): ConsoleAnsiLevel = ConsoleAnsiLevel.NONE
override suspend fun write(text: String) {
// no-op
}
override suspend fun flush() {
// no-op
}
override fun events(): ConsoleEventSource = EmptyConsoleEventSource
override suspend fun setRawMode(enabled: Boolean): Boolean = false
}

View File

@ -0,0 +1,286 @@
/*
* 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.lyngio.console
import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.input.RawModeScope
import com.github.ajalt.mordant.input.enterRawModeOrNull
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.terminal.Terminal
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
import net.sergeych.mp_tools.globalLaunch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* Mordant-backed console runtime implementation.
*/
object MordantLyngConsole : LyngConsole {
private val terminal: Terminal? by lazy {
runCatching { Terminal() }.getOrNull()
}
private val stateMutex = Mutex()
private var rawModeRequested: Boolean = false
private var rawModeScope: RawModeScope? = null
private suspend fun forceRawModeReset(t: Terminal): Boolean = stateMutex.withLock {
if (!rawModeRequested) return@withLock false
runCatching { rawModeScope?.close() }
.onFailure { consoleFlowDebug("forceRawModeReset: close failed", it) }
rawModeScope = null
rawModeRequested = false
if (!t.terminalInfo.inputInteractive) return@withLock false
val reopened = t.enterRawModeOrNull()
rawModeScope = reopened
rawModeRequested = reopened != null
reopened != null
}
override val isSupported: Boolean
get() = terminal != null
override suspend fun isTty(): Boolean {
val t = terminal ?: return false
return t.terminalInfo.outputInteractive
}
override suspend fun geometry(): ConsoleGeometry? {
val t = terminal ?: return null
val size = t.updateSize()
return ConsoleGeometry(size.width, size.height)
}
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val t = terminal ?: return ConsoleAnsiLevel.NONE
return when (t.terminalInfo.ansiLevel) {
AnsiLevel.NONE -> ConsoleAnsiLevel.NONE
AnsiLevel.ANSI16 -> ConsoleAnsiLevel.BASIC16
AnsiLevel.ANSI256 -> ConsoleAnsiLevel.ANSI256
AnsiLevel.TRUECOLOR -> ConsoleAnsiLevel.TRUECOLOR
}
}
override suspend fun write(text: String) {
terminal?.rawPrint(text)
}
override suspend fun flush() {
// Mordant prints via platform streams immediately.
}
override fun events(): ConsoleEventSource {
val t = terminal ?: return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
val sourceState = Mutex()
var running = true
globalLaunch {
var lastWidth = t.updateSize().width
var lastHeight = t.updateSize().height
val startMark = TimeSource.Monotonic.markNow()
var lastHeartbeatMark = startMark
var loops = 0L
var readAttempts = 0L
var readFailures = 0L
var keyEvents = 0L
var resizeEvents = 0L
var rawNullLoops = 0L
var lastKeyMark = startMark
var lastRawRecoveryMark = startMark
consoleFlowDebug("events: collector started")
try {
while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
loops += 1
val currentSize = runCatching { t.updateSize() }.getOrNull()
if (currentSize == null) {
delay(150)
continue
}
if (currentSize.width != lastWidth || currentSize.height != lastHeight) {
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
lastWidth = currentSize.width
lastHeight = currentSize.height
resizeEvents += 1
}
val raw = stateMutex.withLock {
if (!rawModeRequested) {
null
} else {
// Recover raw scope lazily if it was dropped due to an earlier read failure.
if (rawModeScope == null) {
rawModeScope = t.enterRawModeOrNull()
if (rawModeScope == null) {
consoleFlowDebug("events: failed to reopen raw mode scope")
} else {
consoleFlowDebug("events: raw mode scope reopened")
}
}
rawModeScope
}
}
if (raw == null || !t.terminalInfo.inputInteractive) {
rawNullLoops += 1
delay(150)
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
continue
}
readAttempts += 1
val readResult = runCatching { raw.readEventOrNull(150.milliseconds) }
if (readResult.isFailure) {
readFailures += 1
consoleFlowDebug("events: readEventOrNull failed; resetting raw scope", readResult.exceptionOrNull())
// Raw scope became invalid; close and force reopen on next iteration.
stateMutex.withLock {
runCatching { rawModeScope?.close() }
rawModeScope = null
}
delay(50)
continue
}
val ev = readResult.getOrNull()
val resized = runCatching { t.updateSize() }.getOrNull()
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) {
out.trySend(ConsoleEvent.Resize(resized.width, resized.height))
lastWidth = resized.width
lastHeight = resized.height
}
when (ev) {
is KeyboardEvent -> {
keyEvents += 1
lastKeyMark = TimeSource.Monotonic.markNow()
out.trySend(
ConsoleEvent.KeyDown(
key = ev.key,
code = null,
ctrl = ev.ctrl,
alt = ev.alt,
shift = ev.shift,
meta = false,
)
)
}
else -> {
// Mouse/other events are ignored in Lyng console v1.
}
}
// Some terminals silently stop delivering keyboard events while raw reads keep succeeding.
// If we had keys before and then prolonged key inactivity, proactively recycle raw scope.
if (keyEvents > 0L &&
lastKeyMark.elapsedNow() >= 4.seconds &&
lastRawRecoveryMark.elapsedNow() >= 4.seconds
) {
if (rawModeRequested) {
consoleFlowDebug("events: key inactivity detected; forcing raw reset")
val resetOk = forceRawModeReset(t)
if (resetOk) {
consoleFlowDebug("events: raw reset succeeded during inactivity recovery")
lastKeyMark = TimeSource.Monotonic.markNow()
} else {
consoleFlowDebug("events: raw reset failed during inactivity recovery")
}
lastRawRecoveryMark = TimeSource.Monotonic.markNow()
}
}
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
}
} catch (e: CancellationException) {
consoleFlowDebug("events: collector cancelled (normal)")
// normal
} catch (e: ScriptFlowIsNoMoreCollected) {
consoleFlowDebug("events: collector stopped by flow consumer (normal)")
// normal
} catch (e: Exception) {
consoleFlowDebug("events: collector loop failed", e)
// terminate event source loop
} finally {
consoleFlowDebug(
"events: collector ended uptime=${startMark.elapsedNow().inWholeMilliseconds}ms loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested"
)
out.close()
}
}
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (timeoutMs <= 0L) {
return out.receiveCatching().getOrNull()
}
return withTimeoutOrNull(timeoutMs.milliseconds) {
out.receiveCatching().getOrNull()
}
}
override suspend fun close() {
sourceState.withLock { running = false }
out.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
val t = terminal ?: return false
return stateMutex.withLock {
if (enabled) {
if (!t.terminalInfo.inputInteractive) return@withLock false
if (rawModeRequested) return@withLock false
val scope = t.enterRawModeOrNull() ?: return@withLock false
rawModeScope = scope
rawModeRequested = true
consoleFlowDebug("setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested || rawModeScope != null
rawModeRequested = false
val scope = rawModeScope
rawModeScope = null
runCatching { scope?.close() }
.onFailure { consoleFlowDebug("setRawMode(false): close failed", it) }
consoleFlowDebug("setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
}
}

View File

@ -0,0 +1,23 @@
/*
* 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.lyngio.console
/**
* Get the system default console implementation.
*/
expect fun getSystemConsole(): LyngConsole

View File

@ -0,0 +1,55 @@
/*
* 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.lyngio.console.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
/**
* Primitive console operations for access control decisions.
*/
sealed interface ConsoleAccessOp {
data class WriteText(val length: Int) : ConsoleAccessOp
data object ReadEvents : ConsoleAccessOp
data class SetRawMode(val enabled: Boolean) : ConsoleAccessOp
}
class ConsoleAccessDeniedException(
val op: ConsoleAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("Console access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
/**
* Policy interface that decides whether a specific console operation is allowed.
*/
interface ConsoleAccessPolicy {
suspend fun check(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw ConsoleAccessDeniedException(op, res.reason)
}
}
object PermitAllConsoleAccessPolicy : ConsoleAccessPolicy {
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}

View File

@ -0,0 +1,68 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console.security
import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.fs.security.AccessContext
/**
* Decorator that applies a [ConsoleAccessPolicy] to a delegate [LyngConsole].
*/
class LyngConsoleSecured(
private val delegate: LyngConsole,
private val policy: ConsoleAccessPolicy,
private val ctx: AccessContext = AccessContext(),
) : LyngConsole {
override val isSupported: Boolean
get() = delegate.isSupported
override suspend fun isTty(): Boolean = delegate.isTty()
override suspend fun geometry(): ConsoleGeometry? = delegate.geometry()
override suspend fun ansiLevel(): ConsoleAnsiLevel = delegate.ansiLevel()
override suspend fun write(text: String) {
policy.require(ConsoleAccessOp.WriteText(text.length), ctx)
delegate.write(text)
}
override suspend fun flush() {
delegate.flush()
}
override fun events(): ConsoleEventSource {
val source = delegate.events()
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
policy.require(ConsoleAccessOp.ReadEvents, ctx)
return source.nextEvent(timeoutMs)
}
override suspend fun close() {
source.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
policy.require(ConsoleAccessOp.SetRawMode(enabled), ctx)
return delegate.setRawMode(enabled)
}
}

View File

@ -0,0 +1,264 @@
/*
* 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.lyngio.docs
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.type
object ConsoleBuiltinDocs {
private var registered = false
fun ensure() {
if (registered) return
BuiltinDocRegistry.module("lyng.io.console") {
classDoc(
name = "Console",
doc = "Console runtime API."
) {
method(
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "isTty",
doc = "Whether stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
isStatic = true
)
method(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
isStatic = true
)
method(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
isStatic = true
)
method(
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
isStatic = true
)
method(
name = "flush",
doc = "Flush console output buffer.",
isStatic = true
)
method(
name = "home",
doc = "Move cursor to home position (1,1).",
isStatic = true
)
method(
name = "clear",
doc = "Clear the visible screen buffer.",
isStatic = true
)
method(
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
isStatic = true
)
method(
name = "clearLine",
doc = "Clear the current line.",
isStatic = true
)
method(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
isStatic = true
)
method(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
isStatic = true
)
method(
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
isStatic = true
)
method(
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
isStatic = true
)
method(
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
isStatic = true
)
}
classDoc(
name = "ConsoleEventStream",
doc = "Endless iterable stream of console events."
) {
method(
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator")
)
}
classDoc(
name = "ConsoleGeometry",
doc = "Terminal geometry."
) {
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleDetails",
doc = "Consolidated console capability details."
) {
field(
name = "supported",
doc = "Whether console API is supported.",
type = type("lyng.Bool")
)
field(
name = "isTty",
doc = "Whether output is attached to a TTY.",
type = type("lyng.Bool")
)
field(
name = "ansiLevel",
doc = "Detected ANSI color capability.",
type = type("lyng.String")
)
field(
name = "geometry",
doc = "Current geometry or null.",
type = type("ConsoleGeometry", nullable = true)
)
}
classDoc(
name = "ConsoleEvent",
doc = "Base class for console events."
) {
field(
name = "type",
doc = "Event kind string.",
type = type("lyng.String")
)
}
classDoc(
name = "ConsoleResizeEvent",
doc = "Resize event."
) {
field(
name = "type",
doc = "Event kind string: resize.",
type = type("lyng.String")
)
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleKeyEvent",
doc = "Keyboard event."
) {
field(
name = "type",
doc = "Event kind string: keydown or keyup.",
type = type("lyng.String")
)
field(
name = "key",
doc = "Logical key name.",
type = type("lyng.String")
)
field(
name = "code",
doc = "Optional hardware code.",
type = type("lyng.String", nullable = true)
)
field(
name = "ctrl",
doc = "Ctrl modifier state.",
type = type("lyng.Bool")
)
field(
name = "alt",
doc = "Alt modifier state.",
type = type("lyng.Bool")
)
field(
name = "shift",
doc = "Shift modifier state.",
type = type("lyng.Bool")
)
field(
name = "meta",
doc = "Meta modifier state.",
type = type("lyng.Bool")
)
}
valDoc(
name = "Console",
doc = "Console runtime API.",
type = type("Console")
)
valDoc(name = "ConsoleGeometry", doc = "Terminal geometry class.", type = type("lyng.Class"))
valDoc(name = "ConsoleDetails", doc = "Console details class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEvent", doc = "Base console event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleResizeEvent", doc = "Resize event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleKeyEvent", doc = "Keyboard event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEventStream", doc = "Iterable console event stream class.", type = type("lyng.Class"))
}
registered = true
}
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole

View File

@ -0,0 +1,22 @@
/*
* 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.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on JS
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
actual fun getSystemConsole(): LyngConsole = MordantLyngConsole

View File

@ -0,0 +1,46 @@
/*
* 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.lyngio.console
import java.io.File
import java.time.Instant
private val flowDebugLogFilePath: String =
System.getenv("LYNG_CONSOLE_DEBUG_LOG")
?.takeIf { it.isNotBlank() }
?: "/tmp/lyng_console_flow_debug.log"
private val flowDebugLogLock = Any()
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
runCatching {
val line = buildString {
append(Instant.now().toString())
append(" [console-flow] ")
append(message)
append('\n')
if (error != null) {
append(error.stackTraceToString())
append('\n')
}
}
synchronized(flowDebugLogLock) {
File(flowDebugLogFilePath).appendText(line)
}
}
}

View File

@ -0,0 +1,496 @@
/*
* 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.lyngio.console
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.jline.terminal.Attributes
import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder
import org.jline.utils.NonBlockingReader
import java.io.EOFException
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* JVM console implementation:
* - output/capabilities/input use a single JLine terminal instance
* to avoid dual-terminal contention.
*/
object JvmLyngConsole : LyngConsole {
private const val DEBUG_REVISION = "jline-r26-force-rebuild-on-noop-recovery-2026-03-19"
private val codeSourceLocation: String by lazy {
runCatching {
JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString()
}.getOrNull() ?: "<unknown>"
}
private val terminalRef = AtomicReference<Terminal?>(null)
private val terminalInitLock = Any()
private fun currentTerminal(): Terminal? {
val existing = terminalRef.get()
if (existing != null) return existing
synchronized(terminalInitLock) {
val already = terminalRef.get()
if (already != null) return already
val created = buildTerminal()
if (created != null) terminalRef.set(created)
return created
}
}
private fun buildTerminal(): Terminal? {
System.setProperty(TerminalBuilder.PROP_DISABLE_DEPRECATED_PROVIDER_WARNING, "true")
val providerOrders = listOf(
"exec",
"exec,ffm",
null,
)
for (providers in providerOrders) {
val terminal = runCatching {
val builder = TerminalBuilder.builder().system(true)
if (providers != null) builder.providers(providers)
builder.build()
}.onFailure {
if (providers != null) {
consoleFlowDebug("jline-events: terminal build failed providers=$providers", it)
} else {
consoleFlowDebug("jline-events: terminal build failed default providers", it)
}
}.getOrNull()
if (terminal != null) {
val termType = terminal.type.lowercase(Locale.getDefault())
if (termType.contains("dumb")) {
consoleFlowDebug("jline-events: terminal rejected providers=${providers ?: "<default>"} type=${terminal.type}")
runCatching { terminal.close() }
continue
}
consoleFlowDebug("jline-events: terminal built providers=${providers ?: "<default>"} type=${terminal.type}")
consoleFlowDebug("jline-events: runtime-marker rev=$DEBUG_REVISION codeSource=$codeSourceLocation")
return terminal
}
}
return null
}
private val stateMutex = Mutex()
private var rawModeRequested: Boolean = false
private var rawSavedAttributes: Attributes? = null
private fun enforceRawReadAttrs(term: Terminal) {
runCatching {
val attrs = term.attributes
attrs.setLocalFlag(Attributes.LocalFlag.ICANON, false)
attrs.setLocalFlag(Attributes.LocalFlag.ECHO, false)
attrs.setControlChar(Attributes.ControlChar.VMIN, 0)
attrs.setControlChar(Attributes.ControlChar.VTIME, 1)
term.setAttributes(attrs)
}.onFailure {
consoleFlowDebug("jline-events: enforceRawReadAttrs failed", it)
}
}
override val isSupported: Boolean
get() = currentTerminal() != null
override suspend fun isTty(): Boolean {
val term = currentTerminal() ?: return false
return !term.type.lowercase(Locale.getDefault()).contains("dumb")
}
override suspend fun geometry(): ConsoleGeometry? {
val term = currentTerminal() ?: return null
val size = runCatching { term.size }.getOrNull() ?: return null
if (size.columns <= 0 || size.rows <= 0) return null
return ConsoleGeometry(size.columns, size.rows)
}
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val colorTerm = (System.getenv("COLORTERM") ?: "").lowercase(Locale.getDefault())
val term = (System.getenv("TERM") ?: "").lowercase(Locale.getDefault())
return when {
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
else -> ConsoleAnsiLevel.NONE
}
}
override suspend fun write(text: String) {
val term = currentTerminal() ?: return
term.writer().print(text)
}
override suspend fun flush() {
val term = currentTerminal() ?: return
term.writer().flush()
}
override fun events(): ConsoleEventSource {
var activeTerm = currentTerminal() ?: return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
val keyEvents = AtomicLong(0L)
val keyCodesRead = AtomicLong(0L)
val keySendFailures = AtomicLong(0L)
val readFailures = AtomicLong(0L)
val readerRecoveries = AtomicLong(0L)
var lastHeartbeat = TimeSource.Monotonic.markNow()
val keyLoopRunning = AtomicBoolean(true)
val keyLoopCount = AtomicLong(0L)
val keyReadStartNs = AtomicLong(0L)
val keyReadEndNs = AtomicLong(0L)
val lastKeyReadNs = AtomicLong(System.nanoTime())
val lastRecoveryNs = AtomicLong(0L)
val recoveryRequested = AtomicBoolean(false)
val running = AtomicBoolean(true)
var winchHandler: Terminal.SignalHandler? = null
var reader = activeTerm.reader()
var keyThread: Thread? = null
var heartbeatThread: Thread? = null
fun emitResize() {
val size = runCatching { activeTerm.size }.getOrNull() ?: return
out.trySend(ConsoleEvent.Resize(size.columns, size.rows))
}
fun cleanup() {
running.set(false)
keyLoopRunning.set(false)
runCatching { reader.shutdown() }
runCatching {
if (winchHandler != null) {
activeTerm.handle(Terminal.Signal.WINCH, winchHandler)
}
}.onFailure {
consoleFlowDebug("jline-events: WINCH handler restore failed", it)
}
runCatching { keyThread?.interrupt() }
runCatching { heartbeatThread?.interrupt() }
out.close()
}
fun installWinchHandler() {
winchHandler = runCatching {
activeTerm.handle(Terminal.Signal.WINCH) {
emitResize()
}
}.onFailure {
consoleFlowDebug("jline-events: WINCH handler install failed", it)
}.getOrNull()
}
fun tryRebuildTerminal(): Boolean {
val oldTerm = activeTerm
val rebuilt = runCatching {
synchronized(terminalInitLock) {
if (terminalRef.get() === oldTerm) {
terminalRef.set(null)
}
}
runCatching { oldTerm.close() }
.onFailure { consoleFlowDebug("jline-events: old terminal close failed during rebuild", it) }
currentTerminal()
}.onFailure {
consoleFlowDebug("jline-events: terminal rebuild failed", it)
}.getOrNull() ?: return false
if (rebuilt === oldTerm) {
consoleFlowDebug("jline-events: terminal rebuild returned same terminal instance")
return false
}
activeTerm = rebuilt
reader = activeTerm.reader()
val rawRequestedNow = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
if (rawRequestedNow) {
val saved = runCatching { activeTerm.enterRawMode() }.getOrNull()
if (saved != null) {
enforceRawReadAttrs(activeTerm)
if (runCatching { stateMutex.tryLock() }.getOrNull() == true) {
try {
rawSavedAttributes = saved
} finally {
stateMutex.unlock()
}
}
} else {
consoleFlowDebug("jline-events: terminal rebuild succeeded but enterRawMode failed")
}
}
installWinchHandler()
emitResize()
consoleFlowDebug("jline-events: terminal rebuilt and rebound")
return true
}
consoleFlowDebug("jline-events: collector started rev=$DEBUG_REVISION")
emitResize()
installWinchHandler()
keyThread = thread(start = true, isDaemon = true, name = "lyng-jline-key-reader") {
consoleFlowDebug("jline-events: key-reader thread started")
consoleFlowDebug("jline-events: using NonBlockingReader key path")
while (running.get() && keyLoopRunning.get()) {
keyLoopCount.incrementAndGet()
try {
if (recoveryRequested.compareAndSet(true, false)) {
val prevReader = reader
runCatching { prevReader.shutdown() }
.onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) }
reader = activeTerm.reader()
if (reader.hashCode() == prevReader.hashCode()) {
consoleFlowDebug("jline-events: reader recovery no-op oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()} -> forcing terminal rebuild")
if (!tryRebuildTerminal()) {
consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader")
}
} else {
consoleFlowDebug("jline-events: reader recovered oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()}")
}
readerRecoveries.incrementAndGet()
lastRecoveryNs.set(System.nanoTime())
}
val isRaw = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
if (!isRaw) {
Thread.sleep(20)
continue
}
keyReadStartNs.set(System.nanoTime())
val event = readKeyEvent(reader)
keyReadEndNs.set(System.nanoTime())
if (event == null) {
continue
}
keyCodesRead.incrementAndGet()
lastKeyReadNs.set(System.nanoTime())
if (out.trySend(event).isSuccess) {
keyEvents.incrementAndGet()
} else {
keySendFailures.incrementAndGet()
}
} catch (_: InterruptedException) {
break
} catch (e: Throwable) {
readFailures.incrementAndGet()
consoleFlowDebug("jline-events: blocking read failed", e)
try {
Thread.sleep(50)
} catch (_: InterruptedException) {
break
}
}
}
}
heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") {
while (running.get()) {
if (lastHeartbeat.elapsedNow() >= 2.seconds) {
val requested = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
val readStartNs = keyReadStartNs.get()
val readEndNs = keyReadEndNs.get()
val lastKeyNs = lastKeyReadNs.get()
val idleMs = if (lastKeyNs > 0L) (System.nanoTime() - lastKeyNs) / 1_000_000L else 0L
val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) {
(System.nanoTime() - readStartNs) / 1_000_000L
} else 0L
if (requested && keyCodesRead.get() > 0L && idleMs >= 1400L) {
val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L
if (sinceRecoveryMs >= 1200L) {
recoveryRequested.set(true)
consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery")
}
}
consoleFlowDebug(
"jline-events: heartbeat keyCodes=${keyCodesRead.get()} keysSent=${keyEvents.get()} sendFailures=${keySendFailures.get()} readFailures=${readFailures.get()} recoveries=${readerRecoveries.get()} rawRequested=$requested keyLoop=${keyLoopCount.get()} readBlockedMs=$readBlockedMs keyIdleMs=$idleMs keyPath=reader"
)
lastHeartbeat = TimeSource.Monotonic.markNow()
}
try {
Thread.sleep(200)
} catch (_: InterruptedException) {
break
}
}
}
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (!running.get()) return null
if (timeoutMs <= 0L) {
return out.receiveCatching().getOrNull()
}
return withTimeoutOrNull(timeoutMs.milliseconds) {
out.receiveCatching().getOrNull()
}
}
override suspend fun close() {
cleanup()
consoleFlowDebug(
"jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}"
)
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
val term = currentTerminal() ?: return false
return stateMutex.withLock {
if (enabled) {
if (rawModeRequested) return@withLock false
val saved = runCatching { term.enterRawMode() }.getOrNull() ?: return@withLock false
enforceRawReadAttrs(term)
rawSavedAttributes = saved
rawModeRequested = true
consoleFlowDebug("jline-events: setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested
rawModeRequested = false
val saved = rawSavedAttributes
rawSavedAttributes = null
runCatching {
if (saved != null) term.setAttributes(saved)
}.onFailure {
consoleFlowDebug("jline-events: setRawMode(false): restore failed", it)
}
consoleFlowDebug("jline-events: setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
}
private fun readKeyEvent(reader: NonBlockingReader): ConsoleEvent.KeyDown? {
val code = reader.read(120L)
if (code == NonBlockingReader.READ_EXPIRED) return null
if (code < 0) throw EOFException("non-blocking reader returned EOF")
return decodeKey(code) { timeout -> readNextCode(reader, timeout) }
}
private fun decodeKey(code: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (code == 27) {
val next = nextCode(25L)
if (next == null || next < 0) {
return key("Escape")
}
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 6) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
// Alt+key
val base = decodePlainKey(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false
)
}
return decodePlainKey(code)
}
private fun readNextCode(reader: NonBlockingReader, timeoutMs: Long): Int? {
val c = reader.read(timeoutMs)
if (c == NonBlockingReader.READ_EXPIRED) return null
if (c < 0) throw EOFException("non-blocking reader returned EOF while decoding key sequence")
return c
}
private fun decodePlainKey(code: Int): ConsoleEvent.KeyDown = when (code) {
3 -> key("c", ctrl = true)
9 -> key("Tab")
10, 13 -> key("Enter")
127, 8 -> key("Backspace")
32 -> key(" ")
else -> {
if (code in 1..26) {
val ch = ('a'.code + code - 1).toChar().toString()
key(ch, ctrl = true)
} else {
val ch = code.toChar().toString()
key(ch, shift = ch.length == 1 && ch[0].isLetter() && ch[0].isUpperCase())
}
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? = when (seq) {
"[A", "OA" -> key("ArrowUp")
"[B", "OB" -> key("ArrowDown")
"[C", "OC" -> key("ArrowRight")
"[D", "OD" -> key("ArrowLeft")
"[H", "OH" -> key("Home")
"[F", "OF" -> key("End")
"[2~" -> key("Insert")
"[3~" -> key("Delete")
"[5~" -> key("PageUp")
"[6~" -> key("PageDown")
else -> null
}
private fun key(
value: String,
ctrl: Boolean = false,
alt: Boolean = false,
shift: Boolean = false,
): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(
key = value,
code = null,
ctrl = ctrl,
alt = alt,
shift = shift,
meta = false
)
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
actual fun getSystemConsole(): LyngConsole = JvmLyngConsole

View File

@ -0,0 +1,115 @@
/*
* 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.console
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Scope
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIllegalOperationException
import net.sergeych.lyngio.console.security.ConsoleAccessOp
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class LyngConsoleModuleTest {
private fun newScope(): Scope = Scope.new()
@Test
fun installIsIdempotent() = runBlocking {
val scope = newScope()
assertTrue(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
assertFalse(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
}
@Test
fun moduleSmokeScript() = runBlocking {
val scope = newScope()
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
val code = """
import lyng.io.console
import lyng.stdlib
val d = Console.details()
assert(d.supported is Bool)
assert(d.isTty is Bool)
assert(d.ansiLevel is String)
val g = Console.geometry()
if (g != null) {
assert(g.columns is Int)
assert(g.rows is Int)
assert(g.columns > 0)
assert(g.rows > 0)
}
assert(Console.events() is Iterable)
Console.write("")
Console.flush()
Console.home()
Console.clear()
Console.moveTo(1, 1)
Console.clearLine()
Console.enterAltScreen()
Console.leaveAltScreen()
Console.setCursorVisible(true)
val changed = Console.setRawMode(false)
assert(changed is Bool)
true
""".trimIndent()
val result = scope.eval(code)
assertIs<ObjBool>(result)
assertTrue(result.value)
}
@Test
fun denyWritePolicyMapsToIllegalOperation() {
runBlocking {
val denyWritePolicy = object : ConsoleAccessPolicy {
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision = when (op) {
is ConsoleAccessOp.WriteText -> AccessDecision(Decision.Deny, "denied by test policy")
else -> AccessDecision(Decision.Allow)
}
}
val scope = newScope()
createConsoleModule(denyWritePolicy, scope)
val error = kotlin.test.assertFailsWith<ExecutionError> {
scope.eval(
"""
import lyng.io.console
Console.write("x")
""".trimIndent()
)
}
assertIs<ObjIllegalOperationException>(error.errorObject)
}
}
}

View File

@ -0,0 +1,77 @@
/*
* 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.lyngio.console
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MordantLyngConsoleJvmTest {
@Test
fun basicCapabilitiesSmoke() = runBlocking {
val console = getSystemConsole()
assertNotNull(console)
// Must be callable in any environment (interactive or redirected output).
val tty = console.isTty()
val ansi = console.ansiLevel()
val geometry = console.geometry()
if (geometry != null) {
assertTrue(geometry.columns > 0, "columns must be positive when geometry is present")
assertTrue(geometry.rows > 0, "rows must be positive when geometry is present")
}
// no-op smoke checks
console.write("")
console.flush()
// Keep values live so compiler doesn't optimize away calls in future changes
assertNotNull(ansi)
assertTrue(tty || !tty)
}
@Test
fun setRawModeContract() = runBlocking {
val console = getSystemConsole()
val enabledChanged = console.setRawMode(true)
val disabledChanged = console.setRawMode(false)
// If enabling changed state, disabling should also change it back.
if (enabledChanged) {
assertTrue(disabledChanged, "raw mode disable should report changed after enable")
}
}
@Test
fun eventsSourceDoesNotCrash() = runBlocking {
val console = getSystemConsole()
val source = console.events()
val event = source.nextEvent(350)
source.close()
// Any event kind is acceptable in this smoke test; null is also valid when idle.
if (event != null) {
assertTrue(
event is ConsoleEvent.Resize || event is ConsoleEvent.KeyDown || event is ConsoleEvent.KeyUp,
"unexpected event type: ${event::class.simpleName}"
)
}
}
}

View File

@ -0,0 +1,243 @@
/*
* 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.lyngio.console
import kotlinx.cinterop.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import platform.posix.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
internal actual fun getNativeSystemConsole(): LyngConsole = LinuxPosixLyngConsole
internal object LinuxConsoleKeyDecoder {
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (firstCode == 27) {
val next = nextCode(25L)
if (next == null || next < 0) return key("Escape")
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 8) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
val base = decodePlain(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false,
)
}
return decodePlain(firstCode)
}
private fun decodePlain(code: Int): ConsoleEvent.KeyDown {
if (code == 3) return ConsoleEvent.KeyDown(key = "c", ctrl = true)
if (code == 9) return key("Tab")
if (code == 10 || code == 13) return key("Enter")
if (code == 32) return key(" ")
if (code == 127 || code == 8) return key("Backspace")
val c = code.toChar()
return if (c in 'A'..'Z') {
ConsoleEvent.KeyDown(key = c.toString(), shift = true)
} else {
key(c.toString())
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? {
val letter = seq.lastOrNull() ?: return null
val shift = seq.contains(";2")
val alt = seq.contains(";3")
val ctrl = seq.contains(";5")
val key = when (letter) {
'A' -> "ArrowUp"
'B' -> "ArrowDown"
'C' -> "ArrowRight"
'D' -> "ArrowLeft"
'H' -> "Home"
'F' -> "End"
else -> return null
}
return ConsoleEvent.KeyDown(key = key, ctrl = ctrl, alt = alt, shift = shift)
}
private fun key(name: String): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(key = name)
}
@OptIn(ExperimentalForeignApi::class)
object LinuxPosixLyngConsole : LyngConsole {
private val stateMutex = Mutex()
private var rawModeRequested = false
private var savedAttrsBlob: ByteArray? = null
override val isSupported: Boolean
get() = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
override suspend fun isTty(): Boolean = isSupported
override suspend fun geometry(): ConsoleGeometry? = readGeometry()
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val colorTerm = (getenv("COLORTERM")?.toKString() ?: "").lowercase()
val term = (getenv("TERM")?.toKString() ?: "").lowercase()
return when {
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
else -> ConsoleAnsiLevel.NONE
}
}
override suspend fun write(text: String) {
kotlin.io.print(text)
}
override suspend fun flush() {
fflush(null)
}
override fun events(): ConsoleEventSource {
if (!isSupported) {
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
}
return object : ConsoleEventSource {
var closed = false
var lastGeometry: ConsoleGeometry? = null
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (closed) return null
val started = TimeSource.Monotonic.markNow()
while (!closed) {
val g = readGeometry()
if (g != null && (lastGeometry == null || g.columns != lastGeometry?.columns || g.rows != lastGeometry?.rows)) {
lastGeometry = g
return ConsoleEvent.Resize(g.columns, g.rows)
}
val rawRequested = stateMutex.withLock { rawModeRequested }
val pollSliceMs = if (timeoutMs <= 0L) 250L else minOf(250L, timeoutMs)
if (rawRequested) {
val ev = readKeyEvent(pollSliceMs)
if (ev != null) return ev
} else {
delay(25)
}
if (timeoutMs > 0L && started.elapsedNow() >= timeoutMs.milliseconds) {
return null
}
}
return null
}
override suspend fun close() {
closed = true
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
if (!isSupported) return false
return stateMutex.withLock {
if (enabled) {
if (rawModeRequested) return@withLock false
memScoped {
val attrs = alloc<termios>()
if (tcgetattr(STDIN_FILENO, attrs.ptr) != 0) return@withLock false
val saved = ByteArray(sizeOf<termios>().toInt())
saved.usePinned { pinned ->
memcpy(pinned.addressOf(0), attrs.ptr, sizeOf<termios>().convert())
}
savedAttrsBlob = saved
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv() and ECHO.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and IXON.convert<UInt>().inv() and ISTRIP.convert<UInt>().inv()
attrs.c_oflag = attrs.c_oflag and OPOST.convert<UInt>().inv()
if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false
}
rawModeRequested = true
true
} else {
val hadRaw = rawModeRequested
rawModeRequested = false
val saved = savedAttrsBlob
if (saved != null) {
memScoped {
val attrs = alloc<termios>()
saved.usePinned { pinned ->
memcpy(attrs.ptr, pinned.addressOf(0), sizeOf<termios>().convert())
}
tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr)
}
}
hadRaw
}
}
}
private fun readGeometry(): ConsoleGeometry? = memScoped {
val ws = alloc<winsize>()
if (ioctl(STDOUT_FILENO, TIOCGWINSZ.convert(), ws.ptr) != 0) return null
val cols = ws.ws_col.toInt()
val rows = ws.ws_row.toInt()
if (cols <= 0 || rows <= 0) return null
ConsoleGeometry(columns = cols, rows = rows)
}
private fun readByte(timeoutMs: Long): Int? = memScoped {
val pfd = alloc<pollfd>()
pfd.fd = STDIN_FILENO
pfd.events = POLLIN.convert()
pfd.revents = 0
val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt())
if (ready <= 0) return null
val buf = ByteArray(1)
val count = buf.usePinned { pinned ->
read(STDIN_FILENO, pinned.addressOf(0), 1.convert())
}
if (count <= 0) return null
val b = buf[0].toInt()
if (b < 0) b + 256 else b
}
private fun readKeyEvent(timeoutMs: Long): ConsoleEvent.KeyDown? {
val first = readByte(timeoutMs) ?: return null
return LinuxConsoleKeyDecoder.decode(first) { timeout ->
readByte(timeout)
}
}
}

View File

@ -0,0 +1,67 @@
/*
* 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.lyngio.console
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LinuxPosixLyngConsoleTest {
private fun decode(vararg bytes: Int): ConsoleEvent.KeyDown {
var i = 1
return LinuxConsoleKeyDecoder.decode(bytes[0]) { _ ->
if (i >= bytes.size) null else bytes[i++]
}
}
@Test
fun decodesArrowLeft() {
val ev = decode(27, '['.code, 'D'.code)
assertEquals("ArrowLeft", ev.key)
assertFalse(ev.ctrl)
}
@Test
fun decodesArrowRightCtrlModifier() {
val ev = decode(27, '['.code, '1'.code, ';'.code, '5'.code, 'C'.code)
assertEquals("ArrowRight", ev.key)
assertTrue(ev.ctrl)
}
@Test
fun decodesEscape() {
val ev = decode(27)
assertEquals("Escape", ev.key)
}
@Test
fun decodesCtrlC() {
val ev = decode(3)
assertEquals("c", ev.key)
assertTrue(ev.ctrl)
}
@Test
fun decodesUppercaseShift() {
val ev = decode('A'.code)
assertEquals("A", ev.key)
assertTrue(ev.shift)
}
}

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole

View File

@ -0,0 +1,20 @@
/*
* 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.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole

View File

@ -0,0 +1,22 @@
/*
* 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.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on Native
}

View File

@ -0,0 +1,22 @@
/*
* 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.lyngio.console
actual fun getSystemConsole(): LyngConsole = getNativeSystemConsole()
internal expect fun getNativeSystemConsole(): LyngConsole

View File

@ -0,0 +1,22 @@
/*
* 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.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on wasmJs
}

View File

@ -0,0 +1,22 @@
/*
* 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.bytecode
internal actual fun vmIterDebug(message: String, error: Throwable?) {
// no-op on Android
}

View File

@ -306,17 +306,41 @@ private class Parser(fromPos: Pos) {
'\'' -> {
val start = pos.toPos()
var value = currentChar
pos.advance()
if (currentChar == '\\') {
value = currentChar
pos.advance()
value = when (value) {
'n' -> '\n'
'r' -> '\r'
't' -> '\t'
'\'', '\\' -> value
else -> throw ScriptError(currentPos, "unsupported escape character: $value")
if (pos.end) throw ScriptError(start, "unterminated character literal")
value = when (currentChar) {
'n' -> {
pos.advance()
'\n'
}
'r' -> {
pos.advance()
'\r'
}
't' -> {
pos.advance()
'\t'
}
'\'' -> {
pos.advance()
'\''
}
'\\' -> {
pos.advance()
'\\'
}
'u' -> loadUnicodeEscape(start)
else -> throw ScriptError(currentPos, "unsupported escape character: $currentChar")
}
} else {
pos.advance()
}
if (currentChar != '\'') throw ScriptError(currentPos, "expected end of character literal: '")
pos.advance()
@ -494,6 +518,10 @@ private class Parser(fromPos: Pos) {
sb.append('\\'); pos.advance()
}
'u' -> {
sb.append(loadUnicodeEscape(start))
}
else -> {
sb.append('\\').append(currentChar)
pos.advance()
@ -520,6 +548,23 @@ private class Parser(fromPos: Pos) {
return Token(result, start, Token.Type.STRING)
}
private fun loadUnicodeEscape(start: Pos): Char {
// Called when currentChar points to 'u' right after a backslash.
if (currentChar != 'u') throw ScriptError(currentPos, "expected unicode escape marker: u")
pos.advance() ?: throw ScriptError(start, "unterminated unicode escape")
var code = 0
repeat(4) {
val ch = currentChar
if (ch !in hexDigits) {
throw ScriptError(currentPos, "invalid unicode escape sequence, expected 4 hex digits")
}
code = (code shl 4) + ch.digitToInt(16)
pos.advance()
}
return code.toChar()
}
/**
* Load characters from the set until it reaches EOF or invalid character found.
* stop at EOF on character filtered by [isValidChar].

View File

@ -3784,6 +3784,7 @@ class CmdFrame(
val exceptionSlot: Int,
val catchIp: Int,
val finallyIp: Int,
val iterDepthAtPush: Int,
var inCatch: Boolean = false
)
internal val tryStack = ArrayDeque<TryHandler>()
@ -4069,11 +4070,15 @@ class CmdFrame(
return scope
}
fun handleException(t: Throwable): Boolean {
suspend fun handleException(t: Throwable): Boolean {
val handler = tryStack.lastOrNull() ?: return false
vmIterDebug(
"handleException fn=${fn.name} throwable=${t::class.simpleName} message=${t.message} catchIp=${handler.catchIp} finallyIp=${handler.finallyIp} iterDepth=${iterStack.size}"
)
val finallyIp = handler.finallyIp
if (t is ReturnException || t is LoopBreakContinueException) {
if (finallyIp >= 0) {
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:returnOrLoop->finally")
pendingThrowable = t
ip = finallyIp
return true
@ -4082,6 +4087,7 @@ class CmdFrame(
}
if (handler.inCatch) {
if (finallyIp >= 0) {
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:inCatch->finally")
pendingThrowable = t
ip = finallyIp
return true
@ -4091,6 +4097,7 @@ class CmdFrame(
handler.inCatch = true
pendingThrowable = t
if (handler.catchIp >= 0) {
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:toCatch")
val caughtObj = when (t) {
is ExecutionError -> t.errorObject
else -> ObjUnknownException(ensureScope(), t.message ?: t.toString())
@ -4100,6 +4107,7 @@ class CmdFrame(
return true
}
if (finallyIp >= 0) {
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:toFinallyNoCatch")
ip = finallyIp
return true
}
@ -4107,7 +4115,7 @@ class CmdFrame(
}
fun pushTry(exceptionSlot: Int, catchIp: Int, finallyIp: Int) {
tryStack.addLast(TryHandler(exceptionSlot, catchIp, finallyIp))
tryStack.addLast(TryHandler(exceptionSlot, catchIp, finallyIp, iterDepthAtPush = iterStack.size))
}
fun popTry() {
@ -4164,24 +4172,47 @@ class CmdFrame(
fun pushIterator(iter: Obj) {
iterStack.addLast(iter)
if (iter.objClass.className == "FlowIterator") {
vmIterDebug("pushIterator fn=${fn.name} depth=${iterStack.size} iterClass=${iter.objClass.className}")
}
}
fun popIterator() {
val iter = iterStack.lastOrNull()
if (iter != null && iter.objClass.className == "FlowIterator") {
vmIterDebug("popIterator fn=${fn.name} depth=${iterStack.size} iterClass=${iter.objClass.className}")
}
iterStack.removeLastOrNull()
}
suspend fun cancelTopIterator() {
val iter = iterStack.removeLastOrNull() ?: return
vmIterDebug("cancelTopIterator fn=${fn.name} depthAfter=${iterStack.size} iterClass=${iter.objClass.className}")
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
}
suspend fun cancelIterators() {
while (iterStack.isNotEmpty()) {
val iter = iterStack.removeLast()
vmIterDebug("cancelIterators fn=${fn.name} depthAfter=${iterStack.size} iterClass=${iter.objClass.className}")
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
}
}
private suspend fun cancelIteratorsToDepth(depth: Int, reason: String) {
while (iterStack.size > depth) {
val iter = iterStack.removeLast()
vmIterDebug(
"cancelIteratorsToDepth fn=${fn.name} reason=$reason targetDepth=$depth depthAfter=${iterStack.size} iterClass=${iter.objClass.className}"
)
try {
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
} catch (e: Throwable) {
vmIterDebug("cancelIteratorsToDepth: cancelIteration failed fn=${fn.name} reason=$reason", e)
}
}
}
fun pushSlotPlan(plan: Map<String, Int>) {
if (scope.hasSlotPlanConflict(plan)) {
scopeStack.addLast(scope)

View File

@ -0,0 +1,20 @@
/*
* 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.bytecode
internal expect fun vmIterDebug(message: String, error: Throwable? = null)

View File

@ -1,5 +1,5 @@
/*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -24,7 +24,8 @@ import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.lyng.*
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.TypeGenericDoc
import net.sergeych.lyng.miniast.addFnDoc
@ -55,7 +56,7 @@ class ObjFlowBuilder(val output: SendChannel<Obj>) : Obj() {
else
// Flow consumer is no longer collecting; signal producer to stop
throw ScriptFlowIsNoMoreCollected()
} catch (x: Exception) {
} catch (x: Throwable) {
// Any failure to send (including closed channel) should gracefully stop the producer.
if (x is CancellationException) {
// Cancellation is a normal control-flow event
@ -80,7 +81,7 @@ private fun createLyngFlowInput(scope: Scope, producer: Obj): ReceiveChannel<Obj
producer.callOn(builderScope)
} catch (x: ScriptFlowIsNoMoreCollected) {
// premature flow closing, OK
} catch (x: Exception) {
} catch (x: Throwable) {
channel.close(x)
return@globalLaunch
}

View File

@ -0,0 +1,60 @@
/*
* 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.
*
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.*
import net.sergeych.lyng.obj.ObjChar
import net.sergeych.lyng.obj.ObjString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class UnicodeEscapeTest {
@Test
fun parserDecodesUnicodeEscapeInStringLiteral() {
val token = parseLyng("\"\\u263A\"".toSource()).first()
assertEquals(Token.Type.STRING, token.type)
assertEquals("", token.value)
}
@Test
fun parserDecodesUnicodeEscapeInCharLiteral() {
val token = parseLyng("'\\u263A'".toSource()).first()
assertEquals(Token.Type.CHAR, token.type)
assertEquals("", token.value)
}
@Test
fun parserRejectsMalformedUnicodeEscapeInStringLiteral() {
assertFailsWith<ScriptError> {
parseLyng("\"\\u12G4\"".toSource())
}
}
@Test
fun parserRejectsShortUnicodeEscapeInCharLiteral() {
assertFailsWith<ScriptError> {
parseLyng("'\\u12'".toSource())
}
}
@Test
fun evalDecodesUnicodeEscapes() = runTest {
assertEquals(ObjString(""), eval("\"\\u263A\""))
assertEquals(ObjChar('☺'), eval("'\\u263A'"))
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.bytecode
internal actual fun vmIterDebug(message: String, error: Throwable?) {
// no-op on JS
}

View File

@ -0,0 +1,46 @@
/*
* 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.bytecode
import java.io.File
import java.time.Instant
private val vmIterLogFilePath: String =
System.getenv("LYNG_VM_DEBUG_LOG")
?.takeIf { it.isNotBlank() }
?: "/tmp/lyng_vm_iter_debug.log"
private val vmIterLogLock = Any()
internal actual fun vmIterDebug(message: String, error: Throwable?) {
runCatching {
val line = buildString {
append(Instant.now().toString())
append(" [vm-iter] ")
append(message)
append('\n')
if (error != null) {
append(error.stackTraceToString())
append('\n')
}
}
synchronized(vmIterLogLock) {
File(vmIterLogFilePath).appendText(line)
}
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.bytecode
internal actual fun vmIterDebug(message: String, error: Throwable?) {
// no-op on Native
}

View File

@ -0,0 +1,22 @@
/*
* 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.bytecode
internal actual fun vmIterDebug(message: String, error: Throwable?) {
// no-op on wasmJs
}