plum

#treesitter#compiler#wasm

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 &call.args {
4559
+ for (i, a) in call.args.iter().enumerate() {
4560
+ let arg = argExpr(a);
4522
- let _ = inferExpr(argExpr(a), env, ctx);
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 type errors reach the user as invalid wasm** (found in cleanup phase 4, pre-existing): `rec("x")` where `n: Int` is accepted, then fails at wasm load. `rec(None, [], n-1)` with no hints and `Nothing(value: 1)` fail in codegen with a poor message rather than in the checker. Fixing means binding a closure's declared type while checking its initialiser, which changes what the checker accepts.
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.