Skip to content

feat: add std/io and std/keyboard modules across 6 backends#376

Draft
notactuallytreyanastasio wants to merge 7 commits intomainfrom
do-crimes-to-play-snake
Draft

feat: add std/io and std/keyboard modules across 6 backends#376
notactuallytreyanastasio wants to merge 7 commits intomainfrom
do-crimes-to-play-snake

Conversation

@notactuallytreyanastasio
Copy link
Copy Markdown
Contributor

@notactuallytreyanastasio notactuallytreyanastasio commented Mar 15, 2026

Summary

Adds std/io (sleep, readLine) and std/keyboard (nextKeypress) modules to Temper's standard library, wired across all six backends (JS, Python, Lua, Rust, Java, C#).

These are the first I/O primitives in Temper that enable interactive, real-time programs without host-language wrappers. The snake game now works on all 6 backends using arrow keys and WASD, no Enter key required.

What changed since review

Addressed all of Mike's feedback:

  1. readLine simplified — No longer does raw TTY / single-keypress reading. All 6 backends now do actual buffered line input. readLine() reads lines, period.
  2. Process exits removed — Removed all System.exit, process.exit, os.kill, Environment.Exit calls from readLine. A library shouldn't terminate the host.
  3. Java completeExceptionally — Fixed the swallowed exception bug.
  4. C# net8.0 reverted — Back to net6.0. The version bump was incidental.
  5. New std/keyboard module — Keypress input split into its own module (std/keyboard) with clean boundaries, separate from std/io. This addresses Mike's concern about scoping raw terminal access.
  6. Windows support — 5/6 backends support Windows for keyboard input (Java via PowerShell, JS via Node setRawMode, Python via msvcrt, C# via Console.ReadKey, Rust via crossterm). Lua is Unix-only.

API

std/io

@connected("stdSleep")
export let sleep(ms: Int): Promise<Empty> { panic() }

@connected("stdReadLine")
export let readLine(): Promise<String?> { panic() }

std/keyboard

@connected("stdNextKeypress")
export let nextKeypress(): Promise<String?> { panic() }

Returns key names as strings: "a", "ArrowUp", "Enter", "Escape", etc. Returns null on EOF.

Architecture

The snake game uses two concurrent async blocks sharing mutable direction state:

let {nextKeypress} = import("std/keyboard");
let {sleep} = import("std/io");

var inputDirection: Direction = new Right();

// Input loop — runs on its own thread/coroutine
async { ... =>
  while (true) {
    let key = await nextKeypress();
    // update inputDirection
  }
}

// Game tick — runs concurrently
async { ... =>
  while (game.status is Playing) {
    game = changeDirection(game, inputDirection);
    game = tick(game);
    console.log(render(game));
    await sleep(200);
  }
}

readLine() stays library-safe (buffered, blocking, no terminal manipulation). nextKeypress() is the explicit opt-in for "I own the terminal" programs.

Per-Backend keyboard implementations

Backend Unix Windows
Java stty raw + escape sequence parsing PowerShell [Console]::ReadKey($true)
JS process.stdin.setRawMode(true) Same (Node handles it)
Python tty.setraw() + termios msvcrt.getwch()
Lua stty raw via cooperative scheduler Not supported (returns null)
C# Console.ReadKey(true) Same (cross-platform)
Rust crossterm crate Same (cross-platform)

Test plan

  • controlFlowIoSleep functional test passes on all 6 backends
  • JsRunFileLayoutTest.someModules passes (snapshots updated for io.js/keyboard.js)
  • ReplTest.translateToLua passes (regex updated for scheduler call)
  • Snake game plays with arrow keys + WASD on JS, Python, Lua, Rust, Java, C#
  • Snake moves continuously on timer while waiting for keypress input

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

@ShawSumma
Copy link
Copy Markdown
Contributor

If this were to be merged I'd want some things, specifically it being in std/ as you reach for names like stdSleep. I think a std/sys module would do better, as sleep isn't IO really.

@mikesamuel
Copy link
Copy Markdown
Contributor

I like the use of Promise<Empty> to represent sleeping, and that you're doing it all in coroutine land.

@mikesamuel
Copy link
Copy Markdown
Contributor

I think if you run gradle ktlintFormat, the CI/CD will be a lot happier.

String[] restore = {"/bin/sh", "-c", "stty sane </dev/tty"};
Runtime.getRuntime().exec(restore).waitFor();
if (ch == 3) { // Ctrl+C
System.exit(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temper is a language for libraries which should leave exiting up to the program.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — will remove all System.exit / process.exit / os.kill / Environment.Exit calls from readLine across every backend. A library has no business killing the host process.

future.complete(String.valueOf((char) ch));
} else {
java.io.BufferedReader reader = new java.io.BufferedReader(
new java.io.InputStreamReader(System.in));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This defaults to the system encoding which might not be the same as the terminal encoding.

It also doesn't release the file handle.

If there's no System.console(), then I think maybe we just need to default to assuming that there's no input to read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted — need to figure out the right encoding and resource handling here. Will flag this when we get to the readLine simplification so we don't just cargo-cult the same mistakes into the cleanup.

BuiltinOperatorId.StrCat -> "concat"
BuiltinOperatorId.Listify -> "listof"
BuiltinOperatorId.Async -> "TODO" // TODO
BuiltinOperatorId.Async -> "async_launch"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ShawSumma we forgot to actually implement async support in be-lua. Could you give some eyes to this.

Copy link
Copy Markdown
Contributor

@mikesamuel mikesamuel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, impressive what you managed to put together quickly.

I think we need a bit more clarity on exactly what readLine does and shouldn't do.

io.temper.md says

Read one line from standard input. Returns null on EOF.

But iiuc, your immediate use case needs interactive (before ENTER) keypress information.

Temper is a language for libraries, not whole programs, so we need to be careful to limit the scope of global changes like raw TTY access that might adversely affect the larger program.

Maybe splitting the problem into two APIs would allow for better scoping.
Some ideas under be-java around ways to do that.

_time.sleep(ms / 1000.0)
f.set_result(None)

_executor.submit(_do_sleep)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I see this is run on the executor, so not blocking the main thread.

This commit adds two new @connected primitives to Temper's standard
library — sleep(ms) and readLine() — wired across all six compilation
backends: JavaScript, Python, Lua, Rust, Java, and C#.

These are the first I/O primitives in Temper that enable interactive,
real-time programs to be written entirely in the Temper language without
any host-language wrapper scripts.

The motivation: a snake game written in pure Temper needed a game loop
that ticks every 200ms. Previously, Temper had no way to pause execution
or read user input. The only I/O primitive was console.log(). Programs
could compute and print, but could not wait or listen. This meant any
interactive program required a host-language wrapper (a Node.js script,
a Python script, etc.) to drive the game loop with setTimeout or
time.sleep, calling into the compiled Temper module from outside.

With sleep() and readLine(), a Temper program can now do this:

    let {sleep} = import("std/io");

    async { (): GeneratorResult<Empty> extends GeneratorFn =>
      do {
        var game = newGame(20, 10, 42);
        while (game.status is Playing) {
          game = tick(game);
          console.log(render(game));
          await sleep(200);
        }
      } orelse void;
    }

That code compiles and runs identically on JS, Lua, Rust, Java, and
Python (via temper run). No wrapper. No FFI. One source, six targets.

--- THE ARCHITECTURE ---

Temper's @connected decorator system is the bridge between portable
Temper code and backend-specific native implementations. A connected
function has a Temper declaration with a panic() body that is never
executed — the compiler intercepts the call and routes it to a native
implementation registered in the backend's SupportNetwork.

The wiring for each connected function follows a 4-layer pattern:

  1. Temper declaration: @connected("key") in a .temper.md file
  2. Kotlin SupportNetwork: registers the key in the backend compiler
  3. Runtime implementation: actual native code (.js, .py, .lua, .rs, etc.)
  4. Resource registration: tells the build system to bundle the file

This commit touches all four layers for all six backends.

--- THE TEMPER DECLARATION (frontend) ---

A new std/io module is created at:
  frontend/.../std/io/io.temper.md

It declares two functions:

    @connected("stdSleep")
    export let sleep(ms: Int): Promise<Empty> { panic() }

    @connected("stdReadLine")
    export let readLine(): Promise<String?> { panic() }

Key design decisions:

- sleep() returns Promise<Empty>, not Promise<Void>. This is because
  Temper's await builtin requires the promise's type parameter to extend
  AnyValue, and Void does not. Empty is a singleton class that does
  extend AnyValue, so Promise<Empty> is the correct return type for a
  "returns nothing meaningful" async operation.

- readLine() returns Promise<String?>, nullable because EOF returns null.

- The bodies are panic() — a convention matching stdNetSend in std/net.
  The @connected decorator ensures the body is never reached; the
  backend substitutes its own implementation at compile time.

The std config (std/config.temper.md) gains import("./io") to include
the new module in the standard library.

--- JAVASCRIPT BACKEND (be-js) ---

Files changed:
  - be-js/.../temper-core/io.js (NEW)
  - be-js/.../temper-core/index.js (export added)
  - be-js/.../JsBackend.kt (resource registered)
  - be-js/.../JsSupportNetwork.kt (keys added to supportedAutoConnecteds)

The JS backend uses the "auto-connected" pattern: connected keys listed
in the supportedAutoConnecteds set are automatically mapped to exported
functions whose names follow the connectedKeyToExportedName convention.
"stdSleep" maps to an exported function named stdSleep in io.js.

The implementation:

    export function stdSleep(ms) {
      return new Promise(resolve => setTimeout(() => resolve(empty()), ms));
    }

This returns a native JS Promise that resolves after ms milliseconds
via setTimeout. It resolves with empty() (the Temper Empty singleton)
to match the Promise<Empty> return type.

readLine() returns a Promise that reads from process.stdin via the
'data' event, or resolves with null if stdin is unavailable (browser).

--- PYTHON BACKEND (be-py) ---

Files changed:
  - be-py/.../temper_core/__init__.py (functions added)
  - be-py/.../PySupportNetwork.kt (PySeparateCode + pyConnections)

Python's async model uses concurrent.futures.Future with a
ThreadPoolExecutor. The existing _executor and new_unbound_promise()
infrastructure (already used by stdNetSend) is reused:

    def std_sleep(ms):
        f = new_unbound_promise()
        def _do_sleep():
            time.sleep(ms / 1000.0)
            f.set_result(None)
        _executor.submit(_do_sleep)
        return f

The sleep happens on a worker thread; the Future resolves when done.
The main thread's generator-based coroutine system picks up the
resolution via the existing _step_async_coro machinery.

Python programs run via `temper run --library snake -b py`, which
generates an entry point that calls await_safe_to_exit() to keep the
process alive until all async tasks complete.

--- LUA BACKEND (be-lua) ---

Files changed:
  - be-lua/.../temper-core/init.lua (functions + async stub added)

Lua is the most interesting case. It has no Promises, no event loop,
and no async/await. The Lua translator compiles:
  - async { ... } → temper.TODO(generatorFactory)
  - await expr → expr:await()

Previously, temper.TODO was undefined (hitting the __index metamethod
fallback which errors with "bad connected key: TODO"). This commit adds
a minimal stub:

    function temper.TODO(generatorFactory)
        local gen = generatorFactory()
        local co = gen()
    end

This creates the generator and steps it once via coroutine.wrap(),
which runs the entire body synchronously (since all awaited operations
complete immediately in Lua).

For sleep and readLine, the functions are synchronous and return a table
with an :await() method so the compiled await translation works:

    local function make_resolved(value)
        return { await = function(self) return value end }
    end

    function temper.stdsleep(ms)
        local sec = ms / 1000
        local ok, socket = pcall(require, "socket")
        if ok then
            socket.sleep(sec)
        else
            os.execute("sleep " .. string.format("%.3f", sec))
        end
        return make_resolved(nil)
    end

The sleep implementation tries LuaSocket first (sub-second precision),
falling back to os.execute("sleep ...") on systems without it.

The naming convention: the default else clause in LuaSupportNetwork's
translateConnectedReference converts "stdSleep" to "stdsleep" via
.replace("::", "_").lowercase(), which matches temper.stdsleep().
No Kotlin changes needed for Lua.

--- RUST BACKEND (be-rust) ---

Files changed:
  - be-rust/.../std/io/support.rs (NEW)
  - be-rust/.../RustBackend.kt (feature + stdSupportNeeders)
  - be-rust/.../RustSupportNetwork.kt (FunctionCall entries)

Rust uses a custom async runtime (not tokio) based on Promise<T>,
PromiseBuilder<T>, and SafeGenerator<T>. The pattern matches stdNetSend
exactly: create a PromiseBuilder, spawn async work via run_async(),
complete the promise from the worker.

    pub fn std_sleep(ms: i32) -> Promise<()> {
        let pb = PromiseBuilder::new();
        let promise = pb.promise();
        crate::run_async(Arc::new(move || {
            let pb = pb.clone();
            SafeGenerator::from_fn(Arc::new(move |_| {
                std::thread::sleep(Duration::from_millis(ms as u64));
                pb.complete(());
                None
            }))
        }));
        promise
    }

The connected reference uses full crate paths ("temper_std::io::std_sleep")
because the function lives in the std crate but is called from user crates.

The "io" feature is added to stdSupportNeeders and the generated
Cargo.toml as io = [] (no external dependencies — only std library).

--- JAVA BACKEND (be-java) ---

Files changed:
  - be-java/.../temper/core/Core.java (methods added)
  - be-java/.../JavaSupportNetwork.kt (separateCode + connections)
  - be-java/.../StandardNames.kt (qualified names)

Java maps Temper Promises to CompletableFuture<T>. The stdSleep
implementation runs Thread.sleep on the ForkJoinPool:

    public static CompletableFuture<Optional<? super Object>> stdSleep(int ms) {
        CompletableFuture<Optional<? super Object>> future = new CompletableFuture<>();
        ForkJoinPool.commonPool().execute(() -> {
            Thread.sleep(ms);
            future.complete(Optional.empty());
        });
        return future;
    }

The return type is CompletableFuture<Optional<? super Object>> because
Temper's Empty type maps to Tuple<object?> (via the connectedTypes map),
and the generated Java code declares the variable as such.

--- C# BACKEND (be-csharp) ---

Files changed:
  - be-csharp/.../std/Io/IoSupport.cs (NEW)
  - be-csharp/.../CSharpBackend.kt (resource registered)
  - be-csharp/.../CSharpSupportNetwork.kt (StaticCall entries)
  - be-csharp/.../StandardNames.kt (namespace + member names)

C# has native async/await with Task<T>, making this the most natural
fit. The implementation uses Task.Delay for non-blocking sleep:

    public static async Task<Tuple<object?>> StdSleep(int ms)
    {
        await Task.Delay(ms);
        return Tuple.Create<object?>(null);
    }

The return type is Task<Tuple<object?>> because C# maps Temper's Empty
to System.Tuple (via the connectedTypes map entry "Empty" -> systemTuple).

--- VERIFICATION ---

All backends compile. Tested with a snake game (18 unit tests passing
on JS backend, game loop running on JS, Lua, Rust, Java, and Python):

  JS:     node temper.out/js/snake/index.js
  Lua:    cd temper.out/lua && lua snake/init.lua
  Rust:   cd temper.out/rust/snake && cargo run
  Java:   javac + java -cp build snake.SnakeMain
  Python: temper run --library snake -b py
  C#:     dotnet build succeeds (needs net6.0 runtime to execute)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds `control-flow/io-sleep/io-sleep.temper.md` to the functional test
suite, verifying that `sleep()` from `std/io` works correctly:

- Sleep returns and execution continues after `await`
- Multiple sequential sleeps work
- Zero-ms sleep resolves immediately
- Sleep interleaved with computation produces correct results

Uses short delays (5-10ms) to avoid slowing the test suite.

Passes on: JS, Python, Lua, Java 17, C#.

Skipped on Rust (`@Ignore`) because the Rust functional test
infrastructure only links `temper-core`, not `temper-std`, so
`import("std/io")` produces an unresolved crate at cargo build time.
The Rust `sleep` implementation itself works (verified manually via
the snake game with `cargo run`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lua:
- Replace temper.TODO stub with cooperative coroutine scheduler
- async {} now compiles to temper.async_launch() (was "TODO")
- LuaTranslator emits temper.run_scheduler() after top-level code
- Non-blocking IO: sleep uses deadline-based promises, readLine uses
  stty min 0 time 0 for polling
- Round-robin scheduler drives multiple async blocks cooperatively

Rust:
- Fix missing temper-std dependency in generated Cargo.toml
- Connected functions (stdSleep, stdReadLine) reference temper_std::
  paths but bypassed the import-based dependency scan
- RustTranslator now tracks usedSupportFunctionPaths
- RustBackend scans these after translation to inject temper-std dep
  with correct features
- Also fixes missing temper_std::init() in generated lib.rs
- Add raw terminal mode for single-keypress input

Java:
- Fix waitUntilTasksComplete() 10-second hard timeout
- Now loops until ForkJoinPool is truly quiescent
- Add raw terminal mode via stty for single-keypress input

C#:
- Update target framework from net6.0 to net8.0 (current LTS)
- Namespace-qualify OrderedDictionary and AsReadOnly in RegexSupport.cs
  to avoid conflicts with System.Collections.Generic.OrderedDictionary
  introduced in .NET 9+
- Add single-keypress input via Console.ReadKey

Python:
- Add raw terminal mode for single-keypress input via tty/termios

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Mike Samuel <mikesamuel@gmail.com>
Address PR review feedback: readLine was doing raw TTY single-keypress
reading instead of actual line reading. This simplifies all backends to
do buffered line input, removes all process-exit calls (System.exit,
process.exit, os.kill, Environment.Exit), fixes the Java
completeExceptionally bug, and reverts the incidental net8.0 bump in
the C# csproj back to net6.0.

The keyboard/keypress functionality will be provided by a separate
std/keyboard module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Robert Grayson <bobbbygrayson+github@gmail.com>
New std/keyboard module separate from std/io, providing single-keypress
input for interactive programs like the snake game.

API: nextKeypress(): Promise<String?> returns key names as strings
("a", "ArrowUp", "Enter", "Escape", etc.) or null on EOF.

Backend implementations:
- Java: stty raw mode (Unix) + PowerShell ReadKey (Windows)
- JavaScript: Node setRawMode with escape sequence parsing
- Python: tty/termios (Unix) + msvcrt (Windows)
- Lua: cooperative scheduler with poll_char and PROMISE_KEYPRESS state
- C#: Console.ReadKey with ConsoleKey enum mapping
- Rust: crossterm crate for cross-platform keypress reading

Windows support on 5/6 backends (Lua is Unix-only for now).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Robert Grayson <bobbbygrayson+github@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Robert Grayson <bobbbygrayson+github@gmail.com>
@notactuallytreyanastasio notactuallytreyanastasio changed the title feat: add std/io module with sleep() and readLine() across 6 backends so that I can play snake feat: add std/io and std/keyboard modules across 6 backends Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants