Skip to content
Open
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
3 changes: 2 additions & 1 deletion functionMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,12 @@
'WP_Object_Cache::delete' => [null, 'deprecated' => 'false'],
'WP_Dependencies::$groups' => [null, '@phpstan-var' => 'array<string, int|false>'],
'WP_Dependencies::get_etag' => ['non-falsy-string'],
'WP_Dependencies::query' => ["(\$handle is not non-empty-string ? false : (\$status is not TStatus ? false : (\$status is 'registered'|'scripts' ? _WP_Dependency|false : bool)))", '@phpstan-template TStatus' => "'registered'|'scripts'|'enqueued'|'queued'|'to_do'|'to_print'|'done'|'printed'"],
'WP_Dependencies::query' => ["(\$handle is not non-empty-string ? false : (\$status is not TStatus ? false : (\$status is 'registered'|'scripts' ? _WP_Dependency|false : bool)))", '@phpstan-template TStatus' => "of 'registered'|'scripts'|'enqueued'|'queued'|'to_do'|'to_print'|'done'|'printed'"],
'WP_Http::get' => [$httpReturnType],
'WP_Http::head' => [$httpReturnType],
'WP_Http::post' => [$httpReturnType],
'WP_Http::request' => [$httpReturnType],
'WP_Image_Editor::get_output_format' => ['array{string|null, string|false, string}'],
'WP_List_Table::set_pagination_args' => ['void', 'args' => 'array{total_items?: int, total_pages?: int, per_page?: int}'],
'WP_Locale::$word_count_type' => [null, '@phpstan-var' => "'characters_excluding_spaces'|'characters_including_spaces'|'words'"],
'WP_Locale::get_word_count_type' => ["'characters_excluding_spaces'|'characters_including_spaces'|'words'"],
Expand Down
147 changes: 123 additions & 24 deletions src/Visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,51 @@ private function getVariableNamesFromTags(array $tags): array
return $names;
}

/**
* @param array<\phpDocumentor\Reflection\DocBlock\Tag> $tags
* @return list<string>
*/
private static function getTemplateNamesFromTags(array $tags): array
{
$names = [];

foreach ($tags as $tag) {
if (! ($tag instanceof Generic)) {
continue;
}

if (preg_match('#^\s*(\S+)#', (string)$tag, $matches) !== 1) {
continue;
}

$names[] = $matches[1];
}

return $names;
}

/**
* @return list<string>
*/
private function getParamNamesFromMap(string $symbolName): array
{
if (! array_key_exists($symbolName, $this->functionMap)) {
return [];
}

$names = [];

foreach (array_keys($this->functionMap[$symbolName]) as $paramName) {
if (! is_string($paramName) || str_starts_with($paramName, '@')) {
continue;
}

$names[] = $paramName;
}

return $names;
}

private function addTags(string $name, Doc $docComment): ?Doc
{
$additions = $this->additionalTags[$name] ?? [];
Expand All @@ -311,6 +356,15 @@ private function addTags(string $name, Doc $docComment): ?Doc
}

$additions = $this->discoverInheritedArgs($docblock, $additions);
$mapParamNames = $this->getParamNamesFromMap($name);

// A parameter type from the function map replaces the one generated from the hash notation.
$additions = array_filter(
$additions,
static function (WordPressTag $tag) use ($mapParamNames): bool {
return $tag->tag !== '@phpstan-param' || ! in_array($tag->name, $mapParamNames, true);
}
);

