plum

#treesitter#compiler#wasm

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

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


b1c3ec8Peter John 2026-09-05T15:21:03+05:30
improve enum
.codegraph/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ # CodeGraph data files — local to each machine, not for committing.
2
+ # Ignore everything in .codegraph/ except this file itself, so transient
3
+ # files (the database, daemon.pid, sockets, logs) never show up in git.
4
+ *
5
+ !.gitignore
examples/types.plum CHANGED
@@ -37,6 +37,11 @@ enum Color =
37
37
  | Green
38
38
  | Blue
39
39
 
40
+ # A bare enum variant name can be used directly as a type: `v: Red` means
41
+ # "a `Color` value that is specifically the `Red` variant".
42
+ fun stringifyColor(v: Red) -> Str =
43
+ "Red"
44
+
40
45
  fun makeIntBox() -> Box =
41
46
  Box(value: 5)
42
47
 
@@ -107,6 +112,13 @@ fun area(s: ShapeKind) -> Int =
107
112
  Circle(r) =>
108
113
  return r * r
109
114
 
115
+ enum Vec2 =
116
+ | Vec2(x: Int, y: Int)
117
+
118
+ enum ShapeWithFields =
119
+ | CircleField(radius: Int)
120
+ | SquareField(side: Int)
121
+
110
122
  type OptionBox =
111
123
  value: Option[Int]
112
124
 
@@ -148,6 +160,32 @@ test "payload variant construction compiles and runs"
148
160
  test "multi field variant construction compiles and runs"
149
161
  assert area(Rect(3, 4)) == 12
150
162
 
163
+ test "single-variant named-payload enum field access works like a class"
164
+ # `Vec2` has exactly one variant, so its fields unambiguously describe
165
+ # every `Vec2` value — `.x`/`.y` resolve directly, no `match` needed,
166
+ # for both named and positional construction.
167
+ named := Vec2(x: 1, y: 2)
168
+ positional := Vec2(3, 4)
169
+ assert named.x == 1
170
+ assert named.y == 2
171
+ assert positional.x == 3
172
+ assert positional.y == 4
173
+
174
+ test "named-payload field access on a multi-variant enum value works via a checked downcast"
175
+ # Unlike `Vec2` above, `ShapeWithFields` has more than one variant — `.field`
176
+ # here isn't statically provable to always succeed the way it is on a
177
+ # single-variant enum. It's still allowed because `radius`/`side` each
178
+ # belong to exactly one variant (no ambiguity) — codegen compiles it as a
179
+ # ref.cast down to that one variant's own struct, which would trap at
180
+ # runtime if the value were ever the OTHER variant instead.
181
+ c := CircleField(radius: 5)
182
+ s := SquareField(side: 9)
183
+ assert c.radius == 5
184
+ assert s.side == 9
185
+
186
+ test "enum variant used directly as a type checks and runs correctly"
187
+ assert stringifyColor(Red) == "Red"
188
+
151
189
  test "enum class field construct and destructure runs correctly"
152
190
  b := OptionBox(value: Some(42))
153
191
  assert b.unwrap(0) == 42
plum-checker/src/lib.rs CHANGED
@@ -48,7 +48,7 @@ pub fn methodReceiverName(ty: &PlumType) -> Option<String> {
48
48
  }
49
49
  }
50
50
 
