plum

#treesitter#compiler#wasm

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

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


9c5f64a — Peter John 2026-10-25T16:00:23+05:30
feat(checker): structural tag-union types and Roc-style ? checking
plum-checker/src/lib.rs CHANGED
@@ -21,10 +21,125 @@ pub fn plumTypeFromName(name: &str) -> PlumType {
21
21
  "Byte" => PlumType::TByte,
22
22
  "[]Byte" => PlumType::TByteSlice,
23
23
  "Unit" => PlumType::TUnit,
24
+ other if other.starts_with('[') && !other.starts_with("[]") && other.ends_with(']') => {
25
+ parseTagUnion(other).unwrap_or_else(|| PlumType::TNamed(other.to_string()))
26
+ }
24
27
  other => PlumType::TNamed(other.to_string()),
25
28
  }
26
29
  }
27
30
 
31
+ /// Splits `s` on `sep` occurring at bracket/paren depth 0 (so a nested tag
32
+ /// union or payload never splits its parent). `[]` (slice prefix) is neutral.
33
+ fn splitTopLevel(s: &str, sep: char) -> Vec<&str> {
34
+ let mut parts = Vec::new();
35
+ let mut depth = 0i32;
36
+ let mut start = 0;
37
+ let bytes: Vec<(usize, char)> = s.char_indices().collect();
38
+ let mut i = 0;
39
+ while i < bytes.len() {
40
+ let (idx, c) = bytes[i];
41
+ match c {
42
+ '[' if bytes.get(i + 1).map(|b| b.1) == Some(']') => i += 1,
43
+ '[' | '(' => depth += 1,
44
+ ']' | ')' => depth -= 1,
45
+ c if c == sep && depth == 0 => {
46
+ parts.push(&s[start..idx]);
47
+ start = idx + c.len_utf8();
48
+ }
49
+ _ => {}
50
+ }
51
+ i += 1;
52
+ }
53
+ parts.push(&s[start..]);
54
+ parts
55
+ }
56
+
57
+ /// Parses a canonical tag-union string (`[A,B(Str)]`) into `PlumType::TTags`.
58
+ fn parseTagUnion(s: &str) -> Option<PlumType> {
59
+ let inner = s.strip_prefix('[')?.strip_suffix(']')?;
60
+ let mut tags = BTreeMap::new();
61
+ if inner.is_empty() {
62
+ return Some(PlumType::TTags(tags));
63
+ }
64
+ for seg in splitTopLevel(inner, ',') {
65
+ let seg = seg.trim();
66
+ match seg.find('(') {
67
+ Some(p) => {
68
+ let name = seg[..p].trim().to_string();
69
+ let payload = seg[p + 1..].strip_suffix(')')?;
70
+ tags.insert(name, Some(Box::new(plumTypeFromName(payload.trim()))));
71
+ }
72
+ None => {
73
+ tags.insert(seg.to_string(), None);
74
+ }
75
+ }
76
+ }
77
+ Some(PlumType::TTags(tags))
78
+ }
79
+
80
+ /// Replaces every tag-union segment (`[...]`, but not the `[]Byte` slice
81
+ /// spelling) of a mangled name with `Tags`, so specializations that differ
82
+ /// only in their tag unions share one runtime type:
83
+ /// `erasedEnumName("Try$Int$[A,B(Str)]") == "Try$Int$Tags"`.
84
+ pub fn erasedEnumName(name: &str) -> String {
85
+ let chars: Vec<char> = name.chars().collect();
86
+ let mut out = String::new();
87
+ let mut i = 0;
88
+ while i < chars.len() {
89
+ if chars[i] == '[' && chars.get(i + 1) != Some(&']') {
90
+ let mut depth = 0;
91
+ while i < chars.len() {
92
+ match chars[i] {
93
+ '[' => depth += 1,
94
+ ']' => depth -= 1,
95
+ _ => {}
96
+ }
97
+ i += 1;
98
+ if depth == 0 {
99
+ break;
100
+ }
101
+ }
102
+ out.push_str("Tags");
103
+ } else {
104
+ out.push(chars[i]);
105
+ i += 1;
106
+ }
107
+ }
108
+ out
109
+ }
110
+
111
+ /// True when two mangled specialization names (`Try$Int$[A,B]`) are identical
112
+ /// except for their tag-union segments and every `actual` union is a subset of
113
+ /// the matching `expected` one (E is covariant).
114
+ pub fn tagUnionSegmentsCompatible(expected: &str, actual: &str, ctx: &CheckCtx) -> bool {
115
+ let e_segs = splitTopLevel(expected, '$');
116
+ let a_segs = splitTopLevel(actual, '$');
117
+ if e_segs.len() != a_segs.len() || e_segs.len() < 2 {
118
+ return false;
119
+ }
120
+ let mut saw_union = false;
121
+ for (e, a) in e_segs.iter().zip(a_segs.iter()) {
122
+ if e == a {
123
+ continue;
124
+ }
125
+ let both_unions = e.starts_with('[')
126
+ && !e.starts_with("[]")
127
+ && a.starts_with('[')
128
+ && !a.starts_with("[]");
129
+ if !both_unions {
130
+ return false;
131
+ }
132
+ saw_union = true;
133
+ if unify(&plumTypeFromName(e), &plumTypeFromName(a), ctx).is_err() {
134
+ return false;
135
+ }
136
+ }
137
+ saw_union
138
+ }
139
+
140
+ /// Reserved `TypeEnv` key holding the enclosing function's declared return type.
141
+ const RETURN_KEY: &str = "$return";
142
+
28
143
  pub fn plumTypeFromAst(ty: &ast::Type) -> PlumType {
29
144
  plumTypeFromName(&ty.name)
30
145
  }
