Skip to content

Commit

Permalink
Extract psalm plugin from fp4php/functional repository
Browse files Browse the repository at this point in the history
  • Loading branch information
klimick committed May 19, 2023
0 parents commit dadb29e
Show file tree
Hide file tree
Showing 108 changed files with 6,953 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
vendor
composer.lock
34 changes: 34 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "fp4php/functional-psalm-plugin",
"type": "psalm-plugin",
"authors": [
{
"name": "Andrew K.",
"email": "klimichkartorgnusov@gmail.com"
}
],
"license": "MIT",
"require": {
"php": "^8.1",
"ext-simplexml": "*"
},
"require-dev": {
"vimeo/psalm": "^5.7",
"fp4php/functional": "dev-psalm-v5"
},
"autoload": {
"psr-4": {
"Fp\\PsalmPlugin\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Fp\\PsalmPlugin\\Test\\": "tests"
}
},
"extra": {
"psalm": {
"pluginClass": "FunctionalPlugin"
}
}
}
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: '3.6'
services:
php:
container_name: functional-psalm-plugin
build:
context: ./docker/php
dockerfile: Dockerfile
tty: true
volumes:
- ./:/app
30 changes: 30 additions & 0 deletions docker/php/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
FROM php:8.1-cli

ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions

RUN apt-get update -y && apt-get install -y curl unzip pandoc

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/bin --filename=composer --quiet

RUN install-php-extensions xdebug

COPY ./ "$PHP_INI_DIR/conf.d"

ARG HOST_UID=1000
ARG HOST_GID=1000
ARG HOST_USER=docker-user
ARG HOST_GROUP=docker-group

RUN echo '%sudonopswd ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers \
&& groupadd -g $HOST_GID $HOST_GROUP \
&& groupadd sudonopswd \
&& useradd -m -l -g $HOST_GROUP -u $HOST_UID $HOST_USER \
&& usermod -aG sudo $HOST_USER \
&& usermod -aG sudonopswd $HOST_USER \
&& chown -R $HOST_USER:$HOST_GROUP /opt \
&& chmod 755 /opt

USER $HOST_USER

WORKDIR /app
38 changes: 38 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0"?>
<psalm
errorLevel="1"
resolveFromConfigFile="true"
findUnusedCode="false"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true"
>
<projectFiles>
<directory name="src" />
<directory name="tests" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<InternalMethod>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</InternalMethod>
<InternalClass>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</InternalClass>
<InternalProperty>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</InternalProperty>
</issueHandlers>
<plugins>
<pluginClass class="Fp\PsalmPlugin\FunctionalPlugin"/>
</plugins>
</psalm>
74 changes: 74 additions & 0 deletions src/FunctionalPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Fp\PsalmPlugin;

use Fp\PsalmPlugin\Plugin\Hook\AfterExpressionAnalysis\ProveTrueExpressionAnalyzer;
use Fp\PsalmPlugin\Plugin\Hook\DynamicFunctionStorageProvider\PipeFunctionStorageProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\CtorFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\FilterFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\FilterNotNullFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\FoldFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\PartialFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\PartitionFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\PluckFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\SequenceEitherFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\FunctionReturnTypeProvider\SequenceOptionFunctionReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\CollectionFilterMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\EitherFilterOrElseMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\EitherGetReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\FoldMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\MapTapNMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\OptionFilterMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\OptionGetReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\PluckMethodReturnTypeProvider;
use Fp\PsalmPlugin\Plugin\Hook\MethodReturnTypeProvider\SeparatedToEitherMethodReturnTypeProvider;
use Fp\PsalmPlugin\Toolkit\PsalmApi;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface;
use SimpleXMLElement;

