Skip to content

Commit 814bdb9

Browse files
committed
Add type inference testing for generic classes
1 parent b60d25f commit 814bdb9

12 files changed

+308
-43
lines changed

.github/workflows/static-code-analysis.yml

+5
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ jobs:
9090
env:
9191
TACHYCARDIA_MONITOR_GA: enabled
9292

93+
- name: Check - Static Analysis
94+
run: composer test:stan
95+
env:
96+
TACHYCARDIA_MONITOR_GA: enabled
97+
9398
- name: Check - PHP-CS-Fixer
9499
run: composer cs:check
95100

composer.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,19 @@
8686
"test:auto-review": "phpunit --group=auto-review --colors=always",
8787
"test:coverage": "@test:unit --coverage-html=build/phpunit/html",
8888
"test:package": "phpunit --group=package-test --colors=always",
89+
"test:stan": "phpunit --group=static-analysis --colors=always",
8990
"test:unit": "phpunit --group=unit-test --colors=always"
9091
},
9192
"scripts-descriptions": {
9293
"cs:check": "Checks for coding style violations",
9394
"cs:fix": "Fixes any coding style violations",
94-
"phpstan:baseline": "Runs PHPStans and dumps resulting errors to baseline",
95+
"phpstan:baseline": "Runs PHPStan and dumps resulting errors to baseline",
9596
"phpstan:check": "Runs PHPStan with identifiers support",
9697
"test:all": "Runs all PHPUnit tests",
9798
"test:auto-review": "Runs the Auto-Review Tests",
98-
"test:coverage": "Runs UnitTests with code coverage",
99+
"test:coverage": "Runs Unit Tests with code coverage",
99100
"test:package": "Runs the Package Tests",
101+
"test:stan": "Runs the Static Analysis Tests",
100102
"test:unit": "Runs the Unit Tests"
101103
}
102104
}

phpstan-baseline.php

+12
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,17 @@
1919
'count' => 1,
2020
'path' => __DIR__ . '/tests/AutoReview/TestCodeTest.php',
2121
];
22+
$ignoreErrors[] = [
23+
// identifier: method.impossibleType
24+
'message' => '#^Call to method Nexus\\\\Option\\\\Some\\<int\\>\\:\\:isNone\\(\\) will always evaluate to false\\.$#',
25+
'count' => 1,
26+
'path' => __DIR__ . '/tests/Option/OptionTest.php',
27+
];
28+
$ignoreErrors[] = [
29+
// identifier: generator.valueType
30+
'message' => '#^Generator expects value type array\\{string, string, list\\<mixed\\>\\}, array given\\.$#',
31+
'count' => 1,
32+
'path' => __DIR__ . '/tests/Option/OptionTypeInferenceTest.php',
33+
];
2234

2335
return ['parameters' => ['ignoreErrors' => $ignoreErrors]];

phpstan.dist.neon

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ parameters:
1515
- tools
1616
excludePaths:
1717
analyseAndScan:
18+
- tests/**/data/**
1819
- tests/PHPStan/**/data/**
1920
- tools/vendor/**
2021
bootstrapFiles:

src/Nexus/Option/None.php

+19-5
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,18 @@ public function isNone(): bool
3333
return true;
3434
}
3535

36-
public function unwrap(): mixed
36+
public function unwrap(): never
3737
{
3838
throw new NoneException();
3939
}
4040

41+
/**
42+
* @template S
43+
*
44+
* @param S $default
45+
*
46+
* @return S
47+
*/
4148
public function unwrapOr(mixed $default): mixed
4249
{
4350
return $default;
@@ -48,7 +55,7 @@ public function unwrapOrElse(\Closure $default): mixed
4855
return $default();
4956
}
5057

51-
public function map(\Closure $predicate): Option
58+
public function map(\Closure $predicate): self
5259
{
5360
return clone $this;
5461
}
@@ -63,17 +70,17 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed
6370
return $default();
6471
}
6572

66-
public function and(Option $other): Option
73+
public function and(Option $other): self
6774
{
6875
return clone $this;
6976
}
7077

71-
public function andThen(\Closure $predicate): Option
78+
public function andThen(\Closure $predicate): self
7279
{
7380
return clone $this;
7481
}
7582

76-
public function filter(\Closure $predicate): Option
83+
public function filter(\Closure $predicate): self
7784
{
7885
return clone $this;
7986
}
@@ -88,6 +95,13 @@ public function orElse(\Closure $other): Option
8895
return $other();
8996
}
9097

