plum

#treesitter#compiler#wasm

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

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


a577529Peter John 2026-09-03T20:39:58+05:30
feat(plum): let match destructure a plain class like an enum variant
examples/match.plum CHANGED
@@ -189,3 +189,38 @@ fun bothOptions(a: Option, b: Option) -> Int =
189
189
 
190
190
  test "multi subject match with generic enum variant runs correctly"
191
191
  expect bothOptions(Some(3), Some(4)) == 7
192
+
193
+ type Point =
194
+ x: Int
195
+ y: Int
196
+
197
+ fun sumPoint(p: Point) -> Int =
198
+ match p
199
+ Point(x, y) => x + y
200
+
201
+ test "match destructures a plain class the same way it destructures an enum variant"
202
+ expect sumPoint(Point(x: 3, y: 4)) == 7
203
+
204
+ fun classifyPoint(p: Point) -> Str =
205
+ match p
206
+ Point(0, 0) => "origin"
207
+ Point(x, 0) => "on x axis"
208
+ Point(_, _) => "elsewhere"
209
+
210
+ test "match on a plain class supports literal/wildcard sub-patterns and case fallthrough"
211
+ expect classifyPoint(Point(x: 0, y: 0)) == "origin"
212
+ expect classifyPoint(Point(x: 5, y: 0)) == "on x axis"
213
+ expect classifyPoint(Point(x: 5, y: 5)) == "elsewhere"
214
+
215
+ enum Shape =
216
+ | Circle[Point]
217
+ | Square[Point]
218
+
219
+ fun shapeMeasure(s: Shape) -> Int =
220
+ match s
221
+ Circle(Point(x, y)) => x + y
222
+ Square(Point(x, y)) => x * y
223
+
224
+ test "a plain class nested inside an enum variant pattern destructures correctly"
225
+ expect shapeMeasure(Circle(Point(x: 3, y: 4))) == 7
226
+ expect shapeMeasure(Square(Point(x: 3, y: 4))) == 12
plum-checker/src/lib.rs CHANGED
@@ -885,13 +885,31 @@ fn checkPattern(pat: &ast::CasePattern, subject_ty: &PlumType, env: &mut TypeEnv
885
885
  }
886
886
  Ok(())
887
887
  }
888
+ // Not an enum variant — a PLAIN CLASS is destructured the same way (see
889
+ // `plum-wasm-codegen`'s matching `compileClassDestructureArm`): every
888
- // Unmodeled/builtin variant: allow, codegen will catch.
890
+ // instance is already "the one variant", so there's no tag to check,
891
+ // just its declared fields (in declaration order) to match against.
889
- None => {
892
+ None => match ctx.classes.get(name) {
890
- for f in fields {
893
+ Some(class_fields) => {
894
+ if fields.len() != class_fields.len() {
895
+ return Err(format!(
896
+ "constructor pattern '{}' expects {} field(s), got {}",
897
+ name, class_fields.len(), fields.len()
898
+ ));
899
+ }
900
+ for (f, (_, fty)) in fields.iter().zip(class_fields.iter()) {
891
- checkPattern(f, &PlumType::TVar("_".to_string()), env, ctx)?;
901
+ checkPattern(f, fty, env, ctx)?;
902
+ }
903
+ Ok(())
892
904
  }
905
+ // Unmodeled/builtin variant or class: allow, codegen will catch.
906
+ None => {
907
+ for f in fields {
908
+ checkPattern(f, &PlumType::TVar("_".to_string()), env, ctx)?;
909
+ }
893
- Ok(())
910
+ Ok(())
894
- }
911
+ }
912
+ },
895
913
  },
896
914
  }
897
915
  }
plum-checker/tests/checker_tests.rs CHANGED
@@ -1032,3 +1032,36 @@ type MyErr(Err) =
1032
1032
  let source = parse(src);
1033
1033
  assert!(checkSource(&source).is_ok(), "expected Ok, got: {:?}", checkSource(&source));
