diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a27a55a..84da169 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,3 +9,5 @@ on: jobs: build: uses: Exanlv/ci/.github/workflows/php-unit-tests.yml@main + psalm: + uses: Exanlv/ci/.github/workflows/psalm-l1.yml@main diff --git a/composer.json b/composer.json index 9b9e05d..4d304d7 100644 --- a/composer.json +++ b/composer.json @@ -16,16 +16,24 @@ "Tests\\": "tests" } }, + "require": { + "php": "^8.4" + }, "require-dev": { - "php": "^8.4", "symfony/var-dumper": "^7.4", "phpunit/phpunit": "^11.5", "friendsofphp/php-cs-fixer": "^3.92", - "exan/pudocumenter": "^0.1.7" + "exan/pudocumenter": "^0.1.7", + "vimeo/psalm": "^6.16" }, "scripts": { "test": "phpunit tests", + "psalm": "psalm --no-cache", + + "psalm-fix": "psalm --no-cache --alter --issues=MissingReturnType,MissingClosureReturnType,InvalidReturnType,InvalidNullableReturnType,InvalidFalsableReturnType,MissingParamType,MissingPropertyType,MismatchingDocblockParamType,MismatchingDocblockReturnType,LessSpecificReturnType,PossiblyUndefinedVariable,PossiblyUndefinedGlobalVariable,UnusedMethod,PossiblyUnusedMethod,UnusedProperty,PossiblyUnusedProperty,UnusedVariable,UnnecessaryVarAnnotation,ParamNameMismatch", + + "csf": "php-cs-fixer fix --using-cache=no --allow-risky=yes", "cs": "php-cs-fixer fix --using-cache=no --allow-risky=yes --dry-run" } diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..e5ff0b4 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Analyzer/Dto/Yanked.php b/src/Analyzer/Dto/Yanked.php index 0e43a77..0f6ccc8 100644 --- a/src/Analyzer/Dto/Yanked.php +++ b/src/Analyzer/Dto/Yanked.php @@ -9,6 +9,6 @@ public function __construct( public ?array $namespace, public array $uses, - public ?array $args, + public ?array $arg, ) {} } diff --git a/src/Analyzer/Extractor/Nemaspace.php b/src/Analyzer/Extractor/Nemaspace.php index 4e34354..63fa8ae 100644 --- a/src/Analyzer/Extractor/Nemaspace.php +++ b/src/Analyzer/Extractor/Nemaspace.php @@ -15,7 +15,7 @@ class Nemaspace { public function __construct( - private readonly TokenEmitter $tokenEmitter, + public readonly TokenEmitter $tokenEmitter, private ?array &$namespace, ) { $this->tokenEmitter->on(TokenFilter::ofType(T_NAMESPACE), function (array $token): void { diff --git a/src/Analyzer/TokenEmitter.php b/src/Analyzer/TokenEmitter.php index cc0b5de..022f48a 100644 --- a/src/Analyzer/TokenEmitter.php +++ b/src/Analyzer/TokenEmitter.php @@ -10,10 +10,13 @@ class TokenEmitter { /** - * @var array + * @var array */ private array $handlers = []; + /** + * @psalm-return int<100000, 999999> + */ public function on(Closure $match, Closure $handler): int { $id = $this->getId(); @@ -22,6 +25,9 @@ public function on(Closure $match, Closure $handler): int return $id; } + /** + * @psalm-return int<100000, 999999> + */ public function onLineChange(Closure $handler): int { $line = 0; @@ -38,6 +44,9 @@ public function onLineChange(Closure $handler): int }); } + /** + * @psalm-return int<100000, 999999> + */ public function all(Closure $handler): int { $id = $this->getId(); @@ -52,6 +61,11 @@ public function once(Closure $match, Closure $handler): void $this->handlers[$id] = ['match' => $match, 'handler' => $handler, 'once' => true]; } + /** + * @return int[] + * + * @psalm-return list{int<100000, 999999>, int<100000, 999999>} + */ public function counter(Closure $up, Closure $down, int &$value): array { return [ @@ -71,6 +85,9 @@ public function remove(int ...$ids): void } } + /** + * @psalm-return int<100000, 999999> + */ private function getId(): int { do { diff --git a/src/Analyzer/TokenFilter.php b/src/Analyzer/TokenFilter.php index 8f73043..e8b675c 100644 --- a/src/Analyzer/TokenFilter.php +++ b/src/Analyzer/TokenFilter.php @@ -9,16 +9,25 @@ /** @internal */ class TokenFilter { - public static function eq(mixed $value): Closure + /** + * @psalm-return Closure(array|string):bool + */ + public static function eq(string $value): Closure { return fn (array|string $token) => $token === $value; } + /** + * @psalm-return Closure(array|string):bool + */ public static function ofType(int $type): Closure { return fn (array|string $token) => is_array($token) && $token[0] === $type; } + /** + * @psalm-return Closure(array|string):bool + */ public static function any(Closure ...$closures): Closure { return function (array|string $token) use ($closures) { diff --git a/src/Analyzer/Utilize.php b/src/Analyzer/Utilize.php index 9191215..4b40a2a 100644 --- a/src/Analyzer/Utilize.php +++ b/src/Analyzer/Utilize.php @@ -40,15 +40,19 @@ private function format(string $fullyQualifiedClass): string * @param string $declaringNamespace * @param array> $uses */ - public static function fromTokens(?string $declaringNamespace, array $uses): static + public static function fromTokens(?string $declaringNamespace, array $uses): self { $convertedUses = array_merge(...array_map(static::parseLine(...), $uses)); - return new static($declaringNamespace, array_merge(...$convertedUses)); + return new self($declaringNamespace, array_merge(...$convertedUses)); } /** * @param array $line + * + * @return array[] + * + * @psalm-return array, array> */ private static function parseLine($line): array { @@ -61,7 +65,10 @@ private static function parseLine($line): array /** * @param array $tokens - * @return array> + * + * @return ((int|string)[]|string)[][] + * + * @psalm-return array, list> */ private static function individualUses($tokens): array { @@ -103,11 +110,16 @@ private static function parseIndividualUse(array $use): array } if (count($use) === 5 && is_array($use[2]) && $use[2][1] === 'as') { - // use Some\Potentially\Namespaced\Class as Alias + // use Some\Class as Alias + + /** @var array{0: int, 1: string, 2: int} */ $trueImport = array_shift($use); + /** @var array{0: int, 1: string, 2: int} */ $alias = array_pop($use); - return [$alias[1] => $trueImport[1]]; + return [ + $alias[1] => $trueImport[1], + ]; } // use Some\Potentially\Namespaced\{Class, Or\Other\Class} @@ -116,6 +128,10 @@ private static function parseIndividualUse(array $use): array /** * @param array $tokens + * + * @return string[] + * + * @psalm-return array */ private static function parseGroupUse(array $tokens): array { @@ -132,7 +148,7 @@ private static function parseGroupUse(array $tokens): array $imports = static::splitGroupUse($tokens); return array_merge(...array_map(function (array $tokens) use ($namespacePrefix) { - while (count($tokens) && is_array($tokens) && $tokens[0][0] === T_WHITESPACE) { + while (count($tokens) && is_array($tokens[0]) && $tokens[0][0] === T_WHITESPACE) { array_shift($tokens); } @@ -152,7 +168,10 @@ private static function parseGroupUse(array $tokens): array /** * @param array $tokens - * @return array> + * + * @return ((int|string)[]|string)[][] + * + * @psalm-return array, list> */ private static function splitGroupUse(array $tokens): array { diff --git a/src/Args/Arr.php b/src/Args/Arr.php index 99b8bb8..b59f167 100644 --- a/src/Args/Arr.php +++ b/src/Args/Arr.php @@ -8,11 +8,17 @@ class Arr { + /** + * @psalm-return Closure(array):bool + */ public static function count(int $expectedCount): Closure { return fn (array $actual): bool => count($actual) === $expectedCount; } + /** + * @psalm-return Closure(array):bool + */ public static function partial(array $expectation): Closure { $validator = function (array $actual, array $expectation) use (&$validator): bool { diff --git a/src/Args/Date.php b/src/Args/Date.php index 7ac3ba7..9f2a11d 100644 --- a/src/Args/Date.php +++ b/src/Args/Date.php @@ -9,11 +9,17 @@ class Date { + /** + * @psalm-return Closure(DateTimeInterface):bool + */ public static function before(DateTimeInterface $beforeDate): Closure { return fn (DateTimeInterface $date): bool => $date < $beforeDate; } + /** + * @psalm-return Closure(DateTimeInterface):bool + */ public static function after(DateTimeInterface $beforeDate): Closure { return fn (DateTimeInterface $date): bool => $date > $beforeDate; diff --git a/src/Args/Number.php b/src/Args/Number.php index 7c40198..397c257 100644 --- a/src/Args/Number.php +++ b/src/Args/Number.php @@ -8,16 +8,25 @@ class Number { + /** + * @psalm-return Closure(float|int):bool + */ public static function lt(int|float $lessThan): Closure { return fn (int|float $actual): bool => $actual < $lessThan; } + /** + * @psalm-return Closure(float|int):bool + */ public static function gt(int|float $greaterThan): Closure { return fn (int|float $actual): bool => $actual > $greaterThan; } + /** + * @psalm-return Closure(float|int):bool + */ public static function range(int|float $min, int|float $max): Closure { return fn (int|float $actual): bool => $actual >= $min && $actual <= $max; diff --git a/src/Args/Str.php b/src/Args/Str.php index 9f1f5b4..3a1edc6 100644 --- a/src/Args/Str.php +++ b/src/Args/Str.php @@ -8,11 +8,17 @@ class Str { + /** + * @psalm-return Closure(string):bool + */ public static function length(int $expectedLength): Closure { return fn (string $actual): bool => strlen($actual) === $expectedLength; } + /** + * @psalm-return Closure(string):bool + */ public static function contains(string $needle): Closure { return fn (string $actual): bool => str_contains(strtolower($actual), strtolower($needle)); diff --git a/src/Class/Mocker.php b/src/Class/Mocker.php index 14360fe..7d4b92f 100644 --- a/src/Class/Mocker.php +++ b/src/Class/Mocker.php @@ -16,7 +16,8 @@ class Mocker { public private(set) array $interfaces = []; - public private(set) string $extends; + /** @var null|class-string */ + public private(set) ?string $extends = null; public function getCode(): string { @@ -37,7 +38,7 @@ public function getCode(): string $creator = 'return new class '; - if (isset($this->extends)) { + if ($this->extends !== null) { $parent = str_contains($this->extends, '@anonymous') ? get_parent_class($this->extends) : $this->extends; diff --git a/src/Expectation.php b/src/Expectation.php index 590ffe5..5be1f40 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -35,6 +35,9 @@ public function with(mixed ...$expectedArg): Expectation return new Expectation($this->classMock, $this->methodName, $filteredCalls, $this->expectation); } + /** + * @psalm-return int<0, max> + */ private function callsAmount(): int { return count($this->calls); @@ -75,6 +78,9 @@ public function dd(): never dd($data); } + /** + * @psalm-suppress ForbiddenCode + */ var_dump($this->calls); die(); } diff --git a/src/Expector/Expectation.php b/src/Expector/Expectation.php index e334846..403a5f6 100644 --- a/src/Expector/Expectation.php +++ b/src/Expector/Expectation.php @@ -15,7 +15,7 @@ class Expectation { use FiltersMethodArgs; - public private(set) array $with; + public private(set) ?array $with = null; private MockedClassInterface $mock; @@ -44,7 +44,7 @@ public function matches(DetailedMethodCall $call): bool return false; } - if (isset($this->with) && empty( + if ($this->with !== null && empty( $this->filterArgs([$call->call->args], $this->with) )) { return false; diff --git a/src/Expector/MockExpector.php b/src/Expector/MockExpector.php index ef2f2f8..f872158 100644 --- a/src/Expector/MockExpector.php +++ b/src/Expector/MockExpector.php @@ -93,7 +93,7 @@ private function fail(int $i): void ); - MoockAssert::assert(false, true, sprintf($message)); + MoockAssert::assert(false, true, $message); } private function getFunctionNameAt(int $i): string @@ -104,6 +104,8 @@ private function getFunctionNameAt(int $i): string /** * @return DetailedMethodCall[] + * + * @psalm-return list */ private function getActualCalls(): array { diff --git a/src/FiltersMethodArgs.php b/src/FiltersMethodArgs.php index e3e8785..80a0031 100644 --- a/src/FiltersMethodArgs.php +++ b/src/FiltersMethodArgs.php @@ -5,10 +5,14 @@ namespace Exan\Moock; use Closure; +use Exan\Moock\Dto\MethodCall; use RuntimeException; trait FiltersMethodArgs { + /** + * @psalm-return array, mixed> + */ private function filterArgs(array $inputs, array $filters): array { if (empty($inputs)) { @@ -28,8 +32,8 @@ private function filterArgs(array $inputs, array $filters): array } $validator = $valueOrValidator instanceof Closure - ? fn ($call): bool => $valueOrValidator($call[$name]) - : fn ($call): bool => $call[$name] === $valueOrValidator; + ? fn (array $call): bool => $valueOrValidator($call[$name]) + : fn (array $call): bool => $call[$name] === $valueOrValidator; $inputs = array_filter($inputs, $validator); } @@ -37,7 +41,15 @@ private function filterArgs(array $inputs, array $filters): array return $inputs; } - private function convertArgsToDictionary(array $inputs, array $filters) + /** + * @template TArray of array + * + * @param non-empty-list $inputs + * @param list $filters + * + * @return array, mixed> + */ + private function convertArgsToDictionary(array $inputs, array $filters): array { $argKeys = array_keys($inputs[0]); diff --git a/src/Formatting/Variables.php b/src/Formatting/Variables.php index be68303..555a03c 100644 --- a/src/Formatting/Variables.php +++ b/src/Formatting/Variables.php @@ -7,6 +7,7 @@ use ReflectionClass; use ReflectionIntersectionType; use ReflectionNamedType; +use ReflectionType; use ReflectionUnionType; /** @@ -43,7 +44,7 @@ private function formatValue(mixed $value): string : (string) $value; } - private function getTypeSignature(ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type, ?ReflectionClass $declaringClass = null): string + private function getTypeSignature(ReflectionType|null $type, ?ReflectionClass $declaringClass = null): string { if ($type === null) { return ''; @@ -59,9 +60,9 @@ private function getTypeSignature(ReflectionNamedType|ReflectionUnionType|Reflec } $types[] = $signature; - } else { + } elseif ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { $types = array_map( - fn (ReflectionNamedType $subType) => $this->isSemiBuiltIn($subType) ? $subType->getName() : '\\' . $subType->getName(), + fn (ReflectionType $subType): string => $this->getTypeSignature($subType, $declaringClass), $type->getTypes(), ); } diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index da5bc7e..f214c5f 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -22,19 +22,25 @@ class Mocker public readonly array $methods; - /** @param string[] $interfaces */ + /** @param class-string[] $interfaces */ public function __construct( public readonly array $interfaces, ) { - /** @var ReflectionMethod[] */ - $allMethods = array_merge( - ...array_map(function (string $interface): array { + /** @var list> */ + $allMethodsPerInterface = array_map( + /** + * @return list + */ + function (string $interface): array { $ref = new ReflectionClass($interface); return $ref->getMethods(ReflectionMethod::IS_PUBLIC); - }, $this->interfaces), + }, + $this->interfaces ); + $allMethods = array_merge(...$allMethodsPerInterface); + $methodNames = []; $methodsToMock = []; @@ -146,6 +152,11 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re } $fileContents = file_get_contents($class->getFileName()); + if ($fileContents === false) { + throw new RuntimeException( + sprintf('Unable to load source file for class %s', $class->getFileName()) + ); + } if ($class->isAnonymous()) { $fullName = explode(':', $class->getName()); @@ -162,7 +173,12 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re $yanked->uses ); - $arg = $yanked->args; + $arg = $yanked->arg; + if ($arg === null) { + throw new RuntimeException( + sprintf('Unable to retrieve constructor args for %s::%s()', $class->getName(), $method->getName()) + ); + } array_shift($arg); // new array_shift($arg); // (whitespace) @@ -173,8 +189,8 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re return $token; } - $argIsColon = fn (int $i) => isset($arg[$i]) && $arg[$i] === ':'; - $argIsWhitespace = fn (int $i) => isset($arg[$i]) && is_array($arg[$i]) && $arg[$i][0] === T_WHITESPACE; + $argIsColon = fn (int $i): bool => isset($arg[$i]) && $arg[$i] === ':'; + $argIsWhitespace = fn (int $i): bool => isset($arg[$i]) && is_array($arg[$i]) && $arg[$i][0] === T_WHITESPACE; if ( $token[0] === T_STRING diff --git a/src/Mock.php b/src/Mock.php index 336cb57..4a385b7 100644 --- a/src/Mock.php +++ b/src/Mock.php @@ -10,6 +10,9 @@ use Exan\Moock\Expector\MockExpector; use ReflectionFunction; +/** + * @psalm-suppress InvalidReturnStatement,InvalidReturnType + */ class Mock { /** @@ -62,8 +65,10 @@ private static function anonymousClass(string $class): mixed $mocker = new ClassMocker(); - foreach ($implements as $interface) { - $mocker->addInterface($interface); + if ($implements !== false) { + foreach ($implements as $interface) { + $mocker->addInterface($interface); + } } return static::codeToMock($mocker->extends($class)->getCode()); @@ -108,6 +113,9 @@ public static function verify(Closure $expectation): void $mockExpector->validate($expectation); } + /** + * @psalm-return Closure(...mixed):mixed + */ public static function fn(): Closure { $mock = Mock::interface(MockFnInterface::class); diff --git a/src/MockMethod.php b/src/MockMethod.php index ace1185..c2f6a42 100644 --- a/src/MockMethod.php +++ b/src/MockMethod.php @@ -55,7 +55,7 @@ public function void(): static public function returns(mixed $returnValue): static { - $this->classMock->__replace($this->methodName, fn () => $returnValue); + $this->classMock->__replace($this->methodName, fn (): mixed => $returnValue); return $this; } diff --git a/src/Properties/Mocker.php b/src/Properties/Mocker.php index f3ade7e..8b34cbc 100644 --- a/src/Properties/Mocker.php +++ b/src/Properties/Mocker.php @@ -16,8 +16,12 @@ class Mocker { use FormatsVariables; + /** @var null|class-string */ public readonly ?string $parent; + /** + * @param class-string $toMock + */ public function __construct( public readonly string $toMock, ) { @@ -94,6 +98,8 @@ private function getFormattedProperty(ReflectionProperty $property): string /** * @return ReflectionProperty[] + * + * @psalm-return array, ReflectionProperty> */ private function getPropertiesToMock(): array { diff --git a/tests/Analyzer/YankerTest.php b/tests/Analyzer/YankerTest.php index 6478e70..c64e709 100644 --- a/tests/Analyzer/YankerTest.php +++ b/tests/Analyzer/YankerTest.php @@ -90,7 +90,7 @@ public function myMethod(string \$arg = 'not-test'): void T_CONSTANT_ENCAPSED_STRING, "'test'", 5, - ]], $yanked->args); + ]], $yanked->arg); } #[Test] @@ -110,7 +110,7 @@ public function myMethod(string \$other = 'something', string \$arg = new Someth } PHP, ['MyClass', 'myMethod', '$arg']); - $this->assertEquals('new Something(new OtherThing())', $this->implode($yanked->args)); + $this->assertEquals('new Something(new OtherThing())', $this->implode($yanked->arg)); } #[Test] @@ -130,7 +130,7 @@ public function myMethod(string \$other = 'something', string \$arg = new Someth } PHP, ['MyClass', 'myMethod', '$arg']); - $this->assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->args)); + $this->assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->arg)); } #[Test] @@ -149,7 +149,7 @@ public function myMethod(string \$other = 'something', string \$arg = new Someth }; PHP, ['3$0', 'myMethod', '$arg']); - $this->assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->args)); + $this->assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->arg)); } #[Test] @@ -173,7 +173,7 @@ public function it_extracts_args_from_the_correct_anonymous_class_on_single_line T_CONSTANT_ENCAPSED_STRING, '"expected"', 1, - ]], $yanked->args); + ]], $yanked->arg); } #[Test] @@ -205,6 +205,6 @@ public function myMethod(string \$arg = 'expected'): void T_CONSTANT_ENCAPSED_STRING, "'expected'", 13, - ]], $yanked->args); + ]], $yanked->arg); } }