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
2 changes: 1 addition & 1 deletion src/wp-admin/includes/template.php
Original file line number Diff line number Diff line change
Expand Up @@ -1907,7 +1907,7 @@ function add_settings_error( $setting, $code, $message, $type = 'error' ) {
* @type string $code Slug-name to identify the error. Used as part of 'id' attribute in HTML output.
* @type string $message The formatted message text to display to the user (will be shown inside styled
* `<div>` and `<p>` tags).
* @type string $type Optional. Message type, controls HTML class. Possible values include 'error',
* @type string $type Message type, controls HTML class. Possible values include 'error',
* 'success', 'warning', 'info'. Default 'error'.
* }
* }
Expand Down
32 changes: 30 additions & 2 deletions src/wp-includes/blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -1928,8 +1928,36 @@ function traverse_and_serialize_block( $block, $pre_callback = null, $post_callb
* @since 6.6.0
* @since 7.0.0 Adds metadata to attributes of single-pattern container blocks.
*
* @param array $blocks An array blocks.
* @return array An array of blocks with patterns replaced by their content.
* @param array[] $blocks {
* An array of blocks.
*
* @type array ...$0 {
* An associative array of a single parsed block object. See WP_Block_Parser_Block.
*
* @type string|null $blockName Required. Name of block.
* @type array $attrs Required. Attributes from block comment delimiters.
* @type array[] $innerBlocks Required. List of inner blocks. An array of arrays that
* have the same structure as this one.
* @type string $innerHTML Required. HTML from inside block comment delimiters.
* @type array $innerContent Required. List of string fragments and null markers where
* inner blocks were found.
* }
* }
* @return array[] {
* An array of blocks with patterns replaced by their content.
*
* @type array ...$0 {
* An associative array of a single parsed block object. See WP_Block_Parser_Block.
*
* @type string|null $blockName Name of block.
* @type array $attrs Attributes from block comment delimiters.
* @type array[] $innerBlocks List of inner blocks. An array of arrays that
* have the same structure as this one.
* @type string $innerHTML HTML from inside block comment delimiters.
* @type array $innerContent List of string fragments and null markers where
* inner blocks were found.
* }
* }
*/
function resolve_pattern_blocks( $blocks ) {
static $inner_content;
Expand Down
24 changes: 10 additions & 14 deletions src/wp-includes/class-wp-block-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -1277,20 +1277,16 @@ public function get_depth(): int {
*
* @since 6.9.0
*
* @return array[]|null {
* Array of block structures.
*
* @type array ...$0 {
* An associative array of a single parsed block object. See WP_Block_Parser_Block.
*
* @type string|null $blockName Name of block.
* @type array $attrs Attributes from block comment delimiters.
* @type array[] $innerBlocks List of inner blocks. An array of arrays that
* have the same structure as this one.
* @type string $innerHTML HTML from inside block comment delimiters.
* @type array $innerContent List of string fragments and null markers where
* inner blocks were found.
* }
* @return array|null {
* An associative array of a single parsed block object. See WP_Block_Parser_Block.
*
* @type string|null $blockName Name of block.
* @type array $attrs Attributes from block comment delimiters.
* @type array[] $innerBlocks List of inner blocks. An array of arrays that
* have the same structure as this one.
* @type string $innerHTML HTML from inside block comment delimiters.
* @type array $innerContent List of string fragments and null markers where
* inner blocks were found.
* }
*/
public function extract_full_block_and_advance(): ?array {
Expand Down
8 changes: 4 additions & 4 deletions src/wp-includes/widgets/class-wp-widget-categories.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ public function __construct() {
* @param array $args {
* Display arguments.
*
* @type string $before_widget HTML markup to prepend to each widget in the sidebar.
* @type string $after_widget HTML markup to append to each widget in the sidebar.
* @type string $before_title HTML markup to prepend to the widget title when displayed.
* @type string $after_title HTML markup to append to the widget title when displayed.
* @type string $before_widget Required. HTML markup to prepend to each widget in the sidebar.
* @type string $after_widget Required. HTML markup to append to each widget in the sidebar.
* @type string $before_title Required. HTML markup to prepend to the widget title when displayed.
* @type string $after_title Required. HTML markup to append to the widget title when displayed.
* }
* @param array $instance {
* Settings for the current Categories widget instance.
Expand Down
99 changes: 93 additions & 6 deletions tests/phpstan/HashNotationVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@
* or `object`, or a class, on its own or as one member of a union such as
* `string|array` or `array|WP_Error`, where the bare member is the one the
* hash is about. A type that is already more specific than the hash, such as
* `array<string, string|bool>`, is left as written. A shape derived for a
* `array<string, string|bool>`, is left as written, except for a `T[]` whose
* hash is a single `...$0` entry typed as that `T`, which describes the
* elements the way a hash on a bare `array` would. A shape derived for a
* named class is intersected with it, as `stdClass&object{...}`, because an
* object shape is structural on its own and one derived from a bare `object`
* would not be assignable to a property declared `stdClass`.
Expand All @@ -79,8 +81,9 @@
* they describe a value core itself builds. Numbered keys such as `$0`, `$1`
* used for positional arguments are required in either case, because they are
* passed in order. A description opening with `Optional.` overrides all of
* that, and is the only thing that does; the word elsewhere in a description is
* prose, not a marker.
* that, and a `Required.` sentence opening or closing it makes a `@param` key
* required, for a value such as a parsed block whose keys are always set. Those
* are the only markers; either word elsewhere in a description is prose.
*
* The shape of a `@param` hash is left open, with a trailing `...`, because the
* hash lists the keys core reads rather than the only keys a caller may pass.
Expand Down Expand Up @@ -600,8 +603,19 @@ private function substitute( string $declared, array $entries, bool $for_param )

$bare = array();
$shapeable = array();
$elements = array();

foreach ( $members as $position => $member ) {
/*
* A `T[]` member describes its elements, so it takes a hash that does the
* same: a single `...$0` entry documenting the repeated value as a `T`, as
* the blocks `parse_blocks()` returns are.
*/
if ( $this->describes_elements( $member, $entries ) ) {
$elements[] = $position;
continue;
}

if ( ! $this->is_shapeable( $member ) ) {
continue;
}
Expand All @@ -620,19 +634,25 @@ private function substitute( string $declared, array $entries, bool $for_param )
* `WP_Error` carries no keys. So the bare member takes the shape whenever
* there is exactly one. Two of them, or two classes and no bare member,
* would leave it a guess which one the hash is about.
*
* A `T[]` member comes next, so a bare `array` beside it keeps the shape it
* had before `T[]` members could take one, as in `array|array[]`.
*/
if ( 1 === count( $bare ) ) {
$target = $bare[0];
} elseif ( array() === $bare && 1 === count( $shapeable ) ) {
} elseif ( array() === $bare && 1 === count( $elements ) ) {
$target = $elements[0];
} elseif ( array() === $bare && array() === $elements && 1 === count( $shapeable ) ) {
$target = $shapeable[0];
} else {
return null;
}

$member = $members[ $target ];
$normalized = strtolower( $member );
$named = 'array' !== $normalized && 'object' !== $normalized;
$shape = $this->resolve_container( $entries, $for_param, 'array' === $normalized, $named );
$is_array = 'array' === $normalized || in_array( $target, $elements, true );
$named = ! $is_array && 'object' !== $normalized;
$shape = $this->resolve_container( $entries, $for_param, $is_array, $named );
if ( null === $shape ) {
return null;
}
Expand Down Expand Up @@ -683,6 +703,49 @@ private function is_shapeable( string $member ): bool {
return preg_match( '#^\\\\?[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*$#', $member ) === 1;
}

/**
* Reports whether a hash documents the elements of a `T[]` member of a union type.
*
* Only a single `...$0` entry whose own type is that `T` qualifies. Named keys
* beside a `T[]` describe the array itself, as the `string[]` of a list of
* labels keyed by name does, and an entry of any other type would contradict
* the declared element type rather than narrow it. The one exception is a bare
* `array` entry for an `array[]` element, which is how the `array[][]` of
* `wp_print_font_faces()` is written. Its own hash must then describe the
* elements of that `array[]` in turn, so the inner arrays are still only
* narrowed: named keys there, or no hash at all, would type an array of
* arrays as a single shape, or drop a level of it.
*
* @param string $member One member of a union type.
* @param list<mixed> $entries Entries of the hash describing it.
* @return bool
*/
private function describes_elements( string $member, array $entries ): bool {
if ( ! str_ends_with( $member, '[]' ) || 1 !== count( $entries ) ) {
return false;
}

$entry = $entries[0];
if ( ! is_array( $entry ) || true !== ( $entry['variadic'] ?? null ) || ! is_string( $entry['type'] ?? null ) ) {
return false;
}

$element = strtolower( substr( $member, 0, -2 ) );
$type = strtolower( $entry['type'] );

if ( $element === $type ) {
return true;
}

if ( 'array' !== $type || preg_match( '#^array(?:\[\])+$#', $element ) !== 1 ) {
return false;
}

$children = $entry['children'] ?? null;

return is_array( $children ) && $this->describes_elements( $element, array_values( $children ) );
}

/**
* Builds the shape for one hash level.
*
Expand Down Expand Up @@ -794,6 +857,15 @@ private function is_optional( array $entry, bool $for_param ): bool {
return true;
}

/*
* Core marks the keys a caller must pass with a `Required.` sentence. It is
* redundant in a `@return` hash, whose keys are required already, but says
* what the default cannot in a `@param` hash.
*/
if ( $this->is_required( $entry['description'] ) ) {
return false;
}

/*
* A `@return` hash describes a value core builds, so its keys are
* present unless the description says otherwise. `Default ...` is not
Expand All @@ -811,6 +883,21 @@ private function is_optional( array $entry, bool $for_param ): bool {
return true;
}

/**
* Reports whether a description marks its key as required.
*
* The marker is a `Required.` sentence, written either first, as `Optional.`
* is, or last, as some of core's hashes have it. Only the whole sentence
* counts: the word inside one is prose, as in a "Capability required for the
* panel", or the "Required for multi-widgets" of a key that is not.
*
* @param string $description Description of the entry.
* @return bool
*/
private function is_required( string $description ): bool {
return preg_match( '#^\s*Required\.(?:\s|$)|(?:^|[.!?]\s+)Required\.\s*$#i', $description ) === 1;
}

/**
* Formats a key for use in a shape, quoting it when it is not an identifier.
*
Expand Down
17 changes: 15 additions & 2 deletions tests/phpstan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,25 @@ PHPStan reads that hash as free text, so the value stays a plain `array` and not
A hash whose translation would be a guess is left alone, and the value keeps whatever type it has today. The visitor therefore only ever narrows a type, and never contradicts one:

- A `@phpstan-param` or `@phpstan-return` written by hand always wins. Hash notation cannot express everything a type can — a function returning either of two shapes, or one whose return carries keys beyond those it documents, as `get_avatar_data()` returns the processed `$args` too — so a shape that has been tuned in the source is never overwritten by the derived one. Only the tag it was written for: a `@phpstan-param` for one parameter says nothing about the next, and a conditional `@phpstan-return` naming a parameter is not a shape for it.
- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array<string, string|bool>`, is left as written.
- The declared type has to name something a shape can be put on: a bare `array` or `object`, or a class, on its own or as one member of a union such as `string|array` or `array|WP_Error` — in a union the bare member is the one the hash is about, since a `WP_Error` carries no keys. A type that is already more specific than the hash, such as `array<string, string|bool>`, is left as written. The exception is a `T[]` whose hash is a single `...$0` entry typed as that `T`: the hash then describes the elements, as it does for the blocks `parse_blocks()` returns, so its `array[]` becomes `array<array-key, array{blockName: string|null, attrs: array, innerBlocks: array[], innerHTML: string, innerContent: array}>`, sealed like any other `@return` shape. An `array[][]` may document its outer elements as a bare `array`, as `wp_print_font_faces()` does, but only when their own hash goes on to describe the inner arrays the same way. A bare `array` beside the `T[]`, as in `array|array[]`, takes the shape instead. Named keys beside a `T[]` describe the array itself rather than its elements, so a `string[]` with named keys keeps its type.
- The hash has to be well formed: every `{` closed by a `}` on a line of its own, and every `@type` carrying a `$name` and a type that PHPStan's own type parser reads as one. Asking the parser rather than describing what a type may contain is what keeps `?string`, `(string|null)[]` and `callable(): void` from being turned away as malformed.
- A hash on a bare `object` that would have to stay open is skipped, because PHPStan's object shapes have a fixed member list and no `...`, and sealing one would report every member the hash does not name. A hash on a named class is kept: `stdClass&object{...}` is not sealed, since the intersection leaves the class to say what else may be read and a `stdClass` accepts anything. That is what lets the theme data `WP_Theme_Install_List_Table::single_row()` documents keep its shape while the wordpress.org API is still free to add to it.
- A parameter taken by reference is skipped, because PHPStan checks a by-reference argument in both directions, and a shape there would be a contract every caller's variable has to satisfy before the call rather than a description of what the function reads.

Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds. Numbered keys such as `$0` and `$1` are required in either case, because positional arguments are passed in order. A description opening with `Optional.` overrides all of that, and is the only thing that does: the word further into a description is prose, as in the "Optional self closing slash" of a match array whose every group is always set.
Keys of a `@param` hash are optional, at every level, and the shape is left open with a trailing `...`, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and the shape is sealed, since they describe a value core itself builds. Numbered keys such as `$0` and `$1` are required in either case, because positional arguments are passed in order. A description opening with `Optional.` overrides all of that, making the key optional. A `Required.` sentence, opening or closing the description, makes a `@param` key required, which is what a value such as a parsed block needs, since its keys are always set:

```php
* @param array[] $blocks {
* An array of blocks.
*
* @type array ...$0 {
* @type string|null $blockName Required. Name of block.
* @type array $attrs Required. Attributes from block comment delimiters.
* }
* }
```

Those two sentences are the only markers. Either word elsewhere in a description is prose, as in the "Optional self closing slash" of a match array whose every group is always set, or a "Capability required for the panel".

Reading a key that a `@return` hash does not document is therefore reported rather than silently typed as `mixed` — unless the hash says otherwise itself, by listing a `...$N` entry beside its named keys, which is how core writes "and the rest" for the positional hashes of `wp_maybe_grant_site_health_caps()` and `WP_Meta_Query`.

Expand Down
2 changes: 1 addition & 1 deletion tests/phpstan/baselines/argument.type.neon
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ parameters:
count: 1
path: ../../../src/wp-includes/block-supports/layout.php
-
message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array given\.$#'
message: '#^Parameter \#1 \$block of function filter_block_kses expects WP_Block_Parser_Block, array\<string, array\|string\|null\> given\.$#'
identifier: argument.type
count: 1
path: ../../../src/wp-includes/blocks.php
Expand Down
5 changes: 5 additions & 0 deletions tests/phpstan/baselines/parameter.defaultValue.neon
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ parameters:
identifier: parameter.defaultValue
count: 1
path: ../../../src/wp-admin/includes/plugin.php
-
message: '#^Default value of the parameter \#2 \$args \(array\{\}\) of function wp_initialize_site\(\) is incompatible with type array\{user_id\: int, title\?\: string, options\?\: array, meta\?\: array, \.\.\.\}\.$#'
identifier: parameter.defaultValue
count: 1
path: ../../../src/wp-includes/ms-site.php
-
message: '#^Default value of the parameter \#3 \$deprecated \(''''\) of function unregister_setting\(\) is incompatible with type callable\(\)\: mixed\.$#'
identifier: parameter.defaultValue
Expand Down
10 changes: 0 additions & 10 deletions tests/phpstan/baselines/return.type.neon
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,6 @@ parameters:
identifier: return.type
count: 1
path: ../../../src/wp-includes/blocks.php
-
message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\<array\>\|null but returns array\<string, array\|string\|null\>\.$#'
identifier: return.type
count: 1
path: ../../../src/wp-includes/class-wp-block-processor.php
-
message: '#^Method WP_Block_Processor\:\:extract_full_block_and_advance\(\) should return array\<array\>\|null but returns array\<string, list\<string\|null\>\|string\|null\>\.$#'
identifier: return.type
count: 1
path: ../../../src/wp-includes/class-wp-block-processor.php
-
message: '#^Method WP_Image_Editor_Imagick\:\:set_imagick_time_limit\(\) should return int\|null but returns float\.$#'
identifier: return.type
Expand Down
Loading