1034
1034
  }
1035
+
1036
+ #[test]
1037
+ fn matchDestructuresPlainClassAndBindsFieldTypes() {
1038
+ let src = "\
1039
+ type Point =
1040
+ x: Int
1041
+ y: Int
1042
+
1043
+ fun sumPoint(p: Point) -> Int =
1044
+ match p
1045
+ Point(x, y) => x + y
1046
+ ";
1047
+ let source = parse(src);
1048
+ assert!(checkSource(&source).is_ok(), "expected Ok, got: {:?}", checkSource(&source));
1049
+ }
1050
+
1051
+ #[test]
1052
+ fn matchClassPatternWrongFieldCountIsError() {
1053
+ let src = "\
1054
+ type Point =
1055
+ x: Int
1056
+ y: Int
1057
+
1058
+ fun sumPoint(p: Point) -> Int =
1059
+ match p
1060
+ Point(x) => x
1061
+ ";
1062
+ let source = parse(src);
1063
+ let result = checkSource(&source);
1064
+ assert!(result.is_err());
1065
+ let errs = result.unwrap_err();
1066
+ assert!(errs.iter().any(|e| e.message.contains("expects 2 field(s), got 1")), "got: {:?}", errs);
1067
+ }
plum-wasm-codegen/src/lib.rs CHANGED
@@ -505,6 +505,16 @@ fn withGcTypes<R>(f: impl FnOnce(&GcTypeRegistry) -> R) -> R {
505
505
  })
506
506
  }
507
507
 
508
+ /// A constructor-pattern name (`nested_class_scratch_types`'s recorded name, or any
509
+ /// other spot a pattern's own concrete GC type index is needed) is EITHER an enum
510
+ /// variant or a plain class — see `compileClassDestructureArm`. Checks both
511
+ /// registries so a scratch local narrows correctly either way.
512
+ fn classOrVariantTypeIdx(r: &GcTypeRegistry, name: &str) -> u32 {
513
+ *r.variant_type_idx.get(name)
514
+ .or_else(|| r.class_type_idx.get(name))
515
+ .unwrap_or_else(|| panic!("internal codegen error: '{}' missing from the GC type registry (neither a variant nor a class)", name))
516
+ }
517
+
508
518
  /// Resolves an `ast::Type`/`ast::ParamType`'s bare name (e.g. from a function
509
519
  /// signature, before any `PlumType`/checker involvement) to its wasm-gc `ValType`.
510
520
  fn astTypeToWasm(name: &str) -> Option<ValType> {
@@ -2496,6 +2506,11 @@ impl<'a> Collector<'a> {
2496
2506
  for (f, fty) in fields.iter().zip(field_types.iter()) {
2497
2507
  self.collectPattern(f, fty);
2498
2508
  }
2509
+ } else if let Some(class_fields) = self.cctx.classes.get(name) {
2510
+ let field_types: Vec<PlumType> = class_fields.iter().map(|(_, t)| t.clone()).collect();
2511
+ for (f, fty) in fields.iter().zip(field_types.iter()) {
2512
+ self.collectPattern(f, fty);
2513
+ }
2499
2514
  }
2500
2515
  }
2501
2516
  _ => {}
