diff --git a/functionMap.php b/functionMap.php index 17e19710..d5572c25 100644 --- a/functionMap.php +++ b/functionMap.php @@ -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"; +// 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'; /** * This array follows a format similar to PHPStan’s function map: @@ -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 : array) : array|array)"], @@ -393,6 +409,9 @@ 'WP_REST_Request::offsetSet' => ['void', '@phpstan-template TOffset' => 'of key-of', 'offset' => 'TOffset', 'value' => 'T[TOffset]'], 'WP_REST_Request::offsetUnset' => ['void', '@phpstan-template TOffset' => 'of key-of', 'offset' => 'TOffset'], 'WP_REST_Request::set_param' => ['void', '@phpstan-template TOffset' => 'of key-of', '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'], 'WP_Term_Query::query' => ["(\$query is array ? (\$query is array{fields: 'count'}&array ? 0|numeric-string : (\$query is array{fields: 'names'|'slugs'}&array ? list : (\$query is array{fields: 'id=>name'|'id=>slug'}&array ? array : (\$query is array{fields: 'id=>parent'}&array ? array : (\$query is array{fields: 'ids'|'tt_ids'}&array ? list : array))))) : 0|numeric-string|array)"], 'WP_Theme' => [ @@ -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'], 'WP_Widget::display_callback' => [null, '@final' => ''], 'WP_Widget::form' => [null, 'instance' => 'T'], diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 249096be..97ce797b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,7 +15,7 @@ parameters: - path: tests/Faker.php identifier: class.notFound - count: 20 + count: 23 - # PHPStan\Analyser\Analyser::analyse() # PHPStan\Analyser\AnalyserResult::getErrors() diff --git a/src/Visitor.php b/src/Visitor.php index c7270573..327ee1d8 100644 --- a/src/Visitor.php +++ b/src/Visitor.php @@ -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 $additionStrings */ $additionStrings = array_map( @@ -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> */ diff --git a/tests/Faker.php b/tests/Faker.php index e6127546..a9b84771 100644 --- a/tests/Faker.php +++ b/tests/Faker.php @@ -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() diff --git a/tests/VisitorTest.php b/tests/VisitorTest.php index 93f8908c..1d15bc95 100644 --- a/tests/VisitorTest.php +++ b/tests/VisitorTest.php @@ -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' + ', Faker::wpUser()->caps); +assertType('array', Faker::wpUser()->allcaps); +assertType('array', Faker::wpRole()->capabilities); diff --git a/tests/data/return/wp-post-type.php b/tests/data/return/wp-post-type.php new file mode 100644 index 00000000..bee1d039 --- /dev/null +++ b/tests/data/return/wp-post-type.php @@ -0,0 +1,25 @@ +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); diff --git a/tests/data/return/wp-taxonomy.php b/tests/data/return/wp-taxonomy.php new file mode 100644 index 00000000..d414a6a5 --- /dev/null +++ b/tests/data/return/wp-taxonomy.php @@ -0,0 +1,24 @@ +labels->name); +assertType('string', Faker::wpTaxonomy()->labels->singular_name); +assertType('string', Faker::wpTaxonomy()->labels->menu_name); +assertType('string', Faker::wpTaxonomy()->labels->name_admin_bar); +assertType('string', Faker::wpTaxonomy()->labels->template_name); + +// These have no default for one of the two hierarchies. +assertType('string|null', Faker::wpTaxonomy()->labels->popular_items); +assertType('string|null', Faker::wpTaxonomy()->labels->parent_item); +assertType('string|null', Faker::wpTaxonomy()->labels->filter_by_item); + +assertType('string', Faker::wpTaxonomy()->cap->manage_terms); +assertType('string', Faker::wpTaxonomy()->cap->edit_terms); +assertType('string', Faker::wpTaxonomy()->cap->delete_terms); +assertType('string', Faker::wpTaxonomy()->cap->assign_terms); diff --git a/wordpress-stubs.php b/wordpress-stubs.php index 95c790b8..afbf1f6b 100644 --- a/wordpress-stubs.php +++ b/wordpress-stubs.php @@ -48532,6 +48532,7 @@ final class WP_Post_Type * * @since 4.6.0 * @var stdClass $labels + * @phpstan-var 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} */ public $labels; /** @@ -48796,6 +48797,7 @@ final class WP_Post_Type * * @since 4.6.0 * @var stdClass $cap + * @phpstan-var 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} */ public $cap; /** @@ -52150,6 +52152,7 @@ class WP_Role * @since 2.0.0 * @var bool[] Array of key/value pairs where keys represent a capability name and boolean values * represent whether the role has that capability. + * @phpstan-var array */ public $capabilities; /** @@ -54557,6 +54560,7 @@ final class WP_Taxonomy * * @since 4.7.0 * @var stdClass + * @phpstan-var 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} */ public $labels; /** @@ -54657,6 +54661,7 @@ final class WP_Taxonomy * * @since 4.7.0 * @var stdClass + * @phpstan-var object{manage_terms: string, edit_terms: string, delete_terms: string, assign_terms: string} */ public $cap; /** @@ -59383,6 +59388,7 @@ class WP_User * @since 2.0.0 * @var bool[] Array of key/value pairs where keys represent a capability name * and boolean values represent whether the user has that capability. + * @phpstan-var array */ public $caps = array(); /** @@ -59405,6 +59411,7 @@ class WP_User * @since 2.0.0 * @var bool[] Array of key/value pairs where keys represent a capability name * and boolean values represent whether the user has that capability. + * @phpstan-var array */ public $allcaps = array(); /**