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);
}
}