Skip to content

Commit

Permalink
Add type inference testing for generic classes
Browse files Browse the repository at this point in the history
  • Loading branch information
paulbalandan committed Sep 8, 2024
1 parent b60d25f commit 995a0f9
Show file tree
Hide file tree
Showing 12 changed files with 304 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/static-code-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ jobs:
env:
TACHYCARDIA_MONITOR_GA: enabled

- name: Check - Static Analysis
run: composer test:stan
env:
TACHYCARDIA_MONITOR_GA: enabled

- name: Check - PHP-CS-Fixer
run: composer cs:check

Expand Down
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,25 @@
"test:all": [
"@test:unit",
"@test:auto-review",
"test:stan",
"@test:package"
],
"test:auto-review": "phpunit --group=auto-review --colors=always",
"test:coverage": "@test:unit --coverage-html=build/phpunit/html",
"test:package": "phpunit --group=package-test --colors=always",
"test:stan": "phpunit --group=static-analysis --colors=always",
"test:unit": "phpunit --group=unit-test --colors=always"
},
"scripts-descriptions": {
"cs:check": "Checks for coding style violations",
"cs:fix": "Fixes any coding style violations",
"phpstan:baseline": "Runs PHPStans and dumps resulting errors to baseline",
"phpstan:baseline": "Runs PHPStan and dumps resulting errors to baseline",
"phpstan:check": "Runs PHPStan with identifiers support",
"test:all": "Runs all PHPUnit tests",
"test:auto-review": "Runs the Auto-Review Tests",
"test:coverage": "Runs UnitTests with code coverage",
"test:coverage": "Runs Unit Tests with code coverage",
"test:package": "Runs the Package Tests",
"test:stan": "Runs the Static Analysis Tests",
"test:unit": "Runs the Unit Tests"
}
}
6 changes: 6 additions & 0 deletions phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@
'count' => 1,
'path' => __DIR__ . '/tests/AutoReview/TestCodeTest.php',
];
$ignoreErrors[] = [
// identifier: method.impossibleType
'message' => '#^Call to method Nexus\\\\Option\\\\Some\\<int\\>\\:\\:isNone\\(\\) will always evaluate to false\\.$#',
'count' => 1,
'path' => __DIR__ . '/tests/Option/OptionTest.php',
];

return ['parameters' => ['ignoreErrors' => $ignoreErrors]];
1 change: 1 addition & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ parameters:
- tools
excludePaths:
analyseAndScan:
- tests/**/data/**
- tests/PHPStan/**/data/**
- tools/vendor/**
bootstrapFiles:
Expand Down
24 changes: 19 additions & 5 deletions src/Nexus/Option/None.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@ public function isNone(): bool
return true;
}

public function unwrap(): mixed
public function unwrap(): never
{
throw new NoneException();
}

/**
* @template S
*
* @param S $default
*
* @return S
*/
public function unwrapOr(mixed $default): mixed
{
return $default;
Expand All @@ -48,7 +55,7 @@ public function unwrapOrElse(\Closure $default): mixed
return $default();
}

public function map(\Closure $predicate): Option
public function map(\Closure $predicate): self
{
return clone $this;
}
Expand All @@ -63,17 +70,17 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed
return $default();
}

public function and(Option $other): Option
public function and(Option $other): self
{
return clone $this;
}

public function andThen(\Closure $predicate): Option
public function andThen(\Closure $predicate): self
{
return clone $this;
}

public function filter(\Closure $predicate): Option
public function filter(\Closure $predicate): self
{
return clone $this;
}
Expand All @@ -88,6 +95,13 @@ public function orElse(\Closure $other): Option
return $other();
}

