mirror of
https://github.com/danog/psalm-plugin-laravel.git
synced 2024-12-02 09:37:47 +01:00
556 lines
17 KiB
PHP
556 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Psalm\LaravelPlugin\Support;
|
|
|
|
use Codeception\Exception\ModuleRequireException;
|
|
use Codeception\Exception\TestRuntimeException;
|
|
use Codeception\Module as BaseModule;
|
|
use Codeception\Module\Cli;
|
|
use Codeception\Module\Filesystem;
|
|
use Codeception\TestInterface;
|
|
use Composer\InstalledVersions;
|
|
use Composer\Semver\Semver;
|
|
use Composer\Semver\VersionParser;
|
|
use PackageVersions\Versions;
|
|
use PHPUnit\Framework\Assert;
|
|
use Behat\Gherkin\Node\PyStringNode;
|
|
use Behat\Gherkin\Node\TableNode;
|
|
use OutOfBoundsException;
|
|
use PHPUnit\Framework\SkippedTestError;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Duplicate of https://github.com/psalm/codeception-psalm-module
|
|
*/
|
|
class Module extends BaseModule
|
|
{
|
|
/** @var array<string,string> */
|
|
private const VERSION_OPERATORS = [
|
|
'newer than' => '>',
|
|
'older than' => '<',
|
|
];
|
|
|
|
private const DEFAULT_PSALM_CONFIG = "<?xml version=\"1.0\"?>\n"
|
|
. "<psalm totallyTyped=\"true\" %s>\n"
|
|
. " <projectFiles>\n"
|
|
. " <directory name=\".\"/>\n"
|
|
. " </projectFiles>\n"
|
|
. "</psalm>\n";
|
|
|
|
/**
|
|
* @var ?Cli
|
|
*/
|
|
private $cli;
|
|
|
|
/**
|
|
* @var ?Filesystem
|
|
*/
|
|
private $fs;
|
|
|
|
/**
|
|
* @var array<string,string>
|
|
* @psalm-suppress NonInvariantDocblockPropertyType
|
|
*/
|
|
protected array $config = [
|
|
'psalm_path' => 'vendor/bin/psalm',
|
|
'default_dir' => 'tests/_run/',
|
|
];
|
|
|
|
/** @var string */
|
|
private $psalmConfig = '';
|
|
|
|
/** @var string */
|
|
private $preamble = '';
|
|
|
|
/** @var ?array<int, array{type:string,message:string}> */
|
|
private $errors = null;
|
|
|
|
/** @var bool */
|
|
private $hasAutoload = false;
|
|
|
|
/** @var ?int */
|
|
private $exitCode = null;
|
|
|
|
/** @var ?string */
|
|
protected $output = null;
|
|
|
|
public function _beforeSuite($settings = []): void
|
|
{
|
|
$defaultDir = $this->config['default_dir'];
|
|
if (file_exists($defaultDir)) {
|
|
if (is_dir($defaultDir)) {
|
|
return;
|
|
}
|
|
unlink($defaultDir);
|
|
}
|
|
|
|
if (!mkdir($defaultDir, 0755, true)) {
|
|
throw new TestRuntimeException('Failed to create dir: ' . $defaultDir);
|
|
}
|
|
}
|
|
|
|
public function _before(TestInterface $test): void
|
|
{
|
|
$this->hasAutoload = false;
|
|
$this->errors = null;
|
|
$this->output = null;
|
|
$this->exitCode = null;
|
|
$this->config['psalm_path'] = realpath($this->config['psalm_path']);
|
|
$this->psalmConfig = '';
|
|
$this->fs()->cleanDir($this->config['default_dir']);
|
|
$this->preamble = '';
|
|
}
|
|
|
|
/**
|
|
* @param string[] $options
|
|
*/
|
|
public function runPsalmOn(string $filename, array $options = []): void
|
|
{
|
|
$suppressProgress = $this->packageSatisfiesVersionConstraint('vimeo/psalm', '>=3.4.0');
|
|
|
|
$options = array_map('escapeshellarg', $options);
|
|
$cmd = $this->config['psalm_path']
|
|
. ' --output-format=json '
|
|
. ($suppressProgress ? ' --no-progress ' : ' ')
|
|
. join(' ', $options) . ' '
|
|
. ($filename ? escapeshellarg($filename) : '')
|
|
. ' 2>&1';
|
|
$this->debug('Running: ' . $cmd);
|
|
$this->cli()->runShellCommand($cmd, false);
|
|
|
|
/** @psalm-suppress MissingPropertyType shouldn't be required, but older Psalm needs it */
|
|
$this->output = (string)$this->cli()->output;
|
|
/** @psalm-suppress MissingPropertyType shouldn't be required, but older Psalm needs it */
|
|
$this->exitCode = (int)$this->cli()->result;
|
|
|
|
$this->debug(sprintf('Psalm exit code: %d', $this->exitCode));
|
|
// $this->debug('Psalm output: ' . $this->output);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $options
|
|
*/
|
|
public function runPsalmIn(string $dir, array $options = []): void
|
|
{
|
|
$pwd = getcwd();
|
|
$this->fs()->amInPath($dir);
|
|
|
|
$config = $this->psalmConfig ?: self::DEFAULT_PSALM_CONFIG;
|
|
$config = sprintf($config, $this->hasAutoload ? 'autoloader="autoload.php"' : '');
|
|
|
|
$this->fs()->writeToFile('psalm.xml', $config);
|
|
|
|
$this->runPsalmOn('', $options);
|
|
$this->fs()->amInPath($pwd);
|
|
}
|
|
|
|
/**
|
|
* @Then I see exit code :code
|
|
*/
|
|
public function seeExitCode(string $exitCode): void
|
|
{
|
|
if ($this->exitCode === (int) $exitCode) {
|
|
return;
|
|
}
|
|
|
|
Assert::fail("Expected exit code {$exitCode}, got {$this->exitCode}");
|
|
}
|
|
|
|
public function seeThisError(string $type, string $message): void
|
|
{
|
|
$this->parseErrors();
|
|
if (empty($this->errors)) {
|
|
Assert::fail("No errors");
|
|
}
|
|
|
|
foreach ($this->errors as $i => $error) {
|
|
if (
|
|
$this->matches($type, $error['type'])
|
|
&& $this->matches($message, $error['message'])
|
|
) {
|
|
unset($this->errors[$i]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Assert::fail("Didn't see [ $type $message ] in: \n" . $this->remainingErrors());
|
|
}
|
|
|
|
private function matches(string $expected, string $actual): bool
|
|
{
|
|
$regexpDelimiter = '/';
|
|
if ($expected[0] === $regexpDelimiter && $expected[strlen($expected) - 1] === $regexpDelimiter) {
|
|
$regexp = $expected;
|
|
} else {
|
|
$regexp = $this->convertToRegexp($expected);
|
|
}
|
|
|
|
return (bool) preg_match($regexp, $actual);
|
|
}
|
|
|
|
/**
|
|
* @Then I see no errors
|
|
* @Then I see no other errors
|
|
*/
|
|
public function seeNoErrors(): void
|
|
{
|
|
$this->parseErrors();
|
|
if (!empty($this->errors)) {
|
|
Assert::fail("There were errors: \n" . $this->remainingErrors());
|
|
}
|
|
}
|
|
|
|
private function packageSatisfiesVersionConstraint(string $package, string $versionConstraint): bool
|
|
{
|
|
try {
|
|
$currentVersion = $this->getShortVersion($package);
|
|
} catch (OutOfBoundsException $ex) {
|
|
$this->debug(sprintf("Package %s is not installed", $package));
|
|
return false;
|
|
}
|
|
|
|
$this->debug(sprintf("Current version of %s : %s", $package, $currentVersion));
|
|
|
|
// todo: move to init/construct/before?
|
|
$parser = new VersionParser();
|
|
$currentVersion = $parser->normalize($currentVersion);
|
|
|
|
// restore pre-composer/semver:2.0 behaviour for comparison purposes
|
|
if (preg_match('/^dev-/', $currentVersion)) {
|
|
$currentVersion = '9999999-dev';
|
|
}
|
|
|
|
$result = Semver::satisfies($currentVersion, $versionConstraint);
|
|
|
|
$this->debug("Comparing $currentVersion against $versionConstraint => " . ($result ? 'ok' : 'ko'));
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @deprecated
|
|
* This method is only to maintain the public API; please use `self::haveADependencySatisfied` instead.
|
|
*/
|
|
public function seePsalmVersionIs(string $operator, string $version): bool
|
|
{
|
|
return $this->packageSatisfiesVersionConstraint('vimeo/psalm', $operator . $version);
|
|
}
|
|
|
|
/**
|
|
* @Given I have the following code preamble :code
|
|
*/
|
|
public function haveTheFollowingCodePreamble(PyStringNode $code): void
|
|
{
|
|
$this->preamble = $code->getRaw();
|
|
}
|
|
|
|
/**
|
|
* @When I run psalm
|
|
* @When I run Psalm
|
|
*/
|
|
public function runPsalm(): void
|
|
{
|
|
$this->runPsalmIn($this->config['default_dir']);
|
|
}
|
|
|
|
/**
|
|
* @When I run Psalm with dead code detection
|
|
* @When I run psalm with dead code detection
|
|
*/
|
|
public function runPsalmWithDeadCodeDetection(): void
|
|
{
|
|
$this->runPsalmIn($this->config['default_dir'], ['--find-dead-code']);
|
|
}
|
|
|
|
public function seePsalmHasTaintAnalysis(): bool
|
|
{
|
|
$taintAnalysisAvailable = $this->packageSatisfiesVersionConstraint('vimeo/psalm', '>=3.10.0');
|
|
return $taintAnalysisAvailable;
|
|
}
|
|
|
|
/**
|
|
* @Given I have Psalm with taint analysis
|
|
* @Given I have psalm with taint analysis
|
|
*/
|
|
public function havePsalmWithTaintAnalysis(): void
|
|
{
|
|
if (!$this->seePsalmHasTaintAnalysis()) {
|
|
/** @psalm-suppress InternalClass,InternalMethod */
|
|
throw new SkippedTestError("This scenario requires Psalm with taint analysis (3.10+)");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @When I run Psalm with taint analysis
|
|
* @When I run psalm with taint analysis
|
|
*/
|
|
public function runPsalmWithTaintAnalysis(): void
|
|
{
|
|
if (!$this->seePsalmHasTaintAnalysis()) {
|
|
Assert::fail('Taint analysis is available since 3.10.0');
|
|
}
|
|
$this->runPsalmIn($this->config['default_dir'], ['--track-tainted-input']);
|
|
}
|
|
|
|
/**
|
|
* @When I run Psalm on :arg1
|
|
* @When I run psalm on :arg1
|
|
*/
|
|
public function runPsalmOnASingleFile(string $file): void
|
|
{
|
|
$pwd = getcwd();
|
|
$this->fs()->amInPath($this->config['default_dir']);
|
|
|
|
$config = $this->psalmConfig ?: self::DEFAULT_PSALM_CONFIG;
|
|
$config = sprintf($config, $this->hasAutoload ? 'autoloader="autoload.php"' : '');
|
|
|
|
$this->fs()->writeToFile('psalm.xml', $config);
|
|
|
|
$this->runPsalmOn($file);
|
|
$this->fs()->amInPath($pwd);
|
|
}
|
|
|
|
|
|
/**
|
|
* @Given I have the following config :config
|
|
*/
|
|
public function haveTheFollowingConfig(PyStringNode $config): void
|
|
{
|
|
$this->psalmConfig = $config->getRaw();
|
|
}
|
|
|
|
/**
|
|
* @Given I have the following code :code
|
|
*/
|
|
public function haveTheFollowingCode(PyStringNode $code): void
|
|
{
|
|
$file = sprintf(
|
|
'%s/%s.php',
|
|
rtrim($this->config['default_dir'], '/'),
|
|
sha1($this->preamble . $code->getRaw())
|
|
);
|
|
|
|
$this->fs()->writeToFile(
|
|
$file,
|
|
$this->preamble . $code
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @Given I have some future Psalm that supports this feature :ref
|
|
*/
|
|
public function haveSomeFuturePsalmThatSupportsThisFeature(string $ref): void
|
|
{
|
|
/** @psalm-suppress InternalClass,InternalMethod */
|
|
throw new SkippedTestError("Future functionality that Psalm has yet to support: $ref");
|
|
}
|
|
|
|
/**
|
|
* @Given /I have Psalm (newer than|older than) "([0-9.]+)" \(because of "([^"]+)"\)/
|
|
*/
|
|
public function havePsalmOfACertainVersionRangeBecauseOf(string $operator, string $version, string $reason): void
|
|
{
|
|
if (!isset(self::VERSION_OPERATORS[$operator])) {
|
|
throw new TestRuntimeException("Unknown operator: $operator");
|
|
}
|
|
|
|
/**
|
|
* @psalm-suppress RedundantCondition it's not redundant with older Psalm version
|
|
* @psalm-suppress RedundantCast
|
|
*/
|
|
$op = (string) self::VERSION_OPERATORS[$operator];
|
|
|
|
if (!$this->packageSatisfiesVersionConstraint('vimeo/psalm', $op . $version)) {
|
|
/** @psalm-suppress InternalClass,InternalMethod */
|
|
throw new SkippedTestError("This scenario requires Psalm $op $version because of $reason");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @Then I see these errors
|
|
*/
|
|
public function seeTheseErrors(TableNode $list): void
|
|
{
|
|
/** @psalm-suppress MixedAssignment */
|
|
foreach (array_values($list->getRows()) as $i => $error) {
|
|
assert(is_array($error));
|
|
if (0 === $i) {
|
|
continue;
|
|
}
|
|
$this->seeThisError((string) $error[0], (string) $error[1]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @Given I have the following code in :arg1 :arg2
|
|
*/
|
|
public function haveTheFollowingCodeIn(string $filename, string $code): void
|
|
{
|
|
$file = rtrim($this->config['default_dir'], '/') . '/' . $filename;
|
|
$this->fs()->writeToFile($file, $code);
|
|
}
|
|
|
|
/**
|
|
* @Given I have the following autoload map
|
|
* @Given I have the following classmap
|
|
* @Given I have the following class map
|
|
*/
|
|
public function haveTheFollowingAutoloadMap(TableNode $list): void
|
|
{
|
|
$map = [];
|
|
/** @psalm-suppress MixedAssignment */
|
|
foreach (array_values($list->getRows()) as $i => $row) {
|
|
assert(is_array($row));
|
|
if (0 === $i) {
|
|
continue;
|
|
}
|
|
assert(is_string($row[0]));
|
|
assert(is_string($row[1]));
|
|
$map[] = [$row[0], $row[1]];
|
|
}
|
|
|
|
$code = sprintf(
|
|
'<?php
|
|
spl_autoload_register(function(string $class) {
|
|
/** @var ?array<string,string> $classes */
|
|
static $classes = null;
|
|
if (null === $classes) {
|
|
$classes = [%s];
|
|
}
|
|
if (array_key_exists($class, $classes)) {
|
|
/** @psalm-suppress UnresolvableInclude */
|
|
include $classes[$class];
|
|
}
|
|
});',
|
|
join(
|
|
',',
|
|
array_map(
|
|
function (array $row): string {
|
|
return "\n'$row[0]' => '$row[1]'";
|
|
},
|
|
$map
|
|
)
|
|
)
|
|
);
|
|
$file = rtrim($this->config['default_dir'], '/') . '/' . 'autoload.php';
|
|
$this->fs()->writeToFile($file, $code);
|
|
$this->hasAutoload = true;
|
|
}
|
|
|
|
/**
|
|
* @Given I have the :package package satisfying the :versionConstraint
|
|
*/
|
|
public function haveADependencySatisfied(string $package, string $versionConstraint): void
|
|
{
|
|
if ($this->packageSatisfiesVersionConstraint($package, $versionConstraint)) {
|
|
return;
|
|
}
|
|
|
|
/** @psalm-suppress InternalClass,InternalMethod */
|
|
throw new SkippedTestError("This scenario requires $package to match $versionConstraint");
|
|
}
|
|
|
|
private function convertToRegexp(string $in): string
|
|
{
|
|
return '@' . str_replace('%', '.*', preg_quote($in, '@')) . '@';
|
|
}
|
|
|
|
private function cli(): Cli
|
|
{
|
|
if (null === $this->cli) {
|
|
$cli = $this->getModule('Cli');
|
|
if (!$cli instanceof Cli) {
|
|
throw new ModuleRequireException($this, 'Needs Cli module');
|
|
}
|
|
$this->cli = $cli;
|
|
}
|
|
return $this->cli;
|
|
}
|
|
|
|
private function fs(): Filesystem
|
|
{
|
|
if (null === $this->fs) {
|
|
$fs = $this->getModule('Filesystem');
|
|
if (!$fs instanceof Filesystem) {
|
|
throw new ModuleRequireException($this, 'Needs Filesystem module');
|
|
}
|
|
$this->fs = $fs;
|
|
}
|
|
return $this->fs;
|
|
}
|
|
|
|
private function remainingErrors(): string
|
|
{
|
|
$this->parseErrors();
|
|
return (string) new TableNode(array_map(
|
|
function (array $error): array {
|
|
return [
|
|
'type' => $error['type'] ?? '',
|
|
'message' => $error['message'] ?? '',
|
|
];
|
|
},
|
|
$this->errors
|
|
));
|
|
}
|
|
|
|
private function getShortVersion(string $package): string
|
|
{
|
|
/** @psalm-suppress DeprecatedClass Versions is marked deprecated for no good reason */
|
|
if (class_exists(InstalledVersions::class)) {
|
|
/** @psalm-suppress UndefinedClass Composer\InstalledVersions is undefined when using Composer 1.x */
|
|
return (string) InstalledVersions::getPrettyVersion($package);
|
|
} elseif (class_exists(Versions::class)) {
|
|
/**
|
|
* @psalm-suppress ArgumentTypeCoercion Versions::getVersion() has too narrow a signature
|
|
* @psalm-suppress RedundantCondition not redundant with older Psalm
|
|
* @psalm-suppress RedundantCast
|
|
*/
|
|
$version = (string) Versions::getVersion($package);
|
|
} else {
|
|
throw new RuntimeException(
|
|
'Cannot determine versions. Neither of composer:2+,'
|
|
. ' ocramius/package-version or composer/package-versions-deprecated are installed.'
|
|
);
|
|
}
|
|
|
|
if (false === strpos($version, '@')) {
|
|
throw new RuntimeException('$version must contain @');
|
|
}
|
|
|
|
return explode('@', $version)[0];
|
|
}
|
|
|
|
/**
|
|
* @psalm-assert !null $this->errors
|
|
*/
|
|
private function parseErrors(): void
|
|
{
|
|
if (null !== $this->errors) {
|
|
return;
|
|
}
|
|
|
|
if (empty($this->output)) {
|
|
$this->errors = [];
|
|
return;
|
|
}
|
|
|
|
/** @psalm-suppress MixedAssignment */
|
|
$errors = json_decode($this->output, true);
|
|
|
|
if (null === $errors && json_last_error() !== JSON_ERROR_NONE && 0 !== $this->exitCode) {
|
|
Assert::fail("Failed to parse output: " . $this->output . "\nError:" . json_last_error_msg());
|
|
}
|
|
|
|
$this->errors = array_map(
|
|
function (array $row): array {
|
|
return [
|
|
'type' => (string) $row['type'],
|
|
'message' => (string) $row['message'],
|
|
];
|
|
},
|
|
array_values((array)$errors)
|
|
);
|
|
$this->debug($this->remainingErrors());
|
|
}
|
|
}
|