From 27e7fc66b209585b446d0f4cd0bf9f1d3953a529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Mon, 6 Jul 2026 13:15:28 +0200 Subject: [PATCH] add repl --- Cargo.lock | 132 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 26 +++++++++- src/main.rs | 20 +++++--- src/repl.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 src/repl.rs diff --git a/Cargo.lock b/Cargo.lock index 0de428d..58cedb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ dependencies = [ "reqwest", "rusqlite", "rustls", + "rustyline", "serde", "serde_json", "thiserror", @@ -185,6 +186,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.45" @@ -238,6 +245,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -380,6 +396,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "env_filter" version = "2.0.0" @@ -430,6 +452,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -442,6 +470,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -627,6 +666,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -984,6 +1032,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1092,6 +1146,27 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1242,6 +1317,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -1390,6 +1475,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1469,6 +1567,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1879,6 +1999,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 1e70800..6218634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rusqlite = { version = "0.32", features = ["bundled"] } +rustyline = "14" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" diff --git a/README.md b/README.md index 371faf8..71c2b89 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,32 @@ `a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the network, a database, the filesystem, and the terminal without additional setup. ```sh -a file.lua +a file.lua # run a script +a # start an interactive REPL ``` -The interface is a single command: `a` runs the script at the given path. +Run `a` with a script path to execute it, or with no arguments to start an +interactive REPL. + +## The REPL + +Running `a` with no arguments starts a read-eval-print loop in the same +sandboxed environment used for scripts, with the full standard library +available. It is convenient for trying out the library or exploring data. + +- Bare expressions print their value (`1 + 1` → `2`); tables are pretty-printed + with `utils.dump`. +- Statements that span several lines (a `for` loop, a function body) are + detected as incomplete and keep reading until they parse. +- Async calls such as `os.sleep`, `http.get`, and `task.join` suspend and resume + just as they do in a script — each entry runs on the async executor. +- Line editing and history are provided, with history persisted to + `~/.a_history`. Press Ctrl-D to exit (`os.exit` is not available in the + sandbox). + +Each entry is compiled as its own chunk, so a `local` declared on one line is +not visible on the next — assign to a global (drop the `local`) to keep a value +across entries. ## What it is diff --git a/src/main.rs b/src/main.rs index 1d56989..3810a2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,17 @@ use std::path::PathBuf; use clap::Parser; +mod repl; mod sandbox; mod stdlib; -/// Runs a Lua script in a customized environment +/// Runs a Lua script in a customized environment, or an interactive REPL when +/// no script path is given. #[derive(Parser, Debug)] #[command(version, about)] struct Args { - /// Path to the Lua script to execute - filepath: PathBuf, + /// Path to the Lua script to execute. Omit to start an interactive REPL. + filepath: Option, } #[tokio::main] @@ -19,7 +21,13 @@ async fn main() -> Result<(), Box> { let args = Args::parse(); - let source = tokio::fs::read_to_string(&args.filepath).await?; + let lua = sandbox::create_sandboxed_lua()?; + + let Some(filepath) = args.filepath else { + return repl::run(&lua).await; + }; + + let source = tokio::fs::read_to_string(&filepath).await?; // Skip a leading shebang line like stock Lua's luaL_loadfilex does (it // skips any first line starting with '#'). The newline is kept so error @@ -30,11 +38,9 @@ async fn main() -> Result<(), Box> { source.as_str() }; - let lua = sandbox::create_sandboxed_lua()?; - lua.load(chunk) // "@" marks the chunk name as a file path in error messages / tracebacks - .set_name(format!("@{}", args.filepath.display())) + .set_name(format!("@{}", filepath.display())) .exec_async() .await?; diff --git a/src/repl.rs b/src/repl.rs new file mode 100644 index 0000000..757e1de --- /dev/null +++ b/src/repl.rs @@ -0,0 +1,145 @@ +//! Interactive read-eval-print loop. +//! +//! Started when `a` is run with no script path. Each line is compiled and run in +//! the same sandboxed Lua state used for scripts, so the full stdlib (including +//! the async http/sqlite/os.sleep calls) is available. Evaluation uses the async +//! executor, matching how files are run. + +use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable}; +use mlua::Lua; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; + +const PROMPT: &str = "a> "; +const CONTINUATION_PROMPT: &str = "..> "; + +/// Outcome of trying to compile a piece of REPL input. +enum Compiled { + /// A ready-to-run chunk. + Chunk(LuaFunction), + /// Input is syntactically incomplete; more lines are needed. + Incomplete, + /// Input is invalid and cannot become valid by appending more lines. + Error(mlua::Error), +} + +/// Compile a complete piece of REPL input. +/// +/// Mirrors stock Lua's `lua.c`: first try the input wrapped in `return ( ... )` +/// so that bare expressions print their value, and fall back to running it as a +/// statement. Only the statement form's error decides whether the input is +/// merely incomplete (so a half-typed expression keeps reading more lines). +fn compile(lua: &Lua, source: &str) -> Compiled { + if let Ok(func) = lua + .load(format!("return {source}")) + .set_name("=stdin") + .into_function() + { + return Compiled::Chunk(func); + } + + match lua.load(source).set_name("=stdin").into_function() { + Ok(func) => Compiled::Chunk(func), + Err(mlua::Error::SyntaxError { + incomplete_input: true, + .. + }) => Compiled::Incomplete, + Err(err) => Compiled::Error(err), + } +} + +/// Pretty-print the results of an evaluated chunk, one per line, using the +/// project's own `utils.dump`. Nothing is printed when the chunk returns no +/// values (e.g. a plain statement). +fn print_results(lua: &Lua, values: LuaMultiValue) -> mlua::Result<()> { + if values.is_empty() { + return Ok(()); + } + let dump: LuaFunction = lua + .globals() + .get::("utils")? + .get::("dump")?; + for value in values { + let rendered: String = dump.call(value)?; + println!("{rendered}"); + } + Ok(()) +} + +/// Run the interactive REPL against an already-sandboxed Lua state. +pub(crate) async fn run(lua: &Lua) -> Result<(), Box> { + let mut editor = DefaultEditor::new()?; + let history_path = std::env::var_os("HOME").map(|home| { + let mut p = std::path::PathBuf::from(home); + p.push(".a_history"); + p + }); + if let Some(path) = &history_path { + // A missing history file on first run is not an error. + let _ = editor.load_history(path); + } + + println!( + "a {} — interactive Lua. Ctrl-D to exit.", + env!("CARGO_PKG_VERSION") + ); + + // Accumulated lines of an as-yet-incomplete statement. + let mut buffer = String::new(); + + loop { + let prompt = if buffer.is_empty() { + PROMPT + } else { + CONTINUATION_PROMPT + }; + + match editor.readline(prompt) { + Ok(line) => { + if buffer.is_empty() { + buffer = line; + } else { + buffer.push('\n'); + buffer.push_str(&line); + } + + match compile(lua, &buffer) { + Compiled::Incomplete => continue, // keep reading continuation lines + Compiled::Chunk(func) => { + let _ = editor.add_history_entry(buffer.as_str()); + match func.call_async::(()).await { + Ok(values) => { + if let Err(err) = print_results(lua, values) { + eprintln!("{err}"); + } + } + Err(err) => eprintln!("{err}"), + } + buffer.clear(); + } + Compiled::Error(err) => { + let _ = editor.add_history_entry(buffer.as_str()); + eprintln!("{err}"); + buffer.clear(); + } + } + } + // Ctrl-C: abandon the current input and start fresh. + Err(ReadlineError::Interrupted) => { + buffer.clear(); + } + // Ctrl-D (on an empty line) or end of piped input: quit. + Err(ReadlineError::Eof) => break, + Err(err) => { + eprintln!("{err}"); + break; + } + } + } + + if let Some(path) = &history_path { + let _ = editor.save_history(path); + } + + Ok(()) +}