Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
fail-fast: false
matrix:
php:
- 7.4
- 8.0
- 8.1
- 8.2
Expand Down Expand Up @@ -54,7 +53,7 @@ jobs:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: 7.4
php-version: 8.0
coverage: none
- name: Build
run: composer update --no-interaction --no-progress --prefer-dist --prefer-stable
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ This extension specifies types of values passed to:
* `Assert::notSame()`
* `Assert::type()`
* `Assert::count()`
* `Assert::hasKey()`
* `Assert::hasNotKey()`


## Installation
Expand All @@ -61,7 +63,7 @@ If you have enabled `checkAlwaysTrueCheckTypeFunctionCall: true`, you will need
```
parameters:
ignoreErrors:
- '~Call to static method Tester\\Assert::(type|count|same|notSame)\(\) with .* and .* will always evaluate to true\.~'
- '~Call to static method Tester\\Assert::(type|count|same|notSame|hasKey|hasNotKey)\(\) with .* and .* will always evaluate to true\.~'
- '~Call to static method Tester\\Assert::(null|notNull|true|false|truthy|falsey|nan)\(\) with .* will always evaluate to true\.~'
```

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
}
],
"require": {
"php": ">=7.4 <8.6",
"php": ">=8.0 <8.6",
"nikic/php-parser": "^4.13.2 || ^v5.0.2",
"phpstan/phpstan": "^2.0"
},
Expand Down Expand Up @@ -51,7 +51,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
"dev-master": "2.1-dev"
},
"phpstan": {
"includes": [
Expand Down
34 changes: 34 additions & 0 deletions src/Type/NetteTester/AssertMethodExpressionResolversProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PhpParser\Node\Expr\BinaryOp\Equal;
use PhpParser\Node\Expr\BinaryOp\Identical;
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
use PhpParser\Node\Expr\BooleanNot;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Instanceof_;
Expand All @@ -30,6 +31,11 @@ final class AssertMethodExpressionResolversProvider
*/
private static ?array $typeResolvers = null;

/**
* @var array<string, int>|NULL
*/
private static ?array $requiredArgumentCounts = null;

/**
* @return array<string, \Closure(Scope $scope, Arg $value): Expr|\Closure(Scope $scope, Arg $value1, Arg $value2): ?Expr>
*/
Expand Down Expand Up @@ -118,12 +124,40 @@ public static function getResolvers(): array
$count->value,
),
),
'hasKey' => fn (Scope $scope, Arg $key, Arg $value): Expr => new FuncCall(
new Name('array_key_exists'),
[$key, $value],
),
'hasNotKey' => fn (Scope $scope, Arg $key, Arg $value): Expr => new BooleanNot(
new FuncCall(
new Name('array_key_exists'),
[$key, $value],
),
),
];
}

return self::$resolvers;
}

/**
* Number of `Arg` parameters (i.e. excluding the leading `Scope`) each resolver needs to be called with.
*
* @return array<string, int>
*/
public static function getRequiredArgumentCounts(): array
{
if (self::$requiredArgumentCounts === null) {
$requiredArgumentCounts = [];
foreach (self::getResolvers() as $name => $resolver) {
$requiredArgumentCounts[$name] = (new \ReflectionFunction($resolver))->getNumberOfRequiredParameters() - 1;
}
self::$requiredArgumentCounts = $requiredArgumentCounts;
}

return self::$requiredArgumentCounts;
}

/**
* @return array<string, \Closure(Scope $scope, Arg $value): Expr>
*/
Expand Down
18 changes: 16 additions & 2 deletions src/Type/NetteTester/AssertTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
use function array_slice;
use function count;