@@ -82,6 +197,35 @@ pub fn unify(t1: &PlumType, t2: &PlumType, ctx: &CheckCtx) -> Result<(), String>
82
197
  (PlumType::TByteSlice, PlumType::TByteSlice) => Ok(()),
83
198
  (PlumType::TUnit, PlumType::TUnit) => Ok(()),
84
199
  (PlumType::TNamed(a), PlumType::TNamed(b)) if a == b => Ok(()),
200
+ // Tag-union subtyping: every tag of `actual` must exist in `expected`
201
+ // with a unifying payload.
202
+ (PlumType::TTags(exp), PlumType::TTags(act)) => {
203
+ for (tag, act_payload) in act {
204
+ match (exp.get(tag), act_payload) {
205
+ (None, _) => {
206
+ return Err(format!(
207
+ "type mismatch: expected {}, found {} (tag '{}' is not allowed)",
208
+ t1, t2, tag
209
+ ))
210
+ }
211
+ (Some(None), None) => {}
212
+ (Some(Some(ep)), Some(ap)) => unify(ep, ap, ctx)?,
213
+ _ => {
214
+ return Err(format!(
215
+ "type mismatch: expected {}, found {} (tag '{}' payload differs)",
216
+ t1, t2, tag
217
+ ))
218
+ }
219
+ }
220
+ }
221
+ Ok(())
222
+ }
223
+ // Two specializations differing only in their tag-union segments.
224
+ (PlumType::TNamed(a), PlumType::TNamed(b))
225
+ if a != b && tagUnionSegmentsCompatible(a, b, ctx) =>
226
+ {
227
+ Ok(())
228
+ }
85
229
  // A bare enum variant name (`Red`) can be used directly as a type — e.g.
86
230
  // a fn param `v: Red` — meaning "a `Color` value that is specifically
87
231
  // the `Red` variant". A variant's OWN constructions/references always
@@ -705,6 +849,8 @@ fn checkFn(f: &ast::Fn, global_env: &TypeEnv, ctx: &CheckCtx) -> Vec<CheckError>
705
849
  .as_ref()
706
850
  .map(plumTypeFromAst)
707
851
  .unwrap_or(PlumType::TUnit);
852
+ // Reserved env key read by `?` (see `inferExpr`'s `Try` arm); closures remove it.
853
+ env.insert(RETURN_KEY.to_string(), TypeScheme::mono(declared_ret.clone()));
708
854
 
709
855
  match &f.body {
710
856
  ast::FnBody::Expr(e) => match inferExpr(e, &env, ctx) {
@@ -1054,8 +1200,22 @@ fn checkStmt(
1054
1200
  errors.append(&mut checkMatch(m, env, declared_ret, fn_name, fn_pos, ctx));
1055
1201
  }
1056
1202
  ast::Stmt::Break | ast::Stmt::Continue | ast::Stmt::Todo => {}
1057
- // Real semantics (Str check) land in T2.
1058
- ast::Stmt::Crash(_) => {}
1203
+ ast::Stmt::Crash(e) => match inferExpr(e, env, ctx) {
1204
+ Ok(t) => {
1205
+ if let Err(msg) = unify(&PlumType::TStr, &t, ctx) {
1206
+ errors.push(CheckError::at(
1207
+ fn_pos.0,
1208
+ fn_pos.1,
1209
+ format!("fn '{}': crash message must be Str: {}", fn_name, msg),
1210
+ ));
1211
+ }
1212
+ }
1213
+ Err(msg) => errors.push(CheckError::at(
1214
+ fn_pos.0,
1215
+ fn_pos.1,
1216
+ format!("fn '{}': crash: {}", fn_name, msg),
1217
+ )),
1218
+ },
1059
1219
  }
1060
1220
  errors
1061
1221
  }
@@ -1085,6 +1245,50 @@ fn checkMatch(
1085
1245
  return errors; // a subject failed to type — cases can't be checked meaningfully
1086
1246
  }
1087
1247
 
