Skip to content
Draft
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
21 changes: 21 additions & 0 deletions functionMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@
$wpWidgetRssFormArgsType = 'array{number: int, error: bool, title?: string, url?: string, items?: int, show_summary?: int, show_author?: int, show_date?: int}';
$wpWidgetRssFormInputsType = 'array{title?: bool, url?: bool, items?: bool, show_summary?: bool, show_author?: bool, show_date?: bool}';
$filesystemDirlistReturnType = "false|array<string, array{name: string, perms: string, permsn: string, owner: string|false, size: int|string|false, lastmodunix: int|string|false, lastmod: string|false, time: string|false, type: 'f'|'d'|'l', group: string|false, number: int|string|false, files?: array|false}>";
// WordPress declares the objects below as plain `stdClass` and the capability maps as
// `bool[]`, so their contents are `mixed` and their keys `array-key`. The shapes are taken
// from get_post_type_labels(), get_post_type_capabilities(), get_taxonomy_labels() and
// WP_Taxonomy::set_props(), which fill in every default.
//
// These entries are temporary. https://github.com/WordPress/wordpress-develop/pull/13220
// documents the same shapes in core, on the functions that build them and via `@see` from
// the properties, and the visitor picks those up on its own. Drop these once the stubs are
// generated from a release carrying that change.
$postTypeLabelsType = 'object{name: string, singular_name: string, add_new: string, add_new_item: string, edit_item: string, new_item: string, view_item: string, view_items: string, search_items: string, not_found: string, not_found_in_trash: string, parent_item_colon: string|null, all_items: string, archives: string, attributes: string, insert_into_item: string, uploaded_to_this_item: string, featured_image: string, set_featured_image: string, remove_featured_image: string, use_featured_image: string, menu_name: string, name_admin_bar: string, filter_items_list: string, filter_by_date: string, items_list_navigation: string, items_list: string, item_published: string, item_published_privately: string, item_reverted_to_draft: string, item_trashed: string, item_scheduled: string, item_updated: string, item_link: string, item_link_description: string, template_name: string}';
$postTypeCapType = 'object{edit_post: string, read_post: string, delete_post: string, edit_posts: string, edit_others_posts: string, delete_posts: string, publish_posts: string, read_private_posts: string, create_posts: string, read: string, delete_private_posts: string, delete_published_posts: string, delete_others_posts: string, edit_private_posts: string, edit_published_posts: string}';
$taxonomyLabelsType = 'object{name: string, singular_name: string, menu_name: string, name_admin_bar: string, search_items: string, popular_items: string|null, all_items: string, parent_item: string|null, parent_item_colon: string|null, name_field_description: string, slug_field_description: string, parent_field_description: string|null, desc_field_description: string, edit_item: string, view_item: string, update_item: string, add_new_item: string, new_item_name: string, template_name: string, separate_items_with_commas: string|null, add_or_remove_items: string|null, choose_from_most_used: string|null, not_found: string, no_terms: string, filter_by_item: string|null, items_list_navigation: string, items_list: string, most_used: string, back_to_items: string, item_link: string, item_link_description: string}';
$taxonomyCapType = 'object{manage_terms: string, edit_terms: string, delete_terms: string, assign_terms: string}';
$capabilityMapType = 'array<string, bool>';

