plum

#treesitter#compiler#wasm

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

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


c273ea5Peter John 2026-07-20T16:01:25+05:30
fix(plum-checker): stop masking type errors in specializations; resolve generic enum instantiation
plum-checker/src/monomorphize.rs CHANGED
@@ -173,7 +173,6 @@ use crate::{ClassEnv, MethodEnv, EnumVariants, CheckCtx};
173
173
  enum PendingSpecialization<'a> {
174
174
  Class { base: &'a ast::Class, subst: Substitution, mangled: String },
175
175
  Fn { base: &'a ast::Fn, subst: Substitution, mangled: String, new_receiver: Option<String> },
176
- #[allow(dead_code)]
177
176
  Enum { base: &'a ast::Enum, subst: Substitution, mangled: String },
178
177
  }
179
178
 
@@ -181,6 +180,17 @@ struct Monomorphizer<'a> {
181
180
  classes_generic: BTreeMap<String, &'a ast::Class>,
182
181
  fns_generic: BTreeMap<String, &'a ast::Fn>,
183
182
  methods_generic_on: BTreeMap<String, Vec<&'a ast::Fn>>,
183
+ /// Bare variant name (e.g. `"Some"`) -> the generic `Enum` it belongs to. Keyed
184
+ /// by variant name because a construction site (`Some(5)`) parses as a `FnCall`
185
+ /// whose `name` is the VARIANT, not the enum's own name.
186
+ enums_generic_by_variant: BTreeMap<String, &'a ast::Enum>,
187
+ /// Bare variant name -> the mangled enum name that has currently "claimed" it.
188
+ /// Because the runtime `EnumVariants` table is keyed by BARE variant name
189
+ /// globally, two specializations of the same generic enum would both try to
190
+ /// register `"Some"`, silently colliding. We detect that here and error rather
191
+ /// than corrupt one specialization (single-instantiation-per-generic-enum is a
192
+ /// documented limitation of this pass).
193
+ enum_variant_owner: BTreeMap<String, String>,
184
194
  global_env: TypeEnv,
185
195
  classes: ClassEnv,
186
196
  methods: MethodEnv,
@@ -238,9 +248,28 @@ impl<'a> Monomorphizer<'a> {
238
248
  }
239
249
 
240
250
  /// Overwrites `f.returns` with a concrete type derived from the body's tail
241
- /// type `t` when the currently-declared return type is generic/unresolved.
251
+ /// type `t` when the currently-declared return type is genuinely generic or
242
- /// Never clobbers a genuine concrete annotation on an ordinary function, and
252
+ /// unresolved. Never clobbers a real, concrete declared return type even for
253
+ /// a specialization (`resolve_return: true`) — so a generic function whose body
254
+ /// is internally inconsistent with its concrete declared return (e.g.
255
+ /// `wrong(x: a) -> Int = "hello"`) is left for the checker's normal
256
+ /// return-type-mismatch logic to REJECT rather than silently rewritten (and
257
+ /// thereby masked). The overwrite fires only when:
258
+ /// - `f.returns` is `None` — the unparseable `-> a` generic-parameter-return
259
+ /// case, where the grammar dropped the annotation entirely (this only ever
260
+ /// happens for a specialization, which is the only path that can supply a
261
+ /// concrete tail type to fill it in); or
262
+ /// - the declared return names something still-generic: a generic-parameter
263
+ /// letter (e.g. `-> a`) or a generic class used bare (e.g. `-> Box`).
264
+ /// For the `Some(rt)` arm this condition is identical whether `resolve_return`
265
+ /// is `true` or `false`; the specialization path differs only in that its tail
266
+ /// is inferred against a resolved substitution, so a generic-parameter-letter
267
+ /// return resolves to the specialization's concrete bound type (which the
268
+ /// ordinary path cannot do). The unparseable-`None` fill-in is gated on
269
+ /// `resolve_return` so an ordinary void function (`returns: None` meaning "no
270
+ /// declared return", not "a generic return the grammar dropped") is never given
243
- /// never fabricates a return type from an un-inferrable (`TVar`) tail.
271
+ /// a fabricated return type. Never fabricates a return from an un-inferrable
272
+ /// (`TVar`) tail.
244
273
  fn maybe_rewrite_return(&self, f: &mut ast::Fn, t: &PlumType, resolve_return: bool) {
245
274
  if matches!(t, PlumType::TVar(_) | PlumType::TFun(_, _)) {
246
275
  return;
@@ -248,8 +277,7 @@ impl<'a> Monomorphizer<'a> {
248
277
  let needs = match &f.returns {
249
278
  None => resolve_return,
250
279
  Some(rt) => {
251
- resolve_return
252
- || is_generic_param_name(&rt.name)
280
+ is_generic_param_name(&rt.name)
253
281
  || self.classes_generic.contains_key(&rt.name)
254
282
  }
255
283
  };
@@ -384,6 +412,50 @@ impl<'a> Monomorphizer<'a> {
384
412
  Ok(())
385
413
  }
386
414
 
415
+ /// Resolves a construction of a generic enum's variant (e.g. `Some(5)` for
416
+ /// `enum Option = | Some(a) | None`). Unlike class/function resolution, this
417
+ /// does NOT rewrite `call.name`: the variant name (`Some`) must stay exactly as
418
+ /// declared — only the ENUM's own name is mangled (`Option$Int`), and the
419
+ /// specialized `ast::Enum` keeps its variants named `Some`/`None`. We only need
420
+ /// to enqueue the enum's specialization; the checker/codegen's `EnumVariants`
421
+ /// lookup (keyed by bare variant name) resolves `Some` correctly once the
422
+ /// concrete `Option$Int` is the only thing left in the output.
423
+ ///
424
+ /// A variant that carries no generic fields (e.g. `None`) can't pin down the
425
+ /// enum's type parameters on its own, so such a construction site is left alone
426
+ /// here — some other construction site (e.g. `Some(5)`) is what drives the
427
+ /// specialization, and the bare `None` needs no rewriting either way.
428
+ fn resolve_enum_instantiation(&mut self, call: &ast::FnCall, env: &TypeEnv) -> Result<(), String> {
429
+ let Some(e) = self.enums_generic_by_variant.get(call.name.as_str()).copied() else { return Ok(()) };
430
+ let params = enum_generic_params(e);
431
+ let Some(variant) = e.variants.iter().find(|v| v.name == call.name) else { return Ok(()) };
432
+ let mut bindings: BTreeMap<String, PlumType> = BTreeMap::new();
433
+ for (field_ty_name, arg) in variant.fields.iter().zip(call.args.iter()) {
434
+ if params.contains(field_ty_name) {
435
+ let arg_expr = match arg {
436
+ ast::Arg::Positional(e) => e,
437
+ ast::Arg::Keyword { value, .. } => value,
438
+ ast::Arg::Pair { value, .. } => value,
439
+ };
440
+ bindings.entry(field_ty_name.clone()).or_insert_with(|| self.infer(arg_expr, env));
441
+ }
442
+ }
443
+ // This single construction site couldn't pin down every generic parameter
444
+ // (e.g. a payload-free `None`, or a variant that mentions only some of a
445
+ // multi-parameter enum's params). Leave it for another site to drive.
446
+ if bindings.len() != params.len() {
447
+ return Ok(());
448
+ }
449
+ let type_args: Vec<PlumType> = params.iter().map(|p| bindings[p].clone()).collect();
450
+ let mangled = mangle(&e.name, &type_args);
451
+ if !self.specialized.contains(&mangled) && !self.enqueued.contains(&mangled) {
452
+ self.enqueued.insert(mangled.clone());
453
+ self.worklist.push(PendingSpecialization::Enum { base: e, subst: Substitution(bindings), mangled });
454
+ }
455
+ // Intentionally NOT rewriting `call.name` — see doc comment.
456
+ Ok(())
457
+ }
458
+
387
459
  fn rewrite_expr(&mut self, expr: &mut ast::Expr, env: &TypeEnv) -> Result<(), String> {
388
460
  match expr {
389
461
  ast::Expr::ClassCall(call) => {
@@ -401,6 +473,11 @@ impl<'a> Monomorphizer<'a> {
401
473
  };
402
474
  self.rewrite_expr(e, env)?;
403
475
  }
476
+ // A `FnCall` may name either a generic free function or a generic
477
+ // enum's variant; the two name spaces don't overlap (variants are
478
+ // capitalized), so checking both is safe. Enum resolution never
479
+ // rewrites `call.name`, so order doesn't matter.
480
+ self.resolve_enum_instantiation(call, env)?;
404
481
  self.resolve_fn_instantiation(call, env)?;
405
482
  }
406
483
  ast::Expr::Attribute(attr) => {
@@ -462,6 +539,8 @@ pub fn monomorphize_source(source: &ast::Source) -> Result<ast::Source, String>
462
539
  classes_generic: BTreeMap::new(),
463
540
  fns_generic: BTreeMap::new(),
464
541
  methods_generic_on: BTreeMap::new(),
542
+ enums_generic_by_variant: BTreeMap::new(),
543
+ enum_variant_owner: BTreeMap::new(),
465
544
  global_env,
466
545
  classes,
467
546
  methods,
@@ -475,6 +554,11 @@ pub fn monomorphize_source(source: &ast::Source) -> Result<ast::Source, String>
475
554
  for item in &source.items {
476
555
  match item {
477
556
  ast::Item::Class(c) if !c.generics.is_empty() => { m.classes_generic.insert(c.name.clone(), c); }
557
+ ast::Item::Enum(e) if !enum_generic_params(e).is_empty() => {
558
+ for v in &e.variants {
559
+ m.enums_generic_by_variant.insert(v.name.clone(), e);
560
+ }
561
+ }
478
562
  _ => {}
479
563
  }
480
564
  }
@@ -568,7 +652,28 @@ pub fn monomorphize_source(source: &ast::Source) -> Result<ast::Source, String>
568
652
  }
569
653
  PendingSpecialization::Enum { base, subst, mangled } => {
570
654
  if !m.specialized.insert(mangled.clone()) { continue; }
571
- m.produced.push(ast::Item::Enum(specialize_enum(base, &subst, &mangled)));
655
+ let spec_enum = specialize_enum(base, &subst, &mangled);
656
+ // Claim each bare variant name for this mangled enum. If a DIFFERENT
657
+ // mangled enum already owns it, this generic enum is being
658
+ // instantiated at more than one concrete type in the same program —
659
+ // which the flat, bare-variant-name-keyed `EnumVariants` runtime
660
+ // table can't represent (both would register under `"Some"`). Rather
661
+ // than silently let the second specialization corrupt the first, we
662
+ // fail with a clear, specific error. (Re-claiming by the SAME mangled
663
+ // enum can't reach here — worklist dedup + the `specialized` guard
664
+ // above ensure each mangled enum is produced exactly once.)
665
+ for v in &spec_enum.variants {
666
+ if let Some(owner) = m.enum_variant_owner.get(&v.name) {
667
+ if owner != &mangled {
668
+ return Err(format!(
669
+ "monomorphize: generic enum '{}' is instantiated at more than one concrete type in the same program ('{}' and '{}'), which is not yet supported. Only a single concrete instantiation per generic enum is allowed (variant '{}' would collide in the global variant table). This is a known, documented limitation, not a bug.",
670
+ base.name, owner, mangled, v.name
671
+ ));
672
+ }
673
+ }
674
+ m.enum_variant_owner.insert(v.name.clone(), mangled.clone());
675
+ }
676
+ m.produced.push(ast::Item::Enum(spec_enum));
572
677
  }
573
678
  }
574
679
  }
plum-checker/tests/checker_tests.rs CHANGED
@@ -364,6 +364,107 @@ use() -> Bool =
364
364
  assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
365
365
  }
366
366
 
367
+ #[test]
368
+ fn generic_function_with_internally_inconsistent_body_is_rejected_after_specialization() {
369
+ // A generic function whose body is genuinely inconsistent with its concrete,
370
+ // correct declared return type must still be REJECTED after specialization.
371
+ // The `-> Str` on `useIt` means the buggy (unconditional return-overwrite)
372
+ // behavior — rewriting `wrong$Str`'s `-> Int` to `-> Str` — would make the
373
+ // whole program type-check, masking the real `expected Int, found Str` error.
374
+ let src = "\
375
+ wrong(x: a) -> Int =
376
+ \"hello\"
377
+
378
+ useIt() -> Str =
379
+ wrong(\"s\")
380
+ ";
381
+ let source = parse(src);
382
+ let result = check_source(&source);
383
+ assert!(
384
+ result.is_err(),
385
+ "expected the internally-inconsistent generic function to be rejected, got Ok"
386
+ );
387
+ }
388
+
389
+ #[test]
390
+ fn generic_enum_single_instantiation_type_checks() {
391
+ // A generic Option-shaped enum, constructed at one concrete type (`Some(5)`),
392
+ // matched, must resolve end-to-end via check_source.
393
+ let src = "\
394
+ enum Option =
395
+ | Some(a)
396
+ | None
397
+
398
+ get() -> Int =
399
+ o = Some(5)
400
+ match o
401
+ Some(v) =>
402
+ v
403
+ None =>
404
+ 0
405
+ ";
406
+ let source = parse(src);
407
+ let result = check_source(&source);
408
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
409
+
410
+ // Directly prove resolution happened: the monomorphized output must contain a
411
+ // concrete `Option$Int` enum whose `Some` variant carries an `Int` field (not
412
+ // the generic `a`), and must NOT retain the generic `Option` template. The
413
+ // variant name itself stays `Some` (only the enum's own name is mangled).
414
+ let mono = plum_checker::monomorphize::monomorphize_source(&source)
415
+ .expect("monomorphize should succeed");
416
+ let opt = mono.items.iter().find_map(|it| match it {
417
+ Item::Enum(e) if e.name == "Option$Int" => Some(e),
418
+ _ => None,
419
+ });
420
+ let opt = opt.expect("expected a specialized `Option$Int` enum in the output");
421
+ let some = opt.variants.iter().find(|v| v.name == "Some")
422
+ .expect("expected `Some` variant on `Option$Int`");
423
+ assert_eq!(some.fields, vec!["Int".to_string()], "Some's field should be concrete Int");
424
+ assert!(
425
+ !mono.items.iter().any(|it| matches!(it, Item::Enum(e) if e.name == "Option")),
426
+ "the generic `Option` template must be dropped from the output"
427
+ );
428
+ }
429
+
430
+ #[test]
431
+ fn generic_enum_multi_instantiation_is_a_clear_error() {
432
+ // The SAME generic enum instantiated at two different concrete types in one
433
+ // program is a known, documented limitation — it must fail with a clear
434
+ // monomorphize error rather than silently corrupting one specialization.
435
+ let src = "\
436
+ enum Option =
437
+ | Some(a)
438
+ | None
439
+
440
+ useInt() -> Int =
441
+ o = Some(5)
442
+ match o
443
+ Some(v) =>
444
+ v
445
+ None =>
446
+ 0
447
+
448
+ useStr() -> Str =
449
+ o = Some(\"x\")
450
+ match o
451
+ Some(v) =>
452
+ v
453
+ None =>
454
+ \"z\"
455
+ ";
456
+ let source = parse(src);
457
+ let result = check_source(&source);
458
+ let errs = result.err().expect("expected a monomorphize collision error, got Ok");
459
+ assert!(
460
+ errs.iter().any(|e| e.message.contains("monomorphize")
461
+ && e.message.contains("Option")
462
+ && e.message.contains("more than one concrete type")),
463
+ "expected a clear monomorphize multi-instantiation error, got {:?}",
464
+ errs
465
+ );
466
+ }
467
+
367
468
  #[test]
368
469
  fn generic_method_on_generic_class_type_checks() {
369
470
  let src = "\
plum-core/src/parser.rs CHANGED
@@ -169,12 +169,14 @@ impl<'a> AstParser<'a> {
169
169
  }
170
170
 
171
171
  fn parse_enum_variant(&self, node: Node) -> EnumVariant {
172
- // enum_field (aliased to field): "|" type_identifier ("(" type_identifier,* ")")?
172
+ // enum_field (aliased to field): "|" type_identifier ("(" (type_identifier | generic),* ")")?
173
- // named children: type_identifier (name), type_identifier* (fields inside "()")
173
+ // named children: type_identifier (name), then each field type inside "()" — a
174
+ // `type_identifier` (concrete, e.g. `Int`) or an inlined generic letter node
175
+ // (`a`/`b`/`c`/`d`, since `generic` is inlined in the grammar).
174
176
  let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
175
177
  let fields: Vec<String> = (1..node.named_child_count())
176
178
  .filter_map(|i| node.named_child(i as u32))
177
- .filter(|n| n.kind() == "type_identifier")
179
+ .filter(|n| matches!(n.kind(), "type_identifier" | "a" | "b" | "c" | "d"))
178
180
  .map(|n| self.text(n))
179
181
  .collect();
180
182
  EnumVariant { name, fields }
tooling/tree-sitter-plum/grammar.js CHANGED
@@ -146,7 +146,7 @@ module.exports = grammar({
146
146
  seq(
147
147
  "|",
148
148
  field("name", $.type_identifier),
149
- field("parameters", optional(seq("(", commaSep1($.type_identifier), ")"))),
149
+ field("parameters", optional(seq("(", commaSep1(choice($.type_identifier, $.generic)), ")"))),
150
150
  ),
151
151
 
152
152
  fn: ($) =>
tooling/tree-sitter-plum/src/grammar.json CHANGED
Binary file
tooling/tree-sitter-plum/src/node-types.json CHANGED
Binary file
tooling/tree-sitter-plum/src/parser.c CHANGED
Binary file
tooling/tree-sitter-plum/test/corpus/enum.txt CHANGED
@@ -31,3 +31,22 @@ toStr<Bool>() -> Str =
31
31
  (string_start)
32
32
  (string_content)
33
33
  (string_end)))))))
34
+
35
+ ================================================================================
36
+ enum - generic variant fields
37
+ ================================================================================
38
+
39
+ enum Option =
40
+ | Some(a)
41
+ | None
42
+
43
+ --------------------------------------------------------------------------------
44
+
45
+ (source
46
+ (enum
47
+ (type_identifier)
48
+ (field
49
+ (type_identifier)
50
+ (a))
51
+ (field
52
+ (type_identifier))))