1248
+ // A match over a tag union without a catch-all must cover every tag.
1249
+ if let [PlumType::TTags(tags)] = subject_types.as_slice() {
1250
+ let mut covered: std::collections::BTreeSet<&str> = Default::default();
1251
+ let mut catch_all = false;
1252
+ for case in &m.cases {
1253
+ if case.guard.is_some() {
1254
+ continue;
1255
+ }
1256
+ match case.patterns.first() {
1257
+ Some(ast::CasePattern::Wildcard) => catch_all = true,
1258
+ Some(ast::CasePattern::Name(n)) => {
1259
+ if tags.contains_key(n) {
1260
+ covered.insert(n.as_str());
1261
+ } else {
1262
+ catch_all = true; // a binding
1263
+ }
1264
+ }
1265
+ Some(ast::CasePattern::Class { name, .. }) => {
1266
+ covered.insert(name.as_str());
1267
+ }
1268
+ _ => {}
1269
+ }
1270
+ }
1271
+ if !catch_all {
1272
+ let missing: Vec<&str> = tags
1273
+ .keys()
1274
+ .map(|k| k.as_str())
1275
+ .filter(|k| !covered.contains(k))
1276
+ .collect();
1277
+ if !missing.is_empty() {
1278
+ errors.push(CheckError::at(
1279
+ fn_pos.0,
1280
+ fn_pos.1,
1281
+ format!(
1282
+ "fn '{}': non-exhaustive match on {}: missing {}",
1283
+ fn_name,
1284
+ PlumType::TTags(tags.clone()),
1285
+ missing.join(", ")
1286
+ ),
1287
+ ));
1288
+ }
1289
+ }
1290
+ }
1291
+
1088
1292
  for case in &m.cases {
1089
1293
  let mut case_env = env.clone();
1090
1294
  if case.patterns.len() == subject_types.len() {
@@ -1281,6 +1485,29 @@ fn checkPattern(
1281
1485
  env: &mut TypeEnv,
1282
1486
  ctx: &CheckCtx,
1283
1487
  ) -> Result<(), String> {
1488
+ if let PlumType::TTags(tags) = subject_ty {
1489
+ let is_upper = |n: &str| n.chars().next().is_some_and(|c| c.is_uppercase());
1490
+ match pat {
1491
+ ast::CasePattern::Name(n) if is_upper(n) => {
1492
+ return match tags.get(n) {
1493
+ Some(None) => Ok(()),
1494
+ Some(Some(_)) => Err(format!("tag '{}' carries a payload; write '{}(..)'", n, n)),
1495
+ None => Err(format!("tag '{}' is not part of {}", n, subject_ty)),
1496
+ };
1497
+ }
1498
+ ast::CasePattern::Class { name, fields } if is_upper(name) => {
1499
+ return match tags.get(name) {
1500
+ Some(Some(payload)) if fields.len() == 1 => {
1501
+ checkPattern(&fields[0], payload, env, ctx)
1502
+ }
1503
+ Some(Some(_)) => Err(format!("tag '{}' has exactly one payload", name)),
1504
+ Some(None) => Err(format!("tag '{}' has no payload", name)),
1505
+ None => Err(format!("tag '{}' is not part of {}", name, subject_ty)),
1506
+ };
1507
+ }
1508
+ _ => {}
1509
+ }
1510
+ }
1284
1511
  match pat {
1285
1512
  ast::CasePattern::Wildcard => Ok(()),
1286
1513
  ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt, ctx),
@@ -1496,7 +1723,7 @@ fn variantBaseName(name: &str) -> &str {
1496
1723
  name.split('$').next().unwrap_or(name)
1497
1724
  }
1498
1725
 
1499
- /// Finds the "success" (`Ok`/`Some`) and "failure" (`Err`/`None`) variants
1726
+ /// Finds the "success" (`Ok`) and "failure" (`Err`) variants
1500
1727
  /// belonging to the same enum as `enum_name`, for the `?` operator (`Expr::Try`)
1501
1728
  /// — used identically by the checker (`inferExpr`) and codegen (`inferLocalType`
1502
1729
  /// callers), which is why this lives here rather than inline in either. Returns
@@ -1513,8 +1740,8 @@ pub fn tryOperatorVariants<'a>(
1513
1740
  continue;
1514
1741
  }
1515
1742
  match variantBaseName(vname) {
1516
- "Ok" | "Some" => success = Some((vname.as_str(), info)),
1743
+ "Ok" => success = Some((vname.as_str(), info)),
1517
- "Err" | "None" => failure = Some((vname.as_str(), info)),
1744
+ "Err" => failure = Some((vname.as_str(), info)),
1518
1745
  _ => {}
1519
1746
  }
1520
1747
  }
@@ -1524,6 +1751,19 @@ pub fn tryOperatorVariants<'a>(
1524
1751
  }
1525
1752
  }
1526
1753
 