/**
* This array follows a format similar to PHPStan’s function map:
Expand Down Expand Up @@ -381,6 +395,8 @@
'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'"],
'WP_Post_Type::$cap' => [null, '@phpstan-var' => $postTypeCapType],
'WP_Post_Type::$labels' => [null, '@phpstan-var' => $postTypeLabelsType],
'WP_Query' => [null, '@phpstan-property-read bool $query_vars_changed' => '', '@phpstan-property-read bool|string $query_vars_hash' => '', '@phpstan-method void init_query_flags()' => ''],
'WP_Query::have_posts' => [null, '@phpstan-impure' => ''],
'WP_Query::query' => ["(\$query is array ? (\$query is array{fields: 'id=>parent'|'ids'}&array ? array<int, int> : array<int, \WP_Post>) : array<int, int>|array<int, \WP_Post>)"],
Expand All @@ -393,6 +409,9 @@
'WP_REST_Request::offsetSet' => ['void', '@phpstan-template TOffset' => 'of key-of<T>', 'offset' => 'TOffset', 'value' => 'T[TOffset]'],
'WP_REST_Request::offsetUnset' => ['void', '@phpstan-template TOffset' => 'of key-of<T>', 'offset' => 'TOffset'],
'WP_REST_Request::set_param' => ['void', '@phpstan-template TOffset' => 'of key-of<T>', 'key' => 'TOffset', 'value' => 'T[TOffset]'],
'WP_Role::$capabilities' => [null, '@phpstan-var' => $capabilityMapType],
'WP_Taxonomy::$cap' => [null, '@phpstan-var' => $taxonomyCapType],
'WP_Taxonomy::$labels' => [null, '@phpstan-var' => $taxonomyLabelsType],
'WP_Term_Query::get_terms' => ['0|numeric-string|array<int, int|string|\WP_Term>'],
'WP_Term_Query::query' => ["(\$query is array ? (\$query is array{fields: 'count'}&array ? 0|numeric-string : (\$query is array{fields: 'names'|'slugs'}&array ? list<string> : (\$query is array{fields: 'id=>name'|'id=>slug'}&array ? array<int, string> : (\$query is array{fields: 'id=>parent'}&array ? array<int, int> : (\$query is array{fields: 'ids'|'tt_ids'}&array ? list<int> : array<int, \WP_Term>))))) : 0|numeric-string|array<int, int|string|\WP_Term>)"],
'WP_Theme' => [
Expand All @@ -418,6 +437,8 @@
'WP_Theme_JSON_Resolver::get_theme_data' => [null, 'deprecated' => 'array{}'],
'WP_Translations::translate' => ['($singular is null ? null : string)'],
'WP_Translations::translate_plural' => ['($singular is null ? null : ($plural is null ? T : string))', '@phpstan-template T' => 'of string|null', 'singular' => 'T', 'count' => 'int'],
'WP_User::$allcaps' => [null, '@phpstan-var' => $capabilityMapType],
'WP_User::$caps' => [null, '@phpstan-var' => $capabilityMapType],
'WP_Widget' => [null, '@phpstan-template T' => 'of array = array<string, mixed>'],
'WP_Widget::display_callback' => [null, '@final' => ''],
'WP_Widget::form' => [null, 'instance' => 'T'],
Expand Down
2 changes: 1 addition & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ parameters:
-
path: tests/Faker.php
identifier: class.notFound
count: 20
count: 23
-
# PHPStan\Analyser\Analyser::analyse()
# PHPStan\Analyser\AnalyserResult::getErrors()
Expand Down
90 changes: 90 additions & 0 deletions src/Visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ private function addTags(string $name, Doc $docComment): ?Doc
}

$additions = $this->discoverInheritedArgs($docblock, $additions);
$additions = $this->discoverInheritedVar($name, $docblock, $docCommentText, $additions);

/** @var list<string> $additionStrings */
$additionStrings = array_map(
Expand Down Expand Up @@ -357,6 +358,95 @@ static function (WordPressTag $addition): bool {
return $additions;
}

/**
* Gives a property the shape of the value returned by the function it points at.
*
* WordPress documents the contents of an object such as `WP_Post_Type::$labels` on the
* function that builds it, and refers to that function from the property with `@see`,
* so the shape is only written out once.
*
* A shape of the property's own always wins, whether it comes from hash notation in the
* source or from the function map.
*
* @param list<\PhpStubs\WordPress\Core\WordPressTag> $additions
* @return list<\PhpStubs\WordPress\Core\WordPressTag>
*/
private function discoverInheritedVar(string $symbolName, DocBlock $docblock, string $docCommentText, array $additions): array
{
if (! str_contains($symbolName, '::$')) {
return $additions;
}

foreach ($additions as $addition) {
if ($addition->tag === '@phpstan-var') {
return $additions;
}
}

foreach ($this->additionalTagStrings[$symbolName] ?? [] as $mappedTag) {
if (str_starts_with($mappedTag, '@phpstan-var ')) {
return $additions;
}
}

$propertyType = self::getVarTypeName($docblock);

if ($propertyType === null) {
return $additions;
}

$inherited = $this->getInheritedTagForVar($docCommentText, $propertyType);

if (! ($inherited instanceof WordPressTag)) {
return $additions;
}

return array_merge($additions, [$inherited]);
}

private static function getVarTypeName(DocBlock $docblock): ?string
{
/** @var list<\phpDocumentor\Reflection\DocBlock\Tags\Var_> $varTags */
$varTags = $docblock->getTagsByName('var');

foreach ($varTags as $varTag) {
$type = $varTag->getType();

if (! $type instanceof Type) {
continue;
}

return self::getTypeNameFromType($type);
}

return null;
}

private function getInheritedTagForVar(string $docCommentText, string $propertyType): ?WordPressTag
{
foreach ($this->additionalTags as $symbolName => $tags) {
if (! str_contains($docCommentText, sprintf('@see %s()', $symbolName))) {
continue;
}

foreach ($tags as $tag) {
// Only a documented shape of a matching type can describe the property.
if ($tag->tag !== '@phpstan-return' || $tag->type !== $propertyType || ! $tag->hasChildren()) {
continue;
}

$inherited = clone $tag;
$inherited->tag = '@phpstan-var';
$inherited->name = null;
$inherited->description = sprintf('See %s()', $symbolName);

return $inherited;
}
}

return null;
}

/**
* @return list<\PhpStubs\WordPress\Core\WordPressTag>
*/
Expand Down
3 changes: 3 additions & 0 deletions tests/Faker.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@
* @method static \WP_Locale wpLocale()
* @method static \WP_Object_Cache wpObjectCache()
* @method static \WP_Post wpPost()
* @method static \WP_Post_Type wpPostType()
* @method static \WP_Query wpQuery()
* @method static \WP_REST_Request wpRestRequest()
* @method static \WP_REST_Response wpRestResponse()
* @method static \WP_Role wpRole()
* @method static \WP_Screen wpScreen()
* @method static \WP_Scripts wpScripts()
* @method static \WP_Styles wpStyles()
* @method static \WP_Taxonomy wpTaxonomy()
* @method static \WP_Term wpTerm()
* @method static \WP_Theme wpTheme()
* @method static \WP_Translations wpTranslations()
Expand Down
134 changes: 134 additions & 0 deletions tests/VisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@ private static function generateStubs(string $code): string
return (new Result($visitor, []))->prettyPrint();
}