class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
{
Expand All @@ -24,6 +26,18 @@ class AssertTypeSpecifyingExtension implements StaticMethodTypeSpecifyingExtensi
*/
private static function createExpression(Scope $scope, string $name, array $args): ?Expr
{
$requiredArgumentCount = AssertMethodExpressionResolversProvider::getRequiredArgumentCounts()[$name];
if (count($args) < $requiredArgumentCount) {
return null;
}

$args = array_slice($args, 0, $requiredArgumentCount);
foreach ($args as $arg) {
if ($arg->unpack || $arg->name !== null) { // PHPStan resolves named arguments to their positions, unless the call is invalid
return null;
}
}

$resolvers = AssertMethodExpressionResolversProvider::getResolvers();
$resolver = $resolvers[$name];
return $resolver($scope, ...$args);
Expand All @@ -42,7 +56,7 @@ public function getClass(): string
public function isStaticMethodSupported(
MethodReflection $staticMethodReflection,
StaticCall $node,
TypeSpecifierContext $context
TypeSpecifierContext $context,
): bool
{
$methodName = $staticMethodReflection->getName();
Expand All @@ -55,7 +69,7 @@ public function specifyTypes(
MethodReflection $staticMethodReflection,
StaticCall $node,
Scope $scope,
TypeSpecifierContext $context
TypeSpecifierContext $context,
): SpecifiedTypes
{
if ($node->isFirstClassCallable()) {
Expand Down
3 changes: 1 addition & 2 deletions tests/Type/NetteTester/AssertTypeSpecifyingExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,11 @@ public function dataFileAsserts(): iterable

/**
* @dataProvider dataFileAsserts
* @param mixed ...$args
*/
public function testFileAsserts(
string $assertType,
string $file,
...$args
mixed ...$args,
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
Expand Down
84 changes: 61 additions & 23 deletions tests/Type/NetteTester/Fixtures/Foo.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,11 @@ class Foo
{

/**
* @param mixed $a
* @param mixed $b
* @param mixed $c
* @param mixed $d
* @param mixed $e
* @param string[] $f
* @param int[] $g
* @param mixed $h
* @param mixed $i
* @param mixed $j
* @param mixed $k
* @param mixed $l
* @param mixed $m
* @param mixed $n
* @param mixed $o
* @param mixed $p
* @param mixed $q
* @param mixed $r
* @param mixed $s
* @param mixed $t
* @param string|NULL $u
*/
public function doFoo($a, $b, $c, $d, $e, array $f, array $g, $h, $i, $j, $k, $l, $m, $n, $o, $p, $q, $r, $s, $t, ?string $u): void
public function doFoo(mixed $a, mixed $b, mixed $c, mixed $d, mixed $e, array $f, array $g, mixed $h, mixed $i, mixed $j, mixed $k, mixed $l, mixed $m, mixed $n, mixed $o, mixed $p, mixed $q, mixed $r, mixed $s, mixed $t, ?string $u): void
{
Assert::null($a);
assertType('null', $a);
Expand Down Expand Up @@ -116,15 +98,71 @@ public function doFoo($a, $b, $c, $d, $e, array $f, array $g, $h, $i, $j, $k, $l
assertType("''", $z);
}

/**
* @param mixed $value
*/
public function testTypeWithMultiplePossibilities($value): void
public function testTypeWithMultiplePossibilities(mixed $value): void
{
$type = rand(0, 1) > 0 ? 'int' : 'string';
assertType("'int'|'string'", $type);
Assert::type($type, $value);
assertType('int|string', $value);
}

/**
* @param array<int, mixed> $args
*/
public function testUnpackedArguments(array $args): void
{
Assert::null(...$args);
assertType('array<int, mixed>', $args);

Assert::same(...$args);
assertType('array<int, mixed>', $args);
}

/**
* @param array<string, int> $c
*/
public function testNamedArguments(mixed $a, mixed $b, array $c): void
{
Assert::null(description: 'must be null', actual: $a);
assertType('null', $a);

Assert::type(value: $b, type: 'int');
assertType('int', $b);

Assert::count(value: $c, count: 1);
assertType('non-empty-array<string, int>', $c);
}

public function testUnresolvableNamedArguments(?string $description): void
{
Assert::null(description: $description);
assertType('string|null', $description);
}

/**
* @param array{foo?: int, bar: string} $shape
* @param array<string, int> $array
*/
public function testHasKey(array $shape, array $array): void
{
Assert::hasKey('foo', $shape);
assertType('array{foo: int, bar: string}', $shape);

Assert::hasKey('foo', $array, 'description is ignored');
assertType("non-empty-array<string, int>&hasOffset('foo')", $array);
}

/**
* @param array{foo?: int, bar: string} $shape
* @param array<string, int> $array
*/
public function testHasNotKey(array $shape, array $array): void
{
Assert::hasNotKey('foo', $shape);
assertType('array{bar: string}', $shape);

Assert::hasNotKey(actual: $array, key: 'foo');
assertType('array<string, int>', $array);
}

}