plum

#treesitter#compiler#wasm

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

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


7df4164 — Peter John 2026-11-01T18:28:00+05:30
T3: checker coercions
plum-checker/src/lib.rs CHANGED
@@ -3,6 +3,7 @@
3
3
  #![allow(non_snake_case)]
4
4
 
5
5
  pub mod types;
6
+ pub mod unions;
6
7
 
7
8
  use plum_core::ast;
8
9
  use std::cell::{Cell, RefCell};
@@ -830,6 +831,8 @@ pub fn unify(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String>
830
831
  }
831
832
  unify(r1, r2, ctx)
832
833
  }
834
+ // A member value converts to its union enum (`Circle` where `Shape` is expected).
835
+ _ if unions::wrapVariantFor(t1, t2, ctx).is_some() => Ok(()),
833
836
  _ => Err(format!("type mismatch: expected {}, found {}", t1, t2)),
834
837
  }
835
838
  }
@@ -965,21 +968,6 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVaria
965
968
  let mut enum_variants: EnumVariants = BTreeMap::new();
966
969
  let mut enum_params: EnumParams = BTreeMap::new();
967
970
 
968
- // First pass: find every "record-shaped" enum — exactly one variant, whose
969
- // name equals the enum's own name (the `type X = ...` replacement shape,
970
- // e.g. `enum Cat = | Cat(name: Str)`) — so a bare variant elsewhere naming
971
- // one can wrap it as sugar (see the bare-wrap arm below), regardless of
972
- // which declaration appears first in the file.
973
- let record_shaped: std::collections::BTreeSet<&str> = source
974
- .items
975
- .iter()
976
- .filter_map(|item| match item {
977
- ast::Item::Enum(e) if e.variants.len() == 1 && e.variants[0].name == e.name => {
978
- Some(e.name.as_str())
979
- }
980
- _ => None,
981
- })
982
- .collect();
983
971
  for item in &source.items {
984
972
  match item {
985
973
  ast::Item::Enum(e) => {
@@ -999,19 +987,6 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVaria
999
987
  shared_field_types.clone(),
1000
988
  e.params.iter().map(|p| p.name.clone()).collect(),
1001
989
  )
1002
- } else if v.fields.is_empty()
1003
- && e.name != v.name
1004
- && record_shaped.contains(v.name.as_str())
1005
- {
1006
- // Bare-type variant sugar: `| Cat` (no `[...]`) naming a
1007
- // DIFFERENT, already-declared record-shaped enum means "this
1008
- // variant wraps one payload field of that whole record" —
1009
- // shorthand for the equivalent `| Cat[Cat]`, so `enum Animal
1010
- // = | Cat | Dog` needs no separate wrapper tag distinct from
1011
- // the record it holds. Excludes `e.name == v.name` (a plain
1012
- // payload-free singleton like `enum Foo = | Foo`, which is
1013
- // already exactly what it says — nothing else to wrap).
1014
- (vec![PlumType::TNamed(v.name.clone())], Vec::new())
1015
990
  } else {
1016
991
  (
1017
992
  v.fields.iter().map(conv).collect(),
@@ -2738,8 +2713,8 @@ fn checkMatch(
2738
2713
  }
2739
2714
 
2740
2715
  /// Type-checks a `ClassCall`'s fields against its class's declared field types and
2741
- /// returns the BARE class type (`TNamed(call.type_name)`, never the enum a
2716
+ /// returns the BARE class type (`TNamed(call.type_name)`; it converts to a
2742
- /// bare-type variant sugar wraps it in — see `bareVariantWrapType`). Split out from
2717
+ /// union enum on demand, see `unions`). Split out from
2743
2718
  /// `inferExpr`'s `ClassCall` arm so the `FnCall` variant-arg check below can type
2744
2719
  /// the class's OWN inner construction (`FantasyBook(...)` nested inside the
2745
2720
  /// desugared `FantasyBook(FantasyBook(...))`) without re-triggering the wrap.
@@ -2900,20 +2875,6 @@ fn inferClassCallRaw(
2900
2875
  }
2901
2876
  }
2902
2877
 
2903
- /// A class construction call (`FantasyBook(title: ..., ...)`) whose name is ALSO a
2904
- /// bare-type enum variant (`enum Book = | FantasyBook`, see `buildGlobalTables`)
2905
- /// infers as the enum it wraps, not the bare class — mirroring the AST-level
2906
- /// desugaring codegen does (`FantasyBook(...)`
2907
- /// there literally becomes `Book`'s `FantasyBook(FantasyBook(...))` constructor).
2908
- fn bareVariantWrapType(type_name: &str, ctx: &CheckCtx) -> PlumType {
2909
- match ctx.enum_variants.get(type_name) {
2910
- Some(info) if info.field_types == [PlumType::TNamed(type_name.to_string())] => {
2911
- PlumType::TNamed(info.enum_name.clone())
2912
- }
2913
- _ => PlumType::TNamed(type_name.to_string()),
2914
- }
2915
- }
2916
-
2917
2878
  /// Checks a single case pattern against the type of the subject it matches, binding any
2918
2879
  /// new names it introduces into `env`. Constructor-payload sub-patterns (`Next(x)`) bind
2919
2880
  /// against that variant's declared field types (see `EnumVariantInfo::field_types`).
@@ -3502,6 +3463,7 @@ pub fn inferExprWith(
3502
3463
  if let Ok(t) = &r {
3503
3464
  recordType(expr, t);
3504
3465
  recordErrCoercion(expr, t, expected, ctx);
3466
+ unions::recordMemberWrap(expr, t, expected, ctx);
3505
3467
  // A call returning another fn's inferred `!T` is an edge of the call graph.
3506
3468
  if let PlumType::TErr(_, ErrSet::Inferred(k)) = t {
3507
3469
  if !k.is_empty() {
@@ -3969,6 +3931,11 @@ fn inferExprInner(
3969
3931
  if matches!(tt, PlumType::TErr(..)) { &et } else { &tt }
3970
3932
  ));
3971
3933
  }
3934
+ if tt != et {
3935
+ if let Some(joined) = unions::joinBranches(&t.then, &tt, &t.else_, &et, ctx) {
3936
+ return joined.map_err(|e| format!("inline if branches: {}", e));
3937
+ }
3938
+ }
3972
3939
  unify(&tt, &et, ctx).map_err(|e| format!("inline if branches: {}", e))?;
3973
3940
  Ok(tt)
3974
3941
  }
@@ -4075,11 +4042,7 @@ fn inferExprInner(
4075
4042
  }
4076
4043
  }
4077
4044
  ast::Expr::ClassCall(call) => {
4078
- let bare = inferClassCallRaw(call, env, ctx, expected)?;
4045
+ inferClassCallRaw(call, env, ctx, expected)
4079
- Ok(match bare {
4080
- PlumType::TNamed(n) => bareVariantWrapType(&n, ctx),
4081
- other => other,
4082
- })
4083
4046
  }
4084
4047
  ast::Expr::Attribute(attr) => {
4085
4048
  let obj_ty = resolveRecorded(&inferExpr(&attr.object, env, ctx)?);
plum-checker/src/unions.rs ADDED
@@ -0,0 +1,132 @@
1
+ //! Union enums in the checker: a member value converts to its enum, and
2
+ //! branches of different members join to their common enum.
3
+ use crate::types::{self, PlumType};
4
+ use crate::{unify, CheckCtx};
5
+ use plum_core::ast;
6
+ use plum_core::unions as plan;
7
+ use std::cell::Cell;
8
+ use std::collections::{BTreeMap, BTreeSet};
9
+
10
+ thread_local! {
11
+ /// Set while a member field is being unified, so conversions do not nest.
12
+ static IN_WRAP: Cell<bool> = const { Cell::new(false) };
13
+ }
14
+
15
+ fn enumOf(ty: &PlumType) -> Option<(&str, &[PlumType])> {
16
+ match ty {
17
+ PlumType::TNamed(n) => Some((n, &[])),
18
+ PlumType::TApp(n, args) => Some((n, args)),
19
+ _ => None,
20
+ }
21
+ }
22
+
23
+ /// `(hidden variant name, member type)` of every member of `enumName`, with the
24
+ /// enum's generics substituted by `args` when they match in number.
25
+ fn members(enumName: &str, args: &[PlumType], ctx: &CheckCtx) -> Vec<(String, PlumType)> {
26
+ let prefix = format!("{enumName}__");
27
+ ctx.enum_variants
28
+ .iter()
29
+ .filter(|(n, i)| {
30
+ i.enum_name == enumName
31
+ && n.starts_with(&prefix)
32
+ && i.field_types.len() == 1
33
+ && i.field_names.is_empty()
34
+ })
35
+ .map(|(n, i)| {
36
+ let f = &i.field_types[0];
37
+ let f = if !args.is_empty() && args.len() == i.generics.len() {
38
+ let s: BTreeMap<String, PlumType> =
39
+ i.generics.iter().cloned().zip(args.iter().cloned()).collect();
40
+ types::substitute(f, &s)
41
+ } else {
42
+ f.clone()
43
+ };
44
+ (n.clone(), f)
45
+ })
46
+ .collect()
47
+ }
48
+
49
+ /// The hidden variant that converts `actual` into the union enum `expected`.
50
+ pub fn wrapVariantFor(expected: &PlumType, actual: &PlumType, ctx: &CheckCtx) -> Option<String> {
51
+ if IN_WRAP.with(|c| c.get()) {
52
+ return None;
53
+ }
54
+ if matches!(actual, PlumType::TVar(_) | PlumType::TParam(_) | PlumType::TErr(..)) {
55
+ return None;
56
+ }
57
+ let (name, args) = enumOf(expected)?;
58
+ if enumOf(actual).is_some_and(|(a, _)| a == name) {
59
+ return None;
60
+ }
61
+ let ms = members(name, args, ctx);
62
+ if ms.is_empty() {
63
+ return None;
64
+ }
65
+ IN_WRAP.with(|c| c.set(true));
66
+ let found = ms.into_iter().find(|(_, t)| unify(t, actual, ctx).is_ok());
67
+ IN_WRAP.with(|c| c.set(false));
68
+ found.map(|(v, _)| v)
69
+ }
70
+
71
+ /// Records a wrap when `expr` (of type `actual`) lands where `expected` is a union enum.
72
+ pub fn recordMemberWrap(expr: &ast::Expr, actual: &PlumType, expected: Option<&PlumType>, ctx: &CheckCtx) {
73
+ let Some(expected) = expected else { return };
74
+ if let Some(v) = wrapVariantFor(expected, actual, ctx) {
75
+ plan::recordWrap(expr as *const ast::Expr as usize, v);
76
+ }
77
+ }
78
+
79
+ /// Enums that `ty` is a member of, plus `ty`'s own enum.
80
+ fn candidates(ty: &PlumType, ctx: &CheckCtx) -> BTreeSet<String> {
81
+ let mut out = BTreeSet::new();
82
+ for (vn, info) in ctx.enum_variants.iter() {
83
+ if info.field_types.len() == 1
84
+ && info.field_names.is_empty()
85
+ && vn.starts_with(&format!("{}__", info.enum_name))
86
+ {
87
+ let ok = match (&info.field_types[0], ty) {
88
+ (PlumType::TApp(a, _), PlumType::TApp(b, _)) | (PlumType::TNamed(a), PlumType::TNamed(b)) => a == b,
89
+ (a, b) => a == b,
90
+ };
91
+ if ok {
92
+ out.insert(info.enum_name.clone());
93
+ }
94
+ }
95
+ }
96
+ if let Some((n, _)) = enumOf(ty) {
97
+ if !members(n, &[], ctx).is_empty() {
98
+ out.insert(n.to_string());
99
+ }
100
+ }
101
+ out
102
+ }
103
+
104
+ /// Joins two branch types through their one common union enum. `None` when
105
+ /// neither branch involves unions (the ordinary rules apply).
106
+ pub fn joinBranches(
107
+ a: &ast::Expr,
108
+ ta: &PlumType,
109
+ b: &ast::Expr,
110
+ tb: &PlumType,
111
+ ctx: &CheckCtx,
112
+ ) -> Option<Result<PlumType, String>> {
113
+ let ca = candidates(ta, ctx);
114
+ let cb = candidates(tb, ctx);
115
+ if ca.is_empty() || cb.is_empty() {
116
+ return None;
117
+ }
118
+ let common: Vec<&String> = ca.intersection(&cb).collect();
119
+ if common.len() != 1 {
120
+ return Some(Err(format!(
121
+ "cannot join {} and {}: no single enum has both members",
122
+ ta, tb
123
+ )));
124
+ }
125
+ let e = PlumType::TNamed(common[0].clone());
126
+ for (x, tx) in [(a, ta), (b, tb)] {
127
+ if let Some(v) = wrapVariantFor(&e, tx, ctx) {
128
+ plan::recordWrap(x as *const ast::Expr as usize, v);
129
+ }
130
+ }
131
+ Some(Ok(e))
132
+ }
plum-checker/tests/unions_coerce_test.rs ADDED
@@ -0,0 +1,97 @@
1
+ #![allow(non_snake_case)]
2
+ use plum_checker::checkSource;
3
+ use plum_core::unions::{beginPlan, resolveMembers, takePlan, UnionPlan};
4
+ use plum_core::AstParser;
5
+
6
+ const DEFS: &str = "\
7
+ record Circle(r: Float)
8
+ record Square(s: Float)
9
+ record Oval(a: Float)
10
+ enum Shape = Circle | Square
11
+ enum Round = Circle | Oval
12
+ enum Json = Float | Str
13
+ enum Tree[T] = Node[T] | Leaf
14
+ record Node[T](v: T)
15
+ ";
16
+
17
+ fn run(body: &str) -> (Result<(), Vec<String>>, UnionPlan) {
18
+ let src = format!("{DEFS}\n{body}\n");
19
+ let mut parser = tree_sitter::Parser::new();
20
+ parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
21
+ let tree = parser.parse(&src, None).unwrap();
22
+ assert!(!tree.root_node().has_error(), "parse errors:\n{src}");
23
+ let mut source = AstParser::new(&src).parseSource(tree.root_node());
24
+ let errs = resolveMembers(&mut source);
25
+ assert!(errs.is_empty(), "{errs:?}");
26
+ beginPlan();
27
+ let r = checkSource(&source).map_err(|e| e.into_iter().map(|x| x.message).collect());
28
+ (r, takePlan())
29
+ }
30
+
31
+ fn wraps(p: &UnionPlan) -> Vec<String> {
32
+ let mut v: Vec<String> = p.wraps.values().cloned().collect();
33
+ v.sort();
34
+ v
35
+ }
36
+
37
+ #[test]
38
+ fn literal_into_json() {
39
+ let (r, p) = run("fun f() -> Json =\n 3.0");
40
+ r.unwrap();
41
+ assert_eq!(wraps(&p), vec!["Json__Float"]);
42
+ }
43
+
44
+ #[test]
45
+ fn wrap_at_arg_return_push() {
46
+ let (r, p) = run("fun take(s: Shape) -> Float =\n 1.0\n\nfun go() -> Float =\n take(Circle(r: 1.0))");
47
+ r.unwrap();
48
+ assert_eq!(wraps(&p), vec!["Shape__Circle"]);
49
+ let (r, p) = run("fun mk() -> Shape =\n Square(s: 2.0)");
50
+ r.unwrap();
51
+ assert_eq!(wraps(&p), vec!["Shape__Square"]);
52
+ let (r, p) = run("fun many(xs: ...Shape) -> Int =\n 1\n\nfun go() -> Int =\n many(Circle(r: 1.0), Square(s: 1.0))");
53
+ r.unwrap();
54
+ assert_eq!(wraps(&p), vec!["Shape__Circle", "Shape__Square"]);
55
+ }
56
+
57
+ #[test]
58
+ fn wrap_reassign_and_generic() {
59
+ let (r, p) = run("fun pick() -> Shape =\n Circle(r: 1.0)\n\nfun go() -> Int =\n s := pick()\n s = Square(s: 1.0)\n 1");
60
+ r.unwrap();
61
+ assert_eq!(wraps(&p), vec!["Shape__Circle", "Shape__Square"]);
62
+ let (r, p) = run("fun mk() -> Tree[Int] =\n Node(v: 3)");
63
+ r.unwrap();
64
+ assert_eq!(wraps(&p), vec!["Tree__Node"]);
65
+ }
66
+
67
+ #[test]
68
+ fn join_if_branches() {
69
+ let (r, p) = run("fun go(c: Bool) -> Int =\n x := if c then Circle(r: 1.0) else Square(s: 1.0)\n 1");
70
+ r.unwrap();
71
+ assert_eq!(wraps(&p), vec!["Shape__Circle", "Shape__Square"]);
72
+ }
73
+
74
+ #[test]
75
+ fn join_ambiguous() {
76
+ let (r, _) = run("fun go(c: Bool) -> Int =\n x := if c then Square(s: 1.0) else Oval(a: 1.0)\n 1");
77
+ assert!(r.unwrap_err().join("|").contains("cannot join Square and Oval: no single enum has both members"));
78
+ }
79
+
80
+ #[test]
81
+ fn no_wrap_same_type() {
82
+ let (r, p) = run("fun take(c: Circle) -> Int =\n 1\n\nfun go() -> Int =\n take(Circle(r: 1.0))");
83
+ r.unwrap();
84
+ assert!(p.wraps.is_empty());
85
+ let (r, p) = run("fun mk() -> Circle =\n Circle(r: 1.0)");
86
+ r.unwrap();
87
+ assert!(p.wraps.is_empty());
88
+ }
89
+
90
+ #[test]
91
+ fn member_alone_is_its_own_type() {
92
+ let (r, p) = run("fun mk() -> Circle =\n Circle(r: 1.0)");
93
+ r.unwrap();
94
+ assert!(p.wraps.is_empty());
95
+ let (r, _) = run("fun mk() -> Shape =\n Oval(a: 1.0)");
96
+ assert!(r.is_err());
97
+ }