Skip to content

Commit ba63686

Browse files
olwangclaude
andcommitted
Merge feat/capability: capability objects (keyword sugar + VM dynamic dispatch)
Third spec-todo roadmap feature (git worktree, merged once fully working: full parity sweep + clippy green). Includes a VM CallDynamic fix that also repairs generic protocol-bound dispatch parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 8f06591 + 3994039 commit ba63686

7 files changed

Lines changed: 237 additions & 38 deletions

File tree

crates/rsscript/src/hir.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ pub struct HirEffectEvent {
208208
#[derive(Debug, Clone, PartialEq, Eq)]
209209
pub enum HirReturnProof {
210210
NoValue,
211-
Ident { name: String },
211+
Ident {
212+
name: String,
213+
},
212214
StructConstructor,
213215
FreshCall,
214216
/// A literal (string, number, or boolean). A literal owns no borrowed or
@@ -718,6 +720,30 @@ impl Hir {
718720
}
719721
}
720722

723+
/// Concrete impl targets for a protocol method: `(implementing type name,
724+
/// target function name)` for every `impl Protocol for Type` that maps
725+
/// `method`. Used by the reg-VM to dynamically dispatch a `Protocol.method`
726+
/// call by the receiver's runtime type (capability objects + generic bounds),
727+
/// mirroring the compiled backend's closed-world enum dispatch.
728+
pub(crate) fn protocol_method_targets(
729+
&self,
730+
protocol: &str,
731+
method: &str,
732+
) -> Vec<(String, String)> {
733+
let protocol = type_root_name(protocol);
734+
let method = type_root_name(method);
735+
self.protocol_impls
736+
.iter()
737+
.filter(|imp| imp.protocol == protocol)
738+
.filter_map(|imp| {
739+
imp.mappings
740+
.iter()
741+
.find(|mapping| mapping.method == method)
742+
.map(|mapping| (imp.type_name.clone(), mapping.target.clone()))
743+
})
744+
.collect()
745+
}
746+
721747
pub fn resolve_function(&self, namespace: Option<&str>, name: &str) -> Option<&FunctionSig> {
722748
if let Some(namespace) = namespace
723749
&& let Some(signature) = self.signatures.get(&qualified_key(namespace, name))
@@ -3228,7 +3254,11 @@ fn collect_struct_pattern_binding_types(
32283254
};
32293255
let field_type_name = substitute_type_params(&field_type.type_name, substitutions);
32303256
if let Some(pattern) = &field.pattern {
3231-
bindings.extend(match_pattern_binding_types(hir, pattern, Some(&field_type_name)));
3257+
bindings.extend(match_pattern_binding_types(
3258+
hir,
3259+
pattern,
3260+
Some(&field_type_name),
3261+
));
32323262
} else if let Some(binding) = &field.binding {
32333263
bindings.push((binding.clone(), field_type_name));
32343264
}

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,6 +1708,17 @@ enum RegInstr {
17081708
/// `&mut` semantics.
17091709
mut_args: Vec<usize>,
17101710
},
1711+
/// Dynamic protocol dispatch: a `Protocol.method(self: x, ...)` call whose
1712+
/// concrete impl is chosen at runtime by `args[0]`'s struct type name. This is
1713+
/// how capability objects and generic protocol bounds dispatch in the VM,
1714+
/// mirroring the compiled backend's closed-world enum dispatch. `dispatch`
1715+
/// maps each implementing struct name to the impl's target function id.
1716+
CallDynamic {
1717+
dst: Reg,
1718+
dispatch: Vec<(String, usize)>,
1719+
args: Vec<Reg>,
1720+
mut_args: Vec<usize>,
1721+
},
17111722
/// `spawn f(args)` / `async let`: start `function` as a new concurrent task
17121723
/// and put a Task handle in `dst` (the spawning task keeps running).
17131724
SpawnTask {
@@ -5125,6 +5136,36 @@ impl RegLowerer<'_> {
51255136
});
51265137
return Ok(dst);
51275138
}
5139+
// Dynamic protocol dispatch: `Protocol.method(self: x, ...)`
5140+
// where `Protocol` is a protocol with impls. The concrete
5141+
// function is selected at runtime by `args[0]`'s struct type
5142+
// (capability objects + generic bounds) — the VM equivalent
5143+
// of the compiled backend's closed-world enum dispatch.
5144+
// Checked before the `function_ids` lookup because a protocol
5145+
// method also appears there as a bodyless stub (which would
5146+
// wrongly return `Unit`).
5147+
let dispatch: Vec<(String, usize)> = self
5148+
.hir
5149+
.protocol_method_targets(namespace_root, name_root)
5150+
.into_iter()
5151+
.filter_map(|(type_name, target)| {
5152+
self.function_ids
5153+
.get(type_root_name(&target))
5154+
.copied()
5155+
.map(|function| (type_name, function))
5156+
})
5157+
.collect();
5158+
if !dispatch.is_empty() {
5159+
let mut_args =
5160+
self.native_mut_arg_positions(Some(namespace_root), name_root);
5161+
self.emit(RegInstr::CallDynamic {
5162+
dst,
5163+
dispatch,
5164+
args: arg_regs,
5165+
mut_args,
5166+
});
5167+
return Ok(dst);
5168+
}
51285169
if let Some(function) = self.function_ids.get(&qualified_key).copied() {
51295170
let mut_args =
51305171
self.native_mut_arg_positions(Some(namespace_root), name_root);
@@ -5363,9 +5404,7 @@ impl RegLowerer<'_> {
53635404
.is_none_or(|pattern| self.is_supported_match_pattern(pattern))
53645405
})
53655406
}
5366-
MatchPattern::List {
5367-
prefix, suffix, ..
5368-
} => prefix
5407+
MatchPattern::List { prefix, suffix, .. } => prefix
53695408
.iter()
53705409
.chain(suffix)
53715410
.all(|pattern| self.is_supported_match_pattern(pattern)),
@@ -5422,7 +5461,10 @@ impl RegLowerer<'_> {
54225461
let mut failures = Vec::new();
54235462
let required = (prefix.len() + suffix.len()) as i64;
54245463
let len = self.temp();
5425-
self.emit(RegInstr::ListLen { dst: len, list: src });
5464+
self.emit(RegInstr::ListLen {
5465+
dst: len,
5466+
list: src,
5467+
});
54265468
let bound = self.temp();
54275469
self.emit(RegInstr::LoadInt {
54285470
dst: bound,
@@ -7814,6 +7856,51 @@ impl RegVm {
78147856
});
78157857
continue 'frames;
78167858
}
7859+
RegInstr::CallDynamic {
7860+
dst,
7861+
dispatch,
7862+
args,
7863+
mut_args,
7864+
} => {
7865+
// Select the concrete impl by the runtime struct type of
7866+
// the receiver (args[0]), then call it like `CallKnown`.
7867+
let receiver = self.reg(base + args[0]).clone();
7868+
let type_name = match &receiver {
7869+
VmValue::Struct(data) => Some(data.name.clone()),
7870+
_ => None,
7871+
};
7872+
let callee_id = type_name.as_ref().and_then(|name| {
7873+
dispatch
7874+
.iter()
7875+
.find(|(struct_name, _)| struct_name.as_str() == &**name)
7876+
.map(|(_, id)| *id)
7877+
});
7878+
let Some(callee_id) = callee_id else {
7879+
return Err(EvalError::Runtime(format!(
7880+
"reg VM dynamic protocol dispatch found no impl for receiver `{}`.",
7881+
type_name.as_deref().unwrap_or("<non-struct value>")
7882+
)));
7883+
};
7884+
let callee = Rc::clone(&unit.functions[callee_id]);
7885+
self.prepare_frame(next_base, callee.regs);
7886+
for (index, reg) in args.iter().enumerate() {
7887+
let value = self.reg(base + *reg).clone();
7888+
self.set_reg(next_base + index, value);
7889+
}
7890+
let mut_writeback = mut_args
7891+
.iter()
7892+
.map(|&pos| (base + args[pos], next_base + pos))
7893+
.collect();
7894+
self.frames.last_mut().expect("active frame").ip = ip;
7895+
self.frames.push(Frame {
7896+
func: callee,
7897+
ip: 0,
7898+
base: next_base,
7899+
ret_dst: base + *dst,
7900+
mut_writeback,
7901+
});
7902+
continue 'frames;
7903+
}
78177904
RegInstr::SpawnTask {
78187905
dst,
78197906
function: callee_id,

crates/rsscript/src/syntax/parser.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3988,6 +3988,28 @@ fn parse_type_ref(tokens: &[Token], start: usize, end: usize) -> Option<TypeRef>
39883988
if let Some(tuple) = parse_tuple_type_ref(tokens, start, end) {
39893989
return Some(tuple);
39903990
}
3991+
// `capability <Protocol>` (spec §20.2-2) is sugar for `Capability<<Protocol>>`:
3992+
// an explicit, review-visible dynamic-dispatch boundary. It may follow an
3993+
// effect keyword (`read capability Store<T>`); the effect is handled by the
3994+
// parameter parser, so skip it here when locating the `capability` marker.
3995+
if let Some(cap_index) = (start..end).find(|index| {
3996+
ident_name(&tokens[*index])
3997+
.is_some_and(|name| name != "read" && name != "mut" && name != "take")
3998+
}) && ident_name(&tokens[cap_index]).is_some_and(|name| name == "capability")
3999+
{
4000+
let inner = parse_type_ref(tokens, cap_index + 1, end)?;
4001+
return Some(TypeRef {
4002+
name: "Capability".to_string(),
4003+
args: vec![inner],
4004+
malformed_arg_spans: Vec::new(),
4005+
is_fresh: false,
4006+
is_noescape: false,
4007+
is_owned: false,
4008+
fn_params: Vec::new(),
4009+
fn_return: None,
4010+
span: tokens[cap_index].span.clone(),
4011+
});
4012+
}
39914013
let is_fresh = tokens
39924014
.get(start)
39934015
.and_then(ident_name)

crates/rsscript/tests/checker_package/misc.rs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,27 +70,28 @@ fn package_manager_spec_uses_current_http_and_env_facade_shapes() {
7070
}
7171

7272
#[test]
73-
fn rss_spec_keeps_protocol_dynamic_dispatch_unimplemented() {
74-
// Dynamic dispatch is now a committed in-scope roadmap item (§20.2), but it is
75-
// still NOT implemented: the spec must not describe it as implemented, settled,
76-
// or available to package contracts until it is actually built.
73+
fn rss_spec_documents_capability_dispatch_and_rejects_implicit_dyn() {
74+
// Dynamic dispatch is implemented ONLY as the explicit `Capability<Protocol>`
75+
// form (§20.2-2). The spec must document that, and must still reject implicit
76+
// protocol-typed values / Rust-style `dyn` coercion as non-goals.
7777
let spec = fs::read_to_string(common::language_spec_path())
7878
.unwrap_or_else(|error| panic!("RSScript spec should read: {error}"));
7979

8080
for forbidden in [
81-
"Dynamic dispatch (admitted",
8281
"RSScript admits protocol-typed dynamic dispatch",
83-
"The design decision is settled: dynamic dispatch is supported",
82+
"implicit dyn coercion is supported",
8483
"form is admitted, not excluded",
8584
"protocol_dynamic_dispatch",
8685
] {
8786
assert!(
8887
!spec.contains(forbidden),
89-
"protocol dynamic dispatch must remain deferred, found `{forbidden}`"
88+
"implicit dynamic dispatch must stay a non-goal, found `{forbidden}`"
9089
);
9190
}
92-
assert!(spec.contains("Dynamic dispatch (in scope §20.2; not yet implemented)"));
93-
assert!(spec.contains("does not yet implement protocol-typed dynamic dispatch"));
94-
assert!(spec.contains("The only implemented and specified protocol call form is"));
95-
assert!(spec.contains("explicit `Protocol.method(...)` dispatch"));
91+
assert!(spec.contains("Dynamic dispatch (explicit capability form implemented §20.2-2)"));
92+
assert!(
93+
spec.contains("`Capability<Protocol>` form (with the `capability Protocol` keyword sugar)")
94+
);
95+
assert!(spec.contains("Rust-style `dyn Trait` vtable coercion"));
96+
assert!(spec.contains("`Protocol.method(...)` dispatch backed by an explicit generic bound"));
9697
}

crates/rsscript/tests/vm_eval_parity/misc.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,3 +1410,53 @@ fn main() -> Unit {
14101410
source,
14111411
);
14121412
}
1413+
1414+
#[test]
1415+
fn parity_capability_dynamic_dispatch() {
1416+
// Capability objects (spec §20.2-2): `Capability<Protocol>` and the
1417+
// `capability Protocol` keyword form dispatch a protocol method by the
1418+
// value's runtime type. The reg-VM must select the same concrete impl as the
1419+
// compiled backend's closed-world enum dispatch (regression for the VM
1420+
// CallDynamic path; previously the VM returned Unit).
1421+
let source = r#"
1422+
features: local
1423+
1424+
protocol Greeter {
1425+
fn greet(self: read Self) -> fresh String
1426+
}
1427+
1428+
struct English { x: Int }
1429+
struct French { x: Int }
1430+
1431+
fn English.greet(self: read English) -> fresh String {
1432+
if self.x > 0 { return "hello" }
1433+
return "hi"
1434+
}
1435+
fn French.greet(self: read French) -> fresh String {
1436+
if self.x > 0 { return "bonjour" }
1437+
return "salut"
1438+
}
1439+
1440+
impl Greeter for English { greet = English.greet }
1441+
impl Greeter for French { greet = French.greet }
1442+
1443+
fn say(who: read capability Greeter) -> fresh String {
1444+
return Greeter.greet(self: read who)
1445+
}
1446+
1447+
fn main() -> Unit {
1448+
local e = English(x: 1)
1449+
local f = French(x: 2)
1450+
local a: Capability<Greeter> = Capability<Greeter>.from(value: take e)
1451+
local b: Capability<Greeter> = Capability<Greeter>.from(value: take f)
1452+
Log.write(message: read say(who: read a))
1453+
Log.write(message: read say(who: read b))
1454+
return Unit
1455+
}
1456+
"#;
1457+
common::assert_vm_eval_matches_backend(
1458+
"parity-capability-dispatch.rss",
1459+
"rsscript_parity_capability_dispatch",
1460+
source,
1461+
);
1462+
}

docs/RSScript_v0.7_Spec.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3642,19 +3642,20 @@ short-circuit `&&`/`||`, which is only conditionally evaluated. Hoisting it woul
36423642
force unconditional evaluation, so such an `await` stays rejected as a non-linear
36433643
await (`RS0411`).
36443644

3645-
#### Dynamic dispatch (in scope §20.2; not yet implemented)
3645+
#### Dynamic dispatch (explicit capability form implemented §20.2-2)
36463646

3647-
RSScript v0.7 does not yet implement protocol-typed dynamic dispatch, trait
3648-
objects, or protocol-typed values.
3649-
The only implemented and specified protocol call form is
3650-
static, explicit `Protocol.method(...)` dispatch backed by an explicit generic
3651-
bound or an explicit `impl Protocol for Type` declaration.
3647+
Dynamic dispatch in RSScript is **only** the explicit, review-visible
3648+
`Capability<Protocol>` form (with the `capability Protocol` keyword sugar),
3649+
implemented per §20.2-2: it lowers to a closed-world enum-of-impls with `match`
3650+
dispatch on the compiled backend and dispatches by the receiver's runtime type in
3651+
the reg-VM. RSScript does **not** implement implicit protocol-typed values (a bare
3652+
`x: Protocol` parameter) or Rust-style `dyn Trait` vtable coercion — those stay
3653+
non-goals (§21).
36523654

3653-
Explicit, review-visible `capability`-bounded dispatch is now a **committed
3654-
roadmap item** (§20.2-2, §20.1-G) — not Rust-style implicit `dyn` coercion, which
3655-
stays a non-goal (§21). Until it is built, it must not be described as
3656-
implemented, settled, or available to package contracts: syntax, checking,
3657-
lowering, review evidence, and package metadata must land together.
3655+
Aside from capability objects, the only protocol call form is static, explicit
3656+
`Protocol.method(...)` dispatch backed by an explicit generic bound or an explicit
3657+
`impl Protocol for Type` declaration. Implicit `dyn` coercion must not be described
3658+
as implemented, settled, or available to package contracts.
36583659

36593660
Closed sets should still prefer sealed sum types with exhaustive match
36603661
(section 20.1), which are strictly more reviewable. For open sets such as
@@ -5321,10 +5322,13 @@ F. Registry-level review-risk badges
53215322
signals to derive from compiler/package review evidence.
53225323
53235324
G. Capability objects (explicit dynamic dispatch)
5324-
- now in scope: committed roadmap (§20.2).
5325+
- implemented (§20.2-2): `Capability<Protocol>` (and the `capability Protocol`
5326+
keyword sugar) is an explicit, review-visible dynamic-dispatch boundary. The
5327+
compiled backend lowers it to a closed-world enum-of-impls with `match`
5328+
dispatch (no `dyn`); the reg-VM dispatches the same impl by the receiver's
5329+
runtime type. Construct with `Capability<Protocol>.from(value: take x)`.
53255330
- RSScript does not adopt Rust-style `dyn Trait` with vtable coercion.
5326-
- if dynamic dispatch is needed, it must use an explicit capability-bounded
5327-
form that is visible to review:
5331+
- the explicit capability-bounded form is visible to review:
53285332
53295333
fn serve(store: read capability Store<Image>) -> Result<Unit, Error>
53305334

docs/spec-todo.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ model without reversing a review-first tenet. Ordered by readiness/value.
3232
view-of-a-view) and the `scoped-view-escape.rss` fail fixture (`RS0702`). The
3333
`with Buffer.view(...) as v { … }` form also already works.
3434

