feat: add std/io and std/keyboard modules across 6 backends#376
feat: add std/io and std/keyboard modules across 6 backends#376notactuallytreyanastasio wants to merge 7 commits intomainfrom
Conversation
|
If this were to be merged I'd want some things, specifically it being in |
|
I like the use of |
|
I think if you run |
...src/commonMain/resources/lang/temper/be/java/temper-core/src/main/java/temper/core/Core.java
Outdated
Show resolved
Hide resolved
| String[] restore = {"/bin/sh", "-c", "stty sane </dev/tty"}; | ||
| Runtime.getRuntime().exec(restore).waitFor(); | ||
| if (ch == 3) { // Ctrl+C | ||
| System.exit(1); |
There was a problem hiding this comment.
Temper is a language for libraries which should leave exiting up to the program.
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
...src/commonMain/resources/lang/temper/be/java/temper-core/src/main/java/temper/core/Core.java
Outdated
Show resolved
Hide resolved
be-js/src/commonMain/resources/lang/temper/be/js/temper-core/io.js
Outdated
Show resolved
Hide resolved
| BuiltinOperatorId.StrCat -> "concat" | ||
| BuiltinOperatorId.Listify -> "listof" | ||
| BuiltinOperatorId.Async -> "TODO" // TODO | ||
| BuiltinOperatorId.Async -> "async_launch" |
There was a problem hiding this comment.
@ShawSumma we forgot to actually implement async support in be-lua. Could you give some eyes to this.
mikesamuel
left a comment
There was a problem hiding this comment.
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.
be-csharp/src/commonMain/resources/lang/temper/be/csharp/temper-core/TemperLang.Core.csproj
Outdated
Show resolved
Hide resolved
| _time.sleep(ms / 1000.0) | ||
| f.set_result(None) | ||
|
|
||
| _executor.submit(_do_sleep) |
There was a problem hiding this comment.
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>
e25c48b to
085161f
Compare
Summary
Adds
std/io(sleep, readLine) andstd/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:
readLine()reads lines, period.System.exit,process.exit,os.kill,Environment.Exitcalls from readLine. A library shouldn't terminate the host.std/keyboard) with clean boundaries, separate fromstd/io. This addresses Mike's concern about scoping raw terminal access.API
std/io
std/keyboard
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:
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
[Console]::ReadKey($true)process.stdin.setRawMode(true)tty.setraw()+termiosmsvcrt.getwch()stty rawvia cooperative schedulerConsole.ReadKey(true)crosstermcrateTest plan
controlFlowIoSleepfunctional test passes on all 6 backendsJsRunFileLayoutTest.someModulespasses (snapshots updated for io.js/keyboard.js)ReplTest.translateToLuapasses (regex updated for scheduler call)🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com