98+
/**
99+
* @template S
100+
*
101+
* @param Option<S> $other
102+
*
103+
* @return ($other is Some<S> ? Some<S> : self<T>)
104+
*/
91105
public function xor(Option $other): Option
92106
{
93107
return $other->isSome() ? $other : clone $this;

src/Nexus/Option/Option.php

+22-12
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ interface Option extends \IteratorAggregate
2626
{
2727
/**
2828
* Returns `true` if the option is a **Some** value.
29+
*
30+
* @phpstan-assert-if-true Some<T> $this
31+
* @phpstan-assert-if-true false $this->isNone()
32+
* @phpstan-assert-if-false None $this
33+
* @phpstan-assert-if-false true $this->isNone()
2934
*/
3035
public function isSome(): bool;
3136

@@ -40,6 +45,11 @@ public function isSomeAnd(\Closure $predicate): bool;
4045

4146
/**
4247
* Returns `true` if the option is a **None** value.
48+
*
49+
* @phpstan-assert-if-true None $this
50+
* @phpstan-assert-if-true false $this->isSome()
51+
* @phpstan-assert-if-false Some<T> $this
52+
* @phpstan-assert-if-false true $this->isSome()
4353
*/
4454
public function isNone(): bool;
4555

@@ -139,25 +149,25 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed;
139149
* passing the result of a function call, it is recommended to use `Option::andThen()`,
140150
* which is lazily evaluated.
141151
*
142-
* @template U
152+
* @template U of Option
143153
*
144-
* @param self<U> $other
154+
* @param U $other
145155
*
146-
* @return self<U>
156+
* @return U
147157
*/
148158
public function and(self $other): self;
149159

150160
/**
151161
* Returns **None** if the option is **None**, otherwise calls `$other` with the wrapped
152162
* value and returns the result.
153163
*
154-
* @template U
164+
* @template U of Option
155165
*
156-
* @param (\Closure(T): self<U>) $predicate
166+
* @param (\Closure(T): U) $predicate
157167
*
158168
* @param-immediately-invoked-callable $predicate
159169
*
160-
* @return self<U>
170+
* @return U
161171
*/
162172
public function andThen(\Closure $predicate): self;
163173

@@ -182,25 +192,25 @@ public function filter(\Closure $predicate): self;
182192
* passing the result of a function call, it is recommended to use `Option::orElse()`,
183193
* which is lazily evaluated.
184194
*
185-
* @template S
195+
* @template S of Option
186196
*
187-
* @param self<S> $other
197+
* @param S $other
188198
*
189-
* @return self<S>
199+
* @return S
190200
*/
191201
public function or(self $other): self;
192202

193203
/**
194204
* Returns the option if it contains a value, otherwise calls
195205
* `$other` and returns the result.
196206
*
197-
* @template S
207+
* @template S of Option
198208
*
199-
* @param (\Closure(): self<S>) $other
209+
* @param (\Closure(): S) $other
200210
*
201211
* @param-immediately-invoked-callable $other
202212
*
203-
* @return self<S>
213+
* @return S
204214
*/
205215
public function orElse(\Closure $other): self;
206216

src/Nexus/Option/Some.php

+49-3
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,46 @@ public function unwrap(): mixed
4747
return $this->value;
4848
}
4949

50+
/**
51+
* @return T
52+
*/
5053
public function unwrapOr(mixed $default): mixed
5154
{
5255
return $this->value;
5356
}
5457

58+
/**
59+
* @return T
60+
*/
5561
public function unwrapOrElse(\Closure $default): mixed
5662
{
5763
return $this->value;
5864
}
5965

60-
public function map(\Closure $predicate): Option
66+
/**
67+
* @template U
68+
*
69+
* @param (\Closure(T): U) $predicate
70+
*
71+
* @param-immediately-invoked-callable $predicate
72+
*
73+
* @return self<U>
74+
*/
75+
public function map(\Closure $predicate): self
6176
{
6277
return new self($predicate($this->value));
6378
}
6479

80+
/**
81+
* @template U
82+
*
83+
* @param U $default
84+
* @param (\Closure(T): U) $predicate
85+
*
86+
* @param-immediately-invoked-callable $predicate
87+
*
88+
* @return U
89+
*/
6590
public function mapOr(mixed $default, \Closure $predicate): mixed
6691
{
6792
return $predicate($this->value);
@@ -87,16 +112,37 @@ public function filter(\Closure $predicate): Option
87112
return $predicate($this->value) ? clone $this : new None();
88113
}
89114

90-
public function or(Option $other): Option
115+
/**
116+
* @template S of Option
117+
*
118+
* @param S $other
119+
*
120+
* @return self<T>
121+
*/
122+
public function or(Option $other): self
91123
{
92124
return clone $this;
93125
}
94126

95-
public function orElse(\Closure $other): Option
127+
/**
128+
* @template S of Option
129+
*
130+
* @param (\Closure(): S) $other
131+
*
132+
* @return self<T>
133+
*/
134+
public function orElse(\Closure $other): self
96135
{
97136
return clone $this;
98137
}
99138

139+
/**
140+
* @template S
141+
*
142+
* @param Option<S> $other
143+
*
144+
* @return ($other is Some<S> ? None : self<T>)
145+
*/
100146
public function xor(Option $other): Option
101147
{
102148
return $other->isSome() ? new None() : clone $this;

tests/AutoReview/PhpFilesProvider.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ private static function getTestClasses(): array
117117
if (
118118
! $file->isFile()
119119
|| $file->getExtension() !== 'php'
120-
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'Fixtures'.\DIRECTORY_SEPARATOR)
121-
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'data'.\DIRECTORY_SEPARATOR)
120+
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'Fixtures')
121+
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'data')
122122
) {
123123
continue;
124124
}