@@ -2734,8 +2749,7 @@ fn compileFnBody(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Resu
2734
2749
  // holding the narrowed (`ref.cast`) value to be statically typed as that exact
2735
2750
  // variant, and different slots very likely narrow to different variants.
2736
2751
  for vname in &collector.nested_class_scratch_types {
2737
- let variant_idx = withGcTypes(|r| *r.variant_type_idx.get(vname)
2752
+ let variant_idx = withGcTypes(|r| classOrVariantTypeIdx(r, vname));
2738
- .unwrap_or_else(|| panic!("internal codegen error: variant '{}' missing from the GC type registry", vname)));
2739
2753
  groups.push(gcRef(variant_idx));
2740
2754
  idx += 1;
2741
2755
  }
@@ -3483,7 +3497,11 @@ fn compileCasePositions(
3483
3497
  ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
3484
3498
  ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
3485
3499
  ast::CasePattern::Class { name, fields } => {
3500
+ if ctx.enum_variants.contains_key(name) {
3486
- compileVariantConstructorArm(pat, name, fields, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
3501
+ compileVariantConstructorArm(pat, name, fields, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
3502
+ } else {
3503
+ compileClassDestructureArm(name, fields, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
3504
+ }
3487
3505
  }
3488
3506
  }
3489
3507
  }
@@ -3590,6 +3608,51 @@ fn compileVariantConstructorArm(
3590
3608
  Ok(())
3591
3609
  }
3592
3610
 
3611
+ /// Destructures a PLAIN CLASS constructor pattern (`Point(x, y)` where `Point` is a
3612
+ /// `type`, not an enum variant) — the counterpart to `compileVariantConstructorArm`.
3613
+ /// A class subject has only "the one shape": there's no discriminant to test, and
3614
+ /// the subject's own static type already IS this exact concrete GC struct type (the
3615
+ /// checker already verified `name` matches it), so this always matches — just
3616
+ /// destructure fields straight off `scratch_local` itself, no `ref.test`/`ref.cast`
3617
+ /// narrowing or separate scratch local needed at all.
3618
+ #[allow(clippy::too_many_arguments)]
3619
+ fn compileClassDestructureArm(
3620
+ name: &str,
3621
+ fields: &[ast::CasePattern],
3622
+ subject_vt: ValType,
3623
+ scratch_local: u32,
3624
+ case: &ast::Case,
3625
+ pos: usize,
3626
+ all_subjects: &[(ValType, u32)],
3627
+ rest: &[ast::Case],
3628
+ result_vt: Option<ValType>,
3629
+ exhaustive_fallback: bool,
3630
+ body: &mut Vec<u8>,
3631
+ ctx: &LocalCtx,
3632
+ state: &mut ModuleState,
3633
+ ) -> Result<(), String> {
3634
+ let class_fields = ctx
3635
+ .classes
3636
+ .get(name)
3637
+ .ok_or_else(|| format!("codegen: unknown constructor '{}' (neither an enum variant nor a class)", name))?;
3638
+ if !matches!(subject_vt, ValType::Ref(_)) {
3639
+ return Err(format!("codegen: constructor pattern '{}' against a non-class subject", name));
3640
+ }
3641
+ if fields.len() != class_fields.len() {
3642
+ return Err(format!(
3643
+ "codegen: constructor pattern '{}' expects {} field(s), got {}",
3644
+ name, class_fields.len(), fields.len()
3645
+ ));
3646
+ }
3647
+ let class_type_idx = *ctx.gc_types.class_type_idx.get(name)
3648
+ .ok_or_else(|| format!("codegen: class '{}' missing from the GC type registry", name))?;
3649
+ let field_types: Vec<PlumType> = class_fields.iter().map(|(_, t)| t.clone()).collect();
3650
+ compileFieldPatterns(
3651
+ fields, &field_types, 0, scratch_local, class_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state,
3652
+ &mut |body, state| compileCasePositions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state),
3653
+ )
3654
+ }
3655
+
3593
3656
  /// Checks `fields[fpos..]` (a constructor pattern's own sub-patterns, e.g. the `v` in
3594
3657
  /// `Some(v)`, or — recursively — the `Some(v)` in `Wrap(Some(v))`) against the
3595
3658
  /// already-loaded value in `container_local`, one field at a time. Once every field
@@ -3675,11 +3738,8 @@ fn compileFieldPatterns(
3675
3738
  }
3676
3739
  ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
3677
3740
  ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
3678
- ast::CasePattern::Class { name, fields: inner_fields } => {
3741
+ ast::CasePattern::Class { name, fields: inner_fields } if ctx.enum_variants.contains_key(name) => {
3679
- let info = ctx
3680
- .enum_variants
3681
- .get(name)
3682
- .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
3742
+ let info = ctx.enum_variants.get(name).expect("just checked contains_key");
3683
3743
  if !matches!(plumTypeToValtype(field_ty), ValType::Ref(_)) {
3684
3744
  return Err(format!("codegen: constructor pattern '{}' against a non-enum field", name));
3685
3745
  }
@@ -3716,6 +3776,42 @@ fn compileFieldPatterns(
3716
3776
  Instruction::End.encode(body);
3717
3777
  Ok(())
3718
3778
  }
3779
+ // A plain class nested inside another constructor pattern's fields (see
3780
+ // `compileClassDestructureArm`'s doc comment) — there's only ever "the one
3781
+ // shape" to match, so no `ref.test`/`ref.cast` narrowing is needed; just
3782
+ // load the field into its own local (`compileFieldPatterns` needs a LOCAL
3783
+ // to repeatedly `struct.get` against, not a bare stack value) and recurse.
3784
+ ast::CasePattern::Class { name, fields: inner_fields } => {
3785
+ let class_fields = ctx
3786
+ .classes
3787
+ .get(name)
3788
+ .ok_or_else(|| format!("codegen: unknown constructor '{}' (neither an enum variant nor a class)", name))?;
3789
+ if !matches!(plumTypeToValtype(field_ty), ValType::Ref(_)) {
3790
+ return Err(format!("codegen: constructor pattern '{}' against a non-class field", name));
3791
+ }
3792
+ if inner_fields.len() != class_fields.len() {
3793
+ return Err(format!(
3794
+ "codegen: constructor pattern '{}' expects {} field(s), got {}",
3795
+ name, class_fields.len(), inner_fields.len()
3796
+ ));
3797
+ }
3798
+ let inner_class_type_idx = *ctx.gc_types.class_type_idx.get(name)
3799
+ .ok_or_else(|| format!("codegen: class '{}' missing from the GC type registry", name))?;
3800
+ let key = pat as *const ast::CasePattern as usize;
3801
+ let slot = *ctx
3802
+ .nested_class_scratch
3803
+ .get(&key)
3804
+ .ok_or_else(|| "internal codegen error: missing nested constructor pattern scratch slot".to_string())?;
3805
+ let nested_local = ctx.nested_class_scratch_base + slot;
3806
+ let inner_field_types: Vec<PlumType> = class_fields.iter().map(|(_, t)| t.clone()).collect();
3807
+
3808
+ Instruction::LocalGet(container_local).encode(body);
3809
+ Instruction::StructGet { struct_type_index: container_type_idx, field_index: fpos as u32 }.encode(body);
3810
+ Instruction::LocalSet(nested_local).encode(body);
3811
+ compileFieldPatterns(inner_fields, &inner_field_types, 0, nested_local, inner_class_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, &mut |body, state| {
3812
+ compileFieldPatterns(fields, field_types, fpos + 1, container_local, container_type_idx, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
3813
+ })
3814
+ }
3719
3815
  }
3720
3816
  }
3721
3817
 
@@ -4494,8 +4590,7 @@ fn compileClosureBody(
4494
4590
  // holding the narrowed (`ref.cast`) value to be statically typed as that exact
4495
4591
  // variant, and different slots very likely narrow to different variants.
4496
4592
  for vname in &collector.nested_class_scratch_types {
4497
- let variant_idx = withGcTypes(|r| *r.variant_type_idx.get(vname)
4593
+ let variant_idx = withGcTypes(|r| classOrVariantTypeIdx(r, vname));
4498
- .unwrap_or_else(|| panic!("internal codegen error: variant '{}' missing from the GC type registry", vname)));
4499
4594
  groups.push(gcRef(variant_idx));
4500
4595
  idx += 1;
4501
4596
  }