/**
* The `@phpstan-var` tag of the stubs, up to the end of its shape.
*/
private static function phpStanVarTag(string $stubs): string
{
$position = strpos($stubs, '@phpstan-var');
self::assertIsInt($position, $stubs);

$tag = strstr(substr($stubs, $position), '}', true);
self::assertIsString($tag, $stubs);

return $tag;
}

/**
* A function documenting the shape of the object it returns, for the property tests below.
*/
private const REFERENCED_FUNCTION = <<<'PHP'
<?php
/**
* Builds the labels.
*
* @return object {
* Labels object.
*
* @type string $name General name.
* @type string|null $parent Only set for hierarchical things.
* }
*/
function wpstubs_test_build_labels($thing) { return (object) array(); }
PHP;

/**
* An existing `@phpstan-return` in the source docblock must be preserved as the only instance.
*/
Expand Down Expand Up @@ -92,4 +124,106 @@ function wpstubs_test_connector_without_phpstan_return( string $id ): ?array {
self::assertStringContainsString('name: string', $stubs);
self::assertStringContainsString('type: string', $stubs);
}

/**
* A property pointing at the function that builds its value takes that shape.
*/
public function testPhpStanVarIsInheritedFromReferencedFunction(): void
{
$code = self::REFERENCED_FUNCTION . <<<'PHP'
class WPStubs_Test_Labelled {
/**
* Labels object for this thing.
*
* @see wpstubs_test_build_labels()
*
* @var stdClass
*/
public $labels;
}
PHP;

$stubs = self::generateStubs($code);

$varTag = self::phpStanVarTag($stubs);

self::assertStringContainsString('@phpstan-var object{', $varTag);
self::assertStringContainsString('name: string', $varTag);
self::assertStringContainsString('parent: string|null', $varTag);
}

