plum

#treesitter#compiler#wasm

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

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


745232b — Peter John 2026-10-27T00:41:32+05:30
fix(errors): fail Missing is the absent value in a T? slot; nested error unions as success values
README.md CHANGED
@@ -635,6 +635,8 @@ enum Failure =
635
635
 
636
636
  A missing value is `T?`, shorthand for `!T [Missing]`; absence is written `fail Missing`, and a plain `T` in a `T?` slot is the present value. For a reference type (a class, enum, `Str`, `List`, ...) a `T?` is stored as a nullable reference, so a present value costs no allocation and no code word; for `Int`, `Float`, `Bool` and value enums it is an ordinary error union. `T?` uses the same `?`, `!` and `catch` as every other error union. A doubled `?` suffix is not allowed.
637
637
 
638
+ `fail Missing` is also a plain value wherever a `T?` is expected (a call or constructor argument, a `return`, a tail, a parameter default such as `n: Int? = fail Missing`): there it is the absent value, not an early return from the enclosing function, so `Cfg(port: fail Missing)` and a `List[Int?]` built by a function returning that type just work. Where no `T?` is expected (for example a bare statement, or a list literal whose element type is not yet known) `fail Missing` is the usual early return. An error union can be the success type of another (`List[!Int].get(i)` is a `!!Int`): unwrap it one level at a time with `!`, `?` or `catch`.
639
+
638
640
  ### Safe navigation: `?.`
639
641
 
640
642
  `obj?.field` / `obj?.method(args)` (safe navigation, Groovy/Kotlin-style) reaches into an error union (`!T` or `T?`) without unwrapping it -- a success maps the field or method access over the inner value and re-wraps it with the same set, an error passes through untouched. A field that is itself a `T?` flattens, so a chain is one `?` value, not a nested one:
