plum

#treesitter#compiler#wasm

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

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


29ad747 — Peter John 2026-10-25T16:06:45+05:30
feat(codegen): $Tag runtime representation, tag patterns, ? rewrap, crash import
plum-wasm-codegen/src/lib.rs CHANGED
@@ -454,6 +454,12 @@ struct LocalCtx<'a> {
454
454
  /// through to the next statement instead of skipping/exiting — see the
455
455
  /// `plum_compiler_bugs_found_2026_08` memory.
456
456
  loop_extra_depth: Cell<u32>,
457
+ /// The declared return type of the function being compiled (`None` inside a
458
+ /// closure body) — `?` builds the OUTER function's `Err` variant from it.
459
+ ret_type: Option<PlumType>,
460
+ /// Match-subject scratch local -> its tag-union type, for subjects whose
461
+ /// static type is a `TTags` (their `Name`/`Class` arms test `$Tag.id`).
462
+ match_subject_types: RefCell<HashMap<u32, PlumType>>,
457
463
  }
458
464
 
459
465
  fn fnKey(f: &ast::Fn) -> String {
@@ -517,6 +523,138 @@ thread_local! {
517
523
  static CURRENT_CONSTS: RefCell<HashMap<String, ast::Expr>> = RefCell::new(HashMap::new());
518
524
  }
519
525
 
526
+ thread_local! {
527
+ /// Tag name -> runtime `$Tag.id`. Seeded (sorted) from every tag appearing in
528
+ /// the program's signatures/variants at the top of `compileSourceInner`;
529
+ /// `tagId` appends any stray tag literal never mentioned in a type.
530
+ static CURRENT_TAG_IDS: RefCell<std::collections::BTreeMap<String, i32>> =
531
+ const { RefCell::new(std::collections::BTreeMap::new()) };
532
+ }
533
+
534
+ /// Import key of the `plum::crash(Str)` host function in `func_ids`.
535
+ const CRASH_FN_KEY: &str = "__crash";
536
+
537
+ fn tagId(name: &str) -> i32 {
538
+ CURRENT_TAG_IDS.with(|c| {
539
+ let mut m = c.borrow_mut();
540
+ if let Some(id) = m.get(name) {
541
+ return *id;
542
+ }
543
+ let id = m.len() as i32;
544
+ m.insert(name.to_string(), id);
545
+ id
546
+ })
547
+ }
548
+
549
+ /// Collects every tag name inside every `[...]` tag-union segment of `s`
550
+ /// (a canonical type or mangled name), recursing into payload types.
551
+ fn collectTagNames(s: &str, out: &mut std::collections::BTreeSet<String>) {
552
+ let chars: Vec<char> = s.chars().collect();
553
+ let mut i = 0;
554
+ while i < chars.len() {
555
+ if chars[i] == '[' && chars.get(i + 1) != Some(&']') {
556
+ let start = i;
557
+ let mut depth = 0;
558
+ while i < chars.len() {
559
+ match chars[i] {
560
+ '[' => depth += 1,
561
+ ']' => depth -= 1,
562
+ _ => {}
563
+ }
564
+ i += 1;
565
+ if depth == 0 {
566
+ break;
567
+ }
568
+ }
569
+ let inner: String = chars[start + 1..i.saturating_sub(1)].iter().collect();
570
+ let mut item = String::new();
571
+ let mut d = 0;
572
+ let mut items = Vec::new();
573
+ for ch in inner.chars() {
574
+ match ch {
575
+ '(' | '[' => d += 1,
576
+ ')' | ']' => d -= 1,
577
+ _ => {}
578
+ }
579
+ if ch == ',' && d == 0 {
580
+ items.push(std::mem::take(&mut item));
581
+ } else {
582
+ item.push(ch);
583
+ }
584
+ }
585
+ items.push(item);
586
+ for it in items {
587
+ let name = it.split('(').next().unwrap_or("").trim().to_string();
588
+ if !name.is_empty() {
589
+ out.insert(name);
590
+ }
591
+ if let Some(pos) = it.find('(') {
592
+ collectTagNames(&it[pos + 1..], out);
593
+ }
594
+ }
595
+ } else {
596
+ i += 1;
597
+ }
598
+ }
599
+ }
600
+
601
+ /// Pushes the boxed form of the payload value on top of the stack (typed `ty`)
602
+ /// as an `anyref`: `Int`/`Float`/`Byte` get a `$BoxI64`/`$BoxF64`/`$BoxI32`,
603
+ /// reference values are stored as-is.
604
+ fn boxTagPayload(ty: &PlumType, body: &mut Vec<u8>, ctx: &LocalCtx) {
605
+ match plumTypeToValtype(ty) {
606
+ ValType::I64 => Instruction::StructNew(ctx.gc_types.box_i64_idx).encode(body),
607
+ ValType::F64 => Instruction::StructNew(ctx.gc_types.box_f64_idx).encode(body),
608
+ ValType::I32 => Instruction::StructNew(ctx.gc_types.box_i32_idx).encode(body),
609
+ _ => {}
610
+ }
611
+ }
612
+
613
+ /// Inverse of `boxTagPayload`: an `anyref` payload on the stack becomes a `ty` value.
614
+ fn unboxTagPayload(ty: &PlumType, body: &mut Vec<u8>, ctx: &LocalCtx) {
615
+ let unbox = |idx: u32, body: &mut Vec<u8>| {
616
+ Instruction::RefCastNonNull(HeapType::Concrete(idx)).encode(body);
617
+ Instruction::StructGet {
618
+ struct_type_index: idx,
619
+ field_index: 0,
620
+ }
621
+ .encode(body);
622
+ };
623
+ match plumTypeToValtype(ty) {
624
+ ValType::I64 => unbox(ctx.gc_types.box_i64_idx, body),
625
+ ValType::F64 => unbox(ctx.gc_types.box_f64_idx, body),
626
+ ValType::I32 => unbox(ctx.gc_types.box_i32_idx, body),
627
+ ValType::Ref(rt) => {
628
+ if let HeapType::Concrete(_) = rt.heap_type {
629
+ Instruction::RefCastNullable(rt.heap_type).encode(body);
630
+ }
631
+ }
632
+ _ => {}
633
+ }
634
+ }
635
+
636
+ /// Builds a tag value (`NotFound`, `Bad(x)`): `id` plus the optional payload,
637
+ /// boxed by `boxTagPayload`, as a `$Tag` struct.
638
+ fn compileTagLiteral(
639
+ name: &str,
640
+ payload: Option<&ast::Expr>,
641
+ body: &mut Vec<u8>,
642
+ ctx: &LocalCtx,
643
+ state: &mut ModuleState,
644
+ ) -> Result<(), String> {
645
+ Instruction::I32Const(tagId(name)).encode(body);
646
+ match payload {
647
+ Some(e) => {
648
+ let ty = inferLocalType(e, ctx);
649
+ compileExpr(e, body, ctx, state)?;
650
+ boxTagPayload(&ty, body, ctx);
651
+ }
652
+ None => Instruction::RefNull(RefType::ANYREF.heap_type).encode(body),
653
+ }
654
+ Instruction::StructNew(ctx.gc_types.tag_type_idx).encode(body);
655
+ Ok(())
656
+ }
657
+
520
658
  fn withGcTypes<R>(f: impl FnOnce(&GcTypeRegistry) -> R) -> R {
521
659
  CURRENT_GC_TYPES.with(|c| {
522
660
  let borrow = c.borrow();
@@ -570,6 +708,10 @@ fn astTypeToWasm(name: &str) -> Option<ValType> {
570
708
  // this name, orphaned/unused) class struct type instead of the shared
571
709
  // `array<i8>` ref every `[]Byte` value actually is.
572
710
  "ByteSlice" => Some(plumTypeToValtype(&PlumType::TByteSlice)),
711
+ // A structural tag union `[A,B(Str)]` (but not the `[]Byte` slice spelling).
712
+ other if other.starts_with('[') && !other.starts_with("[]") => {
713
+ Some(withGcTypes(|r| gcRef(r.tag_type_idx)))
714
+ }
573
715
  other => Some(plumTypeToValtype(&PlumType::TNamed(other.to_string()))),
574
716
  }
575
717
  }
@@ -649,7 +791,8 @@ fn plumTypeToValtype(t: &PlumType) -> ValType {
649
791
  // where a closure's exact struct type isn't being constructed/destructured
650
792
  // directly (e.g. deciding a local's storage class), matching `TVariadic`'s
651
793
  // existing "not modeled as a concrete shape here" treatment.
794
+ PlumType::TTags(_) => withGcTypes(|r| gcRef(r.tag_type_idx)),
652
- PlumType::TFun(_, _) | PlumType::TVariadic(_) | PlumType::TTags(_) => ValType::Ref(RefType::ANYREF),
795
+ PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
653
796
  PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
654
797
  }
655
798
  }
@@ -689,6 +832,13 @@ pub struct GcTypeRegistry {
689
832
  /// harmlessly never read as such) until/unless the program actually
690
833
  /// instantiates `Array` at least once.
691
834
  pub array_type_idx: u32,
835
+ /// The single `$Tag {id: i32, payload: anyref}` struct every structural
836
+ /// tag-union value (`NotFound`, `Bad(x)`) is represented as.
837
+ pub tag_type_idx: u32,
838
+ /// Boxes for non-reference tag payloads (`Int`/`Float`/`Byte`).
839
+ pub box_i64_idx: u32,
840
+ pub box_f64_idx: u32,
841
+ pub box_i32_idx: u32,
692
842
  }
693
843
 
694
844
  /// Whether `name` is `Array` itself or one of its monomorphized specializations
@@ -755,7 +905,8 @@ fn plumTypeToGcValtype(t: &PlumType, registry: &GcTypeRegistry) -> ValType {
755
905
  // TFun (closures) and TVariadic get their own concrete representation once
756
906
  // Task 2 (closures/variadic calls) lands — `anyref` is a safe, valid-but-not-
757
907
  // yet-meaningful placeholder in the meantime, since nothing consumes it yet.
908
+ PlumType::TTags(_) => gcRef(registry.tag_type_idx),
758
- PlumType::TFun(_, _) | PlumType::TVariadic(_) | PlumType::TTags(_) => ValType::Ref(RefType::ANYREF),
909
+ PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::Ref(RefType::ANYREF),
759
910
  PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
760
911
  }
761
912
  }
@@ -781,6 +932,10 @@ fn buildGcTypeRegistry(
781
932
  /// The single shared `array<anyref>` type every `Array[T]`
782
933
  /// specialization aliases — see `isArraySpecialization`.
783
934
  ArrayRef,
935
+ Tag,
936
+ BoxI64,
937
+ BoxF64,
938
+ BoxI32,
784
939
  }
785
940
 
786
941
  // Pass 1: assign every entry a slot (and therefore a type index) up front, before
@@ -790,6 +945,8 @@ fn buildGcTypeRegistry(
790
945
  let mut enum_super_type_idx: HashMap<String, u32> = HashMap::new();
791
946
  let mut variant_type_idx: HashMap<String, u32> = HashMap::new();
792
947
  let mut array_ref_slot: Option<u32> = None;
948
+ let mut erased_enum_super: HashMap<String, u32> = HashMap::new();
949
+ let mut erased_variant_idx: HashMap<String, u32> = HashMap::new();
793
950
 
794
951
  // `Bool` is an ordinary enum now (`type Bool = | True | False` in
795
952
  // `libs/std/Bool.plum`) — registered exactly like any other enum
@@ -824,10 +981,31 @@ fn buildGcTypeRegistry(
824
981
  }
825
982
  continue;
826
983
  }
984
+ // Specializations that differ only in their tag-union segments
985
+ // (`Try$Int$[A]` / `Try$Int$[A,B]`) share ONE set of wasm types — every
986
+ // tag value is the same `$Tag` struct — so a later one is aliased onto
987
+ // the first one's slots instead of getting its own.
988
+ let erased_enum = plum_checker::erasedEnumName(enum_name);
989
+ if let Some(existing_super) = erased_enum_super.get(&erased_enum) {
990
+ enum_super_type_idx.insert(enum_name.clone(), *existing_super);
991
+ for vname in variant_names {
992
+ let ev = plum_checker::erasedEnumName(vname);
993
+ if let Some(idx) = erased_variant_idx.get(&ev) {
994
+ variant_type_idx.insert(vname.clone(), *idx);
995
+ continue;
996
+ }
997
+ variant_type_idx.insert(vname.clone(), slots.len() as u32);
998
+ erased_variant_idx.insert(ev, slots.len() as u32);
999
+ slots.push(Slot::Variant(vname.clone()));
1000
+ }
1001
+ continue;
1002
+ }
827
1003
  enum_super_type_idx.insert(enum_name.clone(), slots.len() as u32);
1004
+ erased_enum_super.insert(erased_enum, slots.len() as u32);
828
1005
  slots.push(Slot::EnumSuper(enum_name.clone()));
829
1006
  for vname in variant_names {
830
1007
  variant_type_idx.insert(vname.clone(), slots.len() as u32);
1008
+ erased_variant_idx.insert(plum_checker::erasedEnumName(vname), slots.len() as u32);
831
1009
  slots.push(Slot::Variant(vname.clone()));
832
1010
  }
833
1011
  // A RECORD-shaped enum (the `type X = ...` replacement, e.g. `enum Cat =
@@ -842,6 +1020,15 @@ fn buildGcTypeRegistry(
842
1020
  }
843
1021
  }
