plum
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 +12 -49
- plum-checker/src/unions.rs +132 -0
- plum-checker/tests/unions_coerce_test.rs +97 -0
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)`
|
|
2716
|
+
/// returns the BARE class type (`TNamed(call.type_name)`; it converts to a
|
|
2742
|
-
///
|
|
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
|
-
|
|
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
|
+
}
|