51
- pub fn unify(t1: &PlumType, t2: &PlumType) -> Result<(), String> {
51
+ pub fn unify(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String> {
52
52
  match (t1, t2) {
53
53
  (PlumType::TVar(_), _) | (_, PlumType::TVar(_)) => Ok(()),
54
54
  (PlumType::TInt, PlumType::TInt) => Ok(()),
@@ -74,16 +74,74 @@ pub fn unify(t1: &PlumType, t2: &PlumType) -> Result<(), String> {
74
74
  (PlumType::TByteSlice, PlumType::TByteSlice) => Ok(()),
75
75
  (PlumType::TUnit, PlumType::TUnit) => Ok(()),
76
76
  (PlumType::TNamed(a), PlumType::TNamed(b)) if a == b => Ok(()),
77
+ // A bare enum variant name (`Red`) can be used directly as a type — e.g.
78
+ // a fn param `v: Red` — meaning "a `Color` value that is specifically
79
+ // the `Red` variant". A variant's OWN constructions/references always
80
+ // infer as the whole enum's type (`TNamed("Color")`, see
81
+ // `bareVariantWrapType`/`inferExpr`'s `TypeName` arm), never the
82
+ // narrower per-variant type, so accept either direction here rather
83
+ // than requiring the (never-produced) narrower type to show up as an
84
+ // "actual" type.
85
+ (PlumType::TNamed(a), PlumType::TNamed(b))
86
+ if ctx.enum_variants.get(a).is_some_and(|info| &info.enum_name == b)
87
+ || ctx.enum_variants.get(b).is_some_and(|info| &info.enum_name == a) =>
88
+ {
89
+ Ok(())
90
+ }
77
91
  (PlumType::TFun(ps1, r1), PlumType::TFun(ps2, r2)) if ps1.len() == ps2.len() => {
78
92
  for (p1, p2) in ps1.iter().zip(ps2.iter()) {
79
- unify(p1, p2)?;
93
+ unify(p1, p2, ctx)?;
80
94
  }
81
- unify(r1, r2)
95
+ unify(r1, r2, ctx)
82
96
  }
83
97
  _ => Err(format!("type mismatch: expected {}, found {}", t1, t2)),
84
98
  }
85
99
  }
86
100
 
101
+ /// True if `expr` (looking through parens) is a direct construction/reference
102
+ /// of exactly the enum variant named `variant_name` — a bare payload-free
103
+ /// reference (`Red`), a positional-payload construction (`Circle(1)`), or a
104
+ /// named-payload construction (`Circle(radius: 1)`). The checker has no flow
105
+ /// typing, so this is necessarily syntactic: it can't tell that a variable
106
+ /// holds a particular variant, only that an expression directly constructs
107
+ /// or names one.
108
+ fn exprIsVariant(expr: &ast::Expr, variant_name: &str) -> bool {
109
+ match expr {
110
+ ast::Expr::Paren(inner) => exprIsVariant(inner, variant_name),
111
+ ast::Expr::TypeName(n) => n == variant_name,
112
+ ast::Expr::FnCall(call) => call.name == variant_name,
113
+ ast::Expr::ClassCall(call) => call.type_name == variant_name,
114
+ _ => false,
115
+ }
116
+ }
117
+
118
+ /// Like `unify`, but for a value-producing position where the actual
119
+ /// EXPRESSION (not just its inferred type) is available — a call argument, a
120
+ /// return value, an assignment's RHS. `unify` alone treats a specific variant
121
+ /// type (`v: Red`) as compatible with any value of its enum in EITHER
122
+ /// direction (see its `TNamed`/`TNamed` variant-vs-enum arm), because a bare
123
+ /// variant construction always infers as the whole enum's type, never the
124
+ /// narrower per-variant one (`bareVariantWrapType`/`inferExpr`'s `TypeName`
125
+ /// arm) — so by itself it can't reject `stringifyColor(Blue)` against a
126
+ /// declared `stringifyColor(v: Red)`. This adds that rejection back by
127
+ /// checking the actual expression directly names/constructs the expected
128
+ /// variant whenever `unify` only accepted it via that widening rule.
129
+ fn unifyArg(expected: &PlumType, actual: &PlumType, actual_expr: &ast::Expr, ctx: &CheckCtx) -> Result<(), String> {
130
+ unify(expected, actual, ctx)?;
131
+ if let (PlumType::TNamed(variant_name), PlumType::TNamed(actual_name)) = (expected, actual) {
132
+ if variant_name != actual_name
133
+ && ctx.enum_variants.contains_key(variant_name)
134
+ && !exprIsVariant(actual_expr, variant_name)
135
+ {
136
+ return Err(format!(
137
+ "type mismatch: expected variant '{}', found a different value of its enum",
138
+ variant_name
139
+ ));
140
+ }
141
+ }
142
+ Ok(())
143
+ }
144
+
87
145
  pub fn lookup(env: &TypeEnv, name: &str) -> Result<PlumType, String> {
88
146
  env.get(name)
89
147
  .map(|s| *s.body.clone())
@@ -420,7 +478,7 @@ pub fn checkSource(source: &ast::Source) -> CheckResult<()> {
420
478
  match inferExpr(value, &global_env, &ctx) {
421
479
  Ok(actual) => {
422
480
  let expected = plumTypeFromAst(&param.ty);
423
- if let Err(msg) = unify(&expected, &actual) {
481
+ if let Err(msg) = unify(&expected, &actual, &ctx) {
424
482
  errors.push(CheckError {
425
483
  message: format!("enum '{}' variant '{}': param '{}': {}", e.name, v.name, param.name, msg),
426
484
  });
@@ -520,7 +578,7 @@ fn checkFn(f: &ast::Fn, global_env: &TypeEnv, ctx: &CheckCtx) -> Vec<CheckError>
520
578
  ast::FnBody::Expr(e) => {
521
579
  match inferExpr(e, &env, ctx) {
522
580
  Ok(t) => {
523
- if let Err(msg) = unify(&declared_ret, &t) {
581
+ if let Err(msg) = unifyArg(&declared_ret, &t, e, ctx) {
524
582
  errors.push(CheckError { message: format!("fn '{}': return type mismatch: {}", f.name, msg) });
525
583
  }
526
584
  }
@@ -534,7 +592,7 @@ fn checkFn(f: &ast::Fn, global_env: &TypeEnv, ctx: &CheckCtx) -> Vec<CheckError>
534
592
  if let Some(ast::Stmt::Expr(last_expr)) = block.stmts.last() {
535
593
  match inferExpr(last_expr, &env, ctx) {
536
594
  Ok(t) => {
537
- if let Err(msg) = unify(&declared_ret, &t) {
595
+ if let Err(msg) = unifyArg(&declared_ret, &t, last_expr, ctx) {
538
596
  errors.push(CheckError { message: format!("fn '{}': return type mismatch: {}", f.name, msg) });
539
597
  }
540
598
  }
@@ -593,7 +651,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
593
651
  match inferExpr(value, env, ctx) {
594
652
  Ok(t) => {
595
653
  let existing = lookup(env, name).expect("already_declared just confirmed this succeeds");
596
- if let Err(msg) = unify(&existing, &t) {
654
+ if let Err(msg) = unifyArg(&existing, &t, value, ctx) {
597
655
  errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, name, msg) });
598
656
  } else if matches!(existing, PlumType::TVar(_)) {
599
657
  // `unify` never narrows a `TVar` itself —
@@ -622,7 +680,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
622
680
  fields.iter().find(|(n, _)| n == field_name).map(|(_, ty)| ty.clone())
623
681
  }) {
624
682
  Some(field_ty) => {
625
- if let Err(msg) = unify(&field_ty, &value_ty) {
683
+ if let Err(msg) = unifyArg(&field_ty, &value_ty, value, ctx) {
626
684
  errors.push(CheckError { message: format!("fn '{}': assign '{}': {}", fn_name, label, msg) });
627
685
  }
628
686
  }
@@ -640,7 +698,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
640
698
  ast::Stmt::Return(Some(e)) => {
641
699
  match inferExpr(e, env, ctx) {
642
700
  Ok(t) => {
643
- if let Err(msg) = unify(declared_ret, &t) {
701
+ if let Err(msg) = unifyArg(declared_ret, &t, e, ctx) {
644
702
  errors.push(CheckError { message: format!("fn '{}': return type mismatch: {}", fn_name, msg) });
645
703
  }
646
704
  }
@@ -648,14 +706,14 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
648
706
  }
649
707
  }
650
708
  ast::Stmt::Return(None) => {
651
- if let Err(msg) = unify(declared_ret, &PlumType::TUnit) {
709
+ if let Err(msg) = unify(declared_ret, &PlumType::TUnit, ctx) {
652
710
  errors.push(CheckError { message: format!("fn '{}': bare return in non-Unit function: {}", fn_name, msg) });
653
711
  }
654
712
  }
655
713
  ast::Stmt::If(if_) => {
656
714
  match inferExpr(&if_.condition, env, ctx) {
657
715
  Ok(t) => {
658
- if let Err(msg) = unify(&PlumType::TBool, &t) {
716
+ if let Err(msg) = unify(&PlumType::TBool, &t, ctx) {
659
717
  errors.push(CheckError { message: format!("fn '{}': if condition must be Bool: {}", fn_name, msg) });
660
718
  }
661
719
  }
@@ -665,7 +723,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
665
723
  for ei in &if_.else_ifs {
666
724
  match inferExpr(&ei.condition, env, ctx) {
667
725
  Ok(t) => {
668
- if let Err(msg) = unify(&PlumType::TBool, &t) {
726
+ if let Err(msg) = unify(&PlumType::TBool, &t, ctx) {
669
727
  errors.push(CheckError { message: format!("fn '{}': else if condition must be Bool: {}", fn_name, msg) });
670
728
  }
671
729
  }
@@ -680,7 +738,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
680
738
  ast::Stmt::While(w) => {
681
739
  match inferExpr(&w.condition, env, ctx) {
682
740
  Ok(t) => {
683
- if let Err(msg) = unify(&PlumType::TBool, &t) {
741
+ if let Err(msg) = unify(&PlumType::TBool, &t, ctx) {
684
742
  errors.push(CheckError { message: format!("fn '{}': while condition must be Bool: {}", fn_name, msg) });
685
743
  }
686
744
  }
@@ -722,7 +780,7 @@ fn checkStmt(stmt: &ast::Stmt, env: &mut TypeEnv, declared_ret: &PlumType, fn_na
722
780
  ast::Stmt::Assert(c) => {
723
781
  match inferExpr(&c.cond, env, ctx) {
724
782
  Ok(t) => {
725
- if let Err(msg) = unify(&PlumType::TBool, &t) {
783
+ if let Err(msg) = unify(&PlumType::TBool, &t, ctx) {
726
784
  errors.push(CheckError { message: format!("fn '{}': assert must be Bool: {}", fn_name, msg) });
727
785
  }
728
786
  }
@@ -770,7 +828,7 @@ fn checkMatch(m: &ast::Match, env: &TypeEnv, declared_ret: &PlumType, fn_name: &
770
828
  if let Some(guard) = &case.guard {
771
829
  match inferExpr(guard, &case_env, ctx) {
772
830
  Ok(t) => {
773
- if let Err(msg) = unify(&PlumType::TBool, &t) {
831
+ if let Err(msg) = unify(&PlumType::TBool, &t, ctx) {
774
832
  errors.push(CheckError { message: format!("fn '{}': match case guard must be Bool: {}", fn_name, msg) });
775
833
  }
776
834
  }
@@ -816,7 +874,7 @@ fn inferClassCallRaw(call: &ast::ClassCall, env: &TypeEnv, ctx: &CheckCtx) -> Re
816
874
  match fields.iter().find(|(n, _)| n == &fa.name) {
817
875
  Some((_, expected)) => {
818
876
  let actual = inferExpr(&fa.value, env, ctx)?;
819
- unify(expected, &actual)
877
+ unifyArg(expected, &actual, &fa.value, ctx)
820
878
  .map_err(|e| format!("class '{}' field '{}': {}", call.type_name, fa.name, e))?;
821
879
  }
822
880
  None => return Err(format!("unknown field '{}' on class '{}'", fa.name, call.type_name)),
@@ -841,7 +899,7 @@ fn inferClassCallRaw(call: &ast::ClassCall, env: &TypeEnv, ctx: &CheckCtx) -> Re
841
899
  Some(i) => {
842
900
  let expected = &info.field_types[i];
843
901
  let actual = inferExpr(&fa.value, env, ctx)?;
844
- unify(expected, &actual)
902
+ unifyArg(expected, &actual, &fa.value, ctx)
845
903
  .map_err(|e| format!("variant '{}' field '{}': {}", call.type_name, fa.name, e))?;
846
904
  }
847
905
  None => return Err(format!("unknown field '{}' on variant '{}'", fa.name, call.type_name)),
@@ -879,9 +937,9 @@ fn bareVariantWrapType(type_name: &str, ctx: &CheckCtx) -> PlumType {
879
937
  fn checkPattern(pat: &ast::CasePattern, subject_ty: &PlumType, env: &mut TypeEnv, ctx: &CheckCtx) -> Result<(), String> {
880
938
  match pat {
881
939
  ast::CasePattern::Wildcard => Ok(()),
882
- ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt),
940
+ ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt, ctx),
883
- ast::CasePattern::Float(_) => unify(subject_ty, &PlumType::TFloat),
941
+ ast::CasePattern::Float(_) => unify(subject_ty, &PlumType::TFloat, ctx),
884
- ast::CasePattern::String(_) => unify(subject_ty, &PlumType::TStr),
942
+ ast::CasePattern::String(_) => unify(subject_ty, &PlumType::TStr, ctx),
885
943
  ast::CasePattern::Name(n) => {
886
944
  let is_known_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
887
945
  && ctx.enum_variants.contains_key(n);
@@ -1069,35 +1127,35 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1069
1127
  }
1070
1128
  ast::Expr::Not(inner) => {
1071
1129
  let t = inferExpr(inner, env, ctx)?;
1072
- unify(&PlumType::TBool, &t)?;
1130
+ unify(&PlumType::TBool, &t, ctx)?;
1073
1131
  Ok(PlumType::TBool)
1074
1132
  }
1075
1133
  ast::Expr::Unary(u) => inferExpr(&u.operand, env, ctx),
1076
1134
  ast::Expr::Binary(b) => {
1077
1135
  let lt = inferExpr(&b.left, env, ctx)?;
1078
1136
  let rt = inferExpr(&b.right, env, ctx)?;
1079
- unify(&lt, &rt).map_err(|e| format!("binary op: {}", e))?;
1137
+ unify(&lt, &rt, ctx).map_err(|e| format!("binary op: {}", e))?;
1080
1138
  Ok(lt)
1081
1139
  }
1082
1140
  ast::Expr::Bool(b) => {
1083
1141
  let lt = inferExpr(&b.left, env, ctx)?;
1084
1142
  let rt = inferExpr(&b.right, env, ctx)?;
1085
- unify(&PlumType::TBool, &lt).map_err(|e| format!("bool op left: {}", e))?;
1143
+ unify(&PlumType::TBool, &lt, ctx).map_err(|e| format!("bool op left: {}", e))?;
1086
- unify(&PlumType::TBool, &rt).map_err(|e| format!("bool op right: {}", e))?;
1144
+ unify(&PlumType::TBool, &rt, ctx).map_err(|e| format!("bool op right: {}", e))?;
1087
1145
  Ok(PlumType::TBool)
1088
1146
  }
1089
1147
  ast::Expr::Compare(c) => {
1090
1148
  let lt = inferExpr(&c.left, env, ctx)?;
1091
1149
  let rt = inferExpr(&c.right, env, ctx)?;
1092
- unify(&lt, &rt).map_err(|e| format!("compare op: {}", e))?;
1150
+ unify(&lt, &rt, ctx).map_err(|e| format!("compare op: {}", e))?;
1093
1151
  Ok(PlumType::TBool)
1094
1152
  }
1095
1153
  ast::Expr::Ternary(t) => {
1096
1154
  let ct = inferExpr(&t.condition, env, ctx)?;
1097
- unify(&PlumType::TBool, &ct).map_err(|e| format!("ternary condition: {}", e))?;
1155
+ unify(&PlumType::TBool, &ct, ctx).map_err(|e| format!("ternary condition: {}", e))?;
1098
1156
  let tt = inferExpr(&t.then, env, ctx)?;
1099
1157
  let et = inferExpr(&t.else_, env, ctx)?;
1100
- unify(&tt, &et).map_err(|e| format!("ternary branches: {}", e))?;
1158
+ unify(&tt, &et, ctx).map_err(|e| format!("ternary branches: {}", e))?;
1101
1159
  Ok(tt)
1102
1160
  }
1103
1161
  ast::Expr::FnCall(call) => {
@@ -1146,7 +1204,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1146
1204
  }
1147
1205
  _ => inferExpr(arg_expr, env, ctx)?,
1148
1206
  };
1149
- unify(expected, &actual).map_err(|e| format!("variant '{}' arg {}: {}", call.name, i, e))?;
1207
+ unifyArg(expected, &actual, arg_expr, ctx).map_err(|e| format!("variant '{}' arg {}: {}", call.name, i, e))?;
1150
1208
  }
1151
1209
  return Ok(PlumType::TNamed(info.enum_name.clone()));
1152
1210
  }
@@ -1181,7 +1239,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1181
1239
  ast::Arg::Pair { value, .. } => value,
1182
1240
  };
1183
1241
  let actual = inferExpr(arg_expr, env, ctx)?;
1184
- unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
1242
+ unifyArg(expected, &actual, arg_expr, ctx).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
1185
1243
  }
1186
1244
  for (i, arg) in call.args.iter().enumerate().skip(fixed.len()) {
1187
1245
  let arg_expr = match arg {
@@ -1190,7 +1248,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1190
1248
  ast::Arg::Pair { value, .. } => value,
1191
1249
  };
1192
1250
  let actual = inferExpr(arg_expr, env, ctx)?;
1193
- unify(elem, &actual).map_err(|e| format!("call '{}' variadic arg {}: {}", call.name, i, e))?;
1251
+ unifyArg(elem, &actual, arg_expr, ctx).map_err(|e| format!("call '{}' variadic arg {}: {}", call.name, i, e))?;
1194
1252
  }
1195
1253
  Ok(*ret)
1196
1254
  }
@@ -1211,7 +1269,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1211
1269
  ast::Arg::Pair { value, .. } => value,
1212
1270
  };
1213
1271
  let actual = inferExpr(arg_expr, env, ctx)?;
1214
- unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
1272
+ unifyArg(expected, &actual, arg_expr, ctx).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
1215
1273
  }
1216
1274
  Ok(*ret)
1217
1275
  }
@@ -1252,8 +1310,39 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1252
1310
  .find(|(n, _)| n == field_name)
1253
1311
  .map(|(_, t)| t.clone())
1254
1312
  .ok_or_else(|| format!("no field '{}' on type '{}'", field_name, class_name)),
1313
+ // Not a discriminant enum either: `class_name` might
1314
+ // still be an ordinary enum whose value provably came
1315
+ // from a named-payload variant (`Circle(radius: 5)`,
1316
+ // widened to `TNamed("Shape")` by construction — see
1317
+ // `inferClassCallRaw`). If exactly ONE of the enum's
1318
+ // variants declares a field with this name, `.field`
1319
+ // is safe to allow here: codegen compiles it as a
1320
+ // checked downcast to that one variant (traps at
1321
+ // runtime if the value turns out to be a different
1322
+ // variant), so no static proof the value IS that
1323
+ // variant is required. If more than one variant
1324
+ // shares the field name, which one's value would win
1325
+ // is genuinely ambiguous — require a real `match`
1326
+ // instead. Zero owners means an unmodeled type
1255
- // Unmodeled type: allow, codegen will catch.
1327
+ // (unresolved generic, etc.) — allow, codegen will
1328
+ // catch a genuine mismatch.
1329
+ None => {
1330
+ let owners: Vec<&EnumVariantInfo> = ctx.enum_variants.values()
1331
+ .filter(|info| info.enum_name == class_name
1332
+ && info.field_names.iter().any(|n| n == field_name))
1333
+ .collect();
1334
+ match owners.as_slice() {
1335
+ [info] => {
1336
+ let idx = info.field_names.iter().position(|n| n == field_name).expect("just filtered on this");
1337
+ Ok(info.field_types[idx].clone())
1338
+ }
1256
- None => Ok(PlumType::TVar("_".to_string())),
1339
+ [] => Ok(PlumType::TVar("_".to_string())),
1340
+ _ => Err(format!(
1341
+ "field '{}' is ambiguous across multiple variants of enum '{}' — use a match",
1342
+ field_name, class_name
1343
+ )),
1344
+ }
1345
+ }
1257
1346
  },
1258
1347
  }
1259
1348
  }
@@ -1288,7 +1377,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1288
1377
  ast::Arg::Pair { value, .. } => value,
1289
1378
  };
1290
1379
  let actual = inferExpr(arg_expr, env, ctx)?;
1291
- unify(expected, &actual)
1380
+ unifyArg(expected, &actual, arg_expr, ctx)
1292
1381
  .map_err(|e| format!("method '{}.{}' arg {}: {}", class_name, call.name, i, e))?;
1293
1382
  }
1294
1383
  for (i, arg) in call.args.iter().enumerate().skip(fixed.len()) {
@@ -1298,7 +1387,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1298
1387
  ast::Arg::Pair { value, .. } => value,
1299
1388
  };
1300
1389
  let actual = inferExpr(arg_expr, env, ctx)?;
1301
- unify(elem, &actual)
1390
+ unifyArg(elem, &actual, arg_expr, ctx)
1302
1391
  .map_err(|e| format!("method '{}.{}' variadic arg {}: {}", class_name, call.name, i, e))?;
1303
1392
  }
1304
1393
  Ok(*ret.clone())
@@ -1322,7 +1411,7 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1322
1411
  ast::Arg::Pair { value, .. } => value,
1323
1412
  };
1324
1413
  let actual = inferExpr(arg_expr, env, ctx)?;
1325
- unify(expected, &actual)
1414
+ unifyArg(expected, &actual, arg_expr, ctx)
1326
1415
  .map_err(|e| format!("method '{}.{}' arg {}: {}", class_name, call.name, i, e))?;
1327
1416
  }
1328
1417
  Ok(*ret.clone())
plum-checker/tests/checker_tests.rs CHANGED
@@ -1,9 +1,25 @@
1
1
  #![allow(non_snake_case)]
2
2
  use plum_checker::types::*;
3
- use plum_checker::{checkSource, plumTypeFromAst, unify};
3
+ use plum_checker::{checkSource, plumTypeFromAst, unify, CheckCtx};
4
4
  use plum_core::ast::Type as AstType;
5
5
  use plum_core::{ast::*, AstParser};
6
6
 
7
+ fn emptyCtx() -> CheckCtx<'static> {
8
+ use std::sync::OnceLock;
9
+ static CLASSES: OnceLock<plum_checker::ClassEnv> = OnceLock::new();
10
+ static METHODS: OnceLock<plum_checker::MethodEnv> = OnceLock::new();
11
+ static ENUM_VARIANTS: OnceLock<plum_checker::EnumVariants> = OnceLock::new();
12
+ static ENUM_PARAMS: OnceLock<plum_checker::EnumParams> = OnceLock::new();
13
+ static MIN_REQUIRED: OnceLock<plum_checker::MinRequiredArgs> = OnceLock::new();
14
+ CheckCtx {
15
+ classes: CLASSES.get_or_init(Default::default),
16
+ methods: METHODS.get_or_init(Default::default),
17
+ enum_variants: ENUM_VARIANTS.get_or_init(Default::default),
18
+ enum_params: ENUM_PARAMS.get_or_init(Default::default),
19
+ min_required: MIN_REQUIRED.get_or_init(Default::default),
20
+ }
21
+ }
22
+
7
23
  fn parse(src: &str) -> Source {
8
24
  let mut parser = tree_sitter::Parser::new();
9
25
  parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
@@ -26,13 +42,15 @@ fn astTypeUnknownMapsToNamed() {
26
42
 
27
43
  #[test]
28
44
  fn unifySameTypesOk() {
45
+ let ctx = emptyCtx();
29
- assert!(unify(&PlumType::TInt, &PlumType::TInt).is_ok());
46
+ assert!(unify(&PlumType::TInt, &PlumType::TInt, &ctx).is_ok());
30
- assert!(unify(&PlumType::TFloat, &PlumType::TFloat).is_ok());
47
+ assert!(unify(&PlumType::TFloat, &PlumType::TFloat, &ctx).is_ok());
31
48
  }
32
49
 
33
50
  #[test]
34
51
  fn unifyDifferentTypesErr() {
52
+ let ctx = emptyCtx();
35
- assert!(unify(&PlumType::TInt, &PlumType::TFloat).is_err());
53
+ assert!(unify(&PlumType::TInt, &PlumType::TFloat, &ctx).is_err());
36
54
  }
37
55
 
38
56
  #[test]
@@ -852,6 +870,40 @@ fun useSumAll() -> Int =
852
870
  assert!(checkSource(&source).is_ok(), "expected Ok");
853
871
  }
854
872
 
873
+ #[test]
874
+ fn enumVariantAsParamTypeAcceptsThatVariant() {
875
+ let src = "\
876
+ enum Color =
877
+ | Red
878
+ | Blue
879
+
880
+ fun stringifyColor(v: Red) -> Int =
881
+ 0
882
+
883
+ fun useIt() -> Int =
884
+ stringifyColor(Red)
885
+ ";
886
+ let source = parse(src);
887
+ assert!(checkSource(&source).is_ok(), "expected Ok");
888
+ }
889
+
890
+ #[test]
891
+ fn enumVariantAsParamTypeRejectsADifferentVariant() {
892
+ let src = "\
893
+ enum Color =
894
+ | Red
895
+ | Blue
896
+
897
+ fun stringifyColor(v: Red) -> Int =
898
+ 0
899
+
900
+ fun useIt() -> Int =
901
+ stringifyColor(Blue)
902
+ ";
903
+ let source = parse(src);
904
+ assert!(checkSource(&source).is_err());
905
+ }
906
+
855
907
  #[test]
856
908
  fn variadicCallWithMismatchedTrailingArgTypeIsError() {
857
909
  let src = "\
@@ -1132,3 +1184,43 @@ fun bad() -> Shape =
1132
1184
  let errs = result.unwrap_err();
1133
1185
  assert!(errs.iter().any(|e| e.message.contains("unknown field 'z' on variant 'Square'")), "got: {:?}", errs);
1134
1186
  }
1187
+
1188
+ #[test]
1189
+ fn fieldAccessOnMultiVariantEnumValueResolvesViaTheUniqueOwningVariant() {
1190
+ // `radius` belongs to exactly one of `Shape`'s variants, so `.radius` on a
1191
+ // `Shape`-typed value type-checks even though `Shape` itself has more than
1192
+ // one variant — codegen compiles it as a checked downcast to `Circle`.
1193
+ let src = "\
1194
+ enum Shape =
1195
+ | Circle(radius: Int)
1196
+ | Square(side: Int)
1197
+
1198
+ fun getRadius(s: Shape) -> Int =
1199
+ r := Circle(radius: 5)
1200
+ return r.radius
1201
+ ";
1202
+ let source = parse(src);
1203
+ let result = checkSource(&source);
1204
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1205
+ }
1206
+
1207
+ #[test]
1208
+ fn fieldAccessOnMultiVariantEnumValueIsAmbiguousAcrossSameNamedFields() {
1209
+ let src = "\
1210
+ enum Shape =
1211
+ | Circle(radius: Int)
1212
+ | Square(radius: Int)
1213
+
1214
+ fun bad() -> Int =
1215
+ r := Circle(radius: 5)
1216
+ return r.radius
1217
+ ";
1218
+ let source = parse(src);
1219
+ let result = checkSource(&source);
1220
+ assert!(result.is_err());
1221
+ let errs = result.unwrap_err();
1222
+ assert!(
1223
+ errs.iter().any(|e| e.message.contains("field 'radius' is ambiguous across multiple variants of enum 'Shape'")),
1224
+ "got: {:?}", errs
1225
+ );
1226
+ }
plum-wasm-codegen/src/lib.rs CHANGED
@@ -4456,20 +4456,41 @@ fn compileExpr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mut
4456
4456
  // `buildGcTypeRegistry`'s `EnumSuper` arm) — no `ref.cast` to any
4457
4457
  // particular variant needed, since every variant has the exact
4458
4458
  // same field list as the supertype itself.
4459
+ None => match ctx.enum_params.get(&class_name) {
4460
+ Some(params) => {
4461
+ let field_idx = params
4462
+ .iter()
4463
+ .position(|(n, _)| n == field_name)
4464
+ .ok_or_else(|| format!("codegen: no field '{}' on enum '{}'", field_name, class_name))?;
4465
+ let super_type_idx = *ctx.gc_types.enum_super_type_idx.get(&class_name)
4466
+ .ok_or_else(|| format!("codegen: enum '{}' missing from the GC type registry", class_name))?;
4467
+ compileExpr(&attr.object, body, ctx, state)?;
4468
+ Instruction::StructGet { struct_type_index: super_type_idx, field_index: field_idx as u32 }.encode(body);
4469
+ }
4470
+ // Not a discriminant enum either: `class_name` is an
4471
+ // ordinary enum, and `plum-checker`'s `inferExpr` already
4472
+ // verified exactly ONE of its variants declares a field
4473
+ // with this name (ambiguity across sibling variants is a
4474
+ // checker error, never reaches codegen). Compile `.field`
4475
+ // as a checked downcast to that one variant's own
4476
+ // concrete struct type (same `ref.cast` idiom `match`
4477
+ // pattern-matching already uses, e.g. `compileCasePositions`),
4478
+ // then read the field off it — traps at runtime if the
4479
+ // value turns out to be a different variant, so no static
4480
+ // proof the value IS that variant is required here.
4459
- None => {
4481
+ None => {
4460
- let params = ctx
4482
+ let (variant_name, info) = ctx.enum_variants.iter()
4461
- .enum_params
4462
- .get(&class_name)
4463
- .ok_or_else(|| format!("codegen: unknown class '{}'", class_name))?;
4483
+ .find(|(_, info)| info.enum_name == class_name && info.field_names.iter().any(|n| n == field_name))
4464
- let field_idx = params
4465
- .iter()
4466
- .position(|(n, _)| n == field_name)
4467
- .ok_or_else(|| format!("codegen: no field '{}' on enum '{}'", field_name, class_name))?;
4484
+ .ok_or_else(|| format!("codegen: no field '{}' on enum '{}'", field_name, class_name))?;
4485
+ let field_idx = info.field_names.iter().position(|n| n == field_name)
4486
+ .expect("just found by this field name");
4468
- let super_type_idx = *ctx.gc_types.enum_super_type_idx.get(&class_name)
4487
+ let variant_type_idx = *ctx.gc_types.variant_type_idx.get(variant_name)
4469
- .ok_or_else(|| format!("codegen: enum '{}' missing from the GC type registry", class_name))?;
4488
+ .ok_or_else(|| format!("codegen: variant '{}' missing from the GC type registry", variant_name))?;
4470
- compileExpr(&attr.object, body, ctx, state)?;
4489
+ compileExpr(&attr.object, body, ctx, state)?;
4490
+ Instruction::RefCastNonNull(HeapType::Concrete(variant_type_idx)).encode(body);
4471
- Instruction::StructGet { struct_type_index: super_type_idx, field_index: field_idx as u32 }.encode(body);
4491
+ Instruction::StructGet { struct_type_index: variant_type_idx, field_index: field_idx as u32 }.encode(body);
4472
- }
4492
+ }
4493
+ },
4473
4494
  }
4474
4495
  }
4475
4496
  ast::AttrKind::Method(call) => {