Skip to content

Implement instance methods#429

Open
b-temirov wants to merge 11 commits intohkust-taco:hkmc2from
b-temirov:vmethods
Open

Implement instance methods#429
b-temirov wants to merge 11 commits intohkust-taco:hkmc2from
b-temirov:vmethods

Conversation

@b-temirov
Copy link
Copy Markdown
Contributor

This PR adds class-method support for non-inheriting classes by lowering instance methods to standalone direct-call wasm functions with an explicit this parameter, and a prescan that predeclares constructors and methods so self recursion, mutual recursion, later-method calls, and constructor calls to same or later classes work by construction.

//│ (i32.const 42))
//│ (ref.i31
//│ (i32.const 1))))
//│ (ref.cast (ref $Foo)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this new cast needed?

Copy link
Copy Markdown
Contributor Author

@b-temirov b-temirov Mar 30, 2026

Choose a reason for hiding this comment

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

Previously we did

val objType = ctx.getFuncInfo_!(ctorFuncIdx).body.resultType_!
call(funcidx = ctorFuncIdx, as.map(argument), Seq(Result(objType.asValType_!)))

in Instantiate(...) and used the body result type for the call. But now we are using predeclared ConstructorInfo, which doesn't include body result type, so the call using RefType.anyref is less precise, hence the cast

private def directConstructorCall(
      ctorInfo: ConstructorInfo,
      args: Seq[Expr],
  )(using Ctx): Expr =
    ref.cast(
      call(
        funcidx = FuncIdx(ctorInfo.funcId),
        operands = args,
        returnTypes = Seq(Result(RefType.anyref)),
      ),
      RefType(ctx.getType_!(ctorInfo.classBms), nullable = false),
    )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But now we are using predeclared ConstructorInfo, which doesn't include body result type

Why not?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We register ConstructorInfo in the prescan before body lowering so methods can reference constructors defined later in code. At that stage there is no emitted constructor body to get the result. But anyways removing cast seems to be fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔

@LPTK
Copy link
Copy Markdown
Contributor

LPTK commented Apr 1, 2026

Is this ready for review/merge?

@b-temirov
Copy link
Copy Markdown
Contributor Author

Is this ready for review/merge?

Not quite. Inheritance coming soon on top. Or should I raise inheritance separately?

@LPTK
Copy link
Copy Markdown
Contributor

LPTK commented Apr 1, 2026

Yes, better keep the PRs small and merge them regularly.

@b-temirov b-temirov marked this pull request as ready for review April 1, 2026 10:58
Copilot AI review requested due to automatic review settings April 1, 2026 10:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds WebAssembly backend support for instance/class methods on (non-inheriting) classes by lowering methods into standalone direct-call Wasm functions with an explicit receiver (this) parameter, plus a prescan that predeclares constructors/methods to support recursion and forward references.

Changes:

  • Add prescan metadata registration for constructors and methods, and emit lowered method functions.
  • Route constructor/method calls through direct call to the lowered functions (instead of indirect call_ref) where applicable.
  • Add/adjust Wasm golden tests for methods and for constructor calls in matching output.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
hkmc2/shared/src/test/mlscript/wasm/Methods.mls New golden tests covering getters, nullary/callable methods, recursion, forward references, chaining, mutation, and error cases.
hkmc2/shared/src/test/mlscript/wasm/Matching.mls Update golden WAT output to reflect direct constructor calls (call) rather than call_ref.
hkmc2/shared/src/main/scala/hkmc2/codegen/wasm/text/WatBuilder.scala Implement method/ctor metadata prescan, emit lowered method funcs, and add direct-call lowering for constructors/methods and related selection/assignment handling.
hkmc2/shared/src/main/scala/hkmc2/codegen/wasm/text/Ctx.scala Add MethodInfo/ConstructorInfo metadata storage and accessors in the Wasm codegen context.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Contributor

@LPTK LPTK left a comment

Choose a reason for hiding this comment

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

The logic seems quite complicated, for what this achieves. But let's try to merge this soon anyway, as soon as you have fixed what I point out.

Comment on lines +196 to +223
private def registerDefnCallableMetadata(b: Block)(using Ctx, Raise, Scope): Unit = b match
case Define(defn: ClsLikeDefn, rst) =>
registerClassCallableMetadata(defn)
registerDefnCallableMetadata(rst)
case Define(_, rst) =>
registerDefnCallableMetadata(rst)
case Match(_, arms, dflt, rst) =>
arms.foreach((_, body) => registerDefnCallableMetadata(body))
dflt.foreach(registerDefnCallableMetadata)
registerDefnCallableMetadata(rst)
case Begin(_, rst) =>
registerDefnCallableMetadata(rst)
case TryBlock(_, _, rst) =>
registerDefnCallableMetadata(rst)
case Assign(_, _, rst) =>
registerDefnCallableMetadata(rst)
case AssignField(_, _, _, rst) =>
registerDefnCallableMetadata(rst)
case AssignDynField(_, _, _, _, rst) =>
registerDefnCallableMetadata(rst)
case HandleBlock(_, _, _, _, _, _, _, rst) =>
registerDefnCallableMetadata(rst)
case Label(_, _, body, rst) =>
registerDefnCallableMetadata(body)
registerDefnCallableMetadata(rst)
case Scoped(_, body) =>
registerDefnCallableMetadata(body)
case _: BlockTail => ()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't this work?

