Skip to content

Commit b3ce7e4

Browse files
committed
Psalm refactoring
1 parent ea4ed4b commit b3ce7e4

221 files changed

Lines changed: 6866 additions & 2293 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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@
88
*/
99

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

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

14-
file_put_contents(__DIR__.'/../src/Mixin.php', (new MixinGenerator())->generate());
15+
$generator = new MixinGenerator();
16+
file_put_contents(__DIR__.'/../src/Mixin.php', $generator->generate());
17+
18+
file_put_contents(__DIR__.'/../src/HasAssert.php', $generator->generateHasAssert());
19+
20+
(new StaticAnalysisNonReturnGenerator(__DIR__.'/../tests/static-analysis'))->generate();
1521

1622
echo "Done.";

bin/src/MixinGenerator.php

Lines changed: 168 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ final class MixinGenerator
5656
'allNullOrNotNull', // meaningless
5757
];
5858

59+
/**
60+
* @var array<string, bool>
61+
*/
62+
private array $haveAssert = [];
63+
64+
public function generateHasAssert(): string {
65+
$res = <<<'PHP'
66+
<?php
67+
68+
declare(strict_types=1);
69+
70+
namespace Webmozart\Assert;
71+
72+
/** @internal Used by the Psalm plugin */
73+
final class HasAssert
74+
{
75+
public const HAS_ASSERT = [
76+
77+
PHP;
78+
foreach ($this->haveAssert as $name => $has) {
79+
if ($has) {
80+
$name = strtolower($name);
81+
$res .= " '$name' => true,\n";
82+
}
83+
}
84+
$res .= " ];\n}\n";
85+
return $res;
86+
}
87+
5988
public function generate(): string
6089
{
6190
return \sprintf(
@@ -77,6 +106,7 @@ private function namespace(): string
77106

78107
$namespace = sprintf("namespace %s;\n\n", $assert->getNamespaceName());
79108
$namespace .= sprintf("use %s;\n", ArrayAccess::class);
109+
$namespace .= sprintf("use %s;\n", \Closure::class);
80110
$namespace .= sprintf("use %s;\n", Countable::class);
81111
$namespace .= sprintf("use %s;\n", Throwable::class);
82112
$namespace .= "\n";
@@ -93,18 +123,23 @@ private function trait(ReflectionClass $assert): string
93123
$declaredMethods = [];
94124

95125
foreach ($staticMethods as $method) {
126+
$this->haveAssert[$method->getName()] = str_contains($method->getDocComment(), '@psalm-assert');
127+
96128
$nullOr = $this->nullOr($method, 4);
97129
if (null !== $nullOr) {
130+
$this->haveAssert["nullOr".$method->getName()] = str_contains($nullOr, '@psalm-assert');
98131
$declaredMethods[] = $nullOr;
99132
}
100133

101134
$all = $this->all($method, 4);
102135
if (null !== $all) {
136+
$this->haveAssert["all".$method->getName()] = str_contains($all, '@psalm-assert');
103137
$declaredMethods[] = $all;
104138
}
105139

106140
$allNullOr = $this->allNullOr($method, 4);
107141
if (null !== $allNullOr) {
142+
$this->haveAssert["allNullOr".$method->getName()] = str_contains($allNullOr, '@psalm-assert');
108143
$declaredMethods[] = $allNullOr;
109144
}
110145
}
@@ -231,11 +266,11 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
231266
}
232267

233268
if ($parameterReflection->hasType()) {
234-
if ($parameterReflection->name === 'value') {
269+
if (count($parameters) === 1) {
235270
$parameterTypes[$parameterReflection->name] = 'mixed';
236271

237272
$nativeReturnType = match ($typeTemplate) {
238-
'%s|null' => $this->reduceParameterType($parameterReflection->getType()),
273+
'%s|null' => $this->nullableReturnType($method->getReturnType()),
239274
'iterable<%s>' => 'iterable',
240275
'iterable<%s|null>' => 'iterable',
241276
};
@@ -245,6 +280,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
245280
}
246281
}
247282

283+
// Ensure @template comes before @param, and @param values match function signature order
284+
$parsedComment = $this->reorderAnnotations($parsedComment);
285+
if (isset($parsedComment['param'])) {
286+
$parsedComment['param'] = $this->reorderParamsBySignature($parsedComment['param'], $parameters);
287+
}
288+
248289
if (in_array($newMethodName, $this->skipMethods, true)) {
249290
return null;
250291
}
@@ -253,6 +294,13 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
253294

254295
$phpdocReturnType = 'mixed';
255296

297+
$templateTypeNames = [];
298+
if (isset($parsedComment['template'])) {
299+
foreach ($parsedComment['template'] as $template) {
300+
$templateTypeNames[] = explode(' ', $template)[0];
301+
}
302+
}
303+
256304
$phpdocLines = [];
257305
foreach ($parsedComment as $key => $values) {
258306
if ($this->shouldSkipAnnotation($newMethodName, $key)) {
@@ -275,14 +323,24 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
275323

276324
foreach ($values as $i => $value) {
277325
$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);
326+
if ('param' === $key && isset($parts[1]) && isset($parameters[0]) && $parts[1] === '$'.$parameters[0] && 'mixed' !== $parts[0]) {
327+
$parts[0] = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
280328

281329
$values[$i] = \implode(' ', $parts);
330+
331+
if ('mixed' === $phpdocReturnType) {
332+
$phpdocReturnType = $parts[0];
333+
}
282334
}
283335
}
284336

285-
if ('psalm-return' === $key || 'return' === $key) {
337+
if ('return' === $key) {
338+
foreach ($values as $value) {
339+
$parts = $this->splitDocLine($value);
340+
if ('mixed' !== $parts[0]) {
341+
$phpdocReturnType = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
342+
}
343+
}
286344
continue;
287345
}
288346

@@ -294,8 +352,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
294352
$parts = $this->splitDocLine($value);
295353
$type = $parts[0];
296354

355+
if ('template' === $key && 'iterable<%s|null>' === $typeTemplate) {
356+
$type = preg_replace('/^(\S+\s+(?:of|as)\s+)(.+)$/', '$1$2|null', $type) ?? $type;
357+
}
358+
297359
if ('psalm-assert' === $key) {
298-
$type = $this->applyTypeTemplate($type, $typeTemplate);
360+
$type = $this->applyTypeTemplate($type, $typeTemplate, $templateTypeNames);
299361

300362
$phpdocReturnType = $type;
301363
}
@@ -322,6 +384,20 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
322384
}
323385
}
324386

387+
if ('mixed' === $phpdocReturnType) {
388+
$returnType = $method->getReturnType();
389+
if ($returnType !== null) {
390+
$returnTypeStr = $this->reduceParameterType($returnType);
391+
if ('mixed' !== $returnTypeStr) {
392+
$phpdocReturnType = $this->applyTypeTemplate($returnTypeStr, $typeTemplate, $templateTypeNames);
393+
}
394+
}
395+
}
396+
397+
if ('mixed' === $phpdocReturnType && 'mixed' !== $nativeReturnType) {
398+
$phpdocReturnType = $nativeReturnType;
399+
}
400+
325401
$phpdocLines[] = '@return '.$phpdocReturnType;
326402
$phpdocLines[] = '';
327403

@@ -360,8 +436,28 @@ private function reduceParameterType(ReflectionType $type): string
360436
return ($type->allowsNull() ? '?' : '') . $type->getName();
361437
}
362438

363-
private function applyTypeTemplate(string $type, string $typeTemplate): string
439+
private function nullableReturnType(?ReflectionType $type): string
440+
{
441+
if ($type === null) {
442+
return 'mixed';
443+
}
444+
$typeStr = $this->reduceParameterType($type);
445+
if ($typeStr === 'mixed') {
446+
return 'mixed';
447+
}
448+
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
449+
return $typeStr.'|null';
450+
}
451+
452+
return '?'.$typeStr;
453+
}
454+
455+
private function applyTypeTemplate(string $type, string $typeTemplate, array $templateTypeNames = []): string
364456
{
457+
if (in_array($type, $templateTypeNames, true) && str_contains($typeTemplate, 'iterable') && str_contains($typeTemplate, '|null')) {
458+
$typeTemplate = str_replace('|null', '', $typeTemplate);
459+
}
460+
365461
$combinedType = sprintf($typeTemplate, $type);
366462

367463
if ('empty|null' === $combinedType) {
@@ -377,7 +473,7 @@ private function shouldSkipAnnotation(string $newMethodName, string $key): bool
377473
return false;
378474
}
379475

380-
return 'psalm-assert' === $key || 'psalm-return' === $key;
476+
return 'psalm-assert' === $key;
381477
}
382478

383479
/**
@@ -557,6 +653,70 @@ private function splitDocLine(string $line): array
557653
return [trim($matches[1]), $matches[2], $matches[3] ?? null];
558654
}
559655

656+
/**
657+
* Ensures @template annotations appear before @param annotations.
658+
*
659+
* @param array<string, list<string>> $annotations
660+
*
661+
* @return array<string, list<string>>
662+
*/
663+
private function reorderAnnotations(array $annotations): array
664+
{
665+
$keys = array_keys($annotations);
666+
$templatePos = array_search('template', $keys, true);
667+
$paramPos = array_search('param', $keys, true);
668+
669+
if ($templatePos === false || $paramPos === false || $templatePos < $paramPos) {
670+
return $annotations;
671+
}
672+
673+
$result = [];
674+
foreach ($annotations as $key => $values) {
675+
if ($key === 'param') {
676+
$result['template'] = $annotations['template'];
677+
}
678+
if ($key !== 'template') {
679+
$result[$key] = $values;
680+
}
681+
}
682+
683+
return $result;
684+
}
685+
686+
/**
687+
* Reorders @param doc entries to match the function signature parameter order.
688+
*
689+
* @param list<string> $paramDocs
690+
* @param list<string> $parameterNames
691+
*
692+
* @return list<string>
693+
*/
694+
private function reorderParamsBySignature(array $paramDocs, array $parameterNames): array
695+
{
696+
$byVarName = [];
697+
$withoutVarName = [];
698+
699+
foreach ($paramDocs as $doc) {
700+
$parts = $this->splitDocLine($doc);
701+
if (isset($parts[1])) {
702+
$byVarName[$parts[1]] = $doc;
703+
} else {
704+
$withoutVarName[] = $doc;
705+
}
706+
}
707+
708+
$ordered = [];
709+
foreach ($parameterNames as $name) {
710+
$key = '$'.$name;
711+
if (isset($byVarName[$key])) {
712+
$ordered[] = $byVarName[$key];
713+
unset($byVarName[$key]);
714+
}
715+
}
716+
717+
return array_merge($ordered, array_values($byVarName), $withoutVarName);
718+
}
719+
560720
/**
561721
* @psalm-return list<ReflectionMethod>
562722
*

0 commit comments

Comments
 (0)