Skip to content

Commit 9a43380

Browse files
committed
added AssertTypeNarrowingExtension
1 parent 6b4d5e1 commit 9a43380

6 files changed

Lines changed: 314 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ Extensions for specific Nette packages use dedicated namespaces: `Nette\PHPStan\
5656

5757
`RemoveFalseReturnTypeExtension` (`ExpressionTypeResolverExtension`) removes `|false` from return types of native PHP functions and methods where false is trivial or outdated. It handles `FuncCall`, `MethodCall`, and `StaticCall` in a single class. Configuration uses a flat list in NEON — plain names for functions (`json_encode`), `Class::method` notation for methods (`Normalizer::normalize`). It runs before all `DynamicReturnTypeExtension` implementations, delegates to them via `DynamicReturnTypeExtensionRegistry`, and strips `|false` from the result. Config: `extension-php.neon`.
5858

59+
### AssertTypeNarrowingExtension
60+
61+
`AssertTypeNarrowingExtension` (`StaticMethodTypeSpecifyingExtension` + `TypeSpecifierAwareExtension`) narrows variable types after `Tester\Assert` assertion calls. Each assertion method is mapped to an equivalent PHP expression that PHPStan already understands, then delegated to `TypeSpecifier::specifyTypesInCondition()`. Supported methods: `null`, `notNull`, `true`, `false`, `truthy`, `falsey`, `same`, `notSame`, and `type` (with built-in type strings like `'string'`, `'int'`, etc. and class/interface names). Config: `extension-tester.neon`.
62+
5963
### Testing
6064

6165
Tests use **Nette Tester** (not PHPUnit). Test files are `.phpt` in `tests/` with data files in `tests/data/`.

extension-tester.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ services:
1010
Tester\Assert::noError
1111
])
1212
tags: [phpstan.ignoreErrorExtension]
13+
14+
-
15+
class: Nette\PHPStan\Tester\AssertTypeNarrowingExtension
16+
tags: [phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension]

readme.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ testException('listOf() & error', fn() => Expect::listOf(['a' => Expect::string(
6767

6868
<!---->
6969

70+
### Assert Type Narrowing
71+
72+
Narrows variable types after `Tester\Assert` assertion calls, so PHPStan understands the type guarantees made by assertions like `Assert::notNull()`, `Assert::type()`, `Assert::true()`, etc.
73+
74+
```php
75+
/** @var string|null $name */
76+
Assert::notNull($name);
77+
// PHPStan now knows $name is string
78+
79+
Assert::type('int', $value);
80+
// PHPStan now knows $value is int
81+
```
82+
83+
<!---->
84+
7085
### Other
7186

7287
- Narrows the return type of `Expect::array()` from `Structure|Type` to `Structure` or `Type` based on the argument
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nette\PHPStan\Tester;
6+
7+
use PhpParser\Node\Arg;
8+
use PhpParser\Node\Expr;
9+
use PhpParser\Node\Expr\BinaryOp\Identical;
10+
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
11+
use PhpParser\Node\Expr\BooleanNot;
12+
use PhpParser\Node\Expr\ConstFetch;
13+
use PhpParser\Node\Expr\FuncCall;
14+
use PhpParser\Node\Expr\Instanceof_;
15+
use PhpParser\Node\Expr\StaticCall;
16+
use PhpParser\Node\Name;
17+
use PHPStan\Analyser\Scope;
18+
use PHPStan\Analyser\SpecifiedTypes;
19+
use PHPStan\Analyser\TypeSpecifier;
20+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
21+
use PHPStan\Analyser\TypeSpecifierContext;
22+
use PHPStan\Reflection\MethodReflection;
23+
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
24+
use Tester\Assert;
25+
use function count;
26+
27+
28+
/**
29+
* Narrows variable types after Tester\Assert assertion calls.
30+
*/
31+
class AssertTypeNarrowingExtension implements StaticMethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
32+
{
33+
private TypeSpecifier $typeSpecifier;
34+
35+
36+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
37+
{
38+
$this->typeSpecifier = $typeSpecifier;
39+
}
40+
41+
42+
public function getClass(): string
43+
{
44+
return Assert::class;
45+
}
46+
47+
48+
public function isStaticMethodSupported(
49+
MethodReflection $staticMethodReflection,
50+
StaticCall $node,
51+
TypeSpecifierContext $context,
52+
): bool
53+
{
54+
$minArgs = match ($staticMethodReflection->getName()) {
55+
'null', 'notNull', 'true', 'false', 'truthy', 'falsey' => 1,
56+
'same', 'notSame', 'type' => 2,
57+
default => null,
58+
};
59+
return $minArgs !== null && count($node->getArgs()) >= $minArgs;
60+
}
61+
62+
63+
public function specifyTypes(
64+
MethodReflection $staticMethodReflection,
65+
StaticCall $node,
66+
Scope $scope,
67+
TypeSpecifierContext $context,
68+
): SpecifiedTypes
69+
{
70+
$args = $node->getArgs();
71+
$expression = match ($staticMethodReflection->getName()) {
72+
'null' => new Identical($args[0]->value, new ConstFetch(new Name('null'))),
73+
'notNull' => new NotIdentical($args[0]->value, new ConstFetch(new Name('null'))),
74+
'true' => new Identical($args[0]->value, new ConstFetch(new Name('true'))),
75+
'false' => new Identical($args[0]->value, new ConstFetch(new Name('false'))),
76+
'truthy' => $args[0]->value,
77+
'falsey' => new BooleanNot($args[0]->value),
78+
'same' => new Identical($args[1]->value, $args[0]->value),
79+
'notSame' => new NotIdentical($args[1]->value, $args[0]->value),
80+
'type' => $this->createTypeExpression($scope, $args),
81+
default => null,
82+
};
83+
84+
if ($expression === null) {
85+
return new SpecifiedTypes([], []);
86+
}
87+
88+
return $this->typeSpecifier->specifyTypesInCondition(
89+
$scope,
90+
$expression,
91+
TypeSpecifierContext::createTruthy(),
92+
)->setRootExpr($expression);
93+
}
94+
95+
96+
/**
97+
* @param Arg[] $args
98+
*/
99+
private function createTypeExpression(Scope $scope, array $args): ?Expr
100+
{
101+
$typeType = $scope->getType($args[0]->value);
102+
$constantStrings = $typeType->getConstantStrings();
103+
104+
if (count($constantStrings) === 1) {
105+
$typeName = $constantStrings[0]->getValue();
106+
107+
$func = match ($typeName) {
108+
'list', 'array' => 'is_array',
109+
'bool' => 'is_bool',
110+
'callable' => 'is_callable',
111+
'float' => 'is_float',
112+
'int', 'integer' => 'is_int',
113+
'null' => 'is_null',
114+
'object' => 'is_object',
115+
'resource' => 'is_resource',
116+
'scalar' => 'is_scalar',
117+
'string' => 'is_string',
118+
default => null,
119+
};
120+
121+
return $func !== null
122+
? new FuncCall(new Name($func), [$args[1]])
123+
: new Instanceof_($args[1]->value, new Name($typeName));
124+
}
125+
126+
// object argument → instanceof using its class
127+
$classNames = $typeType->getObjectClassNames();
128+
if (count($classNames) === 1) {
129+
return new Instanceof_($args[1]->value, new Name($classNames[0]));
130+
}
131+
132+
return null;
133+
}
134+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* Test: Assert type narrowing.
5+
*/
6+
7+
require __DIR__ . '/../bootstrap.php';
8+
9+
use Nette\PHPStan\Tester\TypeAssert;
10+
11+
TypeAssert::assertTypes(__DIR__ . '/../data/assert-type-narrowing.php', [__DIR__ . '/../../extension.neon']);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Tester\Assert;
6+
use function PHPStan\Testing\assertType;
7+
8+
9+
function testNull(int|string|null $val): void
10+
{
11+
Assert::null($val);
12+
assertType('null', $val);
13+
}
14+
15+
16+
function testNotNull(int|string|null $val): void
17+
{
18+
Assert::notNull($val);
19+
assertType('int|string', $val);
20+
}
21+
22+
23+
function testTrue(mixed $val): void
24+
{
25+
Assert::true($val);
26+
assertType('true', $val);
27+
}
28+
29+
30+
function testFalse(mixed $val): void
31+
{
32+
Assert::false($val);
33+
assertType('false', $val);
34+
}
35+
36+
37+
function testTruthy(stdClass|null $val): void
38+
{
39+
Assert::truthy($val);
40+
assertType('stdClass', $val);
41+
}
42+
43+
44+
function testFalsey(bool $val): void
45+
{
46+
Assert::falsey($val);
47+
assertType('false', $val);
48+
}
49+
50+
51+
function testSame(int|string $val): void
52+
{
53+
Assert::same(42, $val);
54+
assertType('42', $val);
55+
}
56+
57+
58+
function testNotSame(int|null $val): void
59+
{
60+
Assert::notSame(null, $val);
61+
assertType('int', $val);
62+
}
63+
64+
65+
function testTypeString(mixed $val): void
66+
{
67+
Assert::type('string', $val);
68+
assertType('string', $val);
69+
}
70+
71+
72+
function testTypeInt(mixed $val): void
73+
{
74+
Assert::type('int', $val);
75+
assertType('int', $val);
76+
}
77+
78+
79+
function testTypeFloat(mixed $val): void
80+
{
81+
Assert::type('float', $val);
82+
assertType('float', $val);
83+
}
84+
85+
86+
function testTypeBool(mixed $val): void
87+
{
88+
Assert::type('bool', $val);
89+
assertType('bool', $val);
90+
}
91+
92+
93+
function testTypeArray(mixed $val): void
94+
{
95+
Assert::type('array', $val);
96+
assertType('array<mixed, mixed>', $val);
97+
}
98+
99+
100+
function testTypeCallable(mixed $val): void
101+
{
102+
Assert::type('callable', $val);
103+
assertType('callable(): mixed', $val);
104+
}
105+
106+
107+
function testTypeObject(mixed $val): void
108+
{
109+
Assert::type('object', $val);
110+
assertType('object', $val);
111+
}
112+
113+
114+
function testTypeScalar(mixed $val): void
115+
{
116+
Assert::type('scalar', $val);
117+
assertType('bool|float|int|string', $val);
118+
}
119+
120+
121+
function testTypeNull(int|string|null $val): void
122+
{
123+
Assert::type('null', $val);
124+
assertType('null', $val);
125+
}
126+
127+
128+
function testTypeList(mixed $val): void
129+
{
130+
Assert::type('list', $val);
131+
assertType('array<mixed, mixed>', $val);
132+
}
133+
134+
135+
function testTypeClass(mixed $val): void
136+
{
137+
Assert::type(stdClass::class, $val);
138+
assertType('stdClass', $val);
139+
}
140+
141+
142+
function testTypeInterface(mixed $val): void
143+
{
144+
Assert::type(Countable::class, $val);
145+
assertType('Countable', $val);
146+
}

0 commit comments

Comments
 (0)