Skip to content

Fix first-class function conversion and preserve curried calls - #618

Open
LPTK wants to merge 4 commits into
hkmc2from
fix-and-improve-ftc
Open

LPTK wants to merge 4 commits into
hkmc2from
fix-and-improve-ftc

Conversation

@LPTK

@LPTK LPTK commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

[GPT-6 Astra]

With :ftc, curried calls such as add(40)(2) could return undefined, and calls through function values assumed that every value had been transformed. Rewrite FirstClassFunctionTransformer around resolved definition symbols and a shared JavaScript calling convention.

Calls through function values now use .call(undefined, ...) for bare calls and preserve the receiver for member calls. Generated closures accept the explicit receiver argument, capture method values and receivers when read, and use fresh forwarding parameters. External declarations retain their optional-argument and result-normalization conventions.

Keep curried definitions and saturated multi-calls intact for later optimization. Escaping partial applications return native functions compatible with the same call convention. Reflection retains multiple parameter lists. The interop rewrite and currying fix are separate commits.

Add new-resolution diff tests covering incoming JS functions, mixed function values, receiver capture, optional/rest arguments, partial applications, recursion, and generators. The original regression now checks both the preserved multi-call and its optimized result. Updated golden outputs are included.

Validation: hkmc2AllTests/test passed, including compilation prerequisites and all diff tests; Metals compilation passed.

@LPTK
LPTK requested a review from NeilKleistGao November 5, 2026 14:16
Comment on lines +27 to +37
// Escaped function values and partial applications share the native call convention.
:noOpt
:checkIR
:expect [42, 42, 42]
fun triple(first: Int)(second: Int)(third: Int): Int = first + second + third
let partial = triple(1)
let firstClass = triple
[triple(1)(2)(39), partial(2)(39), firstClass(1)(2)(39)]
//│ = [42, 42, 42]
//│ firstClass = class anonymous
//│ partial = fun

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no :ssjs flag here (please have it later) so I put the generated JS code below:

let triple, partial, firstClass, tmp, callPrefix, tmp1, callPrefix1, callPrefix2, tmp2, Function$1, function1, callee, callee1, callee2, callee3, callee4, first1, second1, third, inlinedVal1;
triple = function triple(first2) {
  runtime.checkArgs("triple", 1, arguments.length);
  return (second2) => {
    return (third1) => {
      let tmp3;
      tmp3 = first2 + second2;
      return tmp3 + third1
    }
  }
};
partial = runtime.checkCall(triple(1));
globalThis.Object.freeze(class Function$ extends globalThis.Function {
  static {
    Function$1 = this
  }
  constructor() {
    super();
  }
  get call$__checkNotMethod() { runtime.deboundMethod("call", "Function$"); }
  call(thisArg, first2) {
    runtime.checkArgs("call", 2, arguments.length);
    return runtime.checkCall(triple(first2))
  }
  toString() { return runtime.render(this); }
  static [definitionMetadata] = ["class", "Function$"];
});
function1 = globalThis.Object.freeze(new Function$1());
firstClass = function1;
first1 = 1;
second1 = 2;
third = 39;
inlinedLbl1: {
  let tmp3;
  tmp3 = first1 + second1;
  inlinedVal1 = tmp3 + third;
  break inlinedLbl1;
}
tmp = inlinedVal1;
callee = partial;
callPrefix = runtime.safeCall(callee.call(undefined, 2));
callee1 = callPrefix;
tmp1 = runtime.safeCall(callee1.call(undefined, 39));
callee2 = firstClass;
callPrefix1 = runtime.safeCall(callee2.call(undefined, 1));
callee3 = callPrefix1;
callPrefix2 = runtime.safeCall(callee3.call(undefined, 2));
callee4 = callPrefix2;
tmp2 = runtime.safeCall(callee4.call(undefined, 39));
block$res4 = globalThis.Object.freeze([ tmp, tmp1, tmp2 ]);

The problem is: now we do not generate a class for each lambda function. Instead, we reuse the call method that is builtin in JS Function class. e.g., partial = runtime.checkCall(triple(1));. It is fine only in this transformer, but fails when we pass the generated code to the partial evaluator, since we cannot handle a lambda there. We still need to wrap those first-class-used lambda functions with a class, so that they can be partially evaluated. Meanwhile, I think we still want to keep applications like triple(1)(2)(39) straightforward, i.e., we do not want to introduce any class instance when evaluating it.

Comment on lines +39 to +45
// Recursive calls retain all their argument lists too.
:checkIR
:expect 42
fun sum(n: Int)(acc: Int): Int =
if n is 0 then acc else sum(n - 1)(acc + n)
sum(6)(21)
//│ = 42

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples (including the add(40)(2) above) are a bit too simple, so we always get inlined computations for calls like sum(6)(21), with or without :noOpt. The following one could be better for demonstrating that a curried call is correctly transformed:

:noOpt
:ssjs
fun fib(x)(y)(n) =
  if n is
    0 then x
    1 then y
    else fib(y)(x + y)(n - 1)
fib(0)(1)(10)
let f = fib(0)(1)
f(10)
//│ —————————————| JS (sanitized) |—————————————————————————————————————————————————————————————————————
//│ let fib, f, callee5, fib$tailrec, x, y, n, inlinedVal2;
//│ fib$tailrec = function fib$tailrec(x1, y1, n1) {
//│   runtime.checkArgs("fib$tailrec", 3, arguments.length);
//│   loopLabel: while (true) {
//│     let tmp3, tmp4;
//│     switch (n1) {
//│       case 0:
//│         return x1;
//│       case 1:
//│         return y1;
//│     }
//│     tmp3 = x1 + y1;
//│     tmp4 = n1 - 1;
//│     x1 = y1;
//│     y1 = tmp3;
//│     n1 = tmp4;
//│     continue loopLabel;
//│   }
//│ };
//│ fib = function fib(x1) {
//│   runtime.checkArgs("fib", 1, arguments.length);
//│   return (y1) => {
//│     return (n1) => {
//│       return runtime.checkCall(fib$tailrec(x1, y1, n1))
//│     }
//│   }
//│ };
//│ x = 0;
//│ y = 1;
//│ n = 10;
//│ inlinedLbl2: {
//│   inlinedVal2 = runtime.checkCall(fib$tailrec(x, y, n));
//│   break inlinedLbl2;
//│ }
//│ f = runtime.checkCall(fib(0)(1));
//│ callee5 = f;
//│ block$res5 = runtime.safeCall(callee5.call(undefined, 10));
//│ —————————————————| Output |—————————————————————————————————————————————————————————————————————————
//│ = 55
//│ f = fun

Comment on lines +63 to +66
// Incoming curried JS values receive undefined at every bare application.
:noOpt
:expect [42, 42]
val native: Int -> Int -> Int = globalThis.eval("(function (x) { 'use strict'; if (this !== undefined) throw new Error('outer receiver'); return function (y) { if (this !== undefined) throw new Error('inner receiver'); return x + y; }; })")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wonder if now we can handle an imported module (with or without :ftc) member correctly, with the new symbol resolution.

Btw, this example should be fine if we do not transform it into a class instance. The partial evaluator should treat globalThis.eval dynamically, so we don't need to consider how to specialize the function it returns.

sym.irFunDefn.map(_.params).orElse:
sym.defn.map(_.params.map(LoweredParamList.callingConvention))

private def function(p: Path): Opt[(TermSymbol, LoweredParamList)] =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name function is probably too simple to reflect the things done inside.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants