plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
2003250
— Peter John
2026-11-05T22:32:40+05:30
Check a closure's self-calls in the checker
compiler/checker/src/lib.rs
CHANGED
|
@@ -486,6 +486,10 @@ const RETURN_KEY: &str = "$return";
|
|
|
486
486
|
/// `TypeEnv` key prefix marking a name as a top-level function (a local of the
|
|
487
487
|
/// same name shadows it instead of being a redeclaration).
|
|
488
488
|
pub(crate) const GLOBAL_KEY: &str = "$global:";
|
|
489
|
+
/// Env key prefix holding a closure's own signature while its body is checked
|
|
490
|
+
/// (`$self:rec` for `rec := |n: Int| .. rec(n - 1)`). The plain name stays unbound until
|
|
491
|
+
/// the closure is done; a self-call is the one use of this entry.
|
|
492
|
+
const SELF_KEY: &str = "$self:";
|
|
489
493
|
/// Binds the local `name`; a local named like a top-level fn shadows it, so it no
|
|
490
494
|
/// longer counts as that fn (a later `name = v` reassigns the local).
|
|
491
495
|
fn bindLocal(env: &mut TypeEnv, name: &str, scheme: TypeScheme) {
|
|
@@ -2531,6 +2535,12 @@ fn checkStmtInner(
|
|
|
2531
2535
|
),
|
|
2532
2536
|
));
|
|
2533
2537
|
}
|
|
2538
|
+
// A closure may call itself: while its body is checked, `name` is
|
|
2539
|
+
// bound to its own signature. Only a name that is unbound here
|
|
2540
|
+
// (an existing top-level `fun name` keeps meaning that fn).
|
|
2541
|
+
if matches!(value.kind, ast::ExprKind::Closure(_)) && lookup(env, name).is_err() {
|
|
2542
|
+
withCheck(|s| *s.closure_self.borrow_mut() = Some(name.clone()));
|
|
2543
|
+
}
|
|
2534
2544
|
match inferExpr(value, env, ctx) {
|
|
2535
2545
|
Ok(t) => {
|
|
2536
2546
|
if let Some(rest) = name.strip_prefix(ast::FOR_ITER_PREFIX) {
|
|
@@ -3347,6 +3357,13 @@ fn inferClassCallRaw(
|
|
|
3347
3357
|
// A payload-free variant called like a constructor (`Nothing()`): the enum's type,
|
|
3348
3358
|
// like the bare name's.
|
|
3349
3359
|
Some(info) if info.field_types.is_empty() => {
|
|
3360
|
+
if !call.fields.is_empty() {
|
|
3361
|
+
return Err(format!(
|
|
3362
|
+
"variant '{}': expected 0 arg(s), got {}",
|
|
3363
|
+
call.type_name,
|
|
3364
|
+
call.fields.len()
|
|
3365
|
+
));
|
|
3366
|
+
}
|
|
3350
3367
|
let (_, result, binds) = startVariant(info, &explicit, expected);
|
|
3351
3368
|
Ok(substVars(&result, &binds))
|
|
3352
3369
|
}
|
|
@@ -4161,6 +4178,7 @@ fn inferExprInner(
|
|
|
4161
4178
|
Err(format!("'?' requires an Option or a Result, found {}", inner_ty))
|
|
4162
4179
|
}
|
|
4163
4180
|
ast::Expr { kind: ast::ExprKind::Closure(cl), .. } => {
|
|
4181
|
+
let self_name = withCheck(|s| s.closure_self.borrow_mut().take());
|
|
4164
4182
|
let mut closure_env = env.clone();
|
|
4165
4183
|
// A `?` inside a closure is rejected: it has no enclosing fn return type.
|
|
4166
4184
|
closure_env.remove(RETURN_KEY);
|
|
@@ -4227,6 +4245,12 @@ fn inferExprInner(
|
|
|
4227
4245
|
bindLocal(&mut closure_env, p, TypeScheme::mono(t.clone()));
|
|
4228
4246
|
param_types.push(t);
|
|
4229
4247
|
}
|
|
4248
|
+
// Its own signature, for self-calls (see `SELF_KEY`). The name itself stays
|
|
4249
|
+
// unbound, so a valid recursion is typed and recorded exactly as it always was.
|
|
4250
|
+
if let Some(n) = self_name.filter(|n| !cl.params.contains(n)) {
|
|
4251
|
+
let own = PlumType::TFun(param_types.clone(), Box::new(PlumType::TVar("_".to_string())));
|
|
4252
|
+
closure_env.insert(format!("{}{}", SELF_KEY, n), TypeScheme::mono(own));
|
|
4253
|
+
}
|
|
4230
4254
|
// Thread declared locals through the body so a multi-statement closure
|
|
4231
4255
|
// (not just its trailing expression) resolves completely — only the
|
|
4232
4256
|
// FINAL statement's type is actually needed below, but an earlier
|
|
@@ -4518,8 +4542,30 @@ fn inferExprInner(
|
|
|
4518
4542
|
// An unknown fn (a closure calling itself, not yet bound): allow, codegen will
|
|
4519
4543
|
// catch. Its arguments are still typed, so each has a recorded type.
|
|
4520
4544
|
Err(_) => {
|
|
4545
|
+
let own = match env.get(&format!("{}{}", SELF_KEY, call.name)).map(|s| &*s.body) {
|
|
4546
|
+
Some(PlumType::TFun(ps, _)) => Some(ps.clone()),
|
|
4547
|
+
_ => None,
|
|
4548
|
+
};
|
|
4549
|
+
if let Some(ps) = &own {
|
|
4550
|
+
if call.args.len() != ps.len() {
|
|
4551
|
+
return Err(format!(
|
|
4552
|
+
"call '{}': expected {} args, got {}",
|
|
4553
|
+
call.name,
|
|
4554
|
+
ps.len(),
|
|
4555
|
+
call.args.len()
|
|
4556
|
+
));
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4521
|
-
for a in
|
|
4559
|
+
for (i, a) in call.args.iter().enumerate() {
|
|
4560
|
+
let arg = argExpr(a);
|
|
4522
|
-
let
|
|
4561
|
+
let actual = inferExpr(arg, env, ctx);
|
|
4562
|
+
// A closure's own call: the argument must fit the parameter. The
|
|
4563
|
+
// arguments are still inferred with no expected type, as for any
|
|
4564
|
+
// unknown callee, so what codegen sees is unchanged.
|
|
4565
|
+
if let (Some(ps), Ok(actual)) = (&own, actual) {
|
|
4566
|
+
unifyArg(&ps[i], &actual, arg, ctx)
|
|
4567
|
+
.map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
|
|
4568
|
+
}
|
|
4523
4569
|
}
|
|
4524
4570
|
Ok(PlumType::TVar("_".to_string()))
|
|
4525
4571
|
}
|
compiler/checker/src/state.rs
CHANGED
|
@@ -19,6 +19,10 @@ pub(crate) struct CheckState {
|
|
|
19
19
|
/// means "assigned to a local, no call information"; the flag is whether an unresolved
|
|
20
20
|
/// param is an error (the local is never used as a plain value either).
|
|
21
21
|
pub(crate) closure_hint: RefCell<Option<(Vec<PlumType>, bool)>>,
|
|
22
|
+
/// The local name a closure literal about to be inferred is being declared as
|
|
23
|
+
/// (`name := |..| ..`, `name` not yet bound), taken by the closure branch so its
|
|
24
|
+
/// body can call itself through a typed binding.
|
|
25
|
+
pub(crate) closure_self: RefCell<Option<String>>,
|
|
22
26
|
/// Counter for the fresh `_t<N>` inference variables minted per generic call site.
|
|
23
27
|
pub(crate) fresh: Cell<u32>,
|
|
24
28
|
/// When set, `inferExprWith` records every expression's type here (see `checkProgram`).
|
compiler/checker/tests/checker/diagnostic_tests.rs
CHANGED
|
@@ -71,3 +71,32 @@ fn secondOperandErrorSpansTheString() {
|
|
|
71
71
|
let es = errors("fun f() -> Int = 1 + \"a\"\n");
|
|
72
72
|
assert_eq!((es[0].line, es[0].column, es[0].end_line, es[0].end_column), (1, 22, 1, 25), "{es:?}");
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/// A closure calling itself is checked against its own parameters, like any fn call; the
|
|
76
|
+
/// error is at the call (as for a top-level fn), not left to fail as invalid wasm.
|
|
77
|
+
#[test]
|
|
78
|
+
fn selfCallOfATypedClosureChecksItsArguments() {
|
|
79
|
+
let es = errors("fun main() -> Int =\n rec := |n: Int|\n if n <= 0 then 0 else rec(\"x\")\n rec(3)\n");
|
|
80
|
+
assert_eq!((es[0].line, es[0].column), (3, 27), "{es:?}");
|
|
81
|
+
assert!(es[0].message.contains("call 'rec' arg 0: type mismatch: expected Int, found Str"), "{es:?}");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[test]
|
|
85
|
+
fn selfCallOfAnUntypedClosureChecksItsArgumentsAgainstTheCallersTypes() {
|
|
86
|
+
let es = errors("fun main() -> Int =\n rec := |n|\n if n <= 0 then 0 else rec(\"x\")\n rec(3)\n");
|
|
87
|
+
assert_eq!((es[0].line, es[0].column), (3, 27), "{es:?}");
|
|
88
|
+
assert!(es[0].message.contains("call 'rec' arg 0: type mismatch: expected Int, found Str"), "{es:?}");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[test]
|
|
92
|
+
fn selfCallWithTheWrongArgumentCountIsRejected() {
|
|
93
|
+
let es = errors("fun main() -> Int =\n rec := |n: Int| if n <= 0 then 0 else rec(n - 1, 5)\n rec(3)\n");
|
|
94
|
+
assert!(es[0].message.contains("call 'rec': expected 1 args, got 2"), "{es:?}");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn payloadFreeVariantRejectsNamedArguments() {
|
|
99
|
+
let es = errors("enum Maybe[T] = Just(value: T) | Nothing\n\nfun main() -> Int =\n m: Maybe[Int] := Nothing(value: 1)\n 0\n");
|
|
100
|
+
assert_eq!((es[0].line, es[0].column), (4, 20), "{es:?}");
|
|
101
|
+
assert!(es[0].message.contains("variant 'Nothing': expected 0 arg(s), got 1"), "{es:?}");
|
|
102
|
+
}
|
compiler/wasm-codegen/tests/wasm_codegen/e2e_tail/closure_tail.rs
CHANGED
|
@@ -43,3 +43,15 @@ fn wasm_closure_has_return_call() {
|
|
|
43
43
|
}
|
|
44
44
|
assert!(found, "expected return_call_indirect in a closure body");
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn self_calling_closure_typed_params_runs() {
|
|
49
|
+
let src = format!("{HEAD}fun main() -> Int =\n fact := |n: Int| if n <= 1 then 1 else n * fact(n - 1)\n fact(5)\n");
|
|
50
|
+
assert_eq!(runInt("selftyped", &src), 120);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[test]
|
|
54
|
+
fn self_calling_closure_untyped_params_runs() {
|
|
55
|
+
let src = format!("{HEAD}fun main() -> Int =\n fact := |n| if n <= 1 then 1 else n * fact(n - 1)\n fact(5)\n");
|
|
56
|
+
assert_eq!(runInt("selfuntyped", &src), 120);
|
|
57
|
+
}
|
docs/backlog.md
CHANGED
|
@@ -53,7 +53,7 @@ Open work, in the order the user queued it, plus smaller items found during buil
|
|
|
53
53
|
|
|
54
54
|
## Smaller open items
|
|
55
55
|
|
|
56
|
-
- **Closure self-call
|
|
56
|
+
- **Closure self-call, still open** (`F-CLOSURE-1` fixed the typed-argument and arity checks; a self-call now errors in the checker when an argument does not fit a known parameter type). What stays: (a) a parameter with no declared type and no hint from a later call (`rec := |n| .. rec("x")` passed to `map`) has no type to check against; (b) `rec(None, [], n-1)` / `rec(Some(n), ..)` with untyped params still fail in codegen (invalid wasm or `type name 'Nil' is not yet supported as a value`), because the self-call's arguments are inferred with no expected type; checking them with the parameter as expected type fixes those but changes the wasm of programs that run today (e.g. a typed `Option[Int]` param called with `None`), so do it in a run that may re-baseline wasm; (c) a self-call from a nested closure (`outer := |n| inner := |m| outer(m-1) ..`) fails in codegen with `unknown function 'outer'`; (d) a typed declaration `f: fn(Int) -> Int := |n| ..` does not bind `f` inside its body; (e) a failed closure initialiser also reports a second `'rec' is not a function` at its first use.
|
|
57
57
|
- **LSP columns are bytes, not UTF-16** (phase 4, pre-existing): non-ASCII text before an error underlines the wrong range. Spans take tree-sitter byte columns.
|
|
58
58
|
- **Phase 4 workarounds still in place:** closure-hint twin mapping (`compiler/driver/src/lib.rs`, because `for` lowering numbers `pre` and the checked program separately) and `defaultSingleton` (`compiler/wasm-codegen/src/lib.rs`, because param defaults are never checked).
|
|
59
59
|
- **Unproven, not reproduced (phase 4):** a discarded speculative failure could leave a stale `err_pos` on a later error; `Unit` from a missing type might panic in `scratchTypeIdx` on some pattern shape.
|