plum
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 +2 -0
- plum-checker/src/lib.rs +32 -1
- plum-checker/tests/error_union_tests.rs +22 -0
- plum-examples/error_propagation.plum +11 -0
- plum-examples/safe_navigation.plum +23 -0
- plum-wasm-codegen/src/lib.rs +25 -0
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
|
}
|