844
1022
 
1023
+ let tag_type_idx = slots.len() as u32;
1024
+ slots.push(Slot::Tag);
1025
+ let box_i64_idx = slots.len() as u32;
1026
+ slots.push(Slot::BoxI64);
1027
+ let box_f64_idx = slots.len() as u32;
1028
+ slots.push(Slot::BoxF64);
1029
+ let box_i32_idx = slots.len() as u32;
1030
+ slots.push(Slot::BoxI32);
1031
+
845
1032
  // The registry is fully index-complete after pass 1 (every name has an assigned
846
1033
  // slot) even though no field lists exist yet — safe to hand to `plumTypeToGcValtype`
847
1034
  // for pass 2's field-type resolution.
@@ -853,6 +1040,10 @@ fn buildGcTypeRegistry(
853
1040
  closure_type_idx: 0,
854
1041
  variadic_array_type_idx: HashMap::new(),
855
1042
  array_type_idx: array_ref_slot.unwrap_or(0),
1043
+ tag_type_idx,
1044
+ box_i64_idx,
1045
+ box_f64_idx,
1046
+ box_i32_idx,
856
1047
  };
857
1048
 
858
1049
  // Pass 2: build the real SubType for every slot, now that every cross-reference
@@ -939,6 +1130,29 @@ fn buildGcTypeRegistry(
939
1130
  },
