run-rs 0.1.5

Run a subset of Rust as an interpreted script
run-rs-0.1.5 is not a library.

rustscript

CI Marketplace Licence

Linux macOS Windows

Run a subset of Rust as an interpreted script. Write helper and automation scripts in real Rust and run them like Python or a shell script, without waiting for a full compile.

#!/usr/bin/env rust

use std::fs;

fn main() -> anyhow::Result<()> {
    let text = fs::read_to_string("notes.txt")?;
    println!("{} lines", text.lines().count());
    Ok(())
}

Make it executable and run it directly.

chmod +x notes.rs
./notes.rs

The idea

A script is a normal Rust program with fn main, one file or a module tree. Two layers share the same source.

  • An interpreter parses the files with syn, compiles them once to bytecode, and runs them on a register machine. Locals are numbered slots, so a variable read is an array index, not a name lookup. Ownership and borrow rules carry no meaning at runtime, so there is no borrow checker cost and startup is fast.
  • rust check runs two gates. It builds a small cargo project around the files and runs cargo check on it, which proves the script is valid Rust. It then compiles the script and walks the bytecode to find methods this interpreter does not implement. It is a separate opt-in step, not part of running, and the cargo result is cached by source hash.

Running never waits on a check, so a script starts at once, like Python. The interpreter needs no type checker of its own. When you want proof that a script is valid Rust, rust check makes the real compiler the authority.

Being valid Rust is not the whole story, because the interpreter runs a subset. "x".repeat(3) compiles whether or not the bridge implements repeat, and running the script only exercises the lines that execute, so a gap inside a loop body stays hidden until that loop has data. So rust check also walks the compiled bytecode and reports every method call the interpreter has no implementation for, on every branch, without executing anything. Path calls like std::process::exit are not covered yet.

Install

cargo install run-rs

To install it from a local checkout instead:

cargo install --path crates/rustscript

This installs a binary named rust.

Released versions also ship prebuilt binaries on the releases page. The Linux builds are static musl binaries, so they run on any distribution, including Alpine and old container images. The macOS build is one universal binary that covers both Intel and Apple Silicon.

GitHub Actions

This repository is also a GitHub Action, so a workflow can install the interpreter and run scripts with it.

- uses: VladasZ/rustscript@v0.1

That puts rust on PATH for the rest of the job. Give it a script to install and run in one step.

- uses: VladasZ/rustscript@v0.1
  with:
    script: tools/release.rs
    args: --dry-run
input default meaning
version the calling tag, else newest version to install, for example v0.1.0
script empty script to execute, empty means install only
mode run run, build or check
args empty extra arguments passed to the script
github-token github.token only used to resolve the newest release

Outputs are version, the version that was installed, and bin-path, the directory holding the binary.

The action downloads a prebuilt binary and verifies it against the published SHA256SUMS, so it costs a second or two rather than a full compile. It covers Linux, macOS and Windows on both x86_64 and arm64. See docs/github-actions.md for the details.

Usage

rust run FILE.rs     interpret the script
rust FILE.rs         same as run
rust FILE.rs cmp     compile and run, `cmp` first arg is reserved
rust build FILE.rs   compile to a native binary, cache it, then run
rust check FILE.rs   validate with cargo check and interpreter coverage, does not run
rust clean           clear the cache
rust update          install the latest RustScript from GitHub
rust --version       show version and build information

rust update is explicit. It lists the release tags of VladasZ/rustscript, takes the newest full version, and compares it with the running version. An installed version that already matches, or is newer, is a no-op. Otherwise it installs that tag with cargo install. Prereleases and the moving minor tags are never update targets. On Windows it moves the running executable aside before installation and restores it if the update fails.

rust --version prints the package version, Git commit, UTC build time, and Cargo build profile. A local build with tracked changes marks the commit as dirty, for example:

rustscript 0.1.0 (4ea5a27-dirty, built 2026-07-18T09:21:09Z, release)

rust build compiles the script with cargo instead of interpreting it, then runs the resulting binary and exits with its status. The binary is cached by source hash under the cache dir, so an unchanged script runs again instantly with no cargo call. The first build of a new or edited script is a real cargo build, so it is slow, later runs are not. A successful build also proves the script is valid Rust, so it doubles as a check. Use it for CPU heavy scripts where native speed pays back the build cost. The one shared cargo target dir is kept so an edit rebuilds only the script crate, but only the final binaries are cached, never per script target dirs.

The word cmp as the first argument to a script is reserved. When you run FILE.rs cmp ..., the interpreter compiles and runs the script the same way rust build does, then passes the rest of the arguments on. This is what makes a plain launcher give both modes for free. A command named foo interprets, and foo cmp runs the compiled build, since the launcher forwards the words unchanged. Because cmp is intercepted, a script must not use cmp as its own first positional argument, it would never reach the script. Later arguments are free, only the very first is reserved.

