lua-language-server icon indicating copy to clipboard operation
lua-language-server copied to clipboard

[Feature Request] Automatically overload with `__call` metamethod.

Open mikuhl-dev opened this issue 1 year ago โ€ข 10 comments

There is already custom behavior when __index is defined in a metatable, why not __call? You can manually define the overload, but then you have to define the signature twice.

---@overload fun(a: string, b: number): boolean
local foo = setmetatable({}, {
    ---@param a string
    ---@param b number
    ---@return boolean
    __call = function(a, b)
        return true;
    end
});

mikuhl-dev avatar Oct 29 '24 20:10 mikuhl-dev

This seems to be feature requested long ago: https://github.com/LuaLS/lua-language-server/issues/95

tomlau10 avatar Oct 30 '24 01:10 tomlau10

This issue seems difficult to resolve, and I think maintaining the current state is a good option. For example, simulating a 'class' often involves more complex sub-processing logic, such as dispatching to functions like 'init' or 'alloc' defined in subclasses.

xuhuanzy avatar Oct 30 '24 05:10 xuhuanzy

Also I think the code snippet is a bit incorrect ๐Ÿ˜• the 1st param of __call will always be the table it self? i.e. foo(a, b) => __call(t=foo, a, b)

local foo = setmetatable({}, {
    ---@param a string
    ---@param b number
    ---@return boolean
    __call = function(a, b)
        print(a, b)
        return true
    end
});

print(foo)
foo(1, 2)
table: 0x7f8b47c09860
table: 0x7f8b47c09860	1
  • the 1st param a inside __call is actually the foo table

Thus the __call(t, a, b) signature is actually different from the actual call syntax foo(a, b) as seen by users This adds another difficulty as well.

tomlau10 avatar Oct 30 '24 10:10 tomlau10

Thus the __call(t, a, b) signature is actually different from the actual call syntax foo(a, b) as seen by users This adds another difficulty as well.

The signature for __call is different than the calling syntax but its the same as the call syntax for a table when you want to pass self as the first param.

TestClass:testFunction(1, 2)

In this case, testFunction would receive 3 parameters with the first one being a reference to the instance of TestClass.

And to be honest, most of the metamethods should be able to auto-detect the types coming in, even without them specified.

ChrisKader avatar Nov 02 '24 13:11 ChrisKader

The signature for __call is different than the calling syntax but its the same as the call syntax for a table when you want to pass self as the first param.

Yes, I know this __call metamethod as well as lua's : method call syntax. But I am saying that the function signature is different between the __call and the regular function call using that table, in the viewpoint of luals. ๐Ÿ˜•


Using your example TestClass:testFunction(a, b)

  • I know it is identical to TestClass.testFunction(TestClass, a, b). So in this case their function signature is the same, in which both of them accept 3 params: self, a, b.
  • However this is definitely different from TestClass.testFunction(a, b) which only accepts 2 params: a, b without self

So when we write a __call(t, a, b) in a metatable, certainly luals can detect the existence of such metamethod, but it cannot directly apply this function signature (fun(t, a, b)) to a t(a, b) call, since their function signatures are different. I guess this is where the difficulty lies in. ๐Ÿค” And thus requires a manual @overload fun(a, b) to table t

tomlau10 avatar Nov 03 '24 02:11 tomlau10

I want to contribute and actually get this working (along with some kind of ---@environment/proper setfenv support) but I wanted to possibly do it in the 4.0 branch though I cannot get it to build on macos

ChrisKader avatar Nov 17 '24 01:11 ChrisKader

I wanted to possibly do it in the 4.0 branch though I cannot get it to build on macos

Thanks for your interest in contributing ๐Ÿ˜„.

AFAIK the 4.0 branch is a WIP and a total rewrite of LuaLS, but it is not ready yet. I don't think it is a good time to PR to that branch now even if it is buildable, as that may affect the design architecture that maintainer has planned.

I am sure maintainer will announce it when it is ready.

