Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Missing support for templates resolution in closures/callables/first class callables #7244

Open
veewee opened this issue Dec 30, 2021 · 10 comments

Comments

@veewee
Copy link
Contributor

veewee commented Dec 30, 2021

The templates on closures and callables are not resolved into the actual provided types when a FuncCall is performed on it.

This results in following issues:

Output:

INFO: Trace - 9:32 - $stages: pure-Closure(Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed...):Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed

ERROR: InvalidArgument - 10:21 - Argument 1 expects Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed, "hello" provided

INFO: Trace - 11:30 - $_res: Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed

Example code:

<?php
/**
 * @psalm-suppress ForbiddenCode
 */
function test(): void
{
    $a = [A::class , 'pipe'];
    $stages = Closure::fromCallable($a);
    /** @psalm-trace $stages */;
    $_res = $stages('hello');
    /** @psalm-trace $_res */;
}

class A
{
    /**
     * @template T
     *
     * @param Closure(T): T ...$stages
     *
     * @return Closure(T): T
     *
     * @pure
     */
    function pipe(...$stages): callable
    {
        return array_pop($stages);
    }
}
test();
@psalm-github-bot
Copy link

I found these snippets:

https://psalm.dev/r/0df3da05ef
<?php
/**
 * @psalm-suppress ForbiddenCode
 */
function test(): void
{
    $a = [A::class , 'pipe'];
    $stages = Closure::fromCallable($a);
    /** @psalm-trace $stages */;
    $_res = $stages('hello');
    /** @psalm-trace $_res */;
}

class A
{
    /**
     * @template T
     *
     * @param Closure(T): T ...$stages
     *
     * @return Closure(T): T
     *
     * @pure
     */
    function pipe(...$stages): callable
    {
        return array_pop($stages);
    }
}
test();
Psalm output (using commit 03b7e94):

INFO: Trace - 9:32 - $stages: pure-Closure(Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed...):Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed

ERROR: InvalidArgument - 10:21 - Argument 1 expects Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed, "hello" provided

INFO: Trace - 11:30 - $_res: Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed

@orklah
Copy link
Collaborator

orklah commented Dec 30, 2021

I think my code example with callable was wrong, here's the fix: https://psalm.dev/r/75a4ef108e

But the conclusion is the same...

@psalm-github-bot
Copy link

I found these snippets:

https://psalm.dev/r/75a4ef108e
<?php
/**
 * @psalm-suppress ForbiddenCode
 */
function test(): void
{
    $a = [A::class , 'pipe'];
    $stages = Closure::fromCallable($a)();
    /** @psalm-trace $stages */;
    $_res = $stages('hello');
    /** @psalm-trace $_res */;
}

class A
{
    /**
     * @template T
     *
     * @param Closure(T): T ...$stages
     *
     * @return Closure(T): T
     *
     * @pure
     */
    function pipe(...$stages): callable
    {
        return array_pop($stages);
    }
}
test();
Psalm output (using commit 7b18673):

INFO: Trace - 9:32 - $stages: Closure(T:fn-a::pipe as mixed):T:fn-a::pipe as mixed

ERROR: InvalidArgument - 10:21 - Argument 1 expects T:fn-a::pipe as mixed, "hello" provided

INFO: Trace - 11:30 - $_res: T:fn-a::pipe as mixed

@veewee
Copy link
Contributor Author

veewee commented Jan 22, 2022

@orklah
This problem also applies to first class callable syntax. Which is a bit annoying, since you'dd expect it to resolve the template types.

Is there any entrypoint in code I can look at to check if I can create a fix for this issue?
Or would it be quite complex to implement?

https://psalm.dev/r/8d405f8213

<?php

/**
 * @template T
 * @param T $i
 * @return T
 */
function debug(mixed $i): mixed {
	return $i;
}


$x = debug('hello');
$y = debug(...)($x);


/** @psalm-trace $y */

@veewee veewee changed the title Missing support for templates resolution in closures/callables Missing support for templates resolution in closures/callables/first class callables Jan 22, 2022
@orklah
Copy link
Collaborator

orklah commented Jan 22, 2022

I'm not sure :(

But I'd guess the first thing to begin would be to make sure templates are kept through closures:
https://psalm.dev/r/01e2acbe39

