plum

#treesitter#compiler#wasm

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

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


8a21edd — Peter John 2026-11-01T20:31:34+05:30
T4: checker patterns + narrowing
plum-checker/src/lib.rs CHANGED
@@ -629,7 +629,7 @@ const RETURN_KEY: &str = "$return";
629
629
  const STAR_KEY: &str = "$star";
630
630
  /// `TypeEnv` key prefix marking a name as a top-level function (a local of the
631
631
  /// same name shadows it instead of being a redeclaration).
632
- const GLOBAL_KEY: &str = "$global:";
632
+ pub(crate) const GLOBAL_KEY: &str = "$global:";
633
633
  pub fn plumTypeFromAst(ty: &ast::Type) -> PlumType {
634
634
  if ty.name == ast::ERROR_UNION_TYPE_NAME && !ty.generics.is_empty() {
635
635
  return errUnionFromAst(ty, plumTypeFromAst);
@@ -910,6 +910,8 @@ pub struct EnumVariantInfo {
910
910
  /// The owning enum's generic parameter names (`[K, V]` for `Map`) —
911
911
  /// empty for a non-generic enum and for non-generic uses.
912
912
  pub generics: Vec<String>,
913
+ /// The owning declaration is a `struct` (copy semantics).
914
+ pub is_value: bool,
913
915
  }
914
916
  /// Enum variant name -> its info, e.g. `"Red" -> { enum_name: "Color", tag: 1, field_types: [] }`.
915
917
  pub type EnumVariants = BTreeMap<String, EnumVariantInfo>;
@@ -1002,6 +1004,7 @@ pub fn buildGlobalTables(source: &ast::Source) -> (TypeEnv, MethodEnv, EnumVaria
1002
1004
  field_names,
1003
1005
  values: v.values.clone(),
1004
1006
  generics: enumGenericParams(e),
1007
+ is_value: e.isValue,
1005
1008
  },
1006
1009
  );
1007
1010
  }
@@ -2243,6 +2246,10 @@ fn checkStmtInner(
2243
2246
  }
2244
2247
  ast::AssignTarget::Field(object, field_name) => {
2245
2248
  let label = format!("{}.{}", describeTargetObject(object), field_name);
2249
+ if let Some(msg) = unions::copyWriteError(object, env) {
2250
+ errors.push(CheckError::at(fn_pos.0, fn_pos.1, format!("fn '{}': assign '{}': {}", fn_name, label, msg)));
2251
+ continue;
2252
+ }
2246
2253
  let field_expected = inferExpr(object, env, ctx)
2247
2254
  .ok()
2248
2255
  .and_then(|o| fieldTypeOf(&o, field_name, ctx).ok());
@@ -2451,6 +2458,8 @@ enum CoverFail {
2451
2458
  struct CoverCtor {
2452
2459
  name: String,
2453
2460
  fields: Vec<PlumType>,
2461
+ /// A union member: covered by `Name` or `Name(..)` with irrefutable sub-patterns.
2462
+ member: bool,
2454
2463
  }
2455
2464
 
2456
2465
  static COVER_WILDCARD: ast::CasePattern = ast::CasePattern::Wildcard;
@@ -2486,6 +2495,7 @@ fn coverCtors(ty: &PlumType, ctx: &CheckCtx) -> Option<(String, Vec<CoverCtor>)>
2486
2495
  .map(|n| CoverCtor {
2487
2496
  name: n.to_string(),
2488
2497
  fields: Vec::new(),
2498
+ member: false,
2489
2499
  })
2490
2500
  .collect(),
2491
2501
  )),
@@ -2496,6 +2506,7 @@ fn coverCtors(ty: &PlumType, ctx: &CheckCtx) -> Option<(String, Vec<CoverCtor>)>
2496
2506
  .map(|n| CoverCtor {
2497
2507
  name: n.clone(),
2498
2508
  fields: Vec::new(),
2509
+ member: false,
2499
2510
  })
2500
2511
  .collect(),
2501
2512
  )),