tests/AutoReview/TestCodeTest.php

+58-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Nexus\Option\None;
1717
use Nexus\Option\Some;
1818
use Nexus\Tests\Option\OptionTest;
19+
use PHPStan\Testing\TypeInferenceTestCase;
1920
use PHPUnit\Framework\Attributes\CoversClass;
2021
use PHPUnit\Framework\Attributes\CoversFunction;
2122
use PHPUnit\Framework\Attributes\CoversNothing;
@@ -36,6 +37,7 @@ final class TestCodeTest extends TestCase
3637
private const RECOGNISED_GROUP_NAMES = [
3738
'auto-review',
3839
'package-test',
40+
'static-analysis',
3941
'unit-test',
4042
];
4143

@@ -182,7 +184,7 @@ public function testDataProvidersDeclareCorrectReturnType(string $testClassName,
182184
self::assertMatchesRegularExpression(
183185
'/@return iterable<(?:class-)?string(?:\<\S+\>)?, array\{/',
184186
$docComment,
185-
\sprintf('Return PHPDoc of data provider "%s::%s" must be an iterable of named array shape (i.e., iterable<string, array{string}>).', $testClassName, $dataProviderMethod),
187+
\sprintf('Return PHPDoc of data provider "%s::%s" must be an iterable of named array shape (e.g., iterable<string, array{string}>).', $testClassName, $dataProviderMethod),
186188
);
187189
}
188190

@@ -329,4 +331,59 @@ public static function provideTestClassCases(): iterable
329331
yield $class => [$class];
330332
}
331333
}
334+
335+
#[DataProvider('provideGenericClassHasTypeInferenceTestForNamespaceCases')]
336+
public function testGenericClassHasTypeInferenceTestForNamespace(string $package): void
337+
{
338+
$expectedTypeInferentTest = \sprintf('Nexus\\Tests\\%1$s\\%1$sTypeInferenceTest', $package);
339+
340+
self::assertTrue(class_exists($expectedTypeInferentTest), \sprintf(
341+
'The %s package has generic class(es) thus it requires a %s.',
342+
$package,
343+
$expectedTypeInferentTest,
344+
));
345+
self::assertTrue(is_subclass_of($expectedTypeInferentTest, TypeInferenceTestCase::class), \sprintf(
346+
'Type inference test "%s" should extend %s.',
347+
$expectedTypeInferentTest,
348+
TypeInferenceTestCase::class,
349+
));
350+
351+
$groupAttributes = array_map(static function (\ReflectionAttribute $attribute): string {
352+
$groupAttribute = $attribute->newInstance();
353+
\assert($groupAttribute instanceof Group);
354+
355+
return $groupAttribute->name();
356+
}, (new \ReflectionClass($expectedTypeInferentTest))->getAttributes(Group::class));
357+
self::assertContains('static-analysis', $groupAttributes, \sprintf(
358+
'Test "%s" should have the #[Group(\'static-analysis\')] attribute.',
359+
$expectedTypeInferentTest,
360+
));
361+
}
362+
363+
/**
364+
* @return iterable<string, array{string}>
365+
*/
366+
public static function provideGenericClassHasTypeInferenceTestForNamespaceCases(): iterable
367+
{
368+
$packages = [];
369+
370+
foreach (self::getSourceClasses() as $class) {
371+
$reflection = new \ReflectionClass($class);
372+
$docComment = $reflection->getDocComment();
373+
374+
if (false === $docComment || ! str_contains($docComment, '* @template')) {
375+
continue;
376+
}
377+
378+
$package = explode('\\', $reflection->getNamespaceName())[1];
379+
380+
if (\array_key_exists($package, $packages)) {
381+
continue;
382+
}
383+
384+
$packages[$package] = true;
385+
386+
yield $package => [$package];
387+
}
388+
}
332389
}

0 commit comments

Comments
 (0)