Skip to content

Commit 3eeacf4

Browse files
olwangclaude
andcommitted
rsscript: effect annotations on Fn-type params (mut Ctx closures)
Carry read/mut/take effects on Fn-type parameters end to end (parser -> TypeRef -> checker -> VM -> AOT), reusing the existing fn-param effect/ write-back machinery, so a stored owned-Fn rule can take `mut Ctx` and mutate it in-body (exclusive borrow for the call, sound + explicit). Verified e2e at VM<->compiled parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e54264 commit 3eeacf4

10 files changed

Lines changed: 493 additions & 22 deletions

File tree

crates/rsscript/src/analyzer.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6530,7 +6530,14 @@ fn type_ref_name(ty: &TypeRef) -> String {
65306530
let params = ty
65316531
.fn_params
65326532
.iter()
6533-
.map(type_ref_name)
6533+
.enumerate()
6534+
.map(|(index, param)| {
6535+
let prefix = match ty.fn_param_effects.get(index).copied().flatten() {
6536+
Some(effect) => format!("{} ", effect.as_str()),
6537+
None => String::new(),
6538+
};
6539+
format!("{prefix}{}", type_ref_name(param))
6540+
})
65346541
.collect::<Vec<_>>()
65356542
.join(", ");
65366543
let return_ty = ty

crates/rsscript/src/checks/calls.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4352,8 +4352,27 @@ fn fn_param_types(type_name: &str) -> Vec<&str> {
43524352
if params.is_empty() {
43534353
Vec::new()
43544354
} else {
4355+
// A `Fn(...)` parameter may carry a leading data effect (`read`/`mut`/
4356+
// `take`); every caller here compares the parameter's TYPE, not its
4357+
// effect (the effect is enforced separately by the analyzer's closure
4358+
// mutability/borrow machinery), so strip the keyword to the bare type.
43554359
split_top_level_type_args(params)
4360+
.into_iter()
4361+
.map(fn_param_bare_type)
4362+
.collect()
4363+
}
4364+
}
4365+
4366+
/// Strip a leading `read`/`mut`/`take` effect keyword from a `Fn` parameter
4367+
/// type string, leaving the bare type (`"mut Ctx"` -> `"Ctx"`).
4368+
fn fn_param_bare_type(param: &str) -> &str {
4369+
let param = param.trim();
4370+
for keyword in ["read ", "mut ", "take "] {
4371+
if let Some(rest) = param.strip_prefix(keyword) {
4372+
return rest.trim();
4373+
}
43564374
}
4375+
param
43574376
}
43584377

43594378
fn fn_type_prefix(type_name: &str) -> &'static str {

crates/rsscript/src/hir.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3510,7 +3510,14 @@ fn type_ref_name(ty: &TypeRef) -> String {
35103510
let params = ty
35113511
.fn_params
35123512
.iter()
3513-
.map(type_ref_name)
3513+
.enumerate()
3514+
.map(|(index, param)| {
3515+
let prefix = match ty.fn_param_effects.get(index).copied().flatten() {
3516+
Some(effect) => format!("{} ", effect.as_str()),
3517+
None => String::new(),
3518+
};
3519+
format!("{prefix}{}", type_ref_name(param))
3520+
})
35143521
.collect::<Vec<_>>()
35153522
.join(", ");
35163523
let return_ty = ty

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,29 @@ fn require(condition: bool) -> Option<()> {
11031103
condition.then_some(())
11041104
}
11051105

1106+
/// Argument positions of a closure call site that carry a `mut` effect marker
1107+
/// (`f(read u, mut ctx)`), so a `CallClosure` can write the mutated values back
1108+
/// to the caller after the closure body runs. The call-site effect is the
1109+
/// `HirExpr::Effect { ParamEffect::Mut, .. }` wrapper the checker already
1110+
/// type-checked against the stored `Fn`'s declared `mut` parameter — the same
1111+
/// information `CallKnown`/`CallNative` recover from the callee signature, but
1112+
/// for a first-class closure value the effect lives on the call-site argument.
1113+
fn call_arg_mut_positions(args: &[HirCallArg]) -> Vec<usize> {
1114+
args.iter()
1115+
.enumerate()
1116+
.filter(|(_, arg)| {
1117+
matches!(
1118+
&arg.value,
1119+
HirExpr::Effect {
1120+
effect: ParamEffect::Mut,
1121+
..
1122+
}
1123+
)
1124+
})
1125+
.map(|(index, _)| index)
1126+
.collect()
1127+
}
1128+
11061129
/// Outcome of executing a single "pure" instruction via the shared
11071130
/// [`RegVm::try_exec_pure`] dispatcher. Pure instructions push no frames, never
11081131
/// suspend, and never call other functions, so both the interpreter (`drive`)
@@ -1861,11 +1884,17 @@ enum RegInstr {
18611884
/// registers, so native in-place mutation propagates to the caller.
18621885
mut_args: Vec<usize>,
18631886
},
1864-
#[allow(dead_code)]
18651887
CallClosure {
18661888
dst: Reg,
18671889
closure: Reg,
18681890
args: Vec<Reg>,
1891+
/// Argument positions passed with `mut` (the stored `Fn`'s `mut`
1892+
/// parameters). After the closure returns, each such argument's
1893+
/// (possibly mutated) value is written back to the caller's argument
1894+
/// register, so a `mut Ctx` closure parameter's field mutations
1895+
/// propagate to the caller — identical to `CallKnown`'s `mut_args` and to
1896+
/// AOT's `&mut` argument semantics.
1897+
mut_args: Vec<usize>,
18691898
},
18701899
ListFilter {
18711900
dst: Reg,
@@ -3825,10 +3854,20 @@ impl RegLowerer<'_> {
38253854
if self.function_ids.get(type_root_name(name)).is_none()
38263855
&& let Some(&closure) = self.function.local_regs.get(name)
38273856
{
3857+
// A `mut`-annotated argument at a closure call site
3858+
// (`f(read u, mut ctx)`) is an exclusive borrow for the
3859+
// call: the closure's matching `mut` parameter may mutate it,
3860+
// and the result is written back to the caller's binding. The
3861+
// call-site effect is the `HirExpr::Effect { Mut, .. }`
3862+
// wrapper (already type-checked against the stored `Fn`'s
3863+
// declared `mut` parameter), so mirror `CallKnown`'s
3864+
// `mut_args` from it.
3865+
let mut_args = call_arg_mut_positions(args);
38283866
self.emit(RegInstr::CallClosure {
38293867
dst,
38303868
closure,
38313869
args: arg_regs,
3870+
mut_args,
38323871
});
38333872
return Ok(dst);
38343873
}
@@ -8382,7 +8421,12 @@ impl RegVm {
83828421
let result = self.call_native_key(key, args, mut_args, base)?;
83838422
self.set_reg(base + *dst, result);
83848423
}
8385-
RegInstr::CallClosure { dst, closure, args } => {
8424+
RegInstr::CallClosure {
8425+
dst,
8426+
closure,
8427+
args,
8428+
mut_args,
8429+
} => {
83868430
let closure = match self.reg(base + *closure) {
83878431
VmValue::Closure(closure) => Rc::clone(closure),
83888432
other => {
@@ -8392,8 +8436,9 @@ impl RegVm {
83928436
)));
83938437
}
83948438
};
8395-
let result =
8396-
self.call_closure_from_regs(unit, &closure, args, base, next_base)?;
8439+
let result = self.call_closure_from_regs(
8440+
unit, &closure, args, mut_args, base, next_base,
8441+
)?;
83978442
self.set_reg(base + *dst, result);
83988443
}
83998444
RegInstr::ListFilter {
@@ -9095,6 +9140,7 @@ impl RegVm {
90959140
unit: &RegUnit,
90969141
closure: &VmClosure,
90979142
arg_regs: &[Reg],
9143+
mut_args: &[usize],
90989144
caller_base: usize,
90999145
base: usize,
91009146
) -> Result<VmValue, EvalError> {
@@ -9108,7 +9154,19 @@ impl RegVm {
91089154
let value = self.reg(caller_base + *reg).clone();
91099155
self.set_reg(base + offset + index, value);
91109156
}
9111-
self.run_frame(unit, callee, base)
9157+
let result = self.run_frame(unit, callee, base)?;
9158+
// `mut` closure arguments are an exclusive borrow for the call: after the
9159+
// closure body runs, write each `mut` parameter's final value back to the
9160+
// caller's argument register, so a `mut Ctx` parameter's field mutations
9161+
// propagate to the caller — identical to `CallKnown`'s `mut_writeback` and
9162+
// to AOT's `&mut` argument semantics. The closure body runs synchronously
9163+
// via `run_frame`, so the parameter registers still hold their final
9164+
// values at `base + offset + pos` here.
9165+
for &pos in mut_args {
9166+
let value = self.reg(base + offset + pos).clone();
9167+
self.set_reg(caller_base + arg_regs[pos], value);
9168+
}
9169+
Ok(result)
91129170
}
91139171

91149172
fn call_closure_one(

crates/rsscript/src/rust_lower/lowerer.rs

Lines changed: 103 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,25 +3292,50 @@ impl<'a> RustLowerer<'a> {
32923292
expected: &TypeRef,
32933293
) -> String {
32943294
let previous_value_types = self.value_types.clone();
3295+
let previous_param_effects = self.param_effects.clone();
32953296
for (param, ty) in params.iter().zip(expected.fn_params.iter()) {
32963297
self.value_types.insert(param.clone(), ty.clone());
32973298
}
3299+
// Register each closure parameter's data effect so the body lowers
3300+
// exactly like a regular function with the same parameter effects: a
3301+
// `read T` parameter is a borrowed `&T` (reads `.clone()` out of it where
3302+
// needed), a `mut T` parameter is an exclusive `&mut T` whose field
3303+
// assignments propagate to the caller, and a `take`/defaulted parameter is
3304+
// owned by value. This is what lets a stored `mut Ctx` rule mutate `ctx`.
3305+
for (index, param) in params.iter().enumerate() {
3306+
if let Some(effect) = expected.fn_param_effects.get(index).copied().flatten() {
3307+
self.param_effects.insert(param.clone(), effect);
3308+
}
3309+
}
3310+
// The closure's parameter list must match the stored
3311+
// `Rc<dyn Fn(&UOp, &mut Ctx) -> ..>` signature: annotate each parameter
3312+
// with the same ref it would carry as a regular function parameter
3313+
// (`read T` -> `&T`, `mut T` -> `&mut T`, owned otherwise), mirroring
3314+
// `lower_param`'s effect-to-ref mapping.
32983315
let lowered_params = params
32993316
.iter()
33003317
.enumerate()
33013318
.map(|(index, param)| {
33023319
let name = rust_ident(param);
3303-
expected
3320+
let Some(ty) = expected
33043321
.fn_params
33053322
.get(index)
33063323
.filter(|ty| self.type_ref_is_concrete_for_annotation(ty))
3307-
.map(|ty| {
3308-
format!(
3309-
"{name}: {}",
3310-
self.lower_type_ref(ty, ManagedPosition::Param)
3311-
)
3312-
})
3313-
.unwrap_or(name)
3324+
else {
3325+
return name;
3326+
};
3327+
let bare = self.lower_type_ref(ty, ManagedPosition::Param);
3328+
let effect = expected.fn_param_effects.get(index).copied().flatten();
3329+
// Match the stored `Rc<dyn Fn(..)>` parameter ABI exactly (see
3330+
// `lower_type_ref` for `Fn`): `read T` -> `&T`, `mut T` -> `&mut T`,
3331+
// owned otherwise. Kept uniform (no by-value-`Copy` shortcut) so
3332+
// the closure literal, its stored type, and every call site agree.
3333+
let rust_ty = match effect {
3334+
Some(DataEffect::Read) => format!("&{bare}"),
3335+
Some(DataEffect::Mut) => format!("&mut {bare}"),
3336+
Some(DataEffect::Take) | None => bare,
3337+
};
3338+
format!("{name}: {rust_ty}")
33143339
})
33153340
.collect::<Vec<_>>()
33163341
.join(", ");
@@ -3323,6 +3348,7 @@ impl<'a> RustLowerer<'a> {
33233348
);
33243349
self.current_return_type = previous_return_type;
33253350
self.value_types = previous_value_types;
3351+
self.param_effects = previous_param_effects;
33263352
return lowered;
33273353
}
33283354
let mut out = String::new();
@@ -3331,6 +3357,7 @@ impl<'a> RustLowerer<'a> {
33313357
out.push('}');
33323358
self.current_return_type = previous_return_type;
33333359
self.value_types = previous_value_types;
3360+
self.param_effects = previous_param_effects;
33343361
out
33353362
}
33363363

@@ -3523,19 +3550,61 @@ impl<'a> RustLowerer<'a> {
35233550
.is_some_and(|ty| ty.name == "Fn" && ty.is_owned)
35243551
}
35253552

3553+
/// The declared data effect of the `index`-th parameter of a first-class
3554+
/// closure value's stored `Fn` type, so a call site can pass the argument
3555+
/// with the matching Rust ABI (`read` -> `&`, `mut` -> `&mut`).
3556+
fn closure_value_param_effect(&self, callee: &Callee, index: usize) -> Option<DataEffect> {
3557+
let Callee::Name(name) = callee else {
3558+
return None;
3559+
};
3560+
let ty = self.value_types.get(name)?;
3561+
if ty.name != "Fn" {
3562+
return None;
3563+
}
3564+
ty.fn_param_effects.get(index).copied().flatten()
3565+
}
3566+
35263567
fn lower_call_arg_for_callee(
35273568
&mut self,
35283569
callee: &Callee,
35293570
arg: &CallArg,
35303571
index: usize,
35313572
) -> String {
3532-
// Calling a first-class closure value (`let f = r.fxn; f(read u)`): the
3533-
// callee is `Rc<dyn Fn(P0, P1, ..) -> R>` whose parameters lower BY
3534-
// VALUE. So each argument is passed by value (a `read` of a non-`Copy`
3535-
// value becomes an owned `.clone()`), not borrowed — `f(&u)` would not
3536-
// match `Fn(UOp)`.
3573+
// Calling a first-class closure value (`let f = r.fxn; f(read u, mut ctx)`):
3574+
// the callee is `Rc<dyn Fn(P0, P1, ..) -> R>` whose parameters carry the
3575+
// stored `Fn` type's per-parameter data effects. Pass each argument to
3576+
// match that effect's Rust ABI: `read T` -> `&T` (by value for `Copy`),
3577+
// `mut T` -> `&mut T` (the closure may mutate it; the borrow is exclusive
3578+
// for the call), and a `take`/defaulted parameter by value (a `read` of a
3579+
// non-`Copy` value becomes an owned `.clone()`). This mirrors `lower_param`
3580+
// and the stored `Rc<dyn Fn(&UOp, &mut Ctx)>` signature.
35373581
if self.callee_is_closure_value(callee) {
3538-
return self.lower_owned_expr(&arg.value);
3582+
let effect = self.closure_value_param_effect(callee, index);
3583+
let inner = match &arg.value {
3584+
Expr::Effect { value, .. } => value.as_ref(),
3585+
other => other,
3586+
};
3587+
return match effect {
3588+
Some(DataEffect::Read) => format!("&{}", self.lower_expr(inner)),
3589+
Some(DataEffect::Mut) => {
3590+
// When the argument is itself a `mut` parameter it is already
3591+
// a `&mut T`; reborrow it as the closure's `&mut T` argument by
3592+
// passing the binding directly (`f(read u, mut ctx)` where
3593+
// `ctx: &mut Ctx`), exactly like a `mut`-arg to a regular
3594+
// function. Otherwise take `&mut` of the lowered place.
3595+
if let Expr::Ident(name, _) = inner
3596+
&& self.param_effects.get(name) == Some(&DataEffect::Mut)
3597+
{
3598+
rust_value_ident(name)
3599+
} else {
3600+
format!("&mut {}", self.lower_expr(inner))
3601+
}
3602+
}
3603+
// A defaulted/`take` parameter is passed by value; a `read`
3604+
// call-site marker without a declared effect (older value-model
3605+
// `Fn` types) still lowers by value via `lower_owned_expr`.
3606+
_ => self.lower_owned_expr(&arg.value),
3607+
};
35393608
}
35403609
if runtime_intrinsic_wants_managed_handle_arg(callee, arg.name.as_deref())
35413610
&& let Expr::Effect { effect, value, .. } = &arg.value
@@ -4101,6 +4170,7 @@ impl<'a> RustLowerer<'a> {
41014170
is_noescape: false,
41024171
is_owned: false,
41034172
fn_params: Vec::new(),
4173+
fn_param_effects: Vec::new(),
41044174
fn_return: None,
41054175
span: receiver_type.span.clone(),
41064176
}),
@@ -4149,6 +4219,7 @@ impl<'a> RustLowerer<'a> {
41494219
is_noescape: false,
41504220
is_owned: false,
41514221
fn_params: Vec::new(),
4222+
fn_param_effects: Vec::new(),
41524223
fn_return: None,
41534224
span: receiver_type.span.clone(),
41544225
}),
@@ -4665,10 +4736,24 @@ impl<'a> RustLowerer<'a> {
46654736

46664737
fn lower_type_ref(&self, ty: &TypeRef, position: ManagedPosition) -> String {
46674738
if ty.name == "Fn" {
4739+
// A `Fn`-type parameter's data effect determines how the parameter is
4740+
// PASSED at the Rust call boundary: `read T` -> `&T` (shared borrow),
4741+
// `mut T` -> `&mut T` (exclusive borrow, mutation propagates back),
4742+
// and an omitted effect keeps the value-model default (by value). This
4743+
// mirrors how regular fn params lower and is what makes a stored
4744+
// `Rc<dyn Fn(&UOp, &mut Ctx) -> ..>` rule able to mutate `mut Ctx`.
46684745
let params = ty
46694746
.fn_params
46704747
.iter()
4671-
.map(|param| self.lower_type_ref(param, ManagedPosition::Param))
4748+
.enumerate()
4749+
.map(|(index, param)| {
4750+
let lowered = self.lower_type_ref(param, ManagedPosition::Param);
4751+
match ty.fn_param_effects.get(index).copied().flatten() {
4752+
Some(DataEffect::Read) => format!("&{lowered}"),
4753+
Some(DataEffect::Mut) => format!("&mut {lowered}"),
4754+
Some(DataEffect::Take) | None => lowered,
4755+
}
4756+
})
46724757
.collect::<Vec<_>>()
46734758
.join(", ");
46744759
let return_ty = ty.fn_return.as_ref().map(|return_ty| {
@@ -5786,6 +5871,7 @@ fn type_ref_from_display(name: &str, span: &Span) -> TypeRef {
57865871
is_noescape: false,
57875872
is_owned: false,
57885873
fn_params: Vec::new(),
5874+
fn_param_effects: Vec::new(),
57895875
fn_return: None,
57905876
span: span.clone(),
57915877
}
@@ -5800,6 +5886,7 @@ fn simple_type_ref(name: &str, span: &Span) -> TypeRef {
58005886
is_noescape: false,
58015887
is_owned: false,
58025888
fn_params: Vec::new(),
5889+
fn_param_effects: Vec::new(),
58035890
fn_return: None,
58045891
span: span.clone(),
58055892
}
@@ -5898,6 +5985,7 @@ fn fn_type_ref(params: Vec<TypeRef>, return_ty: Option<TypeRef>, span: &Span) ->
58985985
is_fresh: false,
58995986
is_noescape: true,
59005987
is_owned: false,
5988+
fn_param_effects: vec![None; params.len()],
59015989
fn_params: params,
59025990
fn_return: return_ty.map(Box::new),
59035991
span: span.clone(),

0 commit comments

Comments
 (0)