1754
+ /// True if `n` is an uppercase name that is not a declared type, variant, method
1755
+ /// receiver, builtin type or const — i.e. a structural tag literal.
1756
+ fn isTagLiteralName(n: &str, ctx: &CheckCtx) -> bool {
1757
+ n.chars().next().is_some_and(|c| c.is_uppercase())
1758
+ && !matches!(
1759
+ n,
1760
+ "Int" | "Float" | "Bool" | "Str" | "Byte" | "Unit" | "Self"
1761
+ )
1762
+ && !ctx.enum_variants.contains_key(n)
1763
+ && !ctx.enum_variants.values().any(|i| i.enum_name == n)
1764
+ && !ctx.methods.keys().any(|(recv, _)| recv == n)
1765
+ }
1766
+
1527
1767
  pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<PlumType, String> {
1528
1768
  match expr {
1529
1769
  ast::Expr::Int(_) => Ok(PlumType::TInt),
@@ -1549,6 +1789,9 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1549
1789
  // keeps that working without patching every such site.
1550
1790
  Some(info) if info.enum_name == "Bool" => Ok(PlumType::TBool),
1551
1791
  Some(info) => Ok(PlumType::TNamed(info.enum_name.clone())),
1792
+ None if isTagLiteralName(n, ctx) => {
1793
+ Ok(PlumType::TTags(BTreeMap::from([(n.clone(), None)])))
1794
+ }
1552
1795
  // Unmodeled/builtin type name: allow, codegen will catch.
1553
1796
  None => Ok(PlumType::TNamed(n.to_string())),
1554
1797
  },
@@ -1565,26 +1808,41 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1565
1808
  // for a case a wrong program still fails on, just less precisely.
1566
1809
  ast::Expr::Try(inner) => {
1567
1810
  let inner_ty = inferExpr(inner, env, ctx)?;
1568
- match &inner_ty {
1811
+ let name = match &inner_ty {
1812
+ PlumType::TNamed(name) => name,
1813
+ other => return Err(format!("'?' requires a Try value, found {}", other)),
1814
+ };
1815
+ let Some((_, success_info, _, failure_info)) = tryOperatorVariants(name, ctx) else {
1816
+ return Err(format!("'?' requires a Try value, found '{}'", name));
1817
+ };
1818
+ // The failure branch returns early from the enclosing fn, so the
1819
+ // inner error type must fit the fn's declared error type. Closures
1820
+ // (and test blocks) have no `$return` and aren't checked.
1821
+ if let Some(PlumType::TNamed(outer)) = env.get(RETURN_KEY).map(|s| (*s.body).clone()) {
1569
- PlumType::TNamed(name) => match tryOperatorVariants(name, ctx) {
1822
+ if let Some((_, _, _, outer_failure)) = tryOperatorVariants(&outer, ctx) {
1570
- Some((_, success_info, _, _)) => Ok(success_info
1823
+ if let (Some(inner_err), Some(outer_err)) = (
1571
- .field_types
1824
+ failure_info.field_types.first(),
1572
- .first()
1573
- .cloned()
1574
- .unwrap_or(PlumType::TUnit)),
1825
+ outer_failure.field_types.first(),
1826
+ ) {
1827
+ if unify(outer_err, inner_err, ctx).is_err() {
1575
- None => Err(format!(
1828
+ return Err(format!(
1576
- "'?' requires a Result or Option value, found '{}'",
1829
+ "'?' error type {} is not part of the function's error type {}",
1577
- name
1830
+ inner_err, outer_err
1578
- )),
1831
+ ));
1579
- },
1832
+ }
1580
- other => Err(format!(
1581
- "'?' requires a Result or Option value, found {}",
1582
- other
1583
- )),
1833
+ }
1834
+ }
1584
1835
  }
1836
+ Ok(success_info
1837
+ .field_types
1838
+ .first()
1839
+ .cloned()
1840
+ .unwrap_or(PlumType::TUnit))
1585
1841
  }
1586
1842
  ast::Expr::Closure(cl) => {
1587
1843
  let mut closure_env = env.clone();
1844
+ // A `?` inside a closure returns from the closure, not the outer fn.
1845
+ closure_env.remove(RETURN_KEY);
1588
1846
  let param_names: std::collections::HashSet<&str> =
1589
1847
  cl.params.iter().map(|s| s.as_str()).collect();
1590
1848
  let mut resolved_from_usage: BTreeMap<String, PlumType> = BTreeMap::new();
@@ -1657,6 +1915,16 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1657
1915
  ast::Expr::Compare(c) => {
1658
1916
  let lt = inferExpr(&c.left, env, ctx)?;
1659
1917
  let rt = inferExpr(&c.right, env, ctx)?;
1918
+ if let (PlumType::TTags(l), PlumType::TTags(r)) = (&lt, &rt) {
1919
+ // Tag equality compares ids only, so payload tags can't be compared.
1920
+ if l.values().chain(r.values()).any(|p| p.is_some()) {
1921
+ return Err(format!(
1922
+ "compare op: cannot compare tag unions with payload tags ({} vs {})",
1923
+ lt, rt
1924
+ ));
1925
+ }
1926
+ return Ok(PlumType::TBool);
1927
+ }
1660
1928
  unify(&lt, &rt, ctx).map_err(|e| format!("compare op: {}", e))?;
1661
1929
  Ok(PlumType::TBool)
1662
1930
  }
@@ -1668,9 +1936,9 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1668
1936
  unify(&tt, &et, ctx).map_err(|e| format!("ternary branches: {}", e))?;
1669
1937
  Ok(tt)
1670
1938
  }
1671
- // `left ?: right` — unlike `?`, a plain expression (no early return),
1939
+ // `left ?? right` — unlike `?`, a plain expression (no early return),
1672
1940
  // so (unlike `Try` above) it's fully validated here: `right` must
1673
- // unify with `left`'s success (`Ok`/`Some`) field type.
1941
+ // unify with `left`'s success (`Ok`) field type.
1674
1942
  ast::Expr::Elvis(e) => {
1675
1943
  let left_ty = inferExpr(&e.left, env, ctx)?;
1676
1944
  let success_ty = match &left_ty {
@@ -1682,20 +1950,20 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1682
1950
  .unwrap_or(PlumType::TUnit),
1683
1951
  None => {
1684
1952
  return Err(format!(
1685
- "'?:' requires a Result or Option value, found '{}'",
1953
+ "'??' requires a Try value, found '{}'",
1686
1954
  name
1687
1955
  ))
1688
1956
  }
1689
1957
  },
1690
1958
  other => {
1691
1959
  return Err(format!(
1692
- "'?:' requires a Result or Option value, found {}",
1960
+ "'??' requires a Try value, found {}",
1693
1961
  other
1694
1962
  ))
1695
1963
  }
1696
1964
  };
1697
1965
  let right_ty = inferExpr(&e.right, env, ctx)?;
1698
- unify(&success_ty, &right_ty, ctx).map_err(|e| format!("'?:' branches: {}", e))?;
1966
+ unify(&success_ty, &right_ty, ctx).map_err(|e| format!("'??' branches: {}", e))?;
1699
1967
  Ok(success_ty)
1700
1968
  }
1701
1969
  ast::Expr::FnCall(call) => {
@@ -1888,6 +2156,22 @@ pub fn inferExpr(expr: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<Plum
1888
2156
  }
1889
2157
  }
1890
2158
  Ok(_) => Err(format!("'{}' is not a function", call.name)),
2159
+ Err(_) if isTagLiteralName(&call.name, ctx) && call.args.len() <= 1 => {
2160
+ // `Bad(x)` — an undeclared uppercase name applied to one arg is
2161
+ // a tag literal with a payload.
2162
+ let payload = match call.args.first() {
2163
+ Some(arg) => {
2164
+ let arg_expr = match arg {
2165
+ ast::Arg::Positional(e) => e,
2166
+ ast::Arg::Keyword { value, .. } => value,
2167
+ ast::Arg::Pair { value, .. } => value,
2168
+ };
2169
+ Some(Box::new(inferExpr(arg_expr, env, ctx)?))
2170
+ }
2171
+ None => None,
2172
+ };
2173
+ Ok(PlumType::TTags(BTreeMap::from([(call.name.clone(), payload)])))
2174
+ }
1891
2175
  Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
1892
2176
  }
