Skip to content

Commit 23b9484

Browse files
olwangclaude
andcommitted
rsscript: named function values via forwarding-closure desugar
Passing a named function where a Fn(...) is expected was rejected (RS0026); only inline closures worked. A new syntax pass (function_value_desugar) rewrites a bare argument that names a top-level free function into an equivalent forwarding closure `(p0, ..) { return f(p0: <eff> p0, ..) }`, using the function's own parameter names/effects. It runs at the start of isolate_module_namespaces (before the module early-return and before name mangling), so every consumer — checker, register VM, Rust backend — sees the same closure and behaviour is identical across backends with no new value kind. Scope-aware: a local shadowing a function name is not rewritten. Verified at VM/compiled parity (parity_named_function_value_as_callback) plus a pass fixture. This was the last open tinygrad-port-todo item; the file now has no open items. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a53d5f2 commit 23b9484

6 files changed

Lines changed: 365 additions & 31 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
//! Named-function values via forwarding-closure desugaring.
2+
//!
3+
//! RSScript has no first-class function-pointer value, but a *named function*
4+
//! passed where a `Fn(...)` is expected is exactly what the tinygrad port needs
5+
//! (named matcher/rewrite functions). This pass rewrites a bare argument that
6+
//! names a top-level free function into an equivalent forwarding closure:
7+
//!
8+
//! ```text
9+
//! List.filter(list: read xs, predicate: is_pos)
10+
//! // becomes
11+
//! List.filter(list: read xs, predicate: (x) { return is_pos(x: read x) })
12+
//! ```
13+
//!
14+
//! Doing it at the syntax level (before module isolation and HIR/back-end
15+
//! lowering) means the checker, register VM, and Rust backend all see the same
16+
//! closure, so behaviour is identical across backends with no new value kind.
17+
//! It runs before module isolation, which then mangles the synthesized call like
18+
//! any other reference.
19+
20+
use std::collections::{HashMap, HashSet};
21+
22+
use super::ast::{
23+
Block, Callee, CallArg, DataEffect, Expr, Item, MatchArm, Program, Stmt,
24+
};
25+
26+
/// A free function's parameters: `(name, effect)` in declared order, used to
27+
/// build the forwarding closure's parameter list and forwarding call arguments.
28+
type FunctionParams = Vec<(String, Option<DataEffect>)>;
29+
30+
/// Rewrite bare free-function-name arguments into forwarding closures.
31+
pub fn desugar_function_values(program: &mut Program) {
32+
let functions = collect_free_functions(program);
33+
if functions.is_empty() {
34+
return;
35+
}
36+
for item in &mut program.items {
37+
if let Item::Function(function) = item {
38+
let mut scope: HashSet<String> =
39+
function.params.iter().map(|p| p.name.clone()).collect();
40+
walk_block(&mut function.body, &functions, &mut scope);
41+
}
42+
}
43+
}
44+
45+
/// Map each top-level free function (not a `Type.method`, not `main`) to its
46+
/// parameters.
47+
fn collect_free_functions(program: &Program) -> HashMap<String, FunctionParams> {
48+
let mut functions = HashMap::new();
49+
for item in &program.items {
50+
if let Item::Function(function) = item
51+
&& !function.name.contains('.')
52+
&& function.name != "main"
53+
{
54+
functions.insert(
55+
function.name.clone(),
56+
function
57+
.params
58+
.iter()
59+
.map(|param| (param.name.clone(), param.effect))
60+
.collect(),
61+
);
62+
}
63+
}
64+
functions
65+
}
66+
67+
fn walk_block(block: &mut Block, functions: &HashMap<String, FunctionParams>, scope: &mut HashSet<String>) {
68+
let saved: Vec<String> = scope.iter().cloned().collect();
69+
for statement in &mut block.statements {
70+
walk_stmt(statement, functions, scope);
71+
}
72+
scope.clear();
73+
scope.extend(saved);
74+
}
75+
76+
fn walk_stmt(stmt: &mut Stmt, functions: &HashMap<String, FunctionParams>, scope: &mut HashSet<String>) {
77+
match stmt {
78+
Stmt::Let(let_stmt) => {
79+
if let Some(value) = &mut let_stmt.value {
80+
walk_expr(value, functions, scope);
81+
}
82+
scope.insert(let_stmt.name.clone());
83+
}
84+
Stmt::LetElse(let_else) => {
85+
walk_expr(&mut let_else.value, functions, scope);
86+
let mut else_scope = scope.clone();
87+
walk_block(&mut let_else.else_body, functions, &mut else_scope);
88+
scope.insert(let_else.binding_name.clone());
89+
}
90+
Stmt::Return(ret) => {
91+
if let Some(value) = &mut ret.value {
92+
walk_expr(value, functions, scope);
93+
}
94+
}
95+
Stmt::With(with_stmt) => {
96+
walk_expr(&mut with_stmt.resource, functions, scope);
97+
let mut inner = scope.clone();
98+
inner.insert(with_stmt.binding.clone());
99+
walk_block(&mut with_stmt.body, functions, &mut inner);
100+
}
101+
Stmt::If(if_stmt) => {
102+
walk_expr(&mut if_stmt.condition, functions, scope);
103+
let mut then_scope = scope.clone();
104+
walk_block(&mut if_stmt.then_body, functions, &mut then_scope);
105+
if let Some(else_body) = &mut if_stmt.else_body {
106+
let mut else_scope = scope.clone();
107+
walk_block(else_body, functions, &mut else_scope);
108+
}
109+
}
110+
Stmt::Loop(loop_stmt) => {
111+
if let Some(condition) = &mut loop_stmt.condition {
112+
walk_expr(condition, functions, scope);
113+
}
114+
let mut inner = scope.clone();
115+
walk_block(&mut loop_stmt.body, functions, &mut inner);
116+
}
117+
Stmt::For(for_stmt) => {
118+
walk_expr(&mut for_stmt.iterable, functions, scope);
119+
let mut inner = scope.clone();
120+
inner.insert(for_stmt.binding.clone());
121+
walk_block(&mut for_stmt.body, functions, &mut inner);
122+
}
123+
Stmt::Match(match_stmt) => {
124+
walk_expr(&mut match_stmt.value, functions, scope);
125+
for arm in &mut match_stmt.arms {
126+
walk_match_arm(arm, functions, scope);
127+
}
128+
}
129+
Stmt::TaskGroup(task_group) => {
130+
let mut inner = scope.clone();
131+
walk_block(&mut task_group.body, functions, &mut inner);
132+
}
133+
Stmt::Select(select) => {
134+
for arm in &mut select.arms {
135+
walk_expr(&mut arm.operation, functions, scope);
136+
let mut inner = scope.clone();
137+
inner.insert(arm.binding.clone());
138+
walk_block(&mut arm.body, functions, &mut inner);
139+
}
140+
}
141+
Stmt::Assign(assign) => {
142+
walk_expr(&mut assign.target, functions, scope);
143+
walk_expr(&mut assign.value, functions, scope);
144+
}
145+
Stmt::Expr(expr) => walk_expr(expr, functions, scope),
146+
Stmt::Break(_)
147+
| Stmt::Continue(_)
148+
| Stmt::MalformedWith(_)
149+
| Stmt::MalformedIf(_)
150+
| Stmt::MalformedLoop(_)
151+
| Stmt::MalformedFor(_)
152+
| Stmt::MalformedMatch(_)
153+
| Stmt::Unknown(_) => {}
154+
}
155+
}
156+
157+
fn walk_match_arm(arm: &mut MatchArm, functions: &HashMap<String, FunctionParams>, scope: &mut HashSet<String>) {
158+
let mut inner = scope.clone();
159+
for name in arm.pattern.binding_names() {
160+
inner.insert(name.to_string());
161+
}
162+
if let Some(guard) = &mut arm.guard {
163+
walk_expr(guard, functions, &mut inner);
164+
}
165+
walk_block(&mut arm.body, functions, &mut inner);
166+
}
167+
168+
fn walk_expr(expr: &mut Expr, functions: &HashMap<String, FunctionParams>, scope: &mut HashSet<String>) {
169+
match expr {
170+
Expr::Call { callee, args, .. } => {
171+
if let Callee::ReceiverCall { receiver, .. } = callee {
172+
walk_expr(receiver, functions, scope);
173+
}
174+
for arg in args.iter_mut() {
175+
// A bare function name in argument position becomes a forwarding
176+
// closure; anything else is walked normally.
177+
if let Some(closure) = forwarding_closure_for_arg(&arg.value, functions, scope) {
178+
arg.value = closure;
179+
} else {
180+
walk_expr(&mut arg.value, functions, scope);
181+
}
182+
}
183+
}
184+
Expr::Field { base, .. } => walk_expr(base, functions, scope),
185+
Expr::Index { base, index, .. } => {
186+
walk_expr(base, functions, scope);
187+
walk_expr(index, functions, scope);
188+
}
189+
Expr::Binary { left, right, .. } => {
190+
walk_expr(left, functions, scope);
191+
walk_expr(right, functions, scope);
192+
}
193+
Expr::Effect { value, .. }
194+
| Expr::Manage { value, .. }
195+
| Expr::Spawn { value, .. }
196+
| Expr::Await { value, .. }
197+
| Expr::Try { value, .. } => walk_expr(value, functions, scope),
198+
Expr::ObjectLiteral { fields, .. } => {
199+
for field in fields {
200+
walk_expr(&mut field.value, functions, scope);
201+
}
202+
}
203+
Expr::MapLiteral { entries, .. } => {
204+
for entry in entries {
205+
walk_expr(&mut entry.key, functions, scope);
206+
walk_expr(&mut entry.value, functions, scope);
207+
}
208+
}
209+
Expr::ArrayLiteral { items, .. } => {
210+
for item in items {
211+
walk_expr(item, functions, scope);
212+
}
213+
}
214+
Expr::Closure { params, body, .. } => {
215+
let mut inner = scope.clone();
216+
inner.extend(params.iter().cloned());
217+
walk_block(body, functions, &mut inner);
218+
}
219+
Expr::Match { value, arms, .. } => {
220+
walk_expr(value, functions, scope);
221+
for arm in arms {
222+
walk_match_arm(arm, functions, scope);
223+
}
224+
}
225+
Expr::Ident(_, _)
226+
| Expr::Number(_, _)
227+
| Expr::String(_, _)
228+
| Expr::MultilineString(_, _)
229+
| Expr::Unknown(_) => {}
230+
}
231+
}
232+
233+
/// If `value` is a bare identifier naming a top-level free function not shadowed
234+
/// by a local, return a forwarding closure `(p0, ..) { return f(p0: <eff> p0, ..) }`.
235+
fn forwarding_closure_for_arg(
236+
value: &Expr,
237+
functions: &HashMap<String, FunctionParams>,
238+
scope: &HashSet<String>,
239+
) -> Option<Expr> {
240+
let Expr::Ident(name, span) = value else {
241+
return None;
242+
};
243+
if scope.contains(name) {
244+
return None;
245+
}
246+
let params = functions.get(name)?;
247+
let call_args = params
248+
.iter()
249+
.map(|(param_name, effect)| {
250+
let ident = Expr::Ident(param_name.clone(), span.clone());
251+
let value = match effect {
252+
Some(effect) => Expr::Effect {
253+
effect: *effect,
254+
value: Box::new(ident),
255+
span: span.clone(),
256+
},
257+
None => ident,
258+
};
259+
CallArg {
260+
name: Some(param_name.clone()),
261+
value,
262+
malformed: false,
263+
span: span.clone(),
264+
}
265+
})
266+
.collect();
267+
let call = Expr::Call {
268+
callee: Callee::Name(name.clone()),
269+
args: call_args,
270+
span: span.clone(),
271+
};
272+
let body = Block {
273+
statements: vec![Stmt::Return(super::ast::ReturnStmt {
274+
value: Some(call),
275+
span: span.clone(),
276+
})],
277+
span: span.clone(),
278+
};
279+
Some(Expr::Closure {
280+
params: params.iter().map(|(name, _)| name.clone()).collect(),
281+
captures: Vec::new(),
282+
declared_effects: Vec::new(),
283+
explicit: false,
284+
body,
285+
span: span.clone(),
286+
})
287+
}