/**
* The referenced shape has to fit the type the property is declared as.
*/
public function testPhpStanVarIsNotInheritedForAMismatchingType(): void
{
$code = self::REFERENCED_FUNCTION . <<<'PHP'
class WPStubs_Test_Mismatched {
/**
* An array cannot take the shape of a returned object.
*
* @see wpstubs_test_build_labels()
*
* @var array
*/
public $labels = array();
}
PHP;

$stubs = self::generateStubs($code);

self::assertStringNotContainsString('@phpstan-var', $stubs);
}

/**
* A property that documents its own shape keeps it.
*/
public function testPhpStanVarOfThePropertyWins(): void
{
$code = self::REFERENCED_FUNCTION . <<<'PHP'
class WPStubs_Test_Own_Shape {
/**
* Its own shape wins over the referenced one.
*
* @see wpstubs_test_build_labels()
*
* @var stdClass {
* Own shape.
*
* @type string $own Own key.
* }
*/
public $labels;
}
PHP;

$stubs = self::generateStubs($code);

$varTag = self::phpStanVarTag($stubs);

self::assertSame(1, substr_count($stubs, '@phpstan-var'), $stubs);
self::assertStringContainsString('own: string', $varTag);
self::assertStringNotContainsString('parent: string|null', $varTag);
}

/**
* A property with no reference is left alone.
*/
public function testPhpStanVarIsNotInheritedWithoutAReference(): void
{
$code = self::REFERENCED_FUNCTION . <<<'PHP'
class WPStubs_Test_Unreferenced {
/**
* No reference at all.
*
* @var stdClass
*/
public $labels;
}
PHP;

$stubs = self::generateStubs($code);

self::assertStringNotContainsString('@phpstan-var', $stubs);
}
}
3 changes: 3 additions & 0 deletions tests/data/return/Faker.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@
assertType('WP_Error', Faker::wpError());
assertType('WP_Object_Cache', Faker::wpObjectCache());
assertType('WP_Post', Faker::wpPost());
assertType('WP_Post_Type', Faker::wpPostType());
assertType('WP_Query', Faker::wpQuery());
assertType('WP_REST_Request', Faker::wpRestRequest());
assertType('WP_REST_Response', Faker::wpRestResponse());
assertType('WP_Role', Faker::wpRole());
assertType('WP_Screen', Faker::wpScreen());
assertType('WP_Scripts', Faker::wpScripts());
assertType('WP_Styles', Faker::wpStyles());
assertType('WP_Taxonomy', Faker::wpTaxonomy());
assertType('WP_Term', Faker::wpTerm());
assertType('WP_Theme', Faker::wpTheme());
assertType('WP_Translations', Faker::wpTranslations());
Expand Down
12 changes: 12 additions & 0 deletions tests/data/return/wp-capabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace PhpStubs\WordPress\Core\Tests;

use function PHPStan\Testing\assertType;

// Capability maps are keyed by capability name.
assertType('array<string, bool>', Faker::wpUser()->caps);
assertType('array<string, bool>', Faker::wpUser()->allcaps);
assertType('array<string, bool>', Faker::wpRole()->capabilities);
25 changes: 25 additions & 0 deletions tests/data/return/wp-post-type.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace PhpStubs\WordPress\Core\Tests;

use function PHPStan\Testing\assertType;

// Labels are built by get_post_type_labels(), which fills in every default.
assertType('string', Faker::wpPostType()->labels->name);
assertType('string', Faker::wpPostType()->labels->singular_name);
assertType('string', Faker::wpPostType()->labels->menu_name);
assertType('string', Faker::wpPostType()->labels->name_admin_bar);
assertType('string', Faker::wpPostType()->labels->item_link_description);

// Only used on hierarchical post types, where the default is null otherwise.
assertType('string|null', Faker::wpPostType()->labels->parent_item_colon);

// Capabilities are built by get_post_type_capabilities().
assertType('string', Faker::wpPostType()->cap->edit_post);
assertType('string', Faker::wpPostType()->cap->edit_posts);
assertType('string', Faker::wpPostType()->cap->create_posts);

// Only present when the post type is registered with 'map_meta_cap'.
assertType('string', Faker::wpPostType()->cap->edit_published_posts);
Loading
Loading