Skip to content

Commit e606c8e

Browse files
olwangclaude
andcommitted
rsscript: port-unblocking freshness, default-arg, and module features
Four features needed by the tinygrad port: 1. Fresh sum-variant returns: a bare payload-free variant (`return MUL`) and a variant constructor (`return Pair(...)`, `ArgInts(values: take vals)`) are fresh — classify EnumVariant and sum_type_for_variant idents as Literal. Clears the remaining RS0602 warnings on enum-returning lookups. 2. Clean-local propagation: a managed `let s = <fresh source>; return s` is returnable as fresh (no defensive String.copy). Sound: the binding is marked fresh-returnable only from a fresh initializer and is cleared on move, retain, or managed-closure capture (apply_retention_events / apply_move_events / capture handling now cover fresh-returnable managed bindings, not just locals). 3. Default values for non-Copy params: `axes: read List<Int> = List.new<Int>()` no longer fails RS0202. The filled default carries the parameter's declared effect in both the HIR (checker) and the compiled backend (routed through the normal arg path), so the call-site effect check passes and it lowers correctly. 4. Qualified `module.Type` in type position: `fn f(d: read dtype.DType)` resolves to the module-scoped type symbol without a `use` line in every consuming file. Tests: 3 pass fixtures, a module-type integration test, and adversarial checks (retained/captured managed bindings still RS0601). lib/frontend/lowering/vm_eval differential suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e84b05 commit e606c8e

7 files changed

Lines changed: 218 additions & 16 deletions

File tree

crates/rsscript/src/checks/local.rs

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ use std::collections::{HashMap, HashSet, VecDeque};
44
use crate::diagnostic::Span;
55
use crate::hir::{
66
CallResolution, HirBinding, HirBindingKind, HirBlock, HirCallArg, HirEffectEvent,
7-
HirEffectEventKind, HirExpr, HirFunctionBody, HirReturnProof, HirStmt, ParamEffect,
7+
HirEffectEventKind, HirExpr, HirFunctionBody, HirReturnProof, HirStmt, HirTypeKind, ParamEffect,
8+
ResolvedCalleeKind,
89
};
910
use crate::syntax::ast::{Callee, Expr};
1011

@@ -115,6 +116,11 @@ struct LocalFlowBinding {
115116
value_handle_field: Option<(String, Span)>,
116117
fresh_from_local_source: Option<String>,
117118
fresh_from_scrutinee: bool,
119+
/// The binding's initializer is itself a fresh value (a fresh-returning call,
120+
/// a struct/variant constructor, or a literal). Such a binding holds a fresh,
121+
/// unaliased value, so returning it directly preserves freshness — until it is
122+
/// moved, retained, or captured (which clears its fresh-returnable status).
123+
fresh_from_fresh_value: bool,
118124
}
119125