plum-checker/src/lib.rs CHANGED
@@ -497,13 +497,19 @@ fn recordBind(name: &str, ty: &PlumType) {
497
497
  pub const ERR_WRAP: usize = 4;
498
498
  pub const ERR_FROM_NULLABLE: usize = 5;
499
499
  pub const ERR_TO_NULLABLE: usize = 6;
500
+ /// `+7` marks a `fail Missing` written where a `T?` is expected (a call or
501
+ /// constructor argument, a tail, a `return`): it is the ABSENT VALUE of that `T?`,
502
+ /// not an early return from the enclosing fn.
503
+ pub const ERR_ABSENT: usize = 7;
500
504
 
501
505
  fn recordErrCoercion(expr: &ast::Expr, actual: &PlumType, expected: Option<&PlumType>, ctx: &CheckCtx) {
502
506
  let Some(expected) = expected else { return };
503
507
  let kind = match (expected, actual) {
504
508
  (_, PlumType::TVar(_) | PlumType::TParam(_)) => return,
505
509
  (PlumType::TErr(inner, _), a) => {
510
+ if matches!(**inner, PlumType::TErr(..)) && matches!(a, PlumType::TErr(..)) && unify(inner, a, ctx).is_ok() {
511
+ Some(ERR_WRAP)
506
- if let PlumType::TErr(..) = a {
512
+ } else if let PlumType::TErr(..) = a {
507
513
  // Both sides are error unions; only a layout change needs code.
508
514
  match (nullableErrPayload(a).is_some(), nullableErrPayload(expected).is_some()) {
509
515
  (true, false) => Some(ERR_FROM_NULLABLE),
@@ -651,6 +657,10 @@ fn addToErrSet(set: &ErrSet, names: &BTreeSet<String>) -> Result<(), String> {
651
657
  /// fits a `!T` slot; the reverse needs `?`, `!` or `catch`.
652
658
  fn unifyErr(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String> {
653
659
  match (errView(t1), errView(t2)) {
660
+ // The success type is itself an error union (`Map[K, !V].get`): an
661
+ // actual error union that fits it is the success VALUE, never a
662
+ // propagation of its errors into the outer set.
663
+ (Some((et @ PlumType::TErr(..), _)), Some(_)) if unify(&et, t2, ctx).is_ok() => Ok(()),
654
664
  (Some((et, es)), Some((at, aset))) => {
655
665
  unify(&et, &at, ctx)
656
666
  .map_err(|e| format!("type mismatch: expected {}, found {} ({})", t1, t2, e))?;
@@ -3437,6 +3447,27 @@ fn inferExprInner(
3437
3447
  Ok(success)
3438
3448
  }
3439
3449
  ast::Expr::Fail(f) => {
3450
+ // `fail Missing` where a `T?` is expected is the absent value itself.
3451
+ let ptr = expr as *const ast::Expr as usize + ERR_ABSENT;
3452
+ let absent = match expected {
3453
+ Some(t @ PlumType::TErr(_, ErrSet::Pinned(names)))
3454
+ if f.tag == "Missing" && names.len() == 1 && names.contains("Missing") =>
3455
+ {
3456
+ Some(t.clone())
3457
+ }
3458
+ _ => None,
3459
+ };
3460
+ RECORD.with(|r| {
3461
+ if let Some(m) = r.borrow_mut().as_mut() {
3462
+ m.remove(&ptr);
3463
+ if let Some(t) = &absent {
3464
+ m.insert(ptr, t.clone());
3465
+ }
3466
+ }
3467
+ });
3468
+ if let Some(t) = absent {
3469
+ return Ok(t);
3470
+ }
3440
3471
  let Some(outer) = env.get(RETURN_KEY).map(|s| (*s.body).clone()) else {
3441
3472
  return Err(
3442
3473
  "`fail` is only allowed in a function that returns an error union (-> !T)"
plum-checker/tests/error_union_tests.rs CHANGED
@@ -376,3 +376,25 @@ fun f() -> !Int =
376
376
  assert!(kinds.contains(&k), "missing {:?} in {:?}", k, kinds);
377
377
  }
378
378
  }
379
+
380
+ #[test]
381
+ fn fail_missing_is_an_absent_value_where_a_nullable_is_expected() {
382
+ // Constructor argument of type `Int?`: not an early return, and legal in a
383
+ // fn that returns a plain value.
384
+ let src = "\
385
+ enum Cfg =
386
+ | Cfg(port: Int?)
387
+
388
+ fun mk() -> Cfg =
389
+ Cfg(port: fail Missing)
390
+ ";
391
+ assert_eq!(check(src), Ok(()));
392
+ // Where no `T?` is expected it is still the ordinary early return, so a fn
393
+ // that cannot fail rejects it.
394
+ let bad = "\
395
+ fun f() -> Int =
396
+ x := fail Missing
397
+ 1
398
+ ";
399
+ assert!(check(bad).unwrap_err().contains("declare `-> !T`"));
400
+ }
plum-examples/error_propagation.plum CHANGED
@@ -176,3 +176,14 @@ test "match falls back to a wildcard arm"
176
176
  assert ageOrDefault("30") == 30
177
177
  assert ageOrDefault("999") == 150
178
178
  assert ageOrDefault("x") == 0
179
+
180
+ # An error union can be the success type of another (a `List[!Int]` holds
181
+ # per-element results): `xs.get(i)` is `!!Int`, unwrapped one level at a time.
182
+ test "a list of error unions nests one level per access"
183
+ results := List(parsePositive("1"), parsePositive("-1"), parsePositive("x"))
184
+ first := results.get(0)!
185
+ assert first! == 1
186
+ second := results.get(1)!
187
+ assert {second catch 0} == 0
188
+ fallback := {results.get(9) catch parsePositive("5")}
189
+ assert {fallback catch 0} == 5
plum-examples/safe_navigation.plum CHANGED
@@ -1,11 +1,16 @@
1
1
  import std/Number
2
2
  import std/Str
3
+ import std/List
3
4
  # `T?` is shorthand for `!T [Missing]`: a `T` or nothing. Absence is written
4
5
  # `fail Missing`; a plain `T` where a `T?` is expected is the present value.
5
6
  # For a reference type (class, enum, Str, List, ...) a `T?` is stored as a
6
7
  # nullable reference, so a present value costs no allocation. It is handled with
7
8
  # the same `?`, `!` and `catch` as every other error union.
8
9
  #
10
+ # `fail Missing` also works as a VALUE wherever a `T?` is expected (a call or
11
+ # constructor argument, a list element, a default): it is the absent value, not
12
+ # an early return from the enclosing function.
13
+ #
9
14
  # `obj?.field` / `obj?.method(args)` -- Groovy/Kotlin-style safe navigation.
10
15
  # Reaches into an error union's success value without unwrapping it and
11
16
  # re-wraps the result; an error passes through untouched. A field that is
@@ -40,6 +45,12 @@ fun noPerson() -> Person? =
40
45
  fun noTarget() -> Target? =
41
46
  fail Missing
42
47
 
48
+ fun maybeCount(n: Int? = fail Missing) -> Int? =
49
+ n
50
+
51
+ fun sparse() -> List[Int?] =
52
+ List(1, fail Missing, 3)
53
+
43
54
  fun noUser() -> User? =
44
55
  fail Missing
45
56
 
@@ -72,3 +83,15 @@ test "catch e handles a nullable"
72
83
  match e
73
84
  Missing => Person(name: "gone", age: 1)
74
85
  assert q.name == "gone"
86
+
87
+ test "fail Missing is the absent value in an argument, a list element and a default"
88
+ u := User(name: "w", target: fail Missing)
89
+ assert {targetName(u) catch "none"} == "none"
90
+ xs := sparse()
91
+ assert xs.length() == 3
92
+ second := xs.get(1)!
93
+ assert {second catch 9} == 9
94
+ first := xs.get(0)!
95
+ assert first! == 1
96
+ assert {maybeCount() catch 5} == 5
97
+ assert maybeCount(7)! == 7
plum-wasm-codegen/src/lib.rs CHANGED
@@ -6075,6 +6075,19 @@ fn resultBlock(success: &PlumType) -> BlockType {
6075
6075
  }
6076
6076
  }
6077
6077
 
6078
+ /// The absent value of a `T?`: a null reference, or `(zeros, code)`.
6079
+ fn emitAbsent(ty: &PlumType, tag: &str, body: &mut Vec<u8>) -> Result<(), String> {
6080
+ if nullableErr(ty).is_some() {
6081
+ Instruction::RefNull(nullableHeapType(ty)).encode(body);
6082
+ } else if let PlumType::TErr(t, _) = ty {
6083
+ if !matches!(**t, PlumType::TUnit) {
6084
+ emitZeros(t, body);
6085
+ }
6086
+ Instruction::I32Const(errCode(tag)?).encode(body);
6087
+ }
6088
+ Ok(())
6089
+ }
6090
+
6078
6091
  /// `return (zeros, code)` for an enclosing `!T` fn, or null for a nullable `T?` fn.
6079
6092
  fn emitErrReturn(
6080
6093
  push_code: &dyn Fn(&mut Vec<u8>) -> Result<(), String>,
@@ -6299,6 +6312,11 @@ fn compileExprCore(
6299
6312
  ast::Expr::Yield(_) => return Err("internal codegen error: yield survived expansion".to_string()),
6300
6313
  ast::Expr::Bang(b) => compileErrBang(expr, b, body, ctx, state)?,
6301
6314
  ast::Expr::Catch(c) => compileErrCatch(expr, c, body, ctx, state)?,
6315
+ ast::Expr::Fail(f) if ctx.types.contains_key(&(expr as *const ast::Expr as usize + plum_checker::ERR_ABSENT)) => {
6316
+ // `fail Missing` in a `T?` slot: the absent value, no early return.
6317
+ let ty = &ctx.types[&(expr as *const ast::Expr as usize + plum_checker::ERR_ABSENT)];
6318
+ emitAbsent(ty, &f.tag, body)?;
6319
+ }
6302
6320
  ast::Expr::Fail(f) => {
6303
6321
  let code = errCode(&f.tag)?;
6304
6322
  emitErrReturn(&|b| { Instruction::I32Const(code).encode(b); Ok(()) }, body, ctx)?
@@ -7245,6 +7263,13 @@ fn compileDefaultArgs(
7245
7263
  )
7246
7264
  })?;
7247
7265
  match sig.decl_params.get(i) {
7266
+ // A `T?` param defaulting to `fail Missing`: the absent value (the
7267
+ // default is a clone, so the checker recorded nothing for it).
7268
+ Some(d) if matches!(default_expr, ast::Expr::Fail(f) if f.tag == "Missing")
7269
+ && matches!(d, PlumType::TErr(_, plum_checker::types::ErrSet::Pinned(n)) if n.len() == 1 && n.contains("Missing")) =>
7270
+ {
7271
+ emitAbsent(d, "Missing", body)?
7272
+ }
7248
7273
  Some(d) => compileExprInto(default_expr, d, body, ctx, state)?,
7249
7274
  None => compileExpr(default_expr, body, ctx, state)?,
7250
7275
  }