diff --git a/docs/ai_language_reference.md b/docs/ai_language_reference.md index 8812fbc..389b2df 100644 --- a/docs/ai_language_reference.md +++ b/docs/ai_language_reference.md @@ -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` diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index d5b897a..a32a3b2 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -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`). diff --git a/docs/lyng.io.console.md b/docs/lyng.io.console.md new file mode 100644 index 0000000..93b606e --- /dev/null +++ b/docs/lyng.io.console.md @@ -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`. diff --git a/docs/lyngio.md b/docs/lyngio.md index c10dea0..08e8fa1 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -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) | ❌ | ❌ | diff --git a/docs/tutorial.md b/docs/tutorial.md index 6da9dd0..91d6825 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -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 diff --git a/examples/tetris_console.lyng b/examples/tetris_console.lyng new file mode 100644 index 0000000..13e760a --- /dev/null +++ b/examples/tetris_console.lyng @@ -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 +type Rotation = List +type Rotations = List +type Row = List +type Board = List + +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 { + val out: List = [] + 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, + originRow: Int, + originCol: Int, + useColor: Bool, +): List { + val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, boardW) + UNICODE_BOTTOM_RIGHT + + val panel: List = [] + 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 = [] + + 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 = [] + +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 = [] + + 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)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da17089..9bc18d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file +vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.29.0" } diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 80bed57..e413554 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -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) } } diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index 74e5c1e..e05da8a 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -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 { diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugAndroid.kt new file mode 100644 index 0000000..2a06283 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugAndroid.kt @@ -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 +} diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/PlatformAndroid.kt new file mode 100644 index 0000000..0be9db7 --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/console/PlatformAndroid.kt @@ -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 diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt new file mode 100644 index 0000000..0a2ca88 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt @@ -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(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(0).value + val col = requiredArg(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(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(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() + 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().hasNext().toObj() + } + addFnDoc( + name = "next", + doc = "Return the next console event.", + returns = type("ConsoleEvent"), + moduleName = "lyng.io.console", + ) { + thisAs().next(requireScope()) + } + addFnDoc( + name = "cancelIteration", + doc = "Stop reading console events and release resources.", + returns = type("lyng.Void"), + moduleName = "lyng.io.console", + ) { + thisAs().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 } + ) + } + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebug.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebug.kt new file mode 100644 index 0000000..beca8b9 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebug.kt @@ -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) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/LyngConsole.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/LyngConsole.kt new file mode 100644 index 0000000..ff0637f --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/LyngConsole.kt @@ -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 +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt new file mode 100644 index 0000000..55a1aa0 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt @@ -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(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 + } + } + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/Platform.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/Platform.kt new file mode 100644 index 0000000..6fd3398 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/Platform.kt @@ -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 diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/ConsoleAccessPolicy.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/ConsoleAccessPolicy.kt new file mode 100644 index 0000000..81721f7 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/ConsoleAccessPolicy.kt @@ -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) +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/LyngConsoleSecured.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/LyngConsoleSecured.kt new file mode 100644 index 0000000..76c4b6a --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/security/LyngConsoleSecured.kt @@ -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) + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt new file mode 100644 index 0000000..6937061 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt @@ -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 + } +} diff --git a/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/console/PlatformIos.kt b/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/console/PlatformIos.kt new file mode 100644 index 0000000..591e3a4 --- /dev/null +++ b/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/console/PlatformIos.kt @@ -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 diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJs.kt new file mode 100644 index 0000000..1b506b4 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJs.kt @@ -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 +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/PlatformJs.kt new file mode 100644 index 0000000..0be9db7 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/console/PlatformJs.kt @@ -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 diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJvm.kt new file mode 100644 index 0000000..23ab5db --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugJvm.kt @@ -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) + } + } +} diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt new file mode 100644 index 0000000..7495581 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt @@ -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() ?: "" + } + + private val terminalRef = AtomicReference(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 ?: ""} type=${terminal.type}") + runCatching { terminal.close() } + continue + } + consoleFlowDebug("jline-events: terminal built providers=${providers ?: ""} 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(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 + ) +} diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/PlatformJvm.kt new file mode 100644 index 0000000..22c1627 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/PlatformJvm.kt @@ -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 diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt new file mode 100644 index 0000000..6f3833e --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt @@ -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(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 { + scope.eval( + """ + import lyng.io.console + Console.write("x") + """.trimIndent() + ) + } + + assertIs(error.errorObject) + } + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/console/MordantLyngConsoleJvmTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/console/MordantLyngConsoleJvmTest.kt new file mode 100644 index 0000000..bb95478 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/console/MordantLyngConsoleJvmTest.kt @@ -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}" + ) + } + } +} diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsole.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsole.kt new file mode 100644 index 0000000..9484f13 --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsole.kt @@ -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() + if (tcgetattr(STDIN_FILENO, attrs.ptr) != 0) return@withLock false + + val saved = ByteArray(sizeOf().toInt()) + saved.usePinned { pinned -> + memcpy(pinned.addressOf(0), attrs.ptr, sizeOf().convert()) + } + savedAttrsBlob = saved + + attrs.c_lflag = attrs.c_lflag and ICANON.convert().inv() and ECHO.convert().inv() + attrs.c_iflag = attrs.c_iflag and IXON.convert().inv() and ISTRIP.convert().inv() + attrs.c_oflag = attrs.c_oflag and OPOST.convert().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() + saved.usePinned { pinned -> + memcpy(attrs.ptr, pinned.addressOf(0), sizeOf().convert()) + } + tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) + } + } + hadRaw + } + } + } + + private fun readGeometry(): ConsoleGeometry? = memScoped { + val ws = alloc() + 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() + 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) + } + } +} diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsoleTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsoleTest.kt new file mode 100644 index 0000000..42c7809 --- /dev/null +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/console/LinuxPosixLyngConsoleTest.kt @@ -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) + } +} diff --git a/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/console/PlatformMacos.kt b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/console/PlatformMacos.kt new file mode 100644 index 0000000..591e3a4 --- /dev/null +++ b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/console/PlatformMacos.kt @@ -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 diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/console/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/console/PlatformMingw.kt new file mode 100644 index 0000000..591e3a4 --- /dev/null +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/console/PlatformMingw.kt @@ -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 diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugNative.kt new file mode 100644 index 0000000..711ae2b --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugNative.kt @@ -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 +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/PlatformNative.kt new file mode 100644 index 0000000..06a27a1 --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/console/PlatformNative.kt @@ -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 diff --git a/lyngio/src/wasmJsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugWasmJs.kt b/lyngio/src/wasmJsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugWasmJs.kt new file mode 100644 index 0000000..dcac27d --- /dev/null +++ b/lyngio/src/wasmJsMain/kotlin/net/sergeych/lyngio/console/ConsoleFlowDebugWasmJs.kt @@ -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 +} diff --git a/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugAndroid.kt b/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugAndroid.kt new file mode 100644 index 0000000..ed5080b --- /dev/null +++ b/lynglib/src/androidMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugAndroid.kt @@ -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 +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt index 7171862..76744f5 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt @@ -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]. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt index 437550c..9059207 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt @@ -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() @@ -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) { if (scope.hasSlotPlanConflict(plan)) { scopeStack.addLast(scope) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebug.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebug.kt new file mode 100644 index 0000000..5053e18 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebug.kt @@ -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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt index f22e6b9..fc8ebd5 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjFlow.kt @@ -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() { 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 { + parseLyng("\"\\u12G4\"".toSource()) + } + } + + @Test + fun parserRejectsShortUnicodeEscapeInCharLiteral() { + assertFailsWith { + parseLyng("'\\u12'".toSource()) + } + } + + @Test + fun evalDecodesUnicodeEscapes() = runTest { + assertEquals(ObjString("☺"), eval("\"\\u263A\"")) + assertEquals(ObjChar('☺'), eval("'\\u263A'")) + } +} diff --git a/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJs.kt b/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJs.kt new file mode 100644 index 0000000..328384a --- /dev/null +++ b/lynglib/src/jsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJs.kt @@ -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 +} diff --git a/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJvm.kt b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJvm.kt new file mode 100644 index 0000000..7653a1f --- /dev/null +++ b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugJvm.kt @@ -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) + } + } +} diff --git a/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugNative.kt b/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugNative.kt new file mode 100644 index 0000000..324db03 --- /dev/null +++ b/lynglib/src/nativeMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugNative.kt @@ -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 +} diff --git a/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugWasmJs.kt b/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugWasmJs.kt new file mode 100644 index 0000000..91d5a37 --- /dev/null +++ b/lynglib/src/wasmJsMain/kotlin/net/sergeych/lyng/bytecode/VmIterDebugWasmJs.kt @@ -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 +}