940
1131
  }
941
1132
  }
1133
+ Slot::Tag => SubType {
1134
+ is_final: true,
1135
+ supertype_idx: None,
1136
+ composite_type: CompositeType {
1137
+ inner: CompositeInnerType::Struct(StructType {
1138
+ fields: vec![
1139
+ FieldType {
1140
+ element_type: StorageType::Val(ValType::I32),
1141
+ mutable: false,
1142
+ },
1143
+ FieldType {
1144
+ element_type: StorageType::Val(ValType::Ref(RefType::ANYREF)),
1145
+ mutable: false,
1146
+ },
1147
+ ]
1148
+ .into(),
1149
+ }),
1150
+ shared: false,
1151
+ },
1152
+ },
1153
+ Slot::BoxI64 => boxSubType(ValType::I64),
1154
+ Slot::BoxF64 => boxSubType(ValType::F64),
1155
+ Slot::BoxI32 => boxSubType(ValType::I32),
942
1156
  Slot::ArrayRef => SubType {
943
1157
  is_final: true,
944
1158
  supertype_idx: None,
@@ -958,6 +1172,23 @@ fn buildGcTypeRegistry(
958
1172
  registry
959
1173
  }
960
1174
 
1175
+ fn boxSubType(vt: ValType) -> SubType {
1176
+ SubType {
1177
+ is_final: true,
1178
+ supertype_idx: None,
1179
+ composite_type: CompositeType {
1180
+ inner: CompositeInnerType::Struct(StructType {
1181
+ fields: vec![FieldType {
1182
+ element_type: StorageType::Val(vt),
1183
+ mutable: false,
1184
+ }]
1185
+ .into(),
1186
+ }),
1187
+ shared: false,
1188
+ },
1189
+ }
1190
+ }
1191
+
961
1192
  /// Maps an `ast::ParamType::Fn(params, ret)` to the wasm signature of the *closure
962
1193
  /// function* it compiles to: an implicit leading `env: anyref`, then one param per
963
1194
  /// declared param type, returning `ret`'s val type (or nothing for `Unit`).
@@ -1232,6 +1463,31 @@ fn compileSourceInner(
1232
1463
  let (global_env, methods, enum_variants, enum_params) = plum_checker::buildGlobalTables(source);
1233
1464
  let min_required = plum_checker::buildMinRequiredArgs(source);
1234
1465
 
1466
+ // Intern every tag name the program's types mention, in sorted order.
1467
+ {
1468
+ let mut names = std::collections::BTreeSet::new();
1469
+ for (k, info) in &enum_variants {
1470
+ collectTagNames(k, &mut names);
1471
+ collectTagNames(&info.enum_name, &mut names);
1472
+ for t in &info.field_types {
1473
+ collectTagNames(&t.to_string(), &mut names);
1474
+ }
1475
+ }
1476
+ for scheme in global_env.values() {
1477
+ collectTagNames(&scheme.body.to_string(), &mut names);
1478
+ }
1479
+ for t in methods.values() {
1480
+ collectTagNames(&t.to_string(), &mut names);
1481
+ }
1482
+ CURRENT_TAG_IDS.with(|c| {
1483
+ *c.borrow_mut() = names
1484
+ .into_iter()
1485
+ .enumerate()
1486
+ .map(|(i, n)| (n, i as i32))
1487
+ .collect();
1488
+ });
1489
+ }
1490
+
1235
1491
  let mut module = WasmModule::new();
1236
1492
 
1237
1493
  let mut gc_types = buildGcTypeRegistry(&mut module, source, &enum_variants, &enum_params);
@@ -1317,6 +1573,21 @@ fn compileSourceInner(
1317
1573
  }
1318
1574
  }
1319
1575
 
1576
+ // `plum::crash(Str)`: same wasm type as `printLn`; imported only when the
1577
+ // program actually contains a `crash` statement.
1578
+ {
1579
+ let dbg = format!("{:?}", source.items);
1580
+ let uses_crash = dbg
1581
+ .match_indices("Crash(")
1582
+ .any(|(i, _)| i == 0 || matches!(dbg.as_bytes()[i - 1], b'[' | b'(' | b' '));
1583
+ if uses_crash {
1584
+ let str_vt = plumTypeToValtype(&PlumType::TStr);
1585
+ let type_idx = module.addType(&[str_vt], &[]);
1586
+ let func_idx = module.addImport("plum", "crash", type_idx);
1587
+ func_ids.insert(CRASH_FN_KEY.to_string(), func_idx);
1588
+ }
1589
+ }
1590
+
1320
1591
  // Pre-allocate one instance of every payload-free variant (True/False/None/...)
1321
1592
  // as a global, populated once by a `start` function rather than reconstructed on
1322
1593
  // every reference — see this migration plan's Decision 2. `struct.new` isn't
@@ -3094,6 +3365,18 @@ impl<'a> Collector<'a> {
3094
3365
  /// sub-pattern found *nested* inside another constructor pattern's fields (the
3095
3366
  /// outermost, per-subject pattern doesn't need one — see `nested_class_scratch`).
3096
3367
  fn collectPattern(&mut self, pat: &ast::CasePattern, ty: &PlumType) {
3368
+ // Tag-union subject/field: uppercase names are tags (no binding, no
3369
+ // narrowing scratch slot); a tag's payload sub-pattern binds to its type.
3370
+ if let PlumType::TTags(tags) = ty {
3371
+ if isTagPattern(pat) {
3372
+ if let ast::CasePattern::Class { name, fields } = pat {
3373
+ if let (Some(Some(pty)), Some(f)) = (tags.get(name), fields.first()) {
3374
+ self.collectPattern(f, pty);
3375
+ }
3376
+ }
3377
+ return;
3378
+ }
3379
+ }
3097
3380
  match pat {
3098
3381
  ast::CasePattern::Name(n) => {
3099
3382
  let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
@@ -3473,6 +3756,8 @@ fn compileFnBody(
3473
3756
  type_env: RefCell::new(base_env),
3474
3757
  closure_local_sigs: RefCell::new(HashMap::new()),
3475
3758
  loop_extra_depth: Cell::new(0),
3759
+ ret_type: f.returns.as_ref().map(|r| plum_checker::plumTypeFromAst(r)),
3760
+ match_subject_types: RefCell::new(HashMap::new()),
3476
3761
  };
3477
3762
 
3478
3763
  let result_vt = retTypeToWasm(f.returns.as_ref());
@@ -3561,7 +3846,9 @@ fn compileStmtInValuePosition(
3561
3846
  ) -> Result<(), String> {
3562
3847
  match stmt {
3563
3848
  ast::Stmt::Expr(e, ..) => compileExpr(e, body, ctx, state),
3564
- ast::Stmt::Return(_) | ast::Stmt::Todo => compileStmt(stmt, body, ctx, state),
3849
+ ast::Stmt::Return(_) | ast::Stmt::Todo | ast::Stmt::Crash(_) => {
3850
+ compileStmt(stmt, body, ctx, state)
3851
+ }
3565
3852
  ast::Stmt::If(if_) => compileIf(if_, Some(result_vt), body, ctx, state),
3566
3853
  ast::Stmt::Match(m) => compileMatch(m, body, ctx, state, Some(result_vt)),
3567
3854
  _ => Err(
@@ -3993,8 +4280,18 @@ fn compileStmt(
3993
4280
  Instruction::Unreachable.encode(body);
3994
4281
  Instruction::End.encode(body);
3995
4282
  }
3996
- // Placeholder until T3 implements the `plum::crash` import.
4283
+ // `crash msg` — hand the message to the host's `plum::crash(Str)`
4284
+ // import (registered only when the program uses `crash`, see
4285
+ // `CRASH_FN_KEY`), then trap.
4286
+ ast::Stmt::Crash(msg) => {
4287
+ compileExpr(msg, body, ctx, state)?;
4288
+ let crash_idx = *ctx
4289
+ .func_ids
4290
+ .get(CRASH_FN_KEY)
4291
+ .ok_or_else(|| "internal codegen error: crash import missing".to_string())?;
4292
+ Instruction::Call(crash_idx).encode(body);
3997
- ast::Stmt::Crash(_) => Instruction::Unreachable.encode(body),
4293
+ Instruction::Unreachable.encode(body);
4294
+ }
3998
4295
  ast::Stmt::Todo => {
3999
4296
  // Marks an unimplemented body — trap rather than silently continuing.
4000
4297
  Instruction::Unreachable.encode(body);
@@ -4131,6 +4428,83 @@ fn matchCoversEveryEnumVariant(
4131
4428
  !tuples_seen.is_empty() && tuples_seen.len() == total_combinations
4132
4429
  }
4133
4430
 
4431
+ /// An uppercase `Name`/`Class` pattern — against a tag-union subject, a tag arm.
4432
+ fn isTagPattern(pat: &ast::CasePattern) -> bool {
4433
+ let upper = |n: &str| n.chars().next().is_some_and(|c| c.is_uppercase());
4434
+ match pat {
4435
+ ast::CasePattern::Name(n) => upper(n),
4436
+ ast::CasePattern::Class { name, .. } => upper(name),
4437
+ _ => false,
4438
+ }
4439
+ }
4440
+
4441
+ /// Compiles one tag arm (`A =>` / `B(s) =>`) against a `$Tag` value that
4442
+ /// `load` pushes: tests `id`, binds/unboxes the payload, then runs `on_match`,
4443
+ /// else `on_fail`.
4444
+ #[allow(clippy::too_many_arguments)]
4445
+ fn compileTagPattern(
4446
+ pat: &ast::CasePattern,
4447
+ tags: &std::collections::BTreeMap<String, Option<Box<PlumType>>>,
4448
+ load: &dyn Fn(&mut Vec<u8>),
4449
+ result_vt: Option<ValType>,
4450
+ body: &mut Vec<u8>,
4451
+ ctx: &LocalCtx,
4452
+ state: &mut ModuleState,
4453
+ on_match: &mut dyn FnMut(&mut Vec<u8>, &mut ModuleState) -> Result<(), String>,
4454
+ on_fail: &mut dyn FnMut(&mut Vec<u8>, &mut ModuleState) -> Result<(), String>,
4455
+ ) -> Result<(), String> {
4456
+ let (name, sub) = match pat {
4457
+ ast::CasePattern::Name(n) => (n.as_str(), None),
4458
+ ast::CasePattern::Class { name, fields } => (name.as_str(), fields.first()),
4459
+ _ => unreachable!("isTagPattern checked"),
4460
+ };
4461
+ let payload_ty = tags
4462
+ .get(name)
4463
+ .ok_or_else(|| format!("codegen: tag '{}' is not part of the matched tag union", name))?
4464
+ .as_ref()
4465
+ .map(|b| (**b).clone());
4466
+ let tag_idx = ctx.gc_types.tag_type_idx;
4467
+ load(body);
4468
+ Instruction::StructGet {
4469
+ struct_type_index: tag_idx,
4470
+ field_index: 0,
4471
+ }
4472
+ .encode(body);
4473
+ Instruction::I32Const(tagId(name)).encode(body);
4474
+ Instruction::I32Eq.encode(body);
4475
+ Instruction::If(blockTypeFor(result_vt)).encode(body);
4476
+ match (sub, &payload_ty) {
4477
+ (None, _) | (Some(ast::CasePattern::Wildcard), _) => {}
4478
+ (Some(ast::CasePattern::Name(n)), Some(pty)) if !isTagPattern(&ast::CasePattern::Name(n.clone())) => {
4479
+ let idx = ctx.locals.get(n).copied().ok_or_else(|| {
4480
+ format!("internal codegen error: missing binding local '{}'", n)
4481
+ })?;
4482
+ load(body);
4483
+ Instruction::StructGet {
4484
+ struct_type_index: tag_idx,
4485
+ field_index: 1,
4486
+ }
4487
+ .encode(body);
4488
+ unboxTagPayload(pty, body, ctx);
4489
+ Instruction::LocalSet(idx).encode(body);
4490
+ ctx.type_env
4491
+ .borrow_mut()
4492
+ .insert(n.clone(), TypeScheme::mono(pty.clone()));
4493
+ }
4494
+ (Some(_), _) => {
4495
+ return Err(format!(
4496
+ "codegen: unsupported pattern inside tag '{}' payload (use a name or '_')",
4497
+ name
4498
+ ))
4499
+ }
4500
+ }
4501
+ on_match(body, state)?;
4502
+ Instruction::Else.encode(body);
4503
+ on_fail(body, state)?;
4504
+ Instruction::End.encode(body);
4505
+ Ok(())
4506
+ }
4507
+
4134
4508
  fn compileMatch(
4135
4509
  m: &ast::Match,
4136
4510
  body: &mut Vec<u8>,
@@ -4151,6 +4525,11 @@ fn compileMatch(
4151
4525
  let subject_ty = inferLocalType(subject, ctx);
4152
4526
  let subject_vt = plumTypeToValtype(&subject_ty);
4153
4527
  let scratch_local = ctx.match_scratch_base + base_slot + i as u32;
4528
+ if matches!(subject_ty, PlumType::TTags(_)) {
4529
+ ctx.match_subject_types
4530
+ .borrow_mut()
4531
+ .insert(scratch_local, subject_ty.clone());
4532
+ }
4154
4533
  compileExpr(subject, body, ctx, state)?;
4155
4534
  Instruction::LocalSet(scratch_local).encode(body);
4156
4535
  all_subjects.push((subject_vt, scratch_local));
@@ -4166,8 +4545,14 @@ fn compileMatch(
4166
4545
  // would reallocate every nested `CasePattern::Class` node at a new address and
4167
4546
  // break `nested_class_scratch`'s pointer-identity-keyed lookup.
4168
4547
  let subject_vts: Vec<ValType> = all_subjects.iter().map(|(vt, _)| *vt).collect();
4548
+ // A match over a tag union with no catch-all was proven exhaustive by the checker.
4549
+ let tag_union_match = m.subjects.len() == 1
4550
+ && ctx
4551
+ .match_subject_types
4552
+ .borrow()
4553
+ .contains_key(&all_subjects[0].1);
4169
- let exhaustive_fallback =
4554
+ let exhaustive_fallback = result_vt.is_some()
4170
- result_vt.is_some() && matchCoversEveryEnumVariant(&m.cases, &subject_vts, ctx);
4555
+ && (tag_union_match || matchCoversEveryEnumVariant(&m.cases, &subject_vts, ctx));
4171
4556
 
4172
4557
  compileMatchArmsMulti(
4173
4558
  &m.cases,
@@ -4277,6 +4662,44 @@ fn compileCasePositions(
4277
4662
  }
4278
4663
  let pat = &case.patterns[pos];
4279
4664
  let (subject_vt, scratch_local) = all_subjects[pos];
4665
+ let tag_subject = ctx.match_subject_types.borrow().get(&scratch_local).cloned();
4666
+ if let Some(PlumType::TTags(tags)) = &tag_subject {
4667
+ if isTagPattern(pat) {
4668
+ return compileTagPattern(
4669
+ pat,
4670
+ tags,
4671
+ &|b: &mut Vec<u8>| Instruction::LocalGet(scratch_local).encode(b),
4672
+ result_vt,
4673
+ body,
4674
+ ctx,
4675
+ state,
4676
+ &mut |body, state| {
4677
+ compileCasePositions(
4678
+ case,
4679
+ pos + 1,
4680
+ all_subjects,
4681
+ rest,
4682
+ result_vt,
4683
+ exhaustive_fallback,
4684
+ body,
4685
+ ctx,
4686
+ state,
4687
+ )
4688
+ },
4689
+ &mut |body, state| {
4690
+ compileMatchArmsMulti(
4691
+ rest,
4692
+ all_subjects,
4693
+ result_vt,
4694
+ exhaustive_fallback,
4695
+ body,
4696
+ ctx,
4697
+ state,
4698
+ )
4699
+ },
4700
+ );
4701
+ }
4702
+ }
4280
4703
  match pat {
4281
4704
  ast::CasePattern::Wildcard => {
4282
4705
  // Always matches this position; move on to the next one.
@@ -4318,7 +4741,9 @@ fn compileCasePositions(
4318
4741
  Instruction::LocalSet(idx).encode(body);
4319
4742
  ctx.type_env.borrow_mut().insert(
4320
4743
  n.clone(),
4744
+ TypeScheme::mono(
4321
- TypeScheme::mono(plumTypeFromValtypeHint(subject_vt)),
4745
+ tag_subject.unwrap_or_else(|| plumTypeFromValtypeHint(subject_vt)),
4746
+ ),
4322
4747
  );
4323
4748
  // A binding always matches this position; move on to the next one.
4324
4749
  compileCasePositions(
@@ -4588,6 +5013,55 @@ fn compileFieldPatterns(
4588
5013
  let pat = &fields[fpos];
4589
5014
  let field_ty = &field_types[fpos];
4590
5015
 
5016
+ if let PlumType::TTags(tags) = field_ty {
5017
+ if isTagPattern(pat) {
5018
+ return compileTagPattern(
5019
+ pat,
5020
+ tags,
5021
+ &|b: &mut Vec<u8>| {
5022
+ Instruction::LocalGet(container_local).encode(b);
5023
+ Instruction::StructGet {
5024
+ struct_type_index: container_type_idx,
5025
+ field_index: fpos as u32,
5026
+ }
5027
+ .encode(b);
5028
+ },
5029
+ result_vt,
5030
+ body,
5031
+ ctx,
5032
+ state,
5033
+ &mut |body, state| {
5034
+ compileFieldPatterns(
5035
+ fields,
5036
+ field_types,
5037
+ fpos + 1,
5038
+ container_local,
5039
+ container_type_idx,
5040
+ rest,
5041
+ all_subjects,
5042
+ result_vt,
5043
+ exhaustive_fallback,
5044
+ body,
5045
+ ctx,
5046
+ state,
5047
+ on_match,
5048
+ )
5049
+ },
5050
+ &mut |body, state| {
5051
+ compileMatchArmsMulti(
5052
+ rest,
5053
+ all_subjects,
5054
+ result_vt,
5055
+ exhaustive_fallback,
5056
+ body,
5057
+ ctx,
5058
+ state,
5059
+ )
5060
+ },
5061
+ );
5062
+ }
5063
+ }
5064
+
4591
5065
  match pat {
4592
5066
  ast::CasePattern::Wildcard => compileFieldPatterns(
4593
5067
  fields,
@@ -4951,6 +5425,35 @@ fn compileExpr(
4951
5425
  Instruction::If(BlockType::Result(success_vt)).encode(body);
4952
5426
  Instruction::LocalGet(scratch_local).encode(body);
4953
5427
  Instruction::RefCastNonNull(HeapType::Concrete(failure_idx)).encode(body);
5428
+ // Failure branch: take the inner `Err` payload and build the OUTER
5429
+ // function's `Err` variant from it (the Ok types may differ).
5430
+ let outer_err = match &ctx.ret_type {
5431
+ Some(PlumType::TNamed(outer_enum)) if *outer_enum != enum_name => {
5432
+ plum_checker::tryOperatorVariants(outer_enum, &cctx)
5433
+ .map(|(_, _, fname, _)| fname)
5434
+ }
5435
+ _ => None,
5436
+ };
5437
+ if let Some(outer_err_name) = outer_err {
5438
+ let outer_idx = *ctx
5439
+ .gc_types
5440
+ .variant_type_idx
5441
+ .get(outer_err_name)
5442
+ .ok_or_else(|| {
5443
+ format!(
5444
+ "codegen: variant '{}' missing from the GC type registry",
5445
+ outer_err_name
5446
+ )
5447
+ })?;
5448
+ if outer_idx != failure_idx {
5449
+ Instruction::StructGet {
5450
+ struct_type_index: failure_idx,
5451
+ field_index: 0,
5452
+ }
5453
+ .encode(body);
5454
+ Instruction::StructNew(outer_idx).encode(body);
5455
+ }
5456
+ }
4954
5457
  Instruction::Return.encode(body);
4955
5458
  Instruction::Else.encode(body);
4956
5459
  Instruction::LocalGet(scratch_local).encode(body);
@@ -5116,6 +5619,30 @@ fn compileExpr(
5116
5619
  }
5117
5620
  ast::Expr::Compare(c) => {
5118
5621
  let left_ty = inferLocalType(&c.left, ctx);
5622
+ // Tag unions compare by `$Tag.id` (the checker rejects payload tags).
5623
+ if matches!(left_ty, PlumType::TTags(_)) {
5624
+ let tag_idx = ctx.gc_types.tag_type_idx;
5625
+ for side in [&c.left, &c.right] {
5626
+ compileExpr(side, body, ctx, state)?;
5627
+ Instruction::StructGet {
5628
+ struct_type_index: tag_idx,
5629
+ field_index: 0,
5630
+ }
5631
+ .encode(body);
5632
+ }
5633
+ match &c.op {
5634
+ ast::CmpOp::Eq => Instruction::I32Eq.encode(body),
5635
+ ast::CmpOp::Neq | ast::CmpOp::NotEq2 => Instruction::I32Ne.encode(body),
5636
+ other => {
5637
+ return Err(format!(
5638
+ "codegen: '{:?}' is not supported between tag unions",
5639
+ other
5640
+ ))
5641
+ }
5642
+ }
5643
+ pushBoolRefFromI32Flag(body, ctx);
5644
+ return Ok(());
5645
+ }
5119
5646
  compileExpr(&c.left, body, ctx, state)?;
5120
5647
  compileExpr(&c.right, body, ctx, state)?;
5121
5648
  match left_ty {
@@ -5282,6 +5809,18 @@ fn compileExpr(
5282
5809
  compileExpr(&synthetic, body, ctx, state)?;
5283
5810
  } else if let Some(info) = ctx.enum_variants.get(&call.name) {
5284
5811
  compileVariantConstruction(info, call, expr, body, ctx, state)?;
5812
+ } else if !ctx.func_ids.contains_key(&call.name)
5813
+ && call.args.len() <= 1
5814
+ && call.name.chars().next().is_some_and(|c| c.is_uppercase())
5815
+ && matches!(inferLocalType(expr, ctx), PlumType::TTags(_))
5816
+ {
5817
+ // `Bad(x)` / `Bad()` — an undeclared uppercase name is a tag literal.
5818
+ let payload = call.args.first().map(|a| match a {
5819
+ ast::Arg::Positional(e) => e,
5820
+ ast::Arg::Keyword { value, .. } => value,
5821
+ ast::Arg::Pair { value, .. } => value,
5822
+ });
5823
+ compileTagLiteral(&call.name, payload, body, ctx, state)?;
5285
5824
  } else {
5286
5825
  fn argExprOf(arg: &ast::Arg) -> &ast::Expr {
5287
5826
  match arg {
@@ -5382,6 +5921,11 @@ fn compileExpr(
5382
5921
  let const_value = CURRENT_CONSTS.with(|c| c.borrow().get(n).cloned());
5383
5922
  match const_value {
5384
5923
  Some(value) => compileExpr(&value, body, ctx, state)?,
5924
+ None if n.chars().next().is_some_and(|c| c.is_uppercase())
5925
+ && matches!(inferLocalType(expr, ctx), PlumType::TTags(_)) =>
5926
+ {
5927
+ compileTagLiteral(n, None, body, ctx, state)?
5928
+ }
5385
5929
  None => {
5386
5930
  return Err(format!(
5387
5931
  "codegen: type name '{}' is not yet supported as a value",
@@ -6200,6 +6744,8 @@ fn compileClosureBody(
6200
6744
  type_env: RefCell::new(base_env),
6201
6745
  closure_local_sigs: RefCell::new(HashMap::new()),
6202
6746
  loop_extra_depth: Cell::new(0),
6747
+ ret_type: None,
6748
+ match_subject_types: RefCell::new(HashMap::new()),
6203
6749
  };
6204
6750
 
6205
6751
  compileBlockAsFnBody(&cl.body, &mut body, &local_ctx, state, info.ret_vt)?;
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -1032,3 +1032,208 @@ fn wasmtimeGcConfigSupportsWideningConcreteStructRefToAnyrefAndCastingBack() {
1032
1032
  "concrete struct ref should widen to anyref implicitly and cast back correctly"
1033
1033
  );
1034
1034
  }
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // Roc-style structural error tags (`Try[T, E]` with `E = [A, B(Str)]`).
1038
+ // ---------------------------------------------------------------------------
1039
+
1040
+ const TRY_PRELUDE: &str = "\
1041
+ enum Bool =
1042
+ | True
1043
+ | False
1044
+
1045
+ enum Try[T, E] =
1046
+ | Ok(T)
1047
+ | Err(E)
1048
+
1049
+ ";
1050
+
1051
+ /// Compiles `TRY_PRELUDE + body`, validates it, runs `main() -> Int` and
1052
+ /// returns its result.
1053
+ fn runTryMain(body: &str) -> i64 {
1054
+ let src = format!("{}{}", TRY_PRELUDE, body);
1055
+ let bytes = assertValid(&src);
1056
+ let engine = gcEngine();
1057
+ let module = wasmtime::Module::new(&engine, &bytes).unwrap();
1058
+ let mut store = wasmtime::Store::new(&engine, ());
1059
+ let instance = wasmtime::Instance::new(&mut store, &module, &[]).unwrap();
1060
+ let main = instance
1061
+ .get_typed_func::<(), i64>(&mut store, "main")
1062
+ .unwrap();
1063
+ main.call(&mut store, ()).unwrap()
1064
+ }
1065
+
1066
+ #[test]
1067
+ fn int_payload_roundtrip() {
1068
+ let got = runTryMain(
1069
+ "\
1070
+ fun make() -> Try[Int, [Big(Int), NotFound]] =
1071
+ Err(Big(9007199254740993))
1072
+
1073
+ fun main() -> Int =
1074
+ match make()
1075
+ Ok(v) => v
1076
+ Err(NotFound) => 0
1077
+ Err(Big(n)) => n
1078
+ ",
1079
+ );
1080
+ assert_eq!(got, 9007199254740993);
1081
+ }
1082
+
1083
+ #[test]
1084
+ fn float_payload_roundtrip() {
1085
+ let got = runTryMain(
1086
+ "\
1087
+ fun make() -> Try[Int, [Frac(Float), NotFound]] =
1088
+ Err(Frac(0.1))
1089
+
1090
+ fun main() -> Int =
1091
+ match make()
1092
+ Ok(v) => v
1093
+ Err(NotFound) => 0
1094
+ Err(Frac(f)) =>
1095
+ if f == 0.1
1096
+ return 1
1097
+ return 2
1098
+ ",
1099
+ );
1100
+ assert_eq!(got, 1);
1101
+ }
1102
+
1103
+ #[test]
1104
+ fn nested_err_tag_pattern() {
1105
+ let prog = |ctor: &str| {
1106
+ format!(
1107
+ "\
1108
+ fun make() -> Try[Int, [Bad(Int), NotFound]] =
1109
+ {}
1110
+
1111
+ fun main() -> Int =
1112
+ match make()
1113
+ Ok(v) => v
1114
+ Err(NotFound) => 0
1115
+ Err(Bad(n)) => n
1116
+ ",
1117
+ ctor
1118
+ )
1119
+ };
1120
+ assert_eq!(runTryMain(&prog("Ok(7)")), 7);
1121
+ assert_eq!(runTryMain(&prog("Err(NotFound)")), 0);
1122
+ assert_eq!(runTryMain(&prog("Err(Bad(42))")), 42);
1123
+ }
1124
+
1125
+ #[test]
1126
+ fn question_mark_different_ok_types() {
1127
+ // Inner Try[Int,[A]] inside fn returning Try[Str,[A,B]]: failure rewraps
1128
+ // into the OUTER Err variant (different Ok type), success continues.
1129
+ let prog = |inner: &str| {
1130
+ format!(
1131
+ "\
1132
+ fun inner() -> Try[Int, [A]] =
1133
+ {}
1134
+
1135
+ fun outer() -> Try[Str, [A, B]] =
1136
+ x := inner()?
1137
+ Ok(\"fine\")
1138
+
1139
+ fun main() -> Int =
1140
+ match outer()
1141
+ Ok(_) => 1
1142
+ Err(A) => 2
1143
+ Err(B) => 3
1144
+ ",
1145
+ inner
1146
+ )
1147
+ };
1148
+ assert_eq!(runTryMain(&prog("Err(A)")), 2);
1149
+ assert_eq!(runTryMain(&prog("Ok(5)")), 1);
1150
+ }
1151
+
1152
+ #[test]
1153
+ fn tag_eq() {
1154
+ let got = runTryMain(
1155
+ "\
1156
+ fun main() -> Int =
1157
+ a := A == A
1158
+ b := A == B
1159
+ if a
1160
+ if b
1161
+ return 0
1162
+ return 1
1163
+ return 2
1164
+ ",
1165
+ );
1166
+ assert_eq!(got, 1);
1167
+ }
1168
+
1169
+ #[test]
1170
+ fn match_on_bare_tag_union() {
1171
+ let got = runTryMain(
1172
+ "\
1173
+ fun pick(n: Int) -> [Small, Big(Int)] =
1174
+ if n < 10
1175
+ return Small
1176
+ return Big(n)
1177
+
1178
+ fun main() -> Int =
1179
+ match pick(77)
1180
+ Small => 0
1181
+ Big(n) => n
1182
+ ",
1183
+ );
1184
+ assert_eq!(got, 77);
1185
+ }
1186
+
1187
+ #[test]
1188
+ fn crash_traps_and_calls_import() {
1189
+ let src = format!(
1190
+ "{}fun main() -> Int =\n crash \"boom\"\n",
1191
+ TRY_PRELUDE
1192
+ );
1193
+ let bytes = assertValid(&src);
1194
+ let engine = gcEngine();
1195
+ let module = wasmtime::Module::new(&engine, &bytes).unwrap();
1196
+ assert!(
1197
+ module.imports().any(|i| i.module() == "plum" && i.name() == "crash"),
1198
+ "crash import must be emitted when crash is used"
1199
+ );
1200
+ let plain = assertValid("fun main() -> Int =\n 1\n");
1201
+ let plain_module = wasmtime::Module::new(&engine, &plain).unwrap();
1202
+ assert!(
1203
+ plain_module.imports().all(|i| i.name() != "crash"),
1204
+ "crash import must NOT be emitted when unused"
1205
+ );
1206
+ }
1207
+
1208
+ fn countTypes(bytes: &[u8]) -> usize {
1209
+ let mut n = 0;
1210
+ for payload in wasmparser::Parser::new(0).parse_all(bytes) {
1211
+ if let wasmparser::Payload::TypeSection(s) = payload.unwrap() {
1212
+ for g in s {
1213
+ n += g.unwrap().types().count();
1214
+ }
1215
+ }
1216
+ }
1217
+ n
1218
+ }
1219
+
1220
+ #[test]
1221
+ fn specializations_share_gc_type() {
1222
+ let prog = |second: &str| {
1223
+ format!(
1224
+ "{}\
1225
+ fun f() -> Try[Int, [A]] =
1226
+ Err(A)
1227
+
1228
+ fun g() -> Try[Int, {}] =
1229
+ Err(A)
1230
+
1231
+ fun main() -> Int = 1
1232
+ ",
1233
+ TRY_PRELUDE, second
1234
+ )
1235
+ };
1236
+ let same = assertValid(&prog("[A]"));
1237
+ let wider = assertValid(&prog("[A, B]"));
1238
+ assert_eq!(countTypes(&same), countTypes(&wider));
1239
+ }