1893
2177
  }
plum-checker/src/monomorphize.rs CHANGED
@@ -151,7 +151,10 @@ fn plumTypeToAstType(t: &PlumType) -> ast::Type {
151
151
  PlumType::TByteSlice => "[]Byte".to_string(),
152
152
  PlumType::TUnit => "Unit".to_string(),
153
153
  PlumType::TNamed(n) => n.clone(),
154
+ PlumType::TVar(_)
155
+ | PlumType::TFun(_, _)
156
+ | PlumType::TVariadic(_)
154
- PlumType::TVar(_) | PlumType::TFun(_, _) | PlumType::TVariadic(_) => t.to_string(),
157
+ | PlumType::TTags(_) => t.to_string(),
155
158
  };
156
159
  ast::Type {
157
160
  name,
plum-checker/src/types.rs CHANGED
@@ -22,6 +22,10 @@ pub enum PlumType {
22
22
  /// a body is as a `for` loop's iterable — no other `unify`/`inferExpr` arm
23
23
  /// handles it, so any other use is a type error by construction.
24
24
  TVariadic(Box<PlumType>),
25
+ /// A structural tag-union type `[A, B(Str)]`: tag name -> optional payload type.
26
+ /// `Display` is the canonical string (sorted, no spaces) the parser also
27
+ /// produces for the type annotation, so mangled names round-trip.
28
+ TTags(BTreeMap<String, Option<Box<PlumType>>>),
25
29
  }
26
30
 
27
31
  impl std::fmt::Display for PlumType {
@@ -41,6 +45,16 @@ impl std::fmt::Display for PlumType {
41
45
  }
42
46
  PlumType::TNamed(n) => write!(f, "{}", n),
43
47
  PlumType::TVariadic(inner) => write!(f, "...{}", inner),
48
+ PlumType::TTags(tags) => {
49
+ let parts: Vec<String> = tags
50
+ .iter()
51
+ .map(|(n, p)| match p {
52
+ Some(t) => format!("{}({})", n, t),
53
+ None => n.clone(),
54
+ })
55
+ .collect();
56
+ write!(f, "[{}]", parts.join(","))
57
+ }
44
58
  }
45
59
  }
46
60
  }
plum-checker/tests/checker_tests.rs CHANGED
@@ -1,6 +1,9 @@
1
1
  #![allow(non_snake_case)]