120126
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -520,20 +526,24 @@ fn collect_fresh_return_issue(
520526
.unwrap_or(return_span)
521527
.clone();
522528
if let Some(state) = entry_states.get(return_span) {
523-
if state.is_managed(name)
524-
|| (state.is_local(name)
525-
&& (!state.is_clean_local(name) || !state.is_fresh_returnable_local(name)))
526-
{
529+
// A binding bound from a fresh value (a `let s = <fresh source>`)
530+
// stays returnable-as-fresh until it is moved, retained, or
531+
// captured — including a *managed* `let`, not only an exclusive
532+
// `local`. Those invalidations clear the fresh-returnable flag, so
533+
// an aliased binding falls back to NotClean.
534+
let returns_fresh =
535+
state.is_clean_local(name) && state.is_fresh_returnable_local(name);
536+
if state.is_managed(name) || state.is_local(name) {
537+
if returns_fresh {
538+
return;
539+
}
527540
push_fresh_return_issue(
528541
issues,
529542
FreshReturnIssueKind::NotClean { name: name.clone() },
530543
span,
531544
);
532545
return;
533546
}
534-
if state.is_local(name) {
535-
return;
536-
}
537547
}
538548
push_fresh_return_issue(
539549
issues,
@@ -2598,6 +2608,7 @@ fn collect_select_local_flow(
25982608
value_handle_field: None,
25992609
fresh_from_local_source: None,
26002610
fresh_from_scrutinee: false,
2611+
fresh_from_fresh_value: false,
26012612
};
26022613
let binding_node = push_pattern_binding_flow_step(steps, &arm.span, binding);
26032614
if let Some(body_entry) = arm_flow.entry {
@@ -2674,6 +2685,7 @@ fn fresh_match_pattern_binding(
26742685
value_handle_field: None,
26752686
fresh_from_local_source: source.map(str::to_string),
26762687
fresh_from_scrutinee,
2688+
fresh_from_fresh_value: false,
26772689
})
26782690
}
26792691

@@ -3011,7 +3023,14 @@ fn collect_flow_entry_states(
30113023
fn transfer_flow_step(step: &LocalFlowStep, mut state: BodyState) -> BodyState {
30123024
if let Some(binding) = &step.binding {
30133025
match binding.kind {
3014-
HirBindingKind::ManagedLet => state.bind_managed(binding.name.clone()),
3026+
HirBindingKind::ManagedLet => {
3027+
state.bind_managed(binding.name.clone());
3028+
// A managed binding initialized from a fresh value is returnable
3029+
// as fresh until aliased (move/retain/capture clear this).
3030+
if binding.fresh_from_fresh_value {
3031+
state.mark_fresh_returnable(binding.name.clone());
3032+
}
3033+
}
30153034
HirBindingKind::LocalLet => {
30163035
if binding.fresh_from_scrutinee {
30173036
state.bind_local(binding.name.clone());
@@ -3039,7 +3058,9 @@ fn transfer_flow_step(step: &LocalFlowStep, mut state: BodyState) -> BodyState {
30393058
}
30403059

30413060
for capture in &step.managed_closure_captures {
3042-
if state.is_local(capture) {
3061+
// Capturing a local, or a managed binding that is currently returnable as
3062+
// fresh, into a managed closure aliases it — clear its fresh/clean status.
3063+
if state.is_local(capture) || state.is_fresh_returnable_local(capture) {
30433064
state.mark_retained(capture);
30443065
}
30453066
}
@@ -3176,6 +3197,7 @@ fn local_flow_step_binding(statement: &HirStmt) -> Option<LocalFlowBinding> {
31763197
value_handle_field: value.as_ref().and_then(local_binding_handle_field_source),
31773198
fresh_from_local_source: None,
31783199
fresh_from_scrutinee: false,
3200+
fresh_from_fresh_value: value.as_ref().is_some_and(hir_expr_is_fresh_value),
31793201
}),
31803202
HirStmt::Return { .. }
31813203
| HirStmt::With { .. }
@@ -3192,6 +3214,35 @@ fn local_flow_step_binding(statement: &HirStmt) -> Option<LocalFlowBinding> {
31923214
}
31933215
}
31943216

3217+
/// True if `value` is itself a fresh, unaliased value: a literal, a collection
3218+
/// literal, a struct/variant constructor, or a fresh-returning call (seen through
3219+
/// `?` and effect wrappers). A managed `let` bound to such a value can be returned
3220+
/// directly as `fresh` until it is moved, retained, or captured — exactly the
3221+
/// invalidations the flow analysis already applies to clean locals.
3222+
fn hir_expr_is_fresh_value(value: &HirExpr) -> bool {
3223+
match value {
3224+
HirExpr::Number { .. }
3225+
| HirExpr::String { .. }
3226+
| HirExpr::ObjectLiteral { .. }
3227+
| HirExpr::MapLiteral { .. }
3228+
| HirExpr::ArrayLiteral { .. } => true,
3229+
HirExpr::Call { resolution, .. } => match resolution {
3230+
CallResolution::EnumVariant => true,
3231+
CallResolution::Resolved {
3232+
kind:
3233+
ResolvedCalleeKind::Constructor {
3234+
type_kind: HirTypeKind::Struct,
3235+
},
3236+
..
3237+
} => true,
3238+
CallResolution::Resolved { signature, .. } => signature.returns_fresh,
3239+
_ => false,
3240+
},
3241+
HirExpr::Try { value, .. } | HirExpr::Effect { value, .. } => hir_expr_is_fresh_value(value),
3242+
_ => false,
3243+
}
3244+
}
3245+
31953246
fn local_binding_source_ident(value: &HirExpr) -> Option<(String, Span)> {
31963247
match value {
31973248
HirExpr::Ident { name, span, .. } => Some((name.clone(), span.clone())),
@@ -3357,6 +3408,15 @@ impl BodyState {
33573408
}
33583409
}
33593410

3411+
/// Mark a (managed) binding as returnable-as-fresh: it currently holds a
3412+
/// fresh, unaliased value. Cleared by `mark_moved`/`mark_retained` when the
3413+
/// binding is aliased.
3414+
pub(crate) fn mark_fresh_returnable(&mut self, name: impl Into<String>) {
3415+
let name = name.into();
3416+
self.clean_locals.insert(name.clone());
3417+
self.fresh_returnable_locals.insert(name);
3418+
}
3419+
33603420
pub(crate) fn bind_managed(&mut self, name: impl Into<String>) {
33613421
self.managed.insert(name.into());
33623422
}
@@ -3480,7 +3540,9 @@ impl BodyState {
34803540
if path_root(&event.binding_name).is_some_and(|root| self.locals.contains(root)) {
34813541
self.mark_moved(&event.binding_name, event.span.clone());
34823542
}
3483-
} else if self.locals.contains(&event.binding_name) {
3543+
} else if self.locals.contains(&event.binding_name)
3544+
|| self.fresh_returnable_locals.contains(&event.binding_name)
3545+
{
34843546
self.mark_moved(&event.binding_name, event.span.clone());
34853547
}
34863548
}
@@ -3491,7 +3553,12 @@ impl BodyState {
34913553
if !matches!(event.kind, HirEffectEventKind::Retain { .. }) {
34923554
continue;
34933555
}
3494-
if self.locals.contains(&event.binding_name) {
3556+
// Retaining a local, or a managed binding that is currently returnable
3557+
// as fresh, aliases it into retained state — clear its fresh status so
3558+
// it can no longer be returned as `fresh`.
3559+
if self.locals.contains(&event.binding_name)
3560+
|| self.fresh_returnable_locals.contains(&event.binding_name)
3561+
{
34953562
self.mark_retained(&event.binding_name);
34963563
}
34973564
}

crates/rsscript/src/hir.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,9 +1828,25 @@ fn lower_hir_expr(
18281828
if let Some(default) = &param.default
18291829
&& !provided.contains(param.name.as_str())
18301830
{
1831+
let mut value = lower_hir_expr(hir, function_name, default, value_types);
1832+
// A non-Copy default is materialized at the call site and
1833+
// bound under the parameter's declared effect. Carry that
1834+
// effect on the synthesized argument so the call-site
1835+
// effect check is satisfied — the effect is reviewed at the
1836+
// declaration (`axes: read List<Int> = ...`), not at the
1837+
// omitted call where the argument is implicit.
1838+
if let Some(effect) = param.effect {
1839+
value = HirExpr::Effect {
1840+
effect,
1841+
value: Box::new(value),
1842+
events: Vec::new(),
1843+
type_name: Some(param.type_name.clone()),
1844+
span: span.clone(),
1845+
};
1846+
}
18311847
hir_args.push(HirCallArg {
18321848
name: Some(param.name.clone()),
1833-
value: lower_hir_expr(hir, function_name, default, value_types),
1849+
value,
18341850
span: span.clone(),
18351851
});
18361852
}
@@ -3260,6 +3276,9 @@ fn classify_return_expr(
32603276
match expr {
32613277
// `true` / `false` are boolean literals (lexed as identifiers).
32623278
Expr::Ident(name, _) if name == "true" || name == "false" => HirReturnProof::Literal,
3279+
// A bare payload-free sum variant (`return MUL`) names a freshly-valued
3280+
// variant constant; it owns nothing borrowed, so it is fresh.
3281+
Expr::Ident(name, _) if hir.sum_type_for_variant(name).is_some() => HirReturnProof::Literal,
32633282
Expr::Ident(name, _) => HirReturnProof::Ident { name: name.clone() },
32643283
Expr::Call { callee, args, .. } => {
32653284
if matches!(callee_name(callee), "Err" | "None") {
@@ -3300,8 +3319,12 @@ fn classify_return_expr(
33003319
},
33013320
..
33023321
} => HirReturnProof::StructConstructor,
3322+
// A sum/enum variant constructor (`Pair(a: 1, b: 2)`,
3323+
// `ArgInts(values: take vals)`, `Some(x)`/`Ok(x)` wrappers) builds
3324+
// a brand-new value, so the result is fresh; a moved-in (`take`)
3325+
// payload transfers ownership into the fresh shell.
3326+
CallResolution::EnumVariant => HirReturnProof::Literal,
33033327
CallResolution::Resolved { .. }
3304-
| CallResolution::EnumVariant
33053328
| CallResolution::Ambiguous { .. }
33063329
| CallResolution::Unknown => HirReturnProof::Unknown,
33073330
}

crates/rsscript/src/rust_lower/lowerer.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2781,13 +2781,41 @@ impl<'a> RustLowerer<'a> {
27812781
&& defaults.len() > provided
27822782
{
27832783
let param_types = self.function_param_types.get(&name).cloned();
2784+
let param_effects = self
2785+
.function_param_effects
2786+
.get(&native_boundary_callee_key(callee))
2787+
.cloned();
27842788
for (index, default) in defaults.iter().enumerate().skip(provided) {
27852789
if let Some(default) = default {
2786-
let lowered =
2790+
let effect = param_effects
2791+
.as_ref()
2792+
.and_then(|params| params.get(index))
2793+
.and_then(|(_, effect)| *effect);
2794+
let lowered = if let Some(effect) = effect {
2795+
// A non-Copy default is materialized at the call and
2796+
// passed under the parameter's declared effect;
2797+
// route it through the normal argument path so the
2798+
// borrow/managed-handle ABI matches the signature.
2799+
let synthetic = CallArg {
2800+
name: param_types
2801+
.as_ref()
2802+
.and_then(|params| params.get(index))
2803+
.map(|(param_name, _)| param_name.clone()),
2804+
value: Expr::Effect {
2805+
effect,
2806+
value: Box::new(default.clone()),
2807+
span: span.clone(),
2808+
},
2809+
malformed: false,
2810+
span: span.clone(),
2811+
};
2812+
self.lower_call_arg_for_callee(callee, &synthetic, index)
2813+
} else {
27872814
match param_types.as_ref().and_then(|params| params.get(index)) {
27882815
Some((_, ty)) => self.lower_expr_for_expected_type(default, ty),
27892816
None => self.lower_owned_expr(default),
2790-
};
2817+
}
2818+
};
27912819
args.push(lowered);
27922820
}
27932821
}

crates/rsscript/src/syntax/module_isolation.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,19 @@ impl Resolver {
671671
// that resolve to a module-scoped type are rewritten.
672672
if let Some(mangled) = self.resolve_type_namespace(file, &ty.name) {
673673
ty.name = mangled;
674+
} else if let Some((namespace, type_name)) = ty.name.rsplit_once('.') {
675+
// A qualified `module.Type` reference (`dtype.DType`): if `module`
676+
// names a module that declares `Type`, mangle to its module-scoped
677+
// type symbol. This lets a type-defining module be referenced without
678+
// a `use` line in every consuming file.
679+
let prefix = module_prefix_from_dotted(namespace);
680+
if self
681+
.module_types
682+
.get(&prefix)
683+
.is_some_and(|types| types.contains(type_name))
684+
{
685+
ty.name = format!("{prefix}{MODULE_SEP}{type_name}");
686+
}
674687
}
675688
for arg in &mut ty.args {
676689
self.rewrite_type(arg, file);

crates/rsscript/tests/checker_lowering/misc.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,39 @@ fn module_isolation_resolves_cross_file_associated_constant() {
119119
);
120120
}
121121

122+
#[test]
123+
fn qualified_module_type_in_type_position_resolves() {
124+
// `dtype.DType` in a parameter type resolves to the module-scoped type symbol
125+
// without requiring a `use dtype.DType` line in the consuming file.
126+
let sources = vec![
127+
(
128+
"dtype.rss".to_string(),
129+
"module dtype\n\nstruct DType {\n bits: Int\n}\n".to_string(),
130+
),
131+
(
132+
"main.rss".to_string(),
133+
concat!(
134+
"module app\n\n",
135+
"fn bits_of(d: read dtype.DType) -> Int {\n",
136+
" return d.bits\n",
137+
"}\n\n",
138+
"fn main() -> Unit {\n",
139+
" return Unit\n",
140+
"}\n",
141+
)
142+
.to_string(),
143+
),
144+
];
145+
let package =
146+
lower_sources_to_rust_package_with_options(&sources, "ns-qualtype", "/rt", &[], &[])
147+
.expect("qualified module.Type in type position should lower");
148+
assert!(
149+
package.lib_rs.contains("dtype__DType"),
150+
"qualified `dtype.DType` should lower to the module-scoped type symbol:\n{}",
151+
package.lib_rs
152+
);
153+
}
154+
122155
#[test]
123156
fn module_isolation_distinguishes_dotted_and_underscored_module_paths() {
124157
// `module a.b` and `module a_b` are distinct and must not collide (RS0005).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// #3 A non-Copy parameter may declare a default value; the omitted argument is
2+
// filled with the default carrying the parameter's declared effect, so the
3+
// call-site effect check is satisfied (the effect is reviewed at the declaration).
4+
5+
fn reduce(x: Int, axes: read List<Int> = List.new<Int>()) -> Int {
6+
return x + List.len(list: read axes)
7+
}
8+
9+
fn main() -> Unit {
10+
Log.write(message: read String.from_int(value: reduce(x: 5)))
11+
Log.write(message: read String.from_int(value: reduce(x: 5, axes: read List.new<Int>())))
12+
return Unit
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// #1 A bare payload-free sum variant return and a variant-constructor return are
2+
// fresh (own nothing borrowed / freshly built). #2 A managed `let` bound from a
3+
// fresh-returning call is returnable as fresh (no defensive copy needed).
4+
5+
sum Ops {
6+
ADD
7+
MUL
8+
Pair(left: Int, right: Int)
9+
}
10+
11+
fn kind_to_op(k: Int) -> fresh Ops {
12+
if k == 0 {
13+
return ADD
14+
}
15+
return MUL
16+
}
17+
18+
fn make_pair(a: Int, b: Int) -> fresh Ops {
19+
return Pair(left: a, right: b)
20+
}
21+
22+
fn from_local(k: Int) -> fresh Ops {
23+
let op = kind_to_op(k: k)
24+
return op
25+
}

0 commit comments

Comments
 (0)