35-
- [ ] **Capability objects / explicit dynamic dispatch** (§3.2, §20.1-G, §20.2-2) —
36-
_large._ Review-visible `capability`-bounded dispatch
37-
(`store: read capability Store<T>`), **not** Rust-style implicit `dyn` coercion
38-
(which stays a non-goal, §21). The review map must flag every capability boundary;
39-
capability values carry their protocol's effect declarations (no silent widening).
40-
_Touches:_ parser/types, protocol resolution, lowering (vtable-free representation),
41-
review map / REIR. Must hold parity.
35+
- [x] **Capability objects / explicit dynamic dispatch** (§3.2, §20.1-G, §20.2-2) —
36+
_done._ The compiled backend already lowered `Capability<Protocol>` to a
37+
closed-world enum-of-impls with `match` dispatch (no `dyn`); this completes the
38+
feature: (1) the spec's `capability Protocol` keyword form now parses as sugar for
39+
`Capability<Protocol>` (parser desugar), so `who: read capability Greeter` and
40+
`local x: capability Greeter` work; (2) **fixed a real VM parity bug** — the reg-VM
41+
had *no* runtime dynamic dispatch, so any `Protocol.method(self: x, …)` call
42+
(capability objects *and* generic bounds) returned `Unit` instead of dispatching.
43+
Added a `CallDynamic` instruction + `Hir::protocol_method_targets` that selects the
44+
concrete impl by the receiver's runtime struct type, mirroring the compiled enum
45+
dispatch. Verified at parity (`parity_capability_dynamic_dispatch`, two impls). The
46+
review map already flags dynamic dispatch (`has_dynamic_protocol_dispatch`).
4247

4348
- [ ] **Cross-isolate message API (zero-copy transfer)** (§20.1-B, §20.2-3) —
4449
_large._ Typed send/receive channels between isolates; payloads are Copy/owned data

0 commit comments

Comments
 (0)