Skip to content

Commit cf6a2f8

Browse files
olwangclaude
andcommitted
rsscript: await in expression position via await-hoisting
A nested `await` — in a call argument, return value, or assignment-target index — was rejected (RS0411). Add a syntax pass (syntax/async_await_hoist.rs) that lifts each nested await, in left-to-right evaluation order, into a preceding `let __rss_await_N = await <op>` binding, producing the linear statement-boundary awaits both backends already lower identically. Runs in isolate_module_namespaces so the interpreter and compiled output share the same suspension points. The short-circuit `&&`/`||` RHS is deliberately not hoisted (it is conditionally evaluated) and stays RS0411. Repurposed the not-lowerable fail fixture to that remaining case; removed the assignment-target fail fixture (now supported) and added a VM↔compiled parity fixture covering argument, nested, and assignment-target awaits plus left-to-right order. Spec §14.6.2 / §3.1 / §3.2 / §20.1-A updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7fed45c commit cf6a2f8

8 files changed

Lines changed: 317 additions & 38 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//! `await`-in-expression support via await-hoisting (A-normal form).
2+
//!
3+
//! The async lowering only handles `await` at a statement boundary (`let x =
4+
//! await f()`, `return await f()`, a bare `await f()` statement) or inside an
5+
//! `if`/`loop`/`match`/`with` body. A nested `await` — `f(x: await g())` — is
6+
//! otherwise rejected (`RS0411`).
7+
//!
8+
//! This pass lifts each nested await out into a preceding
9+
//! `let __rss_await_N = await <op>`, producing the linear awaits the backends
10+
//! already lower identically. It is sound and contained: the output uses only
11+
//! already-supported constructs, so the async lowering itself is untouched.
12+
//!
13+
//! Conservative boundaries (left as-is, so they stay rejected rather than risk
14+
//! wrong evaluation order): the RHS of short-circuit `&&`/`||`, `match`/`if`
15+
//! arms reached through an expression, and closure bodies.
16+
17+
use super::ast::{BinaryOp, Block, Callee, Expr, Item, LetKind, LetStmt, Program, Stmt};
18+
19+
/// Hoist nested awaits in every `async fn` body to statement boundaries.
20+
pub fn hoist_async_awaits(program: &mut Program) {
21+
for item in &mut program.items {
22+
if let Item::Function(function) = item
23+
&& function.is_async
24+
{
25+
let mut counter = 0usize;
26+
hoist_block(&mut function.body, &mut counter);
27+
}
28+
}
29+
}
30+
31+
fn hoist_block(block: &mut Block, counter: &mut usize) {
32+
let mut rewritten: Vec<Stmt> = Vec::with_capacity(block.statements.len());
33+
for mut statement in std::mem::take(&mut block.statements) {
34+
let mut hoisted: Vec<Stmt> = Vec::new();
35+
hoist_stmt(&mut statement, &mut hoisted, counter);
36+
rewritten.extend(hoisted);
37+
rewritten.push(statement);
38+
}
39+
block.statements = rewritten;
40+
}
41+
42+
fn hoist_stmt(stmt: &mut Stmt, hoisted: &mut Vec<Stmt>, counter: &mut usize) {
43+
match stmt {
44+
Stmt::Let(let_stmt) => {
45+
if let Some(value) = &mut let_stmt.value {
46+
hoist_value(value, hoisted, counter);
47+
}
48+
}
49+
Stmt::Return(ret) => {
50+
if let Some(value) = &mut ret.value {
51+
hoist_value(value, hoisted, counter);
52+
}
53+
}
54+
Stmt::Expr(value) => hoist_value(value, hoisted, counter),
55+
Stmt::Assign(assign) => {
56+
// The target is an evaluated place: any await in a field/index base or
57+
// index expression is nested and must be hoisted.
58+
hoist_nested(&mut assign.target, hoisted, counter);
59+
hoist_value(&mut assign.value, hoisted, counter);
60+
}
61+
// Control-flow statements own their own blocks (boundaries). Recurse into
62+
// the nested blocks; leave the condition/scrutinee to the existing async
63+
// boundary lowering.
64+
Stmt::If(if_stmt) => {
65+
hoist_block(&mut if_stmt.then_body, counter);
66+
if let Some(else_body) = &mut if_stmt.else_body {
67+
hoist_block(else_body, counter);
68+
}
69+
}
70+
Stmt::Loop(loop_stmt) => hoist_block(&mut loop_stmt.body, counter),
71+
Stmt::For(for_stmt) => hoist_block(&mut for_stmt.body, counter),
72+
Stmt::With(with_stmt) => hoist_block(&mut with_stmt.body, counter),
73+
Stmt::TaskGroup(task_group) => hoist_block(&mut task_group.body, counter),
74+
Stmt::Match(match_stmt) => {
75+
for arm in &mut match_stmt.arms {
76+
hoist_block(&mut arm.body, counter);
77+
}
78+
}
79+
Stmt::Select(select) => {
80+
for arm in &mut select.arms {
81+
hoist_block(&mut arm.body, counter);
82+
}
83+
}
84+
Stmt::LetElse(let_else) => {
85+
hoist_value(&mut let_else.value, hoisted, counter);
86+
hoist_block(&mut let_else.else_body, counter);
87+
}
88+
Stmt::Break(_)
89+
| Stmt::Continue(_)
90+
| Stmt::MalformedWith(_)
91+
| Stmt::MalformedIf(_)
92+
| Stmt::MalformedLoop(_)
93+
| Stmt::MalformedFor(_)
94+
| Stmt::MalformedMatch(_)
95+
| Stmt::Unknown(_) => {}
96+
}
97+
}
98+
99+
/// A root value position (`let`/`return`/`expr`/assign RHS): a top-level `await`
100+
/// (through transparent effect/`?` wrappers) is already linear, so only its
101+
/// operand is searched for nested awaits.
102+
fn hoist_value(expr: &mut Expr, hoisted: &mut Vec<Stmt>, counter: &mut usize) {
103+
match expr {
104+
Expr::Effect { value, .. } => hoist_value(value, hoisted, counter),
105+
Expr::Await { value, .. } => hoist_nested(value, hoisted, counter),
106+
Expr::Try { value, .. } if matches!(value.as_ref(), Expr::Await { .. }) => {
107+
if let Expr::Await { value: inner, .. } = value.as_mut() {
108+
hoist_nested(inner, hoisted, counter);
109+
}
110+
}
111+
_ => hoist_nested(expr, hoisted, counter),
112+
}
113+
}
114+
115+
/// A nested (non-root) position: every `await` found here is lifted to a
116+
/// preceding `let __rss_await_N = await <op>` and replaced with the temp.
117+
fn hoist_nested(expr: &mut Expr, hoisted: &mut Vec<Stmt>, counter: &mut usize) {
118+
if is_await_unit(expr) {
119+
// Hoist awaits inside the awaited operand first (inner-to-outer order).
120+
if let Some(inner) = await_unit_operand_mut(expr) {
121+
hoist_nested(inner, hoisted, counter);
122+
}
123+
let span = expr.span().clone();
124+
let name = format!("__rss_await_{counter}");
125+
*counter += 1;
126+
let original = std::mem::replace(expr, Expr::Ident(name.clone(), span.clone()));
127+
hoisted.push(Stmt::Let(LetStmt {
128+
kind: LetKind::Managed,
129+
name,
130+
type_annotation: None,
131+
value: Some(original),
132+
is_async: false,
133+
is_mut: false,
134+
destructure: None,
135+
malformed: false,
136+
span,
137+
}));
138+
return;
139+
}
140+
match expr {
141+
Expr::Call { callee, args, .. } => {
142+
if let Callee::ReceiverCall { receiver, .. } = callee {
143+
hoist_nested(receiver, hoisted, counter);
144+
}
145+
for arg in args.iter_mut() {
146+
hoist_nested(&mut arg.value, hoisted, counter);
147+
}
148+
}
149+
// Short-circuit operators: the right operand is conditionally evaluated,
150+
// so hoisting it would change semantics — leave it (stays rejected).
151+
Expr::Binary {
152+
op: BinaryOp::LogicalAnd | BinaryOp::LogicalOr,
153+
left,
154+
..
155+
} => hoist_nested(left, hoisted, counter),
156+
Expr::Binary { left, right, .. } => {
157+
hoist_nested(left, hoisted, counter);
158+
hoist_nested(right, hoisted, counter);
159+
}
160+
Expr::Field { base, .. } => hoist_nested(base, hoisted, counter),
161+
Expr::Index { base, index, .. } => {
162+
hoist_nested(base, hoisted, counter);
163+
hoist_nested(index, hoisted, counter);
164+
}
165+
Expr::Effect { value, .. } | Expr::Manage { value, .. } | Expr::Try { value, .. } => {
166+
hoist_nested(value, hoisted, counter);
167+
}
168+
Expr::ObjectLiteral { fields, .. } => {
169+
for field in fields {
170+
hoist_nested(&mut field.value, hoisted, counter);
171+
}
172+
}
173+
Expr::MapLiteral { entries, .. } => {
174+
for entry in entries {
175+
hoist_nested(&mut entry.key, hoisted, counter);
176+
hoist_nested(&mut entry.value, hoisted, counter);
177+
}
178+
}
179+
Expr::ArrayLiteral { items, .. } => {
180+
for item in items {
181+
hoist_nested(item, hoisted, counter);
182+
}
183+
}
184+
// Match-expression arms are statement-boundary scopes of their own; hoist
185+
// within each arm body. Leave the scrutinee and closures to existing rules.
186+
Expr::Match { arms, .. } => {
187+
for arm in arms {
188+
hoist_block(&mut arm.body, counter);
189+
}
190+
}
191+
Expr::Closure { .. }
192+
| Expr::Spawn { .. }
193+
| Expr::Await { .. }
194+
| Expr::Ident(_, _)
195+
| Expr::Number(_, _)
196+
| Expr::String(_, _)
197+
| Expr::MultilineString(_, _)
198+
| Expr::Unknown(_) => {}
199+
}
200+
}
201+
202+
/// `await op` or `await op?` (`Try` wrapping an `Await`) — the unit lifted whole.
203+
fn is_await_unit(expr: &Expr) -> bool {
204+
matches!(expr, Expr::Await { .. })
205+
|| matches!(expr, Expr::Try { value, .. } if matches!(value.as_ref(), Expr::Await { .. }))
206+
}
207+
208+
/// The awaited operand inside an await-unit, for hoisting its own nested awaits.
209+
fn await_unit_operand_mut(expr: &mut Expr) -> Option<&mut Expr> {
210+
match expr {
211+
Expr::Await { value, .. } => Some(value),
212+
Expr::Try { value, .. } => match value.as_mut() {
213+
Expr::Await { value: inner, .. } => Some(inner),
214+
_ => None,
215+
},
216+
_ => None,
217+
}
218+
}

