plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
c273ea5
— Peter 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 +112 -7
- plum-checker/tests/checker_tests.rs +101 -0
- plum-core/src/parser.rs +5 -3
- tooling/tree-sitter-plum/grammar.js +1 -1
- tooling/tree-sitter-plum/src/grammar.json +0 -0
- tooling/tree-sitter-plum/src/node-types.json +0 -0
- tooling/tree-sitter-plum/src/parser.c +0 -0
- tooling/tree-sitter-plum/test/corpus/enum.txt +19 -0
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
|
|
251
|
+
/// type `t` when the currently-declared return type is genuinely generic or
|
|
242
|
-
/// Never clobbers a
|
|
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
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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()
|
|
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))))
|