Skip to content

Commit b64fd58

Browse files
committed
Psalm refactoring
1 parent ea4ed4b commit b64fd58

220 files changed

Lines changed: 6627 additions & 2292 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,14 @@ Overriding the following methods in your assertion class allows you to change th
271271
## Static analysis support
272272

273273
Where applicable, assertion functions are annotated to support Psalm's
274-
[Assertion syntax](https://psalm.dev/docs/annotating_code/assertion_syntax/).
274+
[Assertion syntax](https://psalm.dev/docs/annotating_code/assertion_syntax/).
275+
276+
A native Psalm plugin can be enabled to also add type inference for return types (new in 2.x):
277+
278+
```php
279+
vendor/bin/psalm-plugin enable webmozart/assert
280+
```
281+
275282
A dedicated [PHPStan Plugin](https://github.com/phpstan/phpstan-webmozart-assert) is
276283
required for proper type support.
277284

bin/generate.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
*/
99

1010
use Webmozart\Assert\Bin\MixinGenerator;
11+
use Webmozart\Assert\Bin\StaticAnalysisNonReturnGenerator;
1112

1213
require_once __DIR__.'/../vendor/autoload.php';
1314

1415
file_put_contents(__DIR__.'/../src/Mixin.php', (new MixinGenerator())->generate());
16+
(new StaticAnalysisNonReturnGenerator(__DIR__.'/../tests/static-analysis'))->generate();
1517

1618
echo "Done.";

bin/src/MixinGenerator.php

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ private function namespace(): string
7777

7878
$namespace = sprintf("namespace %s;\n\n", $assert->getNamespaceName());
7979
$namespace .= sprintf("use %s;\n", ArrayAccess::class);
80+
$namespace .= sprintf("use %s;\n", \Closure::class);
8081
$namespace .= sprintf("use %s;\n", Countable::class);
8182
$namespace .= sprintf("use %s;\n", Throwable::class);
8283
$namespace .= "\n";
@@ -231,11 +232,11 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
231232
}
232233

233234
if ($parameterReflection->hasType()) {
234-
if ($parameterReflection->name === 'value') {
235+
if (count($parameters) === 1) {
235236
$parameterTypes[$parameterReflection->name] = 'mixed';
236237

237238
$nativeReturnType = match ($typeTemplate) {
238-
'%s|null' => $this->reduceParameterType($parameterReflection->getType()),
239+
'%s|null' => $this->nullableReturnType($method->getReturnType()),
239240
'iterable<%s>' => 'iterable',
240241
'iterable<%s|null>' => 'iterable',
241242
};
@@ -245,6 +246,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
245246
}
246247
}
247248

249+
// Ensure @template comes before @param, and @param values match function signature order
250+
$parsedComment = $this->reorderAnnotations($parsedComment);
251+
if (isset($parsedComment['param'])) {
252+
$parsedComment['param'] = $this->reorderParamsBySignature($parsedComment['param'], $parameters);
253+
}
254+
248255
if (in_array($newMethodName, $this->skipMethods, true)) {
249256
return null;
250257
}
@@ -253,6 +260,13 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
253260

254261
$phpdocReturnType = 'mixed';
255262

263+
$templateTypeNames = [];
264+
if (isset($parsedComment['template'])) {
265+
foreach ($parsedComment['template'] as $template) {
266+
$templateTypeNames[] = explode(' ', $template)[0];
267+
}
268+
}
269+
256270
$phpdocLines = [];
257271
foreach ($parsedComment as $key => $values) {
258272
if ($this->shouldSkipAnnotation($newMethodName, $key)) {
@@ -275,14 +289,24 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
275289

276290
foreach ($values as $i => $value) {
277291
$parts = $this->splitDocLine($value);
278-
if (('param' === $key || 'psalm-param' === $key) && isset($parts[1]) && isset($parameters[0]) && $parts[1] === '$'.$parameters[0] && 'mixed' !== $parts[0]) {
279-
$parts[0] = $this->applyTypeTemplate($parts[0], $typeTemplate);
292+
if ('param' === $key && isset($parts[1]) && isset($parameters[0]) && $parts[1] === '$'.$parameters[0] && 'mixed' !== $parts[0]) {
293+
$parts[0] = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
280294

281295
$values[$i] = \implode(' ', $parts);
296+
297+
if ('mixed' === $phpdocReturnType) {
298+
$phpdocReturnType = $parts[0];
299+
}
282300
}
283301
}
284302

285-
if ('psalm-return' === $key || 'return' === $key) {
303+
if ('return' === $key) {
304+
foreach ($values as $value) {
305+
$parts = $this->splitDocLine($value);
306+
if ('mixed' !== $parts[0]) {
307+
$phpdocReturnType = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
308+
}
309+
}
286310
continue;
287311
}
288312

@@ -294,8 +318,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
294318
$parts = $this->splitDocLine($value);
295319
$type = $parts[0];
296320

321+
if ('template' === $key && 'iterable<%s|null>' === $typeTemplate) {
322+
$type = preg_replace('/^(\S+\s+(?:of|as)\s+)(.+)$/', '$1$2|null', $type) ?? $type;
323+
}
324+
297325
if ('psalm-assert' === $key) {
298-
$type = $this->applyTypeTemplate($type, $typeTemplate);
326+
$type = $this->applyTypeTemplate($type, $typeTemplate, $templateTypeNames);
299327

300328
$phpdocReturnType = $type;
301329
}
@@ -322,6 +350,20 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
322350
}
323351
}
324352

353+
if ('mixed' === $phpdocReturnType) {
354+
$returnType = $method->getReturnType();
355+
if ($returnType !== null) {
356+
$returnTypeStr = $this->reduceParameterType($returnType);
357+
if ('mixed' !== $returnTypeStr) {
358+
$phpdocReturnType = $this->applyTypeTemplate($returnTypeStr, $typeTemplate, $templateTypeNames);
359+
}
360+
}
361+
}
362+
363+
if ('mixed' === $phpdocReturnType && 'mixed' !== $nativeReturnType) {
364+
$phpdocReturnType = $nativeReturnType;
365+
}
366+
325367
$phpdocLines[] = '@return '.$phpdocReturnType;
326368
$phpdocLines[] = '';
327369

@@ -360,8 +402,28 @@ private function reduceParameterType(ReflectionType $type): string
360402
return ($type->allowsNull() ? '?' : '') . $type->getName();
361403
}
362404

363-
private function applyTypeTemplate(string $type, string $typeTemplate): string
405+
private function nullableReturnType(?ReflectionType $type): string
406+
{
407+
if ($type === null) {
408+
return 'mixed';
409+
}
410+
$typeStr = $this->reduceParameterType($type);
411+
if ($typeStr === 'mixed') {
412+
return 'mixed';
413+
}
414+
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
415+
return $typeStr.'|null';
416+
}
417+
418+
return '?'.$typeStr;
419+
}
420+
421+
private function applyTypeTemplate(string $type, string $typeTemplate, array $templateTypeNames = []): string
364422
{
423+
if (in_array($type, $templateTypeNames, true) && str_contains($typeTemplate, 'iterable') && str_contains($typeTemplate, '|null')) {
424+
$typeTemplate = str_replace('|null', '', $typeTemplate);
425+
}
426+
365427
$combinedType = sprintf($typeTemplate, $type);
366428

367429
if ('empty|null' === $combinedType) {
@@ -377,7 +439,7 @@ private function shouldSkipAnnotation(string $newMethodName, string $key): bool
377439
return false;
378440
}
379441

380-
return 'psalm-assert' === $key || 'psalm-return' === $key;
442+
return 'psalm-assert' === $key;
381443
}
382444

383445
/**
@@ -557,6 +619,70 @@ private function splitDocLine(string $line): array
557619
return [trim($matches[1]), $matches[2], $matches[3] ?? null];
558620
}
559621

622+
/**
623+
* Ensures @template annotations appear before @param annotations.
624+
*
625+
* @param array<string, list<string>> $annotations
626+
*
627+
* @return array<string, list<string>>
628+
*/
629+
private function reorderAnnotations(array $annotations): array
630+
{
631+
$keys = array_keys($annotations);
632+
$templatePos = array_search('template', $keys, true);
633+
$paramPos = array_search('param', $keys, true);
634+
635+
if ($templatePos === false || $paramPos === false || $templatePos < $paramPos) {
636+
return $annotations;
637+
}
638+
639+
$result = [];
640+
foreach ($annotations as $key => $values) {
641+
if ($key === 'param') {
642+
$result['template'] = $annotations['template'];
643+
}
644+
if ($key !== 'template') {
645+
$result[$key] = $values;
646+
}
647+
}
648+
649+
return $result;
650+
}
651+
652+
/**
653+
* Reorders @param doc entries to match the function signature parameter order.
654+
*
655+
* @param list<string> $paramDocs
656+
* @param list<string> $parameterNames
657+
*
658+
* @return list<string>
659+
*/
660+
private function reorderParamsBySignature(array $paramDocs, array $parameterNames): array
661+
{
662+
$byVarName = [];
663+
$withoutVarName = [];
664+
665+
foreach ($paramDocs as $doc) {
666+
$parts = $this->splitDocLine($doc);
667+
if (isset($parts[1])) {
668+
$byVarName[$parts[1]] = $doc;
669+
} else {
670+
$withoutVarName[] = $doc;
671+
}
672+
}
673+
674+
$ordered = [];
675+
foreach ($parameterNames as $name) {
676+
$key = '$'.$name;
677+
if (isset($byVarName[$key])) {
678+
$ordered[] = $byVarName[$key];
679+
unset($byVarName[$key]);
680+
}
681+
}
682+
683+
return array_merge($ordered, array_values($byVarName), $withoutVarName);
684+
}
685+
560686
/**
561687
* @psalm-return list<ReflectionMethod>
562688
*
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webmozart\Assert\Bin;
6+
7+
use RuntimeException;
8+
9+
final class StaticAnalysisNonReturnGenerator
10+
{
11+
private const SOURCE_NAMESPACE = 'namespace Webmozart\\Assert\\Tests\\StaticAnalysis;';
12+
private const TARGET_NAMESPACE = 'namespace Webmozart\\Assert\\Tests\\StaticAnalysis\\NonReturn;';
13+
14+
public function __construct(
15+
private string $staticAnalysisDirectory
16+
) {
17+
}
18+
19+
public function generate(): void
20+
{
21+
$targetDirectory = $this->targetDirectory();
22+
23+
if (!is_dir($targetDirectory) && !mkdir($targetDirectory, 0777, true) && !is_dir($targetDirectory)) {
24+
throw new RuntimeException(sprintf('Could not create "%s".', $targetDirectory));
25+
}
26+
27+
foreach ($this->generatedFiles() as $targetFile => $content) {
28+
if (false === file_put_contents($targetFile, $content)) {
29+
throw new RuntimeException(sprintf('Could not write "%s".', $targetFile));
30+
}
31+
}
32+
}
33+
34+
/**
35+
* @return array<string, string>
36+
*/
37+
public function generatedFiles(): array
38+
{
39+
$generatedFiles = [];
40+
41+
foreach ($this->sourceFiles() as $sourceFile) {
42+
$content = file_get_contents($sourceFile);
43+
if (false === $content) {
44+
throw new RuntimeException(sprintf('Could not read "%s".', $sourceFile));
45+
}
46+
47+
$generatedFiles[$this->targetDirectory().'/'.basename($sourceFile)] = $this->generateFileContent($content, $sourceFile);
48+
}
49+
50+
return $generatedFiles;
51+
}
52+
53+
public function generateFileContent(string $content, string $sourceFile): string
54+
{
55+
$content = str_replace(self::SOURCE_NAMESPACE, self::TARGET_NAMESPACE, $content);
56+
57+
$replacementCount = 0;
58+
$generatedContent = preg_replace_callback(
59+
'/^(\s*)return Assert::([A-Za-z0-9_]+)\((\$[A-Za-z_][A-Za-z0-9_]*)([^;\n]*)\);$/m',
60+
static function (array $matches): string {
61+
return sprintf(
62+
"%sAssert::%s(%s%s);\n\n%sreturn %s;",
63+
$matches[1],
64+
$matches[2],
65+
$matches[3],
66+
$matches[4],
67+
$matches[1],
68+
$matches[3]
69+
);
70+
},
71+
$content,
72+
-1,
73+
$replacementCount
74+
);
75+
76+
if (null === $generatedContent) {
77+
throw new RuntimeException(sprintf('Could not generate non-return static analysis test from "%s".', $sourceFile));
78+
}
79+
80+
$generatedContent = preg_replace("/;\n{3,}([ \t]+return )/", ";\n\n$1", $generatedContent);
81+
if (null === $generatedContent) {
82+
throw new RuntimeException(sprintf('Could not normalize non-return static analysis test from "%s".', $sourceFile));
83+
}
84+
85+
$expectedReplacementCount = substr_count($content, 'return Assert::');
86+
if ($replacementCount !== $expectedReplacementCount) {
87+
throw new RuntimeException(sprintf(
88+
'Expected to replace %d return assertions in "%s", replaced %d.',
89+
$expectedReplacementCount,
90+
$sourceFile,
91+
(int) $replacementCount
92+
));
93+
}
94+
95+
return $generatedContent;
96+
}
97+
98+
/**
99+
* @return list<string>
100+
*/
101+
private function sourceFiles(): array
102+
{
103+
$files = glob($this->staticAnalysisDirectory.'/assert-*.php');
104+
if (false === $files) {
105+
throw new RuntimeException(sprintf('Could not scan "%s".', $this->staticAnalysisDirectory));
106+
}
107+
108+
sort($files);
109+
110+
return $files;
111+
}
112+
113+
private function targetDirectory(): string
114+
{
115+
return $this->staticAnalysisDirectory.'/non-return';
116+
}
117+
}

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@
4040
}
4141
},
4242
"extra": {
43+
"psalm": {
44+
"pluginClass": "Webmozart\\Assert\\PsalmPlugin"
45+
},
4346
"branch-alias": {
44-
"dev-feature/2-0": "2.0-dev"
47+
"dev-feature/2-0": "2.0-dev",
48+
"dev-master": "2.0-dev"
4549
}
4650
},
4751
"scripts": {

psalm.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0"?>
22
<psalm
3-
errorLevel="8"
3+
errorLevel="1"
44
resolveFromConfigFile="true"
55
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
66
xmlns="https://getpsalm.org/schema/config"
@@ -10,4 +10,8 @@
1010
<directory name="bin"/>
1111
<directory name="tests/static-analysis" />
1212
</projectFiles>
13+
14+
<plugins>
15+
<plugin filename="src/PsalmPlugin.php" />
16+
</plugins>
1317
</psalm>

0 commit comments

Comments
 (0)