crates/rsscript/src/syntax/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod ast;
2+
pub(crate) mod async_await_hoist;
23
pub(crate) mod desugar;
34
pub(crate) mod function_value_desugar;
45
pub(crate) mod module_isolation;

crates/rsscript/src/syntax/module_isolation.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ pub fn isolate_module_namespaces(program: &mut Program) {
5151
// This runs for every program (module-less ones included), unlike the module
5252
// rewriting below.
5353
super::function_value_desugar::desugar_function_values(program);
54+
// Hoist `await` out of expression positions into statement-boundary `let`
55+
// bindings, so a nested `await f(await g())` lowers like the linear awaits the
56+
// backends already support. Runs for every program (module-less included).
57+
super::async_await_hoist::hoist_async_awaits(program);
5458
let file_module = collect_file_modules(program);
5559
if file_module.is_empty() {
5660
// No `module` declarations anywhere: pure root namespace, nothing to do.
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// expect: RS0411
2-
2+
// Most `await`-in-expression positions are now lowered by hoisting the await to
3+
// a preceding `let` binding. The exception is the right-hand operand of a
4+
// short-circuit `&&`/`||`: it is only conditionally evaluated, so hoisting it
5+
// would change semantics. Those stay rejected as non-linear awaits.
36
features: async
47

5-
async fn text() -> Result<fresh String, TimerError> {
6-
return Ok("x")
8+
async fn ready() -> Result<Bool, TimerError> {
9+
return Ok(true)
710
}
811

9-
async fn bad() -> Result<fresh String, TimerError> {
10-
let value = String.concat(left: read "a", right: read await text()?)
12+
async fn bad() -> Result<Bool, TimerError> {
13+
let value = false || await ready()?
1114
return Ok(value)
1215
}

crates/rsscript/tests/fixtures/fail/await-in-assignment-target.rss

Lines changed: 0 additions & 16 deletions
This file was deleted.

crates/rsscript/tests/vm_eval_parity/async_concurrency.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,34 @@ async fn main() -> Result<Unit, ChannelError> {
201201
&[],
202202
);
203203
}
204+
205+
#[test]
206+
fn parity_await_in_expression() {
207+
// `await` nested inside argument, return, and assignment-target expressions
208+
// is hoisted to preceding `let` bindings. Evaluation order (left-to-right)
209+
// and the doubly-nested `await g(await f())` form must match across backends.
210+
let source = r#"
211+
features: async, local
212+
213+
async fn step(n: Int) -> Result<Int, String> {
214+
Log.write(message: read String.from_int(value: n))
215+
return Ok(n)
216+
}
217+
218+
fn add(a: Int, b: Int) -> Int { return a + b }
219+
220+
async fn main() -> Result<Unit, String> {
221+
let total = add(a: await step(n: 1)?, b: await step(n: 2)?)
222+
let nested = await step(n: await step(n: 3)?)?
223+
let mut xs = [0, 0, 0]
224+
xs[await step(n: 0)?] = total + nested
225+
Log.write(message: read String.from_int(value: xs[0]))
226+
return Ok(Unit)
227+
}
228+
"#;
229+
common::assert_vm_eval_matches_backend(
230+
"parity-await-in-expression.rss",
231+
"rsscript_parity_await_in_expression",
232+
source,
233+
);
234+
}

docs/RSScript_v0.7_Spec.md

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ Core execution:
1414
- Managed application code, local values under `features: local`, fresh struct
1515
returns, read/mut/take effects, resources through `with`,
1616
`ResourcePool<T: Resource>`, and bodyless native declarations (Chapters 3–15).
17-
- Executable async MVP: `async fn` bodies with direct/statement-boundary
18-
`await`, structured concurrency `task_group { async let ... }`, `select`,
19-
`await for`, and bounded MPSC `Channel`/`Stream` via `rss-async` (§14.4).
17+
- Executable async MVP: `async fn` bodies with `await` in statement and
18+
expression positions (nested awaits are hoisted to `let` bindings; §14.6.2),
19+
structured concurrency `task_group { async let ... }`, `select`, `await for`,
20+
and bounded MPSC `Channel`/`Stream` via `rss-async` (§14.4).
2021
2122
Surface and control flow:
2223
- Receiver-call shorthand with mandatory effect keyword and unique resolution
@@ -642,7 +643,7 @@ generated-namespace isolation via module declarations; #lower_name pin
642643
resource values through with
643644
ResourcePool<T: Resource>
644645
bodyless native declarations through package binding metadata
645-
async fn bodies: direct await plus statement-boundary awaits in if/loop/match/with
646+
async fn bodies: await in statement and expression positions (nested awaits hoisted)
646647
structured concurrency: task_group { async let ... }, select, await for
647648
bounded MPSC Channel and Stream (Receiver.into_stream / Stream.next), via rss-async
648649
receiver-call shorthand with unique resolution
@@ -668,10 +669,13 @@ scoped views / slices
668669
```
669670

670671
`async fn` signatures are review-visible contracts. v0.7 admits an executable async
671-
MVP: `await` appears inside an `async fn`, either directly consuming an async call
672-
at a statement boundary or inside an `if`/`loop`/`match`/`with` body (which lowers
673-
as an explicit async statement boundary); awaits embedded in ordinary expression
674-
arguments are rejected (`RS0411`) until full async expression lowering lands.
672+
MVP: `await` appears inside an `async fn` at a statement boundary, inside an
673+
`if`/`loop`/`match`/`with` body, or **nested in an expression** — an `await` in a
674+
call argument, return value, or assignment-target index is hoisted to a preceding
675+
`let __rss_await_N = await ...` binding (§14.6.2), producing the linear awaits both
676+
backends lower identically. The one remaining non-linear position is the
677+
conditionally-evaluated right operand of a short-circuit `&&`/`||`, which is still
678+
rejected (`RS0411`) to avoid changing evaluation semantics.
675679
Structured concurrency is executable: `task_group { async let ... }` constructs
676680
isolate-local child operations driven by one cooperative poll loop, `select`
677681
awaits the first ready arm, and `await for` iterates a `Stream` / channel
@@ -3606,6 +3610,36 @@ Receiver-call shorthand satisfies the feature admission rule because:
36063610
explicitly marked with the effect keyword and mechanically expandable.
36073611
```
36083612

3613+
#### 14.6.2 `await` in expression position (await-hoisting)
3614+
3615+
The async lowering treats each `await` as a cooperative suspension point and only
3616+
lowers it at a **statement boundary**`let x = await f()`, `return await f()`, a
3617+
bare `await f()` statement — or as the boundary created by an `if`/`loop`/`match`/
3618+
`with` body. An `await` nested inside a larger expression (a call argument, a
3619+
return value, an assignment-target index) is normalized to that linear form by an
3620+
**await-hoisting** desugar before lowering: each nested `await <op>` is lifted, in
3621+
left-to-right evaluation order, into a preceding `let __rss_await_N = await <op>`
3622+
binding and replaced by a reference to that temporary.
3623+
3624+
```rust
3625+
// written
3626+
let total = add(a: await step(n: 1)?, b: await step(n: 2)?)
3627+
// lowered (conceptually)
3628+
let __rss_await_0 = await step(n: 1)?
3629+
let __rss_await_1 = await step(n: 2)?
3630+
let total = add(a: __rss_await_0, b: __rss_await_1)
3631+
```
3632+
3633+
The hoist runs as a syntax pass shared by every backend, so the interpreter and
3634+
the compiled output observe the same suspension points and the same
3635+
left-to-right evaluation order (guaranteed by parity fixtures). The `__rss_await_`
3636+
prefix is reserved (§14.8); user code may not introduce it.
3637+
3638+
One position is deliberately **not** hoisted: the right operand of a
3639+
short-circuit `&&`/`||`, which is only conditionally evaluated. Hoisting it would
3640+
force unconditional evaluation, so such an `await` stays rejected as a non-linear
3641+
await (`RS0411`).
3642+
36093643
#### Dynamic dispatch (deferred, not admitted in v0.7)
36103644

36113645
RSScript v0.7 does not admit protocol-typed dynamic dispatch, trait objects, or
@@ -5228,6 +5262,8 @@ Rust's `Pin`/`Poll`/`Waker` machinery leaking into source.
52285262

52295263
```text
52305264
A. Extended async surface beyond the v0.7 MVP
5265+
- `await` in expression position is implemented (await-hoisting, §14.6.2); the
5266+
remaining gap is the conditionally-evaluated short-circuit `&&`/`||` RHS.
52315267
- Async operation/task handles, if exposed, are isolate-local managed handles,
52325268
not a user-facing Future/Pin/Poll type system.
52335269
- async closures and a stream / "await for" async-sequence form.

0 commit comments

Comments
 (0)