/** @var list<string> $additionStrings */
$additionStrings = array_map(
Expand Down Expand Up @@ -552,6 +606,43 @@ private function addStringTags(string $name, Doc $docComment): ?Doc
$additions = $this->additionalTagStrings[$name];

$docCommentText = $docComment->getText();
try {
$docblock = $this->docBlockFactory->create($docCommentText);
} catch (\RuntimeException | \InvalidArgumentException $e) {
return null;
}

if (count($docblock->getTagsByName('phpstan-return')) > 0) {
$additions = array_filter(
$additions,
static function (string $tag): bool {
return ! str_starts_with($tag, '@phpstan-return ');
}
);
}

$existingParamNames = $this->getVariableNamesFromTags($docblock->getTagsByName('phpstan-param'));
$existingTemplateNames = self::getTemplateNamesFromTags($docblock->getTagsByName('phpstan-template'));

$additions = array_filter(
$additions,
static function (string $tag) use ($existingParamNames, $existingTemplateNames): bool {
if (preg_match('#^@phpstan-param\s.*\$([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)$#s', $tag, $matches) === 1) {
return ! in_array($matches[1], $existingParamNames, true);
}

if (preg_match('#^@phpstan-template\s+(\S+)#', $tag, $matches) === 1) {
return ! in_array($matches[1], $existingTemplateNames, true);
}

return true;
}
);

if (count($additions) === 0) {
return null;
}

$newDocComment = sprintf(
"%s\n * %s\n */",
substr($docCommentText, 0, -self::WP_INDENT_SIZE),
Expand Down Expand Up @@ -597,7 +688,11 @@ private function getAdditionFromParam(Param $tag): ?WordPressTag

// It's common for an args parameter to accept a query var string or array with `string|array`.
// Remove the accepted string type for these so we get the strongest typing we can manage.
$tagVariableType = str_replace(['|string', 'string|'], '', $tagVariableType);
$members = WordPressTag::getUnionMembers($tagVariableType);

if ($members !== null && count($members) > 1) {
$tagVariableType = implode('|', array_diff($members, ['string']));
}

$tag = new WordPressTag();
$tag->tag = '@phpstan-param';
Expand Down Expand Up @@ -734,37 +829,41 @@ private static function getTypeNameFromType(Type $tagVariableType): ?string

private static function getTypeNameFromString(string $tagVariable): ?string
{
// PHPStan doesn't support typed array shapes (`int[]{...}`) so replace
// typed arrays such as `int[]` with `array`.
$tagVariableType = preg_replace('#[a-zA-Z0-9_]+\[\]#', 'array', $tagVariable);
$members = WordPressTag::getUnionMembers($tagVariable);

if ($tagVariableType === null) {
if ($members === null) {
return null;
}

$tagVariableType = str_replace(
[
'stdClass',
'\\object',
],
'object',
$tagVariableType
);
$members = array_map(
static function (string $member): string {
// PHPStan doesn't support typed array shapes (`int[]{...}`) so replace
// typed arrays such as `int[]` with `array`.
if (preg_match('#^[a-zA-Z0-9_\\\\]+(?:\[\])+$#', $member) === 1) {
return 'array';
}

$supportedTypes = [
'object',
'array',
];
if (in_array(ltrim($member, '\\'), ['stdClass', 'object'], true)) {
return 'object';
}

foreach ($supportedTypes as $supportedType) {
if (! str_contains($tagVariableType, "{$supportedType}|")) {
continue;
}
// Move the type that uses the hash notation to the end of union types so the shape works.
$tagVariableType = sprintf('%s|%s', str_replace("{$supportedType}|", '', $tagVariableType), $supportedType);
return $member;
},
$members
);

// Move the type that uses the hash notation to the end of union types so the shape works.
foreach (['object', 'array'] as $supportedType) {
$hashTypes = array_filter(
$members,
static function (string $member) use ($supportedType): bool {
return $member === $supportedType || str_starts_with($member, "{$supportedType}<");
}
);
$members = array_merge(array_diff_key($members, $hashTypes), $hashTypes);
}

return $tagVariableType;
return implode('|', $members);
}

/**
Expand Down
77 changes: 69 additions & 8 deletions src/WordPressTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,18 @@ public function format(): array
$strings = [];
$childStrings = [];
$level = 1;
$children = $this->children;
$listDepth = 0;

if (! $this->isArrayShape()) {
$level = 0;
$children = self::getListElementChildren($this, $listDepth);
}

foreach ($this->children as $child) {
$listOpen = str_repeat('array<array-key, ', $listDepth);
$listClose = str_repeat('>', $listDepth);

foreach ($children as $child) {
$childStrings = array_merge($childStrings, $child->format($level));
}

Expand All @@ -55,27 +61,39 @@ public function format(): array
$name = $this->name !== null ? " \${$this->name}" : '';

if ($this->isArrayShape()) {
// PHPStan doesn't support shapes of generic arrays (`array<K, V>{...}`), so turn
// `array<K, V>` into an unsealed shape whose other elements are typed `K => V`.
$type = $this->type;

if (preg_match('#^(?<union>(?:.*\|)?)array(?<generic><(?:[^<>]++|(?&generic))*>)$#s', $type, $matches) === 1) {
$type = "{$matches['union']}array";
$childStrings[] = sprintf(' ...%s,', $matches['generic']);
}

$strings[] = sprintf(
'%s %s{',
$this->tag,
$this->type
$type
);
} else {
if (! $this->children[0]->hasChildren()) {
if (! $children[0]->hasChildren()) {
$strings[] = sprintf(
'%s array<int|string, %s>%s',
'%s array<int|string, %s%s%s>%s',
$this->tag,
$this->type,
$listOpen,
$listDepth === 0 ? $this->type : $children[0]->type,
$listClose,
$name
);

return $strings;
}

$strings[] = sprintf(
'%s %s<int|string, array{',
'%s %s<int|string, %sarray{',
$this->tag,
$this->type
$this->type,
$listOpen
);
}

Expand All @@ -88,8 +106,51 @@ public function format(): array

$strings[] = $this->isArrayShape()
? sprintf('}%s%s', $name, $description)
: sprintf('}>%s%s', $name, $description);
: sprintf('}%s>%s%s', $listClose, $name, $description);

return $strings;
}

/**
* Unwraps lists of lists (`@type array ...$0 {` nested in `@type array ...$0 {`),
* counting the unwrapped lists in `$depth`.
*
* @return list<\PhpStubs\WordPress\Core\WordPressArg>
*/
private static function getListElementChildren(WithChildren $node, int &$depth): array
{
$children = $node->children;

if (count($children) !== 1 || $children[0]->name !== null || ! $children[0]->hasChildren() || $children[0]->isArrayShape() || $children[0]->isMixedShape()) {
return $children;
}

$depth += 1;

return self::getListElementChildren($children[0], $depth);
}

/**
* Splits a type into the members of its top-level union, keeping the unions
* nested in generics, shapes and callables together.
*
* @return list<string>|null
*/
public static function getUnionMembers(string $type): ?array
{
$member = '(?:[^|<>{}()\'"]++|\'[^\']*\'|"[^"]*"|(?<bracket>[<{(](?:[^<>{}()\'"]++|\'[^\']*\'|"[^"]*"|(?&bracket))*[>})]))++';

if (preg_match_all("#{$member}#", $type, $matches) === false) {
return null;
}

return array_values(
array_filter(
array_map('trim', $matches[0]),
static function (string $member): bool {
return $member !== '';
}
)
);
}
}
13 changes: 13 additions & 0 deletions tests/ParameterTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -450,4 +450,17 @@ public function testWpUploadBits(): void
]
);
}

public function testWpEnqueueScriptModule(): void
{
$this->analyse(
__DIR__ . '/data/param/wp-enqueue-script-module.php',
[
['Parameter #5 $args of function wp_enqueue_script_module expects array{in_footer?: bool, fetchpriority?: \'auto\'|\'high\'|\'low\', ...<string, bool|string>}, array{in_footer: \'yes\'} given.', 10],
['Parameter #5 $args of function wp_enqueue_script_module expects array{in_footer?: bool, fetchpriority?: \'auto\'|\'high\'|\'low\', ...<string, bool|string>}, array{fetchpriority: \'urgent\'} given.', 11],
['Parameter #5 $args of function wp_enqueue_script_module expects array{in_footer?: bool, fetchpriority?: \'auto\'|\'high\'|\'low\', ...<string, bool|string>}, array{other: 1} given.', 12],
['Parameter #5 $args of function wp_enqueue_script_module expects array{in_footer?: bool, fetchpriority?: \'auto\'|\'high\'|\'low\', ...<string, bool|string>}, array{1: true} given.', 13],
]
);
}
}
Loading