crates/rsscript/src/syntax/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod ast;
22
pub(crate) mod desugar;
3+
pub(crate) mod function_value_desugar;
34
pub(crate) mod module_isolation;
45
mod parser;
56

crates/rsscript/src/syntax/module_isolation.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ fn module_prefix_from_dotted(namespace: &str) -> String {
4646

4747
/// Rewrite a program so each `module`-scoped symbol becomes globally unique.
4848
pub fn isolate_module_namespaces(program: &mut Program) {
49+
// Desugar named-function values into forwarding closures first, so the
50+
// synthesized calls are then mangled by isolation like any other reference.
51+
// This runs for every program (module-less ones included), unlike the module
52+
// rewriting below.
53+
super::function_value_desugar::desugar_function_values(program);
4954
let file_module = collect_file_modules(program);
5055
if file_module.is_empty() {
5156
// No `module` declarations anywhere: pure root namespace, nothing to do.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// A named function can be passed where a `Fn(...)` is expected; it desugars to a
2+
// forwarding closure, so no inline lambda or defunctionalizing struct is needed.
3+
4+
fn is_even(x: read Int) -> Bool {
5+
return x % 2 == 0
6+
}
7+
8+
fn count_evens(xs: read List<Int>) -> Int {
9+
let evens = List.filter(list: read xs, predicate: is_even)
10+
return List.len(list: read evens)
11+
}

crates/rsscript/tests/vm_eval_parity/misc.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ fn main() -> Unit {
3838
);
3939
}
4040

41+
#[test]
42+
fn parity_named_function_value_as_callback() {
43+
// A named function passed where a `Fn` is expected desugars to a forwarding
44+
// closure, so the VM and compiled backend filter identically.
45+
let source = r#"
46+
features: local
47+
48+
fn is_even(x: read Int) -> Bool {
49+
return x % 2 == 0
50+
}
51+
52+
fn main() -> Unit {
53+
local xs = List.new<Int>()
54+
List.push(list: mut xs, value: read 1)
55+
List.push(list: mut xs, value: read 2)
56+
List.push(list: mut xs, value: read 3)
57+
List.push(list: mut xs, value: read 4)
58+
let evens = List.filter(list: read xs, predicate: is_even)
59+
Log.write(message: read String.from_int(value: List.len(list: read evens)))
60+
return Unit
61+
}
62+
"#;
63+
common::assert_vm_eval_matches_backend(
64+
"parity-fn-value.rss",
65+
"rsscript_parity_fn_value",
66+
source,
67+
);
68+
}
69+
4170
#[test]
4271
fn parity_struct_match_field_and_assignment() {
4372
let source = r#"

0 commit comments

Comments
 (0)