@@ -2512,7 +2523,10 @@ fn coverCtors(ty: &PlumType, ctx: &CheckCtx) -> Option<(String, Vec<CoverCtor>)>
2512
2523
  Some((
2513
2524
  enum_name.to_string(),
2514
2525
  vs.into_iter()
2526
+ .map(|(k, i)| match unions::memberBase(k, i) {
2527
+ Some(base) => CoverCtor { name: base, fields: Vec::new(), member: true },
2515
- .map(|(k, i)| CoverCtor {
2528
+ None => CoverCtor {
2529
+ member: false,
2516
2530
  name: k.to_string(),
2517
2531
  // A discriminant enum's field types are its shared
2518
2532
  // params, which patterns never destructure.
@@ -2521,6 +2535,7 @@ fn coverCtors(ty: &PlumType, ctx: &CheckCtx) -> Option<(String, Vec<CoverCtor>)>
2521
2535
  } else {
2522
2536
  variantFields(i, ty)
2523
2537
  },
2538
+ },
2524
2539
  })
2525
2540
  .collect(),
2526
2541
  ))
@@ -2572,7 +2587,11 @@ fn coverRows<'a>(
2572
2587
  if n != c.name {
2573
2588
  continue;
2574
2589
  }
2590
+ if c.member {
2591
+ if !fs.iter().all(patCatchAll) {
2592
+ continue;
2593
+ }
2575
- if let ast::CasePattern::Name(_) = r[0] {
2594
+ } else if let ast::CasePattern::Name(_) = r[0] {
2576
2595
  // Bare `Circle` against a payload variant: tag check only.
2577
2596
  row.extend(std::iter::repeat(&COVER_WILDCARD).take(c.fields.len()));
2578
2597
  } else if fs.len() == c.fields.len() {
@@ -2661,12 +2680,19 @@ fn checkMatch(
2661
2680
  let mut case_env = env.clone();
2662
2681
  if case.patterns.len() == subject_types.len() {
2663
2682
  for (pat, sty) in case.patterns.iter().zip(subject_types.iter()) {
2683
+ let narrow = if case.patterns.len() == 1 {
2684
+ unions::narrowTarget(&m.subjects[0], pat, sty, env, ctx)
2685
+ } else {
2686
+ None
2687
+ };
2664
2688
  if let Err(msg) = checkPattern(pat, sty, &mut case_env, ctx) {
2665
2689
  errors.push(CheckError::at(
2666
2690
  fn_pos.0,
2667
2691
  fn_pos.1,
2668
2692
  format!("fn '{}': match case: {}", fn_name, msg),
2669
2693
  ));
2694
+ } else {
2695
+ unions::applyNarrow(narrow, case, &mut case_env, ctx);
2670
2696
  }
2671
2697
  }
2672
2698
  } else {
@@ -2878,7 +2904,7 @@ fn inferClassCallRaw(
2878
2904
  /// Checks a single case pattern against the type of the subject it matches, binding any
2879
2905
  /// new names it introduces into `env`. Constructor-payload sub-patterns (`Next(x)`) bind
2880
2906
  /// against that variant's declared field types (see `EnumVariantInfo::field_types`).
2881
- fn checkPattern(
2907
+ pub(crate) fn checkPattern(
2882
2908
  pat: &ast::CasePattern,
2883
2909
  subject_ty: &PlumType,
2884
2910
  env: &mut TypeEnv,
@@ -2897,6 +2923,9 @@ fn checkPattern(
2897
2923
  }
2898
2924
  }
2899
2925
  }
2926
+ if let Some(r) = unions::checkMemberPattern(pat, subject_ty, env, ctx) {
2927
+ return r;
2928
+ }
2900
2929
  match pat {
2901
2930
  ast::CasePattern::Wildcard => Ok(()),
2902
2931
  ast::CasePattern::Int(_) => unify(subject_ty, &PlumType::TInt, ctx),
@@ -3088,10 +3117,11 @@ pub(crate) fn lookupFieldType(
3088
3117
  // codegen will catch a genuine mismatch, exactly as it always has).
3089
3118
  // Distinguish by whether any variant belonging to this enum name
3090
3119
  // declares named fields at all.
3120
+ [] if unions::isUnion(class_name, ctx)
3091
- [] if ctx
3121
+ || ctx
3092
- .enum_variants
3122
+ .enum_variants
3093
- .values()
3123
+ .values()
3094
- .any(|info| info.enum_name == class_name && !info.field_names.is_empty()) =>
3124
+ .any(|info| info.enum_name == class_name && !info.field_names.is_empty()) =>
3095
3125
  {
3096
3126
  Err(format!(
3097
3127
  "no field '{}' on type '{}'",
@@ -3510,7 +3540,11 @@ fn bindCondEnv(cond: &ast::Expr, env: &TypeEnv, ctx: &CheckCtx) -> Result<TypeEn
3510
3540
  };
3511
3541
  benv.insert(n.clone(), TypeScheme::mono(t));
3512
3542
  }
3543
+ pat => {
3544
+ let narrow = unions::narrowTarget(&b.value, pat, &ty, env, ctx);
3513
- pat => checkPattern(pat, &ty, &mut benv, ctx)?,
3545
+ checkPattern(pat, &ty, &mut benv, ctx)?;
3546
+ unions::applyNarrow(narrow, &b.case, &mut benv, ctx);
3547
+ }
3514
3548
  }
3515
3549
  if let Some(g) = &b.case.guard {
3516
3550
  let gt = inferExpr(g, &benv, ctx)?;
plum-checker/src/unions.rs CHANGED
@@ -46,6 +46,11 @@ fn members(enumName: &str, args: &[PlumType], ctx: &CheckCtx) -> Vec<(String, Pl
46
46
  .collect()
47
47
  }
48
48
 
49
+ /// Whether `enumName` has any type members.
50
+ pub fn isUnion(enumName: &str, ctx: &CheckCtx) -> bool {
51
+ !members(enumName, &[], ctx).is_empty()
52
+ }
53
+
49
54
  /// The hidden variant that converts `actual` into the union enum `expected`.
50
55
  pub fn wrapVariantFor(expected: &PlumType, actual: &PlumType, ctx: &CheckCtx) -> Option<String> {
51
56
  if IN_WRAP.with(|c| c.get()) {
@@ -130,3 +135,146 @@ pub fn joinBranches(
130
135
  }
131
136
  Some(Ok(e))
132
137
  }
138
+
139
+ // ---- patterns and narrowing ------------------------------------------------
140
+
141
+ use crate::types::{TypeEnv, TypeScheme};
142
+ use crate::{checkPattern, EnumVariantInfo};
143
+
144
+ thread_local! {
145
+ /// Address of a top-level pattern that narrows (it records a narrowing, not a pattern).
146
+ static NARROW_TOP: Cell<usize> = const { Cell::new(0) };
147
+ }
148
+
149
+ const COPY_KEY: &str = "$union-copy:";
150
+
151
+ /// The member base name of a hidden variant (`Shape__Circle` -> `Circle`).
152
+ pub fn memberBase(variant: &str, info: &EnumVariantInfo) -> Option<String> {
153
+ if info.field_types.len() != 1 || !info.field_names.is_empty() {
154
+ return None;
155
+ }
156
+ variant
157
+ .strip_prefix(&format!("{}__", info.enum_name))
158
+ .map(|b| b.to_string())
159
+ }
160
+
161
+ /// `(hidden variant, member type)` of the member of `subject`'s enum named `name`.
162
+ fn memberNamed(subject: &PlumType, name: &str, ctx: &CheckCtx) -> Option<(String, PlumType)> {
163
+ let (en, args) = enumOf(subject)?;
164
+ let prefix = format!("{en}__");
165
+ members(en, args, ctx)
166
+ .into_iter()
167
+ .find(|(v, _)| v.strip_prefix(&prefix) == Some(name))
168
+ }
169
+
170
+ /// Checks a pattern that names a member of the subject's union enum. `None`
171
+ /// when it does not (the ordinary rules apply).
172
+ pub fn checkMemberPattern(
173
+ pat: &ast::CasePattern,
174
+ subject: &PlumType,
175
+ env: &mut TypeEnv,
176
+ ctx: &CheckCtx,
177
+ ) -> Option<Result<(), String>> {
178
+ let addr = pat as *const ast::CasePattern as usize;
179
+ match pat {
180
+ ast::CasePattern::Name(n) if n.chars().next().is_some_and(|c| c.is_uppercase()) => {
181
+ let (v, _) = memberNamed(subject, n, ctx)?;
182
+ if NARROW_TOP.with(|c| c.get()) != addr {
183
+ plan::recordPattern(addr, v);
184
+ }
185
+ Some(Ok(()))
186
+ }
187
+ ast::CasePattern::Class { name, fields } => {
188
+ let (v, mt) = memberNamed(subject, name, ctx)?;
189
+ plan::recordPattern(addr, v);
190
+ let tys: Vec<PlumType> = match enumOf(&mt) {
191
+ Some((m, _)) => match ctx.enum_variants.get(m) {
192
+ Some(info) if info.enum_name == m => crate::variantFields(info, &mt),
193
+ _ => vec![mt.clone()],
194
+ },
195
+ None => vec![mt.clone()],
196
+ };
197
+ if fields.len() != tys.len() {
198
+ return Some(Err(format!(
199
+ "constructor pattern '{}' expects {} field(s), got {}",
200
+ name,
201
+ tys.len(),
202
+ fields.len()
203
+ )));
204
+ }
205
+ for (f, t) in fields.iter().zip(tys.iter()) {
206
+ if let Err(e) = checkPattern(f, t, env, ctx) {
207
+ return Some(Err(e));
208
+ }
209
+ }
210
+ Some(Ok(()))
211
+ }
212
+ _ => None,
213
+ }
214
+ }
215
+
216
+ pub struct Narrow {
217
+ subject: String,
218
+ variant: String,
219
+ ty: PlumType,
220
+ enumName: String,
221
+ pat: usize,
222
+ }
223
+
224
+ /// A bare member pattern over a local/param/`self` subject narrows it. Marks
225
+ /// the pattern so `checkMemberPattern` does not also record it.
226
+ pub fn narrowTarget(
227
+ subject: &ast::Expr,
228
+ pat: &ast::CasePattern,
229
+ subject_ty: &PlumType,
230
+ env: &TypeEnv,
231
+ ctx: &CheckCtx,
232
+ ) -> Option<Narrow> {
233
+ let ast::CasePattern::Name(n) = pat else { return None };
234
+ let name = match subject {
235
+ ast::Expr::Var(v) if env.contains_key(v) && !env.contains_key(&format!("{}{}", crate::GLOBAL_KEY, v)) => v.clone(),
236
+ ast::Expr::Self_ => "self".to_string(),
237
+ _ => return None,
238
+ };
239
+ let (variant, ty) = memberNamed(subject_ty, n, ctx)?;
240
+ let addr = pat as *const ast::CasePattern as usize;
241
+ NARROW_TOP.with(|c| c.set(addr));
242
+ Some(Narrow { subject: name, variant, ty, enumName: enumOf(subject_ty)?.0.to_string(), pat: addr })
243
+ }
244
+
245
+ /// After a pattern check: records the narrowing and retypes the subject.
246
+ pub fn applyNarrow(n: Option<Narrow>, case: &ast::Case, env: &mut TypeEnv, ctx: &CheckCtx) {
247
+ let Some(n) = n else { return };
248
+ NARROW_TOP.with(|c| {
249
+ if c.get() == n.pat {
250
+ c.set(0)
251
+ }
252
+ });
253
+ plan::recordNarrow(case as *const ast::Case as usize, n.subject.clone(), n.variant.clone());
254
+ let isStruct = enumOf(&n.ty)
255
+ .and_then(|(m, _)| ctx.enum_variants.get(m))
256
+ .is_some_and(|i| i.is_value);
257
+ env.insert(n.subject.clone(), TypeScheme::mono(n.ty.clone()));
258
+ let key = format!("{COPY_KEY}{}", n.subject);
259
+ if isStruct {
260
+ let member = enumOf(&n.ty).map(|x| x.0).unwrap_or("");
261
+ env.insert(key, TypeScheme::mono(PlumType::TNamed(format!("{member}|{}", n.enumName))));
262
+ } else {
263
+ env.remove(&key);
264
+ }
265
+ }
266
+
267
+ /// The error for `subject.field = ..` where `subject` is a narrowed struct copy.
268
+ pub fn copyWriteError(object: &ast::Expr, env: &TypeEnv) -> Option<String> {
269
+ let name = match object {
270
+ ast::Expr::Var(v) => v.as_str(),
271
+ ast::Expr::Self_ => "self",
272
+ _ => return None,
273
+ };
274
+ let s = env.get(&format!("{COPY_KEY}{name}"))?;
275
+ let PlumType::TNamed(t) = &*s.body else { return None };
276
+ let (member, en) = t.split_once('|')?;
277
+ Some(format!(
278
+ "{name} is a copy of the {member} inside {en}; assign it to a local to change it"
279
+ ))
280
+ }
plum-checker/tests/unions_pattern_test.rs ADDED
@@ -0,0 +1,109 @@
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
+ struct Circle(r: Float)
8
+ struct Rectangle(w: Float, h: Float)
9
+ record Pill(len: Float)
10
+ enum Shape = Circle | Rectangle
11
+ enum Cap = Pill | Circle
12
+ enum Jv = JvNull | Float | Int
13
+ ";
14
+
15
+ fn run(body: &str) -> (Result<(), Vec<String>>, UnionPlan) {
16
+ let src = format!("{DEFS}\n{body}\n");
17
+ let mut parser = tree_sitter::Parser::new();
18
+ parser.set_language(&tree_sitter_plum::LANGUAGE.into()).unwrap();
19
+ let tree = parser.parse(&src, None).unwrap();
20
+ assert!(!tree.root_node().has_error(), "parse errors:\n{src}");
21
+ let mut source = AstParser::new(&src).parseSource(tree.root_node());
22
+ let errs = resolveMembers(&mut source);
23
+ assert!(errs.is_empty(), "{errs:?}");
24
+ beginPlan();
25
+ let r = checkSource(&source).map_err(|e| e.into_iter().map(|x| x.message).collect());
26
+ (r, takePlan())
27
+ }
28
+
29
+ fn errs(r: Result<(), Vec<String>>) -> String {
30
+ r.unwrap_err().join(" | ")
31
+ }
32
+
33
+ #[test]
34
+ fn destructure_member() {
35
+ let (r, p) = run("fun f(s: Shape) -> Float =\n match s\n Circle(r) => r\n Rectangle(w, h) => w * h");
36
+ r.unwrap();
37
+ let mut v: Vec<_> = p.patterns.values().cloned().collect();
38
+ v.sort();
39
+ assert_eq!(v, vec!["Shape__Circle", "Shape__Rectangle"]);
40
+ assert!(p.narrowings.is_empty());
41
+ let (r, _) = run("fun take(i: Int) -> Int =\n i\n\nfun f(s: Shape) -> Int =\n match s\n Circle(r) => take(r)\n Rectangle(w, h) => 1");
42
+ assert!(r.is_err(), "r is a Float, not an Int");
43
+ }
44
+
45
+ #[test]
46
+ fn narrow_match() {
47
+ let (r, p) = run("fun f(s: Shape) -> Float =\n match s\n Circle => s.r\n Rectangle => s.w");
48
+ r.unwrap();
49
+ assert_eq!(p.narrowings.len(), 2);
50
+ let mut v: Vec<_> = p.narrowings.values().map(|x| x.1.clone()).collect();
51
+ v.sort();
52
+ assert_eq!(v, vec!["Shape__Circle", "Shape__Rectangle"]);
53
+ assert!(p.narrowings.values().all(|x| x.0 == "s"));
54
+ // after the match, s is a Shape again
55
+ let (r, _) = run("fun f(s: Shape) -> Float =\n match s\n Circle => 1.0\n Rectangle => 2.0\n s.r");
56
+ assert!(r.is_err());
57
+ // outside its arm s has no w
58
+ let (r, _) = run("fun f(s: Shape) -> Float =\n match s\n Circle => s.w\n Rectangle => 2.0");
59
+ assert!(r.is_err());
60
+ }
61
+
62
+ #[test]
63
+ fn narrow_struct_write_error() {
64
+ let (r, _) = run("fun f(s: Shape) -> Int =\n match s\n Circle =>\n s.r = 1.0\n Rectangle => 0\n 0");
65
+ assert!(
66
+ errs(r).contains("s is a copy of the Circle inside Shape; assign it to a local to change it")
67
+ );
68
+ }
69
+
70
+ #[test]
71
+ fn narrow_record_write_ok() {
72
+ let (r, _) = run("fun f(s: Cap) -> Int =\n match s\n Pill =>\n s.len = 1.0\n Circle => 0\n 0");
73
+ r.unwrap();
74
+ }
75
+
76
+ #[test]
77
+ fn exhaustive_members() {
78
+ let (r, _) = run("fun f(s: Shape) -> Float =\n match s\n Circle => 1.0\n Rectangle => 2.0");
79
+ r.unwrap();
80
+ let (r, _) = run("fun f(s: Shape) -> Float =\n match s\n Circle => 1.0");
81
+ let e = errs(r);
82
+ assert!(e.contains("non-exhaustive match on Shape: missing Rectangle"), "{e}");
83
+ let (r, _) = run("fun f(s: Jv) -> Int =\n match s\n JvNull => 0\n Float => 1\n Int => 2");
84
+ r.unwrap();
85
+ }
86
+
87
+ #[test]
88
+ fn expr_subject_no_narrow() {
89
+ let (r, p) = run("fun g() -> Shape =\n Circle(r: 1.0)\n\nfun f() -> Int =\n match g()\n Circle => 1\n Rectangle => 2");
90
+ r.unwrap();
91
+ assert!(p.narrowings.is_empty());
92
+ assert_eq!(p.patterns.len(), 2);
93
+ }
94
+
95
+ #[test]
96
+ fn pattern_if() {
97
+ let (r, p) = run("fun f(s: Shape) -> Float =\n if Circle := s\n return s.r\n 0.0");
98
+ r.unwrap();
99
+ assert_eq!(p.narrowings.len(), 1);
100
+ let (r, _) = run("fun f(s: Shape) -> Float =\n if Circle := s\n return 1.0\n s.r");
101
+ assert!(r.is_err());
102
+ }
103
+
104
+ #[test]
105
+ fn primitive_member_narrows() {
106
+ let (r, p) = run("fun f(j: Jv) -> Float =\n match j\n Float => j + 1.0\n Int => 0.0\n JvNull => 0.0");
107
+ r.unwrap();
108
+ assert_eq!(p.narrowings.len(), 2);
109
+ }