+added console spport for lyngio
+added console support to lyng/jlyng CLI +added unicode escapes +created tetris console sample
This commit is contained in:
parent
1a080bc53e
commit
d9d7cafec8
@ -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`
|
||||
|
||||
@ -63,8 +63,9 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s
|
||||
Requires installing `lyngio` into the import manager from host code.
|
||||
- `import lyng.io.fs` (filesystem `Path` API)
|
||||
- `import lyng.io.process` (process execution API)
|
||||
- `import lyng.io.console` (console capabilities, geometry, ANSI/output, events)
|
||||
|
||||
## 7. AI Generation Tips
|
||||
- Assume `lyng.stdlib` APIs exist in regular script contexts.
|
||||
- For platform-sensitive code (`fs`, `process`), gate assumptions and mention required module install.
|
||||
- For platform-sensitive code (`fs`, `process`, `console`), gate assumptions and mention required module install.
|
||||
- Prefer extension-method style (`items.filter { ... }`) and standard scope helpers (`let`/`also`/`apply`/`run`).
|
||||
|
||||
101
docs/lyng.io.console.md
Normal file
101
docs/lyng.io.console.md
Normal file
@ -0,0 +1,101 @@
|
||||
### lyng.io.console
|
||||
|
||||
`lyng.io.console` provides optional rich console support for terminal applications.
|
||||
|
||||
> **Note:** this module is part of `lyngio`. It must be explicitly installed into the import manager by host code.
|
||||
>
|
||||
> **CLI note:** the `lyng` CLI now installs `lyng.io.console` in its base scope by default, so scripts can simply `import lyng.io.console`.
|
||||
|
||||
#### Install in host
|
||||
|
||||
```kotlin
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyng.io.console.createConsoleModule
|
||||
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
|
||||
|
||||
suspend fun initScope() {
|
||||
val scope = Script.newScope()
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
|
||||
}
|
||||
```
|
||||
|
||||
#### Use in Lyng script
|
||||
|
||||
```lyng
|
||||
import lyng.io.console
|
||||
|
||||
println("supported = " + Console.isSupported())
|
||||
println("tty = " + Console.isTty())
|
||||
println("ansi = " + Console.ansiLevel())
|
||||
println("geometry = " + Console.geometry())
|
||||
|
||||
Console.write("hello\n")
|
||||
Console.home()
|
||||
Console.clear()
|
||||
Console.moveTo(1, 1)
|
||||
Console.clearLine()
|
||||
Console.enterAltScreen()
|
||||
Console.leaveAltScreen()
|
||||
Console.setCursorVisible(true)
|
||||
Console.flush()
|
||||
```
|
||||
|
||||
Interactive sample script in this repo:
|
||||
|
||||
```bash
|
||||
lyng examples/tetris_console.lyng
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
- `Console.isSupported(): Bool` — whether console control is available on this platform/runtime.
|
||||
- `Console.isTty(): Bool` — whether output is attached to a TTY.
|
||||
- `Console.ansiLevel(): String` — `NONE`, `BASIC16`, `ANSI256`, `TRUECOLOR`.
|
||||
- `Console.geometry(): ConsoleGeometry?` — `{columns, rows}` as typed object or `null`.
|
||||
- `Console.details(): ConsoleDetails` — consolidated capability object.
|
||||
- `Console.write(text: String)` — writes to console output.
|
||||
- `Console.flush()` — flushes buffered output.
|
||||
- `Console.home()` — moves cursor to top-left.
|
||||
- `Console.clear()` — clears visible screen.
|
||||
- `Console.moveTo(row: Int, column: Int)` — moves cursor to 1-based row/column.
|
||||
- `Console.clearLine()` — clears current line.
|
||||
- `Console.enterAltScreen()` — switch to alternate screen buffer.
|
||||
- `Console.leaveAltScreen()` — return to normal screen buffer.
|
||||
- `Console.setCursorVisible(visible: Bool)` — shows/hides cursor.
|
||||
- `Console.events(): ConsoleEventStream` — endless iterable source of typed events: `ConsoleResizeEvent`, `ConsoleKeyEvent`.
|
||||
- `Console.setRawMode(enabled: Bool): Bool` — requests raw input mode, returns `true` if changed.
|
||||
|
||||
#### Event Iteration
|
||||
|
||||
Use events from a loop, typically in a separate coroutine:
|
||||
|
||||
```lyng
|
||||
launch {
|
||||
for (ev in Console.events()) {
|
||||
if (ev is ConsoleKeyEvent) {
|
||||
// handle key
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Event format
|
||||
|
||||
`Console.events()` emits `ConsoleEvent` with at least:
|
||||
|
||||
- `type: String` — `resize`, `keydown`, `keyup`
|
||||
|
||||
Additional fields:
|
||||
|
||||
- `ConsoleResizeEvent`: `columns`, `rows`
|
||||
- `ConsoleKeyEvent`: `key`, `code`, `ctrl`, `alt`, `shift`, `meta`
|
||||
|
||||
#### Security policy
|
||||
|
||||
The module uses `ConsoleAccessPolicy` with operations:
|
||||
|
||||
- `WriteText(length)`
|
||||
- `ReadEvents`
|
||||
- `SetRawMode(enabled)`
|
||||
|
||||
For permissive mode, use `PermitAllConsoleAccessPolicy`.
|
||||
@ -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) | ❌ | ❌ |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
664
examples/tetris_console.lyng
Normal file
664
examples/tetris_console.lyng
Normal file
@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env lyng
|
||||
|
||||
/*
|
||||
* Lyng Console Tetris (interactive sample)
|
||||
*
|
||||
* Controls:
|
||||
* - Left/Right arrows or A/D: move
|
||||
* - Up arrow or W: rotate
|
||||
* - Down arrow or S: soft drop
|
||||
* - Space: hard drop
|
||||
* - Q or Escape: quit
|
||||
*/
|
||||
|
||||
import lyng.io.console
|
||||
|
||||
val MIN_COLS = 56
|
||||
val MIN_ROWS = 24
|
||||
val PANEL_WIDTH = 24
|
||||
val BOARD_MARGIN_ROWS = 8
|
||||
val BOARD_MIN_W = 10
|
||||
val BOARD_MAX_W = 16
|
||||
val BOARD_MIN_H = 16
|
||||
val BOARD_MAX_H = 28
|
||||
val LEVEL_LINES_STEP = 10
|
||||
val DROP_FRAMES_BASE = 15
|
||||
val DROP_FRAMES_MIN = 3
|
||||
val FRAME_DELAY_MS = 35
|
||||
val RESIZE_WAIT_MS = 250
|
||||
val RNG_A = 1103515245
|
||||
val RNG_C = 12345
|
||||
val RNG_M = 2147483647
|
||||
val ROTATION_KICKS = [0, -1, 1, -2, 2]
|
||||
val ANSI_ESC = "\u001b["
|
||||
val ANSI_RESET = ANSI_ESC + "0m"
|
||||
val UNICODE_BLOCK = "██"
|
||||
val UNICODE_TOP_LEFT = "┌"
|
||||
val UNICODE_TOP_RIGHT = "┐"
|
||||
val UNICODE_BOTTOM_LEFT = "└"
|
||||
val UNICODE_BOTTOM_RIGHT = "┘"
|
||||
val UNICODE_HORIZONTAL = "──"
|
||||
val UNICODE_VERTICAL = "│"
|
||||
val UNICODE_DOT = "· "
|
||||
|
||||
type Cell = List<Int>
|
||||
type Rotation = List<Cell>
|
||||
type Rotations = List<Rotation>
|
||||
type Row = List<Int>
|
||||
type Board = List<Row>
|
||||
|
||||
class Piece(val name: String, val rotations: Rotations) {}
|
||||
class RotateResult(val ok: Bool, val rot: Int, val px: Int) {}
|
||||
class GameState(
|
||||
pieceId0: Int,
|
||||
nextId0: Int,
|
||||
next2Id0: Int,
|
||||
px0: Int,
|
||||
py0: Int,
|
||||
) {
|
||||
var pieceId = pieceId0
|
||||
var nextId = nextId0
|
||||
var next2Id = next2Id0
|
||||
var rot = 0
|
||||
var px = px0
|
||||
var py = py0
|
||||
var score = 0
|
||||
var totalLines = 0
|
||||
var level = 1
|
||||
var running = true
|
||||
var gameOver = false
|
||||
}
|
||||
class LoopFrame(val resized: Bool, val originRow: Int, val originCol: Int) {}
|
||||
|
||||
fun clearAndHome() {
|
||||
Console.clear()
|
||||
Console.home()
|
||||
}
|
||||
|
||||
fun repeatText(s: String, n: Int): String {
|
||||
var out: String = ""
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
out += s
|
||||
i = i + 1
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fun maxInt(a: Int, b: Int): Int {
|
||||
if (a > b) a else b
|
||||
}
|
||||
|
||||
fun minInt(a: Int, b: Int): Int {
|
||||
if (a < b) a else b
|
||||
}
|
||||
|
||||
fun clampInt(v: Int, lo: Int, hi: Int): Int {
|
||||
minInt(hi, maxInt(lo, v))
|
||||
}
|
||||
|
||||
fun emptyRow(width: Int): Row {
|
||||
val r: Row = []
|
||||
var x = 0
|
||||
while (x < width) {
|
||||
r.add(0)
|
||||
x = x + 1
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fun createBoard(width: Int, height: Int): Board {
|
||||
val b: Board = []
|
||||
var y = 0
|
||||
while (y < height) {
|
||||
b.add(emptyRow(width))
|
||||
y = y + 1
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fun colorize(text: String, sgr: String, useColor: Bool): String {
|
||||
if (!useColor) return text
|
||||
ANSI_ESC + sgr + "m" + text + ANSI_RESET
|
||||
}
|
||||
|
||||
fun blockText(pieceId: Int, useColor: Bool): String {
|
||||
if (pieceId <= 0) return " "
|
||||
val sgr = when (pieceId) {
|
||||
1 -> "36" // I
|
||||
2 -> "33" // O
|
||||
3 -> "35" // T
|
||||
4 -> "32" // S
|
||||
5 -> "31" // Z
|
||||
6 -> "34" // J
|
||||
7 -> "93" // L
|
||||
else -> "37"
|
||||
}
|
||||
colorize(UNICODE_BLOCK, sgr, useColor)
|
||||
}
|
||||
|
||||
fun emptyCellText(useColor: Bool): String {
|
||||
colorize(UNICODE_DOT, "90", useColor)
|
||||
}
|
||||
|
||||
fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool {
|
||||
val piece: Piece = PIECES[pieceId - 1]
|
||||
val cells = piece.rotations[rot]
|
||||
|
||||
for (cell in cells) {
|
||||
val x = px + cell[0]
|
||||
val y = py + cell[1]
|
||||
|
||||
if (x < 0 || x >= boardW) return false
|
||||
if (y >= boardH) return false
|
||||
|
||||
if (y >= 0) {
|
||||
val row = board[y]
|
||||
if (row[x] != 0) return false
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void {
|
||||
val piece: Piece = PIECES[pieceId - 1]
|
||||
val cells = piece.rotations[rot]
|
||||
|
||||
for (cell in cells) {
|
||||
val x = px + cell[0]
|
||||
val y = py + cell[1]
|
||||
|
||||
if (y >= 0) {
|
||||
val row = board[y]
|
||||
row[x] = pieceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int {
|
||||
val b = board
|
||||
var y = boardH - 1
|
||||
var cleared = 0
|
||||
|
||||
while (y >= 0) {
|
||||
val row = b[y]
|
||||
var full = true
|
||||
var x = 0
|
||||
while (x < boardW) {
|
||||
if (row[x] == 0) {
|
||||
full = false
|
||||
break
|
||||
}
|
||||
x = x + 1
|
||||
}
|
||||
|
||||
if (full) {
|
||||
b.removeAt(y)
|
||||
b.insertAt(0, emptyRow(boardW))
|
||||
cleared = cleared + 1
|
||||
} else {
|
||||
y = y - 1
|
||||
}
|
||||
}
|
||||
|
||||
cleared
|
||||
}
|
||||
|
||||
fun activeCellId(pieceId: Int, rot: Int, px: Int, py: Int, x: Int, y: Int): Int {
|
||||
val piece: Piece = PIECES[pieceId - 1]
|
||||
val cells = piece.rotations[rot]
|
||||
|
||||
for (cell in cells) {
|
||||
val ax = px + cell[0]
|
||||
val ay = py + cell[1]
|
||||
if (ax == x && ay == y) return pieceId
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fun tryRotateCw(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): RotateResult {
|
||||
val piece: Piece = PIECES[pieceId - 1]
|
||||
val rotations = piece.rotations.size
|
||||
val nr = (rot + 1) % rotations
|
||||
for (kx in ROTATION_KICKS) {
|
||||
val nx = px + kx
|
||||
if (canPlace(board, boardW, boardH, pieceId, nr, nx, py)) {
|
||||
return RotateResult(true, nr, nx)
|
||||
}
|
||||
}
|
||||
RotateResult(false, rot, px)
|
||||
}
|
||||
|
||||
fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> {
|
||||
val out: List<String> = []
|
||||
if (pieceId <= 0) {
|
||||
out.add(" ")
|
||||
out.add(" ")
|
||||
out.add(" ")
|
||||
out.add(" ")
|
||||
return out
|
||||
}
|
||||
|
||||
val piece: Piece = PIECES[pieceId - 1]
|
||||
val cells = piece.rotations[0]
|
||||
|
||||
var y = 0
|
||||
while (y < 4) {
|
||||
var line = ""
|
||||
var x = 0
|
||||
while (x < 4) {
|
||||
var filled = false
|
||||
for (cell in cells) {
|
||||
if (cell[0] == x && cell[1] == y) {
|
||||
filled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
line += if (filled) blockText(pieceId, useColor) else " "
|
||||
x = x + 1
|
||||
}
|
||||
out.add(line)
|
||||
y = y + 1
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fun render(
|
||||
state: GameState,
|
||||
board: Board,
|
||||
boardW: Int,
|
||||
boardH: Int,
|
||||
prevFrameLines: List<String>,
|
||||
originRow: Int,
|
||||
originCol: Int,
|
||||
useColor: Bool,
|
||||
): List<String> {
|
||||
val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, boardW) + UNICODE_BOTTOM_RIGHT
|
||||
|
||||
val panel: List<String> = []
|
||||
val nextPiece: Piece = PIECES[state.nextId - 1]
|
||||
val next2Piece: Piece = PIECES[state.next2Id - 1]
|
||||
val nextName = nextPiece.name
|
||||
val next2Name = next2Piece.name
|
||||
val preview = nextPreviewLines(state.nextId, useColor)
|
||||
|
||||
panel.add("Lyng Tetris")
|
||||
panel.add("")
|
||||
panel.add("Score: " + state.score)
|
||||
panel.add("Lines: " + state.totalLines)
|
||||
panel.add("Level: " + state.level)
|
||||
panel.add("")
|
||||
panel.add("Next: " + nextName)
|
||||
panel.add("")
|
||||
for (pl in preview) panel.add(pl)
|
||||
panel.add("After: " + next2Name)
|
||||
panel.add("")
|
||||
panel.add("Keys:")
|
||||
panel.add("A/D or arrows")
|
||||
panel.add("W/Up: rotate")
|
||||
panel.add("S/Down: drop")
|
||||
panel.add("Space: hard drop")
|
||||
panel.add("Q/Esc: quit")
|
||||
|
||||
val frameLines: List<String> = []
|
||||
|
||||
var y = 0
|
||||
while (y < boardH) {
|
||||
var line = UNICODE_VERTICAL
|
||||
|
||||
var x = 0
|
||||
while (x < boardW) {
|
||||
val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y)
|
||||
val row = board[y]
|
||||
val b = row[x]
|
||||
val id = if (a > 0) a else b
|
||||
line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor)
|
||||
x = x + 1
|
||||
}
|
||||
|
||||
line += UNICODE_VERTICAL
|
||||
|
||||
val p = y
|
||||
if (p < panel.size) {
|
||||
line += " " + panel[p]
|
||||
}
|
||||
|
||||
frameLines.add(line)
|
||||
y = y + 1
|
||||
}
|
||||
|
||||
frameLines.add(bottomBorder)
|
||||
|
||||
val prev = prevFrameLines
|
||||
var i = 0
|
||||
while (i < frameLines.size) {
|
||||
val line = frameLines[i]
|
||||
val old = if (i < prev.size) prev[i] else null
|
||||
if (old != line) {
|
||||
Console.moveTo(originRow + i, originCol)
|
||||
Console.clearLine()
|
||||
Console.write(line)
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
frameLines
|
||||
}
|
||||
|
||||
fun waitForMinimumSize(minCols: Int, minRows: Int): Object {
|
||||
while (true) {
|
||||
val g = Console.geometry()
|
||||
val cols = g?.columns ?: 0
|
||||
val rows = g?.rows ?: 0
|
||||
|
||||
if (cols >= minCols && rows >= minRows) return g
|
||||
|
||||
clearAndHome()
|
||||
println("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows))
|
||||
println("Current: %sx%s"(cols, rows))
|
||||
println("Resize the console window to continue...")
|
||||
delay(RESIZE_WAIT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun scoreForLines(cleared: Int, level: Int): Int {
|
||||
when (cleared) {
|
||||
1 -> 100 * level
|
||||
2 -> 300 * level
|
||||
3 -> 500 * level
|
||||
4 -> 800 * level
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
// Classic 7 tetrominoes, minimal rotations per piece.
|
||||
fun cell(x: Int, y: Int): Cell { [x, y] }
|
||||
fun rot(a: Cell, b: Cell, c: Cell, d: Cell): Rotation {
|
||||
val r: Rotation = []
|
||||
r.add(a)
|
||||
r.add(b)
|
||||
r.add(c)
|
||||
r.add(d)
|
||||
r
|
||||
}
|
||||
|
||||
val PIECES: List<Piece> = []
|
||||
|
||||
val iRots: Rotations = []
|
||||
iRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(3,1)))
|
||||
iRots.add(rot(cell(2,0), cell(2,1), cell(2,2), cell(2,3)))
|
||||
PIECES.add(Piece("I", iRots))
|
||||
|
||||
val oRots: Rotations = []
|
||||
oRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(2,1)))
|
||||
PIECES.add(Piece("O", oRots))
|
||||
|
||||
val tRots: Rotations = []
|
||||
tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(2,1)))
|
||||
tRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(1,2)))
|
||||
tRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(1,2)))
|
||||
tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(1,2)))
|
||||
PIECES.add(Piece("T", tRots))
|
||||
|
||||
val sRots: Rotations = []
|
||||
sRots.add(rot(cell(1,0), cell(2,0), cell(0,1), cell(1,1)))
|
||||
sRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(2,2)))
|
||||
PIECES.add(Piece("S", sRots))
|
||||
|
||||
val zRots: Rotations = []
|
||||
zRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(2,1)))
|
||||
zRots.add(rot(cell(2,0), cell(1,1), cell(2,1), cell(1,2)))
|
||||
PIECES.add(Piece("Z", zRots))
|
||||
|
||||
val jRots: Rotations = []
|
||||
jRots.add(rot(cell(0,0), cell(0,1), cell(1,1), cell(2,1)))
|
||||
jRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(1,2)))
|
||||
jRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(2,2)))
|
||||
jRots.add(rot(cell(1,0), cell(1,1), cell(0,2), cell(1,2)))
|
||||
PIECES.add(Piece("J", jRots))
|
||||
|
||||
val lRots: Rotations = []
|
||||
lRots.add(rot(cell(2,0), cell(0,1), cell(1,1), cell(2,1)))
|
||||
lRots.add(rot(cell(1,0), cell(1,1), cell(1,2), cell(2,2)))
|
||||
lRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(0,2)))
|
||||
lRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(1,2)))
|
||||
PIECES.add(Piece("L", lRots))
|
||||
|
||||
if (!Console.isSupported()) {
|
||||
println("Console API is not supported in this runtime.")
|
||||
void
|
||||
} else if (!Console.isTty()) {
|
||||
println("This sample needs an interactive terminal (TTY).")
|
||||
void
|
||||
} else {
|
||||
waitForMinimumSize(MIN_COLS, MIN_ROWS)
|
||||
|
||||
val g0 = Console.geometry()
|
||||
val cols = g0?.columns ?: MIN_COLS
|
||||
val rows = g0?.rows ?: MIN_ROWS
|
||||
|
||||
val boardW = clampInt((cols - PANEL_WIDTH) / 2, BOARD_MIN_W, BOARD_MAX_W)
|
||||
val boardH = clampInt(rows - BOARD_MARGIN_ROWS, BOARD_MIN_H, BOARD_MAX_H)
|
||||
|
||||
val board: Board = createBoard(boardW, boardH)
|
||||
|
||||
var rng = 1337
|
||||
fun nextPieceId() {
|
||||
rng = (rng * RNG_A + RNG_C) % RNG_M
|
||||
(rng % PIECES.size) + 1
|
||||
}
|
||||
|
||||
val state: GameState = GameState(
|
||||
nextPieceId(),
|
||||
nextPieceId(),
|
||||
nextPieceId(),
|
||||
(boardW / 2) - 2,
|
||||
-1,
|
||||
)
|
||||
var prevFrameLines: List<String> = []
|
||||
|
||||
val gameMutex = Mutex()
|
||||
var hasResizeEvent = false
|
||||
|
||||
val rawModeEnabled = Console.setRawMode(true)
|
||||
if (!rawModeEnabled) {
|
||||
println("Raw keyboard mode is not available in this terminal/runtime.")
|
||||
println("Use jlyng in an interactive terminal with raw input support.")
|
||||
void
|
||||
} else {
|
||||
val useColor = Console.ansiLevel() != "NONE"
|
||||
Console.enterAltScreen()
|
||||
Console.setCursorVisible(false)
|
||||
clearAndHome()
|
||||
|
||||
fun applyKeyInput(s: GameState, key: String): Void {
|
||||
if (key == "__CTRL_C__" || key == "q" || key == "Q" || key == "Escape") {
|
||||
s.running = false
|
||||
}
|
||||
else if (key == "ArrowLeft" || key == "a" || key == "A") {
|
||||
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px - 1, s.py)) s.px = s.px - 1
|
||||
}
|
||||
else if (key == "ArrowRight" || key == "d" || key == "D") {
|
||||
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px + 1, s.py)) s.px = s.px + 1
|
||||
}
|
||||
else if (key == "ArrowUp" || key == "w" || key == "W") {
|
||||
val rr: RotateResult = tryRotateCw(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)
|
||||
if (rr.ok) {
|
||||
s.rot = rr.rot
|
||||
s.px = rr.px
|
||||
}
|
||||
}
|
||||
else if (key == "ArrowDown" || key == "s" || key == "S") {
|
||||
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
|
||||
s.py = s.py + 1
|
||||
s.score = s.score + 1
|
||||
}
|
||||
}
|
||||
else if (key == " ") {
|
||||
while (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
|
||||
s.py = s.py + 1
|
||||
s.score = s.score + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var inputRunning = true
|
||||
launch {
|
||||
while (inputRunning) {
|
||||
try {
|
||||
for (ev in Console.events()) {
|
||||
if (!inputRunning) break
|
||||
if (ev is ConsoleKeyEvent) {
|
||||
val ke = ev as ConsoleKeyEvent
|
||||
if (ke.type == "keydown") {
|
||||
val key = ke.key
|
||||
val ctrl = ke.ctrl
|
||||
gameMutex.withLock {
|
||||
val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key
|
||||
applyKeyInput(state, mapped)
|
||||
}
|
||||
}
|
||||
} else if (ev is ConsoleResizeEvent) {
|
||||
gameMutex.withLock {
|
||||
hasResizeEvent = true
|
||||
prevFrameLines = []
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
// Keep game alive: transient console-event failures should not force quit.
|
||||
if (!inputRunning) break
|
||||
Console.setRawMode(true)
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pollLoopFrame(): LoopFrame? {
|
||||
val g = Console.geometry()
|
||||
val c = g?.columns ?: 0
|
||||
val r = g?.rows ?: 0
|
||||
if (c < MIN_COLS || r < MIN_ROWS) {
|
||||
waitForMinimumSize(MIN_COLS, MIN_ROWS)
|
||||
clearAndHome()
|
||||
prevFrameLines = []
|
||||
return null
|
||||
}
|
||||
|
||||
var resized = false
|
||||
gameMutex.withLock {
|
||||
resized = hasResizeEvent
|
||||
hasResizeEvent = false
|
||||
}
|
||||
|
||||
val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH
|
||||
val contentRows = boardH + 1
|
||||
val requiredCols = maxInt(MIN_COLS, contentCols)
|
||||
val requiredRows = maxInt(MIN_ROWS, contentRows)
|
||||
if (c < requiredCols || r < requiredRows) {
|
||||
waitForMinimumSize(requiredCols, requiredRows)
|
||||
clearAndHome()
|
||||
prevFrameLines = []
|
||||
return null
|
||||
}
|
||||
|
||||
val originCol = maxInt(1, ((c - contentCols) / 2) + 1)
|
||||
val originRow = maxInt(1, ((r - contentRows) / 2) + 1)
|
||||
LoopFrame(resized, originRow, originCol)
|
||||
}
|
||||
|
||||
fun advanceGravity(s: GameState, frame: Int): Int {
|
||||
s.level = 1 + (s.totalLines / LEVEL_LINES_STEP)
|
||||
val dropEvery = maxInt(DROP_FRAMES_MIN, DROP_FRAMES_BASE - s.level)
|
||||
|
||||
var nextFrame = frame + 1
|
||||
if (nextFrame < dropEvery) return nextFrame
|
||||
nextFrame = 0
|
||||
|
||||
if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1)) {
|
||||
s.py = s.py + 1
|
||||
return nextFrame
|
||||
}
|
||||
|
||||
lockPiece(board, s.pieceId, s.rot, s.px, s.py)
|
||||
|
||||
val cleared = clearCompletedLines(board, boardW, boardH)
|
||||
if (cleared > 0) {
|
||||
s.totalLines = s.totalLines + cleared
|
||||
s.score = s.score + scoreForLines(cleared, s.level)
|
||||
}
|
||||
|
||||
s.pieceId = s.nextId
|
||||
s.nextId = s.next2Id
|
||||
s.next2Id = nextPieceId()
|
||||
s.rot = 0
|
||||
s.px = (boardW / 2) - 2
|
||||
s.py = -1
|
||||
|
||||
if (!canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)) {
|
||||
s.gameOver = true
|
||||
}
|
||||
nextFrame
|
||||
}
|
||||
|
||||
try {
|
||||
if (!canPlace(board, boardW, boardH, state.pieceId, state.rot, state.px, state.py)) {
|
||||
state.gameOver = true
|
||||
}
|
||||
|
||||
var frame = 0
|
||||
var shouldStop = false
|
||||
var prevOriginRow = -1
|
||||
var prevOriginCol = -1
|
||||
while (!shouldStop) {
|
||||
val frameData = pollLoopFrame()
|
||||
if (frameData == null) {
|
||||
frame = 0
|
||||
continue
|
||||
}
|
||||
|
||||
gameMutex.withLock {
|
||||
if (!state.running || state.gameOver) {
|
||||
shouldStop = true
|
||||
} else {
|
||||
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol
|
||||
if (frameData.resized || movedOrigin) {
|
||||
clearAndHome()
|
||||
prevFrameLines = []
|
||||
}
|
||||
prevOriginRow = frameData.originRow
|
||||
prevOriginCol = frameData.originCol
|
||||
frame = advanceGravity(state, frame)
|
||||
prevFrameLines = render(
|
||||
state,
|
||||
board,
|
||||
boardW,
|
||||
boardH,
|
||||
prevFrameLines,
|
||||
frameData.originRow,
|
||||
frameData.originCol,
|
||||
useColor
|
||||
)
|
||||
}
|
||||
}
|
||||
Console.flush()
|
||||
delay(FRAME_DELAY_MS)
|
||||
}
|
||||
} finally {
|
||||
inputRunning = false
|
||||
Console.setRawMode(false)
|
||||
Console.setCursorVisible(true)
|
||||
Console.leaveAltScreen()
|
||||
Console.flush()
|
||||
}
|
||||
|
||||
if (state.gameOver) {
|
||||
println("Game over.")
|
||||
} else {
|
||||
println("Bye.")
|
||||
}
|
||||
println("Score: %s"(state.score))
|
||||
println("Lines: %s"(state.totalLines))
|
||||
println("Level: %s"(state.level))
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -0,0 +1,600 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.console
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.miniast.*
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjIterable
|
||||
import net.sergeych.lyng.obj.ObjIterationFinishedException
|
||||
import net.sergeych.lyng.obj.ObjIterator
|
||||
import net.sergeych.lyng.obj.ObjNull
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
import net.sergeych.lyng.obj.requiredArg
|
||||
import net.sergeych.lyng.obj.thisAs
|
||||
import net.sergeych.lyng.obj.toObj
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyng.raiseIllegalOperation
|
||||
import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.console.ConsoleEvent
|
||||
import net.sergeych.lyngio.console.ConsoleEventSource
|
||||
import net.sergeych.lyngio.console.LyngConsole
|
||||
import net.sergeych.lyngio.console.getSystemConsole
|
||||
import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException
|
||||
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
|
||||
import net.sergeych.lyngio.console.security.LyngConsoleSecured
|
||||
|
||||
/**
|
||||
* Install Lyng module `lyng.io.console` into the given scope's ImportManager.
|
||||
*/
|
||||
fun createConsoleModule(policy: ConsoleAccessPolicy, scope: Scope): Boolean =
|
||||
createConsoleModule(policy, scope.importManager)
|
||||
|
||||
fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createConsoleModule(policy, scope)
|
||||
|
||||
/** Same as [createConsoleModule] but with explicit [ImportManager]. */
|
||||
fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean {
|
||||
val name = "lyng.io.console"
|
||||
if (manager.packageNames.contains(name)) return false
|
||||
|
||||
manager.addPackage(name) { module ->
|
||||
buildConsoleModule(module, policy)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
|
||||
|
||||
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
|
||||
val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy)
|
||||
|
||||
val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {}
|
||||
|
||||
consoleType.apply {
|
||||
addClassFnDoc(
|
||||
name = "isSupported",
|
||||
doc = "Whether console control API is supported on this platform.",
|
||||
returns = type("lyng.Bool"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
ObjBool(console.isSupported)
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "isTty",
|
||||
doc = "Whether current stdout is attached to an interactive TTY.",
|
||||
returns = type("lyng.Bool"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
ObjBool(console.isTty())
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "ansiLevel",
|
||||
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
|
||||
returns = type("lyng.String"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
ObjString(console.ansiLevel().name)
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "geometry",
|
||||
doc = "Current terminal geometry or null.",
|
||||
returns = type("ConsoleGeometry", nullable = true),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "details",
|
||||
doc = "Get consolidated console details.",
|
||||
returns = type("ConsoleDetails"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
val tty = console.isTty()
|
||||
val ansi = console.ansiLevel()
|
||||
val geometry = console.geometry()
|
||||
ObjConsoleDetails(
|
||||
supported = console.isSupported,
|
||||
isTty = tty,
|
||||
ansiLevel = ansi.name,
|
||||
geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "write",
|
||||
doc = "Write text directly to console output.",
|
||||
params = listOf(ParamDoc("text", type("lyng.String"))),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
val text = requiredArg<ObjString>(0).value
|
||||
console.write(text)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "flush",
|
||||
doc = "Flush console output buffer.",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.flush()
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "home",
|
||||
doc = "Move cursor to home position (1,1).",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.write("\u001B[H")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "clear",
|
||||
doc = "Clear the visible screen buffer.",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.write("\u001B[2J")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "moveTo",
|
||||
doc = "Move cursor to 1-based row and column.",
|
||||
params = listOf(
|
||||
ParamDoc("row", type("lyng.Int")),
|
||||
ParamDoc("column", type("lyng.Int")),
|
||||
),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value
|
||||
val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value
|
||||
console.write("\u001B[${row};${col}H")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "clearLine",
|
||||
doc = "Clear the current line.",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.write("\u001B[2K")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "enterAltScreen",
|
||||
doc = "Switch to terminal alternate screen buffer.",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.write("\u001B[?1049h")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "leaveAltScreen",
|
||||
doc = "Return from alternate screen buffer to normal screen.",
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.write("\u001B[?1049l")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "setCursorVisible",
|
||||
doc = "Show or hide the terminal cursor.",
|
||||
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
val visible = requiredArg<ObjBool>(0).value
|
||||
console.write(if (visible) "\u001B[?25h" else "\u001B[?25l")
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "events",
|
||||
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
|
||||
returns = type("ConsoleEventStream"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
console.events().toConsoleEventStream()
|
||||
}
|
||||
}
|
||||
|
||||
addClassFnDoc(
|
||||
name = "setRawMode",
|
||||
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
|
||||
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
|
||||
returns = type("lyng.Bool"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
consoleGuard {
|
||||
val enabled = requiredArg<ObjBool>(0).value
|
||||
ObjBool(console.setRawMode(enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.addConstDoc(
|
||||
name = "Console",
|
||||
value = consoleType,
|
||||
doc = "Console runtime API.",
|
||||
type = type("Console"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleGeometry",
|
||||
value = ObjConsoleGeometry.type,
|
||||
doc = "Terminal geometry.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleDetails",
|
||||
value = ObjConsoleDetails.type,
|
||||
doc = "Consolidated console capability details.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleEvent",
|
||||
value = ObjConsoleEvent.type,
|
||||
doc = "Base class for console events.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleResizeEvent",
|
||||
value = ObjConsoleResizeEvent.type,
|
||||
doc = "Terminal resize event.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleKeyEvent",
|
||||
value = ObjConsoleKeyEvent.typeObj,
|
||||
doc = "Keyboard event.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "ConsoleEventStream",
|
||||
value = ObjConsoleEventStream.type,
|
||||
doc = "Endless iterable stream of console events.",
|
||||
type = type("lyng.Class"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
}
|
||||
|
||||
private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: ConsoleAccessDeniedException) {
|
||||
raiseIllegalOperation(e.reasonDetail ?: "console access denied")
|
||||
} catch (e: Exception) {
|
||||
raiseIllegalOperation(e.message ?: "console error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConsoleEventSource.toConsoleEventStream(): ObjConsoleEventStream {
|
||||
return ObjConsoleEventStream(this)
|
||||
}
|
||||
|
||||
private class ObjConsoleEventStream(
|
||||
private val source: ConsoleEventSource,
|
||||
) : Obj() {
|
||||
override val objClass: net.sergeych.lyng.obj.ObjClass
|
||||
get() = type
|
||||
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply {
|
||||
addFnDoc(
|
||||
name = "iterator",
|
||||
doc = "Create an iterator over incoming console events.",
|
||||
returns = type("lyng.Iterator"),
|
||||
moduleName = "lyng.io.console",
|
||||
) {
|
||||
val stream = thisAs<ObjConsoleEventStream>()
|
||||
ObjConsoleEventIterator(stream.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjConsoleEventIterator(
|
||||
private val source: ConsoleEventSource,
|
||||
) : Obj() {
|
||||
private var cached: Obj? = null
|
||||
private var closed = false
|
||||
|
||||
override val objClass: net.sergeych.lyng.obj.ObjClass
|
||||
get() = type
|
||||
|
||||
private suspend fun ensureCached(): Boolean {
|
||||
if (closed) return false
|
||||
if (cached != null) return true
|
||||
val event = source.nextEvent()
|
||||
if (event == null) {
|
||||
closeSource()
|
||||
return false
|
||||
}
|
||||
cached = event.toObjEvent()
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun closeSource() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
source.close()
|
||||
}
|
||||
|
||||
suspend fun hasNext(): Boolean = ensureCached()
|
||||
|
||||
suspend fun next(scope: Scope): Obj {
|
||||
if (!ensureCached()) {
|
||||
scope.raiseError(ObjIterationFinishedException(scope))
|
||||
}
|
||||
val out = cached ?: scope.raiseError("console iterator internal error: missing cached event")
|
||||
cached = null
|
||||
return out
|
||||
}
|
||||
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply {
|
||||
addFnDoc(
|
||||
name = "hasNext",
|
||||
doc = "Whether another console event is available.",
|
||||
returns = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
) {
|
||||
thisAs<ObjConsoleEventIterator>().hasNext().toObj()
|
||||
}
|
||||
addFnDoc(
|
||||
name = "next",
|
||||
doc = "Return the next console event.",
|
||||
returns = type("ConsoleEvent"),
|
||||
moduleName = "lyng.io.console",
|
||||
) {
|
||||
thisAs<ObjConsoleEventIterator>().next(requireScope())
|
||||
}
|
||||
addFnDoc(
|
||||
name = "cancelIteration",
|
||||
doc = "Stop reading console events and release resources.",
|
||||
returns = type("lyng.Void"),
|
||||
moduleName = "lyng.io.console",
|
||||
) {
|
||||
thisAs<ObjConsoleEventIterator>().closeSource()
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ConsoleEvent.toObjEvent(): Obj = when (this) {
|
||||
is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows)
|
||||
is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = "keydown", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
|
||||
is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = "keyup", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
|
||||
}
|
||||
|
||||
private abstract class ObjConsoleEventBase(
|
||||
private val eventType: String,
|
||||
final override val objClass: net.sergeych.lyng.obj.ObjClass,
|
||||
) : Obj() {
|
||||
fun eventTypeName(): String = eventType
|
||||
}
|
||||
|
||||
private class ObjConsoleEvent : ObjConsoleEventBase("event", type) {
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply {
|
||||
addPropertyDoc(
|
||||
name = "type",
|
||||
doc = "Event type string: resize, keydown, keyup.",
|
||||
type = type("lyng.String"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { ObjString((this.thisObj as ObjConsoleEventBase).eventTypeName()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjConsoleResizeEvent(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
) : ObjConsoleEventBase("resize", type) {
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply {
|
||||
addPropertyDoc(
|
||||
name = "columns",
|
||||
doc = "Terminal width in character cells.",
|
||||
type = type("lyng.Int"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "rows",
|
||||
doc = "Terminal height in character cells.",
|
||||
type = type("lyng.Int"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjConsoleKeyEvent(
|
||||
type: String,
|
||||
val key: String,
|
||||
val code: String?,
|
||||
val ctrl: Boolean,
|
||||
val alt: Boolean,
|
||||
val shift: Boolean,
|
||||
val meta: Boolean,
|
||||
) : ObjConsoleEventBase(type, typeObj) {
|
||||
companion object {
|
||||
val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply {
|
||||
addPropertyDoc(
|
||||
name = "key",
|
||||
doc = "Logical key name (e.g. ArrowLeft, a, Escape).",
|
||||
type = type("lyng.String"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "code",
|
||||
doc = "Optional hardware/code identifier.",
|
||||
type = type("lyng.String", nullable = true),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = {
|
||||
val code = (this.thisObj as ObjConsoleKeyEvent).code
|
||||
code?.let(::ObjString) ?: ObjNull
|
||||
}
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "ctrl",
|
||||
doc = "Whether Ctrl modifier is pressed.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "alt",
|
||||
doc = "Whether Alt modifier is pressed.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "shift",
|
||||
doc = "Whether Shift modifier is pressed.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "meta",
|
||||
doc = "Whether Meta/Super modifier is pressed.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjConsoleGeometry(
|
||||
val columns: Int,
|
||||
val rows: Int,
|
||||
) : Obj() {
|
||||
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
|
||||
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply {
|
||||
addPropertyDoc(
|
||||
name = "columns",
|
||||
doc = "Terminal width in character cells.",
|
||||
type = type("lyng.Int"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "rows",
|
||||
doc = "Terminal height in character cells.",
|
||||
type = type("lyng.Int"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ObjConsoleDetails(
|
||||
val supported: Boolean,
|
||||
val isTty: Boolean,
|
||||
val ansiLevel: String,
|
||||
val geometry: ObjConsoleGeometry?,
|
||||
) : Obj() {
|
||||
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
|
||||
|
||||
companion object {
|
||||
val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply {
|
||||
addPropertyDoc(
|
||||
name = "supported",
|
||||
doc = "Whether console API is supported.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "isTty",
|
||||
doc = "Whether output is connected to a TTY.",
|
||||
type = type("lyng.Bool"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "ansiLevel",
|
||||
doc = "Detected ANSI color capability level.",
|
||||
type = type("lyng.String"),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { ObjString((this.thisObj as ObjConsoleDetails).ansiLevel) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "geometry",
|
||||
doc = "Current terminal geometry or null.",
|
||||
type = type("ConsoleGeometry", nullable = true),
|
||||
moduleName = "lyng.io.console",
|
||||
getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.console
|
||||
|
||||
import com.github.ajalt.mordant.input.KeyboardEvent
|
||||
import com.github.ajalt.mordant.input.RawModeScope
|
||||
import com.github.ajalt.mordant.input.enterRawModeOrNull
|
||||
import com.github.ajalt.mordant.rendering.AnsiLevel
|
||||
import com.github.ajalt.mordant.terminal.Terminal
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* Mordant-backed console runtime implementation.
|
||||
*/
|
||||
object MordantLyngConsole : LyngConsole {
|
||||
private val terminal: Terminal? by lazy {
|
||||
runCatching { Terminal() }.getOrNull()
|
||||
}
|
||||
|
||||
private val stateMutex = Mutex()
|
||||
private var rawModeRequested: Boolean = false
|
||||
private var rawModeScope: RawModeScope? = null
|
||||
|
||||
private suspend fun forceRawModeReset(t: Terminal): Boolean = stateMutex.withLock {
|
||||
if (!rawModeRequested) return@withLock false
|
||||
runCatching { rawModeScope?.close() }
|
||||
.onFailure { consoleFlowDebug("forceRawModeReset: close failed", it) }
|
||||
rawModeScope = null
|
||||
rawModeRequested = false
|
||||
if (!t.terminalInfo.inputInteractive) return@withLock false
|
||||
val reopened = t.enterRawModeOrNull()
|
||||
rawModeScope = reopened
|
||||
rawModeRequested = reopened != null
|
||||
reopened != null
|
||||
}
|
||||
|
||||
override val isSupported: Boolean
|
||||
get() = terminal != null
|
||||
|
||||
override suspend fun isTty(): Boolean {
|
||||
val t = terminal ?: return false
|
||||
return t.terminalInfo.outputInteractive
|
||||
}
|
||||
|
||||
override suspend fun geometry(): ConsoleGeometry? {
|
||||
val t = terminal ?: return null
|
||||
val size = t.updateSize()
|
||||
return ConsoleGeometry(size.width, size.height)
|
||||
}
|
||||
|
||||
override suspend fun ansiLevel(): ConsoleAnsiLevel {
|
||||
val t = terminal ?: return ConsoleAnsiLevel.NONE
|
||||
return when (t.terminalInfo.ansiLevel) {
|
||||
AnsiLevel.NONE -> ConsoleAnsiLevel.NONE
|
||||
AnsiLevel.ANSI16 -> ConsoleAnsiLevel.BASIC16
|
||||
AnsiLevel.ANSI256 -> ConsoleAnsiLevel.ANSI256
|
||||
AnsiLevel.TRUECOLOR -> ConsoleAnsiLevel.TRUECOLOR
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(text: String) {
|
||||
terminal?.rawPrint(text)
|
||||
}
|
||||
|
||||
override suspend fun flush() {
|
||||
// Mordant prints via platform streams immediately.
|
||||
}
|
||||
|
||||
override fun events(): ConsoleEventSource {
|
||||
val t = terminal ?: return object : ConsoleEventSource {
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
|
||||
|
||||
override suspend fun close() {}
|
||||
}
|
||||
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
|
||||
val sourceState = Mutex()
|
||||
var running = true
|
||||
|
||||
globalLaunch {
|
||||
var lastWidth = t.updateSize().width
|
||||
var lastHeight = t.updateSize().height
|
||||
val startMark = TimeSource.Monotonic.markNow()
|
||||
var lastHeartbeatMark = startMark
|
||||
var loops = 0L
|
||||
var readAttempts = 0L
|
||||
var readFailures = 0L
|
||||
var keyEvents = 0L
|
||||
var resizeEvents = 0L
|
||||
var rawNullLoops = 0L
|
||||
var lastKeyMark = startMark
|
||||
var lastRawRecoveryMark = startMark
|
||||
|
||||
consoleFlowDebug("events: collector started")
|
||||
try {
|
||||
while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
|
||||
loops += 1
|
||||
val currentSize = runCatching { t.updateSize() }.getOrNull()
|
||||
if (currentSize == null) {
|
||||
delay(150)
|
||||
continue
|
||||
}
|
||||
if (currentSize.width != lastWidth || currentSize.height != lastHeight) {
|
||||
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
|
||||
lastWidth = currentSize.width
|
||||
lastHeight = currentSize.height
|
||||
resizeEvents += 1
|
||||
}
|
||||
|
||||
val raw = stateMutex.withLock {
|
||||
if (!rawModeRequested) {
|
||||
null
|
||||
} else {
|
||||
// Recover raw scope lazily if it was dropped due to an earlier read failure.
|
||||
if (rawModeScope == null) {
|
||||
rawModeScope = t.enterRawModeOrNull()
|
||||
if (rawModeScope == null) {
|
||||
consoleFlowDebug("events: failed to reopen raw mode scope")
|
||||
} else {
|
||||
consoleFlowDebug("events: raw mode scope reopened")
|
||||
}
|
||||
}
|
||||
rawModeScope
|
||||
}
|
||||
}
|
||||
if (raw == null || !t.terminalInfo.inputInteractive) {
|
||||
rawNullLoops += 1
|
||||
delay(150)
|
||||
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
|
||||
consoleFlowDebug(
|
||||
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
|
||||
)
|
||||
lastHeartbeatMark = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
readAttempts += 1
|
||||
val readResult = runCatching { raw.readEventOrNull(150.milliseconds) }
|
||||
if (readResult.isFailure) {
|
||||
readFailures += 1
|
||||
consoleFlowDebug("events: readEventOrNull failed; resetting raw scope", readResult.exceptionOrNull())
|
||||
// Raw scope became invalid; close and force reopen on next iteration.
|
||||
stateMutex.withLock {
|
||||
runCatching { rawModeScope?.close() }
|
||||
rawModeScope = null
|
||||
}
|
||||
delay(50)
|
||||
continue
|
||||
}
|
||||
val ev = readResult.getOrNull()
|
||||
|
||||
val resized = runCatching { t.updateSize() }.getOrNull()
|
||||
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) {
|
||||
out.trySend(ConsoleEvent.Resize(resized.width, resized.height))
|
||||
lastWidth = resized.width
|
||||
lastHeight = resized.height
|
||||
}
|
||||
|
||||
when (ev) {
|
||||
is KeyboardEvent -> {
|
||||
keyEvents += 1
|
||||
lastKeyMark = TimeSource.Monotonic.markNow()
|
||||
out.trySend(
|
||||
ConsoleEvent.KeyDown(
|
||||
key = ev.key,
|
||||
code = null,
|
||||
ctrl = ev.ctrl,
|
||||
alt = ev.alt,
|
||||
shift = ev.shift,
|
||||
meta = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Mouse/other events are ignored in Lyng console v1.
|
||||
}
|
||||
}
|
||||
|
||||
// Some terminals silently stop delivering keyboard events while raw reads keep succeeding.
|
||||
// If we had keys before and then prolonged key inactivity, proactively recycle raw scope.
|
||||
if (keyEvents > 0L &&
|
||||
lastKeyMark.elapsedNow() >= 4.seconds &&
|
||||
lastRawRecoveryMark.elapsedNow() >= 4.seconds
|
||||
) {
|
||||
if (rawModeRequested) {
|
||||
consoleFlowDebug("events: key inactivity detected; forcing raw reset")
|
||||
val resetOk = forceRawModeReset(t)
|
||||
if (resetOk) {
|
||||
consoleFlowDebug("events: raw reset succeeded during inactivity recovery")
|
||||
lastKeyMark = TimeSource.Monotonic.markNow()
|
||||
} else {
|
||||
consoleFlowDebug("events: raw reset failed during inactivity recovery")
|
||||
}
|
||||
lastRawRecoveryMark = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
}
|
||||
|
||||
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
|
||||
consoleFlowDebug(
|
||||
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
|
||||
)
|
||||
lastHeartbeatMark = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
consoleFlowDebug("events: collector cancelled (normal)")
|
||||
// normal
|
||||
} catch (e: ScriptFlowIsNoMoreCollected) {
|
||||
consoleFlowDebug("events: collector stopped by flow consumer (normal)")
|
||||
// normal
|
||||
} catch (e: Exception) {
|
||||
consoleFlowDebug("events: collector loop failed", e)
|
||||
// terminate event source loop
|
||||
} finally {
|
||||
consoleFlowDebug(
|
||||
"events: collector ended uptime=${startMark.elapsedNow().inWholeMilliseconds}ms loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested"
|
||||
)
|
||||
out.close()
|
||||
}
|
||||
}
|
||||
|
||||
return object : ConsoleEventSource {
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
|
||||
if (timeoutMs <= 0L) {
|
||||
return out.receiveCatching().getOrNull()
|
||||
}
|
||||
return withTimeoutOrNull(timeoutMs.milliseconds) {
|
||||
out.receiveCatching().getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
sourceState.withLock { running = false }
|
||||
out.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRawMode(enabled: Boolean): Boolean {
|
||||
val t = terminal ?: return false
|
||||
return stateMutex.withLock {
|
||||
if (enabled) {
|
||||
if (!t.terminalInfo.inputInteractive) return@withLock false
|
||||
if (rawModeRequested) return@withLock false
|
||||
val scope = t.enterRawModeOrNull() ?: return@withLock false
|
||||
rawModeScope = scope
|
||||
rawModeRequested = true
|
||||
consoleFlowDebug("setRawMode(true): enabled")
|
||||
true
|
||||
} else {
|
||||
val hadRaw = rawModeRequested || rawModeScope != null
|
||||
rawModeRequested = false
|
||||
val scope = rawModeScope
|
||||
rawModeScope = null
|
||||
runCatching { scope?.close() }
|
||||
.onFailure { consoleFlowDebug("setRawMode(false): close failed", it) }
|
||||
consoleFlowDebug("setRawMode(false): disabled hadRaw=$hadRaw")
|
||||
hadRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,496 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.console
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jline.terminal.Attributes
|
||||
import org.jline.terminal.Terminal
|
||||
import org.jline.terminal.TerminalBuilder
|
||||
import org.jline.utils.NonBlockingReader
|
||||
import java.io.EOFException
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* JVM console implementation:
|
||||
* - output/capabilities/input use a single JLine terminal instance
|
||||
* to avoid dual-terminal contention.
|
||||
*/
|
||||
object JvmLyngConsole : LyngConsole {
|
||||
private const val DEBUG_REVISION = "jline-r26-force-rebuild-on-noop-recovery-2026-03-19"
|
||||
private val codeSourceLocation: String by lazy {
|
||||
runCatching {
|
||||
JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString()
|
||||
}.getOrNull() ?: "<unknown>"
|
||||
}
|
||||
|
||||
private val terminalRef = AtomicReference<Terminal?>(null)
|
||||
private val terminalInitLock = Any()
|
||||
|
||||
private fun currentTerminal(): Terminal? {
|
||||
val existing = terminalRef.get()
|
||||
if (existing != null) return existing
|
||||
synchronized(terminalInitLock) {
|
||||
val already = terminalRef.get()
|
||||
if (already != null) return already
|
||||
val created = buildTerminal()
|
||||
if (created != null) terminalRef.set(created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTerminal(): Terminal? {
|
||||
System.setProperty(TerminalBuilder.PROP_DISABLE_DEPRECATED_PROVIDER_WARNING, "true")
|
||||
|
||||
val providerOrders = listOf(
|
||||
"exec",
|
||||
"exec,ffm",
|
||||
null,
|
||||
)
|
||||
for (providers in providerOrders) {
|
||||
val terminal = runCatching {
|
||||
val builder = TerminalBuilder.builder().system(true)
|
||||
if (providers != null) builder.providers(providers)
|
||||
builder.build()
|
||||
}.onFailure {
|
||||
if (providers != null) {
|
||||
consoleFlowDebug("jline-events: terminal build failed providers=$providers", it)
|
||||
} else {
|
||||
consoleFlowDebug("jline-events: terminal build failed default providers", it)
|
||||
}
|
||||
}.getOrNull()
|
||||
if (terminal != null) {
|
||||
val termType = terminal.type.lowercase(Locale.getDefault())
|
||||
if (termType.contains("dumb")) {
|
||||
consoleFlowDebug("jline-events: terminal rejected providers=${providers ?: "<default>"} type=${terminal.type}")
|
||||
runCatching { terminal.close() }
|
||||
continue
|
||||
}
|
||||
consoleFlowDebug("jline-events: terminal built providers=${providers ?: "<default>"} type=${terminal.type}")
|
||||
consoleFlowDebug("jline-events: runtime-marker rev=$DEBUG_REVISION codeSource=$codeSourceLocation")
|
||||
return terminal
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val stateMutex = Mutex()
|
||||
private var rawModeRequested: Boolean = false
|
||||
private var rawSavedAttributes: Attributes? = null
|
||||
|
||||
private fun enforceRawReadAttrs(term: Terminal) {
|
||||
runCatching {
|
||||
val attrs = term.attributes
|
||||
attrs.setLocalFlag(Attributes.LocalFlag.ICANON, false)
|
||||
attrs.setLocalFlag(Attributes.LocalFlag.ECHO, false)
|
||||
attrs.setControlChar(Attributes.ControlChar.VMIN, 0)
|
||||
attrs.setControlChar(Attributes.ControlChar.VTIME, 1)
|
||||
term.setAttributes(attrs)
|
||||
}.onFailure {
|
||||
consoleFlowDebug("jline-events: enforceRawReadAttrs failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
override val isSupported: Boolean
|
||||
get() = currentTerminal() != null
|
||||
|
||||
override suspend fun isTty(): Boolean {
|
||||
val term = currentTerminal() ?: return false
|
||||
return !term.type.lowercase(Locale.getDefault()).contains("dumb")
|
||||
}
|
||||
|
||||
override suspend fun geometry(): ConsoleGeometry? {
|
||||
val term = currentTerminal() ?: return null
|
||||
val size = runCatching { term.size }.getOrNull() ?: return null
|
||||
if (size.columns <= 0 || size.rows <= 0) return null
|
||||
return ConsoleGeometry(size.columns, size.rows)
|
||||
}
|
||||
|
||||
override suspend fun ansiLevel(): ConsoleAnsiLevel {
|
||||
val colorTerm = (System.getenv("COLORTERM") ?: "").lowercase(Locale.getDefault())
|
||||
val term = (System.getenv("TERM") ?: "").lowercase(Locale.getDefault())
|
||||
return when {
|
||||
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
|
||||
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
|
||||
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
|
||||
else -> ConsoleAnsiLevel.NONE
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(text: String) {
|
||||
val term = currentTerminal() ?: return
|
||||
term.writer().print(text)
|
||||
}
|
||||
|
||||
override suspend fun flush() {
|
||||
val term = currentTerminal() ?: return
|
||||
term.writer().flush()
|
||||
}
|
||||
|
||||
override fun events(): ConsoleEventSource {
|
||||
var activeTerm = currentTerminal() ?: return object : ConsoleEventSource {
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
|
||||
|
||||
override suspend fun close() {}
|
||||
}
|
||||
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
|
||||
val keyEvents = AtomicLong(0L)
|
||||
val keyCodesRead = AtomicLong(0L)
|
||||
val keySendFailures = AtomicLong(0L)
|
||||
val readFailures = AtomicLong(0L)
|
||||
val readerRecoveries = AtomicLong(0L)
|
||||
var lastHeartbeat = TimeSource.Monotonic.markNow()
|
||||
val keyLoopRunning = AtomicBoolean(true)
|
||||
val keyLoopCount = AtomicLong(0L)
|
||||
val keyReadStartNs = AtomicLong(0L)
|
||||
val keyReadEndNs = AtomicLong(0L)
|
||||
val lastKeyReadNs = AtomicLong(System.nanoTime())
|
||||
val lastRecoveryNs = AtomicLong(0L)
|
||||
val recoveryRequested = AtomicBoolean(false)
|
||||
val running = AtomicBoolean(true)
|
||||
var winchHandler: Terminal.SignalHandler? = null
|
||||
var reader = activeTerm.reader()
|
||||
var keyThread: Thread? = null
|
||||
var heartbeatThread: Thread? = null
|
||||
|
||||
fun emitResize() {
|
||||
val size = runCatching { activeTerm.size }.getOrNull() ?: return
|
||||
out.trySend(ConsoleEvent.Resize(size.columns, size.rows))
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
running.set(false)
|
||||
keyLoopRunning.set(false)
|
||||
runCatching { reader.shutdown() }
|
||||
runCatching {
|
||||
if (winchHandler != null) {
|
||||
activeTerm.handle(Terminal.Signal.WINCH, winchHandler)
|
||||
}
|
||||
}.onFailure {
|
||||
consoleFlowDebug("jline-events: WINCH handler restore failed", it)
|
||||
}
|
||||
runCatching { keyThread?.interrupt() }
|
||||
runCatching { heartbeatThread?.interrupt() }
|
||||
out.close()
|
||||
}
|
||||
|
||||
fun installWinchHandler() {
|
||||
winchHandler = runCatching {
|
||||
activeTerm.handle(Terminal.Signal.WINCH) {
|
||||
emitResize()
|
||||
}
|
||||
}.onFailure {
|
||||
consoleFlowDebug("jline-events: WINCH handler install failed", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun tryRebuildTerminal(): Boolean {
|
||||
val oldTerm = activeTerm
|
||||
val rebuilt = runCatching {
|
||||
synchronized(terminalInitLock) {
|
||||
if (terminalRef.get() === oldTerm) {
|
||||
terminalRef.set(null)
|
||||
}
|
||||
}
|
||||
runCatching { oldTerm.close() }
|
||||
.onFailure { consoleFlowDebug("jline-events: old terminal close failed during rebuild", it) }
|
||||
currentTerminal()
|
||||
}.onFailure {
|
||||
consoleFlowDebug("jline-events: terminal rebuild failed", it)
|
||||
}.getOrNull() ?: return false
|
||||
if (rebuilt === oldTerm) {
|
||||
consoleFlowDebug("jline-events: terminal rebuild returned same terminal instance")
|
||||
return false
|
||||
}
|
||||
activeTerm = rebuilt
|
||||
reader = activeTerm.reader()
|
||||
val rawRequestedNow = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
|
||||
rawModeRequested
|
||||
} finally {
|
||||
stateMutex.unlock()
|
||||
}
|
||||
if (rawRequestedNow) {
|
||||
val saved = runCatching { activeTerm.enterRawMode() }.getOrNull()
|
||||
if (saved != null) {
|
||||
enforceRawReadAttrs(activeTerm)
|
||||
if (runCatching { stateMutex.tryLock() }.getOrNull() == true) {
|
||||
try {
|
||||
rawSavedAttributes = saved
|
||||
} finally {
|
||||
stateMutex.unlock()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consoleFlowDebug("jline-events: terminal rebuild succeeded but enterRawMode failed")
|
||||
}
|
||||
}
|
||||
installWinchHandler()
|
||||
emitResize()
|
||||
consoleFlowDebug("jline-events: terminal rebuilt and rebound")
|
||||
return true
|
||||
}
|
||||
|
||||
consoleFlowDebug("jline-events: collector started rev=$DEBUG_REVISION")
|
||||
emitResize()
|
||||
installWinchHandler()
|
||||
|
||||
keyThread = thread(start = true, isDaemon = true, name = "lyng-jline-key-reader") {
|
||||
consoleFlowDebug("jline-events: key-reader thread started")
|
||||
consoleFlowDebug("jline-events: using NonBlockingReader key path")
|
||||
while (running.get() && keyLoopRunning.get()) {
|
||||
keyLoopCount.incrementAndGet()
|
||||
try {
|
||||
if (recoveryRequested.compareAndSet(true, false)) {
|
||||
val prevReader = reader
|
||||
runCatching { prevReader.shutdown() }
|
||||
.onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) }
|
||||
|
||||
reader = activeTerm.reader()
|
||||
if (reader.hashCode() == prevReader.hashCode()) {
|
||||
consoleFlowDebug("jline-events: reader recovery no-op oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()} -> forcing terminal rebuild")
|
||||
if (!tryRebuildTerminal()) {
|
||||
consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader")
|
||||
}
|
||||
} else {
|
||||
consoleFlowDebug("jline-events: reader recovered oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()}")
|
||||
}
|
||||
|
||||
readerRecoveries.incrementAndGet()
|
||||
lastRecoveryNs.set(System.nanoTime())
|
||||
}
|
||||
|
||||
val isRaw = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
|
||||
rawModeRequested
|
||||
} finally {
|
||||
stateMutex.unlock()
|
||||
}
|
||||
if (!isRaw) {
|
||||
Thread.sleep(20)
|
||||
continue
|
||||
}
|
||||
keyReadStartNs.set(System.nanoTime())
|
||||
val event = readKeyEvent(reader)
|
||||
keyReadEndNs.set(System.nanoTime())
|
||||
if (event == null) {
|
||||
continue
|
||||
}
|
||||
keyCodesRead.incrementAndGet()
|
||||
lastKeyReadNs.set(System.nanoTime())
|
||||
if (out.trySend(event).isSuccess) {
|
||||
keyEvents.incrementAndGet()
|
||||
} else {
|
||||
keySendFailures.incrementAndGet()
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
readFailures.incrementAndGet()
|
||||
consoleFlowDebug("jline-events: blocking read failed", e)
|
||||
try {
|
||||
Thread.sleep(50)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") {
|
||||
while (running.get()) {
|
||||
if (lastHeartbeat.elapsedNow() >= 2.seconds) {
|
||||
val requested = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
|
||||
rawModeRequested
|
||||
} finally {
|
||||
stateMutex.unlock()
|
||||
}
|
||||
val readStartNs = keyReadStartNs.get()
|
||||
val readEndNs = keyReadEndNs.get()
|
||||
val lastKeyNs = lastKeyReadNs.get()
|
||||
val idleMs = if (lastKeyNs > 0L) (System.nanoTime() - lastKeyNs) / 1_000_000L else 0L
|
||||
val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) {
|
||||
(System.nanoTime() - readStartNs) / 1_000_000L
|
||||
} else 0L
|
||||
if (requested && keyCodesRead.get() > 0L && idleMs >= 1400L) {
|
||||
val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L
|
||||
if (sinceRecoveryMs >= 1200L) {
|
||||
recoveryRequested.set(true)
|
||||
consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery")
|
||||
}
|
||||
}
|
||||
consoleFlowDebug(
|
||||
"jline-events: heartbeat keyCodes=${keyCodesRead.get()} keysSent=${keyEvents.get()} sendFailures=${keySendFailures.get()} readFailures=${readFailures.get()} recoveries=${readerRecoveries.get()} rawRequested=$requested keyLoop=${keyLoopCount.get()} readBlockedMs=$readBlockedMs keyIdleMs=$idleMs keyPath=reader"
|
||||
)
|
||||
lastHeartbeat = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
try {
|
||||
Thread.sleep(200)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return object : ConsoleEventSource {
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
|
||||
if (!running.get()) return null
|
||||
if (timeoutMs <= 0L) {
|
||||
return out.receiveCatching().getOrNull()
|
||||
}
|
||||
return withTimeoutOrNull(timeoutMs.milliseconds) {
|
||||
out.receiveCatching().getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
cleanup()
|
||||
consoleFlowDebug(
|
||||
"jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRawMode(enabled: Boolean): Boolean {
|
||||
val term = currentTerminal() ?: return false
|
||||
return stateMutex.withLock {
|
||||
if (enabled) {
|
||||
if (rawModeRequested) return@withLock false
|
||||
val saved = runCatching { term.enterRawMode() }.getOrNull() ?: return@withLock false
|
||||
enforceRawReadAttrs(term)
|
||||
rawSavedAttributes = saved
|
||||
rawModeRequested = true
|
||||
consoleFlowDebug("jline-events: setRawMode(true): enabled")
|
||||
true
|
||||
} else {
|
||||
val hadRaw = rawModeRequested
|
||||
rawModeRequested = false
|
||||
val saved = rawSavedAttributes
|
||||
rawSavedAttributes = null
|
||||
runCatching {
|
||||
if (saved != null) term.setAttributes(saved)
|
||||
}.onFailure {
|
||||
consoleFlowDebug("jline-events: setRawMode(false): restore failed", it)
|
||||
}
|
||||
consoleFlowDebug("jline-events: setRawMode(false): disabled hadRaw=$hadRaw")
|
||||
hadRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readKeyEvent(reader: NonBlockingReader): ConsoleEvent.KeyDown? {
|
||||
val code = reader.read(120L)
|
||||
if (code == NonBlockingReader.READ_EXPIRED) return null
|
||||
if (code < 0) throw EOFException("non-blocking reader returned EOF")
|
||||
return decodeKey(code) { timeout -> readNextCode(reader, timeout) }
|
||||
}
|
||||
|
||||
private fun decodeKey(code: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
|
||||
if (code == 27) {
|
||||
val next = nextCode(25L)
|
||||
if (next == null || next < 0) {
|
||||
return key("Escape")
|
||||
}
|
||||
if (next == '['.code || next == 'O'.code) {
|
||||
val sb = StringBuilder()
|
||||
sb.append(next.toChar())
|
||||
var i = 0
|
||||
while (i < 6) {
|
||||
val c = nextCode(25L) ?: break
|
||||
if (c < 0) break
|
||||
sb.append(c.toChar())
|
||||
if (c.toChar().isLetter() || c == '~'.code) break
|
||||
i += 1
|
||||
}
|
||||
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
|
||||
}
|
||||
// Alt+key
|
||||
val base = decodePlainKey(next)
|
||||
return ConsoleEvent.KeyDown(
|
||||
key = base.key,
|
||||
code = base.code,
|
||||
ctrl = base.ctrl,
|
||||
alt = true,
|
||||
shift = base.shift,
|
||||
meta = false
|
||||
)
|
||||
}
|
||||
return decodePlainKey(code)
|
||||
}
|
||||
|
||||
private fun readNextCode(reader: NonBlockingReader, timeoutMs: Long): Int? {
|
||||
val c = reader.read(timeoutMs)
|
||||
if (c == NonBlockingReader.READ_EXPIRED) return null
|
||||
if (c < 0) throw EOFException("non-blocking reader returned EOF while decoding key sequence")
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
private fun decodePlainKey(code: Int): ConsoleEvent.KeyDown = when (code) {
|
||||
3 -> key("c", ctrl = true)
|
||||
9 -> key("Tab")
|
||||
10, 13 -> key("Enter")
|
||||
127, 8 -> key("Backspace")
|
||||
32 -> key(" ")
|
||||
else -> {
|
||||
if (code in 1..26) {
|
||||
val ch = ('a'.code + code - 1).toChar().toString()
|
||||
key(ch, ctrl = true)
|
||||
} else {
|
||||
val ch = code.toChar().toString()
|
||||
key(ch, shift = ch.length == 1 && ch[0].isLetter() && ch[0].isUpperCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? = when (seq) {
|
||||
"[A", "OA" -> key("ArrowUp")
|
||||
"[B", "OB" -> key("ArrowDown")
|
||||
"[C", "OC" -> key("ArrowRight")
|
||||
"[D", "OD" -> key("ArrowLeft")
|
||||
"[H", "OH" -> key("Home")
|
||||
"[F", "OF" -> key("End")
|
||||
"[2~" -> key("Insert")
|
||||
"[3~" -> key("Delete")
|
||||
"[5~" -> key("PageUp")
|
||||
"[6~" -> key("PageDown")
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun key(
|
||||
value: String,
|
||||
ctrl: Boolean = false,
|
||||
alt: Boolean = false,
|
||||
shift: Boolean = false,
|
||||
): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(
|
||||
key = value,
|
||||
code = null,
|
||||
ctrl = ctrl,
|
||||
alt = alt,
|
||||
shift = shift,
|
||||
meta = false
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.io.console
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.obj.ObjBool
|
||||
import net.sergeych.lyng.obj.ObjIllegalOperationException
|
||||
import net.sergeych.lyngio.console.security.ConsoleAccessOp
|
||||
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
|
||||
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
|
||||
import net.sergeych.lyngio.fs.security.AccessContext
|
||||
import net.sergeych.lyngio.fs.security.AccessDecision
|
||||
import net.sergeych.lyngio.fs.security.Decision
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LyngConsoleModuleTest {
|
||||
|
||||
private fun newScope(): Scope = Scope.new()
|
||||
|
||||
@Test
|
||||
fun installIsIdempotent() = runBlocking {
|
||||
val scope = newScope()
|
||||
assertTrue(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
|
||||
assertFalse(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moduleSmokeScript() = runBlocking {
|
||||
val scope = newScope()
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
|
||||
|
||||
val code = """
|
||||
import lyng.io.console
|
||||
import lyng.stdlib
|
||||
|
||||
val d = Console.details()
|
||||
assert(d.supported is Bool)
|
||||
assert(d.isTty is Bool)
|
||||
assert(d.ansiLevel is String)
|
||||
|
||||
val g = Console.geometry()
|
||||
if (g != null) {
|
||||
assert(g.columns is Int)
|
||||
assert(g.rows is Int)
|
||||
assert(g.columns > 0)
|
||||
assert(g.rows > 0)
|
||||
}
|
||||
|
||||
assert(Console.events() is Iterable)
|
||||
Console.write("")
|
||||
Console.flush()
|
||||
Console.home()
|
||||
Console.clear()
|
||||
Console.moveTo(1, 1)
|
||||
Console.clearLine()
|
||||
Console.enterAltScreen()
|
||||
Console.leaveAltScreen()
|
||||
Console.setCursorVisible(true)
|
||||
|
||||
val changed = Console.setRawMode(false)
|
||||
assert(changed is Bool)
|
||||
true
|
||||
""".trimIndent()
|
||||
|
||||
val result = scope.eval(code)
|
||||
assertIs<ObjBool>(result)
|
||||
assertTrue(result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun denyWritePolicyMapsToIllegalOperation() {
|
||||
runBlocking {
|
||||
val denyWritePolicy = object : ConsoleAccessPolicy {
|
||||
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision = when (op) {
|
||||
is ConsoleAccessOp.WriteText -> AccessDecision(Decision.Deny, "denied by test policy")
|
||||
else -> AccessDecision(Decision.Allow)
|
||||
}
|
||||
}
|
||||
|
||||
val scope = newScope()
|
||||
createConsoleModule(denyWritePolicy, scope)
|
||||
|
||||
val error = kotlin.test.assertFailsWith<ExecutionError> {
|
||||
scope.eval(
|
||||
"""
|
||||
import lyng.io.console
|
||||
Console.write("x")
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
assertIs<ObjIllegalOperationException>(error.errorObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyngio.console
|
||||
|
||||
import kotlinx.cinterop.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import platform.posix.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
internal actual fun getNativeSystemConsole(): LyngConsole = LinuxPosixLyngConsole
|
||||
|
||||
internal object LinuxConsoleKeyDecoder {
|
||||
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
|
||||
if (firstCode == 27) {
|
||||
val next = nextCode(25L)
|
||||
if (next == null || next < 0) return key("Escape")
|
||||
if (next == '['.code || next == 'O'.code) {
|
||||
val sb = StringBuilder()
|
||||
sb.append(next.toChar())
|
||||
var i = 0
|
||||
while (i < 8) {
|
||||
val c = nextCode(25L) ?: break
|
||||
if (c < 0) break
|
||||
sb.append(c.toChar())
|
||||
if (c.toChar().isLetter() || c == '~'.code) break
|
||||
i += 1
|
||||
}
|
||||
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
|
||||
}
|
||||
val base = decodePlain(next)
|
||||
return ConsoleEvent.KeyDown(
|
||||
key = base.key,
|
||||
code = base.code,
|
||||
ctrl = base.ctrl,
|
||||
alt = true,
|
||||
shift = base.shift,
|
||||
meta = false,
|
||||
)
|
||||
}
|
||||
return decodePlain(firstCode)
|
||||
}
|
||||
|
||||
private fun decodePlain(code: Int): ConsoleEvent.KeyDown {
|
||||
if (code == 3) return ConsoleEvent.KeyDown(key = "c", ctrl = true)
|
||||
if (code == 9) return key("Tab")
|
||||
if (code == 10 || code == 13) return key("Enter")
|
||||
if (code == 32) return key(" ")
|
||||
if (code == 127 || code == 8) return key("Backspace")
|
||||
val c = code.toChar()
|
||||
return if (c in 'A'..'Z') {
|
||||
ConsoleEvent.KeyDown(key = c.toString(), shift = true)
|
||||
} else {
|
||||
key(c.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? {
|
||||
val letter = seq.lastOrNull() ?: return null
|
||||
val shift = seq.contains(";2")
|
||||
val alt = seq.contains(";3")
|
||||
val ctrl = seq.contains(";5")
|
||||
val key = when (letter) {
|
||||
'A' -> "ArrowUp"
|
||||
'B' -> "ArrowDown"
|
||||
'C' -> "ArrowRight"
|
||||
'D' -> "ArrowLeft"
|
||||
'H' -> "Home"
|
||||
'F' -> "End"
|
||||
else -> return null
|
||||
}
|
||||
return ConsoleEvent.KeyDown(key = key, ctrl = ctrl, alt = alt, shift = shift)
|
||||
}
|
||||
|
||||
private fun key(name: String): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(key = name)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
object LinuxPosixLyngConsole : LyngConsole {
|
||||
private val stateMutex = Mutex()
|
||||
private var rawModeRequested = false
|
||||
private var savedAttrsBlob: ByteArray? = null
|
||||
|
||||
override val isSupported: Boolean
|
||||
get() = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
|
||||
|
||||
override suspend fun isTty(): Boolean = isSupported
|
||||
|
||||
override suspend fun geometry(): ConsoleGeometry? = readGeometry()
|
||||
|
||||
override suspend fun ansiLevel(): ConsoleAnsiLevel {
|
||||
val colorTerm = (getenv("COLORTERM")?.toKString() ?: "").lowercase()
|
||||
val term = (getenv("TERM")?.toKString() ?: "").lowercase()
|
||||
return when {
|
||||
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
|
||||
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
|
||||
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
|
||||
else -> ConsoleAnsiLevel.NONE
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(text: String) {
|
||||
kotlin.io.print(text)
|
||||
}
|
||||
|
||||
override suspend fun flush() {
|
||||
fflush(null)
|
||||
}
|
||||
|
||||
override fun events(): ConsoleEventSource {
|
||||
if (!isSupported) {
|
||||
return object : ConsoleEventSource {
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
|
||||
override suspend fun close() {}
|
||||
}
|
||||
}
|
||||
|
||||
return object : ConsoleEventSource {
|
||||
var closed = false
|
||||
var lastGeometry: ConsoleGeometry? = null
|
||||
|
||||
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
|
||||
if (closed) return null
|
||||
val started = TimeSource.Monotonic.markNow()
|
||||
while (!closed) {
|
||||
val g = readGeometry()
|
||||
if (g != null && (lastGeometry == null || g.columns != lastGeometry?.columns || g.rows != lastGeometry?.rows)) {
|
||||
lastGeometry = g
|
||||
return ConsoleEvent.Resize(g.columns, g.rows)
|
||||
}
|
||||
|
||||
val rawRequested = stateMutex.withLock { rawModeRequested }
|
||||
val pollSliceMs = if (timeoutMs <= 0L) 250L else minOf(250L, timeoutMs)
|
||||
if (rawRequested) {
|
||||
val ev = readKeyEvent(pollSliceMs)
|
||||
if (ev != null) return ev
|
||||
} else {
|
||||
delay(25)
|
||||
}
|
||||
|
||||
if (timeoutMs > 0L && started.elapsedNow() >= timeoutMs.milliseconds) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setRawMode(enabled: Boolean): Boolean {
|
||||
if (!isSupported) return false
|
||||
return stateMutex.withLock {
|
||||
if (enabled) {
|
||||
if (rawModeRequested) return@withLock false
|
||||
memScoped {
|
||||
val attrs = alloc<termios>()
|
||||
if (tcgetattr(STDIN_FILENO, attrs.ptr) != 0) return@withLock false
|
||||
|
||||
val saved = ByteArray(sizeOf<termios>().toInt())
|
||||
saved.usePinned { pinned ->
|
||||
memcpy(pinned.addressOf(0), attrs.ptr, sizeOf<termios>().convert())
|
||||
}
|
||||
savedAttrsBlob = saved
|
||||
|
||||
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv() and ECHO.convert<UInt>().inv()
|
||||
attrs.c_iflag = attrs.c_iflag and IXON.convert<UInt>().inv() and ISTRIP.convert<UInt>().inv()
|
||||
attrs.c_oflag = attrs.c_oflag and OPOST.convert<UInt>().inv()
|
||||
if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false
|
||||
}
|
||||
rawModeRequested = true
|
||||
true
|
||||
} else {
|
||||
val hadRaw = rawModeRequested
|
||||
rawModeRequested = false
|
||||
val saved = savedAttrsBlob
|
||||
if (saved != null) {
|
||||
memScoped {
|
||||
val attrs = alloc<termios>()
|
||||
saved.usePinned { pinned ->
|
||||
memcpy(attrs.ptr, pinned.addressOf(0), sizeOf<termios>().convert())
|
||||
}
|
||||
tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr)
|
||||
}
|
||||
}
|
||||
hadRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readGeometry(): ConsoleGeometry? = memScoped {
|
||||
val ws = alloc<winsize>()
|
||||
if (ioctl(STDOUT_FILENO, TIOCGWINSZ.convert(), ws.ptr) != 0) return null
|
||||
val cols = ws.ws_col.toInt()
|
||||
val rows = ws.ws_row.toInt()
|
||||
if (cols <= 0 || rows <= 0) return null
|
||||
ConsoleGeometry(columns = cols, rows = rows)
|
||||
}
|
||||
|
||||
private fun readByte(timeoutMs: Long): Int? = memScoped {
|
||||
val pfd = alloc<pollfd>()
|
||||
pfd.fd = STDIN_FILENO
|
||||
pfd.events = POLLIN.convert()
|
||||
pfd.revents = 0
|
||||
val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt())
|
||||
if (ready <= 0) return null
|
||||
|
||||
val buf = ByteArray(1)
|
||||
val count = buf.usePinned { pinned ->
|
||||
read(STDIN_FILENO, pinned.addressOf(0), 1.convert())
|
||||
}
|
||||
if (count <= 0) return null
|
||||
val b = buf[0].toInt()
|
||||
if (b < 0) b + 256 else b
|
||||
}
|
||||
|
||||
private fun readKeyEvent(timeoutMs: Long): ConsoleEvent.KeyDown? {
|
||||
val first = readByte(timeoutMs) ?: return null
|
||||
return LinuxConsoleKeyDecoder.decode(first) { timeout ->
|
||||
readByte(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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].
|
||||
|
||||
@ -3784,6 +3784,7 @@ class CmdFrame(
|
||||
val exceptionSlot: Int,
|
||||
val catchIp: Int,
|
||||
val finallyIp: Int,
|
||||
val iterDepthAtPush: Int,
|
||||
var inCatch: Boolean = false
|
||||
)
|
||||
internal val tryStack = ArrayDeque<TryHandler>()
|
||||
@ -4069,11 +4070,15 @@ class CmdFrame(
|
||||
return scope
|
||||
}
|
||||
|
||||
fun handleException(t: Throwable): Boolean {
|
||||
suspend fun handleException(t: Throwable): Boolean {
|
||||
val handler = tryStack.lastOrNull() ?: return false
|
||||
vmIterDebug(
|
||||
"handleException fn=${fn.name} throwable=${t::class.simpleName} message=${t.message} catchIp=${handler.catchIp} finallyIp=${handler.finallyIp} iterDepth=${iterStack.size}"
|
||||
)
|
||||
val finallyIp = handler.finallyIp
|
||||
if (t is ReturnException || t is LoopBreakContinueException) {
|
||||
if (finallyIp >= 0) {
|
||||
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:returnOrLoop->finally")
|
||||
pendingThrowable = t
|
||||
ip = finallyIp
|
||||
return true
|
||||
@ -4082,6 +4087,7 @@ class CmdFrame(
|
||||
}
|
||||
if (handler.inCatch) {
|
||||
if (finallyIp >= 0) {
|
||||
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:inCatch->finally")
|
||||
pendingThrowable = t
|
||||
ip = finallyIp
|
||||
return true
|
||||
@ -4091,6 +4097,7 @@ class CmdFrame(
|
||||
handler.inCatch = true
|
||||
pendingThrowable = t
|
||||
if (handler.catchIp >= 0) {
|
||||
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:toCatch")
|
||||
val caughtObj = when (t) {
|
||||
is ExecutionError -> t.errorObject
|
||||
else -> ObjUnknownException(ensureScope(), t.message ?: t.toString())
|
||||
@ -4100,6 +4107,7 @@ class CmdFrame(
|
||||
return true
|
||||
}
|
||||
if (finallyIp >= 0) {
|
||||
cancelIteratorsToDepth(handler.iterDepthAtPush, "handleException:toFinallyNoCatch")
|
||||
ip = finallyIp
|
||||
return true
|
||||
}
|
||||
@ -4107,7 +4115,7 @@ class CmdFrame(
|
||||
}
|
||||
|
||||
fun pushTry(exceptionSlot: Int, catchIp: Int, finallyIp: Int) {
|
||||
tryStack.addLast(TryHandler(exceptionSlot, catchIp, finallyIp))
|
||||
tryStack.addLast(TryHandler(exceptionSlot, catchIp, finallyIp, iterDepthAtPush = iterStack.size))
|
||||
}
|
||||
|
||||
fun popTry() {
|
||||
@ -4164,24 +4172,47 @@ class CmdFrame(
|
||||
|
||||
fun pushIterator(iter: Obj) {
|
||||
iterStack.addLast(iter)
|
||||
if (iter.objClass.className == "FlowIterator") {
|
||||
vmIterDebug("pushIterator fn=${fn.name} depth=${iterStack.size} iterClass=${iter.objClass.className}")
|
||||
}
|
||||
}
|
||||
|
||||
fun popIterator() {
|
||||
val iter = iterStack.lastOrNull()
|
||||
if (iter != null && iter.objClass.className == "FlowIterator") {
|
||||
vmIterDebug("popIterator fn=${fn.name} depth=${iterStack.size} iterClass=${iter.objClass.className}")
|
||||
}
|
||||
iterStack.removeLastOrNull()
|
||||
}
|
||||
|
||||
suspend fun cancelTopIterator() {
|
||||
val iter = iterStack.removeLastOrNull() ?: return
|
||||
vmIterDebug("cancelTopIterator fn=${fn.name} depthAfter=${iterStack.size} iterClass=${iter.objClass.className}")
|
||||
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
|
||||
}
|
||||
|
||||
suspend fun cancelIterators() {
|
||||
while (iterStack.isNotEmpty()) {
|
||||
val iter = iterStack.removeLast()
|
||||
vmIterDebug("cancelIterators fn=${fn.name} depthAfter=${iterStack.size} iterClass=${iter.objClass.className}")
|
||||
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cancelIteratorsToDepth(depth: Int, reason: String) {
|
||||
while (iterStack.size > depth) {
|
||||
val iter = iterStack.removeLast()
|
||||
vmIterDebug(
|
||||
"cancelIteratorsToDepth fn=${fn.name} reason=$reason targetDepth=$depth depthAfter=${iterStack.size} iterClass=${iter.objClass.className}"
|
||||
)
|
||||
try {
|
||||
iter.invokeInstanceMethod(ensureScope(), "cancelIteration") { ObjVoid }
|
||||
} catch (e: Throwable) {
|
||||
vmIterDebug("cancelIteratorsToDepth: cancelIteration failed fn=${fn.name} reason=$reason", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pushSlotPlan(plan: Map<String, Int>) {
|
||||
if (scope.hasSlotPlanConflict(plan)) {
|
||||
scopeStack.addLast(scope)
|
||||
|
||||
@ -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)
|
||||
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -24,7 +24,8 @@ import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.lyng.*
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
|
||||
import net.sergeych.lyng.miniast.ParamDoc
|
||||
import net.sergeych.lyng.miniast.TypeGenericDoc
|
||||
import net.sergeych.lyng.miniast.addFnDoc
|
||||
@ -55,7 +56,7 @@ class ObjFlowBuilder(val output: SendChannel<Obj>) : Obj() {
|
||||
else
|
||||
// Flow consumer is no longer collecting; signal producer to stop
|
||||
throw ScriptFlowIsNoMoreCollected()
|
||||
} catch (x: Exception) {
|
||||
} catch (x: Throwable) {
|
||||
// Any failure to send (including closed channel) should gracefully stop the producer.
|
||||
if (x is CancellationException) {
|
||||
// Cancellation is a normal control-flow event
|
||||
@ -80,7 +81,7 @@ private fun createLyngFlowInput(scope: Scope, producer: Obj): ReceiveChannel<Obj
|
||||
producer.callOn(builderScope)
|
||||
} catch (x: ScriptFlowIsNoMoreCollected) {
|
||||
// premature flow closing, OK
|
||||
} catch (x: Exception) {
|
||||
} catch (x: Throwable) {
|
||||
channel.close(x)
|
||||
return@globalLaunch
|
||||
}
|
||||
|
||||
60
lynglib/src/commonTest/kotlin/UnicodeEscapeTest.kt
Normal file
60
lynglib/src/commonTest/kotlin/UnicodeEscapeTest.kt
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.lyng.*
|
||||
import net.sergeych.lyng.obj.ObjChar
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class UnicodeEscapeTest {
|
||||
@Test
|
||||
fun parserDecodesUnicodeEscapeInStringLiteral() {
|
||||
val token = parseLyng("\"\\u263A\"".toSource()).first()
|
||||
assertEquals(Token.Type.STRING, token.type)
|
||||
assertEquals("☺", token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parserDecodesUnicodeEscapeInCharLiteral() {
|
||||
val token = parseLyng("'\\u263A'".toSource()).first()
|
||||
assertEquals(Token.Type.CHAR, token.type)
|
||||
assertEquals("☺", token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parserRejectsMalformedUnicodeEscapeInStringLiteral() {
|
||||
assertFailsWith<ScriptError> {
|
||||
parseLyng("\"\\u12G4\"".toSource())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parserRejectsShortUnicodeEscapeInCharLiteral() {
|
||||
assertFailsWith<ScriptError> {
|
||||
parseLyng("'\\u12'".toSource())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun evalDecodesUnicodeEscapes() = runTest {
|
||||
assertEquals(ObjString("☺"), eval("\"\\u263A\""))
|
||||
assertEquals(ObjChar('☺'), eval("'\\u263A'"))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user