tomlau10 avatar Nov 17 '24 03:11 tomlau10

I wanted to come back here and get/share for info.

---@class Test
local test = {}

LuaLS will "autocast" self as the functions prefix expression ONLY IF it is specified as the first parameter. Below, self has type of Test.

test.__newindex = function(self, k, v) end

If I name the first parameter of the function anything OTHER than self, LuaLS will not autocast. LuaLS will also not autocast any other parameter with in functions call signature as self, even if you name it that. Below, t is of type any. self, while colored differently then the other 2 parameters, is still of type any. This is a bit odd as Lua parameters are evaluated based on position.

Here, Lua assigns t as a reference to test.

test.__newindex = function(t, k, v) end
test.print = function(self, a, b) end

If I call __newindex as a "method", self is "autocast". This way I can create a function using one "signature"

test.__newindex = function(self, k, v)

LuaLS resolves the proper signature based on the calling method, regardless how it was originally "created".

test:__newindex(1, "key", "value")

Below, if I try to call test:__newindex with 3 params, LuaLS will warn me.

test:__newindex(1, "key", "value")

In this instance, LuaLS treats self on fun1 as any even tough its the first param provided to the prefixed function. But only when functions are created with the table.

---@class Test2 : table<number, boolean>
local test1 = {
    fun1 = function(self, a, b) end,
    __call = function(self, a, b)
        return true;
    end
}

setmetatable(test1, test1)

At this point, LuaLS shows fun1 asfunction fun1(self: any, a: any, b: any) -> boolean test1:fun1(1, 2)

But does not return a warning about too many params being provided.

-- or if its defined like this
test1.fun1 = function(self, a, b) end
function test1.fun1(self, a, b) end
function test1:fun1(a, b) end

In each example, the functions execute the same, but LuaLS but LuaLS handles self and signatures different and very consistently across situations.

> So when we write a `__call(t, a, b)` in a metatable, certainly luals can detect the existence of such metamethod, but it cannot directly **apply** this function signature (`fun(t, a, b)`) to a `t(a, b)` call, since their function signatures are different. I guess this is where the difficulty lies in. ๐Ÿค” And thus requires a manual `@overload fun(a, b)` to table `t`

I am confused here. Why is test1.__call handled differently than say, test1.fun1?

local r = test1:fun1(1, 2)

In this instance, r == true

Another example:

local test2 = {}
setmetatable(test2, test2)
test2.__call = function(self, ...)
    print(self, select("#",...), ...)
    return true;
end

local r2 = test2(1, 2, 3, 4, 5)

r2 == true

This would be the resulting print. table: 0X401bb480, 5, 1, 2, 3, 4, 5 to be printed.

In the sense of function signatures, metamethods (meta functions) should NOT be handled differently then functions. A function is a function, regardless of how its "created". If the getmetatable function is called on a class, then luaLS should treat functions on mt the same as "inheriting" another class.

---@class Test4
local test4 = {}
test4.this = true;

local mt = getmetatable(test1)
mt.__call = function(self, a, b)
    return true;
end

In this case, mt.__call should have self resolved to Test4. If we modify the return of getmetatable LuaLS should treat functions added to mt as functions as also being added to Test4 (and any future classes that inherit.

The above example is the same as:

---@class Test5
local test5 = setmetatable({
    this = true,
}, {
    __call = function(self, a, b)
        return true;
    end
})

The same idea of we call setmetatable(test6, test6)

---@class Test6
local test6 = {}
setmetatable(test6, test6)
test6.__call = function(...)
    return ...
end

In this case, self on __call resolves to Test6. Since setmetatable(test6, test6) is called, test6(true, 2, 1, true) should return test6, true, 2, 1, true

ChrisKader avatar Feb 14 '25 09:02 ChrisKader

Basically, when functions are "created" in certain ways, LuaLS "evaluates" them differently and inconsistently. This should not be the case though.

A functions calling method, parameters, and return values (the "signature") are evaluated by Lua the same way regardless of how they are called later. LuaLS does this as well, sometimes and not for all functions (like some metamethods).

source

A function call in Lua has the following syntax:

functioncall ::= prefixexp args

In a function call, first prefixexp and args are evaluated. If the value of prefixexp has type function, then this function is called with the given arguments. Otherwise, the prefixexp "call" metamethod is called, having as first parameter the value of prefixexp, followed by the original call arguments (see ยง2.8).

The form

functioncall ::= prefixexp `:ยด Name args`

can be used to call "methods". A call v:name(args) is syntactic sugar for v.name(v,args), except that v is evaluated only once.

Arguments have the following syntax:

args ::= `(ยด [explist] `)ยด
args ::= tableconstructor
args ::= String

