plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


af1776bPeter John 2026-09-08T12:57:14+05:30
feat(lang): add postfix `?` error-propagation operator
README.md CHANGED
@@ -312,6 +312,17 @@ $ plum run plum-examples/io.plum
312
312
  hello from plum
313
313
  ```
314
314
 
315
+ ## Error propagation
316
+
317
+ `expr?` unwraps a `Result`'s `Ok` or an `Option`'s `Some`, or exits the enclosing function early with the `Err`/`None` value as-is otherwise — Rust's `?`:
318
+
319
+ ```plum
320
+ fun sumTwo(a: Str, b: Str) -> Result[Int, Str] =
321
+ x := parsePositive(a)?
322
+ y := parsePositive(b)?
323
+ return Ok(x + y)
324
+ ```
325
+
315
326
  ## Standard library highlights
316
327
 
317
328
  - **`Bool`** (`import std/Bool`) — ordinary `enum Bool = | True | False`
plum-checker/src/lib.rs CHANGED
@@ -1104,6 +1104,39 @@ pub(crate) fn lookupFieldType(class_name: &str, field_name: &str, ctx: &CheckCtx
1104
1104
  }
1105
1105
  }
1106
1106
 
1107
+ /// A mangled variant name's own unmangled base (`"Ok$Int$Str"` -> `"Ok"`,
1108
+ /// `"None"` -> `"None"`) — `monomorphize::mangle` always joins the original
1109
+ /// variant name and its resolved type args with `$`, so the base is simply
1110
+ /// everything before the first `$`.
1111
+ fn variantBaseName(name: &str) -> &str {
1112
+ name.split('$').next().unwrap_or(name)
1113
+ }
1114
+
1115
+ /// Finds the "success" (`Ok`/`Some`) and "failure" (`Err`/`None`) variants
1116
+ /// belonging to the same enum as `enum_name`, for the `?` operator (`Expr::Try`)
1117
+ /// — used identically by the checker (`inferExpr`) and codegen (`inferLocalType`
1118
+ /// callers), which is why this lives here rather than inline in either. Returns
1119
+ /// `None` if `enum_name` isn't (a monomorphized specialization of) `Result` or
1120
+ /// `Option` — i.e. doesn't have both shapes of variant.
1121
+ pub fn tryOperatorVariants<'a>(enum_name: &str, ctx: &'a CheckCtx) -> Option<(&'a str, &'a EnumVariantInfo, &'a str, &'a EnumVariantInfo)> {
1122
+ let mut success: Option<(&str, &EnumVariantInfo)> = None;
1123
+ let mut failure: Option<(&str, &EnumVariantInfo)> = None;
1124
+ for (vname, info) in ctx.enum_variants.iter() {
1125
+ if info.enum_name != enum_name {
1126
+ continue;
1127
+ }
1128
+ match variantBaseName(vname) {
1129
+ "Ok" | "Some" => success = Some((vname.as_str(), info)),
1130
+ "Err" | "None" => failure = Some((vname.as_str(), info)),
1131
+ _ => {}
1132
+ }
1133
+ }
1134
+ match (success, failure) {
1135
+ (Some((sn, si)), Some((fn_, fi))) => Some((sn, si, fn_, fi)),
1136
+ _ => None,
1137
+ }
1138
+ }
1139
+
1107
1140
  pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<PlumType, String> {
1108
1141
  match expr {
1109
1142
  ast::Expr::Int(_) => Ok(PlumType::TInt),
@@ -1134,6 +1167,25 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1134
1167
  },
1135
1168
  },
1136
1169
  ast::Expr::Paren(inner) => inferExpr(inner, env, ctx),
1170
+ // `expr?` — deliberately does NOT check that the enclosing function's
1171
+ // return type actually accepts the `Err`/`None` value this can exit
1172
+ // with early; that's left permissive here (codegen will catch a
1173
+ // genuine mismatch as a wasm type error), consistent with this
1174
+ // checker's existing "unmodeled/unvalidated, codegen catches it"
1175
+ // fallbacks elsewhere (e.g. `TypeName`'s unmodeled-type-name arm
1176
+ // above) — checking it properly would need the enclosing function's
1177
+ // declared return type threaded through every `inferExpr` call site,
1178
+ // for a case a wrong program still fails on, just less precisely.
1179
+ ast::Expr::Try(inner) => {
1180
+ let inner_ty = inferExpr(inner, env, ctx)?;
1181
+ match &inner_ty {
1182
+ PlumType::TNamed(name) => match tryOperatorVariants(name, ctx) {
1183
+ Some((_, success_info, _, _)) => Ok(success_info.field_types.first().cloned().unwrap_or(PlumType::TUnit)),
1184
+ None => Err(format!("'?' requires a Result or Option value, found '{}'", name)),
1185
+ },
1186
+ other => Err(format!("'?' requires a Result or Option value, found {}", other)),
1187
+ }
1188
+ }
1137
1189
  ast::Expr::Closure(cl) => {
1138
1190
  let mut closure_env = env.clone();
1139
1191
  let param_names: std::collections::HashSet<&str> = cl.params.iter().map(|s| s.as_str()).collect();
plum-checker/src/monomorphize.rs CHANGED
@@ -294,6 +294,7 @@ fn substituteTypesInExpr(expr: &mut ast::Expr, subst: &Substitution) {
294
294
  substituteTypesInExpr(&mut b.right, subst);
295
295
  }
296
296
  ast::Expr::Not(inner) => substituteTypesInExpr(inner, subst),
297
+ ast::Expr::Try(inner) => substituteTypesInExpr(inner, subst),
297
298
  ast::Expr::Compare(c) => {
298
299
  substituteTypesInExpr(&mut c.left, subst);
299
300
  substituteTypesInExpr(&mut c.right, subst);
@@ -486,6 +487,7 @@ fn renameVarInExpr(expr: &mut ast::Expr, old: &str, new: &str) {
486
487
  ast::Expr::Bool(b) => { renameVarInExpr(&mut b.left, old, new); renameVarInExpr(&mut b.right, old, new); }
487
488
  ast::Expr::Compare(c) => { renameVarInExpr(&mut c.left, old, new); renameVarInExpr(&mut c.right, old, new); }
488
489
  ast::Expr::Not(inner) => renameVarInExpr(inner, old, new),
490
+ ast::Expr::Try(inner) => renameVarInExpr(inner, old, new),
489
491
  ast::Expr::Unary(u) => renameVarInExpr(&mut u.operand, old, new),
490
492
  ast::Expr::Paren(inner) => renameVarInExpr(inner, old, new),
491
493
  ast::Expr::Ternary(t) => {
@@ -2211,6 +2213,7 @@ impl<'a> Monomorphizer<'a> {
2211
2213
  self.resolveBareVariantAgainstExpected(&mut c.right, &lt);
2212
2214
  }
2213
2215
  ast::Expr::Not(inner) => self.rewriteExpr(inner, env)?,
2216
+ ast::Expr::Try(inner) => self.rewriteExpr(inner, env)?,
2214
2217
  ast::Expr::Unary(u) => self.rewriteExpr(&mut u.operand, env)?,
2215
2218
  ast::Expr::Paren(inner) => self.rewriteExpr(inner, env)?,
2216
2219
  ast::Expr::Ternary(t) => {
plum-core/src/ast.rs CHANGED
@@ -335,6 +335,9 @@ pub enum Expr {
335
335
  ClassCall(ClassCall),
336
336
  /// `expr.field` or `expr.method(…)`
337
337
  Attribute(Box<AttributeExpr>),
338
+ /// `expr?` — unwraps a `Result`'s `Ok`/an `Option`'s `Some`, or returns the
339
+ /// `Err`/`None` value from the enclosing function as-is otherwise.
340
+ Try(Box<Expr>),
338
341
  /// `{expr}` — grouped/parenthesized expression
339
342
  Paren(Box<Expr>),
340
343
  String(StringExpr),
plum-core/src/builtin_usage.rs CHANGED
@@ -188,6 +188,7 @@ fn walkExpr(expr: &Expr, names: &mut HashSet<String>) {
188
188
  walkExpr(&b.right, names);
189
189
  }
190
190
  Expr::Not(e) => walkExpr(e, names),
191
+ Expr::Try(e) => walkExpr(e, names),
191
192
  Expr::Compare(c) => {
192
193
  walkExpr(&c.left, names);
193
194
  walkExpr(&c.right, names);
plum-core/src/parser.rs CHANGED
@@ -670,6 +670,7 @@ impl<'a> AstParser<'a> {
670
670
  "binary_operator" => self.parseBinary(node),
671
671
  "unary_operator" => self.parseUnary(node),
672
672
  "attribute" => self.parseAttribute(node),
673
+ "try_expression" => self.parseTryExpression(node),
673
674
  "fn_call" => Expr::FnCall(self.parseFnCall(node)),
674
675
  "class_call" => Expr::ClassCall(self.parseClassCall(node)),
675
676
  "parenthesized_expression" => {
@@ -802,6 +803,14 @@ impl<'a> AstParser<'a> {
802
803
  Expr::Attribute(Box::new(AttributeExpr { object, attr }))
803
804
  }
804
805
 
806
+ fn parseTryExpression(&self, node: Node) -> Expr {
807
+ // try_expression: primary_expression "?" — the sole named child is the value.
808
+ let value = node.child_by_field_name("value")
809
+ .map(|n| { let u = self.unwrapExprNode(n); self.parsePrimaryExpression(u) })
810
+ .unwrap_or(Expr::Int(0));
811
+ Expr::Try(Box::new(value))
812
+ }
813
+
805
814
  fn parseFnCall(&self, node: Node) -> FnCall {
806
815
  // fn_call: var_identifier fn_argument_list (the callee lexes as var_identifier
807
816
  // to avoid an identifier-token tie with all-lowercase, no-underscore names)
@@ -1052,6 +1061,7 @@ fn isExpressionKind(kind: &str) -> bool {
1052
1061
  | "comparison_operator"
1053
1062
  | "ternary_expression"
1054
1063
  | "attribute"
1064
+ | "try_expression"
1055
1065
  | "fn_call"
1056
1066
  | "class_call"
1057
1067
  | "parenthesized_expression"
plum-examples/try_operator.plum ADDED
@@ -0,0 +1,58 @@
1
+ import std/Result
2
+ import std/Option
3
+ import std/Bool
4
+ import std/Number
5
+ import std/Str
6
+ import std/List
7
+
8
+ # `expr?` unwraps a `Result`'s `Ok` or an `Option`'s `Some`, or exits the
9
+ # enclosing function early with the `Err`/`None` value as-is otherwise — same
10
+ # idea as Rust's `?`. The enclosing function's own return type must accept
11
+ # whatever gets returned early; that isn't checked until codegen (see
12
+ # `plum-checker`'s `inferExpr` on `Expr::Try`), so a mismatch there still
13
+ # surfaces as a clear compile error, just a less precise one.
14
+
15
+ fun parsePositive(s: Str) -> Result[Int, Str] =
16
+ n := parseInt(s)?
17
+ if n < 0
18
+ return Err("negative")
19
+ return Ok(n)
20
+
21
+ fun sumTwo(a: Str, b: Str) -> Result[Int, Str] =
22
+ x := parsePositive(a)?
23
+ y := parsePositive(b)?
24
+ return Ok(x + y)
25
+
26
+ fun firstPositive(list: List[Int]) -> Option[Int] =
27
+ match list.get(0)
28
+ Some(v) =>
29
+ if v > 0
30
+ return Some(v)
31
+ return None
32
+ None =>
33
+ return None
34
+
35
+ fun doubledFirstPositive(list: List[Int]) -> Option[Int] =
36
+ a := firstPositive(list)?
37
+ return Some(a * 2)
38
+
39
+ test "try operator unwraps Ok and propagates the value through two calls"
40
+ r := sumTwo("2", "3")
41
+ assert r.isOk()
42
+ assert r.unwrap() == 5
43
+
44
+ test "try operator exits early with Err, skipping the rest of the function"
45
+ r := sumTwo("2", "-3")
46
+ assert r.isErr()
47
+ assert r.unwrapErr() == "negative"
48
+
49
+ test "try operator unwraps Some and propagates the value"
50
+ l := List(5)
51
+ r := doubledFirstPositive(l)
52
+ assert r.isSome()
53
+ assert r.unwrap() == 10
54
+
55
+ test "try operator exits early with None, skipping the rest of the function"
56
+ l := List[Int]()
57
+ r := doubledFirstPositive(l)
58
+ assert r.isNone()
plum-tooling/tree-sitter-plum/grammar.js CHANGED
@@ -71,7 +71,14 @@ module.exports = grammar({
71
71
  // Conflict arises at the empty-argument-list state (e.g. `Foo()`): the parser cannot
72
72
  // distinguish fn_argument_list from class_argument_list until it sees content or ')'.
73
73
  // Declaring the conflict at the argument-list level (not the call level) resolves this.
74
+ conflicts: ($) => [
74
- conflicts: ($) => [[$.fn_argument_list, $.class_argument_list]],
75
+ [$.fn_argument_list, $.class_argument_list],
76
+ // `cmp ? sum : bits` (ternary) vs `cmp?` (try-operator postfix) both start
77
+ // with `primary_expression "?"` — genuinely ambiguous with only 1 token of
78
+ // lookahead (a `:` may or may not follow much later); GLR resolves it by
79
+ // trying both and keeping whichever completes.
80
+ [$.expression, $.try_expression],
81
+ ],
75
82
  inline: ($) => [$.generic_type],
76
83
  rules: {
77
84
  source: ($) =>
@@ -394,6 +401,7 @@ module.exports = grammar({
394
401
  $.float,
395
402
  $.unary_operator,
396
403
  $.attribute,
404
+ $.try_expression,
397
405
  $.fn_call,
398
406
  $.class_call,
399
407
  $.parenthesized_expression,
@@ -509,6 +517,13 @@ module.exports = grammar({
509
517
  ),
510
518
  ),
511
519
 
520
+ // Rust-style error-propagation postfix: `expr?` — unwraps a `Result`'s `Ok`
521
+ // or an `Option`'s `Some`, or exits the enclosing function early with the
522
+ // `Err`/`None` value as-is otherwise. Same postfix shape/precedence as
523
+ // `attribute` (`.`) just above.
524
+ try_expression: ($) =>
525
+ seq(field("value", $.primary_expression), "?"),
526
+
512
527
  // The callee name lexes as `var_identifier` (widened to a superset of
513
528
  // `fn_identifier`'s charset below) rather than `fn_identifier` — using two
514
529
  // different identifier tokens here was ambiguous for any all-lowercase,
plum-tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -62,6 +62,8 @@
62
62
  ".."
63
63
  ] @operator
64
64
 
65
+ (try_expression "?" @operator)
66
+
65
67
  [
66
68
  "->"
67
69
  "=>"
plum-tooling/tree-sitter-plum/src/grammar.json CHANGED
Binary file
plum-tooling/tree-sitter-plum/src/node-types.json CHANGED
Binary file
plum-tooling/tree-sitter-plum/src/parser.c CHANGED
Binary file
plum-tooling/tree-sitter-plum/test/corpus/try.txt ADDED
@@ -0,0 +1,85 @@
1
+ ================================================================================
2
+ try operator - postfix ? unwraps or returns early
3
+ ================================================================================
4
+
5
+ fun parsePositive(s: Str) -> Result[Int, Str] =
6
+ n := parseInt(s)?
7
+ return Ok(n)
8
+
9
+ --------------------------------------------------------------------------------
10
+
11
+ (source
12
+ (fn
13
+ name: (fn_identifier)
14
+ params: (param
15
+ name: (var_identifier)
16
+ type: (type
17
+ (type_identifier)))
18
+ returns: (type
19
+ (type_identifier)
20
+ generics: (type
21
+ (type_identifier))
22
+ generics: (type
23
+ (type_identifier)))
24
+ body: (body
25
+ (assign
26
+ (var_identifier)
27
+ (expression
28
+ (primary_expression
29
+ (try_expression
30
+ value: (primary_expression
31
+ (fn_call
32
+ function: (var_identifier)
33
+ arguments: (fn_argument_list
34
+ (expression
35
+ (primary_expression
36
+ (var_identifier))))))))))
37
+ (return
38
+ (expression
39
+ (primary_expression
40
+ (fn_call
41
+ function: (type_identifier)
42
+ arguments: (fn_argument_list
43
+ (expression
44
+ (primary_expression
45
+ (var_identifier)))))))))))
46
+
47
+ ================================================================================
48
+ try operator - coexists with the unrelated ternary operator, same `?` token
49
+ ================================================================================
50
+
51
+ fun classify(n: Int) -> Int =
52
+ picked = n > 0 ? n : 0
53
+ picked
54
+
55
+ --------------------------------------------------------------------------------
56
+
57
+ (source
58
+ (fn
59
+ (fn_identifier)
60
+ (param
61
+ (var_identifier)
62
+ (type
63
+ (type_identifier)))
64
+ (type
65
+ (type_identifier))
66
+ (body
67
+ (assign
68
+ (var_identifier)
69
+ (expression
70
+ (ternary_expression
71
+ (expression
72
+ (comparison_operator
73
+ (primary_expression
74
+ (var_identifier))
75
+ (primary_expression
76
+ (integer))))
77
+ (expression
78
+ (primary_expression
79
+ (var_identifier)))
80
+ (expression
81
+ (primary_expression
82
+ (integer))))))
83
+ (expression
84
+ (primary_expression
85
+ (var_identifier))))))
plum-wasm-codegen/src/lib.rs CHANGED
@@ -511,6 +511,12 @@ fn withGcTypes<R>(f: impl FnOnce(&GcTypeRegistry) -> R) -> R {
511
511
  fn classOrVariantTypeIdx(r: &GcTypeRegistry, name: &str) -> u32 {
512
512
  *r.variant_type_idx.get(name)
513
513
  .or_else(|| r.class_type_idx.get(name))
514
+ // A scratch slot narrowed to a MULTI-variant enum's own wide supertype
515
+ // (not any one variant) — the `?` operator's scratch local holds the
516
+ // subject at its natural (possibly multi-variant, e.g. `Result`/`Option`)
517
+ // type, never narrower, since it ref.casts to whichever of the two
518
+ // variants applies only at the point of use, not up front.
519
+ .or_else(|| r.enum_super_type_idx.get(name))
514
520
  .unwrap_or_else(|| panic!("internal codegen error: '{}' missing from the GC type registry", name))
515
521
  }
516
522
 
@@ -2145,6 +2151,7 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
2145
2151
  ast::Expr::Bool(b) => { self.walkExpr(&b.left, None); self.walkExpr(&b.right, None); }
2146
2152
  ast::Expr::Compare(c) => { self.walkExpr(&c.left, None); self.walkExpr(&c.right, None); }
2147
2153
  ast::Expr::Not(inner) => self.walkExpr(inner, None),
2154
+ ast::Expr::Try(inner) => self.walkExpr(inner, None),
2148
2155
  ast::Expr::Unary(u) => self.walkExpr(&u.operand, None),
2149
2156
  ast::Expr::Paren(inner) => self.walkExpr(inner, None),
2150
2157
  ast::Expr::Ternary(t) => {
@@ -2657,6 +2664,7 @@ fn fvCollectRefsExpr(
2657
2664
  ast::Expr::Bool(b) => { fvCollectRefsExpr(&b.left, bound, seen, free, env, fn_decls); fvCollectRefsExpr(&b.right, bound, seen, free, env, fn_decls); }
2658
2665
  ast::Expr::Compare(c) => { fvCollectRefsExpr(&c.left, bound, seen, free, env, fn_decls); fvCollectRefsExpr(&c.right, bound, seen, free, env, fn_decls); }
2659
2666
  ast::Expr::Not(inner) => fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls),
2667
+ ast::Expr::Try(inner) => fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls),
2660
2668
  ast::Expr::Unary(u) => fvCollectRefsExpr(&u.operand, bound, seen, free, env, fn_decls),
2661
2669
  ast::Expr::Paren(inner) => fvCollectRefsExpr(inner, bound, seen, free, env, fn_decls),
2662
2670
  ast::Expr::Ternary(t) => {
@@ -2892,6 +2900,29 @@ impl<'a> Collector<'a> {
2892
2900
  self.walkExpr(&c.right);
2893
2901
  }
2894
2902
  ast::Expr::Not(inner) => self.walkExpr(inner),
2903
+ // `expr?` needs a scratch local to hold `inner`'s value across the
2904
+ // `ref.test` that decides Ok/Some vs Err/None (which consumes it)
2905
+ // and the `ref.cast` that reads it again inside whichever branch —
2906
+ // reuses the same pointer-identity-keyed scratch-slot mechanism
2907
+ // constructor patterns already use (`nested_class_scratch`), keyed
2908
+ // by this `Expr::Try` node itself. Declared with `inner`'s own
2909
+ // (possibly multi-variant, e.g. `Result`/`Option`) enum type —
2910
+ // `classOrVariantTypeIdx` falls back to `enum_super_type_idx` for
2911
+ // exactly this case — since narrowing happens later via `ref.cast`
2912
+ // in each branch, not by declaring this local pre-narrowed.
2913
+ ast::Expr::Try(inner) => {
2914
+ let key = expr as *const ast::Expr as usize;
2915
+ let ty = plum_checker::inferExpr(inner, &self.env, &self.cctx).unwrap_or(PlumType::TVar("_".to_string()));
2916
+ let name = match &ty {
2917
+ PlumType::TNamed(n) => n.clone(),
2918
+ _ => String::new(),
2919
+ };
2920
+ let slot = self.next_nested_class_slot;
2921
+ self.next_nested_class_slot += 1;
2922
+ self.nested_class_scratch.insert(key, slot);
2923
+ self.nested_class_scratch_types.push(name);
2924
+ self.walkExpr(inner);
2925
+ }
2895
2926
  ast::Expr::Unary(u) => self.walkExpr(&u.operand),
2896
2927
  ast::Expr::Paren(inner) => self.walkExpr(inner),
2897
2928
  ast::Expr::Ternary(t) => {
@@ -4039,6 +4070,50 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4039
4070
  ast::Expr::Paren(inner) => {
4040
4071
  compileExpr(inner, body, ctx, state)?;
4041
4072
  }
4073
+ // `expr?` — `ref.test` decides Ok/Some (fall through, extract the
4074
+ // field) vs Err/None (return the already-constructed value AS-IS,
4075
+ // no reconstruction needed). The `then`/`else` branches don't need to
4076
+ // agree in wasm's usual sense: after `return`, the rest of that
4077
+ // branch is unreachable, so the validator accepts the `if`'s single
4078
+ // declared result type (the success field's) coming only from the
4079
+ // `else` branch — the same stack-polymorphism-after-`return` rule
4080
+ // `compileStmt`'s own `Return` arm relies on, just reached from
4081
+ // inside an expression here for the first time.
4082
+ ast::Expr::Try(inner) => {
4083
+ let inner_ty = inferLocalType(inner, ctx);
4084
+ let enum_name = match &inner_ty {
4085
+ PlumType::TNamed(n) => n.clone(),
4086
+ other => return Err(format!("codegen: '?' requires a Result or Option value, found {}", other)),
4087
+ };
4088
+ let cctx = checkCtxOf(ctx.methods, ctx.enum_variants, ctx.enum_params, ctx.min_required);
4089
+ let (success_name, success_info, failure_name, _) = plum_checker::tryOperatorVariants(&enum_name, &cctx)
4090
+ .ok_or_else(|| format!("codegen: '?' requires a Result or Option value, found '{}'", enum_name))?;
4091
+ let success_idx = *ctx.gc_types.variant_type_idx.get(success_name)
4092
+ .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", success_name))?;
4093
+ let failure_idx = *ctx.gc_types.variant_type_idx.get(failure_name)
4094
+ .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", failure_name))?;
4095
+ let success_vt = plumTypeToValtype(&success_info.field_types.first().cloned().unwrap_or(PlumType::TUnit));
4096
+
4097
+ let key = expr as *const ast::Expr as usize;
4098
+ let slot = *ctx.nested_class_scratch.get(&key)
4099
+ .ok_or_else(|| "internal codegen error: missing '?' scratch slot".to_string())?;
4100
+ let scratch_local = ctx.nested_class_scratch_base + slot;
4101
+
4102
+ compileExpr(inner, body, ctx, state)?;
4103
+ Instruction::LocalTee(scratch_local).encode(body);
4104
+ Instruction::RefTestNonNull(HeapType::Concrete(failure_idx)).encode(body);
4105
+ Instruction::If(BlockType::Result(success_vt)).encode(body);
4106
+ Instruction::LocalGet(scratch_local).encode(body);
4107
+ Instruction::RefCastNonNull(HeapType::Concrete(failure_idx)).encode(body);
4108
+ Instruction::Return.encode(body);
4109
+ Instruction::Else.encode(body);
4110
+ Instruction::LocalGet(scratch_local).encode(body);
4111
+ Instruction::RefCastNonNull(HeapType::Concrete(success_idx)).encode(body);
4112
+ if !success_info.field_types.is_empty() {
4113
+ Instruction::StructGet { struct_type_index: success_idx, field_index: 0 }.encode(body);
4114
+ }
4115
+ Instruction::End.encode(body);
4116
+ }
4042
4117
  ast::Expr::Unary(u) => match u.op {
4043
4118
  ast::UnOp::Neg => {
4044
4119
  if matches!(inferLocalType(&u.operand, ctx), PlumType::TFloat) {