This should not be mixed if we want this to work

@psalm-github-bot
Copy link

I found these snippets:

https://psalm.dev/r/01e2acbe39
<?php

/**
 * @template T
 * @param T $i
 * @return T
 */
function debug(mixed $i): mixed {
	return $i;
}


$x = debug('hello');
$y = debug(...);


/** @psalm-trace $x */;
/** @psalm-trace $y */;
Psalm output (using commit d1a946c):

INFO: Trace - 17:23 - $x: "hello"

INFO: Trace - 18:23 - $y: impure-Closure(mixed):mixed

INFO: UnusedVariable - 13:1 - $x is never referenced or the value is not used

INFO: UnusedVariable - 14:1 - $y is never referenced or the value is not used

@veewee
Copy link
Contributor Author

veewee commented Jan 27, 2022

@klimick : would it (theoretically) be possible to apply the higher order logic for first class callables as well?
It kinda behaves like a higher order function at this moment?

list_filter(...) -> Closure(list<T>, callable (T): bool): list<T>

If we were able to dynamically pass the generics from the underlying type - it could fix template resolving in first class callables?

Additional possible case that might need a fix:
As described in #7471, there is also a case for nesting variadic generic functions:

pipe(
    $numbers,
    partial_left(custom_array_map(...), fn($i) => $i + 1)
)

where both pipe, partial_left and custom_array_map are functions with variadic generic templates.
How can psalm, in this case, map the result of custom_array_map(...) to a dynamic function signature?

The signatrue of custom_array_map might look like this

/**
 * @param list<mixed> $args
 * @return list<mixed>
 */
function custom_array_map(...$args) {
    //...
}

The args are being parsed by the dynamic function storage plugin.
This is a hard issue, because it requires the context of the parent function to see how the of will be used.
Basically you want to run the dynamic function storage plugin on the result of the first class callable instead of on the function call itself.

@klimick
Copy link
Contributor

klimick commented Jan 28, 2022

@veewee

Check examples for partialRight and partialLeft:
https://github.com/klimick/psalm/blob/partiall-application-example/partial-left-example.php
https://github.com/klimick/psalm/blob/partiall-application-example/partial-right-example.php

It works with first-class-callable syntax. But plugin for partial use many internal api:
https://github.com/klimick/psalm/blob/partiall-application-example/tests/Config/Plugin/Hook/PartialFunctionStoragePlugin.php

In my case. I only care about the name of the function that will be partially applied.
By this reason the first-class-callable syntax works fine. I just catch function name from foo(...).
I would even start using this plugin. But for some people, using Psalm internal api will not give rest.

Any ideas how to make tempalte related api as public?
Or do you have radically different ideas for plugin implementation?

P.S. I would postpone difficult cases, like custom_array_map.

@veewee
Copy link
Contributor Author

veewee commented Jan 28, 2022

@klimick,

Quickly scrolled through the code.
Since I dont understand everything in the plugin yet, I will go in details somewhere next week to see if I can give a pov on a public api.

It looks nice, but my main concern is this:
Now, your plugin deals with the first class callables placed in the first arguments.
Depending on the partial function, it might take multiple first class callables.
E.g.:

partialLeft(mapMany(...), map1(...), map2(...), map3(...))

So my general feeling here, is that psalm should provide a system that deals with templates in first class callable syntax by itself. Otherwise you'll have to always take care of this stuff in your plugin. I currently don't have a clue how this should work.

Would a similar approach in psalm internal analyzers be able to dynamically fetch+pass the storage to the first-class called function be possible?

Alternatively, there could be a helper function for these kind of actions in plugins. But that most likely would be a "hacky" solution.

@veewee
Copy link
Contributor Author

veewee commented Apr 29, 2022

Spent most of the day investigating this issue.

I haven't found a way for inferring templates on closures...
Both the code and some issues in this repo really warn to not try to set templated parameters on closures.
For example:

Having templates support on first class callables however, is something that we must get working at some point.
Phpstan supports it as well.

I've set up a draft PR that enables template inferring on first class callable functions.
This still does not work on methods and static methods at the moment.

See #7910

Feel free to take a look and give me pointers in order to get a workable solution.
It's quite hard for me to understand every analyzer at this moment.

/cc @klimick , @orklah

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants