Blog

Why We Introduced RustScript

A Rust-flavored surface language that gives the compiler the right information — and gives AI the right syntax


The Starting Point

pd-vm runs RustScript on a stack-based VM with bytecode that plugin frontends can also target. Built-in RustScript and plugin sources lower into a shared FrontendIr before compilation, then run on a runtime that uses no garbage collector and relies on deterministic ownership for memory management.

Given the plugin path for compatibility languages, a fair question is: what does a Rust-shaped scripting syntax buy this VM?

The short answer is that RustScript exists because its syntax carries intent that the compiler can act on. Move-vs-copy distinctions, borrow annotations, and mutability declarations are not runtime features. They are compiler inputs. And they happen to be in a syntax that AI models already know well.

Move and Copy: Telling the Compiler What You Mean

The VM Does Not Have Destructive Loads

ldloc reads a local slot and places a clone of the value on the stack. For heap-backed values (String, Array, Map), that clone is a cheap shared-ownership bump (Arc), not a deep copy. The slot retains its value after the load.

This is important: the VM instruction set is copy-by-default. There is no special move opcode.

Moves Are a Compiler Pattern, Not a VM Primitive

When the compiler decides a value should move rather than copy, it emits the pattern ldloc; ldc Null; stloc — load the value, then immediately null out the source slot. That gives the compiler precise control over when shared ownership ends and when backing memory becomes reclaimable, without adding any new opcodes or runtime machinery.

RustScript makes this compiler decision explicit in the source:

let a = [1, 2, 3];
let b = a;           // move: `a` is consumed, slot is cleared
// a is no longer available here

The compiler sees the local-to-local rebind of a non-copyable value, lowers it as a consuming load (MoveVar in the IR), and emits the null-clear sequence. Using a after this point is a compile-time error, not a runtime surprise.

Compare the same logic in a copy-by-default compatibility frontend:

let a = [1, 2, 3];
let b = a;           // copy: both `a` and `b` share the array

Both programs compile to valid bytecode on the same VM. The difference is what the compiler knows at the point of the assignment. RustScript's syntax tells it "this is a transfer of ownership." A copy-by-default plugin syntax tells it nothing, so it defaults to shared copy.

.copy() and Borrows

When a RustScript user wants to keep the source alive, they say so:

let a = [1, 2, 3];
let b = a.copy();    // explicit clone: `a` stays available
let c = &a;          // borrow: non-consuming access

.copy() emits a ToOwned node in the IR, which the codegen lowers to a deep clone. &a and &mut a emit Borrow / BorrowMut nodes, which currently collapse to non-consuming access forms but participate in the availability analysis — the compiler tracks that a is borrowed and rejects mutations that would invalidate the alias.

None of these constructs change the VM. They change what the compiler does before code reaches the VM.

How the Compiler Uses This Information

The Availability Pass

The core of RustScript's value is the availability analysis in availability.rs. This pass walks the IR with a FlowState that tracks, for every local slot, whether it is:

When enable_local_move_semantics is active (RustScript only), the pass enforces stricter rules:

SituationRustScript behaviorCompatibility plugin behavior
Local-to-local rebind of a collectionMove source, reject reuseCopy (shared ownership)
Local read after moveCompile errorN/A (no moves)
Field read from a struct-like mapMove by default, clear fieldCopy
Closure capture of a localFollows expression semantics (x moves, x.copy() copies, &x borrows)Always copy
Mutation while an alias existsRejected unless detached via .copy()Allowed (runtime CoW)

Consumed-Parameter Inference

RustScript function calls apply consumed-parameter inference at call sites. If the compiler can determine — by analyzing the function body — that a parameter is consumed (moved into a return value, or rebind-moved), it marks that parameter position as consuming. Callers that reuse the argument local after the call get a compile error.

This is entirely a compiler-side analysis. The runtime function-call contract does not change.

Liveness and Dead-Local Clearing

After availability, a liveness rewriter pass inserts Drop statements to null out dead locals as early as possible. This matters for the GC-free runtime: it turns compiler-known variable lifetimes into runtime memory reclamation points without a collector.

RustScript's ownership annotations make liveness analysis more precise because the compiler knows which reads are consuming and which are not.

Closure Captures: Copy, Borrow, Move

pd-vm closures capture values into hidden local slots at the time the closure is created. The source-level capture expression determines the CaptureBindingMode:

let data = [1, 2, 3];
let f = |x| data.len() + x;    // `data` is moved into the closure
// data is no longer available here

let shared = [4, 5, 6];
let g = |x| shared.copy().len() + x;  // `shared` is copied
// shared is still available

The capture binding modes are:

ModeSource syntaxEffect on outer scope
Copyx.copy() in capture positionOuter local stays available
Borrow&x in capture positionNon-consuming; alias tracked
BorrowMut&mut x in capture positionNon-consuming; mutation tracked
Movex (default for non-copyable)Outer local consumed

Compatibility frontends can choose to degrade captures to Copy mode. The capture slot still gets a value, but the outer scope keeps its own copy unconditionally.

Type Inference and the Ownership Surface

RustScript's type rules interact with its ownership model:

These are all DX-layer decisions. They catch mistakes before bytecode is generated without adding runtime cost.

The AI Angle: Syntax Proximity to Rust

There is a practical benefit that was not part of the original design but became increasingly clear as the project grew: AI code-generation models produce better RustScript than they produce compatibility languages for this VM.

The reason is corpus proximity. Large language models trained on significant amounts of Rust code have internalized Rust's ownership patterns, borrow semantics, and idiomatic structures. When asked to write RustScript for pd-vm — which lives in the same repository as the Rust runtime — the model:

This is not a theoretical benefit. In practice, AI-assisted development within the pd-vm repository naturally converges on RustScript because the model sees Rust patterns in scope and extends them into the scripting layer.

Where This Falls Short

Conclusion

RustScript exists because the VM's GC-free, ownership-based runtime model benefits from a surface language that makes ownership decisions visible to the compiler. The VM does not care — ldloc copies, stloc overwrites, and the instruction set stays simple. But the compiler cares deeply, and RustScript gives it the right inputs.

The AI affinity is a bonus that compounds over time. As more of the project is developed with LLM assistance, having a scripting language whose syntax matches the surrounding Rust codebase reduces friction and improves output quality at the same time.