Suggested change
private def registerDefnCallableMetadata(b: Block)(using Ctx, Raise, Scope): Unit = b match
case Define(defn: ClsLikeDefn, rst) =>
registerClassCallableMetadata(defn)
registerDefnCallableMetadata(rst)
case Define(_, rst) =>
registerDefnCallableMetadata(rst)
case Match(_, arms, dflt, rst) =>
arms.foreach((_, body) => registerDefnCallableMetadata(body))
dflt.foreach(registerDefnCallableMetadata)
registerDefnCallableMetadata(rst)
case Begin(_, rst) =>
registerDefnCallableMetadata(rst)
case TryBlock(_, _, rst) =>
registerDefnCallableMetadata(rst)
case Assign(_, _, rst) =>
registerDefnCallableMetadata(rst)
case AssignField(_, _, _, rst) =>
registerDefnCallableMetadata(rst)
case AssignDynField(_, _, _, _, rst) =>
registerDefnCallableMetadata(rst)
case HandleBlock(_, _, _, _, _, _, _, rst) =>
registerDefnCallableMetadata(rst)
case Label(_, _, body, rst) =>
registerDefnCallableMetadata(body)
registerDefnCallableMetadata(rst)
case Scoped(_, body) =>
registerDefnCallableMetadata(body)
case _: BlockTail => ()
private def registerDefnCallableMetadata(b: Block)(using Ctx, Raise, Scope): Unit = b match
case Define(defn: ClsLikeDefn, rst) =>
registerClassCallableMetadata(defn)
registerDefnCallableMetadata(rst)
case _ => b.subBlocks.foreach(subBlocks)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please also update createDefnTypes similarly.

case _: BlockMemberSymbol => disamb.getOrElse(l)
case sym => sym
resolved match
case _: ClassSymbol | _: ModuleOrObjectSymbol => true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't this trigger in

:lot
object Test
Test
//│ —————————————| Lowered IR Tree |————————————————————————————————————————————————————————————————————
//│ Program:
//│   main = Scoped(syms = {member:Test⁰}): 
//│     body = Define:
//│       defn = ClsLikeDefn:
//│         isym = object:Test¹
//│         sym = member:Test⁰
//│         k = Obj
//│       rest = Return: \
//│       res = Ref{disamb=object:Test¹}:
//│         l = member:Test⁰
//│         disamb = S of object:Test¹
//│       implct = true
//│ —————————————————| Output |—————————————————————————————————————————————————————————————————————————

?

Again, self-references never have a disamb and always contain the inner symbol directly.

sel.symbol.flatMap(_.asTrm).flatMap(ctx.getMethodInfo)

/** Returns whether `path` is an explicit owner reference such as a class or module path. */
private def isDirectOwnerRef(path: Path): Bool = path match
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this supposed to be named

Suggested change
private def isDirectOwnerRef(path: Path): Bool = path match
private def isSelfReference(path: Path): Bool = path match

?

val ctorCall = constructorInfoForPath(fun).map: ctorInfo =>
directConstructorCall(ctorInfo, args.map(argument))

val methodCall = fun match
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This will always be computed, even when ctorCall is defined! You should move this expensive code to the orElse argument or make this val a def.

)
lhs match
case ownerRef: Value.Ref if isDirectOwnerRef(ownerRef) =>
val resolvedOwner = ownerRef.l match
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For the same reason stated above, I don't think this makes a lot of sense.

//│ = 41


:wat
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We already have the :wat above, which I think is sufficient.

Suggested change
:wat

case S(fieldSym) =>
if ctx.getMethodInfo(fieldSym).nonEmpty then
errExpr(
Ls(msg"WatBuilder::returningTerm for AssignField(...) to class methods is not implemented yet" -> nme.toLoc),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think we ever want to allow assigning to an object method. This error should be a standard compilation error, not a "not implemented yet".

@LPTK LPTK requested a review from Derppening April 1, 2026 14:00
Copy link
Copy Markdown
Contributor

@Derppening Derppening left a comment

Choose a reason for hiding this comment

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

Just a few preliminary comments for now. I will need to look into this in more detail.

Comment on lines +197 to +199
enum MethodShape:
case Getter
case Callable
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't know there is a meaningful difference between bracketless vs bracketed invocations. AFAICT it seems like bracketless invocations are statically resolved and bracketed invocations are dynamically resolved.

@LPTK is this intended behavior?

Copy link
Copy Markdown
Contributor

@LPTK LPTK Apr 1, 2026

Choose a reason for hiding this comment

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

The resolution is the same. It's just the difference between taking an argument list, as in x.f(), and not taking one, as in x.f (the latter is declared as fun f = ...).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TODO: we don't really need this enum; just store the number of parameter lists that each method accepts.

Comment on lines +201 to +212
case class MethodInfo(
ownerIsym: InnerSymbol,
dSym: TermSymbol,
funcId: SymIdx,
shape: MethodShape,
)

case class ConstructorInfo(
classBms: BlockMemberSymbol,
funcId: SymIdx,
exportName: Opt[Str],
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Please add missing documentation.

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.

4 participants