Skip to content

Commit 1ada821

Browse files
committed
docs: add claude's critique
1 parent a9d1a77 commit 1ada821

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)