|
| 1 | +# RustScript Design Critique |
| 2 | + |
| 3 | +A critical review of the design philosophy, architecture, and trade-offs of the RustScript language and the pd-vm runtime. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1. The Core Idea: What Is RustScript Trying to Be? |
| 8 | + |
| 9 | +RustScript occupies a deliberately strange niche: **a scripting language with Rust-like ownership semantics, running on a stack-based VM with no garbage collector, embedded inside an edge proxy runtime**. It also shares its VM and compiler infrastructure with JavaScript, Lua, and Scheme frontends. |
| 10 | + |
| 11 | +This is an ambitious combination. Let me break down where it's clever, where it's contradictory, and where it has structural problems. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 2. What Works Well |
| 16 | + |
| 17 | +### 2.1 The Multi-Frontend Shared IR Is a Genuinely Good Idea |
| 18 | + |
| 19 | +The architecture of lowering four different surface syntaxes (RustScript, JS, Lua, Scheme) into a single [FrontendIr](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#291-302) before compilation is clean and well-executed. The IR in [ir.rs](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs) is expressive enough to capture ownership semantics (`MoveVar`, `ToOwned`, `Borrow`, `BorrowMut`) while remaining language-agnostic at the bytecode level. |
| 20 | + |
| 21 | +This means the VM doesn't need to know about Rust syntax, and the JS/Lua/Scheme frontends get the performance benefits of the same optimizer pipeline for free. That's a solid engineering decision. |
| 22 | + |
| 23 | +### 2.2 The No-GC Memory Model Is Coherent (For Its Scope) |
| 24 | + |
| 25 | +The choice to use **owned `Value` enums with `Arc`-wrapped heap types** and **destructive `Ldloc` (move-by-default)** semantics is internally consistent. It gives you: |
| 26 | +- Deterministic memory reclamation |
| 27 | +- No GC pauses (critical for an edge proxy) |
| 28 | +- A simple mental model: values are either on the stack, in a local slot, or gone |
| 29 | + |
| 30 | +The `drop_contract_events` instrumentation for tracking value lifecycles is a nice touch for debugging memory behavior. |
| 31 | + |
| 32 | +### 2.3 The Execution Model Is Well-Designed for Embedding |
| 33 | + |
| 34 | +The `ExecOutcome` / `CallOutcome` / `VmYieldReason` layering for suspension is genuinely well thought out. The distinction between: |
| 35 | +- **Fuel/Epoch interrupts** (fire before any opcode, always safe) |
| 36 | +- **Host yields** (only at call boundaries, with IP rewind) |
| 37 | +- **Async pending** (IP advances, result deferred) |
| 38 | + |
| 39 | +...is clear and correct. The fact that instruction fusion explicitly checks `!interruption_enabled()` before entering fused paths shows careful reasoning about atomicity. |
| 40 | + |
| 41 | +### 2.4 The Opcode Set Is Lean and Honest |
| 42 | + |
| 43 | +24 opcodes. CIL/MSIL-style stack machine. No specialized opcodes for strings, arrays, or maps — those go through [Call](file:///d:/Workspace/project-d/pd-vm/src/compiler/typing/state.rs#359-363) to host/builtin functions. This is the right call for a VM that needs to stay simple, JIT-friendly, and maintainable. The type-hint overlay ([operand_type_hints](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#528-543)) for specializing arithmetic at the interpreter level is a pragmatic optimization that doesn't pollute the ISA. |
| 44 | + |
| 45 | +--- |
| 46 | + |
| 47 | +## 3. Philosophical Tensions |
| 48 | + |
| 49 | +### 3.1 "Rust Ownership for Scripting" — A Mismatch of Goals |
| 50 | + |
| 51 | +This is the elephant in the room. Rust's ownership model exists to provide **zero-cost abstractions with compile-time safety guarantees**. It works because Rust compiles to native code and the borrow checker runs at compile time with full program visibility. |
| 52 | + |
| 53 | +RustScript grafts ownership _semantics_ onto a dynamically-typed, interpreted/JIT'd VM. The result is: |
| 54 | + |
| 55 | +- **You pay the cognitive cost of ownership** (move semantics, `.copy()`, borrow rules) without the payoff of zero-overhead abstractions — values are still boxed `Arc<Vec<Value>>` at runtime. |
| 56 | +- **The borrow checker is necessarily weaker** than Rust's. The [availability.rs](file:///d:/Workspace/project-d/pd-vm/src/compiler/lifetime/availability.rs) and [liveness.rs](file:///d:/Workspace/project-d/pd-vm/src/compiler/lifetime/liveness.rs) passes run at compile time on a flat local-slot model, but they can't reason about borrows into data structures, lifetime parameters, or generic constraints. So you get a "Rust-ish" feeling that can't deliver on Rust's actual promises. |
| 57 | +- **Non-RustScript frontends don't use it at all.** JS, Lua, and Scheme silently degrade to copy-capture semantics. This means the ownership model is a RustScript-specific tax, not a VM-level guarantee. |
| 58 | + |
| 59 | +> [!WARNING] |
| 60 | +> The ownership model makes RustScript harder to learn than JS/Lua/Python *without* making it as safe as Rust. Users who want safety should use Rust directly; users who want scripting convenience are driven toward the JS/Lua frontends. This leaves RustScript in a no-man's land. |
| 61 | +
|
| 62 | +**Counter-argument:** if the goal is specifically to teach Rust concepts in a simpler environment, or to provide a scripting language that prevents the most common memory footguns (dangling references, use-after-move), then the partial borrow checker has value. But the documentation and positioning should be explicit about this. |
| 63 | + |
| 64 | +### 3.2 Dynamic Typing Meets Static Inference — A Shotgun Marriage |
| 65 | + |
| 66 | +The type system is caught between two worlds: |
| 67 | + |
| 68 | +- At the IR level, everything is an untyped [Expr](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#78-83) / `Stmt`. The `Value` enum is a flat tagged union with 7 variants. |
| 69 | +- The `typing/` passes bolt on flow-sensitive type inference _after_ parsing, producing `BoundType` annotations. These feed into operand type hints in the bytecode, enabling fast paths in the interpreter and JIT. |
| 70 | +- But the type system is **best-effort**: `BoundType::Unknown` is a valid state, and many operations (especially through containers, closures, or host calls) degrade to [Unknown](file:///d:/Workspace/project-d/pd-vm/src/compiler/pipeline.rs#24-29). |
| 71 | + |
| 72 | +This means: |
| 73 | +- You can't write a program that is _guaranteed_ to be fully typed. The type inference can and does give up. |
| 74 | +- Type errors are partially caught at compile time (e.g., [if](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#837-844)/`else` branch type conflicts) and partially at runtime (`VmError::TypeMismatch`). |
| 75 | +- The `TypeSchema` system (for struct-like schemas and container element types) is promising but incomplete — it doesn't compose well with the rest of the inference. |
| 76 | + |
| 77 | +The 90% static / 10% dynamic split is a pragmatic engineering trade-off, but it makes the language feel unreliable from a type-safety perspective. Users don't know when they can trust the compiler and when they're on their own. |
| 78 | + |
| 79 | +### 3.3 Four Frontends Is a Maintenance Liability |
| 80 | + |
| 81 | +Supporting JS, Lua, Scheme, and RustScript on one VM is impressive, but each frontend has a growing list of "current subset limits" that reveals the cost: |
| 82 | + |
| 83 | +| Frontend | Notable Limitations | |
| 84 | +|---|---| |
| 85 | +| **RustScript** | No recursion, closures can't be stored in collections, callable-in-closure rejection | |
| 86 | +| **JavaScript** | No arrow block bodies, no direct builtins, no `var`/`class` | |
| 87 | +| **Lua** | Minimal function bodies, limited multi-return, no pattern APIs | |
| 88 | +| **Scheme** | `apply` is crippled, `symbol?`/`procedure?` are `false`, `string->number` is a placeholder | |
| 89 | + |
| 90 | +Each frontend is a partial subset of its host language. This creates a **documentation and expectation problem**: users familiar with JavaScript/Lua/Scheme will constantly bump into unsupported features, and the error messages for these are not always clear. |
| 91 | + |
| 92 | +> [!IMPORTANT] |
| 93 | +> The question isn't "can you support 4 languages?" but "should you?" Every hour spent making Lua's `pcall` work is an hour not spent making RustScript's type system stronger. Consider whether JS/Lua/Scheme are strategic priorities or research curiosities. |
| 94 | +
|
| 95 | +--- |
| 96 | + |
| 97 | +## 4. Structural Criticisms |
| 98 | + |
| 99 | +### 4.1 Functions Are Not First-Class Citizens |
| 100 | + |
| 101 | +This is the single most impactful design limitation. Right now: |
| 102 | + |
| 103 | +- Functions are **inlined at call sites**. There are no real call frames, no recursive calls, no function values. |
| 104 | +- Closures exist but **cannot be stored in collections or returned** from functions. |
| 105 | +- `CallableUsedAsValue` is a compile error. |
| 106 | + |
| 107 | +This is not a scripting language limitation — it's closer to a macro expansion system. The [real_script_call_frames_plan.md](file:///d:/Workspace/project-d/plans/real_script_call_frames_plan.md) exists, which is encouraging, but until call frames are real, RustScript is structurally less expressive than any mainstream scripting language. |
| 108 | + |
| 109 | +**Impact:** No recursion means no tree traversal, no divide-and-conquer, no expression parsers, nothing with naturally recursive structure. This severely limits what users can write. |
| 110 | + |
| 111 | +### 4.2 The `Value` Enum Has No Function Variant |
| 112 | + |
| 113 | +Looking at [bytecode.rs](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs): |
| 114 | + |
| 115 | +```rust |
| 116 | +pub enum Value { |
| 117 | + Null, Int(i64), Float(f64), Bool(bool), |
| 118 | + String(SharedString), Array(SharedArray), Map(SharedMap), |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +No [Function](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#272-280), [Closure](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#78-83), [Callable](file:///d:/Workspace/project-d/pd-vm/src/compiler/typing/state.rs#359-363). This is a consequence of 4.1 — functions aren't values, so they can't be in the value type. But this also means: |
| 123 | +- No [map(array, fn)](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#336-339) where `fn` is a user-defined function |
| 124 | +- No callbacks |
| 125 | +- No event handlers passed as values |
| 126 | + |
| 127 | +For an edge proxy runtime that wants user-authored request handlers, this seems like a significant gap. |
| 128 | + |
| 129 | +### 4.3 The [VmMap](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#26-29) Uses Identity Equality for Composite Keys |
| 130 | + |
| 131 | +Arrays and maps as map keys compare by `Arc::ptr_eq` (heap identity), not structural equality. This is documented and intentional, but it's surprising behavior: |
| 132 | + |
| 133 | +``` |
| 134 | +let key = [1, 2, 3]; |
| 135 | +let map = {key: "value"}; |
| 136 | +let lookup = [1, 2, 3]; // Different allocation, same content |
| 137 | +map[lookup] // -> null (miss!) |
| 138 | +``` |
| 139 | + |
| 140 | +This is a foot-gun for anyone used to Python dicts or Lua tables. It's the right engineering choice (structural hashing of nested mutable containers is expensive and fragile), but it should probably be a loud warning in the docs or even a compile-time lint. |
| 141 | + |
| 142 | +### 4.4 Setting a Map Entry to [null](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#734-745) Deletes the Key |
| 143 | + |
| 144 | +From the frontends README: |
| 145 | +> Setting a map entry to [null](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#734-745)/`None` removes that key, including during map/object literal construction, so `{a: null}` omits `a`. |
| 146 | +
|
| 147 | +This is a bold semantic choice. It means: |
| 148 | +- You can't have a key that intentionally maps to [null](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#734-745). |
| 149 | +- The pattern `if map[key] == null` conflates "key absent" with "key present with null value". |
| 150 | +- This breaks JSON round-tripping for objects with explicit [null](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#734-745) fields (despite the JSON builtins being strict about other things like duplicate keys). |
| 151 | + |
| 152 | +This decision saves runtime complexity (no tombstone tracking, simpler [get](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#90-93) semantics) but creates a semantic hole that will surprise users coming from any other language. |
| 153 | + |
| 154 | +### 4.5 No Real Error Handling |
| 155 | + |
| 156 | +There is no [try](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#608-638)/`catch`, no [Result](file:///d:/Workspace/project-d/pd-vm/src/compiler/typing/state.rs#353-357) type in the value system (yet — `Option/Result` plan exists), and host errors bubble up as `VmError::HostError(String)`. The Lua `pcall` lowering has "success-only semantics" (always returns `true`). This means scripts cannot handle errors — they can only crash. |
| 157 | + |
| 158 | +For an edge proxy runtime, this is a reliability concern. A malformed request header shouldn't crash the entire request handler. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## 5. Architecture-Level Observations |
| 163 | + |
| 164 | +### 5.1 The JIT Is Trace-Based Without Full CFG Coverage |
| 165 | + |
| 166 | +The trace JIT records linear sequences, with backward `Brfalse` as the only loop construct. Forward branches become guard exits back to the interpreter. This is fine for hot loops but: |
| 167 | +- **Branchy code** (complex if/else chains, match arms) will never JIT well. |
| 168 | +- **The AOT effort** (documented in [aot_whole_program_effort.md](file:///d:/Workspace/project-d/plans/aot_whole_program_effort.md)) correctly identifies this as a ~3 week rewrite to get full-CFG Cranelift emission. |
| 169 | + |
| 170 | +The trace JIT is a reasonable v1, but the architecture should evolve toward method-level compilation. |
| 171 | + |
| 172 | +### 5.2 The Compilation Pipeline Has Too Many Implicit Phases |
| 173 | + |
| 174 | +The pipeline in [pipeline.rs](file:///d:/Workspace/project-d/pd-vm/src/compiler/pipeline.rs) has the following implicit ordering: |
| 175 | + |
| 176 | +``` |
| 177 | +parse → legalize_builtins → validate_types → enforce_availability → infer_types → codegen |
| 178 | +``` |
| 179 | + |
| 180 | +But these phases communicate through mutation of the same [FrontendIr](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#291-302) struct (adding/removing nodes, inserting `Drop` stmts). The data flow between phases is implicit. A more explicit phase architecture (each phase consuming one type and producing another) would make it easier to reason about where things happen and add new passes. |
| 181 | + |
| 182 | +### 5.3 The 256-Local Limit Will Bite |
| 183 | + |
| 184 | +Local slots are [u8](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#860-868) in bytecode (`Ldloc`/`Stloc` take a single byte operand). This caps programs at 256 locals (including hidden slots for closures, temporaries, etc.). For non-trivial programs with many variables and closure captures, this will be exhausted. The IR already uses [u16](file:///d:/Workspace/project-d/pd-vm/src/vm/mod.rs#869-873) (`LocalSlot = u16`), but the bytecode encoding doesn't. |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## 6. Summary of Recommendations |
| 189 | + |
| 190 | +| Priority | Recommendation | |
| 191 | +|---|---| |
| 192 | +| 🔴 Critical | Implement real call frames and make functions first-class values | |
| 193 | +| 🔴 Critical | Add error handling (at minimum [Result](file:///d:/Workspace/project-d/pd-vm/src/compiler/typing/state.rs#353-357)-like values or [try](file:///d:/Workspace/project-d/pd-vm/src/bytecode.rs#608-638)/`catch`) | |
| 194 | +| 🟡 Important | Clarify the ownership model's purpose — is it for safety, pedagogy, or performance? | |
| 195 | +| 🟡 Important | Re-evaluate whether 4 frontends is worth the maintenance cost | |
| 196 | +| 🟡 Important | Reconsider null-deletes-key semantics for maps | |
| 197 | +| 🟢 Nice-to-have | Move toward method-level JIT compilation (whole-program AOT) | |
| 198 | +| 🟢 Nice-to-have | Explicit pipeline phase types instead of mutating [FrontendIr](file:///d:/Workspace/project-d/pd-vm/src/compiler/ir.rs#291-302) in place | |
| 199 | +| 🟢 Nice-to-have | Widen bytecode local encoding to u16 | |
| 200 | + |
| 201 | +--- |
| 202 | + |
| 203 | +## 7. Final Take |
| 204 | + |
| 205 | +RustScript is an interesting experiment in **bringing systems-language discipline to scripting**. The VM engineering is solid — the execution model, suspension semantics, and JIT infrastructure are well-built for the edge-proxy use case. The multi-frontend architecture is genuinely clever. |
| 206 | + |
| 207 | +But the language design is caught between identities. It's not safe enough to replace Rust, not convenient enough to replace Lua, and not expressive enough (no recursion, no first-class functions) to be a general-purpose scripting language. The ownership model adds complexity without delivering the full value that Rust users expect. |
| 208 | + |
| 209 | +The most impactful work would be: **make functions real** (call frames + first-class values), **add error handling**, and **decide what RustScript's identity is** — then cut what doesn't serve that identity. |
0 commit comments