A #!/usr/bin/env rust first line lets a .rs file run on its own. A shebang is legal Rust, so the file still passes cargo check. A symlink to a script runs too, even without an .rs extension. The link is resolved first, so module files are found next to the real source.

Modules

A script can span multiple files with normal Rust module syntax. mod name; loads name.rs or name/mod.rs next to the declaring file, following the same directory rules as rustc, and inline mod name { .. } blocks work too. Modules nest to any depth.

// tool.rs
mod util;
use util::math::add;

fn main() {
    println!("{}", add(2, 3));
}
// util.rs
pub mod math;
// util/math.rs
pub fn add(a: i64, b: i64) -> i64 { a + b }

Paths resolve like real Rust: crate::, self::, super::, plain, renamed, and grouped imports, use x::{self}, and pub use re-export chains. Imported structs, enums, functions, consts, statics, and type aliases all work across files, including struct literals and tuple struct constructors through an alias. Two modules can each define a type with the same name. When you run rust check it covers the whole file tree, and a change to any module rechecks. Visibility is not enforced at runtime, rust check is the authority when you want it.

Not supported: #[path] on a mod declaration, and glob imports of script modules like use util::*, both stop with a clear error.

A script inside a cargo crate can also use a local library crate declared as a path dependency in the nearest Cargo.toml. The interpreter grafts that crate in from source, and the cargo check gate treats it as a real path dependency, so a set of scripts can share one helper crate.

See docs/multifile.md for a proper guide, a worked example, and the common mistakes.

What works

  • functions, recursion, let and mut, arithmetic, comparison, logical and bitwise operators, casts, and T::from / T::try_from numeric conversions
  • if, if let, while, loop, for over ranges, vectors, maps, and chars, match with guards and patterns
  • struct, enum, tuple structs, unit structs, impl methods and associated functions
  • modules across files and inline, every import style, re-exports, module level const and static, and type aliases
  • closures and the common iterator methods, map, filter, fold, find, any, all, sort_by, sort_by_key, copied, cloned, and more, including method paths like ToString::to_string passed as a function value
  • Vec, String, HashMap, Option, Result, the ? operator
  • slicing with ranges, &v[1..3], &s[..n], and open ends like v[1..] in index position
  • format! and println! with {name}, {:?}, width, and precision
  • matches!, byte string literals b"...", and unsafe blocks run their body
  • #[derive(...)] is accepted, serialization is done by reflection

Standard library subset

Scripts use plain std. The interpreter bridges the common parts.

  • std::fs, read, write, create and remove dirs, copy, rename, read_dir, canonicalize, metadata, symlink_metadata, read_link, File, OpenOptions, and the platform symlink
  • std::io, stdin, stdout, stderr, Read, Write, BufReader, Seek, lines reading, and IsTerminal
  • std::process::Command, output, status, and spawn with Stdio piping and a Child you can stream, feed, and wait on
  • std::net, blocking TcpListener and TcpStream
  • std::time, Instant, SystemTime, and Duration
  • std::env, real script args, vars, var, var_os, set_var, remove_var, current_dir, set_current_dir, temp_dir, and consts::OS / ARCH
  • std::process::exit
  • std::path, Path and PathBuf with display, is_dir, join, ancestors, and more
  • std::collections, HashMap, BTreeMap, sets, and the entry API

Bridged crates

A script may declare real dependencies. A crate runs only if the interpreter has a native bridge for it. These are bridged today.

  • serde and serde_json, including typed from_str::<T> into your own structs, with #[serde(rename = "..")] and Option<T> fields honored, so camelCase APIs map onto snake_case fields
  • anyhow for Result, ?, bail!, ensure!, and context
  • reqwest for HTTP and HTTPS over rustls, the blocking API in a plain script and the async API under #[tokio::main], with headers, query params, json bodies, a timeout, and cookies
  • regex for matching, capture groups, and replace
  • which to find a program on PATH
  • glob for path matching
  • dirs for home, cache, and config dirs
  • chrono for Utc::now, formatting, and date parts
  • rand for random numbers and bytes
  • toml and serde_yaml for typed config
  • colored for terminal colors
  • base64 and hex for encoding
  • ctrlc for a Ctrl-C handler
  • tempfile for temp dirs, files, and NamedTempFile
  • jsonwebtoken for signing JWTs, Header, EncodingKey::from_ec_pem and from_secret, and encode, so ES256 and HS256 tokens work

Three more are bridged on Windows only. Each is compiled out elsewhere, and calling one on another platform returns a plain error saying so rather than failing to resolve.

  • winreg for the registry. RegKey::predef, open_subkey, create_subkey, enum_keys, enum_values, the typed get_value and set_value for numbers and strings, the untyped get_raw_value and set_raw_value for binary and multi string, plus delete_value, delete_subkey and delete_subkey_all
  • windows-service for services. ServiceManager::local_computer, open_service, then query_status, query_config, change_config, start and stop
  • wmi for WMI queries. WMIConnection::new and with_namespace_path, then raw_query, which returns one map per instance. Queries only, no instance creation or method invocation yet

A crate without a bridge still passes cargo check but stops the interpreter with unsupported crate when its code runs.

Async and parallelism

A script whose main is #[tokio::main] runs on a second engine built for real multi-core work. It uses a multi-thread tokio runtime, so tokio::spawn tasks run on many threads at once, .await, tokio::join!, and tokio::task::yield_now work, tokio::time::sleep is real, and the async reqwest client sends requests concurrently. A plain script with no #[tokio::main] keeps the fast single-thread engine untouched, so it pays nothing for this. The current_thread flavor is rejected, only the multi-thread runtime is offered.

Not supported

std::thread is rejected, use tokio::spawn under #[tokio::main] for real parallelism. unsafe blocks run their body, since edition 2024 needs unsafe around calls like env::set_var. Lifetimes and generics parse and run, they just carry no meaning at runtime. static mut is rejected, plain static behaves like a const.

Caching

Check results, compiled binaries, and the prebuilt dependencies live in ~/.cache/rustscript. Interpreting a script never touches this cache. When you run rust check, the fixed dependency set compiles once into a shared target, and each script's result is cached by source hash so an unchanged script rechecks instantly. rust build shares that target and adds the finished binary to a bin folder keyed by the same hash, so a rerun of an unchanged script skips cargo. rust clean clears the cache.

Examples

The scripts in crates/examples/examples cover the common ground people use to judge a scripting language. Fizzbuzz, fibonacci, word count, quicksort, sieve, towers of hanoi, roman numerals, a state machine, file and directory work, a shell command, json config, typed json, an http fetch, and regex extraction. Newer ones show process spawning with streamed output, file and stdin I/O, file metadata and symlinks, tcp sockets, threads, dates, temp dirs, base64 and hex, toml and yaml config, terminal colors, and running a program from PATH.

Run one with the interpreter.

rust run crates/examples/examples/word_count.rs

Compile all of them with the real toolchain as a second check.

cargo build --examples -p rustscript-examples

Tests

cargo test                              all suites, see below
cargo test --test run                   interpreter behavior
cargo test --test equivalence           compiled example vs interpreted, byte identical
cargo test --test multifile             module loading, imports, and conformance
cargo test --test check -- --ignored    the cargo check gate, valid and invalid

The equivalence suite runs every example both as a compiled cargo binary and through the interpreter, then checks the output matches byte for byte. It is the strongest guarantee that the interpreter behaves like the real compiler. The multifile suite does the same for the crates/conformance crate, a deep module tree that exercises every import style, re-export chains, and cross module types, consts, and aliases.

CI runs the same suites on every push to main and every pull request, on Linux, macOS and Windows, alongside cargo fmt --check and cargo clippy --workspace --all-targets -- -D warnings.

Benchmarks

The bench crate compares rustscript against native Rust, Node, and Python 3 on equivalent idiomatic tasks with byte-identical output. It records interleaved wall-clock, self-timed compute, and peak-memory samples at two sizes, retains the raw data and provenance, and draws one PNG per case and tier. See bench/README.md for the methodology, current scope limits, and result format, and docs/profiling.md for how to find interpreter hot spots.

cargo run --release --bin bench
cargo run --release --bin chart

Versioning

Releases are semver tags like v0.1.0. RustScript is still 0.x, so the minor is the breaking axis, and each minor line gets a moving tag that follows its newest patch.

v0.1.0    exact release, never moves
v0.1      moving, follows the newest v0.1.z

Track @v0.1 to pick up patches without surprises, or pin @v0.1.0 when a workflow must never change. A breaking change becomes v0.2.0 with a new v0.2 tag, so it can only arrive when you move the ref yourself. There is no moving v0 tag, because it would span breaking changes and promise nothing.

The action and the interpreter share the tag. Calling the action at an exact version tag installs that same interpreter version by default, so one number covers both. The version input overrides it when you want a new action with an older interpreter.

Licence

Dual licensed under either MIT or Apache-2.0, at your option.

Status

Early but usable. Script arguments, std::io::stdin, process spawning, files, sockets, and time all work now. Typed deserialization honors #[serde(rename = "..")] and Option<T> fields. Known refinements still open are container-level #[serde(rename_all = "..")] and #[serde(default)].