/**
* @template S
*
* @param Option<S> $other
*
* @return ($other is Some<S> ? Some<S> : self<T>)
*/
public function xor(Option $other): Option
{
return $other->isSome() ? $other : clone $this;
Expand Down
34 changes: 22 additions & 12 deletions src/Nexus/Option/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ interface Option extends \IteratorAggregate
{
/**
* Returns `true` if the option is a **Some** value.
*
* @phpstan-assert-if-true Some<T> $this
* @phpstan-assert-if-true false $this->isNone()
* @phpstan-assert-if-false None $this
* @phpstan-assert-if-false true $this->isNone()
*/
public function isSome(): bool;

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

/**
* Returns `true` if the option is a **None** value.
*
* @phpstan-assert-if-true None $this
* @phpstan-assert-if-true false $this->isSome()
* @phpstan-assert-if-false Some<T> $this
* @phpstan-assert-if-false true $this->isSome()
*/
public function isNone(): bool;

Expand Down Expand Up @@ -139,25 +149,25 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed;
* passing the result of a function call, it is recommended to use `Option::andThen()`,
* which is lazily evaluated.
*
* @template U
* @template U of Option
*
* @param self<U> $other
* @param U $other
*
* @return self<U>
* @return U
*/
public function and(self $other): self;

/**
* Returns **None** if the option is **None**, otherwise calls `$other` with the wrapped
* value and returns the result.
*
* @template U
* @template U of Option
*
* @param (\Closure(T): self<U>) $predicate
* @param (\Closure(T): U) $predicate
*
* @param-immediately-invoked-callable $predicate
*
* @return self<U>
* @return U
*/
public function andThen(\Closure $predicate): self;

Expand All @@ -182,25 +192,25 @@ public function filter(\Closure $predicate): self;
* passing the result of a function call, it is recommended to use `Option::orElse()`,
* which is lazily evaluated.
*
* @template S
* @template S of Option
*
* @param self<S> $other
* @param S $other
*
* @return self<S>
* @return S
*/
public function or(self $other): self;

/**
* Returns the option if it contains a value, otherwise calls
* `$other` and returns the result.
*
* @template S
* @template S of Option
*
* @param (\Closure(): self<S>) $other
* @param (\Closure(): S) $other
*
* @param-immediately-invoked-callable $other
*
* @return self<S>
* @return S
*/
public function orElse(\Closure $other): self;

Expand Down
52 changes: 49 additions & 3 deletions src/Nexus/Option/Some.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,46 @@ public function unwrap(): mixed
return $this->value;
}

/**
* @return T
*/
public function unwrapOr(mixed $default): mixed
{
return $this->value;
}

/**
* @return T
*/
public function unwrapOrElse(\Closure $default): mixed
{
return $this->value;
}

public function map(\Closure $predicate): Option
/**
* @template U
*
* @param (\Closure(T): U) $predicate
*
* @param-immediately-invoked-callable $predicate
*
* @return self<U>
*/
public function map(\Closure $predicate): self
{
return new self($predicate($this->value));
}

/**
* @template U
*
* @param U $default
* @param (\Closure(T): U) $predicate
*
* @param-immediately-invoked-callable $predicate
*
* @return U
*/
public function mapOr(mixed $default, \Closure $predicate): mixed
{
return $predicate($this->value);
Expand All @@ -87,16 +112,37 @@ public function filter(\Closure $predicate): Option
return $predicate($this->value) ? clone $this : new None();
}

public function or(Option $other): Option
/**
* @template S of Option
*
* @param S $other
*
* @return self<T>
*/
public function or(Option $other): self
{
return clone $this;
}

public function orElse(\Closure $other): Option
/**
* @template S of Option
*
* @param (\Closure(): S) $other
*
* @return self<T>
*/
public function orElse(\Closure $other): self
{
return clone $this;
}

/**
* @template S
*
* @param Option<S> $other
*
* @return ($other is Some<S> ? None : self<T>)
*/
public function xor(Option $other): Option
{
return $other->isSome() ? new None() : clone $this;
Expand Down
4 changes: 2 additions & 2 deletions tests/AutoReview/PhpFilesProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ private static function getTestClasses(): array
if (
! $file->isFile()
|| $file->getExtension() !== 'php'
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'Fixtures'.\DIRECTORY_SEPARATOR)
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'data'.\DIRECTORY_SEPARATOR)
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'Fixtures')
|| str_contains($file->getPath(), \DIRECTORY_SEPARATOR.'data')
) {
continue;
}
Expand Down
59 changes: 58 additions & 1 deletion tests/AutoReview/TestCodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Nexus\Option\None;
use Nexus\Option\Some;
use Nexus\Tests\Option\OptionTest;
use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\CoversNothing;
Expand All @@ -36,6 +37,7 @@ final class TestCodeTest extends TestCase
private const RECOGNISED_GROUP_NAMES = [
'auto-review',
'package-test',
'static-analysis',
'unit-test',
];

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

Expand Down Expand Up @@ -329,4 +331,59 @@ public static function provideTestClassCases(): iterable
yield $class => [$class];
}
}

#[DataProvider('provideGenericClassHasTypeInferenceTestForNamespaceCases')]
public function testGenericClassHasTypeInferenceTestForNamespace(string $package): void
{
$expectedTypeInferentTest = \sprintf('Nexus\\Tests\\%1$s\\%1$sTypeInferenceTest', $package);

self::assertTrue(class_exists($expectedTypeInferentTest), \sprintf(
'The %s package has generic class(es) thus it requires a %s.',
$package,
$expectedTypeInferentTest,
));
self::assertTrue(is_subclass_of($expectedTypeInferentTest, TypeInferenceTestCase::class), \sprintf(
'Type inference test "%s" should extend %s.',
$expectedTypeInferentTest,
TypeInferenceTestCase::class,
));

$groupAttributes = array_map(static function (\ReflectionAttribute $attribute): string {
$groupAttribute = $attribute->newInstance();
\assert($groupAttribute instanceof Group);

return $groupAttribute->name();
}, (new \ReflectionClass($expectedTypeInferentTest))->getAttributes(Group::class));
self::assertContains('static-analysis', $groupAttributes, \sprintf(
'Test "%s" should have the #[Group(\'static-analysis\')] attribute.',
$expectedTypeInferentTest,
));
}

/**
* @return iterable<string, array{string}>
*/
public static function provideGenericClassHasTypeInferenceTestForNamespaceCases(): iterable
{
$packages = [];

foreach (self::getSourceClasses() as $class) {
$reflection = new \ReflectionClass($class);
$docComment = $reflection->getDocComment();

if (false === $docComment || ! str_contains($docComment, '* @template')) {
continue;
}

$package = explode('\\', $reflection->getNamespaceName())[1];

if (\array_key_exists($package, $packages)) {
continue;
}

$packages[$package] = true;

yield $package => [$package];
}
}
}
Loading

0 comments on commit 995a0f9

Please sign in to comment.