/**
* Plugin entrypoint
*/
final class FunctionalPlugin implements PluginEntryPointInterface
{
public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void
{
PsalmApi::init();

$register = function(string $hook) use ($registration): void {
if (class_exists($hook)) {
$registration->registerHooksFromClass($hook);
}
};

$register(ProveTrueExpressionAnalyzer::class);

$register(OptionGetReturnTypeProvider::class);
$register(EitherGetReturnTypeProvider::class);
$register(EitherFilterOrElseMethodReturnTypeProvider::class);

$register(PartialFunctionReturnTypeProvider::class);
$register(PartitionFunctionReturnTypeProvider::class);
$register(PluckFunctionReturnTypeProvider::class);

$register(FilterFunctionReturnTypeProvider::class);
$register(CollectionFilterMethodReturnTypeProvider::class);
$register(OptionFilterMethodReturnTypeProvider::class);

$register(SequenceOptionFunctionReturnTypeProvider::class);
$register(SequenceEitherFunctionReturnTypeProvider::class);

$register(FilterNotNullFunctionReturnTypeProvider::class);
$register(FoldFunctionReturnTypeProvider::class);
$register(FoldMethodReturnTypeProvider::class);
$register(MapTapNMethodReturnTypeProvider::class);
$register(PluckMethodReturnTypeProvider::class);
$register(CtorFunctionReturnTypeProvider::class);
$register(SeparatedToEitherMethodReturnTypeProvider::class);

$register(PipeFunctionStorageProvider::class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace Fp\PsalmPlugin\Plugin\Hook\AfterExpressionAnalysis;

use Fp\Functional\Option\Option;
use PhpParser\Node;
use PhpParser\Node\Expr\Yield_;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use Psalm\Node\Expr\VirtualFuncCall;
use Psalm\Node\Name\VirtualFullyQualified;
use Psalm\Internal\Analyzer\ClosureAnalyzer;
use Psalm\Internal\Analyzer\Statements\Expression\Call\FunctionCallAnalyzer;
use Psalm\Internal\Analyzer\StatementsAnalyzer;
use Psalm\Plugin\EventHandler\AfterExpressionAnalysisInterface;
use Psalm\Plugin\EventHandler\Event\AfterExpressionAnalysisEvent;

use function Fp\Collection\sequenceOptionT;
use function Fp\Evidence\proveNonEmptyArray;
use function Fp\Evidence\of;

final class ProveTrueExpressionAnalyzer implements AfterExpressionAnalysisInterface
{
public static function getFunctionIds(): array
{
return [strtolower('Fp\Evidence\proveTrue')];
}

public static function afterExpressionAnalysis(AfterExpressionAnalysisEvent $event): ?bool
{
sequenceOptionT(
fn() => Option::some($event->getExpr())
->flatMap(of(Yield_::class))
->flatMap(self::getProveTrueArgsFromYield(...)),
fn() => Option::some($event->getStatementsSource())
->flatMap(of(StatementsAnalyzer::class))
->filter(fn(StatementsAnalyzer $source) => $source->getSource() instanceof ClosureAnalyzer),
fn() => Option::some($event),
)->tapN(self::assert(...));

return null;
}

/**
* @param Node\Arg[] $prove_true_args
*/
private static function assert(
array $prove_true_args,
StatementsAnalyzer $analyzer,
AfterExpressionAnalysisEvent $event,
): void {
FunctionCallAnalyzer::analyze(
$analyzer,
new VirtualFuncCall(
new VirtualFullyQualified('assert'),
$prove_true_args,
),
$event->getContext(),
);
}

/**
* @psalm-return Option<Node\Arg[]>
*/
private static function getProveTrueArgsFromYield(Yield_ $expr): Option
{
$visitor = new class extends NodeVisitorAbstract {
/** @var Node\Arg[] */
public array $proveTrueArgs = [];

public function leaveNode(Node $node): ?int
{
$this->proveTrueArgs = Option::some($node)
->flatMap(of(FuncCall::class))
->filter(fn(FuncCall $n) => 'Fp\Evidence\proveTrue' === $n->name->getAttribute('resolvedName'))
->filter(fn(FuncCall $n) => !$n->isFirstClassCallable())
->map(fn(FuncCall $n) => $n->getArgs())
->getOrElse([]);

return !empty($this->proveTrueArgs)
? NodeTraverser::STOP_TRAVERSAL
: null;
}
};

$traverser = new NodeTraverser();
$traverser->addVisitor($visitor);
$traverser->traverse([$expr]);

return proveNonEmptyArray($visitor->proveTrueArgs);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Fp\PsalmPlugin\Plugin\Hook\DynamicFunctionStorageProvider;

use Fp\Collections\ArrayList;
use Fp\PsalmPlugin\Toolkit\PsalmApi;
use Psalm\Plugin\DynamicFunctionStorage;
use Psalm\Plugin\DynamicTemplateProvider;
use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface;
use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent;
use Psalm\Storage\FunctionLikeParameter;
use Psalm\Type\Atomic;
use Psalm\Type\Atomic\TCallable;

final class PipeFunctionStorageProvider implements DynamicFunctionStorageProviderInterface
{
public static function getFunctionIds(): array
{
return [
'fp\callable\pipe',
];
}

public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage
{
$templates = $event->getTemplateProvider();
$args_count = count($event->getArgs());

if ($args_count < 1) {
return null;
}

$pipe_callables = ArrayList
::range(start: 1, stopExclusive: $args_count)
->map(fn(int $offset) => self::createABCallable($offset, $templates));

$storage = new DynamicFunctionStorage();

$storage->params = $pipe_callables
->zipWithKeys()
->mapN(fn(int $offset, TCallable $callable) => self::createParam(
name: "fn_{$offset}",
type: $callable,
))
->prepended(self::createParam(
name: 'pipe_input',
type: $templates->createTemplate('T1'),
))
->toList();

$storage->return_type = $pipe_callables->lastElement()
->map(fn(TCallable $fn) => $fn->return_type)
->get();

$storage->templates = ArrayList
::range(start: 1, stopExclusive: $args_count + 1)
->map(fn($offset) => "T{$offset}")
->map($templates->createTemplate(...))
->toList();

return $storage;
}

private static function createABCallable(int $offset, DynamicTemplateProvider $templates): TCallable
{
return new TCallable(
value: 'callable',
params: [
self::createParam(
name: 'input',
type: $templates->createTemplate("T{$offset}"),
),
],
return_type: PsalmApi::$types->asUnion(
$templates->createTemplate('T'.($offset + 1)),
),
);
}

private static function createParam(string $name, Atomic $type): FunctionLikeParameter
{
return new FunctionLikeParameter(
name: $name,
by_ref: false,
type: PsalmApi::$types->asUnion($type),
);
}
}
Loading

0 comments on commit dadb29e

Please sign in to comment.