rustscript
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 fs;
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 checkruns two gates. It builds a small cargo project around the files and runscargo checkon 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
use add;
// util.rs
// util/math.rs
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,
letandmut, arithmetic, comparison, logical and bitwise operators, casts, andT::from/T::try_fromnumeric conversions if,if let,while,loop,forover ranges, vectors, maps, and chars,matchwith guards and patternsstruct,enum, tuple structs, unit structs,implmethods and associated functions- modules across files and inline, every import style, re-exports, module
level
constandstatic, 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 likeToString::to_stringpassed as a function value Vec,String,HashMap,Option,Result, the?operator- slicing with ranges,
&v[1..3],&s[..n], and open ends likev[1..]in index position format!andprintln!with{name},{:?}, width, and precisionmatches!, byte string literalsb"...", andunsafeblocks 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 platformsymlinkstd::io,stdin,stdout,stderr,Read,Write,BufReader,Seek,linesreading, andIsTerminalstd::process::Command,output,status, andspawnwithStdiopiping and aChildyou can stream, feed, andwaitonstd::net, blockingTcpListenerandTcpStreamstd::time,Instant,SystemTime, andDurationstd::env, real script args,vars,var,var_os,set_var,remove_var,current_dir,set_current_dir,temp_dir, andconsts::OS/ARCHstd::process::exitstd::path,PathandPathBufwithdisplay,is_dir,join,ancestors, and morestd::collections,HashMap,BTreeMap, sets, and theentryAPI
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.
serdeandserde_json, including typedfrom_str::<T>into your own structs, with#[serde(rename = "..")]andOption<T>fields honored, so camelCase APIs map onto snake_case fieldsanyhowforResult,?,bail!,ensure!, andcontextreqwestfor 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 cookiesregexfor matching, capture groups, and replacewhichto find a program on PATHglobfor path matchingdirsfor home, cache, and config dirschronoforUtc::now, formatting, and date partsrandfor random numbers and bytestomlandserde_yamlfor typed configcoloredfor terminal colorsbase64andhexfor encodingctrlcfor a Ctrl-C handlertempfilefor temp dirs, files, andNamedTempFilejsonwebtokenfor signing JWTs,Header,EncodingKey::from_ec_pemandfrom_secret, andencode, 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.
winregfor the registry.RegKey::predef,open_subkey,create_subkey,enum_keys,enum_values, the typedget_valueandset_valuefor numbers and strings, the untypedget_raw_valueandset_raw_valuefor binary and multi string, plusdelete_value,delete_subkeyanddelete_subkey_allwindows-servicefor services.ServiceManager::local_computer,open_service, thenquery_status,query_config,change_config,startandstopwmifor WMI queries.WMIConnection::newandwith_namespace_path, thenraw_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)].