2
2
  use plum_checker::types::*;
3
+ use plum_checker::{
3
- use plum_checker::{checkSource, plumTypeFromAst, unify, CheckCtx};
4
+ checkSource, erasedEnumName, plumTypeFromAst, plumTypeFromName, tagUnionSegmentsCompatible,
5
+ unify, CheckCtx,
6
+ };
4
7
  use plum_core::ast::Type as AstType;
5
8
  use plum_core::{ast::*, AstParser};
6
9
 
@@ -1299,3 +1302,327 @@ fun bad() -> Int =
1299
1302
  errs
1300
1303
  );
1301
1304
  }
1305
+
1306
+ // ---------------------------------------------------------------------------
1307
+ // Roc-style tag unions + `Try` (T2). Sources define a local `Try` inline.
1308
+ // ---------------------------------------------------------------------------
1309
+
1310
+ /// Like `parse`, but asserts the parse produced no error nodes.
1311
+ fn parseClean(src: &str) -> Source {
1312
+ let mut parser = tree_sitter::Parser::new();
1313
+ parser
1314
+ .set_language(&tree_sitter_plum::LANGUAGE.into())
1315
+ .unwrap();
1316
+ let tree = parser.parse(src, None).unwrap();
1317
+ assert!(
1318
+ !tree.root_node().has_error(),
1319
+ "embedded test source has parse errors:\n{}",
1320
+ src
1321
+ );
1322
+ AstParser::new(src).parseSource(tree.root_node())
1323
+ }
1324
+
1325
+ const TRY_DEF: &str = "\
1326
+ enum Try[T, E] =
1327
+ | Ok(T)
1328
+ | Err(E)
1329
+
1330
+ fun mapErr(self, cb: fn(E) -> F) -> Try[T, F] =
1331
+ match self
1332
+ Ok(v) =>
1333
+ return Ok(v)
1334
+ Err(e) =>
1335
+ return Err(cb(e))
1336
+
1337
+ ";
1338
+
1339
+ fn checkTry(body: &str) -> Result<(), Vec<CheckError>> {
1340
+ checkSource(&parseClean(&format!("{}{}", TRY_DEF, body)))
1341
+ }
1342
+
1343
+ fn errText(r: Result<(), Vec<CheckError>>) -> String {
1344
+ r.unwrap_err()
1345
+ .iter()
1346
+ .map(|e| e.message.clone())
1347
+ .collect::<Vec<_>>()
1348
+ .join(" | ")
1349
+ }
1350
+
1351
+ #[test]
1352
+ fn tagsTypeDisplayAndRoundTrip() {
1353
+ let t = plumTypeFromName("[A,B(Str)]");
1354
+ match &t {
1355
+ PlumType::TTags(m) => {
1356
+ assert_eq!(m.len(), 2);
1357
+ assert!(m["A"].is_none());
1358
+ assert_eq!(m["B"].as_deref(), Some(&PlumType::TStr));
1359
+ }
1360
+ other => panic!("expected TTags, got {:?}", other),
1361
+ }
1362
+ assert_eq!(t.to_string(), "[A,B(Str)]");
1363
+ assert_eq!(plumTypeFromName("[]Byte"), PlumType::TByteSlice);
1364
+ }
1365
+
1366
+ #[test]
1367
+ fn tagsSubsetUnify() {
1368
+ let ctx = emptyCtx();
1369
+ let small = plumTypeFromName("[A]");
1370
+ let big = plumTypeFromName("[A,B(Str)]");
1371
+ assert!(unify(&big, &small, &ctx).is_ok());
1372
+ assert!(unify(&small, &big, &ctx).is_err());
1373
+ assert!(unify(&big, &plumTypeFromName("[B(Int)]"), &ctx).is_err());
1374
+ }
1375
+
1376
+ #[test]
1377
+ fn nested_union_in_payload() {
1378
+ let ctx = emptyCtx();
1379
+ // Brackets inside a payload must not split the outer segment list.
1380
+ assert!(tagUnionSegmentsCompatible(
1381
+ "Try$Int$[A(Try$Int$[X,Y])]",
1382
+ "Try$Int$[A(Try$Int$[X])]",
1383
+ &ctx
1384
+ ));
1385
+ assert!(!tagUnionSegmentsCompatible(
1386
+ "Try$Int$[A(Try$Int$[X])]",
1387
+ "Try$Int$[A(Try$Int$[Z])]",
1388
+ &ctx
1389
+ ));
1390
+ assert!(tagUnionSegmentsCompatible("Try$Int$[A,B]", "Try$Int$[A]", &ctx));
1391
+ assert!(!tagUnionSegmentsCompatible("Try$Int$[A]", "Try$Int$[A,B]", &ctx));
1392
+ // Non-tag segments must match exactly; arity must match.
1393
+ assert!(!tagUnionSegmentsCompatible("Try$Int$[A]", "Try$Str$[A]", &ctx));
1394
+ assert!(!tagUnionSegmentsCompatible("Try$Int$[A]", "Try$Int", &ctx));
1395
+ assert_eq!(
1396
+ erasedEnumName("Try$Int$[A,B(Try$Int$[X])]"),
1397
+ "Try$Int$Tags"
1398
+ );
1399
+ assert_eq!(erasedEnumName("Try$[A]$[B,C]"), "Try$Tags$Tags");
1400
+ assert_eq!(erasedEnumName("List$[]Byte"), "List$[]Byte");
1401
+ }
1402
+
1403
+ #[test]
1404
+ fn tryOperatorVariantsOnlyOkErr() {
1405
+ let src = "\
1406
+ enum Opt =
1407
+ | Some(Int)
1408
+ | None
1409
+ ";
1410
+ let source = parseClean(src);
1411
+ let (_, methods, variants, params) = plum_checker::buildGlobalTables(&source);
1412
+ let min = plum_checker::buildMinRequiredArgs(&source);
1413
+ let ctx = CheckCtx {
1414
+ methods: &methods,
1415
+ enum_variants: &variants,
1416
+ enum_params: &params,
1417
+ min_required: &min,
1418
+ };
1419
+ assert!(plum_checker::tryOperatorVariants("Opt", &ctx).is_none());
1420
+ }
1421
+
1422
+ #[test]
1423
+ fn question_mark_rejects_unlisted_tag() {
1424
+ let r = checkTry(
1425
+ "\
1426
+ fun g() -> Try[Int, [B]] =
1427
+ Err(B)
1428
+
1429
+ fun f() -> Try[Int, [A]] =
1430
+ x := g()?
1431
+ Ok(x)
1432
+ ",
1433
+ );
1434
+ let msg = errText(r);
1435
+ assert!(msg.contains("[B]"), "got: {}", msg);
1436
+ assert!(msg.contains("[A]"), "got: {}", msg);
1437
+ }
1438
+
1439
+ #[test]
1440
+ fn question_mark_widens() {
1441
+ let r = checkTry(
1442
+ "\
1443
+ fun g() -> Try[Int, [A]] =
1444
+ Err(A)
1445
+
1446
+ fun f() -> Try[Int, [A, B]] =
1447
+ x := g()?
1448
+ Ok(x)
1449
+ ",
1450
+ );
1451
+ assert!(r.is_ok(), "got {:?}", r.err());
1452
+ }
1453
+
1454
+ #[test]
1455
+ fn question_mark_different_ok_types_check() {
1456
+ let r = checkTry(
1457
+ "\
1458
+ fun g() -> Try[Int, [A]] =
1459
+ Err(A)
1460
+
1461
+ fun f() -> Try[Str, [A, B]] =
1462
+ x := g()?
1463
+ Ok(\"v\")
1464
+ ",
1465
+ );
1466
+ assert!(r.is_ok(), "got {:?}", r.err());
1467
+ }
1468
+
1469
+ #[test]
1470
+ fn closure_question_mark_no_return_check() {
1471
+ // The closure's own `?` must not be checked against f's `[A]` return union.
1472
+ let r = checkTry(
1473
+ "\
1474
+ fun g() -> Try[Int, [B]] =
1475
+ Err(B)
1476
+
1477
+ fun f() -> Try[Int, [A]] =
1478
+ h := || g()?
1479
+ Ok(1)
1480
+ ",
1481
+ );
1482
+ assert!(r.is_ok(), "got {:?}", r.err());
1483
+ }
1484
+
1485
+ #[test]
1486
+ fn function_return_widens_try_value() {
1487
+ let r = checkTry(
1488
+ "\
1489
+ fun g() -> Try[Int, [A]] =
1490
+ Err(A)
1491
+
1492
+ fun f() -> Try[Int, [A, B(Str)]] =
1493
+ g()
1494
+ ",
1495
+ );
1496
+ assert!(r.is_ok(), "got {:?}", r.err());
1497
+ }
1498
+
1499
+ #[test]
1500
+ fn tag_literal_with_payload_and_wrong_payload() {
1501
+ assert!(checkTry("fun f() -> Try[Int, [Bad(Str)]] =\n Err(Bad(\"x\"))\n").is_ok());
1502
+ assert!(checkTry("fun f() -> Try[Int, [Bad(Str)]] =\n Err(Bad(5))\n").is_err());
1503
+ // Typos are caught by the declared union.
1504
+ assert!(checkTry("fun f() -> Try[Int, [Bad]] =\n Err(Bda)\n").is_err());
1505
+ }
1506
+
1507
+ #[test]
1508
+ fn match_exhaustive_tags() {
1509
+ let ok = checkTry(
1510
+ "\
1511
+ fun f(t: [A, B(Str)]) -> Int =
1512
+ match t
1513
+ A => 1
1514
+ B(s) => 2
1515
+ ",
1516
+ );
1517
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1518
+ let missing = errText(checkTry(
1519
+ "\
1520
+ fun f(t: [A, B(Str)]) -> Int =
1521
+ match t
1522
+ A => 1
1523
+ ",
1524
+ ));
1525
+ assert!(
1526
+ missing.contains("non-exhaustive match on [A,B(Str)]: missing B"),
1527
+ "got: {}",
1528
+ missing
1529
+ );
1530
+ let wild = checkTry(
1531
+ "\
1532
+ fun f(t: [A, B(Str)]) -> Int =
1533
+ match t
1534
+ A => 1
1535
+ _ => 2
1536
+ ",
1537
+ );
1538
+ assert!(wild.is_ok(), "got {:?}", wild.err());
1539
+ let unknown = checkTry(
1540
+ "\
1541
+ fun f(t: [A, B]) -> Int =
1542
+ match t
1543
+ A => 1
1544
+ C => 3
1545
+ _ => 2
1546
+ ",
1547
+ );
1548
+ assert!(unknown.is_err());
1549
+ }
1550
+
1551
+ #[test]
1552
+ fn nested_tag_pattern_in_err() {
1553
+ let r = checkTry(
1554
+ "\
1555
+ fun f(r: Try[Int, [NotFound, Bad(Int)]]) -> Int =
1556
+ match r
1557
+ Ok(v) => v
1558
+ Err(NotFound) => 0
1559
+ Err(Bad(n)) => n
1560
+ ",
1561
+ );
1562
+ assert!(r.is_ok(), "got {:?}", r.err());
1563
+ }
1564
+
1565
+ #[test]
1566
+ fn eq_rejects_payload_tags() {
1567
+ let ok = checkTry("fun f(a: [A, B], b: [A, B]) -> Bool =\n a == b\n");
1568
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1569
+ let lit = checkTry("fun f() -> Bool =\n A == B\n");
1570
+ assert!(lit.is_ok(), "got {:?}", lit.err());
1571
+ let bad = errText(checkTry(
1572
+ "fun f(a: [A, B(Str)], b: [A, B(Str)]) -> Bool =\n a == b\n",
1573
+ ));
1574
+ assert!(bad.contains("payload"), "got: {}", bad);
1575
+ }
1576
+
1577
+ #[test]
1578
+ fn unknown_lowercase_still_error() {
1579
+ let r = checkTry("fun f() -> Int =\n nope\n");
1580
+ assert!(r.is_err());
1581
+ assert!(errText(r).contains("undefined name 'nope'"));
1582
+ }
1583
+
1584
+ #[test]
1585
+ fn crash_requires_str() {
1586
+ assert!(checkTry("fun f() -> Int =\n crash \"boom\"\n").is_ok());
1587
+ let bad = errText(checkTry("fun f() -> Int =\n crash 5\n"));
1588
+ assert!(bad.contains("Str"), "got: {}", bad);
1589
+ }
1590
+
1591
+ #[test]
1592
+ fn elvis_defaults_try_value() {
1593
+ let ok = checkTry(
1594
+ "\
1595
+ fun g() -> Try[Int, [A]] =
1596
+ Err(A)
1597
+
1598
+ fun f() -> Int =
1599
+ g() ?? 7
1600
+ ",
1601
+ );
1602
+ assert!(ok.is_ok(), "got {:?}", ok.err());
1603
+ let bad = checkTry(
1604
+ "\
1605
+ fun g() -> Try[Int, [A]] =
1606
+ Err(A)
1607
+
1608
+ fun f() -> Int =
1609
+ g() ?? \"x\"
1610
+ ",
1611
+ );
1612
+ assert!(bad.is_err());
1613
+ }
1614
+
1615
+ #[test]
1616
+ fn try_closure_maps_error() {
1617
+ let r = checkTry(
1618
+ "\
1619
+ fun g() -> Try[Int, [B]] =
1620
+ Err(B)
1621
+
1622
+ fun f() -> Try[Int, [Mapped]] =
1623
+ x := g() ? |e| Mapped
1624
+ Ok(x)
1625
+ ",
1626
+ );
1627
+ assert!(r.is_ok(), "got {:?}", r.err());
1628
+ }
plum-wasm-codegen/src/lib.rs CHANGED
@@ -649,7 +649,7 @@ fn plumTypeToValtype(t: &PlumType) -> ValType {
649
649
  // where a closure's exact struct type isn't being constructed/destructured
650
650
  // directly (e.g. deciding a local's storage class), matching `TVariadic`'s
651
651
  // existing "not modeled as a concrete shape here" treatment.
652
- PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
652
+ PlumType::TFun(_, _) | PlumType::TVariadic(_) | PlumType::TTags(_) => ValType::Ref(RefType::ANYREF),
653
653
  PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
654
654
  }
655
655
  }
@@ -755,7 +755,7 @@ fn plumTypeToGcValtype(t: &PlumType, registry: &GcTypeRegistry) -> ValType {
755
755
  // TFun (closures) and TVariadic get their own concrete representation once
756
756
  // Task 2 (closures/variadic calls) lands — `anyref` is a safe, valid-but-not-
757
757
  // yet-meaningful placeholder in the meantime, since nothing consumes it yet.
758
- PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
758
+ PlumType::TFun(_, _) | PlumType::TVariadic(_) | PlumType::TTags(_) => ValType::Ref(RefType::ANYREF),
759
759
  PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
760
760
  }
761
761
  }