All argument expressions are evaluated before the call. A call of the form f{fields} is syntactic sugar for f({fields}); that is, the argument list is a single new table. A call of the form f'string' (or f"string" or f[[string]]) is syntactic sugar for f('string'); that is, the argument list is a single literal string.

As an exception to the free-format syntax of Lua, you cannot put a line break before the '(' in a function call. This restriction avoids some ambiguities in the language. If you write

a = f
(g).x(a)

Lua would see that as a single statement, a = f(g).x(a). So, if you want two statements, you must add a semi-colon between them. If you actually want to call f, you must remove the line break before (g).

A call of the form return functioncall is called a tail call. Lua implements proper tail calls (or proper tail recursion): in a tail call, the called function reuses the stack entry of the calling function. Therefore, there is no limit on the number of nested tail calls that a program can execute. However, a tail call erases any debug information about the calling function. Note that a tail call only happens with a particular syntax, where the return has one single function call as argument; this syntax makes the calling function return exactly the returns of the called function. So, none of the following examples are tail calls:

return (f(x))        -- results adjusted to 1
return 2 * f(x)
return x, f(x)       -- additional results
f(x); return         -- results discarded
return x or f(x)     -- results adjusted to 1

2.5.9 โ€“ Function Definitions The syntax for function definition is

function ::= function funcbody
funcbody ::= `(ยด [parlist] `)ยด block end

The following syntactic sugar simplifies function definitions:

stat ::= function funcname funcbody
stat ::= local function Name funcbody
funcname ::= Name {`.ยด Name} [`:ยด Name]

The statement

function f () body end translates to

f = function () body end The statement

function t.a.b.c.f () body end translates to

t.a.b.c.f = function () body end The statement

local function f () body end translates to

local f; f = function () body end not to

local f = function () body end (This only makes a difference when the body of the function contains references to f.)

A function definition is an executable expression, whose value has type function. When Lua pre-compiles a chunk, all its function bodies are pre-compiled too. Then, whenever Lua executes the function definition, the function is instantiated (or closed). This function instance (or closure) is the final value of the expression. Different instances of the same function can refer to different external local variables and can have different environment tables.

Parameters act as local variables that are initialized with the argument values:

parlist ::= namelist [`,ยด `...ยด] | `...ยด

When a function is called, the list of arguments is adjusted to the length of the list of parameters, unless the function is a variadic or vararg function, which is indicated by three dots ('...') at the end of its parameter list. A vararg function does not adjust its argument list; instead, it collects all extra arguments and supplies them to the function through a vararg expression, which is also written as three dots. The value of this expression is a list of all actual extra arguments, similar to a function with multiple results. If a vararg expression is used inside another expression or in the middle of a list of expressions, then its return list is adjusted to one element. If the expression is used as the last element of a list of expressions, then no adjustment is made (unless that last expression is enclosed in parentheses).

As an example, consider the following definitions:

function f(a, b) end
function g(a, b, ...) end
function r() return 1,2,3 end

Then, we have the following mapping from arguments to parameters and to the vararg expression:

CALL            PARAMETERS

f(3)             a=3, b=nil
f(3, 4)          a=3, b=4
f(3, 4, 5)       a=3, b=4
f(r(), 10)       a=1, b=10
f(r())           a=1, b=2

g(3)             a=3, b=nil, ... -->  (nothing)
g(3, 4)          a=3, b=4,   ... -->  (nothing)
g(3, 4, 5, 8)    a=3, b=4,   ... -->  5  8
g(5, r())        a=5, b=1,   ... -->  2  3

Results are returned using the return statement (see ยง2.4.4). If control reaches the end of a function without encountering a return statement, then the function returns with no results.

The colon syntax is used for defining methods, that is, functions that have an implicit extra parameter self. Thus, the statement

function t.a.b.c:f (params) body end is syntactic sugar for t.a.b.c.f = function (self, params) body end

ChrisKader avatar Feb 14 '25 09:02 ChrisKader

Maybe I have been using the wrong term signature to express my viewpoint. ๐Ÿ˜• By signature, what I actually mean is the param list and its order.

Just consider the following:

  • fun(self, a, b)
  • fun(a, b)
  • do you think they are different?

Now consider the following

  • test.__call = function (self, a, b) => fun(self, a, b)
  • test = function (a, b) => fun(a, b)
  • do you think they are different?

I fully understand that with a __call metamethod, we can make a test = {} table to be callable => test(a, b). However, in the viewpoint of LuaLS, it would think that this call operation is calling a function with signature fun(a, b). This is different from the function signature you defined in test.__call = function (self, a, b). How can luals link the two?? ๐Ÿ˜ณ


I just read through your whole reply. If I understand correctly, you are talking / sharing another info related to how the param named self is auto inferred. ๐Ÿค” But this seems unrelated to what I want to express: fun(a, b) is different from fun(self, a, b).

Whether the 1st param in a t.__call() can be auto inferred or not is unrelated here. The key point is there exists an extra first param in the actual function signature.

---@class test1
local test1 = {}
test1.__call = function (t, a, b) --> `fun(t, a, b)`
    return a + b
end
setmetatable(test1, test1)

test1(1, 2) --> in luals's view, this is calling `fun(a, b)`
-- different from `test1.call: fun(t, a, b)`
-- param list is different
-- param ordering is different also
  • test1.__call is having a signature fun(t, a, b) (or fun(self, a, b), but that's not important)
  • if luals automatically overload test with this __call metamethod, this will be equal to adding @overload fun(t, a, b) to this test{} object
  • then this signature will certainly be different from how you actually call the test{} table. because when we use it, we want it's signature to be fun(a, b) ๐Ÿ˜• ๐Ÿ˜•

If I call __newindex as a "method", self is "autocast". This way I can create a function using one "signature"

test.__newindex = function(self, k, v)

LuaLS resolves the proper signature based on the calling method, regardless how it was originally "created".

test:__newindex(1, "key", "value")

Yes, your understanding and observation is correct ๐Ÿ‘

  • But the reason LuaLS can resolves the proper signature, is because you are using the colon call syntax test:__newindex. If you write the code as test.__newindex(1, "key", "value"), then it will be wrong.
  • Similary when you have a test.__call = function(self, a, b), it will only be correct if your write it as test:__call(a, b). But not when you write test.__call(a, b) or test(a, b). test(a, b) call syntax in the viewpoint of luals is just a function call like test.__call, not a method call.

I am confused here. Why is test1.__call handled differently than say, test1.fun1?

No, I didn't say it will be handled differently? They are handled the same. The difference comes from how you call the function

  • you will be calling test1.__call by just test(a, b) => fun(a, b)
    • but the actual signature of test.call is fun(t, a, b), so this is just unmatched
    • you will be getting incorrect param hint
  • for test1.fun1 you are using test1:fun1(a, b) == test1.fun1(test1, a, b) => fun(self, a, b)
    • this matches the actual test1.fun1 signature fun(self, a, b)
    • you can get correct param hint

In short, even if we auto overload the function signature of __call metamethod into the table, this signature is incorrect.


I don't know if I have made myself clear ๐Ÿ˜‚

tomlau10 avatar Feb 14 '25 12:02 tomlau10