Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,14 @@ Overriding the following methods in your assertion class allows you to change th
## Static analysis support

Where applicable, assertion functions are annotated to support Psalm's
[Assertion syntax](https://psalm.dev/docs/annotating_code/assertion_syntax/).
[Assertion syntax](https://psalm.dev/docs/annotating_code/assertion_syntax/).

A native Psalm plugin can be enabled to also add type inference for return types (new in 2.x):

```php
vendor/bin/psalm-plugin enable webmozart/assert
```

A dedicated [PHPStan Plugin](https://github.com/phpstan/phpstan-webmozart-assert) is
required for proper type support.

Expand Down
8 changes: 7 additions & 1 deletion bin/generate.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@
*/

use Webmozart\Assert\Bin\MixinGenerator;
use Webmozart\Assert\Bin\StaticAnalysisNonReturnGenerator;

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

file_put_contents(__DIR__.'/../src/Mixin.php', (new MixinGenerator())->generate());
$generator = new MixinGenerator();
file_put_contents(__DIR__.'/../src/Mixin.php', $generator->generate());

file_put_contents(__DIR__.'/../src/HasAssert.php', $generator->generateHasAssert());

(new StaticAnalysisNonReturnGenerator(__DIR__.'/../tests/static-analysis'))->generate();

echo "Done.";
176 changes: 168 additions & 8 deletions bin/src/MixinGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ final class MixinGenerator
'allNullOrNotNull', // meaningless
];

/**
* @var array<string, bool>
*/
private array $haveAssert = [];

public function generateHasAssert(): string {
$res = <<<'PHP'
<?php

declare(strict_types=1);

namespace Webmozart\Assert;

/** @internal Used by the Psalm plugin */
final class HasAssert
{
public const HAS_ASSERT = [

PHP;
foreach ($this->haveAssert as $name => $has) {
if ($has) {
$name = strtolower($name);
$res .= " '$name' => true,\n";
}
}
$res .= " ];\n}\n";
return $res;
}

public function generate(): string
{
return \sprintf(
Expand All @@ -77,6 +106,7 @@ private function namespace(): string

$namespace = sprintf("namespace %s;\n\n", $assert->getNamespaceName());
$namespace .= sprintf("use %s;\n", ArrayAccess::class);
$namespace .= sprintf("use %s;\n", \Closure::class);
$namespace .= sprintf("use %s;\n", Countable::class);
$namespace .= sprintf("use %s;\n", Throwable::class);
$namespace .= "\n";
Expand All @@ -93,18 +123,23 @@ private function trait(ReflectionClass $assert): string
$declaredMethods = [];

foreach ($staticMethods as $method) {
$this->haveAssert[$method->getName()] = str_contains($method->getDocComment(), '@psalm-assert');

$nullOr = $this->nullOr($method, 4);
if (null !== $nullOr) {
$this->haveAssert["nullOr".$method->getName()] = str_contains($nullOr, '@psalm-assert');
$declaredMethods[] = $nullOr;
}

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

$allNullOr = $this->allNullOr($method, 4);
if (null !== $allNullOr) {
$this->haveAssert["allNullOr".$method->getName()] = str_contains($allNullOr, '@psalm-assert');
$declaredMethods[] = $allNullOr;
}
}
Expand Down Expand Up @@ -231,11 +266,11 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
}

if ($parameterReflection->hasType()) {
if ($parameterReflection->name === 'value') {
if (count($parameters) === 1) {
$parameterTypes[$parameterReflection->name] = 'mixed';

$nativeReturnType = match ($typeTemplate) {
'%s|null' => $this->reduceParameterType($parameterReflection->getType()),
'%s|null' => $this->nullableReturnType($method->getReturnType()),
'iterable<%s>' => 'iterable',
'iterable<%s|null>' => 'iterable',
};
Expand All @@ -245,6 +280,12 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
}
}

// Ensure @template comes before @param, and @param values match function signature order
$parsedComment = $this->reorderAnnotations($parsedComment);
if (isset($parsedComment['param'])) {
$parsedComment['param'] = $this->reorderParamsBySignature($parsedComment['param'], $parameters);
}

if (in_array($newMethodName, $this->skipMethods, true)) {
return null;
}
Expand All @@ -253,6 +294,13 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,

$phpdocReturnType = 'mixed';

$templateTypeNames = [];
if (isset($parsedComment['template'])) {
foreach ($parsedComment['template'] as $template) {
$templateTypeNames[] = explode(' ', $template)[0];
}
}

$phpdocLines = [];
foreach ($parsedComment as $key => $values) {
if ($this->shouldSkipAnnotation($newMethodName, $key)) {
Expand All @@ -275,14 +323,24 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,

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

$values[$i] = \implode(' ', $parts);

if ('mixed' === $phpdocReturnType) {
$phpdocReturnType = $parts[0];
}
}
}

if ('psalm-return' === $key || 'return' === $key) {
if ('return' === $key) {
foreach ($values as $value) {
$parts = $this->splitDocLine($value);
if ('mixed' !== $parts[0]) {
$phpdocReturnType = $this->applyTypeTemplate($parts[0], $typeTemplate, $templateTypeNames);
}
}
continue;
}

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

if ('template' === $key && 'iterable<%s|null>' === $typeTemplate) {
$type = preg_replace('/^(\S+\s+(?:of|as)\s+)(.+)$/', '$1$2|null', $type) ?? $type;
}

if ('psalm-assert' === $key) {
$type = $this->applyTypeTemplate($type, $typeTemplate);
$type = $this->applyTypeTemplate($type, $typeTemplate, $templateTypeNames);

$phpdocReturnType = $type;
}
Expand All @@ -322,6 +384,20 @@ private function assertion(ReflectionMethod $method, string $methodNameTemplate,
}
}

if ('mixed' === $phpdocReturnType) {
$returnType = $method->getReturnType();
if ($returnType !== null) {
$returnTypeStr = $this->reduceParameterType($returnType);
if ('mixed' !== $returnTypeStr) {
$phpdocReturnType = $this->applyTypeTemplate($returnTypeStr, $typeTemplate, $templateTypeNames);
}
}
}

if ('mixed' === $phpdocReturnType && 'mixed' !== $nativeReturnType) {
$phpdocReturnType = $nativeReturnType;
}

$phpdocLines[] = '@return '.$phpdocReturnType;
$phpdocLines[] = '';

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

private function applyTypeTemplate(string $type, string $typeTemplate): string
private function nullableReturnType(?ReflectionType $type): string
{
if ($type === null) {
return 'mixed';
}
$typeStr = $this->reduceParameterType($type);
if ($typeStr === 'mixed') {
return 'mixed';
}
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
return $typeStr.'|null';
}

return '?'.$typeStr;
}

private function applyTypeTemplate(string $type, string $typeTemplate, array $templateTypeNames = []): string
{
if (in_array($type, $templateTypeNames, true) && str_contains($typeTemplate, 'iterable') && str_contains($typeTemplate, '|null')) {
$typeTemplate = str_replace('|null', '', $typeTemplate);
}

$combinedType = sprintf($typeTemplate, $type);

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

return 'psalm-assert' === $key || 'psalm-return' === $key;
return 'psalm-assert' === $key;
}

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

/**
* Ensures @template annotations appear before @param annotations.
*
* @param array<string, list<string>> $annotations
*
* @return array<string, list<string>>
*/
private function reorderAnnotations(array $annotations): array
{
$keys = array_keys($annotations);
$templatePos = array_search('template', $keys, true);
$paramPos = array_search('param', $keys, true);

if ($templatePos === false || $paramPos === false || $templatePos < $paramPos) {
return $annotations;
}

$result = [];
foreach ($annotations as $key => $values) {
if ($key === 'param') {
$result['template'] = $annotations['template'];
}
if ($key !== 'template') {
$result[$key] = $values;
}
}

return $result;
}

/**
* Reorders @param doc entries to match the function signature parameter order.
*
* @param list<string> $paramDocs
* @param list<string> $parameterNames
*
* @return list<string>
*/
private function reorderParamsBySignature(array $paramDocs, array $parameterNames): array
{
$byVarName = [];
$withoutVarName = [];

foreach ($paramDocs as $doc) {
$parts = $this->splitDocLine($doc);
if (isset($parts[1])) {
$byVarName[$parts[1]] = $doc;
} else {
$withoutVarName[] = $doc;
}
}

$ordered = [];
foreach ($parameterNames as $name) {
$key = '$'.$name;
if (isset($byVarName[$key])) {
$ordered[] = $byVarName[$key];
unset($byVarName[$key]);
}
}

return array_merge($ordered, array_values($byVarName), $withoutVarName);
}

/**
* @psalm-return list<ReflectionMethod>
*
Expand Down
Loading
Loading