plum

#treesitter#compiler#wasm

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

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


ec5336fPeter John 2026-09-08T13:13:12+05:30
feat(lang): add elvis `?:` operator
README.md CHANGED
@@ -323,6 +323,15 @@ fun sumTwo(a: Str, b: Str) -> Result[Int, Str] =
323
323
  return Ok(x + y)
324
324
  ```
325
325
 
326
+ `left ?: right` (elvis) is the plain-expression counterpart — no early return, just a fallback value if `left` is `Err`/`None`:
327
+
328
+ ```plum
329
+ fun sumOrZero(a: Str, b: Str) -> Int =
330
+ x := parsePositive(a) ?: 0
331
+ y := parsePositive(b) ?: 0
332
+ x + y
333
+ ```
334
+
326
335
  ## Standard library highlights
327
336
 
328
337
  - **`Bool`** (`import std/Bool`) — ordinary `enum Bool = | True | False`
plum-checker/src/lib.rs CHANGED
@@ -1038,6 +1038,7 @@ fn resolveClosureParamFromFieldUsage(
1038
1038
  ast::Expr::Not(inner) | ast::Expr::Paren(inner) => scanExpr(inner, params, ctx, resolved),
1039
1039
  ast::Expr::Unary(u) => scanExpr(&u.operand, params, ctx, resolved),
1040
1040
  ast::Expr::Ternary(t) => { scanExpr(&t.condition, params, ctx, resolved); scanExpr(&t.then, params, ctx, resolved); scanExpr(&t.else_, params, ctx, resolved); }
1041
+ ast::Expr::Elvis(e) => { scanExpr(&e.left, params, ctx, resolved); scanExpr(&e.right, params, ctx, resolved); }
1041
1042
  _ => {}
1042
1043
  }
1043
1044
  }
@@ -1259,6 +1260,22 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1259
1260
  unify(&tt, &et, ctx).map_err(|e| format!("ternary branches: {}", e))?;
1260
1261
  Ok(tt)
1261
1262
  }
1263
+ // `left ?: right` — unlike `?`, a plain expression (no early return),
1264
+ // so (unlike `Try` above) it's fully validated here: `right` must
1265
+ // unify with `left`'s success (`Ok`/`Some`) field type.
1266
+ ast::Expr::Elvis(e) => {
1267
+ let left_ty = inferExpr(&e.left, env, ctx)?;
1268
+ let success_ty = match &left_ty {
1269
+ PlumType::TNamed(name) => match tryOperatorVariants(name, ctx) {
1270
+ Some((_, success_info, _, _)) => success_info.field_types.first().cloned().unwrap_or(PlumType::TUnit),
1271
+ None => return Err(format!("'?:' requires a Result or Option value, found '{}'", name)),
1272
+ },
1273
+ other => return Err(format!("'?:' requires a Result or Option value, found {}", other)),
1274
+ };
1275
+ let right_ty = inferExpr(&e.right, env, ctx)?;
1276
+ unify(&success_ty, &right_ty, ctx).map_err(|e| format!("'?:' branches: {}", e))?;
1277
+ Ok(success_ty)
1278
+ }
1262
1279
  ast::Expr::FnCall(call) => {
1263
1280
  // `Int(x)`/`Float(x)`/`Byte(x)` are builtin numeric conversions, not
1264
1281
  // ordinary calls — handled here so `y = Float(x)` unifies against
plum-checker/src/monomorphize.rs CHANGED
@@ -295,6 +295,10 @@ fn substituteTypesInExpr(expr: &mut ast::Expr, subst: &Substitution) {
295
295
  }
296
296
  ast::Expr::Not(inner) => substituteTypesInExpr(inner, subst),
297
297
  ast::Expr::Try(inner) => substituteTypesInExpr(inner, subst),
298
+ ast::Expr::Elvis(e) => {
299
+ substituteTypesInExpr(&mut e.left, subst);
300
+ substituteTypesInExpr(&mut e.right, subst);
301
+ }
298
302
  ast::Expr::Compare(c) => {
299
303
  substituteTypesInExpr(&mut c.left, subst);
300
304
  substituteTypesInExpr(&mut c.right, subst);
@@ -488,6 +492,10 @@ fn renameVarInExpr(expr: &mut ast::Expr, old: &str, new: &str) {
488
492
  ast::Expr::Compare(c) => { renameVarInExpr(&mut c.left, old, new); renameVarInExpr(&mut c.right, old, new); }
489
493
  ast::Expr::Not(inner) => renameVarInExpr(inner, old, new),
490
494
  ast::Expr::Try(inner) => renameVarInExpr(inner, old, new),
495
+ ast::Expr::Elvis(e) => {
496
+ renameVarInExpr(&mut e.left, old, new);
497
+ renameVarInExpr(&mut e.right, old, new);
498
+ }
491
499
  ast::Expr::Unary(u) => renameVarInExpr(&mut u.operand, old, new),
492
500
  ast::Expr::Paren(inner) => renameVarInExpr(inner, old, new),
493
501
  ast::Expr::Ternary(t) => {
@@ -2221,6 +2229,10 @@ impl<'a> Monomorphizer<'a> {
2221
2229
  self.rewriteExpr(&mut t.then, env)?;
2222
2230
  self.rewriteExpr(&mut t.else_, env)?;
2223
2231
  }
2232
+ ast::Expr::Elvis(e) => {
2233
+ self.rewriteExpr(&mut e.left, env)?;
2234
+ self.rewriteExpr(&mut e.right, env)?;
2235
+ }
2224
2236
  // String interpolation can embed arbitrary expressions (including generic
2225
2237
  // call sites), so recurse into its interpolated parts.
2226
2238
  ast::Expr::String(s) => {
plum-core/src/ast.rs CHANGED
@@ -338,6 +338,10 @@ pub enum Expr {
338
338
  /// `expr?` — unwraps a `Result`'s `Ok`/an `Option`'s `Some`, or returns the
339
339
  /// `Err`/`None` value from the enclosing function as-is otherwise.
340
340
  Try(Box<Expr>),
341
+ /// `left ?: right` — if `left` is `Ok(v)`/`Some(v)`, the value is `v`; if
342
+ /// it's `Err(_)`/`None`, the value is `right`. Unlike `Try`, a plain
343
+ /// expression with no early return.
344
+ Elvis(Box<ElvisExpr>),
341
345
  /// `{expr}` — grouped/parenthesized expression
342
346
  Paren(Box<Expr>),
343
347
  String(StringExpr),
@@ -421,6 +425,12 @@ pub struct TernaryExpr {
421
425
  pub else_: Expr,
422
426
  }
423
427
 
428
+ #[derive(Debug, Clone, PartialEq)]
429
+ pub struct ElvisExpr {
430
+ pub left: Expr,
431
+ pub right: Expr,
432
+ }
433
+
424
434
  #[derive(Debug, Clone, PartialEq)]
425
435
  pub struct FnCall {
426
436
  pub name: String,
plum-core/src/builtin_usage.rs CHANGED
@@ -198,6 +198,10 @@ fn walkExpr(expr: &Expr, names: &mut HashSet<String>) {
198
198
  walkExpr(&t.then, names);
199
199
  walkExpr(&t.else_, names);
200
200
  }
201
+ Expr::Elvis(e) => {
202
+ walkExpr(&e.left, names);
203
+ walkExpr(&e.right, names);
204
+ }
201
205
  Expr::FnCall(call) => {
202
206
  if isTracked(&call.name) {
203
207
  names.insert(call.name.clone());
plum-core/src/parser.rs CHANGED
@@ -637,6 +637,7 @@ impl<'a> AstParser<'a> {
637
637
  }
638
638
  "boolean_operator" => self.parseBoolOp(node),
639
639
  "ternary_expression" => self.parseTernary(node),
640
+ "elvis_expression" => self.parseElvis(node),
640
641
  "closure" => Expr::Closure(Box::new(self.parseClosure(node))),
641
642
  _ => self.parsePrimaryExpression(node),
642
643
  }
@@ -781,6 +782,18 @@ impl<'a> AstParser<'a> {
781
782
  Expr::Ternary(Box::new(TernaryExpr { condition, then, else_ }))
782
783
  }
783
784
 
785
+ fn parseElvis(&self, node: Node) -> Expr {
786
+ // elvis_expression: expression "?:" expression
787
+ let named: Vec<Node> = self.namedChildren(node);
788
+ let left = named.first()
789
+ .map(|n| { let u = self.unwrapExprNode(*n); self.parseExpression(u) })
790
+ .unwrap_or(Expr::Int(0));
791
+ let right = named.get(1)
792
+ .map(|n| { let u = self.unwrapExprNode(*n); self.parseExpression(u) })
793
+ .unwrap_or(Expr::Int(0));
794
+ Expr::Elvis(Box::new(ElvisExpr { left, right }))
795
+ }
796
+
784
797
  fn parseAttribute(&self, node: Node) -> Expr {
785
798
  // attribute: primary_expression "." fn_identifier fn_argument_list?
786
799
  // The member name is always fn_identifier (a superset of var_identifier); an
@@ -1060,6 +1073,7 @@ fn isExpressionKind(kind: &str) -> bool {
1060
1073
  | "not_operator"
1061
1074
  | "comparison_operator"
1062
1075
  | "ternary_expression"
1076
+ | "elvis_expression"
1063
1077
  | "attribute"
1064
1078
  | "try_expression"
1065
1079
  | "fn_call"
plum-examples/{try_operator.plum → error_propagation.plum} RENAMED
@@ -11,6 +11,11 @@ import std/List
11
11
  # whatever gets returned early; that isn't checked until codegen (see
12
12
  # `plum-checker`'s `inferExpr` on `Expr::Try`), so a mismatch there still
13
13
  # surfaces as a clear compile error, just a less precise one.
14
+ #
15
+ # `left ?: right` (elvis) is the plain-expression counterpart: same Ok/Some
16
+ # vs Err/None narrowing, but no early return — `right` is a fallback value,
17
+ # fully checked (unlike `?`) since there's no enclosing-return-type
18
+ # uncertainty to be permissive about.
14
19
 
15
20
  fun parsePositive(s: Str) -> Result[Int, Str] =
16
21
  n := parseInt(s)?
@@ -36,6 +41,18 @@ fun doubledFirstPositive(list: List[Int]) -> Option[Int] =
36
41
  a := firstPositive(list)?
37
42
  return Some(a * 2)
38
43
 
44
+ # `left ?: right` — same Ok/Some-vs-Err/None narrowing as `?`, but a plain
45
+ # expression (no early return): `right` is the fallback value, unified against
46
+ # the success field's type by the checker.
47
+
48
+ fun sumOrZero(a: Str, b: Str) -> Int =
49
+ x := parsePositive(a) ?: 0
50
+ y := parsePositive(b) ?: 0
51
+ x + y
52
+
53
+ fun firstOrDefault(list: List[Int], default: Int) -> Int =
54
+ list.get(0) ?: default
55
+
39
56
  test "try operator unwraps Ok and propagates the value through two calls"
40
57
  r := sumTwo("2", "3")
41
58
  assert r.isOk()
@@ -56,3 +73,11 @@ test "try operator exits early with None, skipping the rest of the function"
56
73
  l := List[Int]()
57
74
  r := doubledFirstPositive(l)
58
75
  assert r.isNone()
76
+
77
+ test "elvis operator unwraps Ok and falls back to 0 on Err"
78
+ assert sumOrZero("2", "3") == 5
79
+ assert sumOrZero("2", "-3") == 2
80
+
81
+ test "elvis operator unwraps Some and falls back to the given default on None"
82
+ assert firstOrDefault(List(5), 99) == 5
83
+ assert firstOrDefault(List[Int](), 99) == 99
plum-tooling/tree-sitter-plum/grammar.js CHANGED
@@ -387,6 +387,7 @@ module.exports = grammar({
387
387
  $.closure,
388
388
  $.primary_expression,
389
389
  $.ternary_expression,
390
+ $.elvis_expression,
390
391
  ),
391
392
 
392
393
  primary_expression: ($) =>
@@ -594,6 +595,18 @@ module.exports = grammar({
594
595
  seq($.expression, "?", $.expression, ":", $.expression),
595
596
  ),
596
597
 
598
+ // Elvis / null-coalescing: `opt ?: default` — if `left` is `Ok(v)`/`Some(v)`,
599
+ // the value is `v`; if it's `Err(_)`/`None`, the value is `right`. A single
600
+ // atomic `"?:"` token (maximal munch out-munches the bare `"?"` ternary/
601
+ // try-operator tokens at the same position, so — unlike `try_expression`,
602
+ // which genuinely shares ternary's leading `?` and needs the `conflicts`
603
+ // entry above — this needs no special conflict handling).
604
+ elvis_expression: ($) =>
605
+ prec.right(
606
+ PREC.conditional,
607
+ seq($.expression, "?:", $.expression),
608
+ ),
609
+
597
610
  // ==========
598
611
  // Literals
599
612
  // ==========
plum-tooling/tree-sitter-plum/queries/plum/format.scm CHANGED
@@ -82,6 +82,8 @@
82
82
  ":"
83
83
  ] @prepend_space @append_space)
84
84
 
85
+ (elvis_expression "?:" @prepend_space @append_space)
86
+
85
87
  ; ============================================================
86
88
  ; Keywords — space after (Topiary inserts NO whitespace at all
87
89
  ; between adjacent leaves unless a query says to; every keyword
plum-tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -64,6 +64,8 @@
64
64
 
65
65
  (try_expression "?" @operator)
66
66
 
67
+ (elvis_expression "?:" @operator)
68
+
67
69
  [
68
70
  "->"
69
71
  "=>"
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/elvis.txt ADDED
@@ -0,0 +1,107 @@
1
+ ================================================================================
2
+ elvis operator - unwraps Ok/Some or falls back to the right-hand value
3
+ ================================================================================
4
+
5
+ fun firstOrDefault(list: List[Int], default: Int) -> Int =
6
+ list.get(0) ?: default
7
+
8
+ --------------------------------------------------------------------------------
9
+
10
+ (source
11
+ (fn
12
+ (fn_identifier)
13
+ (param
14
+ (var_identifier)
15
+ (type
16
+ (type_identifier)
17
+ (type
18
+ (type_identifier))))
19
+ (param
20
+ (var_identifier)
21
+ (type
22
+ (type_identifier)))
23
+ (type
24
+ (type_identifier))
25
+ (body
26
+ (expression
27
+ (elvis_expression
28
+ (expression
29
+ (primary_expression
30
+ (attribute
31
+ (primary_expression
32
+ (var_identifier))
33
+ (fn_identifier)
34
+ (fn_argument_list
35
+ (expression
36
+ (primary_expression
37
+ (integer)))))))
38
+ (expression
39
+ (primary_expression
40
+ (var_identifier))))))))
41
+
42
+ ================================================================================
43
+ elvis operator - coexists with ternary and try, all sharing `?`-prefixed tokens
44
+ ================================================================================
45
+
46
+ fun classify(n: Int) -> Int =
47
+ a = n > 0 ? n : 0
48
+ b = n ?: 0
49
+ c = n?
50
+ a + b + c
51
+
52
+ --------------------------------------------------------------------------------
53
+
54
+ (source
55
+ (fn
56
+ name: (fn_identifier)
57
+ params: (param
58
+ name: (var_identifier)
59
+ type: (type
60
+ (type_identifier)))
61
+ returns: (type
62
+ (type_identifier))
63
+ body: (body
64
+ (assign
65
+ (var_identifier)
66
+ (expression
67
+ (ternary_expression
68
+ (expression
69
+ (comparison_operator
70
+ (primary_expression
71
+ (var_identifier))
72
+ (primary_expression
73
+ (integer))))
74
+ (expression
75
+ (primary_expression
76
+ (var_identifier)))
77
+ (expression
78
+ (primary_expression
79
+ (integer))))))
80
+ (assign
81
+ (var_identifier)
82
+ (expression
83
+ (elvis_expression
84
+ (expression
85
+ (primary_expression
86
+ (var_identifier)))
87
+ (expression
88
+ (primary_expression
89
+ (integer))))))
90
+ (assign
91
+ (var_identifier)
92
+ (expression
93
+ (primary_expression
94
+ (try_expression
95
+ value: (primary_expression
96
+ (var_identifier))))))
97
+ (expression
98
+ (primary_expression
99
+ (binary_operator
100
+ left: (primary_expression
101
+ (binary_operator
102
+ left: (primary_expression
103
+ (var_identifier))
104
+ right: (primary_expression
105
+ (var_identifier))))
106
+ right: (primary_expression
107
+ (var_identifier))))))))
plum-wasm-codegen/src/lib.rs CHANGED
@@ -2159,6 +2159,10 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
2159
2159
  self.walkExpr(&t.then, None);
2160
2160
  self.walkExpr(&t.else_, None);
2161
2161
  }
2162
+ ast::Expr::Elvis(e) => {
2163
+ self.walkExpr(&e.left, None);
2164
+ self.walkExpr(&e.right, None);
2165
+ }
2162
2166
  ast::Expr::FnCall(call) => {
2163
2167
  let callee = self.fn_decls.get(&call.name).copied();
2164
2168
  for (i, arg) in call.args.iter().enumerate() {
@@ -2434,6 +2438,10 @@ fn scanExprForParamTypes(
2434
2438
  scanExprForParamTypes(&t.then, params, env, cctx, resolved);
2435
2439
  scanExprForParamTypes(&t.else_, params, env, cctx, resolved);
2436
2440
  }
2441
+ ast::Expr::Elvis(e) => {
2442
+ scanExprForParamTypes(&e.left, params, env, cctx, resolved);
2443
+ scanExprForParamTypes(&e.right, params, env, cctx, resolved);
2444
+ }
2437
2445
  ast::Expr::FnCall(call) => {
2438
2446
  if let Ok(PlumType::TFun(param_types, _)) = plum_checker::lookup(env, &call.name) {
2439
2447
  for (arg, expected) in call.args.iter().zip(param_types.iter()) {
@@ -2672,6 +2680,10 @@ fn fvCollectRefsExpr(
2672
2680
  fvCollectRefsExpr(&t.then, bound, seen, free, env, fn_decls);
2673
2681
  fvCollectRefsExpr(&t.else_, bound, seen, free, env, fn_decls);
2674
2682
  }
2683
+ ast::Expr::Elvis(e) => {
2684
+ fvCollectRefsExpr(&e.left, bound, seen, free, env, fn_decls);
2685
+ fvCollectRefsExpr(&e.right, bound, seen, free, env, fn_decls);
2686
+ }
2675
2687
  ast::Expr::FnCall(call) => {
2676
2688
  for arg in &call.args {
2677
2689
  fvCollectRefsExpr(argExprOf(arg), bound, seen, free, env, fn_decls);
@@ -2930,6 +2942,24 @@ impl<'a> Collector<'a> {
2930
2942
  self.walkExpr(&t.then);
2931
2943
  self.walkExpr(&t.else_);
2932
2944
  }
2945
+ // Same scratch-slot need as `Try` above — `left` is narrowed via
2946
+ // `ref.test`/`ref.cast` the same way, just with `right` (an
2947
+ // ordinary expression, no narrowing) as the fallback instead of a
2948
+ // `return`.
2949
+ ast::Expr::Elvis(e) => {
2950
+ let key = expr as *const ast::Expr as usize;
2951
+ let ty = plum_checker::inferExpr(&e.left, &self.env, &self.cctx).unwrap_or(PlumType::TVar("_".to_string()));
2952
+ let name = match &ty {
2953
+ PlumType::TNamed(n) => n.clone(),
2954
+ _ => String::new(),
2955
+ };
2956
+ let slot = self.next_nested_class_slot;
2957
+ self.next_nested_class_slot += 1;
2958
+ self.nested_class_scratch.insert(key, slot);
2959
+ self.nested_class_scratch_types.push(name);
2960
+ self.walkExpr(&e.left);
2961
+ self.walkExpr(&e.right);
2962
+ }
2933
2963
  ast::Expr::FnCall(call) => {
2934
2964
  for arg in &call.args {
2935
2965
  self.walkArg(arg);
@@ -4114,6 +4144,44 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4114
4144
  }
4115
4145
  Instruction::End.encode(body);
4116
4146
  }
4147
+ // `left ?: right` — same `ref.test`/scratch-local narrowing as `Try`
4148
+ // above, but both branches produce an ordinary value (no `return`):
4149
+ // the failure branch compiles `right` (already unified against the
4150
+ // success field's type by the checker), the success branch extracts
4151
+ // the field, same as `Try`'s own success branch.
4152
+ ast::Expr::Elvis(e) => {
4153
+ let left_ty = inferLocalType(&e.left, ctx);
4154
+ let enum_name = match &left_ty {
4155
+ PlumType::TNamed(n) => n.clone(),
4156
+ other => return Err(format!("codegen: '?:' requires a Result or Option value, found {}", other)),
4157
+ };
4158
+ let cctx = checkCtxOf(ctx.methods, ctx.enum_variants, ctx.enum_params, ctx.min_required);
4159
+ let (success_name, success_info, failure_name, _) = plum_checker::tryOperatorVariants(&enum_name, &cctx)
4160
+ .ok_or_else(|| format!("codegen: '?:' requires a Result or Option value, found '{}'", enum_name))?;
4161
+ let success_idx = *ctx.gc_types.variant_type_idx.get(success_name)
4162
+ .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", success_name))?;
4163
+ let failure_idx = *ctx.gc_types.variant_type_idx.get(failure_name)
4164
+ .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", failure_name))?;
4165
+ let success_vt = plumTypeToValtype(&success_info.field_types.first().cloned().unwrap_or(PlumType::TUnit));
4166
+
4167
+ let key = expr as *const ast::Expr as usize;
4168
+ let slot = *ctx.nested_class_scratch.get(&key)
4169
+ .ok_or_else(|| "internal codegen error: missing '?:' scratch slot".to_string())?;
4170
+ let scratch_local = ctx.nested_class_scratch_base + slot;
4171
+
4172
+ compileExpr(&e.left, body, ctx, state)?;
4173
+ Instruction::LocalTee(scratch_local).encode(body);
4174
+ Instruction::RefTestNonNull(HeapType::Concrete(failure_idx)).encode(body);
4175
+ Instruction::If(BlockType::Result(success_vt)).encode(body);
4176
+ compileExpr(&e.right, body, ctx, state)?;
4177
+ Instruction::Else.encode(body);
4178
+ Instruction::LocalGet(scratch_local).encode(body);
4179
+ Instruction::RefCastNonNull(HeapType::Concrete(success_idx)).encode(body);
4180
+ if !success_info.field_types.is_empty() {
4181
+ Instruction::StructGet { struct_type_index: success_idx, field_index: 0 }.encode(body);
4182
+ }
4183
+ Instruction::End.encode(body);
4184
+ }
4117
4185
  ast::Expr::Unary(u) => match u.op {
4118
4186
  ast::UnOp::Neg => {
4119
4187
  if matches!(inferLocalType(&u.operand, ctx), PlumType::TFloat) {